import * as React from 'react'; import { Input } from '@/components/ui/input'; // A text field that types into itself first. // // Preferences is one component holding two hundred pieces of state, and its // biggest panels are eight hundred lines of form. A plain controlled input // sends every keystroke into that state, so every character re-renders the // whole dialog — the external-services panel, the CAT panel — and the letter // appears after the finger has left the key. // // This keeps the text where it is being typed and hands it up shortly after. // The value shown is the operator's, immediately; the parent's copy catches up // a moment later, which is soon enough for anything that reads it — nothing in // a settings form acts on a half-typed word. // // It is a drop-in for Input, on purpose: the fix is a changed import, not a // hundred edited call sites. Which means it has to behave correctly in every // shape those call sites take: // // • Blur flushes at once, so clicking Save cannot lose the last word typed, // and so does unmounting — a panel changed mid-word still hands up what // was there. // • A value that comes back DIFFERENT from what was sent up is adopted, even // while the field has focus. That is how the fields which normalise as you // type keep working: a callsign box that upper-cases, a port box that // drops everything but digits. They echo a corrected value, and the // correction wins. // • A value changed from outside while the field is idle wins too — that is // how loading the settings, or switching profile, refills the form. // • Types that are not text — checkbox, colour, file — pass straight // through. There is no typing to buffer and their events are not text. const PASSTHROUGH = new Set(['checkbox', 'radio', 'file', 'color', 'range', 'submit', 'button', 'image', 'reset']); // Short enough that a normalising field corrects itself while the operator is // still on the same word, long enough that a burst of typing is one render. const DEBOUNCE_MS = 120; export const BufferedInput = React.forwardRef>( ({ value, onChange, onBlur, onFocus, type, ...props }, ref) => { const buffered = value !== undefined && !!onChange && !PASSTHROUGH.has(type ?? 'text'); const incoming = String(value ?? ''); const [local, setLocal] = React.useState(incoming); const focused = React.useRef(false); const timer = React.useRef(undefined); // What we last handed up. Anything else arriving from the parent is the // parent's own doing — a normalisation, a reload — and it wins. const emitted = React.useRef(incoming); const pending = React.useRef | null>(null); const onChangeRef = React.useRef(onChange); React.useEffect(() => { onChangeRef.current = onChange; }, [onChange]); React.useEffect(() => { if (!focused.current || incoming !== emitted.current) { setLocal(incoming); emitted.current = incoming; } }, [incoming]); const flush = React.useCallback(() => { window.clearTimeout(timer.current); timer.current = undefined; const e = pending.current; pending.current = null; if (e) { emitted.current = e.target.value; onChangeRef.current?.(e); } }, []); // Unmounted mid-word — the panel changed, the dialog closed — still hands // up what was typed. React.useEffect(() => () => { window.clearTimeout(timer.current); if (pending.current) onChangeRef.current?.(pending.current); }, []); if (!buffered) { return ; } return ( { focused.current = true; onFocus?.(e); }} onChange={(e) => { const v = e.target.value; setLocal(v); // The element's value changes again before the timer fires, so what // matters is copied out of it now. pending.current = { ...e, target: { ...e.target, value: v } } as React.ChangeEvent; window.clearTimeout(timer.current); timer.current = window.setTimeout(flush, DEBOUNCE_MS); }} onBlur={(e) => { focused.current = false; flush(); onBlur?.(e); }} {...props} /> ); }, ); BufferedInput.displayName = 'BufferedInput';