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
SchemaForm's own rows table
Checks2 rows
| Check | Passed | |
|---|---|---|
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, RowActionversable-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.