06interaction
Quick add input
The ⌘K quick-add field as a standalone component: the live underline, the preview line, and a commit you own.
The field inside ⌘K — type a sentence, watch the underline claim
the phrases it understood, press ↵ — ships on its own as
QuickAddInput. It is fully controlled and does no parsing: you hold the
text, run parseQuickAdd, and pass both in. The result
your commit handler acts on is by construction the one the underline drew —
there is no second parse to disagree with the first.
This one is live — type, or press a phrase to load one; ↵ "creates" into the list under the field:
Wiring it
Three pieces of state — the text, a clock, and the parse derived from them:
The component draws no outer border and opens no popover — it is the inside
of a surface, with its own separators between the input, the preview, and the
legend. Drop it into whatever holds it: a dialog, a card, a sidebar header.
Inside <Kloq>'s own ⌘K it sits in the command dialog; the demo
above wraps it in a plain bordered frame.
Props
| prop | type · description |
|---|---|
value | stringThe text, controlled. |
onValueChange | (value: string) => voidEvery keystroke. |
result | QuickAddResultThe parse of |
now | DateThe clock the parse used; the preview's when-line resolves against the same instant. |
onCommit | () => voidEnter on a non-empty value. Read the draft from the |
onBack? | () => voidEscape: a first press clears a non-empty value, a press on an empty one calls this. ⌘K uses it to return to the command list. |
autoFocus? | booleanFocus the input on mount; default true. Turn it off when the field isn't the reason the surface opened. |
announce? | booleanRead the preview line out as it changes; default true. |
Committing the draft
onCommit receives nothing because everything is already in your result.
One conversion matters when building a store event: the parser's all-day
end is the inclusive last day ("all day friday" starts and ends Friday),
while the store's all-day end is exclusive:
with title from quickAddPreview(result, now).title — never blank, even
for an input that was all metadata — and result.location / a mapped
result.attendees filling the rest.
How the underline works
The <input> renders its text transparent; underneath sits an aria-hidden
mirror div holding the same string split by highlightSegments into plain
and understood runs, the understood ones underlined and tinted by token kind.
Each kind has its own underline style as well as its own colour
(QUICK_ADD_KINDS), and a phrase the parser read but overruled — a length
beside an explicit range — is struck through instead, from result.ignored.
The two layers share one set of type metrics and the mirror copies the
input's scrollLeft, so the highlight sits under the characters at every
width. The parser's token guarantee — sorted, disjoint, in bounds,
input.slice(t.start, t.end) === t.text — is what makes the trick safe:
concatenating the segments reproduces the input exactly, so the overlay can
never drift.
The preview line under the field is quickAddPreview — title, resolved
when, chips, and a struck-through "ignored:" note when the parse overruled
something — in a fixed-height box (a growing one would shift everything
below it on each keystroke), announced politely to screen readers via
aria-live and tied to the input with aria-describedby. The legend maps
the underline colors to kinds, so color is never the only channel. Pass
announce={false} to keep the preview out of the live region — for a demo
that drives value itself, where every scripted keystroke would be read out.
Escape inside the field stops its own propagation — clearing the text
doesn't close a dialog around it. The surrounding surface only sees Escape
once the field is empty and has handed control back through onBack.
The grammar itself — what parses, the claim table, dateOrder and
defaultDurationMin — lives on the quick add page, and
MoveToInput, its sibling for rescheduling,
on the command palette page.