Column actions
Cell-content components — like TextColumn and DateColumn — that you drop into a column’s cell. They render the cell’s data followed by a chevron-down affordance; clicking opens a menu, a popover, or a value picker anchored to that cell. Use them for in-place row actions (“change this row’s priority”) or in-place detail (“show this cell’s breakdown”).
This is distinct from buildActionsColumn — the trailing ”…” column of row-wide actions. Column actions live inside the data cell they act on.
| Component | What it does |
|---|---|
ColumnActionSelect | Composed — choose one value from a list (status, priority, assignment) |
ColumnActionMenu | Primitive — content + chevron that opens a dropdown menu |
ColumnActionPopover | Primitive — content + chevron that opens a popover |
ColumnActionTrigger | The shared content + chevron trigger, for custom compositions |
ColumnActionSelect is built on ColumnActionMenu, which (with ColumnActionPopover) is built on ColumnActionTrigger — itself the table flavor of DisclosureButton (cell-padding alignment + a chevron that stays faint until the row is hovered). Reach for the highest level that fits, drop down when you need more control, and use DisclosureButton directly outside of tables.
Opening a column action never fires onRowClick / row navigation — the DataTable auto-isolates clicks on interactive cell content, so no opt-in is needed. Pair with cellProps: { padding: 'none' } so the cell’s full height is the click target (same as DateColumn).
Choosing a value — ColumnActionSelect
The most common column action is “change this row’s status / priority / assignment.” ColumnActionSelect is the composed component for exactly that: pass options, value, and onValueChange, and it derives the trigger from the selected option, builds the radio-group menu (the current value carries the check), and handles the unset state — no per-row conditional logic in your column definition.
In the demo, the Priority column is a ColumnActionSelect; the Units column is a ColumnActionPopover (see below).
import {
ColumnActionSelect,
type ColumnActionSelectOption,
} from "@vesyl/ui-next";
const PRIORITY_OPTIONS: ColumnActionSelectOption<Priority>[] = [
{ value: "P0", label: "P0", hint: "Urgent", indicator: <Dot palette="red" /> },
{ value: "P1", label: "P1", hint: "Medium", indicator: <Dot palette="yellow" /> },
{ value: "P2", label: "P2", hint: "Low", indicator: <Dot palette="gray" /> },
{ value: "none", label: "No priority", indicator: <Dot /> },
];
{
id: "priority",
header: "Priority",
width: "sm",
cellProps: { padding: "none" },
cell: (task) => (
<ColumnActionSelect
options={PRIORITY_OPTIONS}
value={task.priority}
onValueChange={(value) => setPriority(task.id, value)}
emptyValue="none"
placeholder="P?"
/>
),
}Each option carries its label, an optional hint (the muted secondary text shown only in the menu), and an optional indicator (a leading node — here a colored dot — shown in both the trigger and the menu item).
emptyValue marks the “unset” option: it renders last in the menu under a separator, and when it’s the current value the trigger shows placeholder and stays hidden until the row is hovered (the hideUntilHover behavior, applied for you). Selected values show their label with the default faint chevron — so every select in a table reads consistently.
For a fixed vocabulary, wrap it once so call sites stay terse:
// In your app (the vocabulary is domain-specific, so this lives outside ui-next):
function PriorityColumnAction(props: { value: Priority; onValueChange: (v: Priority) => void }) {
return <ColumnActionSelect options={PRIORITY_OPTIONS} emptyValue="none" placeholder="P?" {...props} />;
}
// Column definition collapses to one line:
cell: (task) => (
<PriorityColumnAction value={task.priority} onValueChange={(v) => setPriority(task.id, v)} />
),Primitives
When the cell isn’t a plain select — free-form popover content, a bespoke menu, or a conditional affordance — drop to the primitives. Both share ColumnActionTrigger (the content + chevron) and differ only in what opens.
Menu — ColumnActionMenu
label is the cell content shown in the trigger; children is the menu body. For a single-select mutation use a DropdownMenuRadioGroup (this is what ColumnActionSelect does under the hood); for an action list use DropdownMenuItems.
import { ColumnActionMenu, DropdownMenuItem } from "@vesyl/ui-next";
cell: (row) => (
<ColumnActionMenu ariaLabel={`Actions for ${row.name}`} label={row.name}>
<DropdownMenuItem onClick={() => edit(row)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => duplicate(row)}>Duplicate</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => remove(row)}>
Delete
</DropdownMenuItem>
</ColumnActionMenu>
),Detail — ColumnActionPopover
Same label, but children is free-form popover content. The default surface is roomy (w-72 p-4) for rich detail; pass contentClassName to tighten it for compact lists. The Units column in the overview demo uses a popover for its SKU breakdown.
import { ColumnActionPopover } from "@vesyl/ui-next";
cell: (task) => (
<ColumnActionPopover
ariaLabel={`SKU breakdown for ${task.title}`}
align="end"
contentClassName="w-56 gap-0 p-2"
label={<UnitsLabel units={task.units} skus={task.skus.length} />}
>
<SkuBreakdown skus={task.skus} />
</ColumnActionPopover>
),Resting state
By default the chevron is faint at rest and sharpens to full strength on row hover or keyboard focus — driven by the group/dt-row the DataTable puts on every row. The cell’s value always stays visible; only the affordance is quieted. No configuration needed.
For empty or optional cells, pass hideUntilHover to hide the whole trigger (value and chevron) until the row is hovered or focused:
| State | At rest | On row hover / focus |
|---|---|---|
| Default | Content + faint chevron | Chevron reaches full strength |
hideUntilHover | Nothing (trigger hidden) | Trigger fades in |
Reserve hideUntilHover for cells where nothing of value is concealed — there is no hover on touch devices, so it suits the empty state (e.g. an unset priority) rather than real data. The default faint chevron is safe everywhere. Both reveal on focus-within so keyboard users can reach the action, both keep the trigger visible while its popup is open, and both use opacity (not display) so the column never shifts when the trigger appears.
Tuning the faint level: it’s one token in one file — web/ui-next/src/components/data-table/columns/column-action.tsx.
Hover each row. By default the chevron is faint and sharpens on row hover or keyboard focus; hideUntilHover hides the whole trigger until then — for empty or optional cells.
Custom compositions — ColumnActionTrigger
ColumnActionTrigger is exported on its own so you can build the affordance into any base-ui trigger, or render it conditionally. It spreads base-ui trigger props (including data-popup-open, which drives the chevron rotation), so drop it into a render prop:
import { ColumnActionTrigger } from "@vesyl/ui-next";
<PopoverTrigger
render={(props) => (
<ColumnActionTrigger {...props}>{label}</ColumnActionTrigger>
)}
/>;A common case: collapse an overflow list into a popover only when it’s long enough to need one, and render inline otherwise. The demo renders tags inline when there are two or fewer, and collapses to a ColumnActionPopover (first tag + +N) past that — same chevron affordance, conditional shape.
Reference
ColumnActionSelect
Composed single-select cell. Render inside a column’s cell; pair with cellProps: { padding: 'none' }.
| Prop | Type | Default | Description |
|---|---|---|---|
options | ColumnActionSelectOption<V>[] | — | Selectable options |
value | V | — | Current value |
onValueChange | (value: V) => void | — | Fired when an option is chosen |
emptyValue | V | — | Option treated as “unset” — hides the trigger until row hover (hideUntilHover), shows placeholder, renders last under a separator |
placeholder | ReactNode | empty option’s label | Trigger content when value === emptyValue |
ariaLabel | string | — | Accessible name for the trigger |
align | 'start' | 'center' | 'end' | 'start' | Menu alignment against the trigger |
side | 'top' | 'bottom' | 'left' | 'right' | 'bottom' | Menu side |
disabled | boolean | false | Disable the trigger |
triggerClassName | string | — | Class applied to the trigger element |
contentClassName | string | — | Class applied to the menu content surface |
ColumnActionSelectOption
| Field | Type | Description |
|---|---|---|
value | V | Option value (the generic V) |
label | ReactNode | Trigger content when selected, and the menu item’s primary text |
hint | ReactNode | Secondary muted text in the menu item only |
indicator | ReactNode | Leading node in both the trigger and the menu item (e.g. a dot) |
ColumnActionMenu
Interactive cell that opens a dropdown menu.
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Visible cell content rendered in the trigger, left of the chevron |
children | ReactNode | — | Menu body — DropdownMenuItem / DropdownMenuRadioGroup / etc. |
ariaLabel | string | — | Accessible name for the trigger button |
align | 'start' | 'center' | 'end' | 'start' | Menu alignment against the trigger |
side | 'top' | 'bottom' | 'left' | 'right' | 'bottom' | Menu side |
alignOffset | number | 0 | Offset along the align axis |
sideOffset | number | 4 | Gap between trigger and menu |
open | boolean | — | Controlled open state |
defaultOpen | boolean | — | Uncontrolled initial open state |
onOpenChange | (open: boolean, ...) => void | — | Open-state change handler |
disabled | boolean | false | Disable the trigger |
hideUntilHover | boolean | false | Hide the whole trigger until row hover/focus — for empty/optional cells. See Resting state |
triggerClassName | string | — | Class applied to the trigger element |
contentClassName | string | — | Class applied to the menu content surface |
ColumnActionPopover
Interactive cell that opens a popover. Same trigger API as ColumnActionMenu; children is free-form popover content.
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Visible cell content rendered in the trigger, left of the chevron |
children | ReactNode | — | Popover body — free-form content |
ariaLabel | string | — | Accessible name for the trigger button |
align | 'start' | 'center' | 'end' | 'start' | Popover alignment against the trigger |
side | 'top' | 'bottom' | 'left' | 'right' | 'bottom' | Popover side |
alignOffset | number | 0 | Offset along the align axis |
sideOffset | number | 4 | Gap between trigger and popover |
open / defaultOpen / onOpenChange | — | — | Open-state passthrough to the popover root |
disabled | boolean | false | Disable the trigger |
hideUntilHover | boolean | false | Hide the whole trigger until row hover/focus — for empty/optional cells. See Resting state |
triggerClassName | string | — | Class applied to the trigger element |
contentClassName | string | — | Class applied to the popover surface (default w-72 p-4 — override for compact lists) |
ColumnActionTrigger
The shared content + chevron trigger. Extends native <button> props (spread base-ui trigger props onto it via render).
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Cell content rendered left of the chevron |
icon | ReactNode | — | Replace the chevron with custom indicator content |
hideUntilHover | boolean | false | Hide the whole trigger until row hover/focus |
… | button props | — | Forwarded to the underlying <button> (incl. ref, onClick) |