Pattern gallery

Row actions

Icon buttons for the actions a reader reaches for constantly, a kebab for the rest, one shared modal instead of one per row.

What this solves

The kit shipped only the folded half: RowMenu, DataTable rowMenu, col.actions. Every consumer hand-rolled the visible half, with three different answers to what can be done to a row, and SchemaForm's own rows table rendered five open buttons the kit standard flags on sight.
Use it when
  • One action reached for constantlyA single visible icon button, no kebab
  • A mix of common and rare actionsShaded and danger actions visible, the rest in a card kebab
  • A row that goes somewhere and a row that previewshref plus a shared modal action
  • Heavy per-row content behind one actionA panel action caching by row id, one open at a time

Rendered

Fixture data; check both themes.

Minimal: one visible action, no kebab

Part
Disc Brake Rotor
Brake Pad Set
Caliper Bracket

Maximal: shade, danger, a card kebab, a shared modal, loading, bricked

Part
Disc Brake Rotor
Brake Pad Set
Caliper Bracket

Navigation rows: href plus a preview modal

PartSKU
Disc Brake Rotor
RT-330
Brake Pad Set
BP-118
Caliper Bracket
CB-402

SchemaForm's own rows table

Checks2 rows
CheckPassed

A shared cached panel

Part
Disc Brake Rotor
Brake Pad Set
Caliper Bracket
patterns/row-actions/example.tsx
Copy this into a fresh route and it renders as above.
"use client";
import {
Card,
Kicker,
Modal,
ModalBody,
ModalTitle,
RowActions,
SchemaForm,
Table,
useModal,
type RowAction,
type TableColumn,
} from "@versable-git/ui";
import { useMemo, useState, type ReactNode } from "react";
const PREVIEW_MODAL = "row-actions-preview";
type Part = { id: number; name: string; sku: string; archived?: boolean };
const PARTS: Part[] = [
{ id: 1, name: "Disc Brake Rotor", sku: "RT-330" },
{ id: 2, name: "Brake Pad Set", sku: "BP-118" },
{ id: 3, name: "Caliper Bracket", sku: "CB-402", archived: true },
];
/** The one action a caller reaches for most, alone: no kebab to hide it behind. */
function Minimal() {
const [opened, setOpened] = useState<string | null>(null);
const columns: TableColumn<Part>[] = [
{ key: "name", header: "Part" },
{
key: "actions",
header: "",
rawHeader: true,
align: "right",
width: "48px",
render: (row) => <RowActions row={row} actions={[{ key: "view", label: "View", icon: "Eye", onSelect: (r) => setOpened(r.name) }]} />,
},
];
return (
<div className="flex flex-col gap-2">
<Table columns={columns} rows={PARTS} rowKey={(r) => r.id} density="compact" />
{opened && <p className="text-base-content/65 text-xs">Opened: {opened}</p>}
</div>
);
}
/** Every shape at once: a shaded and a danger action visible, a card kebab, a shared modal, a loading action, and a bricked one. */
function Maximal() {
const { modalArgs } = useModal<Part>();
const [approved, setApproved] = useState<ReadonlySet<number>>(new Set());
const [duplicating, setDuplicating] = useState<number | null>(null);
const duplicate = (row: Part) => {
setDuplicating(row.id);
setTimeout(() => setDuplicating(null), 900);
};
const columns: TableColumn<Part>[] = [
{ key: "name", header: "Part" },
{
key: "actions",
header: "",
rawHeader: true,
align: "right",
width: "48px",
render: (row) => {
const actions: RowAction<Part>[] = [
{
key: "approve",
label: approved.has(row.id) ? "Approved" : "Approve",
icon: "Check",
shade: true,
color: "success",
active: approved.has(row.id),
onSelect: (r) => setApproved((prev) => new Set(prev).add(r.id)),
},
{ key: "preview", label: "Preview", icon: "Eye", modal: { id: PREVIEW_MODAL, args: (r) => r } },
{ key: "delete", label: "Delete", icon: "Trash", danger: true, onSelect: () => {}, placement: "visible" },
{
key: "duplicate",
label: "Duplicate",
icon: "Copy",
loading: duplicating === row.id,
placement: "menu",
onSelect: duplicate,
},
{
key: "archive",
label: "Archive",
icon: "Ignore",
placement: "menu",
disabled: row.archived,
disabledReason: row.archived ? "Already archived" : undefined,
onSelect: () => {},
},
];
return (
<RowActions
row={row}
actions={actions}
visible={3}
menu={(r, folded) => (
<Card compact shadow="none" title={<span className="text-sm font-medium">{r.name}</span>} className="w-56">
<div className="flex flex-col gap-1">
{folded.map((a) => (
<button
key={a.key}
type="button"
disabled={a.disabled}
className="hover:bg-base-200 rounded-field flex items-center justify-between px-2 py-1.5 text-left text-sm disabled:opacity-40"
onClick={() => !a.disabled && a.onSelect?.(r)}
>
<span>{a.label}</span>
{a.disabledReason && <span className="text-base-content/65 text-xs">{a.disabledReason}</span>}
</button>
))}
</div>
</Card>
)}
/>
);
},
},
];
return (
<div className="flex flex-col gap-2">
<Table columns={columns} rows={PARTS} rowKey={(r) => r.id} density="compact" />
<Modal elementId={PREVIEW_MODAL} size="sm">
<ModalTitle title={modalArgs?.name ?? "Part"} subtitle={modalArgs?.sku} Icon="Eye" />
<ModalBody>
<p className="text-base-content/70 text-sm">The shared modal reads whichever row opened it from `modalArgs`.</p>
</ModalBody>
</Modal>
</div>
);
}
/** A row that goes somewhere, and a row that opens a preview without leaving the page. */
function NavigationRows() {
const { modalArgs } = useModal<Part>();
const columns: TableColumn<Part>[] = [
{ key: "name", header: "Part" },
{ key: "sku", header: "SKU", render: (row) => <span className="font-mono text-xs">{row.sku}</span> },
{
key: "actions",
header: "",
rawHeader: true,
align: "right",
width: "48px",
render: (row) => (
<RowActions
row={row}
actions={[
{ key: "open", label: "Open", icon: "Right", href: (r) => `#part-${r.id}` },
{ key: "preview", label: "Preview", icon: "Eye", modal: { id: `${PREVIEW_MODAL}-nav`, args: (r) => r } },
]}
/>
),
},
];
return (
<div className="flex flex-col gap-2">
<Table columns={columns} rows={PARTS} rowKey={(r) => r.id} density="compact" />
<Modal elementId={`${PREVIEW_MODAL}-nav`} size="sm">
<ModalTitle title={modalArgs?.name ?? "Part"} Icon="Eye" />
<ModalBody>
<p className="text-base-content/70 text-sm">Same shared-modal pattern; `Open` instead navigates via `href`.</p>
</ModalBody>
</Modal>
</div>
);
}
const REVIEW_SCHEMA = {
type: "object",
properties: {
checks: {
type: "array",
title: "Checks",
items: { type: "object", properties: { label: { type: "string", title: "Check" }, passed: { type: "boolean", title: "Passed" } } },
},
},
};
/** The kit's own SchemaForm rows table adopts RowActions: a details toggle and remove stay visible, reorder and duplicate fold. */
function SchemaFormRows() {
const [value, setValue] = useState<Record<string, unknown>>({
checks: [
{ label: "Torque spec", passed: true },
{ label: "Corrosion coating", passed: false },
],
});
return <SchemaForm schema={REVIEW_SCHEMA} value={value} onChange={setValue} />;
}
/** One `panel` action per row, its content cached by row id, only one open at a time. */
function SharedCachedPanel() {
const cache = useMemo(() => new Map<number, { label: string; count: number }>(), []);
const detailsFor = (row: Part) => {
if (!cache.has(row.id)) cache.set(row.id, { label: `${row.name} fitment`, count: row.id * 3 });
return cache.get(row.id)!;
};
const columns: TableColumn<Part>[] = [
{ key: "name", header: "Part" },
{
key: "actions",
header: "",
rawHeader: true,
align: "right",
width: "48px",
render: (row) => (
<RowActions
row={row}
actions={[
{
key: "fitment",
label: "Fitment",
icon: "Table",
panel: (r) => {
const d = detailsFor(r);
return (
<div className="flex w-48 flex-col gap-1 p-3">
<Kicker>{d.label}</Kicker>
<p className="text-sm">{d.count} vehicles</p>
</div>
);
},
},
]}
/>
),
},
];
return <Table columns={columns} rows={PARTS} rowKey={(r) => r.id} density="compact" />;
}
function Example({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-2">
<Kicker>{label}</Kicker>
{children}
</div>
);
}
export function RowActionsPatterns() {
return (
<div className="flex flex-col gap-6">
<Example label="Minimal: one visible action, no kebab">
<Minimal />
</Example>
<Example label="Maximal: shade, danger, a card kebab, a shared modal, loading, bricked">
<Maximal />
</Example>
<Example label="Navigation rows: href plus a preview modal">
<NavigationRows />
</Example>
<Example label="SchemaForm's own rows table">
<SchemaFormRows />
</Example>
<Example label="A shared cached panel">
<SharedCachedPanel />
</Example>
</div>
);
}

Where it ships

  • packages/ui/src/table/row-actions.tsxRowActions, RowAction
  • versable-forge-v6/src/components/row-actions.tsxthe console's hand-rolled sibling this promotes

App-specific: Real callers own their own row shape and action handlers; this page fixes a small parts fixture so the chrome is what's on display.

@versable-git/ui · composites proven in the apps