156 lines
5.6 KiB
TypeScript
156 lines
5.6 KiB
TypeScript
import * as React from 'react';
|
|
import styles from './SearchableSelect.module.scss';
|
|
import { DropdownPortal } from './DropdownPortal';
|
|
import { Icon } from './Icon';
|
|
import { texts } from '../../texts';
|
|
|
|
export interface ISearchableOption {
|
|
value: string;
|
|
label: string;
|
|
/** Второстепенная строка под названием — например, компания у отдела. */
|
|
sublabel?: string;
|
|
}
|
|
|
|
export interface ISearchableSelectProps {
|
|
options: ISearchableOption[];
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
searchPlaceholder?: string;
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
/** Показывать пункт «очистить» — для необязательных полей. */
|
|
allowClear?: boolean;
|
|
clearLabel?: string;
|
|
/** Текст, когда список опций пуст (не результат поиска, а вообще нечего выбирать). */
|
|
emptyMessage?: string;
|
|
}
|
|
|
|
export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = texts.select.placeholder,
|
|
searchPlaceholder = texts.select.searchPlaceholder,
|
|
disabled,
|
|
loading,
|
|
allowClear,
|
|
clearLabel = texts.select.notSelected,
|
|
emptyMessage = texts.select.nothingFound
|
|
}) => {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [query, setQuery] = React.useState('');
|
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
|
|
// или она не проходит текущий фильтр). Показать в этом случае плейсхолдер —
|
|
// значит соврать, будто поле пустое, и затереть значение при сохранении.
|
|
// Поэтому подмешиваем недостающую опцию явным образом.
|
|
const effectiveOptions = React.useMemo(() => {
|
|
if (!value || options.some(o => o.value === value)) return options;
|
|
return [{ value, label: value, sublabel: texts.select.missingInDictionary }, ...options];
|
|
}, [options, value]);
|
|
|
|
const selected = effectiveOptions.filter(o => o.value === value)[0];
|
|
|
|
// Поиск на клиенте: справочник целиком уже загружен, отделов ~700 —
|
|
// фильтровать в памяти дешевле, чем ходить на сервер на каждый символ.
|
|
const filtered = React.useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return effectiveOptions;
|
|
return effectiveOptions.filter(o =>
|
|
o.label.toLowerCase().indexOf(q) !== -1 ||
|
|
(o.sublabel ? o.sublabel.toLowerCase().indexOf(q) !== -1 : false)
|
|
);
|
|
}, [effectiveOptions, query]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const onDocumentMouseDown = (e: MouseEvent): void => {
|
|
const target = e.target as Node;
|
|
if (rootRef.current?.contains(target)) return;
|
|
if (menuRef.current?.contains(target)) return;
|
|
setOpen(false);
|
|
};
|
|
const onKeyDown = (e: KeyboardEvent): void => {
|
|
if (e.key === 'Escape') setOpen(false);
|
|
};
|
|
|
|
document.addEventListener('mousedown', onDocumentMouseDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDocumentMouseDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [open]);
|
|
|
|
React.useEffect(() => {
|
|
if (open && searchRef.current) searchRef.current.focus();
|
|
}, [open]);
|
|
|
|
const toggle = (): void => {
|
|
if (disabled) return;
|
|
setQuery('');
|
|
setOpen(!open);
|
|
};
|
|
|
|
const pick = (next: string): void => {
|
|
onChange(next);
|
|
setOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className={styles.root} ref={rootRef}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.trigger} ${open ? styles.open : ''}`}
|
|
onClick={toggle}
|
|
disabled={disabled || loading}
|
|
>
|
|
<span className={`${styles.value} ${selected ? '' : styles.placeholder}`}>
|
|
{loading ? texts.select.loadingDictionary : (selected ? selected.label : placeholder)}
|
|
</span>
|
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
|
</button>
|
|
|
|
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
|
|
<input
|
|
ref={searchRef}
|
|
className={styles.search}
|
|
placeholder={searchPlaceholder}
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
/>
|
|
<div className={styles.list}>
|
|
{filtered.length === 0 && (
|
|
<div className={styles.empty}>
|
|
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
|
|
</div>
|
|
)}
|
|
{filtered.map(opt => (
|
|
<button
|
|
type="button"
|
|
key={opt.value}
|
|
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
|
onClick={() => pick(opt.value)}
|
|
>
|
|
{opt.label}
|
|
{opt.sublabel && <span className={styles.itemSub}>{opt.sublabel}</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{allowClear && value && (
|
|
<div className={styles.clear}>
|
|
<button type="button" className={styles.item} onClick={() => pick('')}>
|
|
{clearLabel}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</DropdownPortal>
|
|
</div>
|
|
);
|
|
};
|