Compare commits

...

2 Commits

Author SHA1 Message Date
Raunak Bhagat
8f5d7e271a refactor: migrate ModalHeader to Content layout
Modal.Header now uses opal Content component for icon + title rendering.
Description passed to Content directly with a hidden
DialogPrimitive.Description for accessibility. Close button absolutely
positioned per Figma mocks.
2026-03-01 12:06:18 -08:00
Raunak Bhagat
bb6e20614d refactor(opal): split ContentLg into ContentXl + ContentLg
- ContentXl: variant="heading" (icon row on top, flex-col) with
  moreIcon1/moreIcon2 support
- ContentLg: simplified to always flex-row (variant="section")
- Section preset font updated to font-heading-h3-muted
- Renamed type aliases: XlContentProps, LgContentProps, MdContentProps,
  SmContentProps
- Renamed internal layout files to size-based names (ContentLg, ContentMd,
  ContentSm)
2026-03-01 12:05:56 -08:00
12 changed files with 1165 additions and 160 deletions

View File

@@ -42,7 +42,7 @@ import SvgStar from "@opal/icons/star";
## Usage inside Content
Tag can be rendered as an accessory inside `Content`'s LabelLayout via the `tag` prop:
Tag can be rendered as an accessory inside `Content`'s ContentMd via the `tag` prop:
```tsx
import { Content } from "@opal/layouts";

View File

@@ -0,0 +1,200 @@
"use client";
import { Button } from "@opal/components/buttons/Button/components";
import type { SizeVariant } from "@opal/shared";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ContentLgSizePreset = "headline" | "section";
interface ContentLgPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** Gap between icon container and content (CSS value). */
gap: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: SizeVariant;
/** Tailwind padding class for the edit button container. */
editButtonPadding: string;
}
interface ContentLgProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text. */
title: string;
/** Optional description below the title. */
description?: string;
/** Enable inline editing of the title. */
editable?: boolean;
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** Size preset. Default: `"headline"`. */
sizePreset?: ContentLgSizePreset;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const CONTENT_LG_PRESETS: Record<ContentLgSizePreset, ContentLgPresetConfig> = {
headline: {
iconSize: "2rem",
iconContainerPadding: "p-0.5",
gap: "0.25rem",
titleFont: "font-heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
},
section: {
iconSize: "1.25rem",
iconContainerPadding: "p-1",
gap: "0rem",
titleFont: "font-heading-h3-muted",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
},
};
// ---------------------------------------------------------------------------
// ContentLg
// ---------------------------------------------------------------------------
function ContentLg({
sizePreset = "headline",
icon: Icon,
title,
description,
editable,
onTitleChange,
}: ContentLgProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const config = CONTENT_LG_PRESETS[sizePreset];
function startEditing() {
setEditValue(title);
setEditing(true);
}
function commit() {
const value = editValue.trim();
if (value && value !== title) onTitleChange?.(value);
setEditing(false);
}
return (
<div className="opal-content-lg" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(
"opal-content-lg-icon-container shrink-0",
config.iconContainerPadding
)}
style={{ minHeight: config.lineHeight }}
>
<Icon
className="opal-content-lg-icon"
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<div className="opal-content-lg-body">
<div className="opal-content-lg-title-row">
{editing ? (
<div className="opal-content-lg-input-sizer">
<span
className={cn("opal-content-lg-input-mirror", config.titleFont)}
>
{editValue || "\u00A0"}
</span>
<input
ref={inputRef}
className={cn(
"opal-content-lg-input",
config.titleFont,
"text-text-04"
)}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
size={1}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setEditValue(title);
setEditing(false);
}
}}
style={{ height: config.lineHeight }}
/>
</div>
) : (
<span
className={cn(
"opal-content-lg-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{title}
</span>
)}
{editable && !editing && (
<div
className={cn(
"opal-content-lg-edit-button",
config.editButtonPadding
)}
>
<Button
icon={SvgEdit}
prominence="internal"
size={config.editButtonSize}
tooltip="Edit"
tooltipSide="right"
onClick={startEditing}
/>
</div>
)}
</div>
{description && (
<div className="opal-content-lg-description font-secondary-body text-text-03">
{description}
</div>
)}
</div>
</div>
);
}
export { ContentLg, type ContentLgProps, type ContentLgSizePreset };

View File

@@ -0,0 +1,279 @@
"use client";
import { Button } from "@opal/components/buttons/Button/components";
import { Tag, type TagProps } from "@opal/components/Tag/components";
import type { SizeVariant } from "@opal/shared";
import SvgAlertCircle from "@opal/icons/alert-circle";
import SvgAlertTriangle from "@opal/icons/alert-triangle";
import SvgEdit from "@opal/icons/edit";
import SvgXOctagon from "@opal/icons/x-octagon";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ContentMdSizePreset = "main-content" | "main-ui" | "secondary";
type ContentMdAuxIcon = "info-gray" | "info-blue" | "warning" | "error";
interface ContentMdPresetConfig {
iconSize: string;
iconContainerPadding: string;
iconColorClass: string;
titleFont: string;
lineHeight: string;
gap: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: SizeVariant;
editButtonPadding: string;
optionalFont: string;
/** Aux icon size = lineHeight 2 × p-0.5. */
auxIconSize: string;
}
interface ContentMdProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text. */
title: string;
/** Optional description text below the title. */
description?: string;
/** Enable inline editing of the title. */
editable?: boolean;
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** When `true`, renders "(Optional)" beside the title. */
optional?: boolean;
/** Auxiliary status icon rendered beside the title. */
auxIcon?: ContentMdAuxIcon;
/** Tag rendered beside the title. */
tag?: TagProps;
/** Size preset. Default: `"main-ui"`. */
sizePreset?: ContentMdSizePreset;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
"main-content": {
iconSize: "1rem",
iconContainerPadding: "p-1",
iconColorClass: "text-text-04",
titleFont: "font-main-content-emphasis",
lineHeight: "1.5rem",
gap: "0.125rem",
editButtonSize: "sm",
editButtonPadding: "p-0",
optionalFont: "font-main-content-muted",
auxIconSize: "1.25rem",
},
"main-ui": {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-03",
titleFont: "font-main-ui-action",
lineHeight: "1.25rem",
gap: "0.25rem",
editButtonSize: "xs",
editButtonPadding: "p-0",
optionalFont: "font-main-ui-muted",
auxIconSize: "1rem",
},
secondary: {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-04",
titleFont: "font-secondary-action",
lineHeight: "1rem",
gap: "0.125rem",
editButtonSize: "2xs",
editButtonPadding: "p-0",
optionalFont: "font-secondary-action",
auxIconSize: "0.75rem",
},
};
// ---------------------------------------------------------------------------
// ContentMd
// ---------------------------------------------------------------------------
const AUX_ICON_CONFIG: Record<
ContentMdAuxIcon,
{ icon: IconFunctionComponent; colorClass: string }
> = {
"info-gray": { icon: SvgAlertCircle, colorClass: "text-text-02" },
"info-blue": { icon: SvgAlertCircle, colorClass: "text-status-info-05" },
warning: { icon: SvgAlertTriangle, colorClass: "text-status-warning-05" },
error: { icon: SvgXOctagon, colorClass: "text-status-error-05" },
};
function ContentMd({
icon: Icon,
title,
description,
editable,
onTitleChange,
optional,
auxIcon,
tag,
sizePreset = "main-ui",
}: ContentMdProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const config = CONTENT_MD_PRESETS[sizePreset];
function startEditing() {
setEditValue(title);
setEditing(true);
}
function commit() {
const value = editValue.trim();
if (value && value !== title) onTitleChange?.(value);
setEditing(false);
}
return (
<div className="opal-content-md" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(
"opal-content-md-icon-container shrink-0",
config.iconContainerPadding
)}
style={{ minHeight: config.lineHeight }}
>
<Icon
className={cn("opal-content-md-icon", config.iconColorClass)}
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<div className="opal-content-md-body">
<div className="opal-content-md-title-row">
{editing ? (
<div className="opal-content-md-input-sizer">
<span
className={cn("opal-content-md-input-mirror", config.titleFont)}
>
{editValue || "\u00A0"}
</span>
<input
ref={inputRef}
className={cn(
"opal-content-md-input",
config.titleFont,
"text-text-04"
)}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
size={1}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setEditValue(title);
setEditing(false);
}
}}
style={{ height: config.lineHeight }}
/>
</div>
) : (
<span
className={cn(
"opal-content-md-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{title}
</span>
)}
{optional && (
<span
className={cn(config.optionalFont, "text-text-03 shrink-0")}
style={{ height: config.lineHeight }}
>
(Optional)
</span>
)}
{auxIcon &&
(() => {
const { icon: AuxIcon, colorClass } = AUX_ICON_CONFIG[auxIcon];
return (
<div
className="opal-content-md-aux-icon shrink-0 p-0.5"
style={{ height: config.lineHeight }}
>
<AuxIcon
className={colorClass}
style={{
width: config.auxIconSize,
height: config.auxIconSize,
}}
/>
</div>
);
})()}
{tag && <Tag {...tag} />}
{editable && !editing && (
<div
className={cn(
"opal-content-md-edit-button",
config.editButtonPadding
)}
>
<Button
icon={SvgEdit}
prominence="internal"
size={config.editButtonSize}
tooltip="Edit"
tooltipSide="right"
onClick={startEditing}
/>
</div>
)}
</div>
{description && (
<div className="opal-content-md-description font-secondary-body text-text-03">
{description}
</div>
)}
</div>
</div>
);
}
export {
ContentMd,
type ContentMdProps,
type ContentMdSizePreset,
type ContentMdAuxIcon,
};

View File

@@ -0,0 +1,129 @@
"use client";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ContentSmSizePreset = "main-content" | "main-ui" | "secondary";
type ContentSmOrientation = "vertical" | "inline" | "reverse";
type ContentSmProminence = "default" | "muted";
interface ContentSmPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Gap between icon container and title (CSS value). */
gap: string;
}
/** Props for {@link ContentSm}. Does not support editing or descriptions. */
interface ContentSmProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text (read-only — editing is not supported). */
title: string;
/** Size preset. Default: `"main-ui"`. */
sizePreset?: ContentSmSizePreset;
/** Layout orientation. Default: `"inline"`. */
orientation?: ContentSmOrientation;
/** Title prominence. Default: `"default"`. */
prominence?: ContentSmProminence;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const CONTENT_SM_PRESETS: Record<ContentSmSizePreset, ContentSmPresetConfig> = {
"main-content": {
iconSize: "1rem",
iconContainerPadding: "p-1",
titleFont: "font-main-content-body",
lineHeight: "1.5rem",
gap: "0.125rem",
},
"main-ui": {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
titleFont: "font-main-ui-action",
lineHeight: "1.25rem",
gap: "0.25rem",
},
secondary: {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
titleFont: "font-secondary-action",
lineHeight: "1rem",
gap: "0.125rem",
},
};
// ---------------------------------------------------------------------------
// ContentSm
// ---------------------------------------------------------------------------
function ContentSm({
icon: Icon,
title,
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
}: ContentSmProps) {
const config = CONTENT_SM_PRESETS[sizePreset];
const titleColorClass =
prominence === "muted" ? "text-text-03" : "text-text-04";
return (
<div
className="opal-content-sm"
data-orientation={orientation}
style={{ gap: config.gap }}
>
{Icon && (
<div
className={cn(
"opal-content-sm-icon-container shrink-0",
config.iconContainerPadding
)}
style={{ minHeight: config.lineHeight }}
>
<Icon
className="opal-content-sm-icon text-text-03"
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<span
className={cn(
"opal-content-sm-title",
config.titleFont,
titleColorClass
)}
style={{ height: config.lineHeight }}
>
{title}
</span>
</div>
);
}
export {
ContentSm,
type ContentSmProps,
type ContentSmSizePreset,
type ContentSmOrientation,
type ContentSmProminence,
};

View File

@@ -0,0 +1,258 @@
"use client";
import { Button } from "@opal/components/buttons/Button/components";
import type { SizeVariant } from "@opal/shared";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ContentXlSizePreset = "headline" | "section";
interface ContentXlPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** More-icon-1 width/height (CSS value). */
moreIcon1Size: string;
/** Tailwind padding class for the more-icon-1 container. */
moreIcon1ContainerPadding: string;
/** More-icon-2 width/height (CSS value). */
moreIcon2Size: string;
/** Tailwind padding class for the more-icon-2 container. */
moreIcon2ContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: SizeVariant;
/** Tailwind padding class for the edit button container. */
editButtonPadding: string;
}
interface ContentXlProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text. */
title: string;
/** Optional description below the title. */
description?: string;
/** Enable inline editing of the title. */
editable?: boolean;
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** Size preset. Default: `"headline"`. */
sizePreset?: ContentXlSizePreset;
/** Optional secondary icon rendered in the icon row. */
moreIcon1?: IconFunctionComponent;
/** Optional tertiary icon rendered in the icon row. */
moreIcon2?: IconFunctionComponent;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const CONTENT_XL_PRESETS: Record<ContentXlSizePreset, ContentXlPresetConfig> = {
headline: {
iconSize: "2rem",
iconContainerPadding: "p-0.5",
moreIcon1Size: "1rem",
moreIcon1ContainerPadding: "p-0.5",
moreIcon2Size: "2rem",
moreIcon2ContainerPadding: "p-0.5",
titleFont: "font-heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
},
section: {
iconSize: "1.5rem",
iconContainerPadding: "p-0.5",
moreIcon1Size: "0.75rem",
moreIcon1ContainerPadding: "p-0.5",
moreIcon2Size: "1.5rem",
moreIcon2ContainerPadding: "p-0.5",
titleFont: "font-heading-h3",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
},
};
// ---------------------------------------------------------------------------
// ContentXl
// ---------------------------------------------------------------------------
function ContentXl({
sizePreset = "headline",
icon: Icon,
title,
description,
editable,
onTitleChange,
moreIcon1: MoreIcon1,
moreIcon2: MoreIcon2,
}: ContentXlProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const config = CONTENT_XL_PRESETS[sizePreset];
function startEditing() {
setEditValue(title);
setEditing(true);
}
function commit() {
const value = editValue.trim();
if (value && value !== title) onTitleChange?.(value);
setEditing(false);
}
return (
<div className="opal-content-xl">
{(Icon || MoreIcon1 || MoreIcon2) && (
<div className="opal-content-xl-icon-row">
{Icon && (
<div
className={cn(
"opal-content-xl-icon-container shrink-0",
config.iconContainerPadding
)}
style={{ minHeight: config.lineHeight }}
>
<Icon
className="opal-content-xl-icon"
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
{MoreIcon1 && (
<div
className={cn(
"opal-content-xl-more-icon-container shrink-0",
config.moreIcon1ContainerPadding
)}
>
<MoreIcon1
className="opal-content-xl-icon"
style={{
width: config.moreIcon1Size,
height: config.moreIcon1Size,
}}
/>
</div>
)}
{MoreIcon2 && (
<div
className={cn(
"opal-content-xl-more-icon-container shrink-0",
config.moreIcon2ContainerPadding
)}
>
<MoreIcon2
className="opal-content-xl-icon"
style={{
width: config.moreIcon2Size,
height: config.moreIcon2Size,
}}
/>
</div>
)}
</div>
)}
<div className="opal-content-xl-body">
<div className="opal-content-xl-title-row">
{editing ? (
<div className="opal-content-xl-input-sizer">
<span
className={cn("opal-content-xl-input-mirror", config.titleFont)}
>
{editValue || "\u00A0"}
</span>
<input
ref={inputRef}
className={cn(
"opal-content-xl-input",
config.titleFont,
"text-text-04"
)}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
size={1}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setEditValue(title);
setEditing(false);
}
}}
style={{ height: config.lineHeight }}
/>
</div>
) : (
<span
className={cn(
"opal-content-xl-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{title}
</span>
)}
{editable && !editing && (
<div
className={cn(
"opal-content-xl-edit-button",
config.editButtonPadding
)}
>
<Button
icon={SvgEdit}
prominence="internal"
size={config.editButtonSize}
tooltip="Edit"
tooltipSide="right"
onClick={startEditing}
/>
</div>
)}
</div>
{description && (
<div className="opal-content-xl-description font-secondary-body text-text-03">
{description}
</div>
)}
</div>
</div>
);
}
export { ContentXl, type ContentXlProps, type ContentXlSizePreset };

View File

@@ -8,14 +8,21 @@ A two-axis layout component for displaying icon + title + description rows. Rout
### `sizePreset` — controls sizing (icon, padding, gap, font)
#### HeadingLayout presets
#### ContentXl presets (variant="heading")
| Preset | Icon | Icon padding | moreIcon1 | mI1 padding | moreIcon2 | mI2 padding | Title font | Line-height |
|---|---|---|---|---|---|---|---|---|
| `headline` | 2rem (32px) | `p-0.5` (2px) | 1rem (16px) | `p-0.5` (2px) | 2rem (32px) | `p-0.5` (2px) | `font-heading-h2` | 2.25rem (36px) |
| `section` | 1.5rem (24px) | `p-0.5` (2px) | 0.75rem (12px) | `p-0.5` (2px) | 1.5rem (24px) | `p-0.5` (2px) | `font-heading-h3` | 1.75rem (28px) |
#### ContentLg presets (variant="section")
| Preset | Icon | Icon padding | Gap | Title font | Line-height |
|---|---|---|---|---|---|
| `headline` | 2rem (32px) | `p-0.5` (2px) | 0.25rem (4px) | `font-heading-h2` | 2.25rem (36px) |
| `section` | 1.25rem (20px) | `p-1` (4px) | 0rem | `font-heading-h3` | 1.75rem (28px) |
| `section` | 1.25rem (20px) | `p-1` (4px) | 0rem | `font-heading-h3-muted` | 1.75rem (28px) |
#### LabelLayout presets
#### ContentMd presets
| Preset | Icon | Icon padding | Icon color | Gap | Title font | Line-height |
|---|---|---|---|---|---|---|
@@ -29,18 +36,18 @@ A two-axis layout component for displaying icon + title + description rows. Rout
| variant | Description |
|---|---|
| `heading` | Icon on **top** (flex-col) — HeadingLayout |
| `section` | Icon **inline** (flex-row) — HeadingLayout or LabelLayout |
| `body` | Body text layout — BodyLayout (future) |
| `heading` | Icon on **top** (flex-col) — ContentXl |
| `section` | Icon **inline** (flex-row) — ContentLg or ContentMd |
| `body` | Body text layout — ContentSm |
### Valid Combinations -> Internal Routing
| sizePreset | variant | Routes to |
|---|---|---|
| `headline` / `section` | `heading` | **HeadingLayout** (icon on top) |
| `headline` / `section` | `section` | **HeadingLayout** (icon inline) |
| `main-content` / `main-ui` / `secondary` | `section` | **LabelLayout** |
| `main-content` / `main-ui` / `secondary` | `body` | BodyLayout (future) |
| `headline` / `section` | `heading` | **ContentXl** (icon on top) |
| `headline` / `section` | `section` | **ContentLg** (icon inline) |
| `main-content` / `main-ui` / `secondary` | `section` | **ContentMd** |
| `main-content` / `main-ui` / `secondary` | `body` | **ContentSm** |
Invalid combinations (e.g. `sizePreset="headline" + variant="body"`) are excluded at the type level.
@@ -55,14 +62,20 @@ Invalid combinations (e.g. `sizePreset="headline" + variant="body"`) are exclude
| `description` | `string` | — | Optional description below the title |
| `editable` | `boolean` | `false` | Enable inline editing of the title |
| `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) |
## Internal Layouts
### HeadingLayout
### ContentXl
For `headline` / `section` presets. Supports `variant="heading"` (icon on top) and `variant="section"` (icon inline). Description is always `font-secondary-body text-text-03`.
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`.
### LabelLayout
### ContentLg
For `headline` / `section` presets with `variant="section"`. Always inline (flex-row). Description is always `font-secondary-body text-text-03`.
### 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`.
@@ -72,7 +85,7 @@ For `main-content` / `main-ui` / `secondary` presets. Always inline. Both `icon`
import { Content } from "@opal/layouts";
import SvgSearch from "@opal/icons/search";
// HeadingLayout — headline, icon on top
// ContentXl — headline, icon on top
<Content
icon={SvgSearch}
sizePreset="headline"
@@ -81,7 +94,17 @@ import SvgSearch from "@opal/icons/search";
description="Configure your agent's behavior"
/>
// HeadingLayout — section, icon inline
// 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"
@@ -90,7 +113,7 @@ import SvgSearch from "@opal/icons/search";
description="Connected integrations"
/>
// LabelLayout — with icon and description
// ContentMd — with icon and description
<Content
icon={SvgSearch}
sizePreset="main-ui"
@@ -98,7 +121,7 @@ import SvgSearch from "@opal/icons/search";
description="Agent system prompt"
/>
// LabelLayout — title only (no icon, no description)
// ContentMd — title only (no icon, no description)
<Content
sizePreset="main-content"
title="Featured Agent"

