Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx
T

138 lines
4.3 KiB
TypeScript

import * as React from 'react';
import styles from './SearchableSelect.module.scss';
import { Icon } from './Icon';
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;
}
export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
options,
value,
onChange,
placeholder = 'Выберите значение',
searchPlaceholder = 'Поиск...',
disabled,
loading,
allowClear,
clearLabel = 'Не выбрано'
}) => {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const rootRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
const selected = options.filter(o => o.value === value)[0];
// Поиск на клиенте: справочник целиком уже загружен, отделов ~700 —
// фильтровать в памяти дешевле, чем ходить на сервер на каждый символ.
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter(o =>
o.label.toLowerCase().indexOf(q) !== -1 ||
(o.sublabel ? o.sublabel.toLowerCase().indexOf(q) !== -1 : false)
);
}, [options, query]);
React.useEffect(() => {
if (!open) return;
const onDocumentMouseDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
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 ? 'Загрузка справочника...' : (selected ? selected.label : placeholder)}
</span>
<Icon name="chevron-down" size="sm" className={styles.chevron} />
</button>
{open && (
<div className={styles.menu}>
<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}>Ничего не найдено</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>
)}
</div>
)}
</div>
);
};