Fix SearchableSelect — only open on focus, close properly on blur/select

Dropdown was opening automatically on render and not closing when clicking
elsewhere. Now opens only on focus/click, closes on blur, selection, Enter,
Escape, and Tab. Selected value persists in the input after selection.
This commit is contained in:
Jordan Ramos
2026-06-10 11:40:20 -06:00
parent 0f83f48cc6
commit 6465ac2a40

View File

@@ -41,7 +41,7 @@ const OPTION_HIGHLIGHT = {
export default function SearchableSelect({ value, options, onChange, onClose, placeholder, autoFocus }) { export default function SearchableSelect({ value, options, onChange, onClose, placeholder, autoFocus }) {
const [filter, setFilter] = useState(value || ''); const [filter, setFilter] = useState(value || '');
const [highlightIdx, setHighlightIdx] = useState(-1); const [highlightIdx, setHighlightIdx] = useState(-1);
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef(null); const inputRef = useRef(null);
const listRef = useRef(null); const listRef = useRef(null);
@@ -49,6 +49,7 @@ export default function SearchableSelect({ value, options, onChange, onClose, pl
if (autoFocus && inputRef.current) { if (autoFocus && inputRef.current) {
inputRef.current.focus(); inputRef.current.focus();
inputRef.current.select(); inputRef.current.select();
setIsOpen(true);
} }
}, [autoFocus]); }, [autoFocus]);
@@ -79,25 +80,31 @@ export default function SearchableSelect({ value, options, onChange, onClose, pl
e.preventDefault(); e.preventDefault();
if (highlightIdx >= 0 && filteredOptions[highlightIdx]) { if (highlightIdx >= 0 && filteredOptions[highlightIdx]) {
onChange(filteredOptions[highlightIdx]); onChange(filteredOptions[highlightIdx]);
setFilter(filteredOptions[highlightIdx]);
} else if (filter.trim()) { } else if (filter.trim()) {
onChange(filter.trim()); onChange(filter.trim());
} }
setIsOpen(false);
if (onClose) onClose(); if (onClose) onClose();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setIsOpen(false);
if (onClose) onClose(); if (onClose) onClose();
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
// Accept current value on tab
if (highlightIdx >= 0 && filteredOptions[highlightIdx]) { if (highlightIdx >= 0 && filteredOptions[highlightIdx]) {
onChange(filteredOptions[highlightIdx]); onChange(filteredOptions[highlightIdx]);
setFilter(filteredOptions[highlightIdx]);
} else if (filter.trim()) { } else if (filter.trim()) {
onChange(filter.trim()); onChange(filter.trim());
} }
setIsOpen(false);
if (onClose) onClose(); if (onClose) onClose();
} }
}; };
const handleSelect = (opt) => { const handleSelect = (opt) => {
onChange(opt); onChange(opt);
setFilter(opt);
setIsOpen(false);
if (onClose) onClose(); if (onClose) onClose();
}; };
@@ -106,16 +113,18 @@ export default function SearchableSelect({ value, options, onChange, onClose, pl
<input <input
ref={inputRef} ref={inputRef}
style={{ style={{
background: '#0F172A', border: '1px solid #7C3AED', borderRadius: isOpen ? '0.375rem 0.375rem 0 0' : '0.375rem', background: '#0F172A', border: isOpen ? '1px solid #7C3AED' : '1px solid #334155', borderRadius: isOpen ? '0.375rem 0.375rem 0 0' : '0.375rem',
color: '#E2E8F0', padding: '0.3rem 0.5rem', fontSize: '0.7rem', width: '100%', color: '#E2E8F0', padding: '0.3rem 0.5rem', fontSize: '0.7rem', width: '100%',
fontFamily: "'JetBrains Mono', monospace", outline: 'none', boxSizing: 'border-box', fontFamily: "'JetBrains Mono', monospace", outline: 'none', boxSizing: 'border-box',
}} }}
value={filter} value={filter}
onChange={e => { setFilter(e.target.value); setHighlightIdx(-1); setIsOpen(true); }} onChange={e => { setFilter(e.target.value); setHighlightIdx(-1); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => { onBlur={() => {
// Delay close so click on option can fire // Delay close so click on option can fire
setTimeout(() => { setTimeout(() => {
setIsOpen(false);
if (filter.trim() && filter.trim() !== value) { if (filter.trim() && filter.trim() !== value) {
onChange(filter.trim()); onChange(filter.trim());
} }