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 5 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
46 changes: 46 additions & 0 deletions addon/components/listbox.hbs
@@ -0,0 +1,46 @@
<div
{{did-insert this.registerDocumentClickListener}}
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
{{will-destroy this.unregisterDocumentClickListener}}>

{{yield
(hash
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
open=this.isOpen
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
disabled=(if @disabled true false)
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
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
)
Button=(component
'listbox/-button'
guid=this.guid
isOpen=this.isOpen
registerButtonElement=this.registerButtonElement
handleButtonClick=this.handleButtonClick
handleKeyPress=this.handleKeyPress
handleKeyDown=this.handleKeyDown
disabled=@disabled
)
Label=(component
'listbox/-label'
guid=this.guid
isOpen=this.isOpen
disabled=@disabled
registerLabelElement=this.registerLabelElement
handleLabelClick=this.handleLabelClick
)
)
}}

</div>
316 changes: 316 additions & 0 deletions addon/components/listbox.js
@@ -0,0 +1,316 @@
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 selectedOptionGuid() {
return this.optionElements[this.selectedOptionIndex]?.id;
}

@action
handleButtonClick(e) {
if (e.ctrlKey) return;
this.activateBehaviour = ACTIVATE_NONE;

if (this.isOpen) {
this.close();
} else {
this.open();
}
}

@action
handleKeyDown(event) {
if (event.key === 'ArrowDown') {
if (!this.isOpen) {
this.activateBehaviour = ACTIVATE_FIRST;
this.open();
} 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.open();
} 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.close();
}
}

@action
handleKeyPress(event) {
if (
event.key === 'Enter' ||
((event.key === 'Space' || event.key === ' ') && this.search === '')
) {
this.activateBehaviour = ACTIVATE_FIRST;
if (this.isOpen) {
this.setSelectedOption(undefined, event);
this.close();
} else {
this.open();
}
} else if (event.key.length === 1) {
this.addSearchCharacter(event.key);
}
}
@action
handleLabelClick(e) {
e.preventDefault();
if (e.ctrlKey) return;
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
this.buttonElement.focus();
}

@action
registerButtonElement(buttonElement) {
this.buttonElement = buttonElement;
}

@action
registerDocumentClickListener() {
this._handleDocumentClick = this.handleDocumentClick.bind(this);

document.addEventListener('click', this._handleDocumentClick);
}

@action
registerLabelElement(labelElement) {
this.labelElement = labelElement;
}

@action
registerOptionElement(optionValue, optionElement) {
this.optionElements.push(optionElement);
this.optionValues.push(optionValue);

if (this.args.value) {
if (this.args.value === optionValue) {
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;
this.optionsElement.focus();
}

@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 optionId, optionValue, found;

if (optionComponent) {
optionId = optionComponent.guid + '-option';
optionValue = optionComponent.args.value;
} else if (this.activeOptionIndex !== undefined) {
optionId = this.optionElements[this.activeOptionIndex].id;
optionValue = this.optionValues[this.activeOptionIndex];
} else {
return;
}

this.optionElements.find((o, i) => {
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
if (!found && o.id === optionId && !o.hasAttribute('disabled')) {
this.selectedOptionIndex = i;

if (this.args.onChange) {
this.args.onChange(optionValue);
}

if (e.type === 'click') {
this.close();
}

found = true;
}
});

if (!found) {
this.optionsElement.focus();
}
}

@action
unregisterDocumentClickListener() {
document.removeEventListener('click', this._handleDocumentClick);
}

@action
unregisterOptionsElement() {
this.optionsElement = undefined;
}

@action
unsetActiveOption() {
this.activeOptionIndex = undefined;
}

handleDocumentClick(e) {
if (e.ctrlKey) return;

if (
!this.buttonElement?.contains(e.target) &&
!this.optionsElement?.contains(e.target)
) {
this.isOpen = false;

let hasFocus = this.focusAncestor(e.target);

if (!hasFocus) {
this.buttonElement.focus();
}
}
}

focusAncestor(element) {
if (!element) return false;
if (element.tagName === 'BODY') return false;

element.focus();

if (document.activeElement === element) {
return true;
} else {
return this.focusAncestor(element.parentElement);
}
}

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;
}
}
}

open() {
this.activeOptionIndex = undefined;
this.selectedOptionIndex = undefined;
this.optionElements = [];
this.optionValues = [];
this.isOpen = true;
}

close() {
this.isOpen = false;
this.buttonElement.focus();
}
}
18 changes: 18 additions & 0 deletions addon/components/listbox/-button.hbs
@@ -0,0 +1,18 @@
{{#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=@disabled}}
alexlafroscia marked this conversation as resolved.
Show resolved Hide resolved
disabled={{if @disabled true false}}
{{did-insert @registerButtonElement}}
...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}}