Draft row
The last row of every grid. "+ New candidate" turns it into a row you type into, cell by cell, as in a spreadsheet. Enter saves it; Escape cancels it.
Playground
Change the controls to try each prop. The preview puts the row at the end of an empty table; the code lists only the props you changed.
| Name | ||
|---|---|---|
import { DraftRow } from '@/ui/draft-row';<DraftRow noun="candidate" />At rest
"+ New" and the record noun, in the name column, in the faintest text until hovered. Pass the row to the table's bodyEnd. The page's Add button opens the same row.
| Candidate | Current company | Owner | ||
|---|---|---|---|---|
| Daniel Okafor | daniel@okafor.dev | Halcyon Bank | Joseph Ariel | |
import { DraftRow } from '@/ui/draft-row';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; email: string; company: string; owner: string };
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'email', label: 'Email', width: 220, tone: 'default', cell: (row) => row.email },
{ key: 'company', label: 'Current company', width: 180, cell: (row) => row.company },
{ key: 'owner', label: 'Owner', cell: (row) => row.owner },
];
export default function DraftRowRest() {
return (
<Table
label="Candidates"
columns={columns}
rows={[{ id: 'c1', name: 'Daniel Okafor', email: 'daniel@okafor.dev', company: 'Halcyon Bank', owner: 'Joseph Ariel' }]}
selectable
rowLabel={(row) => row.name}
bodyEnd={<DraftRow noun="candidate" columns={columns.length} selectable />}
/>
);
}Add a record
Press + New candidate and type. Enter saves; Tab moves to the next cell; Escape cancels. Owner is required here, so Enter without it marks the cell and says what it needs. A saved row joins the table with the Row in pattern and the draft row opens blank for the next one.
| Candidate | Current company | Owner | ||
|---|---|---|---|---|
| Daniel Okafor | daniel@okafor.dev | Halcyon Bank | Joseph Ariel | |
import { useState } from 'react';
import { DraftCell, DraftRow } from '@/ui/draft-row';
import { moveList } from '@/ui/motion';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; email: string; company: string; owner: string };
const blank = { name: '', email: '', company: '', owner: '' };
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'email', label: 'Email', width: 220, tone: 'default', cell: (row) => row.email },
{ key: 'company', label: 'Current company', width: 180, cell: (row) => row.company },
{ key: 'owner', label: 'Owner', cell: (row) => row.owner },
];
// Type a name and press Enter: Owner is required, so the row says so. Fill it and press Enter again.
export default function DraftRowAddARecord() {
const [rows, setRows] = useState<Candidate[]>([{ id: 'c1', name: 'Daniel Okafor', email: 'daniel@okafor.dev', company: 'Halcyon Bank', owner: 'Joseph Ariel' }]);
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(blank);
const [errors, setErrors] = useState<Partial<Record<keyof typeof blank, string>>>({});
const [status, setStatus] = useState<'idle' | 'saving'>('idle');
const set = (key: keyof typeof blank) => (value: string) => {
setDraft({ ...draft, [key]: value });
setErrors({ ...errors, [key]: undefined });
};
const save = () => {
const name = draft.name.trim();
const owner = draft.owner.trim();
const next = { name: name ? undefined : 'Enter a name.', owner: owner ? undefined : 'Choose or enter Owner.' };
setErrors(next);
if (next.name || next.owner) return;
setStatus('saving');
setTimeout(() => {
moveList(() => {
setRows((all) => [...all, { id: `c${all.length + 1}`, ...draft, name, owner }]);
setDraft(blank);
setStatus('idle');
});
}, 800);
};
return (
<Table
label="Candidates"
columns={columns}
rows={rows}
rowMotion
selectable
rowLabel={(row) => row.name}
bodyEnd={
<DraftRow noun="candidate" columns={columns.length} selectable open={open} onOpenChange={(next) => { setOpen(next); if (!next) { setDraft(blank); setErrors({}); } }} status={status} onSave={save}>
<DraftCell label="Candidate" identity selectable autoFocus value={draft.name} onValueChange={set('name')} error={errors.name} />
<DraftCell label="Email" type="email" value={draft.email} onValueChange={set('email')} />
<DraftCell label="Current company" value={draft.company} onValueChange={set('company')} />
<DraftCell label="Owner" value={draft.owner} onValueChange={set('owner')} error={errors.owner} />
</DraftRow>
}
/>
);
}Row states
Top to bottom: values filled from mail read subtle with their source until kept; a refused cell turns red and says what it needs above it; saving dims the row behind a pulsing dot; a save that couldn't be confirmed offers Retry save.
| Candidate | Current company | Owner | ||
|---|---|---|---|---|
| from mail | from mail | |||
import { DraftCell, DraftRow } from '@/ui/draft-row';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string };
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => row.name },
{ key: 'email', label: 'Email', width: 240, cell: () => null },
{ key: 'company', label: 'Current company', width: 180, cell: () => null },
{ key: 'owner', label: 'Owner', cell: () => null },
];
const noop = () => {};
// Filled from mail, refused, saving and not confirmed, top to bottom.
export default function DraftRowRowStates() {
return (
<Table
label="Candidates"
columns={columns}
rows={[]}
selectable
bodyEnd={
<>
<DraftRow noun="candidate" columns={4} selectable defaultOpen>
<DraftCell label="Candidate" identity selectable value="Maya Chen" source="mail" onValueChange={noop} />
<DraftCell label="Email" value="maya.chen@halcyonbank.com" onValueChange={noop} />
<DraftCell label="Current company" value="Halcyon Bank" source="mail" onValueChange={noop} />
<DraftCell label="Owner" value="" onValueChange={noop} />
</DraftRow>
<DraftRow noun="candidate" columns={4} selectable defaultOpen>
<DraftCell label="Candidate" identity selectable value="Maya Chen" onValueChange={noop} />
<DraftCell label="Email" value="maya.chen@halcyonbank.com" onValueChange={noop} />
<DraftCell label="Current company" value="Halcyon Bank" onValueChange={noop} />
<DraftCell label="Owner" value="" placeholder="Choose an owner" error="Choose or enter Owner." onValueChange={noop} />
</DraftRow>
<DraftRow noun="candidate" columns={4} selectable defaultOpen status="saving">
<DraftCell label="Candidate" identity selectable value="Maya Chen" onValueChange={noop} />
<DraftCell label="Email" value="maya.chen@halcyonbank.com" onValueChange={noop} />
<DraftCell label="Current company" value="Halcyon Bank" onValueChange={noop} />
<DraftCell label="Owner" value="Joseph Ariel" onValueChange={noop} />
</DraftRow>
<DraftRow noun="candidate" columns={4} selectable defaultOpen status="unconfirmed" onRetry={noop}>
<DraftCell label="Candidate" identity selectable value="Maya Chen" onValueChange={noop} />
<DraftCell label="Email" value="maya.chen@halcyonbank.com" onValueChange={noop} />
<DraftCell label="Current company" value="Halcyon Bank" onValueChange={noop} />
<DraftCell label="Owner" value="Joseph Ariel" onValueChange={noop} />
</DraftRow>
</>
}
/>
);
}A save that fails
Press Enter: the first save loses the connection. The row keeps everything typed, an error toast says what happened and stays, and Retry save sends the same request again.
| Company | Website | |
|---|---|---|
import { useState } from 'react';
import { DraftCell, DraftRow } from '@/ui/draft-row';
import { Table, type TableColumn } from '@/ui/table';
import { toast } from '@/ui/toast';
type Company = { id: string; name: string; website: string };
const columns: TableColumn<Company>[] = [
{ key: 'name', label: 'Company', cell: (row) => row.name },
{ key: 'website', label: 'Website', width: 220, cell: (row) => row.website },
];
// The first save loses the connection: the row keeps what was typed and offers Retry save, which then succeeds.
export default function DraftRowFailedSave() {
const [rows, setRows] = useState<Company[]>([]);
const [draft, setDraft] = useState({ name: 'Orbital Freight', website: 'orbital.io' });
const [status, setStatus] = useState<'idle' | 'saving' | 'unconfirmed'>('idle');
const [tries, setTries] = useState(0);
const save = () => {
setStatus('saving');
setTimeout(() => {
if (tries === 0) {
setTries(1);
setStatus('unconfirmed');
toast.add({ type: 'error', title: "Couldn't confirm Orbital Freight was saved", description: 'Check your connection, then press Retry save on the row.' });
return;
}
setRows([{ id: 'co1', ...draft }]);
setDraft({ name: '', website: '' });
setStatus('idle');
}, 800);
};
return (
<Table
label="Companies"
columns={columns}
rows={rows}
selectable
bodyEnd={
<DraftRow noun="company" columns={2} selectable defaultOpen status={status} onSave={save} onRetry={save}>
<DraftCell label="Company" identity selectable value={draft.name} onValueChange={(name) => setDraft({ ...draft, name })} />
<DraftCell label="Website" type="url" value={draft.website} onValueChange={(website) => setDraft({ ...draft, website })} />
</DraftRow>
}
/>
);
}