View File

@@ -1,21 +1,24 @@
import "@opal/layouts/Content/styles.css";
import {
BodyLayout,
type BodyOrientation,
type BodyProminence,
} from "@opal/layouts/Content/BodyLayout";
ContentSm,
type ContentSmOrientation,
type ContentSmProminence,
} from "@opal/layouts/Content/ContentSm";
import {
HeadingLayout,
type HeadingLayoutProps,
} from "@opal/layouts/Content/HeadingLayout";
ContentXl,
type ContentXlProps,
} from "@opal/layouts/Content/ContentXl";
import {
LabelLayout,
type LabelLayoutProps,
} from "@opal/layouts/Content/LabelLayout";
ContentLg,
type ContentLgProps,
} from "@opal/layouts/Content/ContentLg";
import {
ContentMd,
type ContentMdProps,
} from "@opal/layouts/Content/ContentMd";
import type { TagProps } from "@opal/components/Tag/components";
import type { IconFunctionComponent } from "@opal/types";
import { widthVariants, type WidthVariant } from "@opal/shared";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
// Shared types
@@ -62,14 +65,25 @@ interface ContentBaseProps {
// Discriminated union: valid sizePreset × variant combinations
// ---------------------------------------------------------------------------
type HeadingContentProps = ContentBaseProps & {
type XlContentProps = ContentBaseProps & {
/** Size preset. Default: `"headline"`. */
sizePreset?: "headline" | "section";
/** Variant. Default: `"heading"` for heading-eligible presets. */
variant?: "heading" | "section";
variant?: "heading";
/** Optional secondary icon rendered in the icon row (ContentXl only). */
moreIcon1?: IconFunctionComponent;
/** Optional tertiary icon rendered in the icon row (ContentXl only). */
moreIcon2?: IconFunctionComponent;
};
type LabelContentProps = ContentBaseProps & {
type LgContentProps = ContentBaseProps & {
/** Size preset. Default: `"headline"`. */
sizePreset?: "headline" | "section";
/** Variant. */
variant: "section";
};
type MdContentProps = ContentBaseProps & {
sizePreset: "main-content" | "main-ui" | "secondary";
variant?: "section";
/** When `true`, renders "(Optional)" beside the title in the muted font variant. */
@@ -80,20 +94,24 @@ type LabelContentProps = ContentBaseProps & {
tag?: TagProps;
};
/** BodyLayout does not support descriptions or inline editing. */
type BodyContentProps = Omit<
/** ContentSm does not support descriptions or inline editing. */
type SmContentProps = Omit<
ContentBaseProps,
"description" | "editable" | "onTitleChange"
> & {
sizePreset: "main-content" | "main-ui" | "secondary";
variant: "body";
/** Layout orientation. Default: `"inline"`. */
orientation?: BodyOrientation;
orientation?: ContentSmOrientation;
/** Title prominence. Default: `"default"`. */
prominence?: BodyProminence;
prominence?: ContentSmProminence;
};
type ContentProps = HeadingContentProps | LabelContentProps | BodyContentProps;
type ContentProps =
| XlContentProps
| LgContentProps
| MdContentProps
| SmContentProps;
// ---------------------------------------------------------------------------
// Content — routes to the appropriate internal layout
@@ -111,34 +129,42 @@ function Content(props: ContentProps) {
let layout: React.ReactNode = null;
// Heading layout: headline/section presets with heading/section variant
// ContentXl / ContentLg: headline/section presets
if (sizePreset === "headline" || sizePreset === "section") {
layout = (
<HeadingLayout
sizePreset={sizePreset}
variant={variant as HeadingLayoutProps["variant"]}
{...rest}
/>
);
if (variant === "heading") {
layout = (
<ContentXl
sizePreset={sizePreset}
{...(rest as Omit<ContentXlProps, "sizePreset">)}
/>
);
} else {
layout = (
<ContentLg
sizePreset={sizePreset}
{...(rest as Omit<ContentLgProps, "sizePreset">)}
/>
);
}
}
// Label layout: main-content/main-ui/secondary with section variant
// ContentMd: main-content/main-ui/secondary with section variant
else if (variant === "section" || variant === "heading") {
layout = (
<LabelLayout
<ContentMd
sizePreset={sizePreset}
{...(rest as Omit<LabelLayoutProps, "sizePreset">)}
{...(rest as Omit<ContentMdProps, "sizePreset">)}
/>
);
}
// Body layout: main-content/main-ui/secondary with body variant
// ContentSm: main-content/main-ui/secondary with body variant
else if (variant === "body") {
layout = (
<BodyLayout
<ContentSm
sizePreset={sizePreset}
{...(rest as Omit<
React.ComponentProps<typeof BodyLayout>,
React.ComponentProps<typeof ContentSm>,
"sizePreset"
>)}
/>
@@ -167,7 +193,8 @@ export {
type ContentProps,
type SizePreset,
type ContentVariant,
type HeadingContentProps,
type LabelContentProps,
type BodyContentProps,
type XlContentProps,
type LgContentProps,
type MdContentProps,
type SmContentProps,
};

View File

@@ -1,41 +1,46 @@
/* ---------------------------------------------------------------------------
Content — HeadingLayout
/* ===========================================================================
Content — ContentXl
Two icon placement modes (driven by variant):
left (variant="section") : flex-row — icon beside content
top (variant="heading") : flex-col — icon above content
Icon row on top (flex-col). Icon row contains main icon + optional
moreIcon1 / moreIcon2 in a flex-row.
Sizing (icon size, gap, padding, font, line-height) is driven by the
sizePreset prop via inline styles + Tailwind classes in the component.
--------------------------------------------------------------------------- */
=========================================================================== */
/* ---------------------------------------------------------------------------
Layout — icon placement
Layout — flex-col (icon row above body)
--------------------------------------------------------------------------- */
.opal-content-heading {
@apply flex items-start;
}
.opal-content-heading[data-icon-placement="left"] {
@apply flex-row;
}
.opal-content-heading[data-icon-placement="top"] {
@apply flex-col;
.opal-content-xl {
@apply flex flex-col items-start;
}
/* ---------------------------------------------------------------------------
Icon
Icon row — flex-row containing main icon + more icons
--------------------------------------------------------------------------- */
.opal-content-heading-icon-container {
.opal-content-xl-icon-row {
@apply flex flex-row items-center;
}
/* ---------------------------------------------------------------------------
Icons
--------------------------------------------------------------------------- */
.opal-content-xl-icon-container {
display: flex;
align-items: center;
justify-content: center;
}
.opal-content-heading-icon {
.opal-content-xl-more-icon-container {
display: flex;
align-items: center;
justify-content: center;
}
.opal-content-xl-icon {
color: var(--text-04);
}
@@ -43,7 +48,7 @@
Body column
--------------------------------------------------------------------------- */
.opal-content-heading-body {
.opal-content-xl-body {
@apply flex flex-1 flex-col items-start;
min-width: 0.0625rem;
}
@@ -52,12 +57,12 @@
Title row — title (or input) + edit button
--------------------------------------------------------------------------- */
.opal-content-heading-title-row {
.opal-content-xl-title-row {
@apply flex items-center w-full;
gap: 0.25rem;
}
.opal-content-heading-title {
.opal-content-xl-title {
@apply text-left overflow-hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
@@ -66,23 +71,23 @@
min-width: 0.0625rem;
}
.opal-content-heading-input-sizer {
.opal-content-xl-input-sizer {
display: inline-grid;
align-items: stretch;
}
.opal-content-heading-input-sizer > * {
.opal-content-xl-input-sizer > * {
grid-area: 1 / 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-heading-input-mirror {
.opal-content-xl-input-mirror {
visibility: hidden;
white-space: pre;
}
.opal-content-heading-input {
.opal-content-xl-input {
@apply bg-transparent outline-none border-none;
}
@@ -90,11 +95,11 @@
Edit button — visible only on hover of the outer container
--------------------------------------------------------------------------- */
.opal-content-heading-edit-button {
.opal-content-xl-edit-button {
@apply opacity-0 transition-opacity shrink-0;
}
.opal-content-heading:hover .opal-content-heading-edit-button {
.opal-content-xl:hover .opal-content-xl-edit-button {
@apply opacity-100;
}
@@ -102,13 +107,112 @@
Description
--------------------------------------------------------------------------- */
.opal-content-heading-description {
.opal-content-xl-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
Content — LabelLayout
Content — ContentLg
Always inline (flex-row) — icon beside content.
Sizing (icon size, gap, padding, font, line-height) is driven by the
sizePreset prop via inline styles + Tailwind classes in the component.
=========================================================================== */
/* ---------------------------------------------------------------------------
Layout
--------------------------------------------------------------------------- */
.opal-content-lg {
@apply flex flex-row items-start;
}
/* ---------------------------------------------------------------------------
Icon
--------------------------------------------------------------------------- */
.opal-content-lg-icon-container {
display: flex;
align-items: center;
justify-content: center;
}
.opal-content-lg-icon {
color: var(--text-04);
}
/* ---------------------------------------------------------------------------
Body column
--------------------------------------------------------------------------- */
.opal-content-lg-body {
@apply flex flex-1 flex-col items-start;
min-width: 0.0625rem;
}
/* ---------------------------------------------------------------------------
Title row — title (or input) + edit button
--------------------------------------------------------------------------- */
.opal-content-lg-title-row {
@apply flex items-center w-full;
gap: 0.25rem;
}
.opal-content-lg-title {
@apply text-left overflow-hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-lg-input-sizer {
display: inline-grid;
align-items: stretch;
}
.opal-content-lg-input-sizer > * {
grid-area: 1 / 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-lg-input-mirror {
visibility: hidden;
white-space: pre;
}
.opal-content-lg-input {
@apply bg-transparent outline-none border-none;
}
/* ---------------------------------------------------------------------------
Edit button — visible only on hover of the outer container
--------------------------------------------------------------------------- */
.opal-content-lg-edit-button {
@apply opacity-0 transition-opacity shrink-0;
}
.opal-content-lg:hover .opal-content-lg-edit-button {
@apply opacity-100;
}
/* ---------------------------------------------------------------------------
Description
--------------------------------------------------------------------------- */
.opal-content-lg-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
Content — ContentMd
Always inline (flex-row). Icon color varies per sizePreset and is applied
via Tailwind class from the component.
@@ -118,7 +222,7 @@
Layout
--------------------------------------------------------------------------- */
.opal-content-label {
.opal-content-md {
@apply flex flex-row items-start;
}
@@ -126,7 +230,7 @@
Icon
--------------------------------------------------------------------------- */
.opal-content-label-icon-container {
.opal-content-md-icon-container {
display: flex;
align-items: center;
justify-content: center;
@@ -136,7 +240,7 @@
Body column
--------------------------------------------------------------------------- */
.opal-content-label-body {
.opal-content-md-body {
@apply flex flex-1 flex-col items-start;
min-width: 0.0625rem;
}
@@ -145,12 +249,12 @@
Title row — title (or input) + edit button
--------------------------------------------------------------------------- */
.opal-content-label-title-row {
.opal-content-md-title-row {
@apply flex items-center w-full;
gap: 0.25rem;
}
.opal-content-label-title {
.opal-content-md-title {
@apply text-left overflow-hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
@@ -159,23 +263,23 @@
min-width: 0.0625rem;
}
.opal-content-label-input-sizer {
.opal-content-md-input-sizer {
display: inline-grid;
align-items: stretch;
}
.opal-content-label-input-sizer > * {
.opal-content-md-input-sizer > * {
grid-area: 1 / 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-label-input-mirror {
.opal-content-md-input-mirror {
visibility: hidden;
white-space: pre;
}
.opal-content-label-input {
.opal-content-md-input {
@apply bg-transparent outline-none border-none;
}
@@ -183,7 +287,7 @@
Aux icon
--------------------------------------------------------------------------- */
.opal-content-label-aux-icon {
.opal-content-md-aux-icon {
display: flex;
align-items: center;
justify-content: center;
@@ -193,11 +297,11 @@
Edit button — visible only on hover of the outer container
--------------------------------------------------------------------------- */
.opal-content-label-edit-button {
.opal-content-md-edit-button {
@apply opacity-0 transition-opacity shrink-0;
}
.opal-content-label:hover .opal-content-label-edit-button {
.opal-content-md:hover .opal-content-md-edit-button {
@apply opacity-100;
}
@@ -205,13 +309,13 @@
Description
--------------------------------------------------------------------------- */
.opal-content-label-description {
.opal-content-md-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
Content — BodyLayout
Content — ContentSm
Three orientation modes (driven by orientation prop):
inline : flex-row — icon left, title right
@@ -226,19 +330,19 @@
Layout — orientation
--------------------------------------------------------------------------- */
.opal-content-body {
.opal-content-sm {
@apply flex items-start;
}
.opal-content-body[data-orientation="inline"] {
.opal-content-sm[data-orientation="inline"] {
@apply flex-row;
}
.opal-content-body[data-orientation="vertical"] {
.opal-content-sm[data-orientation="vertical"] {
@apply flex-col;
}
.opal-content-body[data-orientation="reverse"] {
.opal-content-sm[data-orientation="reverse"] {
@apply flex-row-reverse;
}
@@ -246,7 +350,7 @@
Icon
--------------------------------------------------------------------------- */
.opal-content-body-icon-container {
.opal-content-sm-icon-container {
display: flex;
align-items: center;
justify-content: center;
@@ -256,7 +360,7 @@
Title
--------------------------------------------------------------------------- */
.opal-content-body-title {
.opal-content-sm-title {
@apply text-left overflow-hidden;
display: -webkit-box;
-webkit-box-orient: vertical;

View File

@@ -8,7 +8,7 @@ Layout primitives for composing icon + title + description rows. These component
| Component | Description | Docs |
|---|---|---|
| [`Content`](./Content/README.md) | Icon + title + description row. Routes to an internal layout (`HeadingLayout`, `LabelLayout`, or `BodyLayout`) based on `sizePreset` and `variant`. | [Content README](./Content/README.md) |
| [`Content`](./Content/README.md) | Icon + title + description row. Routes to an internal layout (`ContentLg`, `ContentMd`, or `ContentSm`) based on `sizePreset` and `variant`. | [Content README](./Content/README.md) |
| [`ContentAction`](./ContentAction/README.md) | Wraps `Content` in a flex-row with an optional `rightChildren` slot for action buttons. Adds padding alignment via the shared `SizeVariant` scale. | [ContentAction README](./ContentAction/README.md) |
## Quick Start
@@ -88,6 +88,7 @@ These are not exported — `Content` routes to them automatically:
| Layout | Used when | File |
|---|---|---|
| `HeadingLayout` | `sizePreset` is `headline` or `section` | `Content/HeadingLayout.tsx` |
| `LabelLayout` | `sizePreset` is `main-content`, `main-ui`, or `secondary` with `variant="section"` | `Content/LabelLayout.tsx` |
| `BodyLayout` | `variant="body"` | `Content/BodyLayout.tsx` |
| `ContentXl` | `sizePreset` is `headline` or `section` with `variant="heading"` | `Content/ContentXl.tsx` |
| `ContentLg` | `sizePreset` is `headline` or `section` with `variant="section"` | `Content/ContentLg.tsx` |
| `ContentMd` | `sizePreset` is `main-content`, `main-ui`, or `secondary` with `variant="section"` | `Content/ContentMd.tsx` |
| `ContentSm` | `variant="body"` | `Content/ContentSm.tsx` |

View File

@@ -16,7 +16,7 @@
// - Interactive.Container (height + min-width + padding)
// - Button (icon sizing)
// - ContentAction (padding only)
// - Content (HeadingLayout / LabelLayout) (edit-button size)
// - Content (ContentLg / ContentMd) (edit-button size)
// ---------------------------------------------------------------------------
/**

View File

@@ -3,9 +3,9 @@
import React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
import type { IconProps } from "@opal/types";
import Text from "@/refresh-components/texts/Text";
import type { IconFunctionComponent } from "@opal/types";
import { Button } from "@opal/components";
import { Content } from "@opal/layouts";
import { SvgX } from "@opal/icons";
import { WithoutStyles } from "@/types";
import { Section, SectionProps } from "@/layouts/general-layouts";
@@ -407,7 +407,7 @@ ModalContent.displayName = DialogPrimitive.Content.displayName;
* ```
*/
interface ModalHeaderProps extends WithoutStyles<SectionProps> {
icon?: React.FunctionComponent<IconProps>;
icon?: IconFunctionComponent;
title: string;
description?: string;
onClose?: () => void;
@@ -416,8 +416,6 @@ const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
({ icon: Icon, title, description, onClose, children, ...props }, ref) => {
const { closeButtonRef, setHasDescription } = useModalContext();
// useLayoutEffect ensures aria-describedby is set before paint,
// so screen readers announce the description when the dialog opens
React.useLayoutEffect(() => {
setHasDescription(!!description);
}, [description, setHasDescription]);
@@ -440,52 +438,38 @@ const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
return (
<Section ref={ref} padding={1} alignItems="start" height="fit" {...props}>
<Section gap={0.5}>
{Icon && (
<Section
gap={0}
padding={0}
flexDirection="row"
justifyContent="between"
>
{/*
The `h-[1.5rem]` and `w-[1.5rem]` were added as backups here.
However, prop-resolution technically resolves to choosing classNames over size props, so technically the `size={24}` is the backup.
We specify both to be safe.
# Note
1.5rem === 24px
*/}
<Icon
className="stroke-text-04 h-[1.5rem] w-[1.5rem]"
size={24}
/>
{closeButton}
</Section>
)}
<Section
alignItems="start"
gap={0}
padding={0}
flexDirection="row"
justifyContent="between"
>
<Section alignItems="start" padding={0} gap={0}>
<DialogPrimitive.Title asChild>
<Text headingH3>{title}</Text>
</DialogPrimitive.Title>
{description && (
<DialogPrimitive.Description asChild>
<Text secondaryBody text03>
<Section
flexDirection="row"
justifyContent="between"
alignItems="start"
gap={0}
padding={0}
>
<div className="relative w-full">
{/* Close button is absolutely positioned because:
1. Figma mocks place it overlapping the top-right of the content area
2. Using ContentAction with rightChildren causes the description
to wrap to the second line early due to the button reserving space */}
<div className="absolute top-0 right-0">{closeButton}</div>
<DialogPrimitive.Title asChild>
<div>
<Content
icon={Icon}
title={title}
description={description}
sizePreset="section"
variant="heading"
/>
{description && (
<DialogPrimitive.Description className="hidden">
{description}
</Text>
</DialogPrimitive.Description>
)}
</Section>
{!Icon && closeButton}
</Section>
</DialogPrimitive.Description>
)}
</div>
</DialogPrimitive.Title>
</div>
</Section>
{children}
</Section>
);

View File

@@ -248,7 +248,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
bottomSlot={<AgentChatInput agent={agent} onSubmit={handleStartChat} />}
>
<Modal.Header
icon={(props) => <AgentAvatar agent={agent} {...props} size={24} />}
icon={(props) => <AgentAvatar agent={agent} {...props} size={28} />}
title={agent.name}
onClose={() => agentViewerModal.toggle(false)}
/>