FieldDraft
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/field-draft.jsonA field that writes somewhere holds two things at once — what is stored and what is in the box — and every product that wrote one by hand broke a different rule between them. kilna broke all of them, one screen at a time:
- Leaving writes only a change. The Overview wrote on every blur, so tabbing through twelve fields was twelve operations in the history and twelve toasts.
- While nobody is typing, the box follows the stored value. The fields were uncontrolled, so a value a plugin wrote kept showing the old one — and the next blur wrote the old one back over it.
- While someone is typing, their words win until they leave. A refetch in the middle of a sentence does not take it away from them.
- Escape puts back what is stored and writes nothing — and stops there, so a screen-level Escape (closing a panel, a dialog) does not also fire.
- Enter on something unreadable keeps the words and says so, with
aria-invalid. Leaving with them puts the stored value back.
useFieldDraft is those rules wired to an input: spread what it returns onto an
Input or a Textarea.
InlineField is built on it; this is for a
field of your own.
const title = useFieldDraft(work.title, (text) => save({ title: text }))<Input aria-label={t('title')} {...title} />
const notes = useFieldDraft(work.notes, (text) => save({ notes: text }), { multiline: true })<Textarea aria-label={t('notes')} {...notes} />The value is text on both sides. The hook compares what is typed with what
is stored as strings, so a value that is not text is spelled first —
format(value) in, parse(text) in onCommit. readable says which texts can
be written at all; unreadable text is never handed to onCommit.
onCommit can refuse by returning false — a server that said no — and the
box goes back to what is stored.
Enter leaves the field, which writes it — except with multiline, where
Enter is a new line, and never while an input method is composing, where Enter
picks the candidate.
stepDraft is the whole of it, pure. The rules are a function from a state
and an event to the next state and the text to write, tested without a DOM;
the hook only wires it. Use it directly for a field the hook does not fit.
useFieldDraft( value: string, onCommit: (text: string) => boolean | void, options?: { multiline?: boolean; readable?: (text: string) => boolean },): { value, 'aria-invalid', onFocus, onChange, onBlur, onKeyDown }
stepDraft(state: DraftState, event: DraftEvent): { state: DraftState; commit: string | null }startDraft(stored: string): DraftState| Event | |
|---|---|
stored |
The stored value moved underneath — a refetch, a plugin, an undo |
focus / type |
Someone is in the field, typing |
refuse |
Enter on unreadable text: keep it, mark it |
escape |
Put back what is stored |
leave |
Write the text if it changed |
Accessibility
The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 14 rules applied and passed, and none failed. The accessibility report has every item.
History
Each release that changed ui/field-draft.tsx - the file a product copies - newest first.