102 lines
3.2 KiB
JavaScript
102 lines
3.2 KiB
JavaScript
// src/components/ui/SearchBar.jsx
|
|
// Debounced search input with a magnifier icon and animated clear button.
|
|
//
|
|
// Props:
|
|
// value — string — controlled value (optional; uncontrolled if omitted)
|
|
// onChange — (value: string) => void — called after debounce delay
|
|
// placeholder — string (default: 'Search…')
|
|
// debounce — number — ms delay before onChange fires (default: 300)
|
|
// disabled — boolean
|
|
// className — extra classes on the wrapper
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
export default function SearchBar({
|
|
value: controlledValue,
|
|
onChange,
|
|
placeholder = 'Search…',
|
|
debounce = 300,
|
|
disabled = false,
|
|
className = '',
|
|
}) {
|
|
const isControlled = controlledValue !== undefined
|
|
const [localValue, setLocalValue] = useState(isControlled ? controlledValue : '')
|
|
const timerRef = useRef(null)
|
|
const inputRef = useRef(null)
|
|
const prevControlledRef = useRef(controlledValue)
|
|
|
|
// Only sync from outside when the parent *explicitly* changes the value
|
|
// (e.g. "Clear filters" resets it to ''). Do NOT sync on every re-render —
|
|
// that's what caused the keystroke lag: the debounced parent setState was
|
|
// writing back into this input and overwriting what the user just typed.
|
|
useEffect(() => {
|
|
if (isControlled && controlledValue !== prevControlledRef.current) {
|
|
prevControlledRef.current = controlledValue
|
|
setLocalValue(controlledValue)
|
|
}
|
|
}, [controlledValue, isControlled])
|
|
|
|
function handleChange(e) {
|
|
const v = e.target.value
|
|
setLocalValue(v)
|
|
prevControlledRef.current = v // keep ref in sync so the effect doesn't clobber it
|
|
if (debounce > 0) {
|
|
clearTimeout(timerRef.current)
|
|
timerRef.current = setTimeout(() => onChange?.(v), debounce)
|
|
} else {
|
|
onChange?.(v)
|
|
}
|
|
}
|
|
|
|
function handleClear() {
|
|
setLocalValue('')
|
|
prevControlledRef.current = ''
|
|
clearTimeout(timerRef.current)
|
|
onChange?.('')
|
|
inputRef.current?.focus()
|
|
}
|
|
|
|
const displayValue = localValue
|
|
|
|
return (
|
|
<div className={`searchbar ${className}`}>
|
|
{/* Search icon */}
|
|
<span className="searchbar-icon" aria-hidden="true">
|
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="6.5" cy="6.5" r="4.5" />
|
|
<path d="M10 10l3.5 3.5" />
|
|
</svg>
|
|
</span>
|
|
|
|
<input
|
|
ref={inputRef}
|
|
type="search"
|
|
className="searchbar-input"
|
|
value={displayValue}
|
|
onChange={handleChange}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
aria-label={placeholder}
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
spellCheck="false"
|
|
/>
|
|
|
|
{/* Clear button — only when there's text */}
|
|
{displayValue && !disabled && (
|
|
<button
|
|
type="button"
|
|
className="searchbar-clear"
|
|
onClick={handleClear}
|
|
aria-label="Clear search"
|
|
tabIndex={-1}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
|
<path d="M1 1l8 8M9 1L1 9" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|