feat(ui): searchable Select component (drop-in)
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 43s
CI / Web (React + Vite) (push) Successful in 34s
Docker Web / Build + push Web (push) Has been cancelled
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 43s
CI / Web (React + Vite) (push) Successful in 34s
Docker Web / Build + push Web (push) Has been cancelled
Заменили нативный <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:
parent
86930bb71b
commit
4859ece60b
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue