import { createRef, Fragment, h, RefObject } from 'preact'; import { AbstractSelect, DEFAULT_PROPS as ABSTRACT_DEFAULT_PROPS, Props as AbstractSelectProps, State as AbstractSelectState } from './abstract-select'; import * as announce from './announce'; import { Dropdown } from './dropdown'; import { Remove, Toggle } from './icons'; import { ResultList } from './result-list'; import { style } from './style'; import { cn, DeepPartial, extend, Key, scope } from './util'; const forceImportOfH = h; export interface Props extends AbstractSelectProps { value: any; label: string; comboboxLabel: string; onChange: (value: any) => void; allowClear?: boolean; placeholder?: string; } interface State extends AbstractSelectState { value: any; } const DEFAULT_PROPS = extend({}, ABSTRACT_DEFAULT_PROPS, { allowClear: false }); export class SingleSelect extends AbstractSelect { private containerRef: RefObject; private dropdownRef: RefObject; private bodyRef: RefObject; private searchRef: RefObject; private valueRef: RefObject; public static defaultProps = DEFAULT_PROPS; constructor(props) { super(props); this.searchRef = createRef(); this.bodyRef = createRef(); this.containerRef = createRef(); this.dropdownRef = createRef(); this.valueRef = createRef(); this.state = extend(this.state, { value: this.props.value }); } public componentWillMount() { announce.initialize(); } public render(props, state) { const { minimumCharacters, tabIndex, label, allowClear, placeholder } = props; const { value, open, loading, focused, search, results } = state; let classes = cn(style.control, style.single, { [style.open]: open }, { [style.focused]: focused }); if (props.containerClass && props.containerClass.length > 0) { classes += ' ' + props.containerClass; } const resultsDomId = this.namespace + '-results'; const optionDomId = this.namespace + '-val'; const resultsNamespace = this.namespace + '-res-'; const dictionary = this.dictionary; const showPlaceholder = !value && placeholder && placeholder.length > 0; const placeholderDomId = this.namespace + '-placeholder'; return (
{scope(() => { const disabled = !value; const clazz = cn(style.remove, { [style.offscreen]: !allowClear }); return ( ); })}
{open && (
= 0 ? resultsNamespace + results.active : undefined } aria-busy={loading} onInput={this.onSearchInput} onKeyDown={this.onSearchKeyDown} onFocus={this.onSearchFocus} />
)}
); } public componentDidMount() { const css = this.props.containerStyle; if (css && css.length > 0) { this.containerRef.current!.setAttribute('style', css); } } private onLoadMoreResults = () => { this.loadMore(); }; public onFocusIn = (event: FocusEvent) => { this.updateState({ focused: true }); const { openOnFocus } = this.props; const { open } = this.state; if (!open && openOnFocus && this.searchRef.current !== document.activeElement) { this.open(); } }; public onFocusOut = (event: FocusEvent) => { const receiver = event.relatedTarget as Node; const container = this.containerRef.current; const dropdown = this.dropdownRef.current; const search = this.searchRef.current; const focused = container.contains(receiver) || (dropdown && (dropdown === receiver || dropdown.contains(receiver))) || receiver === search; if (this.state.focused !== focused) { this.updateState({ focused }); } if (!focused) { this.closeIfOpen(); } }; public closeIfOpen() { if (this.state.open) { this.close(); } } public close = (state?: DeepPartial) => { const control = this; control.valueRef.current!.focus(); this.updateState([ state, { open: false, results: { results: null }, search: '' } ]); }; private getValueAsArray() { return this.state.value ? [this.state.value] : []; } private onContainerMouseDown = (event: MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (this.state.open) { this.close(); } else { this.open(); } }; private open(query: string = '') { this.search(query, this.getValueAsArray(), { open: true }, () => { this.searchRef.current.focus(); }); } private onSearchFocus = (event: FocusEvent) => { this.updateState({ focused: true }); }; private onSearchInput = (event: Event) => { const value = (event.target as HTMLInputElement).value; this.search(value, this.getValueAsArray()); }; private onClearFocus = (event: FocusEvent) => { this.closeIfOpen(); }; private onClearClick = (event: Event) => { this.selectResult(undefined); event.preventDefault(); event.stopPropagation(); }; private onClearMouseDown = (event: Event) => { event.stopPropagation(); event.preventDefault(); }; public onSearchKeyDown = (event: KeyboardEvent) => { if (this.handleResultNavigationKeyDown(event)) { return; } const { open } = this.state; if (open && this.hasSearchResults) { switch (event.key) { case Key.Enter: this.selectActiveResult(); event.preventDefault(); event.stopPropagation(); break; case Key.Escape: this.close(); event.preventDefault(); event.stopPropagation(); break; case Key.Tab: // TODO select on tab? this.close(); event.preventDefault(); event.stopPropagation(); } } }; public selectActiveResult = () => { const { active } = this.state.results; if (active >= 0) { this.selectResult(this.getSelectedSearchResult()); } }; public selectResult = (result: any) => { const { onChange } = this.props; this.close({ value: result }); // TODO announce? // const label = this.getItemLabel(result); onChange(result); }; private onValueKeyDown = (event: KeyboardEvent) => { switch (event.key) { case Key.Space: case Key.ArrowDown: case Key.Down: this.open(); event.preventDefault(); event.stopPropagation(); return; } if (event.key.length === 1) { // focus on search which will put the printable character into the field this.open(); } }; private onDropdownMouseDown = (event: MouseEvent) => { this.searchRef.current.focus(); event.preventDefault(); event.stopPropagation(); }; public onResultMouseMove = (index: number, event: MouseEvent) => { this.selectSearchResult(index); }; public onResultClicked = (result: any, event: MouseEvent) => { this.selectResult(result); event.preventDefault(); event.stopPropagation(); }; }