1
0
Fork 0
mirror of synced 2025-04-10 04:20:59 +00:00
select2/control/src/single-select.tsx
2019-08-08 15:47:13 -07:00

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