/
kan64
/
spreadsheet-lab
Обзор
Документация
Войти
/
kan64
/
spreadsheet-lab
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
client/src/components/SearchableDropdown.tsx
195 строк
6 KB
Anton Kravchenkov
feat(ui): Select integration
04 мар 2026, 22:37
04 мар 2026, 22:37
6ec6b1b
Код
Авторство
О чём код?
import React, { useState, useCallback, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; export interface SearchableDropdownProps<T> { value: string; onValueChange: (value: string) => void; items: T[]; onSelect: (item: T) => void; itemLabel: (item: T) => string; filterItems?: (items: T[], query: string) => T[]; renderItem?: (item: T) => React.ReactNode; placeholder?: string; disabled?: boolean; className?: string; } const defaultFilter = <T,>(items: T[], query: string, itemLabel: (item: T) => string): T[] => { if (!query.trim()) return items; const q = query.trim().toLowerCase(); return items.filter((i) => itemLabel(i).toLowerCase().includes(q)); }; export function SearchableDropdown<T>({ value, onValueChange, items, onSelect, itemLabel, filterItems, renderItem, placeholder = '', disabled = false, className = '', }: SearchableDropdownProps<T>) { const [showDropdown, setShowDropdown] = useState(false); const [highlightIdx, setHighlightIdx] = useState(-1); const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number } | null>(null); const wrapperRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement>(null); const dropdownRef = useRef<HTMLDivElement>(null); const filtered = filterItems ? filterItems(items, value) : defaultFilter(items, value, itemLabel); const updateDropdownPos = useCallback(() => { if (inputRef.current) { const rect = inputRef.current.getBoundingClientRect(); setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); } }, []); const openDropdown = useCallback(() => { updateDropdownPos(); setShowDropdown(true); setHighlightIdx(-1); }, [updateDropdownPos]); const closeDropdown = useCallback(() => { setShowDropdown(false); }, []); useEffect(() => { if (!showDropdown) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; const inWrapper = wrapperRef.current?.contains(target); const inDropdown = dropdownRef.current?.contains(target); if (!inWrapper && !inDropdown) { closeDropdown(); } }; const handleScroll = () => updateDropdownPos(); document.addEventListener('mousedown', handleClickOutside, true); window.addEventListener('scroll', handleScroll, true); return () => { document.removeEventListener('mousedown', handleClickOutside, true); window.removeEventListener('scroll', handleScroll, true); }; }, [showDropdown, closeDropdown, updateDropdownPos]); const handleSelect = (item: T) => { onSelect(item); closeDropdown(); }; const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { onValueChange(e.target.value); if (!showDropdown) openDropdown(); setHighlightIdx(-1); }; const handleInputFocus = () => { openDropdown(); }; const handleInputKeyDown = (e: React.KeyboardEvent) => { if (showDropdown && filtered.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIdx((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIdx((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); } else if (e.key === 'Enter' && highlightIdx >= 0) { e.preventDefault(); handleSelect(filtered[highlightIdx]); return; } else if (e.key === 'Escape') { closeDropdown(); return; } } }; const handleChevronClick = () => { if (showDropdown) { closeDropdown(); } else { inputRef.current?.focus(); openDropdown(); } }; const handleClear = (e: React.MouseEvent) => { e.preventDefault(); onValueChange(''); inputRef.current?.focus(); if (!showDropdown) openDropdown(); }; const dropdownPortal = showDropdown && dropdownPos && filtered.length > 0 ? createPortal( <div ref={dropdownRef} className="searchable-dropdown-list" style={{ top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width }} > {filtered.map((item, idx) => ( <button key={idx} className={`searchable-dropdown-item${idx === highlightIdx ? ' highlighted' : ''}`} onMouseDown={(e) => { e.preventDefault(); handleSelect(item); }} onMouseEnter={() => setHighlightIdx(idx)} > {renderItem ? renderItem(item) : itemLabel(item)} </button> ))} </div>, document.body, ) : null; return ( <div ref={wrapperRef} className={`searchable-dropdown-wrapper ${className}`}> <input ref={inputRef} className="searchable-dropdown-input" value={value} onChange={handleInputChange} onKeyDown={handleInputKeyDown} onFocus={handleInputFocus} placeholder={placeholder} autoComplete="off" disabled={disabled} /> {value && ( <button type="button" className="searchable-dropdown-clear" onMouseDown={(e) => { e.preventDefault(); handleClear(e); }} tabIndex={-1} disabled={disabled} title="Очистить" > <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <path d="M4 4L10 10M10 4L4 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> </button> )} <button type="button" className={`searchable-dropdown-chevron${showDropdown ? ' open' : ''}`} onMouseDown={(e) => { e.preventDefault(); handleChevronClick(); }} tabIndex={-1} disabled={disabled} > <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> </button> {dropdownPortal} </div> ); }