Vesyl UI

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.

ComponentWhat it does
ColumnActionSelectComposed — choose one value from a list (status, priority, assignment)
ColumnActionMenuPrimitive — content + chevron that opens a dropdown menu
ColumnActionPopoverPrimitive — content + chevron that opens a popover
ColumnActionTriggerThe 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).

Task
Status
Priority
Units
Restock pallet A-12
Open
Cycle count zone 3
Open
Sweep staging lane
Open
Receive PO-9981
Open
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.

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:

StateAt restOn row hover / focus
DefaultContent + faint chevronChevron reaches full strength
hideUntilHoverNothing (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.

Behavior
Action
Default
Faint chevron at rest; sharpens on row hover
Hover only
hideUntilHover — whole trigger hidden until row hover

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
Tags
VS-100231
FragileSignature
VS-100232
VS-100233
Gift
VS-100234

Reference

ColumnActionSelect

Composed single-select cell. Render inside a column’s cell; pair with cellProps: { padding: 'none' }.

PropTypeDefaultDescription
optionsColumnActionSelectOption<V>[]Selectable options
valueVCurrent value
onValueChange(value: V) => voidFired when an option is chosen
emptyValueVOption treated as “unset” — hides the trigger until row hover (hideUntilHover), shows placeholder, renders last under a separator
placeholderReactNodeempty option’s labelTrigger content when value === emptyValue
ariaLabelstringAccessible name for the trigger
align'start' | 'center' | 'end''start'Menu alignment against the trigger
side'top' | 'bottom' | 'left' | 'right''bottom'Menu side
disabledbooleanfalseDisable the trigger
triggerClassNamestringClass applied to the trigger element
contentClassNamestringClass applied to the menu content surface

ColumnActionSelectOption

FieldTypeDescription
valueVOption value (the generic V)
labelReactNodeTrigger content when selected, and the menu item’s primary text
hintReactNodeSecondary muted text in the menu item only
indicatorReactNodeLeading node in both the trigger and the menu item (e.g. a dot)

ColumnActionMenu

Interactive cell that opens a dropdown menu.

PropTypeDefaultDescription
labelReactNodeVisible cell content rendered in the trigger, left of the chevron
childrenReactNodeMenu body — DropdownMenuItem / DropdownMenuRadioGroup / etc.
ariaLabelstringAccessible name for the trigger button
align'start' | 'center' | 'end''start'Menu alignment against the trigger
side'top' | 'bottom' | 'left' | 'right''bottom'Menu side
alignOffsetnumber0Offset along the align axis
sideOffsetnumber4Gap between trigger and menu
openbooleanControlled open state
defaultOpenbooleanUncontrolled initial open state
onOpenChange(open: boolean, ...) => voidOpen-state change handler
disabledbooleanfalseDisable the trigger
hideUntilHoverbooleanfalseHide the whole trigger until row hover/focus — for empty/optional cells. See Resting state
triggerClassNamestringClass applied to the trigger element
contentClassNamestringClass applied to the menu content surface

ColumnActionPopover

Interactive cell that opens a popover. Same trigger API as ColumnActionMenu; children is free-form popover content.

PropTypeDefaultDescription
labelReactNodeVisible cell content rendered in the trigger, left of the chevron
childrenReactNodePopover body — free-form content
ariaLabelstringAccessible name for the trigger button
align'start' | 'center' | 'end''start'Popover alignment against the trigger
side'top' | 'bottom' | 'left' | 'right''bottom'Popover side
alignOffsetnumber0Offset along the align axis
sideOffsetnumber4Gap between trigger and popover
open / defaultOpen / onOpenChangeOpen-state passthrough to the popover root
disabledbooleanfalseDisable the trigger
hideUntilHoverbooleanfalseHide the whole trigger until row hover/focus — for empty/optional cells. See Resting state
triggerClassNamestringClass applied to the trigger element
contentClassNamestringClass 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).

PropTypeDefaultDescription
childrenReactNodeCell content rendered left of the chevron
iconReactNodeReplace the chevron with custom indicator content
hideUntilHoverbooleanfalseHide the whole trigger until row hover/focus
button propsForwarded to the underlying <button> (incl. ref, onClick)