tables
AI Fill Drawer
A record drawer of key/value fields where the model fills the empty values — each arrives as a sourced suggestion to accept, edit or reject.
CompanyIndustry
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, RotateCcw, Sparkles, Square, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FieldDef {
key: string;
label: string;
/** A value the user (or system) already set. `null` means empty. */
value: string | null;
/** Set false for fields the model must never fill (IDs, owners, legal names…). */
fillable?: boolean;
}
export interface FillResult {
/** `null` when the model couldn't find a value it trusts. */
value: string | null;
/** Where the value came from — shown under the suggestion. */
source?: string;
}
type FieldState =
| { status: "set"; value: string }
| { status: "empty" }
| { status: "filling" }
| { status: "suggested"; value: string; source?: string }
| { status: "missing" };
export interface AiFillDrawerProps {
open: boolean;
onClose: () => void;
title: string;
subtitle?: string;
fields: FieldDef[];
/** Fills a single field. Called once per empty, fillable field. */
onFill: (key: string) => Promise<FillResult>;
/** Fires whenever a value is committed — typed, or an accepted suggestion. */
onChange?: (key: string, value: string | null) => void;
className?: string;
}
const initialState = (f: FieldDef): FieldState =>
f.value ? { status: "set", value: f.value } : { status: "empty" };
export function AiFillDrawer({
open,
onClose,
title,
subtitle,
fields,
onFill,
onChange,
className,
}: AiFillDrawerProps) {
const reduceMotion = useReducedMotion();
const [state, setState] = React.useState<Record<string, FieldState>>(() =>
Object.fromEntries(fields.map((f) => [f.key, initialState(f)]))
);
const [editing, setEditing] = React.useState<string | null>(null);
/** Bumped by Stop — results from an older run are dropped. */
const run = React.useRef(0);
const panelRef = React.useRef<HTMLElement>(null);
const wasOpen = React.useRef(open);
React.useEffect(() => {
const justOpened = open && !wasOpen.current;
wasOpen.current = open;
if (!open) return;
if (justOpened) panelRef.current?.focus({ preventScroll: true });
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && !(e.target instanceof HTMLInputElement)) onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const patch = (key: string, next: FieldState) => setState((s) => ({ ...s, [key]: next }));
const isFillable = (f: FieldDef) => f.fillable !== false;
const fillableEmpty = fields.filter(
(f) => isFillable(f) && (state[f.key].status === "empty" || state[f.key].status === "missing")
);
const filling = fields.filter((f) => state[f.key].status === "filling");
const suggested = fields.filter((f) => state[f.key].status === "suggested");
const busy = filling.length > 0;
function fill(keys: string[]) {
const id = run.current;
for (const key of keys) {
patch(key, { status: "filling" });
onFill(key)
.then((r) => {
if (run.current !== id) return;
patch(key, r.value ? { status: "suggested", value: r.value, source: r.source } : { status: "missing" });
})
.catch(() => run.current === id && patch(key, { status: "missing" }));
}
}
function stop() {
run.current += 1;
setState((s) =>
Object.fromEntries(
Object.entries(s).map(([k, v]) => [k, v.status === "filling" ? { status: "empty" } : v])
)
);
}
function commit(key: string, value: string | null) {
patch(key, value ? { status: "set", value } : { status: "empty" });
onChange?.(key, value);
}
function accept(key: string) {
const s = state[key];
if (s.status === "suggested") commit(key, s.value);
}
function reject(key: string) {
patch(key, { status: "empty" });
}
return (
<AnimatePresence>
{open && (
<motion.aside
ref={panelRef}
tabIndex={-1}
aria-label={title}
initial={reduceMotion ? { opacity: 0 } : { x: "100%", opacity: 0.6 }}
animate={{ x: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { x: "100%", opacity: 0.6 }}
transition={{ type: "spring", stiffness: 380, damping: 38 }}
className={cn(
"flex h-full w-full max-w-sm flex-col overflow-hidden border-l bg-background text-sm shadow-xl outline-none",
className
)}
>
<header className="flex items-start gap-2 px-5 pb-3 pt-4">
<div className="min-w-0 flex-1">
<h2 className="truncate font-medium">{title}</h2>
{subtitle && <p className="truncate text-xs text-muted-foreground">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="size-4" />
</button>
</header>
<div className="flex items-center gap-2 border-b px-5 pb-3">
{busy ? (
<button
type="button"
onClick={stop}
className="flex h-8 items-center gap-1.5 rounded-full border px-3 font-medium transition-colors hover:bg-accent"
>
<Square className="size-3 fill-current" aria-hidden />
Stop
</button>
) : (
<button
type="button"
onClick={() => fill(fillableEmpty.map((f) => f.key))}
disabled={fillableEmpty.length === 0}
className="flex h-8 items-center gap-1.5 rounded-full border px-3 font-medium transition-colors hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent"
>
<Sparkles className="size-3.5" aria-hidden />
Fill with AI
</button>
)}
<span className="text-xs text-muted-foreground" role="status" aria-live="polite">
{busy
? `Filling ${filling.length} field${filling.length === 1 ? "" : "s"}…`
: fillableEmpty.length > 0
? `${fillableEmpty.length} empty`
: "All fields have values"}
</span>
</div>
<dl className="flex flex-1 flex-col gap-1.5 overflow-y-auto px-2 py-3">
{fields.map((f) => (
<FieldRow
key={f.key}
field={f}
state={state[f.key]}
editing={editing === f.key}
onEdit={() => setEditing(f.key)}
onEditDone={(value) => {
setEditing(null);
if (value !== undefined) commit(f.key, value);
}}
onFill={() => fill([f.key])}
onAccept={() => accept(f.key)}
onReject={() => reject(f.key)}
/>
))}
</dl>
<AnimatePresence initial={false}>
{suggested.length > 0 && !busy && (
<motion.footer
initial={{ y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 12, opacity: 0 }}
transition={{ duration: 0.18 }}
className="flex items-center gap-2 border-t bg-muted/40 px-5 py-3"
>
<span className="flex-1 text-xs text-muted-foreground">
{suggested.length} AI value{suggested.length === 1 ? "" : "s"} to review
</span>
<button
type="button"
onClick={() => suggested.forEach((f) => reject(f.key))}
className="h-8 rounded-full px-3 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Reject all
</button>
<button
type="button"
onClick={() => suggested.forEach((f) => accept(f.key))}
className="h-8 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
Accept all
</button>
</motion.footer>
)}
</AnimatePresence>
</motion.aside>
)}
</AnimatePresence>
);
}
function FieldRow({
field,
state,
editing,
onEdit,
onEditDone,
onFill,
onAccept,
onReject,
}: {
field: FieldDef;
state: FieldState;
editing: boolean;
onEdit: () => void;
/** `undefined` = cancelled. */
onEditDone: (value?: string | null) => void;
onFill: () => void;
onAccept: () => void;
onReject: () => void;
}) {
const labelId = React.useId();
// The input blurs as it unmounts after Enter/Esc; only the first exit counts.
const exited = React.useRef(false);
function exit(value?: string | null) {
if (exited.current) return;
exited.current = true;
onEditDone(value);
}
React.useEffect(() => {
if (editing) exited.current = false;
}, [editing]);
const suggested = state.status === "suggested";
const current = state.status === "set" || state.status === "suggested" ? state.value : "";
return (
<div
className={cn(
"group grid grid-cols-[7.5rem_1fr] items-start gap-3 rounded-lg px-3 py-2 transition-colors",
suggested && "bg-muted/60"
)}
>
<dt id={labelId} className="truncate pt-1.5 text-xs text-muted-foreground">
{field.label}
</dt>
<dd className="min-w-0">
{editing ? (
<input
autoFocus
defaultValue={current}
aria-labelledby={labelId}
onBlur={(e) => exit(e.target.value.trim() || null)}
onKeyDown={(e) => {
if (e.key === "Enter") exit(e.currentTarget.value.trim() || null);
if (e.key === "Escape") {
e.preventDefault();
exit(undefined);
}
}}
className="h-8 w-full rounded-md border bg-background px-2 outline-none focus:ring-2 focus:ring-ring/40"
/>
) : (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={state.status}
initial={{ opacity: 0, y: 3 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
className="flex min-h-8 items-start gap-1"
>
{state.status === "filling" ? (
<span className="flex h-8 flex-1 items-center" aria-label="Filling">
<span className="h-3 w-3/4 animate-pulse rounded-full bg-muted-foreground/15" />
</span>
) : (
<>
<button
type="button"
onClick={onEdit}
aria-label={`Edit ${field.label}`}
className="-mx-1.5 min-w-0 flex-1 rounded-md px-1.5 py-1.5 text-left transition-colors hover:bg-accent"
>
{state.status === "set" || state.status === "suggested" ? (
<span className="flex items-start gap-1.5">
{suggested && (
<Sparkles className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-label="AI suggestion" />
)}
<span className="break-words">{state.value}</span>
</span>
) : state.status === "missing" ? (
<span className="italic text-muted-foreground">Couldn't find a value</span>
) : (
<span className="text-muted-foreground/60">Empty</span>
)}
</button>
{suggested && (
<span className="flex shrink-0 items-center">
<IconButton label={`Accept AI value for ${field.label}`} onClick={onAccept}>
<Check className="size-3.5" />
</IconButton>
<IconButton label={`Reject AI value for ${field.label}`} onClick={onReject}>
<X className="size-3.5" />
</IconButton>
</span>
)}
{(state.status === "empty" || state.status === "missing") && field.fillable !== false && (
<IconButton
label={`${state.status === "missing" ? "Retry" : "Fill"} ${field.label} with AI`}
onClick={onFill}
className="opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
>
{state.status === "missing" ? <RotateCcw className="size-3.5" /> : <Sparkles className="size-3.5" />}
</IconButton>
)}
</>
)}
</motion.div>
</AnimatePresence>
)}
{suggested && state.source && !editing && (
<p className="truncate pt-0.5 text-xs text-muted-foreground">from {state.source}</p>
)}
</dd>
</div>
);
}
function IconButton({
label,
onClick,
className,
children,
}: {
label: string;
onClick: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
className
)}
>
{children}
</button>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# AI Fill Drawer
## Summary
A record-details drawer that lists a record's fields as key/value rows and lets the model fill the empty values. "Fill with AI" fills every empty field at once. Each value arrives on its own as a marked suggestion, with the source it came from, and only becomes the record's real value once the user accepts or edits it. Values the user already set are never touched.
## When to use
- Enrichment: CRM accounts, contacts, products, tickets or assets where many fields can be found from the web, documents or connected tools.
- Records opened from a table row, where the drawer keeps the table in view for context.
- Any form where most fields are guessable and the user's job is to check, not type.
## When not to use
- Fields whose value is a decision, not a fact: owner, stage, price, approval. Mark them not fillable.
- Identifiers and legal or regulated data (tax IDs, contract terms) where a plausible wrong value is worse than an empty one.
- Bulk enrichment of hundreds of rows. Run a background job and review the result in the table instead.
- Long free-text fields. A drafted description or note belongs in an editor with Refinement Controls, not a one-line value cell.
## Anatomy
- **Header** — record title, a one-line subtitle (type, created date), close.
- **Action bar** — "Fill with AI" (becomes "Stop" while running) and a status line ("4 empty", "Filling 3 fields…", "All fields have values").
- **Field list** — a definition list: muted key on the left, value on the right. A row can be:
- **Set** — plain value, click to edit.
- **Empty** — muted "Empty"; on hover or focus, a per-field ✦ fill button.
- **Filling** — a pulsing skeleton bar in place of the value.
- **Suggested** — a tinted row, ✦ before the value, "from <source>" underneath, and accept ✓ / reject × buttons.
- **Not found** — italic "Couldn't find a value" with a retry button.
- **Review footer** — slides up once filling ends and suggestions remain: "3 AI values to review · Reject all · Accept all".
## Behavior
- "Fill with AI" requests every empty, fillable field in parallel. Rows resolve independently, in whatever order the answers arrive. Don't hold all of them back until the slowest finishes.
- Only empty fields are filled. A value the user set — or accepted — is never overwritten by a later fill.
- Stop cancels the run: rows still filling go back to Empty, and late answers from that run are ignored. Suggestions that already arrived stay.
- A suggestion is not the record's value until it is accepted. `onChange` fires only on accept or on an edit.
- Accept turns the row into a plain set value. Reject empties it again and leaves it fillable.
- Clicking any value, including a suggestion, edits it inline. Enter or blur saves, Esc cancels. Saving an edited suggestion counts as accepting the user's value.
- When the model finds nothing, say so ("Couldn't find a value") instead of guessing. Retry is per field.
- The review footer is hidden while filling, so Accept all can't accept a half-finished set.
- Esc closes the drawer, unless focus is in a field editor.
## Content guidelines
- Keys are short nouns in the record's own vocabulary ("Headquarters", not "Company HQ location").
- Suggested values use the same format the field would use if typed: ranges like "1,001–5,000", bare domains, no sentences.
- Always show a source when the model has one — a page, a document, a tool. "Web search" is acceptable; no source at all should be rare.
- The status line counts, never apologizes: "2 empty", not "We couldn't fill 2 fields".
## Accessibility
- Fields are a `<dl>` of `<dt>`/`<dd>` pairs, so screen readers announce key and value together.
- Each value is a button labelled "Edit <field>"; the inline input is labelled by its key.
- Accept, reject, fill and retry are icon buttons with full labels ("Accept AI value for Industry").
- The ✦ marker has an accessible label ("AI suggestion"), and the tinted row is never the only signal — the accept/reject buttons and the source line also mark it.
- The status line is a polite live region, so progress ("Filling 3 fields…") and completion are announced.
- The drawer takes focus when opened. With `prefers-reduced-motion`, it fades instead of sliding.
## Related patterns
- Describe Filters — the table this drawer is usually opened from.
- Confidence Indicator — add next to a suggestion when the model can report how sure it is.
- Source Trust Card — expand "from <source>" into a full source preview.
- Background Tasks Drawer — the same drawer shell, for long-running work.