/** * SearchableSelect — Inline searchable dropdown for the Granite Loader Sheet. * * Supports: * - Static options (small lists like EQUIP_STATUS) * - Large searchable lists (RESPONSIBLE_TEAM, SITE_NAME, EQUIP_TEMPLATE) * - Keyboard navigation (arrow keys, enter, escape) * - Typeahead filtering * - Portal-free (renders inline to avoid z-index issues in table cells) */ import React, { useState, useRef, useEffect, useCallback } from 'react'; const DROPDOWN_STYLE = { position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 100, background: '#0F172A', border: '1px solid #7C3AED', borderRadius: '0 0 0.375rem 0.375rem', maxHeight: '180px', overflowY: 'auto', boxShadow: '0 8px 24px rgba(0, 0, 0, 0.5)', }; const OPTION_STYLE = { padding: '0.3rem 0.6rem', fontSize: '0.7rem', color: '#E2E8F0', cursor: 'pointer', fontFamily: "'JetBrains Mono', monospace", }; const OPTION_HIGHLIGHT = { ...OPTION_STYLE, background: 'rgba(124, 58, 237, 0.2)', }; export default function SearchableSelect({ value, options, onChange, onClose, placeholder, autoFocus }) { const [filter, setFilter] = useState(value || ''); const [highlightIdx, setHighlightIdx] = useState(-1); const [isOpen, setIsOpen] = useState(false); const inputRef = useRef(null); const listRef = useRef(null); useEffect(() => { if (autoFocus && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); setIsOpen(true); } }, [autoFocus]); const filtered = useCallback(() => { if (!filter.trim()) return options.slice(0, 50); const lower = filter.toLowerCase(); return options.filter(o => o.toLowerCase().includes(lower)).slice(0, 50); }, [filter, options]); const filteredOptions = filtered(); // Scroll highlighted item into view useEffect(() => { if (highlightIdx >= 0 && listRef.current) { const el = listRef.current.children[highlightIdx]; if (el) el.scrollIntoView({ block: 'nearest' }); } }, [highlightIdx]); const handleKeyDown = (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIdx(prev => Math.min(prev + 1, filteredOptions.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIdx(prev => Math.max(prev - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); if (highlightIdx >= 0 && filteredOptions[highlightIdx]) { onChange(filteredOptions[highlightIdx]); setFilter(filteredOptions[highlightIdx]); } else if (filter.trim()) { onChange(filter.trim()); } setIsOpen(false); if (onClose) onClose(); } else if (e.key === 'Escape') { setIsOpen(false); if (onClose) onClose(); } else if (e.key === 'Tab') { if (highlightIdx >= 0 && filteredOptions[highlightIdx]) { onChange(filteredOptions[highlightIdx]); setFilter(filteredOptions[highlightIdx]); } else if (filter.trim()) { onChange(filter.trim()); } setIsOpen(false); if (onClose) onClose(); } }; const handleSelect = (opt) => { onChange(opt); setFilter(opt); setIsOpen(false); if (onClose) onClose(); }; return (
{ setFilter(e.target.value); setHighlightIdx(-1); setIsOpen(true); }} onFocus={() => setIsOpen(true)} onKeyDown={handleKeyDown} onBlur={() => { // Delay close so click on option can fire setTimeout(() => { setIsOpen(false); if (filter.trim() && filter.trim() !== value) { onChange(filter.trim()); } if (onClose) onClose(); }, 150); }} placeholder={placeholder || 'Search...'} /> {isOpen && filteredOptions.length > 0 && (
{filteredOptions.map((opt, i) => (
setHighlightIdx(i)} onMouseDown={(e) => { e.preventDefault(); handleSelect(opt); }} > {opt}
))} {filteredOptions.length === 50 && (
Type to filter more...
)}
)}
); }