ComponentsTables and views

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.

NameEmail
States
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.

CandidateEmailCurrent companyOwner
Daniel Okafordaniel@okafor.devHalcyon BankJoseph 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.

CandidateEmailCurrent companyOwner
Daniel Okafordaniel@okafor.devHalcyon BankJoseph 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.

CandidateEmailCurrent companyOwner
from mailfrom 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.

CompanyWebsite
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>
      }
    />
  );
}

On this page