diff --git a/addon/components/listbox.hbs b/addon/components/listbox.hbs new file mode 100644 index 0000000..8b149a2 --- /dev/null +++ b/addon/components/listbox.hbs @@ -0,0 +1,47 @@ +{{yield + (hash + isOpen=this.isOpen + disabled=this.isDisabled + openListbox=this.openListbox + closeListbox=this.closeListbox + Options=(component + 'listbox/-options' + isOpen=this.isOpen + guid=this.guid + registerOptionElement=this.registerOptionElement + registerOptionsElement=this.registerOptionsElement + unregisterOptionsElement=this.unregisterOptionsElement + hasLabelElement=this.labelElement + activeOptionGuid=this.activeOptionGuid + selectedOptionGuid=this.selectedOptionGuid + setActiveOption=this.setActiveOption + unsetActiveOption=this.unsetActiveOption + setSelectedOption=this.setSelectedOption + handleKeyPress=this.handleKeyPress + handleKeyUp=this.handleKeyUp + openListbox=this.openListbox + closeListbox=this.closeListbox + handleClickOutside=this.handleClickOutside + ) + Button=(component + 'listbox/-button' + guid=this.guid + isOpen=this.isOpen + registerButtonElement=this.registerButtonElement + handleButtonClick=this.handleButtonClick + handleKeyPress=this.handleKeyPress + handleKeyUp=this.handleKeyUp + isDisabled=this.isDisabled + openListbox=this.openListbox + closeListbox=this.closeListbox + optionsElement=this.optionsElement + ) + Label=(component + 'listbox/-label' + guid=this.guid + isOpen=this.isOpen + registerLabelElement=this.registerLabelElement + handleLabelClick=this.handleLabelClick + ) + ) +}} \ No newline at end of file diff --git a/addon/components/listbox.js b/addon/components/listbox.js new file mode 100644 index 0000000..b44a54f --- /dev/null +++ b/addon/components/listbox.js @@ -0,0 +1,298 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { guidFor } from '@ember/object/internals'; +import { action } from '@ember/object'; +import { debounce } from '@ember/runloop'; + +const ACTIVATE_NONE = 0; +const ACTIVATE_FIRST = 1; +const ACTIVATE_LAST = 2; + +export default class ListboxComponent extends Component { + @tracked activeOptionIndex; + activateBehaviour = ACTIVATE_NONE; + buttonElement; + guid = `${guidFor(this)}-headlessui-listbox`; + @tracked _isOpen = this.args.isOpen || false; + labelElement; + optionsElement; + optionElements = []; + optionValues = {}; + search = ''; + @tracked selectedOptionIndex; + + get activeOptionGuid() { + return this.optionElements[this.activeOptionIndex]?.id; + } + + get isDisabled() { + return !!this.args.disabled; + } + + get selectedOptionGuid() { + return this.optionElements[this.selectedOptionIndex]?.id; + } + + get isOpen() { + return this._isOpen; + } + + set isOpen(isOpen) { + if (isOpen) { + this.activeOptionIndex = undefined; + this.selectedOptionIndex = undefined; + this.optionElements = []; + this.optionValues = {}; + this._isOpen = true; + } else { + this._isOpen = false; + } + } + + @action + closeListbox() { + this.isOpen = false; + } + + @action + handleButtonClick(e) { + if (e.button !== 0) return; + this.activateBehaviour = ACTIVATE_NONE; + this.isOpen = !this.isOpen; + } + + @action + handleClickOutside(e) { + for (let i = 0; i < e.path?.length; i++) { + if (e.path[i] === this.buttonElement) { + return true; + } + } + + this.closeListbox(); + + return true; + } + + @action + handleKeyUp(event) { + if (event.key === 'ArrowDown') { + if (!this.isOpen) { + this.activateBehaviour = ACTIVATE_FIRST; + this.isOpen = true; + } else { + this.setNextOptionActive(); + } + } else if (event.key === 'ArrowRight') { + if (this.isOpen) { + this.setNextOptionActive(); + } + } else if (event.key === 'ArrowUp') { + if (!this.isOpen) { + this.activateBehaviour = ACTIVATE_LAST; + this.isOpen = true; + } else { + this.setPreviousOptionActive(); + } + } else if (event.key === 'ArrowLeft') { + if (this.isOpen) { + this.setPreviousOptionActive(); + } + } else if (event.key === 'Home' || event.key === 'PageUp') { + this.setFirstOptionActive(); + } else if (event.key === 'End' || event.key === 'PageDown') { + this.setLastOptionActive(); + } else if (event.key === 'Escape') { + this.isOpen = false; + } + } + + @action + handleKeyPress(event) { + if ( + event.key === 'Enter' || + ((event.key === 'Space' || event.key === ' ') && this.search === '') + ) { + this.activateBehaviour = ACTIVATE_FIRST; + if (this.isOpen) { + this.setSelectedOption(event.target, event); + this.isOpen = false; + } else { + this.isOpen = true; + } + } else if (event.key.length === 1) { + this.addSearchCharacter(event.key); + } + } + @action + handleLabelClick(e) { + e.preventDefault(); + e.stopPropagation(); + if (e.ctrlKey || e.button !== 0) return; + this.buttonElement.focus(); + } + + @action + openListbox() { + this.isOpen = true; + } + + @action + registerButtonElement(buttonElement) { + this.buttonElement = buttonElement; + } + + @action + registerLabelElement(labelElement) { + this.labelElement = labelElement; + } + + @action + registerOptionElement(optionComponent, optionElement) { + this.optionElements.push(optionElement); + this.optionValues[optionComponent.guid] = optionComponent.args.value; + + // store the index at which the option appears in the list + // so we can avoid a O(n) find operation later + optionComponent.index = this.optionElements.length - 1; + optionElement.setAttribute('data-index', this.optionElements.length - 1); + + if (this.args.value) { + if (this.args.value === optionComponent.args.value) { + this.selectedOptionIndex = this.activeOptionIndex = + this.optionElements.length - 1; + } + } + + if (!this.selectedOptionIndex) { + switch (this.activateBehaviour) { + case ACTIVATE_FIRST: + this.setFirstOptionActive(); + break; + case ACTIVATE_LAST: + this.setLastOptionActive(); + break; + } + } + } + + @action + registerOptionsElement(optionsElement) { + this.optionsElement = optionsElement; + } + + @action + setActiveOption(optionComponent) { + this.optionElements.forEach((o, i) => { + if (o.id === optionComponent.guid && !o.hasAttribute('disabled')) { + this.activeOptionIndex = i; + document.querySelector('#' + optionComponent.guid).focus(); + } + }); + } + + @action + setSelectedOption(optionComponent, e) { + let optionIndex, optionValue; + + if (optionComponent.constructor.name === 'ListboxOptionComponent') { + optionValue = optionComponent.args.value; + optionIndex = optionComponent.index; + } else if (this.activeOptionIndex !== undefined) { + optionValue = + this.optionValues[this.optionElements[this.activeOptionIndex].id]; + optionIndex = parseInt( + this.optionElements[this.activeOptionIndex].getAttribute('data-index') + ); + } else { + return; + } + + if (!this.optionElements[optionIndex].hasAttribute('disabled')) { + this.selectedOptionIndex = optionIndex; + + if (this.args.onChange) { + this.args.onChange(optionValue); + } + + if (e.type === 'click') { + this.isOpen = false; + } + } else { + this.optionsElement.focus(); + } + } + + @action + unregisterOptionsElement() { + this.optionsElement = undefined; + } + + @action + unsetActiveOption() { + this.activeOptionIndex = undefined; + } + + setNextOptionActive() { + for ( + let i = this.activeOptionIndex + 1; + i < this.optionElements.length; + i++ + ) { + if (!this.optionElements[i].hasAttribute('disabled')) { + this.activeOptionIndex = i; + break; + } + } + } + + setPreviousOptionActive() { + for (let i = this.activeOptionIndex - 1; i >= 0; i--) { + if (!this.optionElements[i].hasAttribute('disabled')) { + this.activeOptionIndex = i; + break; + } + } + } + + setFirstOptionActive() { + for (let i = 0; i < this.optionElements.length; i++) { + if (!this.optionElements[i].hasAttribute('disabled')) { + this.activeOptionIndex = i; + break; + } + } + } + + setLastOptionActive() { + for (let i = this.optionElements.length - 1; i >= 0; i--) { + if (!this.optionElements[i].hasAttribute('disabled')) { + this.activeOptionIndex = i; + break; + } + } + } + + clearSearch() { + this.search = ''; + } + + addSearchCharacter(key) { + debounce(this, this.clearSearch, 500); + + this.search += key.toLowerCase(); + + for (let i = 0; i < this.optionElements.length; i++) { + if ( + !this.optionElements[i].hasAttribute('disabled') && + this.optionElements[i].textContent + .trim() + .toLowerCase() + .startsWith(this.search) + ) { + this.activeOptionIndex = i; + break; + } + } + } +} diff --git a/addon/components/listbox/-button.hbs b/addon/components/listbox/-button.hbs new file mode 100644 index 0000000..04486be --- /dev/null +++ b/addon/components/listbox/-button.hbs @@ -0,0 +1,18 @@ +{{#let (element (or @as 'button')) as |Tag|}} + + {{yield}} + +{{/let}} \ No newline at end of file diff --git a/addon/components/listbox/-label.hbs b/addon/components/listbox/-label.hbs new file mode 100644 index 0000000..0e1f08a --- /dev/null +++ b/addon/components/listbox/-label.hbs @@ -0,0 +1,11 @@ +{{#let (element (or @as 'label')) as |Tag|}} + + {{yield}} + +{{/let}} \ No newline at end of file diff --git a/addon/components/listbox/-option.hbs b/addon/components/listbox/-option.hbs new file mode 100644 index 0000000..3cfb4cc --- /dev/null +++ b/addon/components/listbox/-option.hbs @@ -0,0 +1,23 @@ +{{#let (element (or @as 'li')) as |Tag|}} + + {{yield + (hash + active=this.isActiveOption + selected=this.isSelectedOption + disabled=(if @disabled true false) + ) + }} + +{{/let}} \ No newline at end of file diff --git a/addon/components/listbox/-option.js b/addon/components/listbox/-option.js new file mode 100644 index 0000000..cd8ea81 --- /dev/null +++ b/addon/components/listbox/-option.js @@ -0,0 +1,24 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { guidFor } from '@ember/object/internals'; +import { action } from '@ember/object'; + +export default class ListboxOptionComponent extends Component { + @tracked guid = `${guidFor(this)}-headlessui-listbox-option`; + + @action + handleClick(e) { + e.stopPropagation(); + e.preventDefault(); + + this.args.setSelectedOption(this, e); + } + + get isActiveOption() { + return this.args.activeOptionGuid == this.guid; + } + + get isSelectedOption() { + return this.args.selectedOptionGuid == this.guid; + } +} diff --git a/addon/components/listbox/-options.hbs b/addon/components/listbox/-options.hbs new file mode 100644 index 0000000..38b904c --- /dev/null +++ b/addon/components/listbox/-options.hbs @@ -0,0 +1,41 @@ +{{#if (or @isOpen @static)}} + {{#let (element (or @as 'ul')) as |Tag|}} + + {{yield + (hash + Option=(component + 'listbox/-option' + registerOptionElement=@registerOptionElement + activeOptionGuid=@activeOptionGuid + selectedOptionGuid=@selectedOptionGuid + setActiveOption=@setActiveOption + unsetActiveOption=@unsetActiveOption + setSelectedOption=@setSelectedOption + ) + ) + }} + + {{/let}} +{{/if}} \ No newline at end of file diff --git a/app/components/listbox.js b/app/components/listbox.js new file mode 100644 index 0000000..26bdbfd --- /dev/null +++ b/app/components/listbox.js @@ -0,0 +1 @@ +export { default } from 'ember-headlessui/components/listbox'; diff --git a/app/components/listbox/-button.js b/app/components/listbox/-button.js new file mode 100644 index 0000000..b135a0e --- /dev/null +++ b/app/components/listbox/-button.js @@ -0,0 +1 @@ +export { default } from 'ember-headlessui/components/listbox/-button'; diff --git a/app/components/listbox/-label.js b/app/components/listbox/-label.js new file mode 100644 index 0000000..a5bc4c4 --- /dev/null +++ b/app/components/listbox/-label.js @@ -0,0 +1 @@ +export { default } from 'ember-headlessui/components/listbox/-label'; diff --git a/app/components/listbox/-option.js b/app/components/listbox/-option.js new file mode 100644 index 0000000..9b35911 --- /dev/null +++ b/app/components/listbox/-option.js @@ -0,0 +1 @@ +export { default } from 'ember-headlessui/components/listbox/-option'; diff --git a/app/components/listbox/-options.js b/app/components/listbox/-options.js new file mode 100644 index 0000000..3fda48d --- /dev/null +++ b/app/components/listbox/-options.js @@ -0,0 +1 @@ +export { default } from 'ember-headlessui/components/listbox/-options'; diff --git a/tests/accessibility-assertions.js b/tests/accessibility-assertions.js index 58a4f64..1a84dd2 100644 --- a/tests/accessibility-assertions.js +++ b/tests/accessibility-assertions.js @@ -26,6 +26,30 @@ function getDialogOverlays() { ); } +function getListboxButton() { + return document.querySelector('[role="button"]'); +} + +function getListboxButtons() { + return Array.from(document.querySelectorAll('[role="button"]')); +} + +function getListboxLabel() { + return document.querySelector('[id$="-headlessui-listbox-label"]'); +} + +function getListboxOptions() { + return Array.from(document.querySelectorAll('[role="option"]')); +} + +function getListbox() { + return document.querySelector('[role="listbox"]'); +} + +function getListboxes() { + return Array.from(document.querySelectorAll('[role="listbox"]')); +} + const DialogState = { /** The dialog is visible to the user. */ Visible: 'Visible', @@ -37,6 +61,12 @@ const DialogState = { InvisibleUnmounted: 'InvisibleUnmounted', }; +const ListboxState = { + Visible: 'Visible', + InvisibleHidden: 'InvisibleHidden', + InvisibleUnmounted: 'InvisibleUnmounted', +}; + function assertNever(x) { throw new Error('Unexpected object: ' + x); } @@ -527,8 +557,276 @@ function getByText(text) { return null; } +function assertListboxButton( + { state, attributes, textContent }, + button = getListboxButton() +) { + try { + if (button === null) return Qunit.assert.ok(button); + Qunit.assert.dom(button).hasAttribute('id'); + Qunit.assert.dom(button).hasAria('haspopup', 'listbox'); + if (textContent) { + Qunit.assert.dom(button).hasText(textContent); + } + switch (state) { + case ListboxState.InvisibleUnmounted: { + Qunit.assert.dom(button).doesNotHaveAria('listbox'); + if (button.hasAttribute('disabled')) { + Qunit.assert.dom(button).doesNotHaveAria('expanded'); + Qunit.assert.dom(button).isDisabled(); + } else { + Qunit.assert.dom(button).hasAria('expanded', 'false'); + } + break; + } + case ListboxState.Visible: { + Qunit.assert.dom(button).hasAria('controls', { any: true }); + Qunit.assert.dom(button).hasAria('expanded', 'true'); + break; + } + default: + Qunit.assert.ok( + state, + 'you have to provide state to assertListboxButton' + ); + } + + for (let attributeName in attributes) { + Qunit.assert + .dom(button) + .hasAttribute(attributeName, attributes[attributeName]); + } + } catch (err) { + Error.captureStackTrace(err, assertListboxButton); + throw err; + } +} + +function assertListboxLabelLinkedWithListbox( + label = getListboxLabel(), + listbox = getListbox() +) { + try { + if (label === null) return Qunit.assert.ok(label); + if (listbox === null) return Qunit.assert.ok(listbox); + + Qunit.assert + .dom(listbox) + .hasAttribute('aria-labelledby', label.getAttribute('id')); + } catch (err) { + Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox); + throw err; + } +} + +function assertListboxButtonLinkedWithListbox( + button = getListboxButton(), + listbox = getListbox() +) { + try { + if (button === null) return Qunit.assert.ok(button); + if (listbox === null) return Qunit.assert.ok(listbox); + + // Ensure link between button & listbox is correct + Qunit.assert + .dom(button) + .hasAttribute('aria-controls', listbox.getAttribute('id')); + Qunit.assert + .dom(listbox) + .hasAttribute('aria-labelledby', button.getAttribute('id')); + } catch (err) { + Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox); + throw err; + } +} + +function assertActiveListboxOption(item, listbox = getListbox()) { + try { + if (item === null) return Qunit.assert.ok(item); + if (listbox === null) return Qunit.assert.ok(listbox); + + // Ensure link between listbox & listbox item is correct + Qunit.assert + .dom(listbox) + .hasAttribute('aria-activedescendant', item.getAttribute('id')); + } catch (err) { + Error.captureStackTrace(err, assertActiveListboxOption); + throw err; + } +} + +function assertNoActiveListboxOption(listbox = getListbox()) { + try { + if (listbox === null) return Qunit.assert.ok(listbox); + + // Ensure we don't have an active listbox + Qunit.assert.dom(listbox).doesNotHaveAttribute('aria-activedescendant'); + } catch (err) { + Error.captureStackTrace(err, assertNoActiveListboxOption); + throw err; + } +} + +function assertNoSelectedListboxOption(items = getListboxOptions()) { + try { + for (let item of items) + Qunit.assert.dom(item).doesNotHaveAttribute('aria-selected'); + } catch (err) { + Error.captureStackTrace(err, assertNoSelectedListboxOption); + throw err; + } +} + +function assertListboxButtonLinkedWithListboxLabel( + button = getListboxButton(), + label = getListboxLabel() +) { + try { + if (button === null) return Qunit.assert.ok(button); + if (label === null) return Qunit.assert.ok(label); + + // Ensure link between button & label is correct + Qunit.assert + .dom(button) + .hasAttribute('aria-labelledby', `${label.id} ${button.id}`); + } catch (err) { + Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel); + throw err; + } +} + +function assertListboxLabel( + { attributes, tag, textContent }, + label = getListboxLabel() +) { + try { + if (label === null) return Qunit.assert.ok(label); + + // Ensure menu button have these properties + Qunit.assert.dom(label).hasAttribute('id'); + + if (textContent) { + Qunit.assert.dom(label).hasText(textContent); + } + + if (tag) { + Qunit.assert.dom(label).hasTagName(tag); + } + + // Ensure menu button has the following attributes + for (let attributeName in attributes) { + Qunit.assert + .dom(label) + .hasAttribute(attributeName, attributes[attributeName]); + } + } catch (err) { + Error.captureStackTrace(err, assertListboxLabel); + throw err; + } +} + +export function assertListboxOption({ tag, attributes, selected }, item) { + try { + if (item === null) return Qunit.assert.notOk(item); + + // Check that some attributes exists, doesn't really matter what the values are at this point in + // time, we just require them. + Qunit.assert.dom(item).hasAttribute('id'); + + // Check that we have the correct values for certain attributes + Qunit.assert.dom(item).hasAttribute('role', 'option'); + if (!item.getAttribute('aria-disabled')) + Qunit.assert.dom(item).hasAttribute('tabindex', '-1'); + + // Ensure listbox button has the following attributes + if (!tag && !attributes && !selected) return; + + for (let attributeName in attributes) { + Qunit.assert + .dom(item) + .hasAttribute(attributeName, attributes[attributeName]); + } + + if (tag) { + Qunit.assert.dom(item).hasTagName(tag); + } + + if (selected != null) { + switch (selected) { + case true: + return Qunit.assert.dom(item).hasAttribute('aria-selected', 'true'); + + case false: + return Qunit.assert.dom(item).doesNotHaveAttribute('aria-selected'); + + default: + Qunit.assert.ok(); + } + } + } catch (err) { + Error.captureStackTrace(err, assertListboxOption); + throw err; + } +} + +function assertListbox( + { state, attributes, textContent }, + listbox = getListbox(), + orientation = 'vertical' +) { + try { + switch (state) { + case ListboxState.InvisibleHidden: { + if (listbox === null) return Qunit.assert.dom(listbox).exists(); + + assertHidden(listbox); + + Qunit.assert.dom(listbox).hasAria('labelledby'); + Qunit.assert.dom(listbox).hasAria('orientation', orientation); + Qunit.assert.dom(listbox).hasAttribute('role', 'listbox'); + + if (textContent) Qunit.assert.dom(listbox).hasText(textContent); + + for (let attributeName in attributes) { + Qunit.assert + .dom(listbox) + .hasAttribute(attributeName, attributes[attributeName]); + } + break; + } + case ListboxState.InvisibleUnmounted: { + Qunit.assert.dom(listbox).doesNotExist(); + break; + } + case ListboxState.Visible: { + // Qunit.assert.dom(listbox).isVisible(); + Qunit.assert.dom(listbox).exists(); + + Qunit.assert.dom(listbox).hasAria('labelledby', { any: true }); + Qunit.assert.dom(listbox).hasAttribute('aria-orientation', orientation); + Qunit.assert.dom(listbox).hasAttribute('role', 'listbox'); + + if (textContent) Qunit.assert.dom(listbox).hasText(textContent); + + for (let attributeName in attributes) { + Qunit.assert + .dom(listbox) + .hasAttribute(attributeName, attributes[attributeName]); + } + break; + } + default: + Qunit.assert.ok(state, 'you have to provide state to assertListbox'); + } + } catch (err) { + Error.captureStackTrace(err, assertListbox); + throw err; + } +} + export { DialogState, + ListboxState, assertDialog, assertDialogDescription, assertDialogOverlay, @@ -539,4 +837,19 @@ export { getDialogOverlays, assertActiveElement, getByText, + assertListboxButton, + assertListboxButtonLinkedWithListbox, + assertListboxButtonLinkedWithListboxLabel, + assertListboxLabel, + assertListboxLabelLinkedWithListbox, + assertActiveListboxOption, + assertNoActiveListboxOption, + assertNoSelectedListboxOption, + getListboxOptions, + assertListbox, + getListbox, + getListboxes, + getListboxButton, + getListboxButtons, + getListboxLabel, }; diff --git a/tests/dummy/app/components/listboxes/basic.hbs b/tests/dummy/app/components/listboxes/basic.hbs new file mode 100644 index 0000000..65c7820 --- /dev/null +++ b/tests/dummy/app/components/listboxes/basic.hbs @@ -0,0 +1,85 @@ +
+ + Select a person: + + {{this.selectedPerson.name}} + + + + + + {{#each this.people as |person|}} + + + + {{person.name}} + + {{#if option.selected}} + + + + {{/if}} + + + {{/each}} + + +
\ No newline at end of file diff --git a/tests/dummy/app/components/listboxes/basic.js b/tests/dummy/app/components/listboxes/basic.js new file mode 100644 index 0000000..947f70a --- /dev/null +++ b/tests/dummy/app/components/listboxes/basic.js @@ -0,0 +1,24 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +const PEOPLE = [ + { id: 1, name: 'Durward Reynolds', unavailable: false }, + { id: 2, name: 'Kenton Towne', unavailable: false }, + { id: 3, name: 'Therese Wunsch', unavailable: false }, + { id: 4, name: 'Benedict Kessler', unavailable: true }, + { id: 5, name: 'Katelyn Rohan', unavailable: false }, +]; + +export default class ListboxesBasicComponent extends Component { + @tracked selectedPerson = PEOPLE[0]; + + get people() { + return PEOPLE; + } + + @action + setSelectedPerson(person) { + this.selectedPerson = person; + } +} diff --git a/tests/dummy/app/router.js b/tests/dummy/app/router.js index 9878c63..7188ed3 100644 --- a/tests/dummy/app/router.js +++ b/tests/dummy/app/router.js @@ -14,6 +14,10 @@ Router.map(function () { this.route('menu-with-transition-and-popper'); }); + this.route('listbox', function () { + this.route('listbox-basic'); + }); + this.route('switch', function () { this.route('switch-basic'); this.route('switch-checkbox'); diff --git a/tests/dummy/app/templates/index.hbs b/tests/dummy/app/templates/index.hbs index d08a008..495fa46 100644 --- a/tests/dummy/app/templates/index.hbs +++ b/tests/dummy/app/templates/index.hbs @@ -23,6 +23,14 @@ +
  • +

    Listbox

    + +
  • Switch