mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(opal): add @opal/AttachmentItemButton (#14629)
This commit is contained in:
1 parent
fe538f0b89
commit
4ad6cfbc27
13 files changed
+751
-158
No files matched your search
@@ -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) |
|
||||
|
||||
+159
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
=========================================================================== */
|
||||
|
||||
|
||||
Reference in new issue
Block a user