import * as React from 'react'; import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks'; import { cn } from '../../../lib/utils'; export interface InputProps extends React.ComponentProps<"input"> { /** * When provided, the input value is persisted to storage under this key. * * Rules: * - If the parent passes `value` (controlled), storage is used for initial * hydration only on first mount (defaultValue path is not applicable), but * writes are still saved so the next mount can restore the value. * In fully controlled mode the parent owns the value — storage just seeds * the `defaultValue` on next fresh mount if the parent doesn't supply one. * - If the parent passes `defaultValue` or neither, the component is * uncontrolled — storage provides the default and tracks changes via * an onChange wrapper. * * @example storageKey="search-filter" */ storageKey?: string; /** * Which storage backend to use. * @default 'local' */ storageType?: StorageType; /** * TTL in ms. Stored value expires after this duration. */ storageTtl?: number; /** * Control height/density. * - `default` — 40px (forms, standalone fields). * - `sm` — 36px, smaller text (inline edits, dense rows, settings chips). */ inputSize?: 'default' | 'sm'; } /** * Shared input styling — single source so controlled/uncontrolled branches * stay identical. * * Surface: `bg-input` (a token a notch off the page/panel) instead of * `bg-transparent`, so the field reads as a real input on any surface (cards, * dialogs) rather than a near-black hole. * * Focus: a CRISP thin accent outline — the border turns the `--ring` colour * and a tight 1px solid ring of the same colour doubles it to a clean ~2px * edge (NO blurry translucent halo). `:focus-visible` only, so mouse users on * other controls don't get rings. This is the sharp Vercel/Linear look. */ const INPUT_BASE = "flex w-full rounded-[var(--radius)] border border-border bg-input shadow-sm transition-[color,background-color,border-color,box-shadow] file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"; const INPUT_SIZE: Record, string> = { default: "h-10 px-3 py-2 text-base md:text-sm", sm: "h-9 px-2.5 py-1.5 text-sm", }; /** Resolve the full input class for a given size. */ export const inputClass = (size: NonNullable = 'default') => cn(INPUT_BASE, INPUT_SIZE[size]); /** Default-size input styling — kept as a named export for reuse (e.g. Editable). */ export const INPUT_CLASS = inputClass('default'); /** * Textarea styling — shares the SAME border/surface/focus base as inputs (one * source of truth) with multi-line height/padding instead of a fixed height. */ export const TEXTAREA_CLASS = cn(INPUT_BASE, 'min-h-[60px] px-3 py-2 text-sm'); const Input = React.forwardRef( ({ className, type, storageKey, storageType, storageTtl, inputSize = 'default', onChange, defaultValue, value, ...props }, ref) => { const resolvedClass = cn(inputClass(inputSize), className); const storageOptions: UseStoredValueOptions | undefined = storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined; const [storedValue, setStoredValue] = useStoredValue( storageKey, // seed: use provided defaultValue or controlled value as fallback seed (defaultValue as string | undefined) ?? (value as string | undefined) ?? '', storageOptions, ); // Wrap onChange to persist to storage on every change. // Only active when storageKey is provided. const handleChange = React.useCallback( (e: React.ChangeEvent) => { if (storageKey) { setStoredValue(e.target.value); } onChange?.(e); }, [storageKey, setStoredValue, onChange], ); // If parent passes explicit `value` — fully controlled, we don't inject // defaultValue (React would warn about switching modes). We still write // to storage on change via handleChange. if (value !== undefined) { return ( ); } // Uncontrolled: inject stored value as defaultValue (if no defaultValue given). // React requires defaultValue to be stable on first render — storedValue from // useLocalStorage is initialValue on SSR and the real value after hydration. // This is fine: defaultValue only seeds the initial DOM value. return ( ); }, ); Input.displayName = "Input" export { Input }