Compare commits

...

1 Commits

Author SHA1 Message Date
Raunak Bhagat
9c3e9a9eea feat: add LineItemButton component and Content withInteractive variant
Add LineItemButton to @opal/components — a composite wrapping
Interactive.Base(select) > Interactive.Container > ContentAction into a
single ergonomic API for selectable list rows (model pickers, menus).

Add withInteractive prop to all Content size variants (Sm/Md/Lg/Xl) so
the title color can hook into Interactive.Base's --interactive-foreground
CSS variable.

Add widthVariant prop to Hoverable.Root for controlling container width.

Add ref prop to Content layout components and Hoverable (React 19
ref-as-prop pattern).
2026-03-05 16:28:45 -08:00
15 changed files with 384 additions and 35 deletions

View File

@@ -0,0 +1,84 @@
# LineItemButton
**Import:** `import { LineItemButton, type LineItemButtonProps } from "@opal/components";`
A composite component that wraps `Interactive.Base(select) > 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.Base (variant="select") <- prominence, selected, disabled, onClick, href, ref
└─ Interactive.Container <- type, width, size, rounding (derived from size)
└─ ContentAction <- withInteractive, paddingVariant="fit", widthVariant="full"
├─ Content <- icon, title, description, sizePreset, variant, ...
└─ rightChildren
```
`paddingVariant` is hardcoded to `"fit"` (Container owns the padding) and `widthVariant` is hardcoded to `"full"`. These are not exposed as props.
## Props
### Interactive surface (always `variant="select"`)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `prominence` | `"light" \| "heavy"` | `"light"` | Interactive select prominence |
| `selected` | `boolean` | — | Whether the item appears selected |
| `disabled` | `boolean` | — | Disables interaction |
| `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 |
| `transient` | `boolean` | — | Transient interactive state |
| `ref` | `React.Ref<HTMLElement>` | — | Forwarded ref |
### Sizing
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `size` | `SizeVariant` | `"lg"` | Container height |
| `width` | `WidthVariant` | `"full"` | Container width |
| `type` | `"submit" \| "button" \| "reset"` | — | HTML button type |
| `tooltip` | `string` | — | Tooltip text shown on hover |
| `tooltipSide` | `TooltipSide` | `"top"` | Tooltip side |
### Content (pass-through to ContentAction)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string` | **(required)** | Row label |
| `icon` | `IconFunctionComponent` | — | Left icon |
| `description` | `string` | — | Description below the title |
| `sizePreset` | `SizePreset` | `"headline"` | Content size preset |
| `variant` | `ContentVariant` | `"heading"` | Content layout variant |
| `rightChildren` | `ReactNode` | — | Content after the label (e.g. action button) |
All other `ContentAction` / `Content` props (`editable`, `onTitleChange`, `optional`, `auxIcon`, `tag`, `withInteractive`, etc.) are also passed through.
## Usage
```tsx
import { LineItemButton } from "@opal/components";
// Simple selectable row
<LineItemButton
prominence="heavy"
selected={isSelected}
size="md"
onClick={handleClick}
title="gpt-4o"
sizePreset="main-ui"
variant="section"
/>
// With right-side action
<LineItemButton
prominence="heavy"
selected={isSelected}
onClick={handleClick}
title="claude-opus-4"
sizePreset="main-ui"
variant="section"
rightChildren={<Tag title="Default" color="blue" />}
/>
```

View File

