Files
onyx/web/lib/opal/src/components/buttons/line-item-button/README.md
T

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" />}
/>