Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Implement <Listbox /> component #76

Merged
merged 20 commits into from Sep 3, 2021
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
45 changes: 45 additions & 0 deletions addon/components/listbox.hbs
@@ -0,0 +1,45 @@
{{yield
(hash
isOpen=this.isOpen
disabled=this.isDisabled
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
handleKeyDown=this.handleKeyDown
openListbox=this.openListbox
closeListbox=this.closeListbox
)
Button=(component
'listbox/-button'
guid=this.guid
isOpen=this.isOpen
registerButtonElement=this.registerButtonElement
handleButtonClick=this.handleButtonClick
handleKeyPress=this.handleKeyPress
handleKeyDown=this.handleKeyDown
handleClickOutside=this.handleClickOutside
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
)
)
}}
306 changes: 306 additions & 0 deletions addon/components/listbox.js
@@ -0,0 +1,306 @@
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 = 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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like using a "setter" like this; keeps the code for manipulating the value clean, but allows us to perform these side-effects easily as well!

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) {
if (e.button !== 0) return;
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
this.closeListbox();

for (let i = 0; i < e.path?.length; i++) {
if (typeof e.path[i].focus === 'function') {
e.path[i].focus();
}

if (document.activeElement === e.path[i]) {
e.stopPropagation();
break;
}
}
}

@action
handleKeyDown(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 + '-option'] =
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.find((o, i) => {
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
if (
o.id === optionComponent.guid + '-option' &&
!o.hasAttribute('disabled')
) {
this.activeOptionIndex = i;
document.querySelector('#' + optionComponent.guid + '-option').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;
}
}
}
}
19 changes: 19 additions & 0 deletions addon/components/listbox/-button.hbs
@@ -0,0 +1,19 @@
{{#let (element (or @as 'button')) as |Tag|}}
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
<Tag
role={{if @role @role 'button'}}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem like the upstream Listbox.Button component has an @role argument; is it necessary for us to support it?

id='{{@guid}}-button'
aria-haspopup='listbox'
aria-controls={{@guid}}
aria-labelledby='{{@guid}}-label {{@guid}}-button'
{{on 'click' @handleButtonClick}}
{{on 'keydown' @handleKeyDown}}
{{on 'keypress' @handleKeyPress}}
{{aria "expanded" value=@isOpen removeWhen=@isDisabled}}
disabled={{@isDisabled}}
{{did-insert @registerButtonElement}}
{{click-outside @handleClickOutside}}
...attributes
>
{{yield}}
</Tag>
{{/let}}
10 changes: 10 additions & 0 deletions addon/components/listbox/-label.hbs
@@ -0,0 +1,10 @@
{{#let (element (or @as 'label')) as |Tag|}}
<Tag
id='{{@guid}}-label'
for='{{@guid}}-button'
{{on 'click' @handleLabelClick}}
{{did-insert @registerLabelElement}}
...attributes>
{{yield}}
</Tag>
{{/let}}