12 KiB
LineItemButton
Import: import { LineItemButton, type LineItemButtonProps } from "@opal/components";
A composite component that wraps Interactive.Stateful > Interactive.Container > ContentAction into a single API. Use it for selectable list rows such as model pickers, menu items, or any row that acts like a button.
Architecture
Interactive.Stateful <- selectVariant, state, interaction, onClick, href, ref
└─ Interactive.Container <- width, rounding
└─ ContentAction <- padding
├─ Content <- icon, title, description, sizePreset, variant, ...
└─ rightChildren
The row renders as a focusable <div role="button"> (with Enter/Space activation) rather than a
native <button>, so interactive rightChildren such as action buttons don't produce invalid
button-in-button nesting. With href it renders an anchor instead. With presentational it makes
no control at all — use it when the row sits inside a primitive that already owns focus and
keyboard handling, where a second button would mean double tab stops and double announcements.
padding is forwarded to the inner ContentAction, on top of the row's own p-1.5 inset.
It is not an open Spacing: the prop is inherited from ContentActionProps, which narrows it to
0 | 0.5 | 1 | 2 — the four paddings Interactive.Container applies at its size presets, so that a
row's label lines up with an adjacent button. A step outside that set is a type error.
Props
Interactive surface
| Prop | Type | Default | Description |
|---|---|---|---|
selectVariant |
"select-light" | "select-heavy" |
"select-light" |
Interactive select variant |
state |
InteractiveStatefulState |
"empty" |
Value state ("empty", "filled", "selected") |
interaction |
InteractiveStatefulInteraction |
"rest" |
JS-controlled interaction state override |
onClick |
MouseEventHandler<HTMLElement> |
— | Click handler |
href |
string |
— | Renders an anchor instead of a div |
target |
string |
— | Anchor target (e.g. "_blank") |
group |
string |
— | Interactive group key |
ref |
React.Ref<HTMLElement> |
— | Forwarded ref |
disabled |
boolean |
false |
Disabled colors; suppresses the row's own click only — nested rightChildren stay clickable |
presentational |
boolean |
false |
Render as plain markup (role="presentation", no tab stop, no Enter/Space) for rows nested inside another interactive primitive — e.g. a Radix Select.Item. The palette stays: drive it with state / interaction from the owner |
Row element
These land on the row itself rather than on the content inside it. Anything
LineItemButton does not name reaches the row element, so the table below is
illustrative, not exhaustive — data-*, the rest of aria-*, and the other
HTMLAttributes<HTMLDivElement> handlers all arrive the same way.
role, tabIndex, onKeyDown and onKeyUp are the exception: a non-anchor
row sets the first two to make itself a focusable "button", and uses the
latter two for Enter/Space activation. Pass your own and the row defers —
handlers compose, yours first, and preventDefault() stops the row's own
activation.
| Prop | Type | Description |
|---|---|---|
aria-label / aria-labelledby / aria-describedby |
string |
Accessible name and description for the row |
onMouseEnter / onMouseLeave / onMouseMove / onMouseDown |
MouseEventHandler<HTMLDivElement> |
Mouse handlers, e.g. hover-to-open a flyout |
onPointerEnter / onPointerLeave |
PointerEventHandler<HTMLDivElement> |
Pointer equivalents |
title is deliberately not forwarded: it is the row's label to
ContentAction, and forwarding it as the native attribute would put two
meanings in one prop. Use tooltip for hover text.
Sizing
| Prop | Type | Default | Description |
|---|---|---|---|
rounding |
Rounding |
3 |
Corner radius step (N / 4 rem, or "full"); height is content-driven |
width |
WidthVariant |
"full" |
Container width |
padding |
0 | 0.5 | 1 | 2 |
0.5 |
Padding around the inner ContentAction, as a spacing step (N / 4 rem) |
tooltip |
string | RichStr |
— | Tooltip shown on hover; markdown, e.g. a link |
tooltipSide |
TooltipSide |
"top" |
Tooltip side |
Content (pass-through to ContentAction)
| Prop | Type | Default | Description |
|---|---|---|---|
title |
string |
(required) | Row label |
titleMaxLines |
number |
— | Cap the title at N lines and truncate the rest. Unset wraps without a limit — a row showing a user-authored name usually wants 1 |
icon |
IconFunctionComponent |
— | Left icon |
description |
string |
— | Description below the title |
descriptionMaxLines |
number |
— | Cap the description at N lines and truncate the rest. Unset wraps without a limit — a row showing user-authored text usually wants 1 |
suffix |
string |
— | Muted text beside the title, like "(Default)". Shown only by the heading variant with a main-* sizePreset; the default headline row drops it |
tag |
TagProps |
— | Tag beside the title, like a plan badge; heading variant only |
sizePreset |
SizePreset |
"headline" |
Content size preset |
variant |
ContentVariant |
"heading" |
Content layout variant |
rightChildren |
ReactNode |
— | Content after the label (e.g. action button) |
color |
ColorTypes |
"interactive" |
Content colour mode. Defaults to "interactive", which is what lets the row's hover / selected / disabled colours reach its title and icon — passing anything else opts out of that. undefined counts as not passing one. |
strikethrough |
boolean |
false |
Strike the label through, e.g. a row switched off |
That table is the entire content surface. The remaining ContentAction /
Content props — editable, onTitleChange, auxIcon, tag, and the rest —
are not part of LineItemButton's type; no call site passed one. Naming
only what a row uses is what lets everything else reach the row element.
Usage
import { LineItemButton } from "@opal/components";
// Simple selectable row
<LineItemButton
selectVariant="select-heavy"
state={isSelected ? "selected" : "empty"}
rounding={2}
onClick={handleClick}
title="gpt-4o"
sizePreset="main-ui"
variant="section"
/>
// With right-side action
<LineItemButton
selectVariant="select-heavy"
state={isSelected ? "selected" : "empty"}
onClick={handleClick}
title="claude-opus-4"
sizePreset="main-ui"
variant="section"
rightChildren={<Tag title="Default" color="blue" />}
/>