"use client"; import { useEffect, useRef, useState } from "react"; export type DraftParseResult = { ok: true; value: T } | { ok: false; error: string }; interface DraftInputProps { ariaLabel: string; className?: string; disabled?: boolean; format: (value: T) => string; /** Printed text; defaults to the input text. */ printFormat?: (value: T) => string; inputMode?: "decimal" | "text"; maxLength?: number; onCommit: (value: T) => void; /** Reports whether the raw text is currently valid (false while invalid). */ onValidityChange?: (isValid: boolean) => void; parse: (text: string) => DraftParseResult; placeholder?: string; value: T; } /** * Input that keeps the raw text the user typed. Only a valid text is * committed into the document; an invalid text stays visible with a red * border and is never saved. A value changed from outside (reload, undo) * replaces the text unless the text already represents that value. */ export function DraftInput({ ariaLabel, className = "estimate-cell-input", disabled, format, printFormat, inputMode = "text", maxLength, onCommit, onValidityChange, parse, placeholder, value, }: DraftInputProps) { const [text, setText] = useState(() => format(value)); const [error, setError] = useState(null); const [syncedValue, setSyncedValue] = useState(value); if (!Object.is(syncedValue, value)) { // Adjusting state while rendering (instead of an effect) avoids a frame // with stale text after an external change. setSyncedValue(value); const parsed = parse(text); if (!parsed.ok || !Object.is(parsed.value, value)) { setText(format(value)); setError(null); } } const validityRef = useRef(onValidityChange); useEffect(() => { validityRef.current = onValidityChange; }); const isValid = error === null; useEffect(() => { validityRef.current?.(isValid); }, [isValid]); // A removed input (deleted row) must not leave an invalid marker behind. useEffect(() => () => validityRef.current?.(true), []); function handleChange(nextText: string) { setText(nextText); const parsed = parse(nextText); if (!parsed.ok) { setError(parsed.error); return; } setError(null); if (!Object.is(parsed.value, value)) { setSyncedValue(parsed.value); onCommit(parsed.value); } } function handleBlur() { // Show the canonical notation again ("0,80" → "0,8") once editing ends. if (error === null) { setText(format(value)); } } return ( <> handleChange(event.target.value)} placeholder={placeholder} title={error ?? undefined} type="text" value={text} /> {printFormat ? printFormat(value) : error ? format(value) : text} ); }