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 = ({ 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(null); const menuRef = React.useRef(null); const searchRef = React.useRef(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 (
setQuery(e.target.value)} />
{filtered.length === 0 && (
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
)} {filtered.map(opt => ( ))}
{allowClear && value && (
)}
); };