From 653a4075e6e974b9e1f47660557695c0dd080a13 Mon Sep 17 00:00:00 2001 From: David McNamara <70960245+dmcnamara-eng@users.noreply.github.com> Date: Fri, 3 Sep 2021 17:05:11 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Implement=20``=20compo?= =?UTF-8?q?nent=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📦 Use ember-sinon * ✅ Implement tests * ✨ Implement component * ✨ Add basic to dummy app * ✨ Stub components/modifiers in app * ♻️ Use isOpen and open setter Use `isOpen` as the yielded property name Use a `setter` for this property to ensure state is correct when property is changed externally. Pass `openListbox` and `closeListbox` actions to components. * ♻️ Use focus-trap and click-outside Use `focus-trap` to manage focus when opening/closing the listbox Use `click-outside` to manage document click events * ♻️ Use isDisabled property * ♻️ Allow custom ul tagname * ♻️ Improve performance when setting a selected option Now storing the index of each listbox option within the list. This means we avoid an expensive `find` operation when setting the selected option on mouse click or key event. * ♻️ Remove ember-sinon dependency * ♻️ Remove 1px class from listbox * ♻️ Expose openListbox, closeListbox, isOpen * ♻️ Remove aria modifier * ♻️ Option GUID cleanup * ♻️ find -> forEach * ♻️ Remove click-outside dependency * 🚨 Fix linter errors * ♻️ Remove redundant conditional * ♻️ Add test todos for hidden render strategy --- addon/components/listbox.hbs | 47 + addon/components/listbox.js | 298 ++ addon/components/listbox/-button.hbs | 18 + addon/components/listbox/-label.hbs | 11 + addon/components/listbox/-option.hbs | 23 + addon/components/listbox/-option.js | 24 + addon/components/listbox/-options.hbs | 41 + app/components/listbox.js | 1 + app/components/listbox/-button.js | 1 + app/components/listbox/-label.js | 1 + app/components/listbox/-option.js | 1 + app/components/listbox/-options.js | 1 + tests/accessibility-assertions.js | 313 ++ .../dummy/app/components/listboxes/basic.hbs | 85 + tests/dummy/app/components/listboxes/basic.js | 24 + tests/dummy/app/router.js | 4 + tests/dummy/app/templates/index.hbs | 8 + .../app/templates/listbox/listbox-basic.hbs | 1 + tests/integration/components/listbox-test.js | 3320 +++++++++++++++++ 19 files changed, 4222 insertions(+) create mode 100644 addon/components/listbox.hbs create mode 100644 addon/components/listbox.js create mode 100644 addon/components/listbox/-button.hbs create mode 100644 addon/components/listbox/-label.hbs create mode 100644 addon/components/listbox/-option.hbs create mode 100644 addon/components/listbox/-option.js create mode 100644 addon/components/listbox/-options.hbs create mode 100644 app/components/listbox.js create mode 100644 app/components/listbox/-button.js create mode 100644 app/components/listbox/-label.js create mode 100644 app/components/listbox/-option.js create mode 100644 app/components/listbox/-options.js create mode 100644 tests/dummy/app/components/listboxes/basic.hbs create mode 100644 tests/dummy/app/components/listboxes/basic.js create mode 100644 tests/dummy/app/templates/listbox/listbox-basic.hbs create mode 100644 tests/integration/components/listbox-test.js diff --git a/addon/components/listbox.hbs b/addon/components/listbox.hbs new file mode 100644 index 00000000..8b149a25 --- /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 00000000..b44a54fe --- /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 00000000..04486be1 --- /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 00000000..0e1f08aa --- /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 00000000..3cfb4cc0 --- /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 00000000..cd8ea81d --- /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 00000000..38b904c0 --- /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 00000000..26bdbfdf --- /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 00000000..b135a0e5 --- /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 00000000..a5bc4c49 --- /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 00000000..9b35911a --- /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 00000000..3fda48dc --- /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 58a4f641..1a84dd2e 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 00000000..65c78201 --- /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 00000000..947f70a0 --- /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 9878c63d..7188ed3b 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 d08a0081..495fa466 100644 --- a/tests/dummy/app/templates/index.hbs +++ b/tests/dummy/app/templates/index.hbs @@ -23,6 +23,14 @@ +
  • +

    Listbox

    +
      +
    • + Listbox (basic) +
    • +
    +
  • Switch

      diff --git a/tests/dummy/app/templates/listbox/listbox-basic.hbs b/tests/dummy/app/templates/listbox/listbox-basic.hbs new file mode 100644 index 00000000..2510e0ff --- /dev/null +++ b/tests/dummy/app/templates/listbox/listbox-basic.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/integration/components/listbox-test.js b/tests/integration/components/listbox-test.js new file mode 100644 index 00000000..02ea3e93 --- /dev/null +++ b/tests/integration/components/listbox-test.js @@ -0,0 +1,3320 @@ +import { module, test, todo, skip } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { + click, + render, + triggerKeyEvent, + triggerEvent, + focus, +} from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { + assertActiveElement, + assertListboxButton, + assertListboxLabel, + assertListboxOption, + assertListbox, + assertListboxButtonLinkedWithListboxLabel, + assertListboxButtonLinkedWithListbox, + assertListboxLabelLinkedWithListbox, + assertActiveListboxOption, + assertNoActiveListboxOption, + assertNoSelectedListboxOption, + ListboxState, + getListboxButton, + getListboxButtons, + getListboxLabel, + getListboxOptions, + getListbox, + getListboxes, +} from '../../accessibility-assertions'; + +async function typeWord(word) { + word.split('').forEach((char) => { + triggerEvent(document.activeElement, 'keypress', { + key: char, + }); + }); + await new Promise((r) => setTimeout(r, 600)); +} + +module('Integration | Component | ', function (hooks) { + setupRenderingTest(hooks); + + test('should be possible to render a Listbox without crashing', async function () { + await render(hbs` + + Trigger + + option + + + `); + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'my-custom-property' }, + }); + assertListbox({ + state: ListboxState.InvisibleUnmounted, + }); + }); + + test('should be possible to render a Listbox using a "isOpen" property', async function () { + await render(hbs` + + Trigger + {{#if listbox.isOpen}} + + option + + {{/if}} + + `); + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'my-custom-property' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + await click(getListboxButton()); + + assertListboxButton({ + state: ListboxState.Visible, + attributes: { 'data-test': 'my-custom-property' }, + }); + assertListbox({ state: ListboxState.Visible }); + }); + + test('should be possible to disable a Listbox', async function () { + await render(hbs` + + Trigger + + option + + + `); + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'my-custom-property' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + }); + + module('', () => { + test('should be possible to render a using yielded props', async () => { + await render(hbs` + + {{listbox.isOpen}} {{listbox.disabled}} + Trigger + + option + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'my-custom-property' }, + }); + assertListboxLabel({ + attributes: { 'data-test': 'headlessui-listbox-label-1' }, + textContent: 'false false', + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + await click(getListboxButton()); + + assertListboxLabel({ + attributes: { 'data-test': 'headlessui-listbox-label-1' }, + textContent: 'true false', + }); + assertListbox({ state: ListboxState.Visible }); + assertListboxLabelLinkedWithListbox(); + assertListboxButtonLinkedWithListboxLabel(); + }); + + test('should be possible to render a Listbox.Label using a yielded props and tag name', async () => { + await render(hbs` + + Label + Trigger + + Option A + Option B + Option C + + + `); + + assertListboxLabel({ + attributes: { 'data-test': 'headlessui-listbox-label-1' }, + textContent: 'Label', + tag: 'p', + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + await click(getListboxButton()); + assertListboxLabel({ + attributes: { 'data-test': 'headlessui-listbox-label-1' }, + textContent: 'Label', + tag: 'p', + }); + assertListbox({ state: ListboxState.Visible }); + }); + }); + + module('', () => { + test('should be possible to render a using yielded props', async () => { + await render(hbs` + + Label + {{listbox.isOpen}} {{listbox.disabled}} + + Option A + Option B + Option C + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + textContent: 'false false', + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + await click(getListboxButton()); + + assertListboxButton({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + textContent: 'true false', + }); + assertListbox({ state: ListboxState.Visible }); + }); + + test('should be possible to render a using yielded props and tag name', async () => { + await render(hbs` + + Label + {{listbox.isOpen}} {{listbox.disabled}} + + Option A + Option B + Option C + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + textContent: 'false false', + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + await click(getListboxButton()); + + assertListboxButton({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + textContent: 'true false', + }); + assertListbox({ state: ListboxState.Visible }); + }); + + test('should be possible to render a Listbox.Button and a Listbox.Label and see them linked together', async () => { + await render(hbs` + + Label + Trigger + + Option A + Option B + Option C + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + assertListboxButtonLinkedWithListboxLabel(); + }); + }); + + module('', () => { + test('should be possible to render a using yielded props', async () => { + await render(hbs` + + Trigger + + {{listbox.isOpen}} + {{listbox.isOpen}} + {{listbox.isOpen}} + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + await click(getListboxButton()); + + assertListboxButton({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ + state: ListboxState.Visible, + textContent: 'true true true', + }); + await assertActiveElement(getListbox()); + }); + + test('should be possible to always render the Listbox.Options if we provide it a `static` prop', async (assert) => { + await render(hbs` + + Trigger + + Option A + Option B + Option C + + + `); + + // Let's verify that the Listbox is already there + assert.dom(getListbox()).exists(); + }); + + todo( + 'should be possible to use a different render strategy for the Listbox.Options', + async () => {} + ); + }); + + module('', () => { + test('should be possible to render a using yielded props', async () => { + await render(hbs` + + Trigger + + {{option.active}} {{option.selected}} {{option.disabled}} + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + await click(getListboxButton()); + + assertListboxButton({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ + state: ListboxState.Visible, + textContent: 'false false false', + }); + }); + }); + + module('Listbox Rendering composition', () => { + todo( + 'should be possible to conditionally render classNames (aka className can be a function?!)', + async function () {} + ); + + test('should be possible to swap the Listbox option with a button for example', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Open Listbox + await click(getListboxButton()); + + // Verify options are buttons now + getListboxOptions().forEach((option) => + assertListboxOption({ tag: 'button' }, option) + ); + }); + }); + + module('Listbox composition', () => { + todo( + 'test should be possible to wrap the Listbox.Options with a Transition component', + async function () {} + ); + }); + + module('Listbox keyboard actions', () => { + test('`Enter` key', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + + getListboxOptions().forEach((option) => + assertListboxOption({ selected: false }, option) + ); + // Verify that the first listbox option is active + assertActiveListboxOption(options[0]); + assertNoSelectedListboxOption(); + }); + + test('should not be possible to open the listbox with Enter when the button is disabled', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Try to open the listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify it is still closed + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + }); + + test('should be possible to open the listbox with Enter, and focus the selected option', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option, i) => + assertListboxOption({ selected: i === 1 }, option) + ); + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]); + }); + + todo( + 'should be possible to open the listbox with Enter, and focus the selected option (when using the `hidden` render strategy)', + async () => {} + ); + + test('should be possible to open the listbox with Enter, and focus the selected option (with a list of objects)', async function (assert) { + this.set('myOptions', [ + { id: 'a', name: 'Option A' }, + { id: 'b', name: 'Option B' }, + { id: 'c', name: 'Option C' }, + ]); + this.set('selectedOption', this.myOptions[1]); + await render(hbs` + + Trigger + + {{#each this.myOptions as |myOption|}} + + {{myOption.name}} + + {{/each}} + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option, i) => + assertListboxOption({ selected: i === 1 }, option) + ); + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]); + }); + + test('should have no active listbox option when there are no listbox options at all', async () => { + await render(hbs` + + Trigger + + + `); + + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + + assertNoActiveListboxOption(); + }); + + test('should focus the first non disabled listbox option when opening with Enter', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + let options = getListboxOptions(); + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[1]); + }); + + test('should focus the first non disabled listbox option when opening with Enter (jump over multiple disabled ones)', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + let options = getListboxOptions(); + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[2]); + }); + + test('should have no active listbox option upon Enter key press, when there are no non-disabled listbox options', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton().focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + assertNoActiveListboxOption(); + }); + + test('should be possible to close the listbox with Enter when there is no active listboxoption', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Open listbox + await click(getListboxButton()); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + + // Close listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify it is closed + assertListboxButton({ state: ListboxState.InvisibleUnmounted }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Verify the button is focused again + await assertActiveElement(getListboxButton()); + }); + + test('should be possible to close the listbox with Enter and choose the active listbox option', async function (assert) { + let callValue = '', + callCount = 0; + + this.set('onChange', (value) => { + this.set('selectedOption', value); + callValue = value; + callCount++; + }); + + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Open listbox + await click(getListboxButton()); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + + // Activate the first listbox option + let options = getListboxOptions(); + await triggerEvent(options[0], 'mouseover'); + + // Choose option, and close listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify it is closed + assertListboxButton({ state: ListboxState.InvisibleUnmounted }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Verify we got the change event + assert.equal(callCount, 1, 'handleChange called once exactly'); + assert.equal(callValue, 'a', 'handleChange called with "a"'); + + // Verify the button is focused again + await assertActiveElement(getListboxButton()); + + // Open listbox again + await click(getListboxButton()); + + // Verify the active option is the previously selected one + assertActiveListboxOption(getListboxOptions()[0]); + }); + }); + + module('Listbox `Space` key', () => { + test('should be possible to open the listbox with Space', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Space'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[0]); + }); + + test('should not be possible to open the listbox with Space when the button is disabled', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Try to open the listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Space'); + // Verify it is still closed + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + }); + + test('should be possible to open the listbox with Space, and focus the selected option', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Space'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option, i) => + assertListboxOption({ selected: i === 1 }, option) + ); + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]); + }); + + test('should have no active listbox option when there are no listbox options at all', async () => { + await render(hbs` + + Trigger + + + `); + + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Space'); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + + assertNoActiveListboxOption(); + }); + + test('should focus the first non disabled listbox option when opening with Space', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Space'); + + let options = getListboxOptions(); + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[1]); + }); + + test('should focus the first non disabled listbox option when opening with Space (jump over multiple disabled ones)', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Space'); + + let options = getListboxOptions(); + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[2]); + }); + + test('should have no active listbox option upon Space key press, when there are no non-disabled listbox options', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Space'); + + assertNoActiveListboxOption(); + }); + + test('should be possible to close the listbox with Space and choose the active listbox option', async function (assert) { + let callValue = '', + callCount = 0; + + this.set('onChange', (value) => { + this.set('selectedOption', value); + callValue = value; + callCount++; + }); + + await render(hbs` + + Trigger + + Option A + Option B + Option C + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Open listbox + await click(getListboxButton()); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + + // Activate the first listbox option + let options = getListboxOptions(); + await triggerEvent(options[0], 'mouseover'); + + // Choose option, and close listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Space'); + + // Verify it is closed + assertListboxButton({ state: ListboxState.InvisibleUnmounted }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Verify we got the change event + assert.equal(callCount, 1, 'handleChange called once exactly'); + assert.equal(callValue, 'a', 'handleChange called with "a"'); + + // Verify the button is focused again + await assertActiveElement(getListboxButton()); + + // Open listbox again + await click(getListboxButton()); + + // Verify the active option is the previously selected one + assertActiveListboxOption(getListboxOptions()[0]); + }); + }); + + module('Listbox `Escape` key', () => { + test('should be possible to close an open listbox with Escape', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + // Focus the button + getListboxButton().focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Space'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Close listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'Escape'); + + // Verify it is closed + assertListboxButton({ state: ListboxState.InvisibleUnmounted }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Verify the button is focused again + await assertActiveElement(getListboxButton()); + }); + }); + + module('Listbox `Tab` key', () => { + test('should focus trap when we use Tab', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[0]); + + // Try to tab + await triggerKeyEvent(document.activeElement, 'keypress', 'Tab'); + + // Verify it is still open + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + }); + + test('should focus trap when we use Shift+Tab', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[0]); + + // Try to Shift+Tab + await triggerKeyEvent(document.activeElement, 'keypress', 'Tab', { + shiftKey: true, + }); + + // Verify it is still open + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + }); + }); + + module('Listbox `ArrowDown` key', () => { + test('should be possible to open the listbox with ArrowDown', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowDown'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + + // Verify that the first listbox option is active + assertActiveListboxOption(options[0]); + }); + + test('should be possible to use ArrowDown to navigate the listbox options', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[0]); + + // We should be able to go down once + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowDown'); + assertActiveListboxOption(options[1]); + + // We should be able to go down again + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowDown'); + assertActiveListboxOption(options[2]); + + // We should NOT be able to go down again (because last option). Current implementation won't go around. + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowDown'); + assertActiveListboxOption(options[2]); + }); + + test('should be possible to use ArrowDown to navigate the listbox options and skip the first disabled one', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[1]); + + // We should be able to go down once + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowDown'); + assertActiveListboxOption(options[2]); + }); + + test('should be possible to use ArrowDown to navigate the listbox options and jump to the first non-disabled one', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[2]); + }); + }); + + module('Listbox `ArrowRight` key', () => { + test('should be possible to use ArrowRight to navigate the listbox options', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[0]); + + // We should be able to go right once + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowRight'); + assertActiveListboxOption(options[1]); + + // We should be able to go right again + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowRight'); + assertActiveListboxOption(options[2]); + + // We should NOT be able to go right again (because last option). Current implementation won't go around. + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowRight'); + assertActiveListboxOption(options[2]); + }); + }); + + module('Listbox `ArrowUp` key', () => { + test('should be possible to open the listbox with ArrowUp and the last option should be active', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + + // ! ALERT: The LAST option should now be active + assertActiveListboxOption(options[2]); + }); + + test('should not be possible to open the listbox with ArrowUp and the last option should be active when the button is disabled', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Try to open the listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + // Verify it is still closed + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + }); + + test('should be possible to open the listbox with ArrowUp, and focus the selected option', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option, i) => + assertListboxOption({ selected: i === 1 }, option) + ); + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]); + }); + + test('should have no active listbox option when there are no listbox options at all', async () => { + await render(hbs` + + Trigger + + + `); + + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + + assertNoActiveListboxOption(); + }); + + test('should be possible to use ArrowUp to navigate the listbox options and jump to the first non-disabled one', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[0]); + }); + + test('should not be possible to navigate up or down if there is only a single non-disabled option', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[2]); + + // We should not be able to go up (because those are disabled) + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + assertActiveListboxOption(options[2]); + + // We should not be able to go down (because this is the last option) + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowDown'); + assertActiveListboxOption(options[2]); + }); + + test('should be possible to use ArrowUp to navigate the listbox options', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[2]); + + // We should be able to go down once + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + assertActiveListboxOption(options[1]); + + // We should be able to go down again + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + assertActiveListboxOption(options[0]); + + // We should NOT be able to go up again (because first option). Current implementation won't go around. + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + assertActiveListboxOption(options[0]); + }); + }); + + module('Listbox `ArrowLeft` key', () => { + test('should be possible to use ArrowLeft to navigate the listbox options', async (assert) => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + orientation: 'horizontal', + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + assertActiveListboxOption(options[2]); + + // We should be able to go left once + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowLeft'); + assertActiveListboxOption(options[1]); + + // We should be able to go left again + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowLeft'); + assertActiveListboxOption(options[0]); + + // We should NOT be able to go left again (because first option). Current implementation won't go around. + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowLeft'); + assertActiveListboxOption(options[0]); + }); + }); + + module('Listbox `End` key', () => { + test('should be possible to use the End key to go to the last listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + let options = getListboxOptions(); + + // We should be on the first option + assertActiveListboxOption(options[0]); + + // We should be able to go to the last option + await triggerKeyEvent(document.activeElement, 'keyup', 'End'); + assertActiveListboxOption(options[2]); + }); + + test('should be possible to use the End key to go to the last non disabled listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + let options = getListboxOptions(); + + // We should be on the first option + assertActiveListboxOption(options[0]); + + // We should be able to go to the last non-disabled option + await triggerKeyEvent(document.activeElement, 'keyup', 'End'); + assertActiveListboxOption(options[1]); + }); + + test('should be possible to use the End key to go to the first listbox option if that is the only non-disabled listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'End'); + + let options = getListboxOptions(); + assertActiveListboxOption(options[0]); + }); + + test('should have no active listbox option upon End key press, when there are no non-disabled listbox options', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'End'); + + assertNoActiveListboxOption(); + }); + }); + + module('Listbox `PageDown` key', () => { + test('should be possible to use the PageDown key to go to the last listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + let options = getListboxOptions(); + + // We should be on the first option + assertActiveListboxOption(options[0]); + + // We should be able to go to the last option + await triggerKeyEvent(document.activeElement, 'keyup', 'PageDown'); + assertActiveListboxOption(options[2]); + }); + + test('should be possible to use the PageDown key to go to the last non disabled listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keypress', 'Enter'); + + let options = getListboxOptions(); + + // We should be on the first option + assertActiveListboxOption(options[0]); + + // We should be able to go to the last non-disabled option + await triggerKeyEvent(document.activeElement, 'keyup', 'PageDown'); + assertActiveListboxOption(options[1]); + }); + + test('should be possible to use the PageDown key to go to the first listbox option if that is the only non-disabled listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'PageDown'); + + let options = getListboxOptions(); + assertActiveListboxOption(options[0]); + }); + + test('should have no active listbox option upon PageDown key press, when there are no non-disabled listbox options', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'PageDown'); + + assertNoActiveListboxOption(); + }); + }); + + module('Listbox `Home` key', () => { + test('should be possible to use the Home key to go to the first listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + let options = getListboxOptions(); + + // We should be on the last option + assertActiveListboxOption(options[2]); + + // We should be able to go to the first option + await triggerKeyEvent(document.activeElement, 'keyup', 'Home'); + assertActiveListboxOption(options[0]); + }); + + test('should be possible to use the Home key to go to the first non disabled listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'Home'); + + let options = getListboxOptions(); + + // We should be on the first non-disabled option + assertActiveListboxOption(options[2]); + }); + + test('should be possible to use the Home key to go to the last listbox option if that is the only non-disabled listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'Home'); + + let options = getListboxOptions(); + assertActiveListboxOption(options[3]); + }); + + test('should have no active listbox option upon Home key press, when there are no non-disabled listbox options', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'Home'); + + assertNoActiveListboxOption(); + }); + }); + + module('Listbox `PageUp` key', () => { + test('should be possible to use the PageUp key to go to the first listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + let options = getListboxOptions(); + + // We should be on the last option + assertActiveListboxOption(options[2]); + + // We should be able to go to the first option + await triggerKeyEvent(document.activeElement, 'keyup', 'PageUp'); + assertActiveListboxOption(options[0]); + }); + + test('should be possible to use the PageUp key to go to the first non disabled listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'PageUp'); + + let options = getListboxOptions(); + + // We should be on the first non-disabled option + assertActiveListboxOption(options[2]); + }); + + test('should be possible to use the PageUp key to go to the last listbox option if that is the only non-disabled listbox option', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'PageUp'); + + let options = getListboxOptions(); + assertActiveListboxOption(options[3]); + }); + + test('should have no active listbox option upon PageUp key press, when there are no non-disabled listbox options', async () => { + await render(hbs` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `); + + // Open listbox + await click(getListboxButton()); + + // We opened via click, we don't have an active option + assertNoActiveListboxOption(); + + // We should not be able to go to the end + await triggerKeyEvent(document.activeElement, 'keyup', 'PageUp'); + + assertNoActiveListboxOption(); + }); + }); + + module('Listbox `Any` key aka search', () => { + test('should be possible to type a full word that has a perfect match', async () => { + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + // Open listbox + await click(getListboxButton()); + + let options = getListboxOptions(); + + // We should be able to go to the second option + await typeWord('bob'); + assertActiveListboxOption(options[1]); + + // We should be able to go to the first option + await typeWord('alice'); + assertActiveListboxOption(options[0]); + + // We should be able to go to the last option + await typeWord('charlie'); + assertActiveListboxOption(options[2]); + }); + + test('should be possible to type a partial of a word', async () => { + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + let options = getListboxOptions(); + + // We should be on the last option + assertActiveListboxOption(options[2]); + + // We should be able to go to the second option + await typeWord('bo'); + assertActiveListboxOption(options[1]); + + // We should be able to go to the first option + await typeWord('ali'); + assertActiveListboxOption(options[0]); + + // We should be able to go to the last option + await typeWord('char'); + assertActiveListboxOption(options[2]); + }); + + test('should be possible to type words with spaces', async () => { + await render(hbs` + + Trigger + + value a + value b + value c + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + let options = getListboxOptions(); + + // We should be on the last option + assertActiveListboxOption(options[2]); + + // We should be able to go to the second option + await typeWord('value b'); + assertActiveListboxOption(options[1]); + + // We should be able to go to the first option + await typeWord('value a'); + assertActiveListboxOption(options[0]); + + // We should be able to go to the last option + await typeWord('value c'); + assertActiveListboxOption(options[2]); + }); + + test('should not be possible to search for a disabled option', async () => { + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + let options = getListboxOptions(); + + // We should be on the last option + assertActiveListboxOption(options[2]); + + // We should not be able to go to the disabled option + await typeWord('bo'); + + // We should still be on the last option + assertActiveListboxOption(options[2]); + }); + + test('should be possible to search for a word (case insensitive)', async () => { + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + // Focus the button + getListboxButton()?.focus(); + + // Open listbox + await triggerKeyEvent(document.activeElement, 'keyup', 'ArrowUp'); + + let options = getListboxOptions(); + + // We should be on the last option + assertActiveListboxOption(options[2]); + + // Search for bob in a different casing + await typeWord('BO'); + + // We should be on `bob` + assertActiveListboxOption(options[1]); + }); + }); + + module('listbox mouse interactions', () => { + test('should focus the Listbox.Button when we click the Listbox.Label', async () => { + await render(hbs` + + Label + Trigger + + alice + bob + charlie + + + `); + + // Ensure the button is not focused yet + await assertActiveElement(document.body); + + // Focus the label + await click(getListboxLabel()); + + // Ensure that the actual button is focused instead + await assertActiveElement(getListboxButton()); + }); + + test('should not focus the Listbox.Button when we right click the Listbox.Label', async () => { + await render(hbs` + + Label + Trigger + + alice + bob + charlie + + + `); + + // Ensure the button is not focused yet + await assertActiveElement(document.body); + + // Focus the label + await click(getListboxLabel(), { button: 2 }); + + // Ensure that the body is still active + await assertActiveElement(document.body); + }); + + test('should be possible to open the listbox on click', async (assert) => { + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Open listbox + await click(getListboxButton()); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option) => assertListboxOption({}, option)); + }); + + test('should not be possible to open the listbox on right click', async () => { + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Try to open the listbox + await click(getListboxButton(), { button: 2 }); + + // Verify it is still closed + assertListboxButton({ state: ListboxState.InvisibleUnmounted }); + }); + + test('should not be possible to open the listbox on click when the button is disabled', async () => { + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Try to open the listbox + try { + await click(getListboxButton()); + } catch (e) { + // + } + + // Verify it is still closed + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + }); + + test('should be possible to open the listbox on click, and focus the selected option', async (assert) => { + await render(hbs` + + Trigger + + a + b + c + + + `); + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { 'data-test': 'headlessui-listbox-button-1' }, + }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Open listbox + await click(getListboxButton()); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + assertListbox({ + state: ListboxState.Visible, + attributes: { 'data-test': 'headlessui-listbox-options-1' }, + }); + await assertActiveElement(getListbox()); + assertListboxButtonLinkedWithListbox(); + + // Verify we have listbox options + let options = getListboxOptions(); + assert.equal(options.length, 3); + options.forEach((option, i) => + assertListboxOption({ selected: i === 1 }, option) + ); + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]); + }); + + test('should be possible to close a listbox on click', async () => { + await render(hbs` + + Trigger + + a + b + c + + + `); + + // Open listbox + await click(getListboxButton()); + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }); + + // Click to close + await click(getListboxButton()); + + // Verify it is closed + assertListboxButton({ state: ListboxState.InvisibleUnmounted }); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + }); + + test('should be a no-op when we click outside of a closed listbox', async () => { + await render(hbs` + + Trigger + + a + b + c + + + `); + + // Verify that the window is closed + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Click something that is not related to the listbox + await click(document.body); + + // Should still be closed + assertListbox({ state: ListboxState.InvisibleUnmounted }); + }); + + test('should be possible to click outside of the listbox which should close the listbox', async () => { + await render(hbs` + + Trigger + + a + b + c + + + `); + + // Open listbox + await click(getListboxButton()); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + + // Click something that is not related to the listbox + await click(document.body); + + // Should be closed now + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Verify the button is focused again + await assertActiveElement(getListboxButton()); + }); + + test('should be possible to click outside of the listbox on another listbox button which should close the current listbox and open the new listbox', async (assert) => { + await render(hbs` +
      + + Trigger + + a + b + c + + + + Trigger + + a + b + c + + +
      + `); + + let [button1, button2] = getListboxButtons(); + + // Click the first listbox button + await click(button1); + assert.equal(getListboxes().length, 1); // Only 1 listbox should be visible + + // Ensure the open listbox is linked to the first button + assertListboxButtonLinkedWithListbox(button1, getListbox()); + + // Click the second listbox button + await click(button2); + + assert.equal(getListboxes().length, 1); // Only 1 listbox should be visible + + // Ensure the open listbox is linked to the second button + assertListboxButtonLinkedWithListbox(button2, getListbox()); + }); + + test('should be possible to click outside of the listbox which should close the listbox (even if we press the listbox button)', async () => { + await render(hbs` + + Trigger + + a + b + c + + + `); + + // Open listbox + await click(getListboxButton()); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + + // Click the listbox button again + await click(getListboxButton()); + + // Should be closed now + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Verify the button is focused again + await assertActiveElement(getListboxButton()); + }); + + skip('should be possible to click outside of the listbox, on an element which is within a focusable element, which closes the listbox', async function (assert) { + let callCount = 0; + + this.set('handleFocus', () => { + callCount++; + }); + + await render(hbs` +
      + + Trigger + + a + b + c + + + +
      + `); + + // Click the listbox button + await click(getListboxButton()); + + // Ensure the listbox is open + assertListbox({ state: ListboxState.Visible }); + + // Click the span inside the button + await click(document.querySelector('#btn span')); + + // Ensure the listbox is closed + assertListbox({ state: ListboxState.InvisibleUnmounted }); + + // Ensure the outside button is focused + await assertActiveElement(document.getElementById('btn')); + + // Ensure that the focus button only got focus once (first click) + assert.equal(callCount, 1, 'handleFocus called once exactly'); + }); + + test('should be possible to hover an option and make it active', async () => { + await render(hbs` +
      + + Trigger + + a + b + c + + + +
      + `); + + // Open listbox + await click(getListboxButton()); + + let options = getListboxOptions(); + // We should be able to go to the second option + await triggerEvent(options[1], 'mouseover'); + assertActiveListboxOption(options[1]); + + // We should be able to go to the first option + await triggerEvent(options[0], 'mouseover'); + assertActiveListboxOption(options[0]); + + // We should be able to go to the last option + await triggerEvent(options[2], 'mouseover'); + assertActiveListboxOption(options[2]); + }); + + test('should make a listbox option active when you move the mouse over it', async () => { + await render(hbs` +
      + + Trigger + + a + b + c + + + +
      + `); + + // Open listbox + await click(getListboxButton()); + + let options = getListboxOptions(); + // We should be able to go to the second option + await triggerEvent(options[1], 'mouseover'); + assertActiveListboxOption(options[1]); + }); + + test('should be a no-op when we move the mouse and the listbox option is already active', async () => { + await render(hbs` +
      + + Trigger + + a + b + c + + + +
      + `); + + // Open listbox + await click(getListboxButton()); + + let options = getListboxOptions(); + + // We should be able to go to the second option + await triggerEvent(options[1], 'mouseover'); + assertActiveListboxOption(options[1]); + + await triggerEvent(options[1], 'mouseover'); + + // Nothing should be changed + assertActiveListboxOption(options[1]); + }); + + test('should be a no-op when we move the mouse and the listbox option is disabled', async () => { + await render(hbs` +
      + + Trigger + + a + b + c + + + +
      + `); + + // Open listbox + await click(getListboxButton()); + + let options = getListboxOptions(); + + await triggerEvent(options[1], 'mouseover'); + assertNoActiveListboxOption(); + }); + + test('should not be possible to hover an option that is disabled', async () => { + await render(hbs` +
      + + Trigger + + a + b + c + + + +
      + `); + + // Open listbox + await click(getListboxButton()); + + let options = getListboxOptions(); + + // Try to hover over option 1, which is disabled + await triggerEvent(options[1], 'mouseover'); + + // We should not have an active option now + assertNoActiveListboxOption(); + }); + + test('should be possible to mouse leave an option and make it inactive', async () => { + await render(hbs` +
      + + Trigger + + a + b + c + + + +
      + `); + + // Open listbox + await click(getListboxButton()); + + let options = getListboxOptions(); + + // We should be able to go to the second option + await triggerEvent(options[1], 'mouseover'); + assertActiveListboxOption(options[1]); + + await triggerEvent(options[1], 'mouseout'); + assertNoActiveListboxOption(); + + // We should be able to go to the first option + await triggerEvent(options[0], 'mouseover'); + assertActiveListboxOption(options[0]); + + await triggerEvent(options[0], 'mouseout'); + assertNoActiveListboxOption(); + + // We should be able to go to the last option + await triggerEvent(options[2], 'mouseover'); + assertActiveListboxOption(options[2]); + + await triggerEvent(options[2], 'mouseout'); + assertNoActiveListboxOption(); + }); + + test('should be possible to mouse leave a disabled option and be a no-op', async () => { + await render(hbs` +
      + + Trigger + + a + b + c + + + +
      + `); + + // Open listbox + await click(getListboxButton()); + + let options = getListboxOptions(); + + // Try to hover over option 1, which is disabled + await triggerEvent(options[1], 'mouseover'); + assertNoActiveListboxOption(); + + await triggerEvent(options[1], 'mouseout'); + assertNoActiveListboxOption(); + }); + + test('should be possible to click a listbox option, which closes the listbox', async function (assert) { + let callValue = '', + callCount = 0; + + this.set('onChange', (value) => { + this.set('selectedOption', value); + callValue = value; + callCount++; + }); + + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + // Open listbox + await click(getListboxButton()); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + + let options = getListboxOptions(); + + // We should be able to click the first option + await click(options[1]); + assertListbox({ state: ListboxState.InvisibleUnmounted }); + assert.equal(callCount, 1, 'handleChange called once exactly'); + assert.equal(callValue, 'bob', 'handleChange called with "bob"'); + + // Verify the button is focused again + await assertActiveElement(getListboxButton()); + + // Open listbox again + await click(getListboxButton()); + + // Verify the active option is the previously selected one + assertActiveListboxOption(getListboxOptions()[1]); + }); + + test('should be possible to click a disabled listbox option, which is a no-op', async function (assert) { + let callCount = 0; + + this.set('onChange', (value) => { + this.set('selectedOption', value); + callCount++; + }); + + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + // Open listbox + await click(getListboxButton()); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + + let options = getListboxOptions(); + + // We should be able to click the first option + await click(options[1]); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + assert.equal(callCount, 0, 'handleChange not called'); + + // Close the listbox + await click(getListboxButton()); + + // Open listbox again + await click(getListboxButton()); + + // Verify the active option is non existing + assertNoActiveListboxOption(); + }); + + test('should be possible focus a listbox option, so that it becomes active', async () => { + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + // Open listbox + await click(getListboxButton()); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + + let options = getListboxOptions(); + + // Verify that nothing is active yet + assertNoActiveListboxOption(); + + // We should be able to focus the first option + await focus(options[1]); + assertActiveListboxOption(options[1]); + }); + + test('should not be possible to focus a listbox option which is disabled', async () => { + await render(hbs` + + Trigger + + alice + bob + charlie + + + `); + + // Open listbox + await click(getListboxButton()); + assertListbox({ state: ListboxState.Visible }); + await assertActiveElement(getListbox()); + + let options = getListboxOptions(); + + // We should not be able to focus the first option + await focus(options[1]); + assertNoActiveListboxOption(); + }); + }); +});