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
- The notes below are still in draft form. They are incomplete, sometimes incorrect, and often duplicated. Clean up is in progress
- Remove the previous 'Search' and 'Autocomplete' tests when this is running
- The reason for removing and adding the 'select' element (instead of just the options) is because of a behavior in safari where it doesn't respect resetting the position of the selected element on arrow movements
- I'm adding display inline when the DOMContentLoaded fires. Adding it before then throws an error. TBD if that causes a layout shift which would need to be dealt with
- I remove the placeholder text when you first click into the input field because it aways throws me when the text stays there on default forms
- The setTimeout move to the next tab is becaue I haven't found a way to move to the next tab index programatically. Closing the menu first means the tab doesn't move. And closing it after didn't work
Functionality
Neither the INPUT or the MENU is focused
- [.] - INPUT is visible
- [.] - MENU is invisible
- [.] - PLACEHOLDER shows previous SELECTION if one exists
- [.] - PLACEHOLDER shows default text if no previous SELECTION exists
INPUT Becomes Focused
- [.] - PLACEHOLDER is removed
- [.] - MENU opens
- [.] - MENU displays default OPTIONS with making one SELECTED
ENTER is pressed while INPUT has focus with no VALUE
- [.] - no-op
ESCAPE pressed while INPUT has focus with no VALUE
- [.] - Hides the MENU
- [.] - INPUT is blured
- [.] - PLACEHOLDER shows previous SELECTION if one exists
- [.] - PLACEHOLDER shows default text if no previous SELECTION exists
ESCAPE is pressed in the MENU
- [.] - INPUT is focused with existing text remaining in place
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
- [.] - MENU is closed
- [.] - INPUT PLACEHOLDER returns to its previous value
TAB is pressed while MENU is active with VALUE in INPUT
- [.] - MENU closes
- [.] - INPUT VALUE is set to nothing
- [.] - INPUT PLACEHOLDER is returned to previous value
- [.] - Focus moves to next tab index as normal
TAB is pressed while MENU is active with VALUE in INPUT but no valid OPTIONs available
ARROW DOWN is pressed while MENU is active with VALUE in INPUT but no valid OPTIONs available
TAB pressed when VALUE in INPUT has zero MENU results
- [.] - no-op
-
[.] - Hitting TAB in INPUT when INPUT is empty
- [.] - Focuses the MENU
- [.] - Selects the first OPTION
- [.] - Hitting TAB in INPUT when INPUT has TEXT
- [.] - Focuses the MENU
- [.] - No adjustement to SELECTION since it will already be on the first OPTION
[] - Hitting TAB when MENU has focus
- [ ] - Moves to next TAB INDEX
- [ ] - Closes MENU
- [ ] - Removes any VALUE from INPUT
- [ ] - Displays previous SELECTION as PLACEHOLDER if one exists
- [ ] - Displays default PLACEHOLDER if no previous SELECTION exists
- [ ] -
- [ ] -
- [ ] -
- [ ] -
- [ ] -
- [ ] -
- [ ] -
- [ ] -
- [ ] -
- [ ] -
- [ ] -
- [ ] -
TODO
- [ ] - Clean up and dedupe the requirements
- [ ] - Switch so that escape when there is text in input ejects from it instead of removing it but leving the form focused
- [ ] - Consider tabbing to next tab index if there is text in the input but id doesn't match anything
- [.] - Don't load the options in at the start. call them dynamically so they can be updated outside the component. May have to keep some type of loader in there for the dom ready to prevent issue where the data doesn't show up on first hitting the open element
- [.] - Load data from child <option> elements
- [.] - Clicking in the input field opens the menu
- [.] - Clicking outside the input field closes the menu
- [.] - Clicking outside the menu closes the menu
- [ ] - Investigate making this a custom built-in form form element so it can have that API
- [ ] - Verify that options can be updated live outside the component
- [.] - Hitting escape while inside resets the form but keeps you in it
- [ ] - Clicking the up arrow when you're at the top of the list takes up back to the input field
- [ ] - If you mouse all the way back up, the selection is removed until you hit anoter key
- [ ] - escape with text removes it. escape without text blurs the input
- [ ] - If you just arrow down and then immediately up, go back to the input
- [ ] - Don't try to go past the last item with arrow down
- [ ] - Investigate UX of moving to alpha sort when a search term is in the Input field
- [ ] - Add customization that lets you show the default placeholder instead of the prious selection
- [ ] - Maybe refactor a little after a break
- [ ] - Display No Matches when there aren't any
- [ ] - See if there's a way to eliminate the quick blink when tabbing from the menu to the next tab index
- Add to refs: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent
- https://developer.mozilla.org/en-US/docs/Web/Events/Creating_and_triggering_events
References
- MDN: Using custom elements
- MDN: Web Components
- MDN: JavaScript Classes
- MDN: CSS position
- Is it possible to simulate key press events programmatically? - I didn't end up using this. Something to look into if the need arises
- MDN: Event.preventDefault()
- MDN: CustomEvent