Initial commit: Fast Inventory app
Monorepo with Express + SQLite backend and Vite + React frontend. Features: item CRUD, file uploads with thumbnails, soft delete, item duplication with file copying, autocomplete inputs, stats dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
106
client/src/components/AutocompleteInput.tsx
Normal file
106
client/src/components/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export default function AutocompleteInput({ value, onChange, suggestions, placeholder, className, onKeyDown }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlightIdx, setHighlightIdx] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const filtered = value
|
||||
? suggestions.filter(s => s.toLowerCase().includes(value.toLowerCase()))
|
||||
: suggestions;
|
||||
|
||||
const handleSelect = useCallback((val: string) => {
|
||||
onChange(val);
|
||||
setOpen(false);
|
||||
setHighlightIdx(-1);
|
||||
}, [onChange]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!open || filtered.length === 0) {
|
||||
// Tab with no dropdown: accept what's typed, let focus move naturally
|
||||
onKeyDown?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIdx(i => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIdx(i => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && highlightIdx >= 0) {
|
||||
e.preventDefault();
|
||||
handleSelect(filtered[highlightIdx]);
|
||||
} else if (e.key === 'Tab' && highlightIdx >= 0) {
|
||||
// Tab accepts highlighted suggestion
|
||||
handleSelect(filtered[highlightIdx]);
|
||||
// Don't prevent default — let focus move to next field
|
||||
} else if (e.key === 'Tab' && filtered.length > 0 && value) {
|
||||
// Tab with text typed: accept first match
|
||||
handleSelect(filtered[0]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setHighlightIdx(-1);
|
||||
} else {
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightIdx >= 0 && listRef.current) {
|
||||
const el = listRef.current.children[highlightIdx] as HTMLElement;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [highlightIdx]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
setHighlightIdx(-1);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
// Delay to allow click on dropdown items
|
||||
setTimeout(() => setOpen(false), 150);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="absolute z-50 left-0 min-w-full w-max top-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-40 overflow-y-auto text-sm"
|
||||
>
|
||||
{filtered.slice(0, 10).map((item, idx) => (
|
||||
<li
|
||||
key={item}
|
||||
className={`px-3 py-1.5 cursor-pointer ${idx === highlightIdx ? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}`}
|
||||
onMouseDown={() => handleSelect(item)}
|
||||
onMouseEnter={() => setHighlightIdx(idx)}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user