369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
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<Props, State> {
|
|
private containerRef: RefObject<HTMLElement>;
|
|
private dropdownRef: RefObject<HTMLElement>;
|
|
private bodyRef: RefObject<HTMLElement>;
|
|
private searchRef: RefObject<HTMLInputElement>;
|
|
private valueRef: RefObject<HTMLElement>;
|
|
|
|
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 (
|
|
<Fragment>
|
|
<div
|
|
class={classes}
|
|
ref={this.containerRef}
|
|
onFocusCapture={this.onFocusIn}
|
|
onBlurCapture={this.onFocusOut}
|
|
tabIndex={-1}
|
|
onMouseDown={this.onContainerMouseDown}
|
|
>
|
|
<div class={cn(style.body)} ref={this.bodyRef}>
|
|
<div
|
|
aria-label={label}
|
|
role='listbox'
|
|
aria-activedescendant={optionDomId}
|
|
aria-expanded='false'
|
|
class={cn(style.value)}
|
|
tabIndex={tabIndex}
|
|
ref={this.valueRef}
|
|
onKeyDown={this.onValueKeyDown}
|
|
aria-describedby={showPlaceholder ? placeholderDomId : undefined}
|
|
>
|
|
{value && (
|
|
<div
|
|
class={style.item}
|
|
role='option'
|
|
aria-selected='true'
|
|
aria-label={this.getItemLabel(value)}
|
|
aria-setsize={-1}
|
|
aria-posinset={-1}
|
|
id={optionDomId}
|
|
>
|
|
<div class={style.content}>{this.renderValue(value)}</div>
|
|
</div>
|
|
)}
|
|
{showPlaceholder && (
|
|
<div class={cn(style.placeholder)} id={placeholderDomId}>
|
|
{placeholder}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{scope(() => {
|
|
const disabled = !value;
|
|
const clazz = cn(style.remove, { [style.offscreen]: !allowClear });
|
|
return (
|
|
<button
|
|
class={clazz}
|
|
onClick={this.onClearClick}
|
|
onFocus={this.onClearFocus}
|
|
onMouseDown={this.onClearMouseDown}
|
|
disabled={disabled}
|
|
aria-disabled={disabled}
|
|
title={dictionary.clearButtonTitle()}
|
|
type='button'
|
|
>
|
|
<span>
|
|
<Remove width={20} height={20} />
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
<div className={style.toggle} aria-hidden={true} title={dictionary.expandButtonTitle()}>
|
|
<Toggle height={20} width={20} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{open && (
|
|
<Dropdown
|
|
class={cn(style.dropdown, style.single)}
|
|
onMouseDown={this.onDropdownMouseDown}
|
|
controlRef={this.containerRef}
|
|
dropdownRef={this.dropdownRef}
|
|
onFocusOut={this.onFocusOut}
|
|
>
|
|
<div>
|
|
<input
|
|
type='text'
|
|
ref={this.searchRef}
|
|
value={search}
|
|
class={cn(style.search)}
|
|
role='combobox'
|
|
aria-autocomplete='list'
|
|
aria-haspopup='true'
|
|
aria-owns={resultsDomId}
|
|
aria-controls={resultsDomId}
|
|
aria-expanded={open ? 'true' : 'false'}
|
|
aria-activedescendant={
|
|
results.active >= 0 ? resultsNamespace + results.active : undefined
|
|
}
|
|
aria-busy={loading}
|
|
onInput={this.onSearchInput}
|
|
onKeyDown={this.onSearchKeyDown}
|
|
onFocus={this.onSearchFocus}
|
|
/>
|
|
<ResultList
|
|
namespace={resultsNamespace}
|
|
minimumCharacters={minimumCharacters}
|
|
dictionary={this.dictionary}
|
|
itemLabel={this.getItemLabel}
|
|
renderItem={this.renderResult}
|
|
listboxDomId={resultsDomId}
|
|
search={search}
|
|
{...this.state.results}
|
|
loading={loading}
|
|
onResultClicked={this.onResultClicked}
|
|
onMouseMove={this.onResultMouseMove}
|
|
onLoadMore={this.onLoadMoreResults}
|
|
/>
|
|
</div>
|
|
</Dropdown>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
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<State>) => {
|
|
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();
|
|
};
|
|
}
|