From 56da301f99555c9582810c004816d17a81d8d08b Mon Sep 17 00:00:00 2001 From: Michael Nutt Date: Wed, 26 Aug 2026 18:04:11 -0500 Subject: [PATCH 1/2] fix(fluid-select)!: make options operable by keyboard FluidSelect is built on ember-basic-dropdown, whose trigger handles only Enter, Space and Escape. There was no arrow-key navigation into the popup and the options were divs with role="button", no tabindex and no key handler, so a keyboard user could open the dropdown and then select nothing. Multi-select escaped this by accident by rendering FluidCheckbox, a real button. Implements the APG listbox pattern for both modes: the list is a role=listbox that takes focus on open, options are role=option with aria-selected, and arrow keys, Home/End, Enter, Space and Escape drive an active option tracked with aria-activedescendant. Escape restores focus to whatever opened it. The active option is written straight to the DOM rather than held in component state, because re-rendering the popup makes ember-basic-dropdown recompute its position, which re-renders the popup. Also gives the search input an accessible name and replaces role="search", which is a landmark role and invalid on an input, with role="combobox". BREAKING CHANGE: multi-select options are no longer a FluidCheckbox. The checkbox is now presentational markup inside the role=option element, which owns the click handler, so no `[role="checkbox"]` element exists in the list. The bundled page object is updated to match: every option is clicked the same way and `hasCheckbox` matches a descendant. Consuming tests that reach for `[role="checkbox"]` inside a FluidSelect option need updating. --- addon-test-support/pages/fluid-select.js | 19 +- addon/components/fluid-select.hbs | 12 +- addon/components/fluid-select.js | 167 ++++++++++++++++ addon/components/fluid-select/list.hbs | 13 +- .../fluid-select/option-checkbox.hbs | 18 ++ .../fluid-select/option-checkbox.js | 3 + addon/components/fluid-select/option.hbs | 77 +++----- addon/components/fluid-select/option.js | 3 + addon/components/fluid-select/search.hbs | 7 +- addon/components/fluid-select/trigger.hbs | 3 +- .../fluid-select/option-checkbox.js | 1 + .../components/fluid-select-test.js | 187 +++++++++++++++++- 12 files changed, 445 insertions(+), 65 deletions(-) create mode 100644 addon/components/fluid-select/option-checkbox.hbs create mode 100644 addon/components/fluid-select/option-checkbox.js create mode 100644 app/components/fluid-select/option-checkbox.js diff --git a/addon-test-support/pages/fluid-select.js b/addon-test-support/pages/fluid-select.js index 43df2cd..e7e4494 100644 --- a/addon-test-support/pages/fluid-select.js +++ b/addon-test-support/pages/fluid-select.js @@ -1,9 +1,11 @@ import { findElementWithAssert, + attribute, create, collection, fillable, hasClass, + isVisible, property, text, } from 'ember-cli-page-object'; @@ -79,21 +81,16 @@ export const FluidSelect = { list: { scope: '.fluid-select__list', + activeDescendant: attribute('aria-activedescendant'), + options: collection('.fluid-select__option', { - hasCheckbox: hasClass('fluid-checkbox'), + hasCheckbox: isVisible('.fluid-checkbox'), isSelected: hasClass('fluid-select__option--selected'), + ariaSelected: attribute('aria-selected'), + id: attribute('id'), click() { - // If the list option is a `FluidCheckbox`, then we actually want to click the checkbox within the option - // Otherwise, click the option's element itself - if (this.hasCheckbox) { - const checkbox = findOne(this, '[role="checkbox"]'); - checkbox.click(); - } else { - const self = findOne(this); - self.click(); - } - + findOne(this).click(); return settled(); }, }), diff --git a/addon/components/fluid-select.hbs b/addon/components/fluid-select.hbs index 1161239..ad659b6 100644 --- a/addon/components/fluid-select.hbs +++ b/addon/components/fluid-select.hbs @@ -2,8 +2,8 @@ id === this.activeOptionId) ?? null; + } + + @action + registerList(element) { + this.listElement = element; + + const popup = element.closest('.ember-basic-dropdown-content') ?? element.parentElement; + + if (!popup?.contains(document.activeElement)) { + element.focus({ preventScroll: true }); + } + } + + /** + * Options report themselves rather than the list enumerating them: `list` renders them + * through the `await` helper, so none of them exist yet when the list is inserted. + */ + @action + registerOption(element) { + if (element.getAttribute('aria-selected') === 'true' || !this.activeOptionId) { + this.activateOption(element); + } + } + + @action + handleOpen(dropdown, event) { + this.dropdownApi = dropdown; + this.previouslyFocusedElement = document.activeElement; + + if (this.onOpen) { + this.onOpen(dropdown, event); + } + } + + @action + handleClose(dropdown, event) { + this.dropdownApi = null; + this.activeOptionId = null; + this.listElement = null; + + const previous = this.previouslyFocusedElement; + this.previouslyFocusedElement = null; + + if (previous && previous.isConnected) { + previous.focus(); + } + + if (this.onClose) { + this.onClose(dropdown, event); + } + } + + /** + * Written straight to the DOM. Routing the active option through the template + * re-renders the popup, which makes ember-basic-dropdown recompute its position, + * which re-renders the popup — an unbreakable loop. + */ + activateOption(option) { + this.activeOption?.classList.remove(HIGHLIGHT_CLASS); + this.activeOptionId = option?.id ?? null; + + // Whichever element holds focus is the one that must carry the pointer: the search + // input when there is one, otherwise the listbox itself. + const popup = this.listElement?.closest('.ember-basic-dropdown-content'); + + for (const host of [this.listElement, popup?.querySelector('[role="combobox"]')]) { + if (!host) { + continue; + } else if (this.activeOptionId) { + host.setAttribute('aria-activedescendant', this.activeOptionId); + } else { + host.removeAttribute('aria-activedescendant'); + } + } + + if (option) { + option.classList.add(HIGHLIGHT_CLASS); + option.scrollIntoView({ block: 'nearest' }); + } + } + + moveActiveOption(offset) { + const options = this.optionElements; + + if (!options.length) { + return; + } + + const current = options.indexOf(this.activeOption); + const next = current === -1 ? 0 : (current + offset + options.length) % options.length; + + this.activateOption(options[next]); + } + + activateOptionAt(index) { + const options = this.optionElements; + this.activateOption(index < 0 ? options[options.length - 1] : options[index]); + } + + selectActiveOption() { + this.activeOption?.click(); + } + + @action + handleKeyDown(event) { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.moveActiveOption(1); + break; + case 'ArrowUp': + event.preventDefault(); + this.moveActiveOption(-1); + break; + case 'Home': + event.preventDefault(); + this.activateOptionAt(0); + break; + case 'End': + event.preventDefault(); + this.activateOptionAt(-1); + break; + case 'Enter': + event.preventDefault(); + this.selectActiveOption(); + break; + case ' ': + // Never in the search input, where the space bar has to keep typing spaces. + if (event.target.tagName !== 'INPUT') { + event.preventDefault(); + this.selectActiveOption(); + } + break; + case 'Escape': + event.preventDefault(); + this.dropdownApi?.actions.close(event); + break; + default: + break; + } + } + @action updateSearchQuery(query) { set(this, 'searchQuery', query); diff --git a/addon/components/fluid-select/list.hbs b/addon/components/fluid-select/list.hbs index 8f518e7..23aa147 100644 --- a/addon/components/fluid-select/list.hbs +++ b/addon/components/fluid-select/list.hbs @@ -1,4 +1,13 @@ -
+
{{#if (has-block)}} {{yield @options @select}} {{else if (or @loading (is-pending @options))}} @@ -27,6 +36,7 @@ @selected={{@selected}} @multiple={{@multiple}} @labelPath={{@labelPath}} + @onRegister={{@onOptionRegister}} @select={{action @select}} /> {{/each}} @@ -38,6 +48,7 @@ @selected={{@selected}} @multiple={{@multiple}} @labelPath={{@labelPath}} + @onRegister={{@onOptionRegister}} @select={{action @select}} /> {{/if}} diff --git a/addon/components/fluid-select/option-checkbox.hbs b/addon/components/fluid-select/option-checkbox.hbs new file mode 100644 index 0000000..de09777 --- /dev/null +++ b/addon/components/fluid-select/option-checkbox.hbs @@ -0,0 +1,18 @@ +{{! Deliberately not a FluidCheckbox: the enclosing `[role="option"]` owns the semantics, + and a real checkbox inside it would be a second focus stop with a conflicting role. }} + + + + + {{@label}} + + \ No newline at end of file diff --git a/addon/components/fluid-select/option-checkbox.js b/addon/components/fluid-select/option-checkbox.js new file mode 100644 index 0000000..fa85053 --- /dev/null +++ b/addon/components/fluid-select/option-checkbox.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/fluid-select/option.hbs b/addon/components/fluid-select/option.hbs index 402a525..f8dd8c6 100644 --- a/addon/components/fluid-select/option.hbs +++ b/addon/components/fluid-select/option.hbs @@ -1,57 +1,38 @@ -{{#if (and (has-block) @multiple)}} - {{yield - (hash - checkbox=(component - "fluid-checkbox" - defaultClass=(concat - "pl-4 pr-6 fluid-select__option" - (if @dark " fluid-select__option--dark") - (if this.isSelected this.selectedClass) - ) - label=this.optionLabel - checked=this.isSelected - onchange=(action @select @option) - ) - ) - }} -{{else if (has-block)}} +{{#let (and (not (has-block)) (not @multiple)) as |isPlainOption|}}
- {{yield}} -
-{{else if @multiple}} - -{{else}} -
- - {{this.optionLabel}} - + {{#if (and (has-block) @multiple)}} + {{yield + (hash + checkbox=(component + "fluid-select/option-checkbox" label=this.optionLabel checked=this.isSelected + ) + ) + }} + {{else if (has-block)}} + {{yield}} + {{else if @multiple}} + + {{else}} + + {{this.optionLabel}} + + {{/if}}
-{{/if}} \ No newline at end of file +{{/let}} \ No newline at end of file diff --git a/addon/components/fluid-select/option.js b/addon/components/fluid-select/option.js index 390d9c8..1fe4b10 100644 --- a/addon/components/fluid-select/option.js +++ b/addon/components/fluid-select/option.js @@ -1,9 +1,12 @@ import Component from '@ember/component'; import { computed, get } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; export default class FluidSelectOption extends Component { tagName = ''; + optionId = `${guidFor(this)}-option`; + @computed('option', 'labelPath') get optionLabel() { const path = get(this, 'labelPath'); diff --git a/addon/components/fluid-select/search.hbs b/addon/components/fluid-select/search.hbs index b543141..d313d3d 100644 --- a/addon/components/fluid-select/search.hbs +++ b/addon/components/fluid-select/search.hbs @@ -17,10 +17,15 @@
diff --git a/addon/components/fluid-select/trigger.hbs b/addon/components/fluid-select/trigger.hbs index c640997..c313df0 100644 --- a/addon/components/fluid-select/trigger.hbs +++ b/addon/components/fluid-select/trigger.hbs @@ -6,12 +6,13 @@ (concat " ember-basic-dropdown-trigger--" @hPosition) }}{{if @vPosition (concat " ember-basic-dropdown-trigger--" @vPosition)}} {{@defaultClass}}" - role="button" type="button" tabindex={{unless @dropdown.disabled "0"}} data-ebd-id="{{@dropdown.uniqueId}}-trigger" data-test-fluid-select-trigger aria-owns="ember-basic-dropdown-content-{{@dropdown.uniqueId}}" + aria-haspopup="listbox" + aria-controls={{if @dropdown.isOpen @listId}} aria-expanded={{if @dropdown.isOpen "true"}} aria-disabled={{if @dropdown.disabled "true"}} disabled={{@disabled}} diff --git a/app/components/fluid-select/option-checkbox.js b/app/components/fluid-select/option-checkbox.js new file mode 100644 index 0000000..00e95b6 --- /dev/null +++ b/app/components/fluid-select/option-checkbox.js @@ -0,0 +1 @@ +export { default } from '@movable/fluid/components/fluid-select/option-checkbox'; diff --git a/tests/integration/components/fluid-select-test.js b/tests/integration/components/fluid-select-test.js index 31d04a6..832b9f9 100644 --- a/tests/integration/components/fluid-select-test.js +++ b/tests/integration/components/fluid-select-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { settled, findAll, click, render } from '@ember/test-helpers'; +import { settled, findAll, find, click, render, triggerKeyEvent } from '@ember/test-helpers'; import { A } from '@ember/array'; import { hbs } from 'ember-cli-htmlbars'; import component from '@movable/fluid/test-support/pages/fluid-select'; @@ -545,6 +545,191 @@ module('Integration | Component | fluid-select', function (hooks) { }); }); + module('keyboard navigation', function () { + const LISTBOX = '[role="listbox"]'; + + test('the popup exposes listbox semantics', async function (assert) { + await render( + hbs`` + ); + await component.open(); + + assert.dom(LISTBOX).exists('the list is a listbox'); + assert + .dom('[data-test-fluid-select-trigger]') + .hasAria('haspopup', 'listbox', 'the trigger advertises the popup'); + assert.strictEqual( + component.popup.list.options.length, + findAll('[role="option"]').length, + 'every option carries role="option"' + ); + assert.strictEqual( + component.popup.list.options[0].ariaSelected, + 'false', + 'unselected options report aria-selected="false"' + ); + }); + + test('the listbox takes focus on open and points at the first option', async function (assert) { + await render( + hbs`` + ); + await component.open(); + + assert.dom(LISTBOX).isFocused('focus moves into the popup'); + assert.strictEqual( + component.popup.list.activeDescendant, + component.popup.list.options[0].id, + 'the first option is active' + ); + }); + + test('the arrow keys move the active option and wrap', async function (assert) { + await render( + hbs`` + ); + await component.open(); + + const { options } = component.popup.list; + + await triggerKeyEvent(LISTBOX, 'keydown', 'ArrowDown'); + assert.strictEqual( + component.popup.list.activeDescendant, + options[1].id, + 'down moves forward' + ); + + await triggerKeyEvent(LISTBOX, 'keydown', 'ArrowUp'); + await triggerKeyEvent(LISTBOX, 'keydown', 'ArrowUp'); + assert.strictEqual( + component.popup.list.activeDescendant, + options[options.length - 1].id, + 'up from the first option wraps to the last' + ); + + await triggerKeyEvent(LISTBOX, 'keydown', 'Home'); + assert.strictEqual(component.popup.list.activeDescendant, options[0].id, 'Home goes first'); + + await triggerKeyEvent(LISTBOX, 'keydown', 'End'); + assert.strictEqual( + component.popup.list.activeDescendant, + options[options.length - 1].id, + 'End goes last' + ); + }); + + test('the active option is visibly highlighted', async function (assert) { + await render( + hbs`` + ); + await component.open(); + + await triggerKeyEvent(LISTBOX, 'keydown', 'ArrowDown'); + + const active = find(`#${component.popup.list.activeDescendant}`); + assert.dom(active).hasClass('fluid-select__option--highlighted'); + assert.notEqual( + window.getComputedStyle(active).backgroundColor, + window.getComputedStyle(active.previousElementSibling).backgroundColor, + 'the highlight is actually rendered, not just a class name' + ); + }); + + test('Enter selects the active option and closes', async function (assert) { + await render( + hbs`` + ); + await component.open(); + + await triggerKeyEvent(LISTBOX, 'keydown', 'ArrowDown'); + await triggerKeyEvent(LISTBOX, 'keydown', 'Enter'); + + assert.strictEqual(this.get('selected'), 'banana', 'the active option was selected'); + assert.ok(component.popup.isHidden, 'the popup closed'); + }); + + test('Escape closes without selecting and restores focus to the trigger', async function (assert) { + await render( + hbs`` + ); + await component.open(); + + await triggerKeyEvent(LISTBOX, 'keydown', 'ArrowDown'); + await triggerKeyEvent(LISTBOX, 'keydown', 'Escape'); + + assert.strictEqual(this.get('selected'), null, 'nothing was selected'); + assert.ok(component.popup.isHidden, 'the popup closed'); + assert.dom('[data-test-fluid-select-trigger]').isFocused('focus returned to the trigger'); + }); + + test('the already-selected option starts active', async function (assert) { + this.set('selected', 'orange'); + await render( + hbs`` + ); + await component.open(); + + assert.strictEqual( + component.popup.list.activeDescendant, + component.popup.list.options[2].id, + 'the active option is the selected one, not the first' + ); + assert.strictEqual(component.popup.list.options[2].ariaSelected, 'true'); + }); + + test('multi-select toggles with Space and stays open', async function (assert) { + this.set('selected', A([])); + this.set('select', (value) => this.set('selected', A([...this.get('selected'), value]))); + + await render(hbs` + + `); + await component.open(); + + assert.dom(LISTBOX).hasAria('multiselectable', 'true'); + + await triggerKeyEvent(LISTBOX, 'keydown', ' '); + assert.deepEqual(this.get('selected').slice(), ['apple'], 'Space selected the active option'); + assert.ok(component.popup.isVisible, 'the popup stayed open'); + + await triggerKeyEvent(LISTBOX, 'keydown', 'ArrowDown'); + await triggerKeyEvent(LISTBOX, 'keydown', ' '); + assert.deepEqual( + this.get('selected').slice(), + ['apple', 'banana'], + 'a second option selected' + ); + }); + + test('the search input is a labelled combobox that drives the listbox', async function (assert) { + this.set('search', (term) => this.get('options').filter((o) => o.includes(term))); + + await render(hbs` + + `); + await component.open(); + + const input = find('.fluid-select__search input'); + + assert.dom(input).hasAria('label', 'Search options', 'the input has an accessible name'); + assert + .dom(input) + .hasAttribute('role', 'combobox', 'role="search", invalid on an input, is gone'); + assert.dom(input).hasAria('controls', find(LISTBOX).id, 'the combobox owns the listbox'); + assert.dom(input).isFocused('the search input takes focus, not the listbox'); + + await triggerKeyEvent(input, 'keydown', 'ArrowDown'); + assert.strictEqual( + component.popup.list.activeDescendant, + component.popup.list.options[1].id, + 'arrow keys forwarded from the input move the active option' + ); + + await triggerKeyEvent(input, 'keydown', 'Enter'); + assert.strictEqual(this.get('selected'), 'banana', 'Enter from the input selects'); + }); + }); + test('it can render ellipsis with block [ch53009]', async function (assert) { this.label = 'Metus molestie condimentum elit cursus magna primis velit imperdiet'; await render(hbs` From 837511b68261a0b3b3a11ae090c31055018b3194 Mon Sep 17 00:00:00 2001 From: Michael Nutt Date: Tue, 1 Sep 2026 15:07:10 -0500 Subject: [PATCH 2/2] fix(fluid-select): keep the yielded checkbox's block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checkbox `option` yields used to be a real FluidCheckbox, so consumers name their options with a block and pass no @label. OptionCheckbox rendered only @label, so those options came out with no text at all — no accessible name, and nothing for a page object to match on. Consumer classes were dropped too, since the component read @class but never applied ...attributes. Render the block when there is one, fall back to @label, and splat attributes. The existing block-mode coverage only exercised the @label form, which is why this got through; the new test uses a block and no label. --- .../fluid-select/option-checkbox.hbs | 10 +++- .../components/fluid-select-test.js | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/addon/components/fluid-select/option-checkbox.hbs b/addon/components/fluid-select/option-checkbox.hbs index de09777..38193ac 100644 --- a/addon/components/fluid-select/option-checkbox.hbs +++ b/addon/components/fluid-select/option-checkbox.hbs @@ -1,6 +1,6 @@ {{! Deliberately not a FluidCheckbox: the enclosing `[role="option"]` owns the semantics, and a real checkbox inside it would be a second focus stop with a conflicting role. }} - + + {{! The checkbox FluidSelect used to yield was a real FluidCheckbox, which rendered a + block. Consumers pass their own markup and no @label, so keep honouring the block. }} - {{@label}} + {{#if (has-block)}} + {{yield}} + {{else}} + {{@label}} + {{/if}} \ No newline at end of file diff --git a/tests/integration/components/fluid-select-test.js b/tests/integration/components/fluid-select-test.js index 832b9f9..d180f26 100644 --- a/tests/integration/components/fluid-select-test.js +++ b/tests/integration/components/fluid-select-test.js @@ -270,6 +270,52 @@ module('Integration | Component | fluid-select', function (hooks) { assert.equal(component.popup.list.selectedOptions.length, 2); }); + test('the yielded checkbox renders its own block', async function (assert) { + // The checkbox this used to yield was a real FluidCheckbox, and consumers name + // their options with a block instead of @label. Dropping the block leaves the + // option with no accessible name and nothing for a page object to match on. + await render(hbs` + + + + {{#each options as |option|}} + + + {{option}} + + + {{/each}} + + + `); + + await component.open(); + + assert.equal( + component.popup.list.options[0].text, + this.get('options')[0], + 'the option is named by the block, with no @label passed' + ); + assert + .dom('.fluid-select__option .consumer-block') + .exists({ count: this.get('options').length }, 'every option renders its block'); + assert + .dom('.fluid-select__option .fluid-checkbox.consumer-class') + .exists({ count: this.get('options').length }, 'attributes reach the checkbox'); + }); + test('checkboxes', async function (assert) { await render(hbs`