Skip to content

Commit

Permalink
[RMS 8] Keyboard Navigable (#1)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Jeremy-Walton authored Aug 25, 2024
1 parent 273743f commit 7a40fdd
Show file tree
Hide file tree
Showing 3 changed files with 56 additions and 32 deletions.
5 changes: 5 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
<option value="8">Record 8</option>
<option value="9">Record 9</option>
<option value="10">Record 10</option>
<option value="11">Record 11</option>
<option value="12">Record 12</option>
<option value="13">Record 13</option>
<option value="14">Record 14</option>
<option value="15">Record 15</option>
</tailored-select>
</form>
<script type="module" src="/src/assets/javascript/application.js"></script>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
81 changes: 50 additions & 31 deletions src/assets/javascript/components/tailored-select.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ export default class TailoredSelect extends LitElement {

connectedCallback() {
super.connectedCallback()

// Ensure focusable
// this.tabIndex = 0
}

firstUpdated(changedProperties) {
Expand All @@ -38,19 +35,19 @@ 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()
}

// Input Behavior

handleInputBlur() {
this.hasFocus = false
this.resetOptionFocus()
this.resetActiveOption()
// this.emit('ts-blur')
}

Expand All @@ -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
}
}
Expand All @@ -85,13 +87,17 @@ export default class TailoredSelect extends LitElement {
if (!value) {
// Make all options visible
this.availableOptions.forEach((opt) => (opt.hidden = false))
this.resetActiveOption()
return
}

const matcher = new RegExp(value, 'i')
this.availableOptions.forEach((opt) => {
opt.hidden = !Boolean(opt.value.match(matcher))
})

this.resetActiveOption()
this.updateNoResultsMessage()
}

deleteSelection() {
Expand Down Expand Up @@ -121,7 +127,7 @@ export default class TailoredSelect extends LitElement {

// Option Behavior

handleOptionFocus(option) {
handleOptionActive(option) {
if (option.selected) {
return
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}

Expand Down Expand Up @@ -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);
}
Expand Down

0 comments on commit 7a40fdd

Please sign in to comment.