Last active
March 21, 2019 07:26
-
-
Save smhigley/68e641bed43077767ee8ac1a4337cae9 to your computer and use it in GitHub Desktop.
Readonly Combobox using StencilJS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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