Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions addon-test-support/pages/fluid-select.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
findElementWithAssert,
attribute,
create,
collection,
fillable,
hasClass,
isVisible,
property,
text,
} from 'ember-cli-page-object';
Expand Down Expand Up @@ -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();
},
}),
Expand Down
12 changes: 10 additions & 2 deletions addon/components/fluid-select.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<BasicDropdown
class="w-full"
@renderInPlace={{@renderInPlace}}
@onOpen={{action (optional @onOpen)}}
@onClose={{action (optional @onClose)}}
@onOpen={{this.handleOpen}}
@onClose={{this.handleClose}}
@triggerComponent={{component "fluid-select/trigger"}}
@matchTriggerWidth={{@matchTriggerWidth}}
as |dropdown|
Expand Down Expand Up @@ -34,12 +34,15 @@
selected=@selected
disabled=@disabled
label=@label
listId=this.listId
)
search=(component
"fluid-select/search"
searchQuery=this.searchQuery
dark=@dark
loading=this.searchLoading
listId=this.listId
onKeyDown=this.handleKeyDown
search=(queue (action this.updateSearchQuery) (action (perform this.searchTask)))
)
list=(component
Expand All @@ -49,6 +52,10 @@
loading=@loading
multiple=@multiple
labelPath=@labelPath
listId=this.listId
onRegister=this.registerList
onOptionRegister=this.registerOption
onKeyDown=this.handleKeyDown
options=(if (and this.lastSearch this.searchQuery) this.lastSearch.value @options)
select=(if @multiple (action this.updateSelected) (action selectAndClose))
)
Expand All @@ -58,6 +65,7 @@
dark=@dark
labelPath=@labelPath
selected=@selected
onRegister=this.registerOption
select=(if @multiple (action this.updateSelected) (action selectAndClose))
)
open=(action dropdown.actions.open)
Expand Down
167 changes: 167 additions & 0 deletions addon/components/fluid-select.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import Component from '@ember/component';
import { action, set } from '@ember/object';
import { reads } from '@ember/object/computed';
import { guidFor } from '@ember/object/internals';
import { restartableTask } from 'ember-concurrency';
import { warn } from '@ember/debug';

const OPTION_SELECTOR = '[role="option"]:not([aria-disabled="true"])';
const HIGHLIGHT_CLASS = 'fluid-select__option--highlighted';

export default class FluidSelect extends Component {
tagName = '';

Expand All @@ -12,6 +16,13 @@ export default class FluidSelect extends Component {

searchQuery = '';

listId = `${guidFor(this)}-listbox`;

activeOptionId = null;
listElement = null;
previouslyFocusedElement = null;
dropdownApi = null;

@restartableTask
searchTask = function* (searchTerm) {
if (searchTerm == null || searchTerm === '') {
Expand All @@ -23,6 +34,162 @@ export default class FluidSelect extends Component {
}
};

/**
* Options are consumer-composable — `list` yields its own block and the popup can be
* assembled by hand — so read them back out of the DOM rather than tracking them.
*/
get optionElements() {
if (!this.listElement) {
return [];
}

return Array.from(this.listElement.querySelectorAll(OPTION_SELECTOR));
}

get activeOption() {
return this.optionElements.find(({ id }) => 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);
Expand Down
13 changes: 12 additions & 1 deletion addon/components/fluid-select/list.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
<div class="fluid-select__list py-2" ...attributes>
<div
class="fluid-select__list py-2"
role="listbox"
id={{@listId}}
tabindex="-1"
aria-multiselectable={{if @multiple "true"}}
...attributes
{{did-insert @onRegister}}
{{on "keydown" @onKeyDown}}
>
{{#if (has-block)}}
{{yield @options @select}}
{{else if (or @loading (is-pending @options))}}
Expand Down Expand Up @@ -27,6 +36,7 @@
@selected={{@selected}}
@multiple={{@multiple}}
@labelPath={{@labelPath}}
@onRegister={{@onOptionRegister}}
@select={{action @select}}
/>
{{/each}}
Expand All @@ -38,6 +48,7 @@
@selected={{@selected}}
@multiple={{@multiple}}
@labelPath={{@labelPath}}
@onRegister={{@onOptionRegister}}
@select={{action @select}}
/>
{{/if}}
Expand Down
24 changes: 24 additions & 0 deletions addon/components/fluid-select/option-checkbox.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{{! 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. }}
<span class="fluid-checkbox {{if @checked "fluid-checkbox--checked"}} {{@class}}" ...attributes>
<span
class="fluid-checkbox__box border-2 {{if @checked "fluid-checkbox__box--checked"}}"
aria-hidden="true"
>
<span class="fluid-checkbox__inner-box w-full h-full">
{{#if @checked}}
{{svg-jar "check" class="fluid-checkbox__check"}}
{{/if}}
</span>
</span>

{{! 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. }}
<span class="fluid-checkbox__label flex-grow">
{{#if (has-block)}}
{{yield}}
{{else}}
{{@label}}
{{/if}}
</span>
</span>
3 changes: 3 additions & 0 deletions addon/components/fluid-select/option-checkbox.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import templateOnly from '@ember/component/template-only';

export default templateOnly();
Loading
Loading