2026-06-10 09:47:25 -06:00
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-06-10 11:40:20 -06:00
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
2026-06-10 09:47:25 -06:00
|
|
|
const inputRef = useRef(null);
|
|
|
|
|
const listRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (autoFocus && inputRef.current) {
|
|
|
|
|
inputRef.current.focus();
|
|
|
|
|
inputRef.current.select();
|
2026-06-10 11:40:20 -06:00
|
|
|
setIsOpen(true);
|
2026-06-10 09:47:25 -06:00
|
|
|
}
|
|
|
|
|
}, [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]);
|
2026-06-10 11:40:20 -06:00
|
|
|
setFilter(filteredOptions[highlightIdx]);
|
2026-06-10 09:47:25 -06:00
|
|
|
} else if (filter.trim()) {
|
|
|
|
|
onChange(filter.trim());
|
|
|
|
|
}
|
2026-06-10 11:40:20 -06:00
|
|
|
setIsOpen(false);
|
2026-06-10 09:47:25 -06:00
|
|
|
if (onClose) onClose();
|
|
|
|
|
} else if (e.key === 'Escape') {
|
2026-06-10 11:40:20 -06:00
|
|
|
setIsOpen(false);
|
2026-06-10 09:47:25 -06:00
|
|
|
if (onClose) onClose();
|
|
|
|
|
} else if (e.key === 'Tab') {
|
|
|
|
|
if (highlightIdx >= 0 && filteredOptions[highlightIdx]) {
|
|
|
|
|
onChange(filteredOptions[highlightIdx]);
|
2026-06-10 11:40:20 -06:00
|
|
|
setFilter(filteredOptions[highlightIdx]);
|
2026-06-10 09:47:25 -06:00
|
|
|
} else if (filter.trim()) {
|
|
|
|
|
onChange(filter.trim());
|
|
|
|
|
}
|
2026-06-10 11:40:20 -06:00
|
|
|
setIsOpen(false);
|
2026-06-10 09:47:25 -06:00
|
|
|
if (onClose) onClose();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSelect = (opt) => {
|
|
|
|
|
onChange(opt);
|
2026-06-10 11:40:20 -06:00
|
|
|
setFilter(opt);
|
|
|
|
|
setIsOpen(false);
|
2026-06-10 09:47:25 -06:00
|
|
|
if (onClose) onClose();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
|
|
<input
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
style={{
|
2026-06-10 11:40:20 -06:00
|
|
|
background: '#0F172A', border: isOpen ? '1px solid #7C3AED' : '1px solid #334155', borderRadius: isOpen ? '0.375rem 0.375rem 0 0' : '0.375rem',
|
2026-06-10 09:47:25 -06:00
|
|
|
color: '#E2E8F0', padding: '0.3rem 0.5rem', fontSize: '0.7rem', width: '100%',
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace", outline: 'none', boxSizing: 'border-box',
|
|
|
|
|
}}
|
|
|
|
|
value={filter}
|
|
|
|
|
onChange={e => { setFilter(e.target.value); setHighlightIdx(-1); setIsOpen(true); }}
|
2026-06-10 11:40:20 -06:00
|
|
|
onFocus={() => setIsOpen(true)}
|
2026-06-10 09:47:25 -06:00
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
onBlur={() => {
|
|
|
|
|
// Delay close so click on option can fire
|
|
|
|
|
setTimeout(() => {
|
2026-06-10 11:40:20 -06:00
|
|
|
setIsOpen(false);
|
2026-06-10 09:47:25 -06:00
|
|
|
if (filter.trim() && filter.trim() !== value) {
|
|
|
|
|
onChange(filter.trim());
|
|
|
|
|
}
|
|
|
|
|
if (onClose) onClose();
|
|
|
|
|
}, 150);
|
|
|
|
|
}}
|
|
|
|
|
placeholder={placeholder || 'Search...'}
|
|
|
|
|
/>
|
|
|
|
|
{isOpen && filteredOptions.length > 0 && (
|
|
|
|
|
<div ref={listRef} style={DROPDOWN_STYLE}>
|
|
|
|
|
{filteredOptions.map((opt, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={opt}
|
|
|
|
|
style={i === highlightIdx ? OPTION_HIGHLIGHT : OPTION_STYLE}
|
|
|
|
|
onMouseEnter={() => setHighlightIdx(i)}
|
|
|
|
|
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt); }}
|
|
|
|
|
>
|
|
|
|
|
{opt}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{filteredOptions.length === 50 && (
|
|
|
|
|
<div style={{ ...OPTION_STYLE, color: '#64748B', fontStyle: 'italic' }}>
|
|
|
|
|
Type to filter more...
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|