Skip to content

Instantly share code, notes, and snippets.

@smhigley
Last active March 21, 2019 07:26
Show Gist options
  • Save smhigley/68e641bed43077767ee8ac1a4337cae9 to your computer and use it in GitHub Desktop.
Save smhigley/68e641bed43077767ee8ac1a4337cae9 to your computer and use it in GitHub Desktop.
Readonly Combobox using StencilJS
import { Component, Event, EventEmitter, Prop, State } from '@stencil/core';
import { getActionFromKey, getUpdatedIndex, MenuActions, uniqueId } from '../../shared/utils';
interface SelectOption {
name: string;
value: string;
}
@Component({
tag: 'combo-readonly',
styleUrl: '../../shared/combo-base.css',
shadow: false
})
export class ComboReadonly {
/**
* Array of name/value options
*/
@Prop() options: SelectOption[];
/**
* String label
*/
@Prop() label: string;
/**
* Emit a custom select event on value change
*/
@Event({
eventName: 'select'
}) selectEvent: EventEmitter;
// Active option index
@State() activeIndex = 0;
// Menu state
@State() open = false;
// Selected option index
@State() selectedIndex: number;
// input value
@State() value = '';
// Unique ID that should really use a UUID library instead
private htmlId = uniqueId();
// Prevent menu closing before click completed
private ignoreBlur = false;
// save reference to input element
private inputRef: HTMLInputElement;
render() {
const {
activeIndex,
htmlId,
label = '',
open = false,
options = [],
value
} = this;
const activeId = open ? `${htmlId}-${activeIndex}` : '';
return ([
<label id={htmlId} class="combo-label">{label}</label>,
<div role="combobox" aria-haspopup="listbox" aria-expanded={`${open}`} class={{ combo: true, open }}>
<input
aria-activedescendant={activeId}
aria-autocomplete="none"
aria-labelledby={htmlId}
class="combo-input"
readonly
ref={(el) => this.inputRef = el}
type="text"
value={value}
onBlur={this.onInputBlur.bind(this)}
onClick={() => this.updateMenuState(true)}
onKeyDown={this.onInputKeyDown.bind(this)}
/>
<div class="combo-menu" role="listbox">
{options.map((option, i) => {
return (
<div
class={{ 'option-current': this.activeIndex === i, 'combo-option': true }}
id={`${this.htmlId}-${i}`}
aria-selected={this.selectedIndex === i ? 'true' : false}
role="option"
onClick={() => { this.onOptionClick(i); }}
onMouseDown={this.onOptionMouseDown.bind(this)}
>{option.name}</div>
);
})}
</div>
</div>
]);
}
private onInputKeyDown(event: KeyboardEvent) {
const { key } = event;
const max = this.options.length - 1;
const action = getActionFromKey(key, this.open);
switch(action) {
case MenuActions.Next:
case MenuActions.Last:
case MenuActions.First:
case MenuActions.Previous:
event.preventDefault();
return this.onOptionChange(getUpdatedIndex(this.activeIndex, max, action));
case MenuActions.CloseSelect:
this.selectOption(this.activeIndex);
case MenuActions.Close:
return this.updateMenuState(false);
case MenuActions.Type:
case MenuActions.Open:
return this.updateMenuState(true);
}
}
private onInputBlur() {
if (this.ignoreBlur) {
this.ignoreBlur = false;
return;
}
this.updateMenuState(false, false);
}
private onOptionChange(index: number) {
this.activeIndex = index;
}
private onOptionClick(index: number) {
this.onOptionChange(index);
this.selectOption(index);
this.updateMenuState(false);
}
private onOptionMouseDown() {
this.ignoreBlur = true;
}
private selectOption(index: number) {
const selected = this.options[index];
this.value = selected.name;
this.selectedIndex = index;
this.selectEvent.emit(selected);
}
private updateMenuState(open: boolean, callFocus = true) {
this.open = open;
callFocus && this.inputRef.focus();
}
}
export enum Keys {
Backspace = 'Backspace',
Clear = 'Clear',
Down = 'ArrowDown',
End = 'End',
Enter = 'Enter',
Escape = 'Escape',
Home = 'Home',
Left = 'ArrowLeft',
PageDown = 'PageDown',
PageUp = 'PageUp',
Right = 'ArrowRight',
Space = ' ',
Tab = 'Tab',
Up = 'ArrowUp'
}
export enum MenuActions {
Close,
CloseSelect,
First,
Last,
Next,
Open,
Previous,
Select,
Type
}
// return combobox action from key press
export function getActionFromKey(key: string, menuOpen: boolean): MenuActions {
// handle opening when closed
if (!menuOpen && key === Keys.Down) {
return MenuActions.Open;
}
// handle keys when open
if (key === Keys.Down) {
return MenuActions.Next;
}
else if (key === Keys.Up) {
return MenuActions.Previous;
}
else if (key === Keys.Home) {
return MenuActions.First;
}
else if (key === Keys.End) {
return MenuActions.Last;
}
else if (key === Keys.Escape) {
return MenuActions.Close;
}
else if (key === Keys.Enter ) {
return MenuActions.CloseSelect;
}
else if (key === Keys.Backspace || key === Keys.Clear || key.length === 1) {
return MenuActions.Type;
}
}
// get updated option index
export function getUpdatedIndex(current: number, max: number, action: MenuActions): number {
switch(action) {
case MenuActions.First:
return 0;
case MenuActions.Last:
return max;
case MenuActions.Previous:
return Math.max(0, current - 1);
case MenuActions.Next:
return Math.min(max, current + 1);
default:
return current;
}
}
// generate unique ID, the quick 'n dirty way
let idIndex = 0;
export function uniqueId() {
return `combo-${++idIndex}`;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment