02examples
Natural-language event entry
Type "lunch with Sam tomorrow 1pm" into a box above the grid; the parser underlines what it understood and the event lands.
A time-blocking planner: one line of text above the grid. As you type, the parser claims the phrases it understands — a day, a time or range, a duration, a place, an email — and the box underlines each one in place. The row underneath reads the draft back: title, resolved when, and every claimed span with its kind. Enter commits it to the calendar and moves the view to that day.
Try the three phrases, or write your own.
"use client";
import { useMemo, useRef, useState } from "react";
import {
Kloq,
formatInstant,
highlightSegments,
newEventId,
parseQuickAdd,
quickAddPreview,
toDateOnlyString,
useKloq,
useKloqStore,
type CalEvent,
} from "kloq";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";
import { EVENTS, PHRASES } from "./data";
// the input and the mirror under it must share these metrics exactly
const TYPE = "px-4 py-2.5 font-sans text-sm leading-6 tracking-normal";
const dayAfter = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
function Planner() {
const store = useKloqStore();
const k = useKloq();
const inputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [added, setAdded] = useState<string | null>(null);
const { result, preview } = useMemo(() => {
const now = new Date();
const result = parseQuickAdd(text, now, { defaultDurationMin: 60 });
return { result, preview: quickAddPreview(result, now) };
}, [text]);
const segments = highlightSegments(text, result.tokens);
const commit = () => {
if (!result.start || !result.end) return;
const base = {
id: newEventId(),
title: preview.title,
color: "emerald" as const,
...(result.location ? { location: result.location } : {}),
...(result.attendees.length ? { attendees: result.attendees.map((email) => ({ email })) } : {}),
};
// the parser's all-day `end` is the inclusive last day; the store's is exclusive
const event: CalEvent = result.allDay
? { ...base, allDay: true, start: toDateOnlyString(result.start), end: toDateOnlyString(dayAfter(result.end)) }
: { ...base, start: formatInstant(result.start), end: formatInstant(result.end) };
store.add(event);
k.goToDate(result.start);
setAdded(`${preview.title} · ${preview.when}`);
setText("");
};
return (
<div className="border-b">
<div className="relative">
<div
aria-hidden
className={cn("pointer-events-none absolute inset-0 overflow-hidden whitespace-pre text-transparent", TYPE)}
>
{segments.map((s, i) => (
<span
key={i}
className={cn(s.kind && "rounded-[3px] bg-red-500/10 underline decoration-red-500 decoration-2 underline-offset-4")}
>
{s.text}
</span>
))}
</div>
<input
ref={inputRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
commit();
} else if (e.key === "Escape") setText("");
}}
placeholder="What are you planning? — “gym tomorrow morning for 45 mins”"
aria-label="Plan an event in plain English"
autoComplete="off"
spellCheck={false}
className={cn("relative w-full bg-transparent text-foreground caret-red-500 outline-none placeholder:text-muted-foreground", TYPE)}
/>
</div>
<div className="flex min-h-9 flex-wrap items-center gap-x-4 gap-y-1 border-t px-4 py-1.5 font-mono text-xs text-muted-foreground tabular-nums">
{text.trim() ? (
<>
<span className="text-foreground">{preview.title}</span>
<span>{preview.when}</span>
{preview.chips.map((chip) => (
<span key={chip}>· {chip}</span>
))}
{result.tokens.map((t) => (
<span key={t.start}>
{t.kind} <span className="text-foreground">“{t.text}”</span>
</span>
))}
<span className="ml-auto inline-flex items-center gap-1.5">
<Kbd>↵</Kbd> add
</span>
</>
) : (
<>
{PHRASES.map((phrase) => (
<button
key={phrase}
type="button"
onClick={() => {
setText(phrase);
inputRef.current?.focus();
}}
className="whitespace-nowrap rounded-md border px-2 py-0.5 transition-colors hover:text-foreground"
>
{phrase}
</button>
))}
{added ? (
<span className="ml-auto truncate text-foreground" aria-live="polite">
<span className="text-red-500">✓</span> {added}
</span>
) : null}
</>
)}
</div>
</div>
);
}
export default function QuickAddPlannerExample() {
return (
<div className="h-[34rem]">
<Kloq defaultEvents={EVENTS} persistence={false} designMode={false} storage={false}>
<Kloq.Toolbar />
<Planner />
</Kloq>
</div>
);
}
import { instantAt, resolveView, type CalEvent } from "kloq";
/** Each phrase exercises a different part of the grammar; all three parse with full confidence. */
export const PHRASES = [
"Deep work tomorrow from 9 to 11",
"Lunch with Sam friday 1pm at Dishoom",
"Review tuesday 2-3pm with jules@acme.com",
];
/** A light week so the planner has something to slot around. */
export function buildWeek(now: Date = new Date()): CalEvent[] {
const week = resolveView("week", 1, now);
const at = (day: number, hour: number, minute = 0) => instantAt(week, day, hour * 60 + minute);
return [
{ id: "standup", title: "Standup", color: "blue", start: at(0, 9, 30), end: at(0, 9, 45), rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" },
{ id: "design", title: "Design review", color: "violet", start: at(1, 14), end: at(1, 15) },
{ id: "interview", title: "Interview", color: "rose", start: at(3, 11), end: at(3, 11, 45) },
];
}
export const EVENTS = buildWeek();
How it works
parseQuickAdd(text, now, { defaultDurationMin })runs on every keystroke, in auseMemokeyed on the text.nowis an argument, not something the parser reads, so the parse is deterministic; the planner passes a freshnew Date()each time so "tomorrow" is always tomorrow. The grammar is on Quick add.highlightSegments(text, result.tokens)interleaves the input with its tokens. Concatenating the segments reproduces the input exactly, so the planner draws them in a transparent-text mirror under the real<input>, with matching padding and type metrics — the underline sits under the characters that were understood.quickAddPreview(result, now)folds the result into one line: a title that is never blank, a formatted when, and chips for location and attendees.- The planner is a child of
<Kloq>, which is what lets it calluseKloqStore()anduseKloq(). On Enter it builds aCalEventthe same way the built-in ⌘K quick add does —formatInstantfor timed events,toDateOnlyStringwith an exclusive end for all-day ones (the parser's all-dayendis inclusive) — thenstore.add(event)andk.goToDate(start). The event shapes are on Events. - A time with no day resolves to today, or tomorrow if that time has passed;
a day with no time is an all-day event; a bare number needs
at,fromor a meridiem before it counts as a time, so "sprint 2 to 4" stays a title.
Take it further
kloq exports the same field it uses inside ⌘K as QuickAddInput,
if you would rather not draw the mirror yourself. With a controlled events
array the commit becomes onEventsChange([...events, event]) and the parser
result can be sent to a server before it lands — see
Controlled state.