@@ -0,0 +1,137 @@
import "@opal/components/tooltip.css";
import { Interactive, type InteractiveBaseProps } from "@opal/core";
import type { SizeVariant, WidthVariant } from "@opal/shared";
import type { TooltipSide } from "@opal/components";
import type { ContentActionProps } from "@opal/layouts/ContentAction/components";
import { ContentAction } from "@opal/layouts";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ContentPassthroughProps = Omit<
ContentActionProps,
"paddingVariant" | "widthVariant" | "ref"
>;
interface LineItemButtonProps extends ContentPassthroughProps {
/** Interactive select prominence. @default "light" */
prominence?: "light" | "heavy";
/** Whether this item is selected. */
selected?: boolean;
/** Whether this item is disabled. */
disabled?: boolean;
/** Click handler. */
onClick?: InteractiveBaseProps["onClick"];
/** When provided, renders an anchor instead of a div. */
href?: string;
/** Anchor target (e.g. "_blank"). */
target?: string;
/** Interactive group key. */
group?: string;
/** Transient interactive state. */
transient?: boolean;
/** Forwarded ref. */
ref?: React.Ref<HTMLElement>;
/** Container height. @default "lg" */
size?: SizeVariant;
/** Container width. @default "full" */
width?: WidthVariant;
/** HTML button type. */
type?: "submit" | "button" | "reset";
/** Tooltip text shown on hover. */
tooltip?: string;
/** Which side the tooltip appears on. @default "top" */
tooltipSide?: TooltipSide;
}
// ---------------------------------------------------------------------------
// LineItemButton
// ---------------------------------------------------------------------------
function LineItemButton({
// Interactive surface
prominence = "light",
selected,
disabled,
onClick,
href,
target,
group,
transient,
ref,
// Sizing
size = "lg",
width = "full",
type,
tooltip,
tooltipSide = "top",
// ContentAction pass-through
...contentActionProps
}: LineItemButtonProps) {
const item = (
<Interactive.Base
variant="select"
prominence={prominence}
selected={selected}
disabled={disabled}
onClick={onClick}
href={href}
target={target}
group={group}
transient={transient}
ref={ref}
>
<Interactive.Container
type={type}
widthVariant={width}
heightVariant={size}
roundingVariant={
size === "lg" ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<ContentAction
{...(contentActionProps as ContentActionProps)}
withInteractive
paddingVariant="fit"
widthVariant="full"
/>
</Interactive.Container>
</Interactive.Base>
);
if (!tooltip) return item;
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{item}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className="opal-tooltip"
side={tooltipSide}
sideOffset={4}
>
{tooltip}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
export { LineItemButton, type LineItemButtonProps };

View File

@@ -13,6 +13,12 @@ export {
type OpenButtonProps,
} from "@opal/components/buttons/OpenButton/components";
/* LineItemButton */
export {
LineItemButton,
type LineItemButtonProps,
} from "@opal/components/buttons/LineItemButton/components";
/* Tag */
export {
Tag,

View File

@@ -2,6 +2,7 @@ import "@opal/core/hoverable/styles.css";
import React, { createContext, useContext, useState, useCallback } from "react";
import { cn } from "@opal/utils";
import type { WithoutStyles } from "@opal/types";
import { widthVariants, type WidthVariant } from "@opal/shared";
// ---------------------------------------------------------------------------
// Context-per-group registry
@@ -38,6 +39,10 @@ interface HoverableRootProps
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
children: React.ReactNode;
group: string;
/** Width preset. @default "auto" */
widthVariant?: WidthVariant;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
type HoverableItemVariant = "opacity-on-hover";
@@ -47,6 +52,8 @@ interface HoverableItemProps
children: React.ReactNode;
group?: string;
variant?: HoverableItemVariant;
/** Ref forwarded to the item `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -77,6 +84,8 @@ interface HoverableItemProps
function HoverableRoot({
group,
children,
widthVariant = "auto",
ref,
onMouseEnter: consumerMouseEnter,
onMouseLeave: consumerMouseLeave,
...props
@@ -103,7 +112,13 @@ function HoverableRoot({
return (
<GroupContext.Provider value={hovered}>
<div {...props} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<div
{...props}
ref={ref}
className={cn(widthVariants[widthVariant])}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</div>
</GroupContext.Provider>
@@ -147,6 +162,7 @@ function HoverableItem({
group,
variant = "opacity-on-hover",
children,
ref,
...props
}: HoverableItemProps) {
const contextValue = useContext(
@@ -165,6 +181,7 @@ function HoverableItem({
return (
<div
{...props}
ref={ref}
className={cn("hoverable-item")}
data-hoverable-variant={variant}
data-hoverable-active={

View File

@@ -40,6 +40,9 @@ interface BodyLayoutProps {
/** Title prominence. Default: `"default"`. */
prominence?: BodyProminence;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -80,6 +83,7 @@ function BodyLayout({
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
ref,
}: BodyLayoutProps) {
const config = BODY_PRESETS[sizePreset];
const titleColorClass =
@@ -87,6 +91,7 @@ function BodyLayout({
return (
<div
ref={ref}
className="opal-content-body"
data-orientation={orientation}
style={{ gap: config.gap }}

View File

@@ -48,6 +48,12 @@ interface ContentLgProps {
/** Size preset. Default: `"headline"`. */
sizePreset?: ContentLgSizePreset;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -86,6 +92,8 @@ function ContentLg({
description,
editable,
onTitleChange,
withInteractive,
ref,
}: ContentLgProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -104,7 +112,12 @@ function ContentLg({
}
return (
<div className="opal-content-lg" style={{ gap: config.gap }}>
<div
ref={ref}
className="opal-content-lg"
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (
<div
className={cn(

View File

@@ -61,6 +61,12 @@ interface ContentMdProps {
/** Size preset. Default: `"main-ui"`. */
sizePreset?: ContentMdSizePreset;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -130,6 +136,8 @@ function ContentMd({
auxIcon,
tag,
sizePreset = "main-ui",
withInteractive,
ref,
}: ContentMdProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -149,7 +157,12 @@ function ContentMd({
}
return (
<div className="opal-content-md" style={{ gap: config.gap }}>
<div
ref={ref}
className="opal-content-md"
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (
<div
className={cn(

View File

@@ -40,6 +40,12 @@ interface ContentSmProps {
/** Title prominence. Default: `"default"`. */
prominence?: ContentSmProminence;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -80,14 +86,18 @@ function ContentSm({
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
withInteractive,
ref,
}: ContentSmProps) {
const config = CONTENT_SM_PRESETS[sizePreset];
return (
<div
ref={ref}
className="opal-content-sm"
data-orientation={orientation}
data-prominence={prominence}
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (

View File

@@ -60,6 +60,12 @@ interface ContentXlProps {
/** Optional tertiary icon rendered in the icon row. */
moreIcon2?: IconFunctionComponent;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -106,6 +112,8 @@ function ContentXl({
onTitleChange,
moreIcon1: MoreIcon1,
moreIcon2: MoreIcon2,
withInteractive,
ref,
}: ContentXlProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -124,7 +132,11 @@ function ContentXl({
}
return (
<div className="opal-content-xl">
<div
ref={ref}
className="opal-content-xl"
data-interactive={withInteractive || undefined}
>
{(Icon || MoreIcon1 || MoreIcon2) && (
<div className="opal-content-xl-icon-row">
{Icon && (

View File

@@ -52,6 +52,9 @@ interface HeadingLayoutProps {
/** Variant controls icon placement. `"heading"` = top, `"section"` = inline. Default: `"heading"`. */
variant?: HeadingVariant;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -91,6 +94,7 @@ function HeadingLayout({
description,
editable,
onTitleChange,
ref,
}: HeadingLayoutProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -112,6 +116,7 @@ function HeadingLayout({
return (
<div
ref={ref}
className="opal-content-heading"
data-icon-placement={iconPlacement}
style={{ gap: iconPlacement === "left" ? config.gap : undefined }}

View File

@@ -61,6 +61,9 @@ interface LabelLayoutProps {
/** Size preset. Default: `"main-ui"`. */
sizePreset?: LabelSizePreset;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -130,6 +133,7 @@ function LabelLayout({
auxIcon,
tag,
sizePreset = "main-ui",
ref,
}: LabelLayoutProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -149,7 +153,7 @@ function LabelLayout({
}
return (
<div className="opal-content-label" style={{ gap: config.gap }}>
<div ref={ref} className="opal-content-label" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(

View File

@@ -30,6 +30,14 @@ A two-axis layout component for displaying icon + title + description rows. Rout
| `main-ui` | 1rem (16px) | `p-0.5` (2px) | `text-03` | 0.25rem (4px) | `font-main-ui-action` | 1.25rem (20px) |
| `secondary` | 0.75rem (12px) | `p-0.5` (2px) | `text-04` | 0.125rem (2px) | `font-secondary-action` | 1rem (16px) |
#### ContentSm presets (variant="body")
| Preset | Icon | Icon padding | Gap | Title font | Line-height |
|---|---|---|---|---|---|
| `main-content` | 1rem (16px) | `p-1` (4px) | 0.125rem (2px) | `font-main-content-body` | 1.5rem (24px) |
| `main-ui` | 1rem (16px) | `p-0.5` (2px) | 0.25rem (4px) | `font-main-ui-action` | 1.25rem (20px) |
| `secondary` | 0.75rem (12px) | `p-0.5` (2px) | 0.125rem (2px) | `font-secondary-action` | 1rem (16px) |
> Icon container height (icon + 2 x padding) always equals the title line-height.
### `variant` — controls structure / layout
@@ -53,31 +61,42 @@ Invalid combinations (e.g. `sizePreset="headline" + variant="body"`) are exclude
## Props
### Common props (all variants)
| Prop | Type | Default | Description |
|---|---|---|---|
| `sizePreset` | `SizePreset` | `"headline"` | Size preset (see tables above) |
| `variant` | `ContentVariant` | `"heading"` | Layout variant (see table above) |
| `variant` | `ContentVariant` | `"heading"` | Layout variant |
| `icon` | `IconFunctionComponent` | — | Optional icon component |
| `title` | `string` | **(required)** | Main title text |
| `description` | `string` | — | Optional description below the title |
| `editable` | `boolean` | `false` | Enable inline editing of the title |
| `description` | `string` | — | Optional description (not available for `variant="body"`) |
| `editable` | `boolean` | `false` | Enable inline editing (not available for `variant="body"`) |
| `onTitleChange` | `(newTitle: string) => void` | — | Called when user commits an edit |
| `moreIcon1` | `IconFunctionComponent` | — | Secondary icon in icon row (ContentXl only) |
| `moreIcon2` | `IconFunctionComponent` | — | Tertiary icon in icon row (ContentXl only) |
| `widthVariant` | `WidthVariant` | `"auto"` | `"auto"` shrink-wraps, `"full"` stretches |
| `withInteractive` | `boolean` | — | Opts title into `Interactive.Base`'s `--interactive-foreground` color |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root `<div>` of the resolved layout |
## Internal Layouts
### ContentXl-only props (`variant="heading"`)
### ContentXl
| Prop | Type | Default | Description |
|---|---|---|---|
| `moreIcon1` | `IconFunctionComponent` | — | Secondary icon in icon row |
| `moreIcon2` | `IconFunctionComponent` | — | Tertiary icon in icon row |
For `headline` / `section` presets with `variant="heading"`. Icon row on top (flex-col), supports `moreIcon1` and `moreIcon2` in the icon row. Description is always `font-secondary-body text-text-03`.
### ContentMd-only props (`sizePreset="main-content" / "main-ui" / "secondary"`, `variant="section"`)
### ContentLg
| Prop | Type | Default | Description |
|---|---|---|---|
| `optional` | `boolean` | — | Renders "(Optional)" beside the title |
| `auxIcon` | `"info-gray" \| "info-blue" \| "warning" \| "error"` | — | Auxiliary status icon beside the title |
| `tag` | `TagProps` | — | Tag rendered beside the title |
For `headline` / `section` presets with `variant="section"`. Always inline (flex-row). Description is always `font-secondary-body text-text-03`.
### ContentSm-only props (`variant="body"`)
### ContentMd
For `main-content` / `main-ui` / `secondary` presets. Always inline. Both `icon` and `description` are optional. Description is always `font-secondary-body text-text-03`.
| Prop | Type | Default | Description |
|---|---|---|---|
| `orientation` | `"vertical" \| "inline" \| "reverse"` | `"inline"` | Layout orientation |
| `prominence` | `"default" \| "muted" \| "muted-2x"` | `"default"` | Title prominence |
## Usage Examples
@@ -94,42 +113,34 @@ import SvgSearch from "@opal/icons/search";
description="Configure your agent's behavior"
/>
// ContentXl — with more icons
<Content
icon={SvgSearch}
sizePreset="headline"
variant="heading"
title="Agent Settings"
moreIcon1={SvgStar}
moreIcon2={SvgLock}
/>
// ContentLg — section, icon inline
<Content
icon={SvgSearch}
sizePreset="section"
variant="section"
title="Data Sources"
description="Connected integrations"
/>
// ContentMd — with icon and description
// ContentMd — with tag and optional marker
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Instructions"
description="Agent system prompt"
tag={{ title: "New", color: "green" }}
optional
/>
// ContentMdtitle only (no icon, no description)
// ContentSmbody text
<Content
sizePreset="main-content"
title="Featured Agent"
icon={SvgSearch}
sizePreset="main-ui"
variant="body"
title="Last updated 2 hours ago"
prominence="muted"
/>
// Editable title
<Content
icon={SvgSearch}
sizePreset="headline"
variant="heading"
title="My Agent"

View File

@@ -59,6 +59,12 @@ interface ContentBaseProps {
* @default "auto"
*/
widthVariant?: WidthVariant;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>` of the resolved layout. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------

View File

@@ -22,6 +22,7 @@
.opal-content-xl-icon-row {
@apply flex flex-row items-center;
gap: 0.25rem;
}
/* ---------------------------------------------------------------------------
@@ -385,3 +386,28 @@
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-title {
@apply text-text-02;
}
/* ===========================================================================
Interactive-foreground opt-in
When a Content variant is nested inside an Interactive.Base and
`withInteractive` is set, the title delegates its color to the
`--interactive-foreground` CSS variable controlled by the ancestor
Interactive.Base variant.
=========================================================================== */
.opal-content-xl[data-interactive] .opal-content-xl-title {
color: var(--interactive-foreground);
}
.opal-content-lg[data-interactive] .opal-content-lg-title {
color: var(--interactive-foreground);
}
.opal-content-md[data-interactive] .opal-content-md-title {
color: var(--interactive-foreground);
}
.opal-content-sm[data-interactive] .opal-content-sm-title {
color: var(--interactive-foreground);
}