Cookbook Home ~ alanwsmith.com ~ links ~ podcast ~ twitter

WORK IN PROGRESS


I'm still buliding this out. It's very much a DRAFT and is various stages of scuffed depending on the day


Strict Selection Menu Form Control

I'm building this because I haven't been able to figure out how to get the default form controls to do what I want

The primary goal is to have an input field where you can enter text that brings up a drop down menu of possible options. But, I want to limit the possible values from the input field to what's in the selection and I want it so that if you press enter whichever one is first in the list gets selected. There's a bunch of other functionatliy listed below, but those items are the key

Example

HTML

<strict-select>
  <option value="roboto">Roboto</option>
  <option value="opensans">Open Sans</option>
  <option value="notosansjp">Noto Sans JP</option>
  <option value="montserrat">Montserrat</option>
  <option value="lato">Lato</option>
  <option value="poppins">Poppins</option>
  <option value="sourcesanspro">Source Sans Pro</option>
</strict-select>

JavaScript

class StrictSelect extends HTMLElement {
    constructor() {
        super()
        this.attachShadow({ mode: 'open' })
        this.defaultOptions = {}
        this.placeholder = 'Select'
        this.options = []
        this.upArrowCheck = ''
        this.tabTimeout = null

        const log = (msg) => {
            console.log(msg)
        }

        const handleDocumentClick = (event) => {
            if (event.target !== this) {
                this.input.placeholder = this.placeholder
                this.input.value = ''
                removeMenu()
            }
        }

        const handleDOMContentLoaded = () => {
            this.style.display = 'inline'
            this.style.position = 'relative'
        }

        const handleInputFocus = () => {
            this.input.setAttribute('placeholder', '')
            renderOptions()
        }

        const handleInputKeydown = (event) => {
            const keyCheck = event.key.toLowerCase()
            if (keyCheck === 'tab') {
                if (this.options.length > 0) {
                    event.preventDefault()
                    setSelection(0)
                    this.select.focus()
                } else {
                    event.preventDefault()
                }
            }
        }

        const handleInputKeyup = (event) => {
            const keyCheck = event.key.toLowerCase()
            if (keyCheck === 'enter') {
                if (this.input.value !== '') {
                    if (this.options.length > 0) {
                        registerSelection()
                    }
                }
            } else if (keyCheck === 'arrowdown') {
                if (this.options.length > 0) {
                    if (this.input.value === '') {
                        setSelection(0)
                    } else {
                        setSelection(1)
                    }
                    this.select.focus()
                }
            } else if (keyCheck === 'escape') {
                if (this.input.value !== '') {
                    this.input.value = ''
                    renderOptions()
                } else {
                    this.input.setAttribute('placeholder', this.placeholder)
                    removeMenu()
                    this.input.blur()
                }
            } else {
                renderOptions()
            }
        }

        const handleSelectKeydown = (event) => {
            const keyCheck = event.key.toLowerCase()
            if (keyCheck === 'tab') {
                if (this.tabTimeout) {
                    clearTimeout(this.tabTimeout)
                }
                this.tabTimeout = setTimeout(() => {
                    removeMenu()
                    this.input.value = ''
                    this.input.placeholder = this.placeholder
                }, 30)
            }
        }

        // TODO: Handle escape here
        const handleSelectKeyup = (event) => {
            const keyCheck = event.key.toLowerCase()
            // TODO: May need to be a check for items in the
            // options here.
            if (keyCheck === 'enter') {
                registerSelection()
            } else if (keyCheck === 'escape') {
                this.input.focus()
            } else if (keyCheck === 'arrowup') {
                if (this.upArrowCheck === this.select.value) {
                    this.input.focus()
                    setSelection(null)
                }
            }
            if (this.select) {
                this.upArrowCheck = this.select.value
            }
        }

        const handleSelectMouseUp = () => {
            registerSelection(this.select.value)
        }

        const registerSelection = (key = null) => {
            const checkValue = key === null ? this.select.value : key
            for (let option of this.options) {
                if (checkValue === option.value) {
                    this.placeholder = option.text
                    this.input.setAttribute('placeholder', this.placeholder)
                    this.input.value = ''
                    this.input.blur()
                }
            }
            removeMenu()
        }

        const removeMenu = () => {
            if (this.select) {
                while (this.select.firstChild) {
                    this.select.firstChild.remove()
                }
                this.select.blur()
                this.select.remove()
                this.select = null
            }
        }

        const renderOptions = () => {
            if (this.select) {
                while (this.select.firstChild) {
                    this.select.firstChild.remove()
                }
                this.select.blur()
                this.select.remove()
                this.select = null
            }

            updateOptions()

            this.select = document.createElement('select')
            this.select.addEventListener('keydown', handleSelectKeydown)
            this.select.addEventListener('keyup', handleSelectKeyup)
            this.select.addEventListener('mouseup', handleSelectMouseUp)
            this.select.size = 5
            this.select.style.position = 'absolute'

            for (let option of this.options) {
                this.select.appendChild(option)
            }

            this.wrapper.appendChild(this.select)

            if (this.input.value !== '' && this.options.length > 0) {
                setSelection(0)
            }

            this.upArrowCheck = null
        }

        const setSelection = (index = null) => {
            for (let option of this.options) {
                option.removeAttribute('selected')
            }
            if (index !== null) {
                this.upArrowCheck = this.options[index].value
                this.options[index].setAttribute('selected', true)
            }
        }

        // this makes new object to avoid having to worry about
        // stuff with the outside set. but it looks at that
        // set every time to make it's stuff
        const updateOptions = () => {
            this.options = []
            for (let option of this.getElementsByTagName('option')) {
                if (option.text.toLowerCase().includes(this.input.value)) {
                    const optionEl = document.createElement('option')
                    optionEl.value = option.value
                    optionEl.innerText = option.text
                    this.options.push(optionEl)
                }
            }
        }

        this.wrapper = document.createElement('div')
        this.wrapper.style.display = 'inline'

        this.input = document.createElement('input')
        this.input.setAttribute('value', '')
        this.input.setAttribute('type', 'text')
        this.input.setAttribute('placeholder', this.placeholder)
        this.input.addEventListener('focus', handleInputFocus)
        this.input.addEventListener('keyup', handleInputKeyup)
        this.input.addEventListener('keydown', handleInputKeydown)
        this.input.setAttribute('autocorrect', false)
        this.input.setAttribute('spellcheck', false)

        this.wrapper.appendChild(this.input)
        this.shadowRoot.append(this.wrapper)

        document.addEventListener('mousedown', handleDocumentClick)
        document.addEventListener('DOMContentLoaded', handleDOMContentLoaded)
    }
}

customElements.define('strict-select', StrictSelect)

Notes

Functionality

Neither the INPUT or the MENU is focused

INPUT Becomes Focused

ENTER is pressed while INPUT has focus with no VALUE

ESCAPE pressed while INPUT has focus with no VALUE

ESCAPE is pressed in the MENU

ENTER is pressed while INPUT has focus has a VALUE

ESCAPE is pressed while INPUT has focus has a VALUE

CLICK occurs outside INPUT or MENU

CLICK occurs on MENU OPTION

CLICK occurs in INPUT while MENU has focus

CLICK occurs outside the INPUT and MENU when INPUT has no VALUE

TAB is pressed while MENU is active with VALUE in INPUT

TAB is pressed while MENU is active with VALUE in INPUT but no valid OPTIONs available

  • [.] - no-op
  • ARROW DOWN is pressed while MENU is active with VALUE in INPUT but no valid OPTIONs available

  • [.] - no-op
  • TAB pressed when VALUE in INPUT has zero MENU results


    [] - Hitting TAB when MENU has focus

    TODO

    References