feat(ui): searchable Select component (drop-in)

Заменили нативный <select> на кастомный комбобокс с поисковой строкой:
кликабельная кнопка-триггер, dropdown с input «Поиск…» и фильтрацией
по подстроке, навигация ↑/↓/Enter/Esc. API совместим — onChange получает
synthetic event с e.target.value, поэтому все 22 существующих <Select>
работают без правок call site. Дочерние <option> парсятся в options
автоматически (поддержка <optgroup>).

Why: справочники быстро растут (поставщики, страны, типы цен) — выбор
из длинного списка через нативный select утомителен. Поиск нужен везде.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 01:02:29 +05:00
parent 306153d128
commit 196658e548

View file

@ -1,4 +1,8 @@
import { useEffect, useState, type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from 'react' import {
Children, isValidElement, useEffect, useMemo, useRef, useState,
type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes,
} from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -30,8 +34,150 @@ export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea {...props} className={cn(inputClass, 'h-auto py-2 font-[inherit] leading-normal', props.className)} /> return <textarea {...props} className={cn(inputClass, 'h-auto py-2 font-[inherit] leading-normal', props.className)} />
} }
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) { type SelectOption = { value: string; label: string; disabled?: boolean }
return <select {...props} className={cn(inputClass, props.className)} />
function nodeText(node: ReactNode): string {
if (node == null || node === false) return ''
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (Array.isArray(node)) return node.map(nodeText).join('')
if (isValidElement(node)) return nodeText((node.props as { children?: ReactNode }).children)
return ''
}
function extractOptions(children: ReactNode): SelectOption[] {
const out: SelectOption[] = []
Children.forEach(children, (child) => {
if (!isValidElement(child)) return
const t = child.type as unknown as string
if (t === 'option') {
const props = child.props as { value?: string | number; children?: ReactNode; disabled?: boolean }
out.push({
value: String(props.value ?? ''),
label: nodeText(props.children),
disabled: props.disabled,
})
} else if (t === 'optgroup') {
const props = child.props as { children?: ReactNode }
out.push(...extractOptions(props.children))
}
})
return out
}
/** Drop-in замена нативного <select>: визуально похож на TextInput, но при клике
* раскрывает выпадающий список с поиском по подстроке. API совместим со старым
* Select onChange получает synthetic event с e.target.value. Дочерние <option>
* парсятся в options array (label = текст внутри <option>). */
export function Select({ value, onChange, disabled, className, children, placeholder, ...rest }: SelectHTMLAttributes<HTMLSelectElement> & { placeholder?: string }) {
const options = useMemo(() => extractOptions(children), [children])
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [highlight, setHighlight] = useState(0)
const wrapRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const current = String(value ?? '')
const selected = options.find((o) => o.value === current)
const filtered = useMemo(() => {
if (!query.trim()) return options
const q = query.trim().toLowerCase()
return options.filter((o) => o.label.toLowerCase().includes(q))
}, [options, query])
useEffect(() => { setHighlight(0) }, [query, open])
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false); setQuery('')
}
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [open])
useEffect(() => {
if (open) requestAnimationFrame(() => searchRef.current?.focus())
}, [open])
const choose = (v: string) => {
setOpen(false); setQuery('')
if (onChange) {
const fake = { target: { value: v }, currentTarget: { value: v } } as unknown as React.ChangeEvent<HTMLSelectElement>
onChange(fake)
}
}
const triggerLabel = selected
? selected.label
: (placeholder ?? (options.find((o) => o.value === '')?.label ?? '—'))
return (
<div ref={wrapRef} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={open}
data-name={rest.name}
className={cn(inputClass, 'flex items-center justify-between text-left pr-9', className)}
>
<span className={cn('truncate', !selected && 'text-slate-400')}>{triggerLabel}</span>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
</button>
{open && (
<div className="absolute z-50 mt-1 w-full max-h-72 overflow-hidden rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg flex flex-col">
<div className="p-2 border-b border-slate-100 dark:border-slate-800">
<input
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск…"
className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]"
onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); setQuery('') }
else if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlight((h) => Math.min(h + 1, Math.max(0, filtered.length - 1)))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlight((h) => Math.max(0, h - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
const opt = filtered[highlight]
if (opt && !opt.disabled) choose(opt.value)
}
}}
/>
</div>
<ul className="py-1 overflow-auto" role="listbox">
{filtered.length === 0 ? (
<li className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</li>
) : filtered.map((opt, i) => (
<li key={`${opt.value}-${i}`}>
<button
type="button"
disabled={opt.disabled}
onMouseEnter={() => setHighlight(i)}
onClick={() => !opt.disabled && choose(opt.value)}
className={cn(
'w-full text-left px-3 py-1.5 text-sm flex items-center',
i === highlight && !opt.disabled && 'bg-slate-100 dark:bg-slate-800',
opt.value === current && 'font-medium text-[var(--color-brand)]',
opt.disabled && 'opacity-50 cursor-not-allowed',
)}
>
<span className="truncate">{opt.label || '—'}</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
)
} }
interface MoneyInputProps { interface MoneyInputProps {