SaaS fixes (#6578)

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: James Brunton <[email protected]>
Co-authored-by: Reece Browne <[email protected]>
Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Reece <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Ludy <[email protected]>
This commit is contained in:
Anthony Stirling
2026-06-16 16:41:25 +01:00
committed by GitHub
co-authored by Claude Opus 4.8 James Brunton Reece Browne ConnorYoh Reece EthanHealy01 Ludy
parent 96accea984
commit ddf78d11ae
415 changed files with 29552 additions and 5855 deletions
+6
View File
@@ -75,6 +75,12 @@
display: block;
}
.sui-check__icon {
display: inline-flex;
align-items: center;
color: var(--color-text-4);
flex-shrink: 0;
}
.sui-check__text {
display: flex;
flex-direction: column;
+8 -1
View File
@@ -7,13 +7,15 @@ export interface CheckboxProps extends Omit<
> {
label?: ReactNode;
description?: ReactNode;
/** Optional glyph shown between the box and the label text. */
leadingIcon?: ReactNode;
/** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */
indeterminate?: boolean;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(
{ label, description, indeterminate, className, id, ...rest },
{ label, description, leadingIcon, indeterminate, className, id, ...rest },
ref,
) {
return (
@@ -57,6 +59,11 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
</svg>
<span className="sui-check__dash" />
</span>
{leadingIcon && (
<span className="sui-check__icon" aria-hidden>
{leadingIcon}
</span>
)}
{(label || description) && (
<span className="sui-check__text">
{label && <span className="sui-check__label">{label}</span>}
+12
View File
@@ -0,0 +1,12 @@
.sui-chipflow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
.sui-chipflow__sep {
color: var(--color-text-4);
font-size: 0.75rem;
line-height: 1;
flex-shrink: 0;
}
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ChipFlow } from "@shared/components/ChipFlow";
const meta: Meta<typeof ChipFlow> = {
title: "Primitives/ChipFlow",
component: ChipFlow,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
items: ["Classify", "Extract", "Name", "Normalize"],
separator: "arrow",
tone: "neutral",
size: "sm",
},
argTypes: {
separator: { control: "inline-radio", options: ["arrow", "none"] },
},
};
export default meta;
type Story = StoryObj<typeof ChipFlow>;
export const Pipeline: Story = { args: { separator: "arrow" } };
export const Plain: Story = {
args: { separator: "none", items: ["HIPAA", "GDPR", "SOC 2"] },
};
+47
View File
@@ -0,0 +1,47 @@
import { Fragment } from "react";
import type { ReactNode } from "react";
import { Chip } from "@shared/components/Chip";
import type { ChipTone, ChipSize } from "@shared/components/Chip";
import "@shared/components/ChipFlow.css";
export interface ChipFlowProps {
/** Items rendered as chips, in order. */
items: ReactNode[];
/** `arrow` joins chips with a → connector (pipeline look); `none` just wraps. */
separator?: "arrow" | "none";
tone?: ChipTone;
size?: ChipSize;
className?: string;
}
/**
* A sequence of {@link Chip}s, optionally joined by arrows to read as a
* pipeline (A → B → C). Use `separator="arrow"` for flows, `none` for a plain
* wrapped chip list.
*/
export function ChipFlow({
items,
separator = "none",
tone = "neutral",
size = "sm",
className,
}: ChipFlowProps) {
return (
<div
className={["sui-chipflow", className ?? ""].filter(Boolean).join(" ")}
>
{items.map((item, i) => (
<Fragment key={i}>
{i > 0 && separator === "arrow" && (
<span className="sui-chipflow__sep" aria-hidden>
</span>
)}
<Chip tone={tone} size={size}>
{item}
</Chip>
</Fragment>
))}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
.sui-datarow {
display: flex;
gap: 0.5rem;
}
.sui-datarow--center {
align-items: center;
}
.sui-datarow--top {
align-items: flex-start;
}
.sui-datarow__label {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-4);
}
.sui-datarow__value {
flex: 1;
min-width: 0;
font-size: 0.75rem;
color: var(--color-text-1);
overflow-wrap: anywhere;
}
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DataRow } from "@shared/components/DataRow";
import { ChipFlow } from "@shared/components/ChipFlow";
import { Card } from "@shared/components/Card";
const meta: Meta<typeof DataRow> = {
title: "Primitives/DataRow",
component: DataRow,
tags: ["autodocs"],
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DataRow>;
export const Single: Story = {
args: { label: "Reviewer", children: "[email protected]" },
};
export const Summary: Story = {
render: () => (
<Card padding="default">
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<DataRow label="Enforces" align="top">
<ChipFlow items={["Classify", "Extract", "Name"]} />
</DataRow>
<DataRow label="Sources">3 selected</DataRow>
<DataRow label="Reviewer">matt@stirlingpdf.com</DataRow>
</div>
</Card>
),
};
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import "@shared/components/DataRow.css";
export interface DataRowProps {
/** Left-aligned key. */
label: ReactNode;
/** Value — text, chips, or any node. */
children: ReactNode;
/** Fixed key-column width (CSS length). Defaults to 4.5rem. */
labelWidth?: string;
/** Vertical alignment of label vs value. `top` suits multi-line values. */
align?: "center" | "top";
className?: string;
}
/**
* A key/value row for read-only detail/summary read-outs (a single line of a
* description list). Compose several inside a Card. Use `align="top"` when the
* value wraps (e.g. a chip flow).
*/
export function DataRow({
label,
children,
labelWidth = "4.5rem",
align = "center",
className,
}: DataRowProps) {
return (
<div
className={["sui-datarow", `sui-datarow--${align}`, className ?? ""]
.filter(Boolean)
.join(" ")}
role="group"
>
<span className="sui-datarow__label" style={{ width: labelWidth }}>
{label}
</span>
<div className="sui-datarow__value">{children}</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
.sui-iconbadge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
flex-shrink: 0;
}
.sui-iconbadge--sm {
width: 1.75rem;
height: 1.75rem;
}
.sui-iconbadge--md {
width: 2rem;
height: 2rem;
}
.sui-iconbadge--blue {
color: var(--color-blue);
background: color-mix(in srgb, var(--color-blue) 14%, transparent);
}
.sui-iconbadge--purple {
color: var(--color-purple);
background: color-mix(in srgb, var(--color-purple) 14%, transparent);
}
.sui-iconbadge--green {
color: var(--color-green);
background: color-mix(in srgb, var(--color-green) 14%, transparent);
}
.sui-iconbadge--amber {
color: var(--color-amber);
background: color-mix(in srgb, var(--color-amber) 14%, transparent);
}
.sui-iconbadge--red {
color: var(--color-red);
background: color-mix(in srgb, var(--color-red) 14%, transparent);
}
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IconBadge } from "@shared/components/IconBadge";
function Glyph() {
return (
<svg viewBox="0 0 24 24" width={16} height={16} fill="currentColor">
<rect x="3" y="4" width="18" height="4" rx="1" />
<rect x="3" y="10" width="18" height="4" rx="1" />
<rect x="3" y="16" width="18" height="4" rx="1" />
</svg>
);
}
const meta: Meta<typeof IconBadge> = {
title: "Primitives/IconBadge",
component: IconBadge,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: { accent: "blue", size: "md", children: <Glyph /> },
argTypes: {
accent: {
control: "inline-radio",
options: ["blue", "purple", "green", "amber", "red"],
},
size: { control: "inline-radio", options: ["sm", "md"] },
},
};
export default meta;
type Story = StoryObj<typeof IconBadge>;
export const Blue: Story = {};
export const Accents: Story = {
render: () => (
<div style={{ display: "flex", gap: 12 }}>
{(["blue", "purple", "green", "amber", "red"] as const).map((a) => (
<IconBadge key={a} accent={a}>
<Glyph />
</IconBadge>
))}
</div>
),
};
+40
View File
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import "@shared/components/IconBadge.css";
export type IconBadgeAccent = "blue" | "purple" | "green" | "amber" | "red";
export interface IconBadgeProps {
children: ReactNode;
/** Tone tint. Defaults to blue. */
accent?: IconBadgeAccent;
size?: "sm" | "md";
className?: string;
}
/**
* A glyph in a rounded, tone-tinted square — the recurring "category icon box"
* motif used by panel headers, summaries and list leads. Centralises the tint
* so every consumer shares one treatment.
*/
export function IconBadge({
children,
accent = "blue",
size = "md",
className,
}: IconBadgeProps) {
return (
<span
className={[
"sui-iconbadge",
`sui-iconbadge--${accent}`,
`sui-iconbadge--${size}`,
className ?? "",
]
.filter(Boolean)
.join(" ")}
aria-hidden
>
{children}
</span>
);
}
+88
View File
@@ -0,0 +1,88 @@
.sui-listrow {
display: flex;
align-items: flex-start;
gap: 0.625rem;
width: 100%;
padding: 0.7rem 0.875rem;
text-align: left;
background: transparent;
color: var(--color-text-1);
}
.sui-listrow--divider {
border-top: 1px solid var(--color-border);
}
.sui-listrow--interactive {
cursor: pointer;
transition: background var(--motion-fast);
}
.sui-listrow--interactive:hover {
background: var(--color-bg-hover);
}
.sui-listrow--interactive:focus-visible {
outline: 0.125rem solid var(--color-blue);
outline-offset: -0.125rem;
}
.sui-listrow__leading {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: var(--radius-md);
flex-shrink: 0;
margin-top: 0.05rem;
color: var(--color-text-4);
background: var(--color-bg-muted);
}
.sui-listrow__leading[data-tone="success"] {
color: var(--color-green);
background: color-mix(in srgb, var(--color-green) 14%, transparent);
}
.sui-listrow__leading[data-tone="warning"] {
color: var(--color-amber);
background: color-mix(in srgb, var(--color-amber) 14%, transparent);
}
.sui-listrow__leading[data-tone="danger"] {
color: var(--color-red);
background: color-mix(in srgb, var(--color-red) 14%, transparent);
}
.sui-listrow__leading[data-tone="info"] {
color: var(--color-blue);
background: color-mix(in srgb, var(--color-blue) 14%, transparent);
}
.sui-listrow__leading[data-tone="purple"] {
color: var(--color-purple);
background: color-mix(in srgb, var(--color-purple) 14%, transparent);
}
.sui-listrow__text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.sui-listrow__title {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sui-listrow__desc {
font-size: 0.75rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.sui-listrow__meta {
font-size: 0.6875rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.sui-listrow__trailing {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
}
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ListRow } from "@shared/components/ListRow";
import { Card } from "@shared/components/Card";
import { StatusBadge } from "@shared/components/StatusBadge";
const meta: Meta<typeof ListRow> = {
title: "Primitives/ListRow",
component: ListRow,
tags: ["autodocs"],
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ListRow>;
function Dot({ ch }: { ch: string }) {
return <span style={{ fontSize: 12, fontWeight: 700 }}>{ch}</span>;
}
export const Default: Story = {
args: {
leading: <Dot ch="✓" />,
leadingTone: "success",
title: "MSA_Acme_2026.pdf",
description: "Classified as Contract • 3 tables extracted",
meta: "2h ago",
},
};
/** A divided list inside a padding-none Card — the canonical usage. */
export const InCard: Story = {
render: () => (
<Card padding="none">
<ListRow
leading={<Dot ch="✓" />}
leadingTone="success"
title="MSA_Acme_2026.pdf"
description="Classified as Contract • 3 tables extracted"
meta="2h ago"
/>
<ListRow
divider
leading={<Dot ch="!" />}
leadingTone="warning"
title="scan_002.pdf"
description="Low confidence (62%) • flagged for review"
meta="Yesterday"
trailing={
<StatusBadge tone="warning" size="sm">
Flagged
</StatusBadge>
}
/>
<ListRow
divider
leading={<Dot ch="✓" />}
leadingTone="success"
title="Invoice_4471.pdf"
description="Classified as Invoice • renamed to standard"
meta="5h ago"
/>
</Card>
),
};
export const Interactive: Story = {
args: {
leading: <Dot ch="→" />,
leadingTone: "info",
title: "Clickable row",
description: "The whole row is a button",
onClick: () => {},
},
};
+79
View File
@@ -0,0 +1,79 @@
import type { ReactNode } from "react";
import "@shared/components/ListRow.css";
import type { StatusTone } from "@shared/components/StatusBadge";
export interface ListRowProps {
/** Leading visual (icon/avatar), shown in a tone-tinted square. */
leading?: ReactNode;
/** Tint for the leading square. Defaults to neutral. */
leadingTone?: StatusTone;
/** Primary line. */
title: ReactNode;
/** Secondary line. */
description?: ReactNode;
/** Tertiary line (e.g. timestamp). */
meta?: ReactNode;
/** Right-aligned content (badge, chevron, action). */
trailing?: ReactNode;
/** Makes the whole row a button. */
onClick?: () => void;
/** Draw a top hairline — set on every row after the first inside a list. */
divider?: boolean;
className?: string;
}
/**
* A single list row: a tone-tinted leading glyph + a title/description/meta
* stack + an optional trailing slot. Compose a divided list by wrapping rows in
* a {@code Card padding="none"} and setting {@code divider} on all but the first.
*/
export function ListRow({
leading,
leadingTone = "neutral",
title,
description,
meta,
trailing,
onClick,
divider,
className,
}: ListRowProps) {
const classes = [
"sui-listrow",
divider ? "sui-listrow--divider" : "",
onClick ? "sui-listrow--interactive" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
const content = (
<>
{leading && (
<span
className="sui-listrow__leading"
data-tone={leadingTone}
aria-hidden
>
{leading}
</span>
)}
<span className="sui-listrow__text">
<span className="sui-listrow__title">{title}</span>
{description && (
<span className="sui-listrow__desc">{description}</span>
)}
{meta && <span className="sui-listrow__meta">{meta}</span>}
</span>
{trailing && <span className="sui-listrow__trailing">{trailing}</span>}
</>
);
return onClick ? (
<button type="button" className={classes} onClick={onClick}>
{content}
</button>
) : (
<div className={classes}>{content}</div>
);
}
+12
View File
@@ -17,6 +17,18 @@
color: inherit;
text-align: left;
}
/* Compact density for narrow rails / metric strips. */
.sui-metric--sm {
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
min-width: 0;
}
.sui-metric--sm .sui-metric__value {
font-size: 1.125rem;
}
.sui-metric--sm .sui-metric__label {
white-space: nowrap;
}
.sui-metric--primary {
background: var(--color-bg-subtle);
}
@@ -14,6 +14,8 @@ export interface MetricCardProps {
deltaDirection?: DeltaDirection;
/** Visual emphasis. `primary` = darker surface, used for hero metrics. */
emphasis?: "default" | "primary";
/** Density. `sm` tightens padding + value size for narrow rails/strips. */
size?: "sm" | "md";
/** Optional icon shown in the top-right corner. */
icon?: ReactNode;
onClick?: () => void;
@@ -41,6 +43,7 @@ export function MetricCard({
delta,
deltaDirection,
emphasis = "default",
size = "md",
icon,
onClick,
className,
@@ -50,6 +53,7 @@ export function MetricCard({
const classes = [
"sui-metric",
`sui-metric--${size}`,
emphasis === "primary" ? "sui-metric--primary" : "",
interactive ? "sui-metric--interactive" : "",
className ?? "",
+45
View File
@@ -40,8 +40,53 @@
.sui-navitem__trailing {
display: inline-flex;
align-items: center;
margin-left: auto;
}
.sui-navitem:focus-visible {
outline: 0.125rem solid var(--color-blue);
outline-offset: 0.125rem;
}
/* ---- Status accent (optional) ---- */
.sui-navitem--accent {
position: relative;
}
.sui-navitem--accent::before {
content: "";
position: absolute;
left: 0;
top: 0.3125rem;
bottom: 0.3125rem;
width: 3px;
border-radius: 3px;
}
.sui-navitem[data-accent="blue"]::before {
background: var(--color-blue);
}
.sui-navitem[data-accent="purple"]::before {
background: var(--color-purple);
}
.sui-navitem[data-accent="green"]::before {
background: var(--color-green);
}
.sui-navitem[data-accent="amber"]::before {
background: var(--color-amber);
}
.sui-navitem[data-accent="red"]::before {
background: var(--color-red);
}
.sui-navitem[data-accent="blue"] .sui-navitem__icon {
color: var(--color-blue);
}
.sui-navitem[data-accent="purple"] .sui-navitem__icon {
color: var(--color-purple);
}
.sui-navitem[data-accent="green"] .sui-navitem__icon {
color: var(--color-green);
}
.sui-navitem[data-accent="amber"] .sui-navitem__icon {
color: var(--color-amber);
}
.sui-navitem[data-accent="red"] .sui-navitem__icon {
color: var(--color-red);
}
+16 -2
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import "@shared/components/NavItem.css";
export type NavItemAccent = "blue" | "purple" | "green" | "amber" | "red";
export interface NavItemProps {
/** Stable view id passed to the click handler. */
id: string;
@@ -8,6 +10,11 @@ export interface NavItemProps {
icon?: ReactNode;
/** Show the active highlight (navActive background, navActiveText colour). */
isActive?: boolean;
/**
* Optional status accent: draws a left edge bar in the tone colour and tints
* the leading icon to match. For listing live/paused/etc. entities.
*/
accent?: NavItemAccent;
/** Optional trailing badge (e.g. unread count, "new"). */
trailing?: ReactNode;
onClick?: (id: string) => void;
@@ -19,13 +26,14 @@ export interface NavItemProps {
*
* Active styling: navActive background, navActiveText colour, weight 500.
* Hover styling: navHover background, navHoverText colour (only when not
* already active).
* already active). An optional `accent` adds a status edge bar + icon tint.
*/
export function NavItem({
id,
label,
icon,
isActive,
accent,
trailing,
onClick,
className,
@@ -34,9 +42,15 @@ export function NavItem({
<button
type="button"
onClick={() => onClick?.(id)}
className={["sui-navitem", isActive ? "is-active" : "", className ?? ""]
className={[
"sui-navitem",
isActive ? "is-active" : "",
accent ? "sui-navitem--accent" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
data-accent={accent}
aria-current={isActive ? "page" : undefined}
>
{icon && (
+11 -5
View File
@@ -16,16 +16,22 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-md);
color: var(--color-text-3);
flex-shrink: 0;
width: 1.875rem;
height: 1.875rem;
border: 1px solid var(--color-border);
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-2);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
color var(--motion-fast),
border-color var(--motion-fast);
}
.sui-panelhdr__back:hover {
background: var(--color-bg-hover);
border-color: var(--color-text-4);
color: var(--color-text-1);
}
.sui-panelhdr__text {
+14 -1
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import "@shared/components/PanelHeader.css";
import { IconBadge } from "@shared/components/IconBadge";
export interface PanelHeaderProps {
title: ReactNode;
@@ -7,6 +8,10 @@ export interface PanelHeaderProps {
subtitle?: ReactNode;
/** Show a back chevron and trigger this callback when clicked. */
onBack?: () => void;
/** Optional leading visual (e.g. a category glyph) shown in a tinted box. */
icon?: ReactNode;
/** Accent tint for the leading icon box. Defaults to blue. */
iconAccent?: "blue" | "purple" | "green" | "amber" | "red";
/** Right-aligned action buttons / chips. */
actions?: ReactNode;
className?: string;
@@ -14,12 +19,15 @@ export interface PanelHeaderProps {
/**
* Header strip used by drill-down panels (admin tabs, agent detail, settings
* sub-pages). Back chevron renders only when `onBack` is supplied.
* sub-pages). Back chevron renders only when `onBack` is supplied; an optional
* leading `icon` renders in a tinted box before the title.
*/
export function PanelHeader({
title,
subtitle,
onBack,
icon,
iconAccent = "blue",
actions,
className,
}: PanelHeaderProps) {
@@ -49,6 +57,11 @@ export function PanelHeader({
</svg>
</button>
)}
{icon && (
<IconBadge accent={iconAccent} size="md">
{icon}
</IconBadge>
)}
<div className="sui-panelhdr__text">
<div className="sui-panelhdr__title">{title}</div>
{subtitle && <div className="sui-panelhdr__sub">{subtitle}</div>}
@@ -0,0 +1,33 @@
.sui-sectionhdr {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.4rem 0;
background: transparent;
border: none;
text-align: left;
}
button.sui-sectionhdr {
cursor: pointer;
}
.sui-sectionhdr__title {
flex: 1;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-4);
}
.sui-sectionhdr__count {
font-size: 0.6875rem;
color: var(--color-text-4);
}
.sui-sectionhdr__chevron {
color: var(--color-text-4);
flex-shrink: 0;
transition: transform var(--motion-fast);
}
.sui-sectionhdr__chevron[data-collapsed="true"] {
transform: rotate(-90deg);
}
@@ -0,0 +1,36 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SectionHeader } from "@shared/components/SectionHeader";
const meta: Meta<typeof SectionHeader> = {
title: "Primitives/SectionHeader",
component: SectionHeader,
tags: ["autodocs"],
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "20rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SectionHeader>;
export const Static: Story = { args: { title: "Policies", count: "3 active" } };
export const Collapsible: Story = {
render: () => {
const [open, setOpen] = useState(true);
return (
<SectionHeader
title="Policies"
count="3 active"
collapsible
expanded={open}
onToggle={() => setOpen((v) => !v)}
/>
);
},
};
@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
import "@shared/components/SectionHeader.css";
export interface SectionHeaderProps {
/** Uppercase eyebrow title. */
title: ReactNode;
/** Optional right-aligned tally/count. */
count?: ReactNode;
/** Render as a button with a disclosure chevron. */
collapsible?: boolean;
/** When collapsible, whether the section is expanded. */
expanded?: boolean;
onToggle?: () => void;
className?: string;
}
/**
* An uppercase section eyebrow with an optional trailing count and, when
* `collapsible`, a disclosure chevron + toggle. Use above a group of rows/cards.
*/
export function SectionHeader({
title,
count,
collapsible,
expanded = true,
onToggle,
className,
}: SectionHeaderProps) {
const classes = ["sui-sectionhdr", className ?? ""].filter(Boolean).join(" ");
const inner = (
<>
<span className="sui-sectionhdr__title">{title}</span>
{count != null && <span className="sui-sectionhdr__count">{count}</span>}
{collapsible && (
<svg
className="sui-sectionhdr__chevron"
data-collapsed={!expanded}
viewBox="0 0 24 24"
width={14}
height={14}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<polyline points="6 9 12 15 18 9" />
</svg>
)}
</>
);
return collapsible ? (
<button
type="button"
className={classes}
onClick={onToggle}
aria-expanded={expanded}
>
{inner}
</button>
) : (
<div className={classes}>{inner}</div>
);
}
+19
View File
@@ -44,6 +44,25 @@
-webkit-appearance: none;
}
/*
* The native option popup is OS-rendered: without explicit colours it falls
* back to the UA scheme (white) while the option text inherits the themed
* (light) colour — i.e. white-on-white in dark mode wherever the surrounding
* app keeps color-scheme: light (e.g. the editor, whose dark mode is driven by
* Mantine, not color-scheme). Theme the options explicitly and pin the
* select's color-scheme to the active SUI theme so the popup is always legible.
*/
.sui-select__el option {
background-color: var(--color-surface);
color: var(--color-text-1);
}
[data-theme="dark"] .sui-select__el {
color-scheme: dark;
}
[data-theme="light"] .sui-select__el {
color-scheme: light;
}
.sui-select--sm .sui-select__el {
font-size: 0.8125rem;
padding-left: var(--space-2);
@@ -0,0 +1,30 @@
.sui-settingsrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
}
.sui-settingsrow__text {
display: flex;
flex-direction: column;
min-width: 0;
}
.sui-settingsrow__label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
}
.sui-settingsrow__desc {
font-size: 0.75rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.sui-settingsrow__control {
flex: 0 0 auto;
min-width: 0;
max-width: 11rem;
display: inline-flex;
align-items: center;
justify-content: flex-end;
}
@@ -0,0 +1,82 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SettingsRow } from "@shared/components/SettingsRow";
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
import { Select } from "@shared/components/Select";
import { Card } from "@shared/components/Card";
const meta: Meta<typeof SettingsRow> = {
title: "Primitives/SettingsRow",
component: SettingsRow,
tags: ["autodocs"],
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SettingsRow>;
export const Toggle: Story = {
args: {
label: "Auto-classify",
control: <ToggleSwitch checked onChange={() => {}} size="sm" />,
},
};
export const WithDescription: Story = {
args: {
label: "Detect PII",
description: "Scan documents for sensitive fields on save",
control: <ToggleSwitch checked onChange={() => {}} size="sm" />,
},
};
/** A settings list inside a padding-none Card. */
export const List: Story = {
render: () => (
<Card padding="none">
{[
{ label: "Auto-classify", on: true },
{ label: "Extract tables", on: true },
{ label: "Strip blank pages", on: false },
].map((r, i) => (
<div
key={r.label}
style={{
padding: "0.7rem 0.875rem",
borderTop: i > 0 ? "1px solid var(--color-border)" : undefined,
}}
>
<SettingsRow
label={r.label}
control={
<ToggleSwitch checked={r.on} onChange={() => {}} size="sm" />
}
/>
</div>
))}
</Card>
),
};
export const SelectControl: Story = {
args: {
label: "OCR level",
control: (
<Select
inputSize="sm"
value="high"
onChange={() => {}}
options={[
{ value: "standard", label: "Standard" },
{ value: "high", label: "High" },
{ value: "max", label: "Maximum" },
]}
/>
),
},
};
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import "@shared/components/SettingsRow.css";
export interface SettingsRowProps {
/** Left-aligned setting name. */
label: ReactNode;
/** Optional secondary line under the label. */
description?: ReactNode;
/** Right-aligned control (toggle / select / input / button). */
control: ReactNode;
className?: string;
}
/**
* Horizontal settings row: label (+ optional description) on the left, control
* right-aligned. The companion to vertical {@link FormField} — use this for the
* label-left/control-right settings pattern (the layout ToggleSwitch's docs
* point at). Stack rows inside a {@code Card padding="none"} for a settings list.
*/
export function SettingsRow({
label,
description,
control,
className,
}: SettingsRowProps) {
return (
<div
className={["sui-settingsrow", className ?? ""].filter(Boolean).join(" ")}
>
<div className="sui-settingsrow__text">
<span className="sui-settingsrow__label">{label}</span>
{description && (
<span className="sui-settingsrow__desc">{description}</span>
)}
</div>
<div className="sui-settingsrow__control">{control}</div>
</div>
);
}
@@ -0,0 +1,27 @@
.sui-steps {
display: flex;
gap: 0.3rem;
width: 100%;
}
.sui-steps__bar {
flex: 1;
border-radius: 999px;
background: var(--color-border);
transition: background var(--motion-fast);
}
.sui-steps--md .sui-steps__bar {
height: 0.3rem;
}
.sui-steps--sm .sui-steps__bar {
height: 0.2rem;
}
/* Completed steps: solid accent. */
.sui-steps__bar[data-state="done"] {
background: var(--color-blue);
}
/* Current step: solid accent + a soft ring so it reads as "you are here". */
.sui-steps__bar[data-state="current"] {
background: var(--color-blue);
box-shadow: 0 0 0 0.1875rem
color-mix(in srgb, var(--color-blue) 22%, transparent);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { StepIndicator } from "@shared/components/StepIndicator";
const meta: Meta<typeof StepIndicator> = {
title: "Primitives/StepIndicator",
component: StepIndicator,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: { total: 3, current: 2, size: "md" },
argTypes: {
current: { control: { type: "number", min: 1, max: 5 } },
total: { control: { type: "number", min: 1, max: 5 } },
size: { control: "inline-radio", options: ["sm", "md"] },
},
decorators: [
(S) => (
<div style={{ maxWidth: "20rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof StepIndicator>;
export const Step1: Story = { args: { total: 3, current: 1 } };
export const Step2: Story = { args: { total: 3, current: 2 } };
export const Step3: Story = { args: { total: 3, current: 3 } };
export const Small: Story = { args: { total: 4, current: 2, size: "sm" } };
@@ -0,0 +1,43 @@
import "@shared/components/StepIndicator.css";
export interface StepIndicatorProps {
/** Total number of steps. */
total: number;
/** Current step, 1-based. */
current: number;
size?: "sm" | "md";
className?: string;
}
/**
* A segmented step/progress rail for multi-step flows (wizards, onboarding).
* Segments before `current` read as completed, the `current` segment is
* emphasised, and the rest are upcoming.
*/
export function StepIndicator({
total,
current,
size = "md",
className,
}: StepIndicatorProps) {
return (
<div
className={["sui-steps", `sui-steps--${size}`, className ?? ""]
.filter(Boolean)
.join(" ")}
role="progressbar"
aria-valuemin={1}
aria-valuemax={total}
aria-valuenow={current}
>
{Array.from({ length: total }, (_, i) => {
const step = i + 1;
const state =
step < current ? "done" : step === current ? "current" : "upcoming";
return (
<span key={step} className="sui-steps__bar" data-state={state} />
);
})}
</div>
);
}