feat(opal): add @opal/AttachmentItemButton (#14629)

This commit is contained in:
Raunak Bhagat authored and GitHub committed 2026-09-11 02:16:53 +00:00
1 parent fe538f0b89
commit 4ad6cfbc27
13 files changed
+751 -158

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ The barrel file at `index.ts` re-exports each component and its prop types. Each
| Component | Description | Docs |
| ---------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------- |
| [Button](./buttons/button/) | Label and/or icon-only stateless button | [README](./buttons/button/README.md) |
| [AttachmentItemButton](./buttons/attachment-item-button/) | File-like resource row: icon/image tile, content, center and action slots | [README](./buttons/attachment-item-button/README.md) |
| [SelectButton](./buttons/select-button/) | Stateful toggle button with optional foldable content | [README](./buttons/select-button/README.md) |
| [OpenButton](./buttons/open-button/) | Trigger button with rotating chevron for popovers | [README](./buttons/open-button/README.md) |
| [Tag](./tag/) | Small colored label for status/category metadata | [README](./tag/README.md) |
@@ -0,0 +1,159 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AttachmentItemButton, Button, Text } from "@opal/components";
import SvgFileText from "@opal/icons/file-text";
import SvgKey from "@opal/icons/key";
import SvgTrash from "@opal/icons/trash";
import SvgExternalLink from "@opal/icons/external-link";
const meta: Meta<typeof AttachmentItemButton> = {
title: "opal/components/AttachmentItemButton",
component: AttachmentItemButton,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof AttachmentItemButton>;
export const Default: Story = {
render: () => (
<div className="w-[32rem]">
<AttachmentItemButton
icon={SvgFileText}
title="Research Doc"
description="PDF"
/>
</div>
),
};
export const States: Story = {
render: () => (
<div className="flex w-[32rem] flex-col gap-1">
<AttachmentItemButton
icon={SvgFileText}
title="Empty"
description="At rest"
/>
<AttachmentItemButton
icon={SvgFileText}
title="Selected"
description="Tile swaps to a checkbox"
state="selected"
/>
<AttachmentItemButton
icon={SvgFileText}
title="Disabled"
description="Suppressed palette and clicks"
disabled
/>
</div>
),
};
/**
* Prominence sets only the rest intensity; hover and selected palettes come
* from `Interactive.Stateful` in all three.
*/
export const Prominences: Story = {
render: () => (
<div className="flex w-[32rem] flex-col gap-1">
<AttachmentItemButton
prominence="primary"
icon={SvgFileText}
title="primary"
description="Rests on background-tint-00"
/>
<AttachmentItemButton
prominence="secondary"
icon={SvgFileText}
title="secondary"
description="Rests on background-neutral-01"
/>
<AttachmentItemButton
icon={SvgFileText}
title="tertiary (default)"
description="Transparent at rest"
/>
</div>
),
};
export const Image: Story = {
render: () => (
<div className="w-[32rem]">
<AttachmentItemButton
imageSrc="https://picsum.photos/seed/opal/72"
imageAlt="Random placeholder"
title="Cover Image"
description="PNG"
/>
</div>
),
};
/**
* The center slot expands between the title group and the action slot; the
* action slot keeps its minimum width even when it has no children, so rows
* with and without actions stay aligned.
*/
export const Slots: Story = {
render: () => (
<div className="flex w-[40rem] flex-col gap-1">
<AttachmentItemButton
icon={SvgFileText}
title="With every slot"
description="PDF"
centerChildren={
<div className="flex flex-row justify-end">
<Text font="secondary-body" color="text-03">
2 days ago
</Text>
</div>
}
rightChildren={
<>
<Button icon={SvgExternalLink} prominence="tertiary" size="sm" />
<Button icon={SvgTrash} prominence="tertiary" size="sm" />
</>
}
/>
<AttachmentItemButton
icon={SvgFileText}
title="No actions"
description="The empty action slot still reserves its width"
/>
</div>
),
};
export const Presentational: Story = {
render: () => (
<div className="w-[32rem]">
<AttachmentItemButton
presentational
prominence="secondary"
icon={SvgKey}
title="Production key"
description="sk-...4f2a"
rightChildren={
<Button icon={SvgTrash} prominence="tertiary" size="sm" />
}
/>
</div>
),
};
export const Editable: Story = {
render: () => (
<div className="w-[32rem]">
<AttachmentItemButton
presentational
icon={SvgFileText}
title="Rename me"
description="Inline title editing via Content"
editable
onTitleChange={(next) => console.log("renamed to", next)}
/>
</div>
),
};
@@ -0,0 +1,80 @@
# AttachmentItemButton
**Import:** `import { AttachmentItemButton, type AttachmentItemButtonProps } from "@opal/components";`
A row for a file-like resource: a large tinted tile (icon or image) beside a
title/description, an expanding center slot, and a reserved trailing action
slot. The tile's rounding (`--radius-08`) complements the row's container
rounding (`--radius-12`).
## Structure
```
root Interactive.Stateful + Interactive.Container
├─ title tile + icon-less Content (main-ui / section, frozen)
│ ├─ tile icon on tint, or image cover-filling; Checkbox when selected
│ └─ Content title, description, optional inline editing
├─ center centerChildren — flex-1, caller-aligned
└─ action rightChildren — min-width --spacing-block-36, always reserved
```
## Tile
Exactly one of two arms, enforced at the type level:
- `icon` — centered on the tinted tile.
- `imageSrc` + `imageAlt` — image fills the tile (`object-fit: cover`).
`imageAlt` is mandatory: the image is content, not chrome.
When `state="selected"`, the tile swaps its content for a checked `Checkbox`.
## Interactivity
Three modes, same contract as `LineItemButton`:
- **Button** (default) — focusable `role="button"` div with native-style
Enter/Space activation. Not a `<button>`, so action buttons in
`rightChildren` never nest invalid HTML. Clicks and keystrokes from nested
interactive children never activate the row — no `stopPropagation` needed
at call sites.
- **Anchor** — pass `href` (+ `target`).
- **Presentational** — pass `presentational`; the row renders no control
semantics of its own and defers them to the primitive that owns it.
`prominence` sets the intensity at rest: `"primary"` rests on
`background-tint-00`, `"secondary"` on `background-neutral-01`, and
`"tertiary"` (default) is transparent. Hover / selected / disabled palettes
come from `Interactive.Stateful` and are shared across all three.
## Content
The `Content` axis is frozen at `main-ui`/`section` — the tile is this
component's identity, so `sizePreset`/`variant` are not exposed. `title` and
`description` default to one line each (`titleMaxLines` /
`descriptionMaxLines` widen this). `editable` + `onTitleChange` enable
Content's inline title editing.
## Usage
```tsx
// File row: click-selectable, hover-revealed delete
<AttachmentItemButton
icon={SvgFileText}
title={file.name}
description="PDF"
state={selected ? "selected" : "empty"}
onClick={toggle}
centerChildren={<Text font="secondary-body" color="text-03">2 days ago</Text>}
rightChildren={<Button icon={SvgTrash} onClick={remove} prominence="tertiary" size="sm" />}
/>
// Static token row on a page surface
<AttachmentItemButton
presentational
prominence="secondary"
icon={SvgKey}
title={token.name}
description={token.display}
rightChildren={<Button icon={SvgTrash} onClick={revoke} prominence="tertiary" size="sm" />}
/>
```
@@ -0,0 +1,301 @@
import "@opal/components/buttons/attachment-item-button/styles.css";
import type React from "react";
import { Interactive, type InteractiveStatefulProps } from "@opal/core";
import type {
ExtremaSizeVariants,
IconFunctionComponent,
RichStr,
} from "@opal/types";
import { Checkbox, Tooltip, type TooltipSide } from "@opal/components";
import {
composeKeyHandler,
guardNestedInteractiveClick,
handleRowKeyDown,
handleRowKeyUp,
} from "@opal/components/buttons/row-interaction";
import { Content } from "@opal/layouts";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* The leading tile shows exactly one thing: an icon on the tinted tile, or an
* image filling it. The union keeps the two exclusive, and makes `imageAlt`
* mandatory the moment an image appears — the tile owns its chrome (size,
* rounding, fit), so neither arm is a free-form slot.
*/
type AttachmentItemButtonTileProps =
| {
/** Icon centered on the tinted tile. */
icon: IconFunctionComponent;
imageSrc?: never;
imageAlt?: never;
}
| {
/** Image filling the tile (`object-fit: cover`, tile rounding). */
imageSrc: string;
/** Alt text for the image. Required — the image is content, not chrome. */
imageAlt: string;
icon?: never;
};
/**
* The `Content` props a row actually uses — enumerated rather than spread,
* so that everything a caller passes which is *not* here is DOM, and reaches
* the row element (the `RowContentProps` doctrine from `LineItemButton`).
*
* `sizePreset` and `variant` are deliberately not exposed: every attachment
* row renders the same `main-ui`/`section` Content. The tile is this
* component's identity; the Content axis stays frozen.
*/
type AttachmentItemButtonContentProps = {
/** Main label. */
title: string | RichStr;
/**
* Cap the title at N lines and truncate the rest. Defaults to `1` — these
* rows show user-authored names (file names, token names).
*
* @default 1
*/
titleMaxLines?: number;
/** Secondary line under the title. */
description?: string | RichStr;
/**
* Cap the description at N lines and truncate the rest.
*
* @default 1
*/
descriptionMaxLines?: number;
/** Enable inline editing of the title (Content's pencil affordance). */
editable?: boolean;
/** Called when the user commits a title edit. */
onTitleChange?: (newTitle: string) => void;
};
/**
* `presentational` and `href` are mutually exclusive. An anchor is a native
* control — focusable, activated by Enter — which is exactly what the
* presentational mode promises the row will not be.
*/
type AttachmentItemButtonModeProps =
| {
/**
* Render the row as plain markup inside another interactive primitive:
* no button role, no tab stop, no Enter/Space activation. The row keeps
* its interactive palette — drive it with `state` / `prominence` /
* `interaction` from the owning control. `role`, `tabIndex` and the key
* handlers still pass through, so the owner can substitute its own
* semantics.
*/
presentational: true;
href?: never;
target?: never;
}
| ({ presentational?: false } & Pick<
InteractiveStatefulProps,
"href" | "target"
>);
type AttachmentItemButtonOwnProps = Pick<
InteractiveStatefulProps,
"state" | "interaction" | "onClick" | "group" | "ref" | "disabled"
> &
AttachmentItemButtonModeProps & {
/**
* Surface intensity at rest — hover, selected and disabled palettes
* stay the same across all three.
*
* - `"primary"` — rests on `background-tint-00` (elevated rows on a card)
* - `"secondary"` — rests on `background-neutral-01` (rows on a plain page surface)
* - `"tertiary"` — transparent at rest
*
* @default "tertiary"
*/
prominence?: "primary" | "secondary" | "tertiary";
/** Container width. @default "full" */
width?: ExtremaSizeVariants;
/** Tooltip text shown on hover. */
tooltip?: string;
/** Which side the tooltip appears on. @default "top" */
tooltipSide?: TooltipSide;
/**
* Content rendered between the title group and the action slot. Expands
* to fill the free width, so callers place counts, metadata or timestamps
* here and align them inside the slot.
*/
centerChildren?: React.ReactNode;
/**
* Trailing action slot. Keeps a minimum width of `--spacing-block-36`
* even when empty or hover-hidden, so rows with and without actions align
* and hover-revealed buttons cause no layout shift.
*/
rightChildren?: React.ReactNode;
};
/**
* `title` is omitted from the DOM attributes because the row already means
* something by it — its label — and the native attribute would put two
* meanings in one prop. `children` is omitted because a row renders none:
* its content comes from `title` and friends.
*/
type AttachmentItemButtonProps = AttachmentItemButtonOwnProps &
AttachmentItemButtonTileProps &
AttachmentItemButtonContentProps &
Omit<React.HTMLAttributes<HTMLDivElement>, "title" | "children">;
// ---------------------------------------------------------------------------
// AttachmentItemButton
// ---------------------------------------------------------------------------
function AttachmentItemButton({
// Interactive surface
prominence = "tertiary",
state,
interaction,
onClick,
href,
target,
group,
ref,
disabled,
presentational,
// Sizing
width = "full",
tooltip,
tooltipSide = "top",
// Tile
icon: Icon,
imageSrc,
imageAlt,
// Content
title,
titleMaxLines = 1,
description,
descriptionMaxLines = 1,
editable,
onTitleChange,
centerChildren,
rightChildren,
/*
* Named so the row can defer to a caller's value instead of overwriting it.
* They would otherwise ride `rowProps` onto the container and be replaced
* by the button semantics spread after it — accepted by the type, then
* gone.
*/
role,
tabIndex,
onKeyDown,
onKeyUp,
/*
* Whatever is left is DOM — labels, handlers, `data-*` — and belongs on
* the row.
*/
...rowProps
}: AttachmentItemButtonProps) {
// See row-interaction.ts: the row is a focusable div (role="button") so
// interactive `rightChildren` don't nest a <button> inside a <button>.
const rowButtonProps: Pick<
React.HTMLAttributes<HTMLDivElement>,
"role" | "tabIndex" | "onKeyDown" | "onKeyUp"
> = presentational
? { role: role ?? "presentation", tabIndex, onKeyDown, onKeyUp }
: href
? { role, tabIndex, onKeyDown, onKeyUp }
: {
role: role ?? "button",
tabIndex: tabIndex ?? 0,
onKeyDown: composeKeyHandler(onKeyDown, handleRowKeyDown),
onKeyUp: composeKeyHandler(onKeyUp, handleRowKeyUp),
};
const item = (
<Interactive.Stateful
variant="select-heavy"
prominence={prominence}
state={state}
interaction={interaction}
onClick={guardNestedInteractiveClick(onClick)}
href={href}
target={target}
group={group}
ref={ref}
disabled={disabled}
>
<Interactive.Container
width={width}
size="fit"
rounding={3}
{...rowProps}
{...rowButtonProps}
>
<div className="opal-attachment-item-button">
<div className="opal-attachment-item-button-title">
<div className="opal-attachment-item-button-tile">
{state === "selected" ? (
/* Purely a visual indicator. `inert` + aria-hidden take the
checkbox out of the tab order, the accessibility tree and
the event flow — the row alone owns activation, and its
own selected styling carries the state for AT. */
<span inert aria-hidden className="contents">
<Checkbox checked readOnly />
</span>
) : imageSrc ? (
<img
src={imageSrc}
alt={imageAlt}
className="opal-attachment-item-button-image"
/>
) : (
Icon && <Icon className="opal-attachment-item-button-icon" />
)}
</div>
<Content
sizePreset="main-ui"
variant="section"
color="interactive"
title={title}
titleMaxLines={titleMaxLines}
description={description}
descriptionMaxLines={descriptionMaxLines}
editable={editable}
onTitleChange={onTitleChange}
width="full"
/>
</div>
{centerChildren != null && (
<div className="opal-attachment-item-button-center">
{centerChildren}
</div>
)}
<div className="opal-attachment-item-button-action">
{rightChildren}
</div>
</div>
</Interactive.Container>
</Interactive.Stateful>
);
return (
<Tooltip tooltip={tooltip} side={tooltipSide}>
{item}
</Tooltip>
);
}
export { AttachmentItemButton, type AttachmentItemButtonProps };
@@ -0,0 +1,53 @@
@reference "#reference.css";
/* ============================================================================
AttachmentItemButton — layout only
Colors come from the Interactive.Stateful palette (`--interactive-foreground`
/ `--interactive-foreground-icon`) via the `select-heavy` variant and its
prominence cells; nothing here sets a state color.
Structure: root → title (tile + Content) / center / action.
============================================================================ */
.opal-attachment-item-button {
@apply flex w-full min-w-0 flex-row items-center justify-between gap-2 p-1;
}
/* Natural width (shrinkable for truncation, but no grow): the center slot is
the one that expands, and justify-between keeps the action slot trailing
when there is no center. */
.opal-attachment-item-button-title {
@apply flex min-w-0 flex-row items-center gap-2;
}
/* The signature square: its rounding (radius-08) complements the row's own
container rounding (radius-12) at the shared inset. Sized by the same
token as the action slot so the row's two fixed-width regions agree. */
.opal-attachment-item-button-tile {
@apply flex shrink-0 items-center justify-center overflow-hidden;
height: var(--spacing-block-36);
width: var(--spacing-block-36);
background-color: var(--background-tint-01);
border-radius: var(--radius-08);
}
.opal-attachment-item-button-icon {
@apply h-4 w-4;
stroke: var(--interactive-foreground-icon, var(--text-02));
}
.opal-attachment-item-button-image {
@apply h-full w-full object-cover;
}
.opal-attachment-item-button-center {
@apply min-w-0 flex-1;
}
/* Reserved even when empty: rows with and without actions align, and
hover-revealed buttons cause no layout shift. */
.opal-attachment-item-button-action {
@apply flex shrink-0 flex-row items-center justify-center gap-2 self-stretch;
min-width: var(--spacing-block-36);
}
@@ -14,6 +14,12 @@ import {
type SizePreset,
ContentAction,
} from "@opal/layouts";
import {
composeKeyHandler,
guardNestedInteractiveClick,
handleRowKeyDown,
handleRowKeyUp,
} from "@opal/components/buttons/row-interaction";
// ---------------------------------------------------------------------------
// Types
@@ -146,57 +152,6 @@ type LineItemButtonProps = LineItemButtonOwnProps &
// LineItemButton
// ---------------------------------------------------------------------------
// Mirrors native <button> activation (Enter fires on keydown, Space on keyup).
// Guarded so keystrokes on nested interactive children (e.g. `rightChildren`
// action buttons) don't also activate the row.
function handleRowKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.click();
} else if (e.key === " ") {
e.preventDefault();
}
}
function handleRowKeyUp(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.target !== e.currentTarget) return;
if (e.key === " ") {
e.preventDefault();
e.currentTarget.click();
}
}
// The caller's handler runs first and can stop the row's own activation with
// `preventDefault()` — the order a native control gives you. Composed rather
// than replaced, because a row that accepts a handler and then overwrites it
// is the same silent drop this component exists to avoid.
function composeKeyHandler(
caller: React.KeyboardEventHandler<HTMLDivElement> | undefined,
row: React.KeyboardEventHandler<HTMLDivElement>
): React.KeyboardEventHandler<HTMLDivElement> {
if (!caller) return row;
return (e) => {
caller(e);
if (!e.defaultPrevented) row(e);
};
}
// Ignore clicks originating from nested interactive children (e.g.
// `rightChildren` action buttons) so they don't also activate the row.
function guardNestedInteractiveClick(
onClick: React.MouseEventHandler<HTMLElement> | undefined
): React.MouseEventHandler<HTMLElement> | undefined {
if (!onClick) return undefined;
return (e) => {
const nested = (e.target as HTMLElement).closest(
'button, a, [role="button"]'
);
if (nested && nested !== e.currentTarget) return;
onClick(e);
};
}
function LineItemButton({
// Interactive surface
selectVariant = "select-light",
@@ -50,7 +50,7 @@ type OpenButtonContentProps =
children?: string | RichStr;
};
type OpenButtonVariant = "select-light" | "select-heavy" | "select-tinted";
type OpenButtonVariant = "select-light" | "select-heavy";
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
variant?: OpenButtonVariant;
@@ -0,0 +1,99 @@
import type React from "react";
// ---------------------------------------------------------------------------
// Shared interaction helpers for row-shaped buttons (LineItemButton,
// AttachmentItemButton). A row renders as a focusable div (role="button")
// instead of a native <button> so interactive `rightChildren` don't nest a
// <button> inside a <button> — invalid HTML that breaks hydration. These
// helpers restore the native-control behavior that choice gives up.
// ---------------------------------------------------------------------------
// A caller's preventDefault() on Space arrives on keydown, but the click
// fires on keyup — a different event, so `defaultPrevented` can't carry the
// cancellation across. The composed keydown wrapper records it here instead,
// and the keyup handler honors and clears it.
const SPACE_CANCELED = "rowSpaceCanceled";
// Mirrors native <button> activation (Enter fires on keydown, Space on keyup).
// Guarded so keystrokes on nested interactive children (e.g. `rightChildren`
// action buttons) don't also activate the row.
function handleRowKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.click();
} else if (e.key === " ") {
e.preventDefault();
delete e.currentTarget.dataset[SPACE_CANCELED];
}
}
function handleRowKeyUp(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.target !== e.currentTarget) return;
if (e.key === " ") {
e.preventDefault();
if (e.currentTarget.dataset[SPACE_CANCELED]) {
delete e.currentTarget.dataset[SPACE_CANCELED];
return;
}
e.currentTarget.click();
}
}
// The caller's handler runs first and can stop the row's own activation with
// `preventDefault()` — the order a native control gives you. Composed rather
// than replaced, because a row that accepts a handler and then overwrites it
// is the same silent drop these components exist to avoid.
function composeKeyHandler(
caller: React.KeyboardEventHandler<HTMLDivElement> | undefined,
row: React.KeyboardEventHandler<HTMLDivElement>
): React.KeyboardEventHandler<HTMLDivElement> {
if (!caller) return row;
return (e) => {
caller(e);
if (e.defaultPrevented) {
if (e.key === " " && e.type === "keydown") {
e.currentTarget.dataset[SPACE_CANCELED] = "true";
}
return;
}
row(e);
};
}
// Everything a click may legitimately land on without meaning "activate the
// row": nested action buttons and links, and the form controls an inline
// editor renders.
const NESTED_INTERACTIVE_SELECTOR =
"button, a, input, textarea, select, label, " +
'[role="button"], [role="checkbox"], [contenteditable="true"]';
// Ignore clicks originating from nested interactive children (e.g.
// `rightChildren` action buttons) so they don't also activate the row.
// An anchor row also needs preventDefault, or its native link navigates
// after the nested action runs; on other rows the default stays, so nested
// labels and form controls keep their native behavior.
function guardNestedInteractiveClick(
onClick: React.MouseEventHandler<HTMLElement> | undefined
): React.MouseEventHandler<HTMLElement> | undefined {
if (!onClick) return undefined;
return (e) => {
const nested = (e.target as HTMLElement).closest(
NESTED_INTERACTIVE_SELECTOR
);
if (nested && nested !== e.currentTarget) {
if (e.currentTarget instanceof HTMLAnchorElement) {
e.preventDefault();
}
return;
}
onClick(e);
};
}
export {
handleRowKeyDown,
handleRowKeyUp,
composeKeyHandler,
guardNestedInteractiveClick,
};
+6
View File
@@ -30,6 +30,12 @@ export {
type FilterButtonProps,
} from "@opal/components/buttons/filter-button/components";
/* AttachmentItemButton */
export {
AttachmentItemButton,
type AttachmentItemButtonProps,
} from "@opal/components/buttons/attachment-item-button/components";
/* LineItemButton */
export {
LineItemButton,
+1
View File
@@ -37,6 +37,7 @@ export type {
export type {
InteractiveStatefulProps,
InteractiveStatefulVariant,
InteractiveStatefulProminence,
InteractiveStatefulState,
InteractiveStatefulInteraction,
} from "@opal/core/interactive/stateful/components";
@@ -8,7 +8,8 @@ Stateful interactive surface primitive for elements that maintain a value state
| Prop | Type | Default | Description |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ---------------------------------------- |
| `variant` | `"select-light" \| "select-heavy" \| "select-card" \| "select-tinted" \| "select-filter" \| "sidebar-heavy" \| "sidebar-light"` | `"select-heavy"` | Color variant |
| `variant` | `"select-light" \| "select-heavy" \| "select-card" \| "select-input" \| "select-filter" \| "sidebar-heavy" \| "sidebar-light"` | `"select-heavy"` | Color variant |
| `prominence` | `"primary" \| "secondary" \| "tertiary"` | `"tertiary"` | Rest intensity — select-heavy only: `primary` rests on `background-tint-00`, `secondary` on `background-neutral-01`, `tertiary` transparent. Affects only the at-rest empty/filled backgrounds; other variants ignore it. |
| `state` | `"empty" \| "filled" \| "selected"` | `"empty"` | Current value state |
| `interaction` | `"rest" \| "hover" \| "active"` | `"rest"` | JS-controlled interaction override |
| `group` | `string` | — | Tailwind group class for `group-hover:*` |
@@ -21,8 +22,8 @@ Stateful interactive surface primitive for elements that maintain a value state
- **`select-light`** — Transparent selected background. For inline toggles.
- **`select-heavy`** — Tinted selected background (`action-selection-01`). For list rows, model pickers, buttons.
- **`select-card`** — Like `select-heavy`, but the filled state gets a visible background (`background-tint-00`) with neutral foreground. Designed for larger surfaces (cards) where background carries more of the visual distinction than foreground color alone.
- **`select-tinted`** — Like `select-heavy` but with a tinted rest background (`background-tint-01`).
- **`select-filter`** — Like `select-tinted` for empty/filled; selected state uses inverted backgrounds and inverted text.
- **`select-input`** — Rests at `background-neutral-00` (matches the input bar); hover/active shows `neutral-03`.
- **`select-filter`** — Empty/filled rest on tint-01; selected state uses inverted backgrounds and inverted text.
- **`sidebar-heavy`** — Sidebar navigation: muted when unselected, bold when selected.
- **`sidebar-light`** — Sidebar navigation: uniformly muted across all states.
@@ -14,11 +14,11 @@ type InteractiveStatefulVariant =
| "select-light"
| "select-heavy"
| "select-card"
| "select-tinted"
| "select-input"
| "select-filter"
| "sidebar-heavy"
| "sidebar-light";
type InteractiveStatefulProminence = "primary" | "secondary" | "tertiary";
type InteractiveStatefulState = "empty" | "filled" | "selected";
type InteractiveStatefulInteraction = "rest" | "hover" | "active";
@@ -36,9 +36,8 @@ interface InteractiveStatefulProps extends WithoutStyles<
* - `"select-light"` — transparent selected background (for inline toggles)
* - `"select-heavy"` — tinted selected background (for list rows, model pickers)
* - `"select-card"` — like select-heavy but filled state has a visible background (for cards/larger surfaces)
* - `"select-tinted"` — like select-heavy but with a tinted rest background
* - `"select-input"` — rests at neutral-00 (matches input bar), hover/open shows neutral-03 + border-01
* - `"select-filter"` — like select-tinted for empty/filled; selected state uses inverted tint backgrounds and inverted text (for filter buttons)
* - `"select-input"` — rests at neutral-00 (matches input bar), hover/active shows neutral-03
* - `"select-filter"` — empty/filled rest on tint-01; selected state uses inverted tint backgrounds and inverted text (for filter buttons)
* - `"sidebar-heavy"` — sidebar navigation items: muted when unselected (text-03/text-02), bold when selected (text-04/text-03)
* - `"sidebar-light"` — sidebar navigation items: uniformly muted across all states (text-02/text-02)
*
@@ -46,6 +45,21 @@ interface InteractiveStatefulProps extends WithoutStyles<
*/
variant?: InteractiveStatefulVariant;
/**
* Surface intensity at rest. Only the `select-heavy` family defines
* prominence cells; other variants have a single look and ignore it.
*
* - `"primary"` — rests on `background-tint-00`
* - `"secondary"` — rests on `background-neutral-01`
* - `"tertiary"` — transparent at rest
*
* Prominence affects only the at-rest empty/filled backgrounds: hover,
* active, selected and disabled cells stay the variant's own.
*
* @default "tertiary"
*/
prominence?: InteractiveStatefulProminence;
/**
* The current value state of this element.
*
@@ -112,6 +126,7 @@ interface InteractiveStatefulProps extends WithoutStyles<
function InteractiveStateful({
ref,
variant = "select-heavy",
prominence = "tertiary",
state = "empty",
interaction = "rest",
group,
@@ -133,6 +148,7 @@ function InteractiveStateful({
const dataAttrs = {
"data-interactive-variant": variant,
"data-interactive-prominence": prominence,
"data-interactive-state": state,
"data-interaction": interaction !== "rest" ? interaction : undefined,
"data-disabled": isDisabled ? "true" : undefined,
@@ -171,6 +187,7 @@ export {
InteractiveStateful,
type InteractiveStatefulProps,
type InteractiveStatefulVariant,
type InteractiveStatefulProminence,
type InteractiveStatefulState,
type InteractiveStatefulInteraction,
};
@@ -13,7 +13,12 @@
Children read the variables with no independent transitions.
State dimension: `data-interactive-state` = "empty" | "filled" | "selected"
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "select-card" | "select-tinted" | "select-filter" | "sidebar-heavy" | "sidebar-light"
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "select-card" | "select-input" | "select-filter" | "sidebar-heavy" | "sidebar-light"
Prominence dimension: `data-interactive-prominence` = "primary" | "secondary" | "tertiary"
Only the select-heavy family defines prominence cells, and only for the
at-rest (no interaction) empty/filled backgrounds; hover, active,
selected and disabled cells are shared across prominences. Other
variants ignore prominence.
Interaction override: `data-interaction="hover"` and `data-interaction="active"`
allow JS-controlled visual state overrides.
@@ -31,6 +36,12 @@
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-prominence="secondary"][data-interactive-state="empty"] {
@apply bg-background-neutral-01;
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-prominence="primary"][data-interactive-state="empty"] {
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
@@ -64,6 +75,12 @@
--interactive-foreground: var(--action-selection-05);
--interactive-foreground-icon: var(--action-selection-05);
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-prominence="secondary"][data-interactive-state="filled"] {
@apply bg-background-neutral-01;
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-prominence="primary"][data-interactive-state="filled"] {
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
@@ -315,43 +332,6 @@
--interactive-foreground-icon: var(--action-selection-03);
}
/* ===========================================================================
Select-Tinted — like Select-Heavy but with a tinted rest background
=========================================================================== */
/* ---------------------------------------------------------------------------
Select-Tinted — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"] {
@apply bg-background-tint-01;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
--interactive-foreground-icon: var(--text-04);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-neutral-00;
--interactive-foreground: var(--text-05);
--interactive-foreground-icon: var(--text-05);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-01);
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Input — Empty
Matches input bar background at rest, tints on hover/open.
@@ -387,68 +367,8 @@
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Tinted — Filled
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"] {
@apply bg-background-tint-01;
--interactive-foreground: var(--action-selection-05);
--interactive-foreground-icon: var(--action-selection-05);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-01);
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Tinted — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"] {
@apply bg-[var(--action-selection-01)];
--interactive-foreground: var(--action-selection-05);
--interactive-foreground-icon: var(--action-selection-05);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--action-selection-03);
--interactive-foreground-icon: var(--action-selection-03);
}
/* ===========================================================================
Select-Filter — empty/filled identical to Select-Tinted;
Select-Filter — empty/filled rest on tint-01;
selected uses inverted tint backgrounds and inverted text
=========================================================================== */