Auto Save Input
An input that debounces edits and persists them via an async onSave, with an inline idle/saving/saved/error status.
- Status
- Since
0.1.0- Accessibility pattern
- native input + status region
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Auto Save Input before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify auto-save-inputInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/auto-save-inputRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/input-group, @vegastack/spinner.
Usage
import { AutoSaveInput } from "@/components/ui/auto-save-input";
<AutoSaveInput
aria-label="Workspace name"
defaultValue={workspace.name}
onSave={async (name) => {
await updateWorkspace({ name });
}}
validate={(v) => v.trim().length > 0}
/>;AutoSaveInput composes InputGroup around an
InputGroupInput — upstream's Input flattened into the
box — so the trailing status indicator sits in a real addon slot rather than on top
of a padded field. It owns its value as
local state from defaultValue (or follows value + onValueChange when
controlled), debounces keystrokes, and calls onSave(value) once the field
settles. A trailing indicator reflects the outcome: a spinner while saving, a
success check once saved, and an error cross on failure.
Presentational component
The component owns only the debounce timer and the inline status UI. It does
not persist anything itself — onSave is yours, and so is everything around
it (success/error toasts, optimistic cache updates, cross-field effects).
There is no toast coupling; if you want a toast, fire it from onSave or
onStatusChange. This split keeps the component reusable across apps while
the app keeps the side effects.
Anatomy
Examples
A failing onSave (rejected promise) flags the error state and sets
aria-invalid; a failing validate skips the save entirely and flags error
without ever calling onSave. Edit the field below and pause — it saves after
800ms; clear it and the validator blocks the save.
States
Status is owned by the component and only advances when the field is edited, so this is a live example — type into each field and pause for the debounce to watch the trailing indicator move. Each field is wired to a different outcome:
- Saving — a deliberately slow
onSaveholds the spinningLoaderin flight. - Saved — a fast
onSaveresolves to thetext-success-textCheck. - Error (rejecting onSave) — a rejected promise flags the
text-destructive-textXand setsaria-invalid. - Error (failed validate) — clearing the field fails
validate, which flagserrorwithout ever callingonSave.
Controlled value
Pass value + onValueChange to let the parent own the draft. External value
changes — like switching records — are treated as a new saved baseline, so
moving between records never auto-saves stale data and never re-flags a status.
Edit the field to save after the debounce, or switch records to reset the
baseline.
Editing saves after the debounce; switching records resets the baseline.
Status indicator
AutoSaveIndicator is the same lifecycle as a line of text for an action row: a spinner and
"Saving…", a check and "Saved", and the -text ink with its own icon for "Couldn't save" and a
conflict ("Not saved"). It is a polite live region, empty while idle.
Icon only
variant="icon" is the same status as one 16px icon for a field's trailing slot — an
InputGroupAddon align="inline-end" on an input or textarea, or beside a select: a muted
spinner while saving, a text-success-text check once saved, a text-destructive-text
warning triangle when the save failed (text-warning-text on a conflict). Each state pops in
(a fade and a 0.9 → 1 scale on the motion tokens), the check fades out over duration-slow
when the status returns to idle, and the box is always reserved, so nothing shifts. The
wording ("Saving", "Saved", "Couldn't save") stays as screen-reader text in the live region;
reduced motion swaps instantly.
An invalid value
A value the field rejects (an empty required name) is not a save state: nothing was sent. Pass
invalid and the indicator — either variant — drops the tick and says Not saved in the
warning ink, so a field never shows "saved" beside its own error. useInvalidRevert finishes the
job: leave the field while it is invalid and, a second later, onRevert puts the last saved value
back and shows why ("Name can't be empty"); coming back to the field, or fixing the value, cancels
it. AutoSaveInput does both itself — a value validate rejects reads "Not saved", and
onInvalidRevert turns on the revert.
In a top bar
compact="narrow" keeps a text indicator to one line and, below sm or in a narrow
AppShellHeader (under 40rem), shows the icon alone — the words are still read out. Use it for a
document's connection state ("Reconnecting…") among a top bar's actions.
<AutoSaveIndicator
status="connecting"
compact="narrow"
labels={{ connecting: "Reconnecting…" }}
/>A whole form (useAutoSave)
useAutoSave saves a form object rather than one field: debounced (600ms), only the changed
fields, one save in flight at a time with the latest values sent next, a version carried for
optimistic concurrency (isConflict turns a stale-version error into the conflict status),
and a flush() to call before a step change or navigation. Pending changes are also saved on
unmount and when the tab closes.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
onSave* | (value: string) => Promise<void> | — | Async persistence callback invoked after the debounce window when the value
changed. Resolve to flag saved; reject (or throw) to flag error. The
status indicator reflects the outcome inline — the app may also react here
(e.g. fire a toast), but the component never couples to one. |
credential | boolean | true | false (FRM-17) marks a name-like field that is not a credential — a record, table or field
name — so browsers and password managers stop offering to fill it: autoComplete="off",
data-1p-ignore, data-lpignore="true", data-bwignore. An explicit autoComplete wins. |
debounceMs | number | 800 | Debounce delay in milliseconds between the last keystroke and the onSave
call. Keystrokes within the window reset the timer. |
defaultValue | string | '' | Initial value for uncontrolled use. The component owns the draft from here on and compares typed input against the last saved value to decide whether a save is needed. |
onInvalidRevert | ((savedValue: string) => void) | — | Leaving the field while validate rejects its value puts the last saved value back after
revertDelay, then calls this with it — show the host's message here ("Name can't be
empty"). Omit to leave the invalid value in place. |
onStatusChange | ((status: AutoSaveStatus) => void) | — | Fired whenever the save status changes. Use it to drive surrounding UI (disable a submit button, etc.) without re-deriving the state yourself. |
onValueChange | ((value: string) => void) | — | Fired whenever the draft value changes. Required for controlled value
usage; optional for uncontrolled defaultValue usage. |
revertDelay | number | 1000 | Milliseconds after the field is left before an invalid value is reverted. |
size | "default" | "lg" | "sm" | null | — | |
validate | ((value: string) => boolean) | — | Optional synchronous guard run before saving — return false to skip the
save and surface the error status (e.g. empty or malformed input). |
value | string | — | Controlled value of the field. Pair with onValueChange so user edits are
mirrored by the parent. External value changes are treated as a new saved
baseline, so switching records never auto-saves stale data. |
variant | "default" | "ghost" | null | — |
Data attributes and CSS variables on AutoSaveInput
| Attribute | Values |
|---|---|
data-slot | "auto-save-input" | "auto-save-input-status" |
data-state | mirrors a prop or state value |
AutoSaveIndicator
| Prop | Type | Default | Description |
|---|---|---|---|
status* | AutoSaveIndicatorStatus | — | The status to show — useAutoSave().status, or a live document's connection (connecting,
offline). idle shows nothing. |
compact | "narrow" | — | text only: where the wording yields to the icon alone (still read out). narrow — below
sm and in a narrow AppShellHeader (under 40rem), for a status in a top bar's actions. |
invalid | boolean | false | The field's value is invalid, so it is not being saved: the tick and spinner give way to a
visible "Not saved" in the warning ink — in both variants, the icon variant included — so a
field never shows "saved" beside its own error. Whatever status says. |
labels | Partial<Record<"invalid" | ShownStatus, string>> | { saving: 'Saving…', saved: 'Saved', error: "Couldn't save", conflict: 'Not saved', connecting: 'Connecting…', offline: 'Offline — changes will sync', invalid: 'Not saved' } (icon: 'Saving', 'Saved', "Couldn't save", 'Not saved', 'Connecting', 'Offline — changes will sync', 'Not saved') | Replaces the default wording per status — the visible line for text, the screen-reader
label for icon (a document kept on the device says so: offline: "Offline — saved on this
device") — and invalid, which both variants show. |
variant | "icon" | "text" | 'text' | text — icon and wording, for an action row. icon — the icon alone in a fixed 16px box,
for a field's trailing slot (InputGroupAddon align="inline-end", or beside a select): a
muted spinner while saving or connecting, a muted check once saved (it fades out when the
status returns to idle), a warning-ink triangle on error or conflict, and a warning-ink
cloud-off while offline.
The wording stays as screen-reader text in the live region. |
Data attributes and CSS variables on AutoSaveIndicator
| Attribute | Values |
|---|---|
data-compact | mirrors a prop or state value |
data-invalid | "" |
data-slot | "auto-save-indicator" | "auto-save-indicator-text" |
data-state | mirrors a prop or state value |
data-variant | "icon" |
useAutoSave
| Prop | Type | Default | Description |
|---|---|---|---|
onSave* | (changes: Partial<T>, context: { version: number | undefined; }) => Promise<{ version?: number; } | void> | — | Persists the CHANGED fields only — the keys whose value differs from what was last
saved. Receives the version the host last reported (or the previous save returned) for
optimistic concurrency; resolve with { version } to advance it, reject to flag an error. |
value* | T | — | The current values. Every change starts (or restarts) the debounce. |
delay | number | 600 | Quiet time after the last change before saving, in milliseconds. |
enabled | boolean | true | Whether saving is on. Turning it on takes the current value as the saved baseline, so
a record that has just been created is not saved a second time. |
isConflict | ((error: unknown) => boolean) | — | Recognises a stale-version error, which becomes the conflict status. |
version | number | — | The record's version as the host knows it. A higher value (another write moved the record on) is adopted before the next save; a lower one is ignored. |
useInvalidRevert
| Prop | Type | Default | Description |
|---|---|---|---|
invalid* | boolean | — | The field's value is invalid now — the value that would be saved is not. |
onRevert* | () => void | — | Puts the last saved value back in the field — and shows the host's message ("Name can't be
empty"). Called once, delay after the field is left while still invalid. |
delay | number | 1000 | Milliseconds after the field is left before the value is reverted. |
AutoSaveStatus is the union of states the trailing indicator can reflect; pass
onStatusChange to react to transitions (e.g. disable a submit button while
saving).
| Prop | Type | Default | Description |
|---|---|---|---|
AutoSaveStatus | "idle" | "saving" | "saved" | "error" | — | Lifecycle of an auto-save: idle (no pending change), saving (onSave in flight), saved (last save resolved), error (rejected or failed validation). |
Accessibility
- Renders a native
<input>— always associate a visible<label>(wrap it or usehtmlFor/id); usearia-labelonly when a visible label is impossible. - Status is conveyed by a distinct icon per state (spinner / check / cross) paired with a semantic color token, so it never depends on color alone.
- A single
role="status" aria-live="polite" aria-atomic="true"slot announces"Saving","Saved","Save failed", or"Not saved"(a valuevalidaterejects) as hidden text while the visible icon remains decorative. AutoSaveIndicator invalidshows "Not saved" as visible text, in the same live region, never a check beside an error.- The
errorstatus setsaria-invalidon the field, matchingInput's invalid styling and announcing the failure to screen readers. - The spinner respects
prefers-reduced-motion(motion-reduce:animate-none). - On focus the underlying
InputGroupdarkens its border subtly (border-ring/50, easing in over 150ms) and paints no fill — text entry's exception to FOC-14 (MK 2026-09-27) — instead of removing the affordance; neveroutline: nonewith nothing in its place. Theerrorstatus keeps its destructive border while focused.
| Key | Action |
|---|---|
| Any text key | Edits the value and (re)starts the debounce timer. |
Tab | Moves focus to/from the field as a normal input. |
| Contract | States tested |
|---|---|
| Behaviour | default, active, disabled, empty, error, invalid, saved, saving, success |
| Accessibility | disabled, invalid, labeled, live, status-announcement, semantic-html |
| Visual | default, invalid, error, success, empty |
Do / Don't
Chip Input
Free-token entry — Enter or comma commits, delimited pastes split into chips, and per-chip validation keeps invalid entries visible instead of dropping them.
Dropzone
File acquisition — drop, browse, or paste — as a thin shell over the use-file-drop hook; the drop surface is the named, focusable control.