From 7a40fdd942ea48676aa1844509f0a2081e97f627 Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Sun, 25 Aug 2024 17:21:58 -0400 Subject: [PATCH] [RMS 8] Keyboard Navigable (#1) This PR addresses some issues with the keyboard focus and active state of option. It also shows the no results message when searching in addition to when everything is selected --- index.html | 5 ++ package.json | 2 +- .../components/tailored-select.component.js | 81 ++++++++++++------- 3 files changed, 56 insertions(+), 32 deletions(-) diff --git a/index.html b/index.html index f4684be..9320bf6 100644 --- a/index.html +++ b/index.html @@ -21,6 +21,11 @@ + + + + + diff --git a/package.json b/package.json index 4052b4d..42094f9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@rolemodel/tailored-select", "description": "Tailored Select is a Web Component built to be a searchable select box. Inspired by tom-select.js to provide a framework agnostic autocomplete widget with native-feeling keyboard navigation. Useful for tagging, contact lists, etc.", - "version": "0.0.2", + "version": "0.0.3", "author": "RoleModel Software", "license": "MIT", "type": "module", diff --git a/src/assets/javascript/components/tailored-select.component.js b/src/assets/javascript/components/tailored-select.component.js index 378ee8c..0599035 100644 --- a/src/assets/javascript/components/tailored-select.component.js +++ b/src/assets/javascript/components/tailored-select.component.js @@ -27,9 +27,6 @@ export default class TailoredSelect extends LitElement { connectedCallback() { super.connectedCallback() - - // Ensure focusable - // this.tabIndex = 0 } firstUpdated(changedProperties) { @@ -38,11 +35,11 @@ export default class TailoredSelect extends LitElement { // On load, everything starts in available. We need to move selected over. this.availableOptions.forEach((option) => { option.addEventListener('click', () => this.toggleOption(option)) - option.addEventListener('mouseover', () => this.handleOptionFocus(option)) + option.addEventListener('mouseover', () => this.handleOptionActive(option)) this.assignOptionSlot(option) }) - this.resetOptionFocus() + this.resetActiveOption() this.updateFormValue() } @@ -50,7 +47,7 @@ export default class TailoredSelect extends LitElement { handleInputBlur() { this.hasFocus = false - this.resetOptionFocus() + this.resetActiveOption() // this.emit('ts-blur') } @@ -66,16 +63,21 @@ export default class TailoredSelect extends LitElement { handleInputKeyDown(event) { switch (event.key) { case 'ArrowDown': - this.focusNextOption() + this.activateNextOption() break case 'ArrowUp': - this.focusPreviousOption() + this.activatePreviousOption() break case 'Enter': - this.toggleOption(this.focusedOption) + if (this.availableOptions.length > 0) { + this.toggleOption(this.activeOption) + } break case 'Backspace': this.deleteSelection() + if (!this.activeOption) { + this.resetActiveOption() + } break } } @@ -85,6 +87,7 @@ export default class TailoredSelect extends LitElement { if (!value) { // Make all options visible this.availableOptions.forEach((opt) => (opt.hidden = false)) + this.resetActiveOption() return } @@ -92,6 +95,9 @@ export default class TailoredSelect extends LitElement { this.availableOptions.forEach((opt) => { opt.hidden = !Boolean(opt.value.match(matcher)) }) + + this.resetActiveOption() + this.updateNoResultsMessage() } deleteSelection() { @@ -121,7 +127,7 @@ export default class TailoredSelect extends LitElement { // Option Behavior - handleOptionFocus(option) { + handleOptionActive(option) { if (option.selected) { return } @@ -150,42 +156,52 @@ export default class TailoredSelect extends LitElement { return this.shadowRoot.querySelector('div[role="listbox"]') } - focusNextOption(option = this.focusedOption) { + activateNextOption(option = this.activeOption) { const index = this.availableOptions.indexOf(option) - if (index == this.availableOptions.length - 1) return + if (index == this.availableOptions.length - 1) return false const nextOption = this.availableOptions[index + 1] this.setActiveOption(nextOption) + return true } - focusPreviousOption() { - const index = this.availableOptions.indexOf(this.focusedOption) - if (index == 0) return + activatePreviousOption() { + const index = this.availableOptions.indexOf(this.activeOption) + if (index == 0) return false const nextOption = this.availableOptions[index - 1] this.setActiveOption(nextOption) + return true + } + + ensureActiveOption(option) { + if (this.activateNextOption(option)) return + if (this.activatePreviousOption(option)) return + + this.clearActiveOption() } setActiveOption(option) { - this.clearOptionFocus() + this.clearActiveOption() this.setHeight(option) - option.classList.add('focused') + option.classList.add('active') } - resetOptionFocus() { - this.clearOptionFocus() + resetActiveOption() { + this.clearActiveOption() - const firstOption = this.availableOptions[0] - if (firstOption) { - this.setActiveOption(firstOption) + const firstSelectableOption = this.availableOptions.filter((option) => !option.hidden)[0] + if (firstSelectableOption) { + this.setActiveOption(firstSelectableOption) } } - clearOptionFocus() { - this.removeFocus(this.focusedOption) + clearActiveOption() { + this.removeActiveOption(this.activeOption) } - removeFocus(option) { - option?.classList.remove('focused') + + removeActiveOption(option) { + option?.classList.remove('active') } setHeight(option) { @@ -201,8 +217,8 @@ export default class TailoredSelect extends LitElement { } } - get focusedOption() { - return this.availableOptions.find((opt) => opt.classList.contains('focused')) + get activeOption() { + return this.availableOptions.find((opt) => opt.classList.contains('active')) } // handleChange(event) { @@ -236,7 +252,10 @@ export default class TailoredSelect extends LitElement { } toggleOption(option) { - if (!option.selected) this.focusNextOption(option) + if (!option.selected) { + // Only performed when toggling on + this.ensureActiveOption(option) + } option.selected = !option.selected this.assignOptionSlot(option) @@ -260,7 +279,7 @@ export default class TailoredSelect extends LitElement { } updateNoResultsMessage() { - const noResults = this.availableOptions.every((opt) => opt.hidden) + const noResults = this.availableOptions.every((option) => option.hidden) this.noResultsMessage.classList.toggle('active', noResults) } @@ -474,7 +493,7 @@ export default class TailoredSelect extends LitElement { cursor: pointer; } - ::slotted(option.focused) { + ::slotted(option.active) { background-color: var(--option-background-color-hover); color: var(--option-text-color-hover); }