mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-03 22:55:46 +00:00
Compare commits
2 Commits
v3.0.0-clo
...
content-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5d7e271a | ||
|
|
bb6e20614d |
@@ -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";
|
||||
|
||||
200
web/lib/opal/src/layouts/Content/ContentLg.tsx
Normal file
200
web/lib/opal/src/layouts/Content/ContentLg.tsx
Normal 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 };
|
||||
279
web/lib/opal/src/layouts/Content/ContentMd.tsx
Normal file
279
web/lib/opal/src/layouts/Content/ContentMd.tsx
Normal 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,
|
||||
};
|
||||
129
web/lib/opal/src/layouts/Content/ContentSm.tsx
Normal file
129
web/lib/opal/src/layouts/Content/ContentSm.tsx
Normal 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,
|
||||
};
|
||||
258
web/lib/opal/src/layouts/Content/ContentXl.tsx
Normal file
258
web/lib/opal/src/layouts/Content/ContentXl.tsx
Normal 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 };
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user