Skip to content

Commit

Permalink
✨ Implement <Listbox /> component (#76)
Browse files Browse the repository at this point in the history
* 📦 Use ember-sinon

* ✅ Implement <Listbox> tests

* ✨ Implement <Listbox> component

* ✨ Add basic <Listbox> 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
  • Loading branch information
dmcnamara-eng authored Sep 3, 2021
1 parent afe523f commit 653a407
Show file tree
Hide file tree
Showing 19 changed files with 4,222 additions and 0 deletions.
47 changes: 47 additions & 0 deletions addon/components/listbox.hbs
Original file line number Diff line number Diff line change
@@ -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
)
)
}}
298 changes: 298 additions & 0 deletions addon/components/listbox.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
18 changes: 18 additions & 0 deletions addon/components/listbox/-button.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{{#let (element (or @as 'button')) as |Tag|}}
<Tag
role={{if @role @role 'button'}}
id='{{@guid}}-button'
aria-haspopup='listbox'
aria-controls={{@guid}}
aria-labelledby='{{@guid}}-label {{@guid}}-button'
{{on 'click' @handleButtonClick}}
{{on 'keyup' @handleKeyUp}}
{{on 'keypress' @handleKeyPress}}
aria-expanded={{unless @isDisabled (if @isOpen 'true' 'false')}}
disabled={{@isDisabled}}
{{did-insert @registerButtonElement}}
...attributes
>
{{yield}}
</Tag>
{{/let}}
11 changes: 11 additions & 0 deletions addon/components/listbox/-label.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{#let (element (or @as 'label')) as |Tag|}}
<Tag
id='{{@guid}}-label'
for='{{@guid}}-button'
{{on 'click' @handleLabelClick}}
{{did-insert @registerLabelElement}}
...attributes
>
{{yield}}
</Tag>
{{/let}}
Loading

0 comments on commit 653a407

Please sign in to comment.