mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Portal (#6391)
# Description of Changes ## What & why This PR introduces the **Stirling developer portal** — a new control-plane frontend that sits alongside the existing PDF editor — plus the shared design system and workspace structure needed to host both apps in one frontend. The portal is the parent product surface: where users connect sources, compose pipelines, wire agents, and manage usage / billing / infrastructure, with the PDF editor as one capability inside it. This PR lays the **foundation** — workspace reshape, design system, app shell, navigation, and a mock-driven home — rather than wiring real backends (those surfaces are placeholders for follow-up phases). ## What's in this PR **1. Frontend repo reshape (`frontend/src/` → `frontend/editor/`)** The existing editor app moved under `frontend/editor/`, so `editor`, `portal`, and `shared` are siblings in one workspace. All references were updated accordingly: `LICENSE`, `.dockerignore`, `.gitignore`, build/sign shell scripts, the GH language-check script, the Taskfile, and Docker config. **No editor source logic changed — path references only.** **2. New shared design system (`frontend/shared/`)** - **Design tokens** in `tokens.css` as the single runtime source of truth (light/dark, category accents, gradients). `tokens.ts` now holds only the `Tier` type — the old JS palette mirror was removed (nothing consumed it and it had drifted). - ~30 framework-light **components** (Card, Button, Input, Select, Tabs, Modal, Drawer, Toast, MetricCard, StatusBadge, Skeleton, EmptyState, …) with Storybook stories. - **Typed data catalogues**: `endpoints.ts` (10 verticals / 64 endpoints) and `ops.ts`. **3. New developer portal app (`frontend/portal/`)** - App shell: `Header`, `Sidebar`, `AssistantPanel`, search modal, notifications, tier switcher, theme toggle, MSW toggle. - **Tier-aware** home (free / pay-as-you-go / enterprise): KPI strip, 30-day usage chart, onboarding checklist, quick actions, recent activity, region health, product grid, and a curated **"Popular use cases"** teaser. - **Documents** view hosting the full, tab-filterable endpoint catalogue. - Placeholder views for Sources / Pipelines / Agents / Editor / Infrastructure / Usage & Billing / Developer Docs / Settings (follow-up phases). - **MSW-mocked** API layer: `api/*` issues real `fetch`, intercepted by mocks in dev/Storybook; pointing at a real backend is just a matter of not registering MSW. `react-router` URLs; Tier / View / UI contexts. **4. Tooling & guardrails** - ESLint extended to `portal` + `shared`, with **layering-boundary rules**: `shared/` may depend only on third-party packages and itself (no `@app` / `@portal` / `@core` / `@proprietary` / Tauri), so it stays cleanly extractable into a standalone package later. - `dpdm` circular-dependency check now walks editor + portal + shared (the old glob matched only 2 files). - New **devDependencies only** — Storybook (+ a11y/docs/themes addons), MSW. No runtime dependencies added. - New tasks: `frontend:dev:portal`, `frontend:build:portal`. ## Testing done locally - `tsc` for both `portal` and `shared` projects — clean - `eslint --max-warnings=0` across the whole frontend — clean - `dpdm` circular-dependency check — no cycles - Editor builds clean: `vite build editor --mode core` (✓ built, only the pre-existing >500 kB chunk-size advisory) - Editor runs in dev (core mode) with **zero console errors**; portal runs in dev across all three tiers ## Notes for reviewers - The change is overwhelmingly **additive**: `shared/` and `portal/` are brand-new; the existing editor is path-reference changes only. - The portal is intentionally **mock-driven** at this stage — real backends and the remaining views land in follow-up phases. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b355ccec9e
commit
919f0ade99
@@ -0,0 +1,68 @@
|
||||
.sui-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-on-accent);
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: 0.01em;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sui-avatar--interactive {
|
||||
cursor: pointer;
|
||||
transition: transform var(--motion-fast);
|
||||
}
|
||||
.sui-avatar--interactive:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.sui-avatar--xs {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
.sui-avatar--sm {
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.sui-avatar--md {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.sui-avatar--lg {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.sui-avatar__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sui-avatar--blue {
|
||||
background: var(--grad-blue-btn);
|
||||
}
|
||||
.sui-avatar--purple {
|
||||
background: var(--grad-purple-btn);
|
||||
}
|
||||
.sui-avatar--green {
|
||||
background: var(--grad-green-btn);
|
||||
}
|
||||
.sui-avatar--amber {
|
||||
background: var(--grad-amber-btn);
|
||||
}
|
||||
.sui-avatar--red {
|
||||
background: var(--grad-red-btn);
|
||||
}
|
||||
.sui-avatar--neutral {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Avatar } from "@shared/components/Avatar";
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: "Primitives/Avatar",
|
||||
component: Avatar,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "centered" },
|
||||
args: { name: "Harper Lee", size: "md", tone: "blue" },
|
||||
argTypes: {
|
||||
size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] },
|
||||
tone: {
|
||||
control: "inline-radio",
|
||||
options: ["blue", "purple", "green", "amber", "red", "neutral"],
|
||||
},
|
||||
onClick: { action: "clicked" },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Avatar>;
|
||||
|
||||
/** Flip name / size / tone / interactive in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const SizeRow: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<Avatar name="A B" size="xs" />
|
||||
<Avatar name="A B" size="sm" />
|
||||
<Avatar name="A B" size="md" />
|
||||
<Avatar name="A B" size="lg" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToneRow: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["blue", "purple", "green", "amber", "red", "neutral"] as const).map(
|
||||
(t) => (
|
||||
<Avatar key={t} name={t[0].toUpperCase()} tone={t} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import "@shared/components/Avatar.css";
|
||||
|
||||
export type AvatarSize = "xs" | "sm" | "md" | "lg";
|
||||
export type AvatarTone =
|
||||
| "blue"
|
||||
| "purple"
|
||||
| "green"
|
||||
| "amber"
|
||||
| "red"
|
||||
| "neutral";
|
||||
|
||||
export interface AvatarProps {
|
||||
/** Image source. Falls back to initials when missing or load fails. */
|
||||
src?: string;
|
||||
/** Full name. Initials are derived from the first letter of each word, max 2. */
|
||||
name: string;
|
||||
size?: AvatarSize;
|
||||
/** Background tone when rendering initials. Defaults to blue. */
|
||||
tone?: AvatarTone;
|
||||
/** Optional click handler — renders as a button when supplied. */
|
||||
onClick?: () => void;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Round avatar showing either an image or coloured initials. Used by the
|
||||
* Header account button, reviewer rows, and assistant message gutters.
|
||||
*/
|
||||
export function Avatar({
|
||||
src,
|
||||
name,
|
||||
size = "md",
|
||||
tone = "blue",
|
||||
onClick,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: AvatarProps) {
|
||||
const classes = [
|
||||
"sui-avatar",
|
||||
`sui-avatar--${size}`,
|
||||
`sui-avatar--${tone}`,
|
||||
onClick ? "sui-avatar--interactive" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const content = src ? (
|
||||
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
|
||||
) : (
|
||||
<span aria-hidden>{initialsOf(name)}</span>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel ?? name}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={classes} role="img" aria-label={ariaLabel ?? name}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
.sui-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sui-banner--info {
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-text-2);
|
||||
border-color: var(--color-blue-border);
|
||||
}
|
||||
.sui-banner--success {
|
||||
background: var(--color-green-light);
|
||||
color: var(--color-text-2);
|
||||
border-color: var(--color-green-border);
|
||||
}
|
||||
.sui-banner--warning {
|
||||
background: var(--color-amber-light);
|
||||
color: var(--color-text-2);
|
||||
border-color: var(--color-amber-border);
|
||||
}
|
||||
.sui-banner--danger {
|
||||
background: var(--color-red-light);
|
||||
color: var(--color-text-2);
|
||||
border-color: var(--color-red-border);
|
||||
}
|
||||
.sui-banner--neutral {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-2);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.sui-banner__icon {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.sui-banner--info .sui-banner__icon {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
.sui-banner--success .sui-banner__icon {
|
||||
color: var(--color-green);
|
||||
}
|
||||
.sui-banner--warning .sui-banner__icon {
|
||||
color: var(--color-amber-dark);
|
||||
}
|
||||
.sui-banner--danger .sui-banner__icon {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.sui-banner__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-banner__title {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.sui-banner__desc {
|
||||
color: var(--color-text-3);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.sui-banner__action {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sui-banner__close {
|
||||
flex: 0 0 auto;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-4);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.sui-banner__close:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Banner } from "@shared/components/Banner";
|
||||
import { Button } from "@shared/components/Button";
|
||||
|
||||
const meta: Meta<typeof Banner> = {
|
||||
title: "Primitives/Banner",
|
||||
component: Banner,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
tone: "info",
|
||||
title: "Heads up",
|
||||
description: "Storage quota approaching 80%.",
|
||||
},
|
||||
argTypes: {
|
||||
tone: {
|
||||
control: "inline-radio",
|
||||
options: ["info", "success", "warning", "danger", "neutral"],
|
||||
},
|
||||
onDismiss: { action: "dismissed" },
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "40rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Banner>;
|
||||
|
||||
/** Flip tone / title / description / action / onDismiss in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithAction: Story = {
|
||||
args: {
|
||||
tone: "warning",
|
||||
title: "Approaching cap",
|
||||
description: "389k of 500k docs processed.",
|
||||
action: (
|
||||
<Button size="sm" variant="outline" accent="amber">
|
||||
Upgrade
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ToneMatrix: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Banner tone="info" title="Info" description="Just so you know." />
|
||||
<Banner
|
||||
tone="success"
|
||||
title="Deployed"
|
||||
description="Prior Auth v3.1.0 is live in us-east-1."
|
||||
/>
|
||||
<Banner
|
||||
tone="warning"
|
||||
title="Schema drift"
|
||||
description="12 docs in 1h didn't match — confidence ↓ 0.07."
|
||||
/>
|
||||
<Banner
|
||||
tone="danger"
|
||||
title="Pipeline run failed"
|
||||
description="8% error rate — 14 docs sent to review queue."
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/Banner.css";
|
||||
|
||||
export type BannerTone = "info" | "success" | "warning" | "danger" | "neutral";
|
||||
|
||||
export interface BannerProps {
|
||||
tone?: BannerTone;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Right-aligned action — typically a button. */
|
||||
action?: ReactNode;
|
||||
/** When set, shows an × button that calls this handler. */
|
||||
onDismiss?: () => void;
|
||||
/** Optional leading icon (caller supplies — keeps the primitive icon-set-agnostic). */
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline alert. Use `tone` to convey severity; pair with `action` for
|
||||
* "Approaching cap → Upgrade" style flows. Use `Toast` (separate primitive)
|
||||
* for transient/dismissible notifications layered over the UI.
|
||||
*/
|
||||
export function Banner({
|
||||
tone = "info",
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
onDismiss,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
}: BannerProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={["sui-banner", `sui-banner--${tone}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{icon && (
|
||||
<div className="sui-banner__icon" aria-hidden>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="sui-banner__body">
|
||||
{title && <div className="sui-banner__title">{title}</div>}
|
||||
{description && <div className="sui-banner__desc">{description}</div>}
|
||||
{children}
|
||||
</div>
|
||||
{action && <div className="sui-banner__action">{action}</div>}
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-banner__close"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
.sui-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--motion-fast),
|
||||
box-shadow var(--motion-fast),
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast),
|
||||
border-color var(--motion-fast);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.sui-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.sui-btn:focus-visible {
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* sizes */
|
||||
.sui-btn--md {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.4375rem 0.875rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
.sui-btn--sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.3125rem 0.625rem;
|
||||
min-height: 1.625rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.sui-btn--full {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* gradient (primary) */
|
||||
.sui-btn--gradient {
|
||||
color: var(--color-text-on-accent);
|
||||
background: var(--grad-blue-btn);
|
||||
box-shadow: var(--shadow-blue);
|
||||
}
|
||||
.sui-btn--gradient.sui-btn--purple {
|
||||
background: var(--grad-purple-btn);
|
||||
}
|
||||
.sui-btn--gradient.sui-btn--green {
|
||||
background: var(--grad-green-btn);
|
||||
}
|
||||
.sui-btn--gradient:hover:not(:disabled) {
|
||||
box-shadow: var(--shadow-blue-hover);
|
||||
transform: translateY(-0.0625rem);
|
||||
}
|
||||
.sui-btn--gradient:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* outline (secondary) */
|
||||
.sui-btn--outline {
|
||||
color: var(--color-text-2);
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.sui-btn--outline.sui-btn--blue {
|
||||
color: var(--color-blue);
|
||||
border-color: var(--color-blue-border);
|
||||
}
|
||||
.sui-btn--outline.sui-btn--purple {
|
||||
color: var(--color-purple);
|
||||
border-color: var(--color-purple-border);
|
||||
}
|
||||
.sui-btn--outline.sui-btn--green {
|
||||
color: var(--color-green);
|
||||
border-color: var(--color-green-border);
|
||||
}
|
||||
.sui-btn--outline.sui-btn--amber {
|
||||
color: var(--color-amber);
|
||||
border-color: var(--color-amber-border);
|
||||
}
|
||||
.sui-btn--outline.sui-btn--red {
|
||||
color: var(--color-red);
|
||||
border-color: var(--color-red-border);
|
||||
}
|
||||
.sui-btn--outline:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
/* ghost (tertiary) */
|
||||
.sui-btn--ghost {
|
||||
color: var(--color-text-3);
|
||||
background: transparent;
|
||||
}
|
||||
.sui-btn--ghost:hover:not(:disabled) {
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
/* spinner */
|
||||
.sui-btn__spinner {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
.sui-btn__label {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "@shared/components/Button";
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: "Primitives/Button",
|
||||
component: Button,
|
||||
parameters: { layout: "centered" },
|
||||
args: { children: "Connect agent" },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "inline-radio",
|
||||
options: ["gradient", "outline", "ghost"],
|
||||
},
|
||||
accent: {
|
||||
control: "inline-radio",
|
||||
options: ["blue", "purple", "green", "amber", "red"],
|
||||
},
|
||||
size: { control: "inline-radio", options: ["sm", "md"] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Gradient: Story = { args: { variant: "gradient" } };
|
||||
export const Outline: Story = { args: { variant: "outline" } };
|
||||
export const Ghost: Story = { args: { variant: "ghost" } };
|
||||
|
||||
export const WithTrailingArrow: Story = {
|
||||
args: {
|
||||
variant: "gradient",
|
||||
children: "Build a pipeline",
|
||||
trailingIcon: <span aria-hidden>→</span>,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = { args: { variant: "gradient", loading: true } };
|
||||
export const Disabled: Story = {
|
||||
args: { variant: "gradient", disabled: true },
|
||||
};
|
||||
|
||||
export const AccentMatrix: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{(["gradient", "outline"] as const).flatMap((variant) =>
|
||||
(["blue", "purple", "green", "amber", "red"] as const).map((accent) => (
|
||||
<Button
|
||||
key={`${variant}-${accent}`}
|
||||
variant={variant}
|
||||
accent={accent}
|
||||
>
|
||||
{variant} · {accent}
|
||||
</Button>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import "@shared/components/Button.css";
|
||||
|
||||
export type ButtonVariant = "gradient" | "outline" | "ghost";
|
||||
export type ButtonSize = "sm" | "md";
|
||||
export type ButtonAccent = "blue" | "purple" | "green" | "amber" | "red";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** Visual variant. `gradient` is the primary CTA; `outline` is secondary; `ghost` is tertiary/text. */
|
||||
variant?: ButtonVariant;
|
||||
/** Optional accent colour. Currently affects `gradient` and `outline` variants. */
|
||||
accent?: ButtonAccent;
|
||||
size?: ButtonSize;
|
||||
/** Icon node rendered before the label. */
|
||||
leadingIcon?: ReactNode;
|
||||
/** Icon node rendered after the label (arrow → for "next" CTAs). */
|
||||
trailingIcon?: ReactNode;
|
||||
/** Show a spinner and disable interactivity. */
|
||||
loading?: boolean;
|
||||
/** Stretches to fill its parent container. */
|
||||
fullWidth?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stirling's two-button system: a gradient primary CTA and an outlined
|
||||
* secondary action. Ghost is reserved for tertiary links inside a row.
|
||||
*
|
||||
* Buttons compose with the existing CSS variables — they re-skin cleanly in
|
||||
* dark mode without per-variant overrides.
|
||||
*/
|
||||
export function Button({
|
||||
variant = "gradient",
|
||||
accent = "blue",
|
||||
size = "md",
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const classes = [
|
||||
"sui-btn",
|
||||
`sui-btn--${variant}`,
|
||||
`sui-btn--${accent}`,
|
||||
`sui-btn--${size}`,
|
||||
fullWidth ? "sui-btn--full" : "",
|
||||
loading ? "sui-btn--loading" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button {...rest} disabled={disabled || loading} className={classes}>
|
||||
{loading ? (
|
||||
<span className="sui-btn__spinner" aria-hidden />
|
||||
) : (
|
||||
leadingIcon
|
||||
)}
|
||||
{children && <span className="sui-btn__label">{children}</span>}
|
||||
{!loading && trailingIcon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
.sui-card {
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition:
|
||||
box-shadow var(--motion-fast),
|
||||
border-color var(--motion-fast),
|
||||
transform var(--motion-fast);
|
||||
}
|
||||
.sui-card--pad-none {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sui-card--pad-tight {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.sui-card--pad-default {
|
||||
padding: 1.125rem 1.25rem;
|
||||
}
|
||||
.sui-card--pad-loose {
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.sui-card--interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sui-card--interactive:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-0.0625rem);
|
||||
}
|
||||
|
||||
.sui-card--accent-blue::before,
|
||||
.sui-card--accent-purple::before,
|
||||
.sui-card--accent-green::before,
|
||||
.sui-card--accent-amber::before,
|
||||
.sui-card--accent-red::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 0.25rem;
|
||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||
}
|
||||
.sui-card--accent-blue::before {
|
||||
background: var(--color-blue);
|
||||
}
|
||||
.sui-card--accent-purple::before {
|
||||
background: var(--color-purple);
|
||||
}
|
||||
.sui-card--accent-green::before {
|
||||
background: var(--color-green);
|
||||
}
|
||||
.sui-card--accent-amber::before {
|
||||
background: var(--color-amber);
|
||||
}
|
||||
.sui-card--accent-red::before {
|
||||
background: var(--color-red);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Card } from "@shared/components/Card";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { MetricCard } from "@shared/components/MetricCard";
|
||||
import { StatusBadge } from "@shared/components/StatusBadge";
|
||||
|
||||
function CardBody({ color }: { color: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 14, color: "var(--color-text-1)" }}>
|
||||
{color} card
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "4px 0 0",
|
||||
fontSize: 12,
|
||||
color: "var(--color-text-3)",
|
||||
}}
|
||||
>
|
||||
Surface treatment with the {color} accent strip.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: "Primitives/Card",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
accent: undefined,
|
||||
padding: "default",
|
||||
interactive: false,
|
||||
},
|
||||
argTypes: {
|
||||
accent: {
|
||||
control: "inline-radio",
|
||||
options: [undefined, "blue", "purple", "green", "amber", "red"],
|
||||
},
|
||||
padding: {
|
||||
control: "inline-radio",
|
||||
options: ["tight", "default", "loose"],
|
||||
},
|
||||
interactive: { control: "boolean" },
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ width: "20rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: (args) => (
|
||||
<Card {...args}>
|
||||
<CardBody color={args.accent ?? "neutral"} />
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
/** Flip accent / padding / interactive in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const AccentMatrix: Story = {
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ width: "100%" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{(["blue", "purple", "green", "amber", "red", undefined] as const).map(
|
||||
(accent) => (
|
||||
<Card key={accent ?? "none"} accent={accent}>
|
||||
<CardBody color={accent ?? "neutral"} />
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InContext_ProductGrid: Story = {
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ width: "100%" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<Card accent="purple" padding="loose" interactive>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>Sources</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "6px 0 14px",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-3)",
|
||||
}}
|
||||
>
|
||||
Attach pipelines where PDFs already live.
|
||||
</p>
|
||||
<Button variant="outline" accent="purple" size="sm">
|
||||
Connect a source
|
||||
</Button>
|
||||
</Card>
|
||||
<Card accent="blue" padding="loose" interactive>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>Pipelines</h3>
|
||||
<StatusBadge tone="info" size="sm">
|
||||
Hero
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 14px",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-3)",
|
||||
}}
|
||||
>
|
||||
Compose document workflows from typed operations.
|
||||
</p>
|
||||
<Button variant="outline" accent="blue" size="sm">
|
||||
Build a pipeline
|
||||
</Button>
|
||||
</Card>
|
||||
<Card accent="purple" padding="loose" interactive>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>Agents</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "6px 0 14px",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-3)",
|
||||
}}
|
||||
>
|
||||
Wire your agent via MCP, REST, or tool definitions.
|
||||
</p>
|
||||
<Button variant="outline" accent="purple" size="sm">
|
||||
Connect an agent
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InContext_MetricsInsideCard: Story = {
|
||||
args: { padding: "loose" },
|
||||
render: (args) => (
|
||||
<Card {...args}>
|
||||
<h3 style={{ margin: "0 0 12px", fontSize: 14 }}>Last 24 hours</h3>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<MetricCard label="Docs" value={"1,287"} />
|
||||
<MetricCard label="Errors" value={"0.4%"} />
|
||||
<MetricCard label="P95" value="412ms" />
|
||||
<MetricCard label="Uptime" value="99.99%" />
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import "@shared/components/Card.css";
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Adds an accent strip on the left edge of the card. */
|
||||
accent?: "blue" | "purple" | "green" | "amber" | "red";
|
||||
/**
|
||||
* Padding profile. `tight` = 0.75rem, `default` = 1.125rem, `loose` = 1.5rem.
|
||||
* `none` removes padding so the card surface can host edge-to-edge content
|
||||
* (e.g. a list with row dividers).
|
||||
*/
|
||||
padding?: "none" | "tight" | "default" | "loose";
|
||||
/** Use the lifted surface treatment (taller shadow, hover affordance). */
|
||||
interactive?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic surface — the prototype's cardStyle / cardStyleSm helper, lifted
|
||||
* into a primitive. Any list item, panel, or detail card that needs the
|
||||
* standard surface treatment composes from here.
|
||||
*/
|
||||
export function Card({
|
||||
accent,
|
||||
padding = "default",
|
||||
interactive = false,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={[
|
||||
"sui-card",
|
||||
`sui-card--pad-${padding}`,
|
||||
accent ? `sui-card--accent-${accent}` : "",
|
||||
interactive ? "sui-card--interactive" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
.sui-check {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.sui-check--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sui-check__input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.sui-check__box {
|
||||
flex: 0 0 auto;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.0625rem;
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1.5px solid var(--color-border-hover);
|
||||
background: var(--color-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
border-color var(--motion-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sui-check__input:focus-visible + .sui-check__box {
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sui-check__input:checked + .sui-check__box {
|
||||
background: var(--color-blue);
|
||||
border-color: var(--color-blue);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.sui-check__tick {
|
||||
opacity: 0;
|
||||
}
|
||||
.sui-check__input:checked + .sui-check__box .sui-check__tick {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sui-check--mixed .sui-check__box {
|
||||
background: var(--color-blue);
|
||||
border-color: var(--color-blue);
|
||||
}
|
||||
.sui-check--mixed .sui-check__tick {
|
||||
opacity: 0;
|
||||
}
|
||||
.sui-check--mixed .sui-check__dash {
|
||||
position: absolute;
|
||||
width: 0.5rem;
|
||||
height: 2px;
|
||||
background: var(--color-text-on-accent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.sui-check__dash {
|
||||
display: none;
|
||||
}
|
||||
.sui-check--mixed .sui-check__dash {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sui-check__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sui-check__label {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sui-check__desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
|
||||
import "@shared/components/Checkbox.css";
|
||||
|
||||
export interface CheckboxProps extends Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
"type" | "size"
|
||||
> {
|
||||
label?: ReactNode;
|
||||
description?: 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 },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<label
|
||||
className={[
|
||||
"sui-check",
|
||||
rest.disabled ? "sui-check--disabled" : "",
|
||||
indeterminate ? "sui-check--mixed" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
htmlFor={id}
|
||||
>
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (typeof ref === "function") ref(el);
|
||||
else if (ref)
|
||||
(ref as React.MutableRefObject<HTMLInputElement | null>).current =
|
||||
el;
|
||||
if (el) el.indeterminate = !!indeterminate;
|
||||
}}
|
||||
type="checkbox"
|
||||
id={id}
|
||||
className="sui-check__input"
|
||||
{...rest}
|
||||
/>
|
||||
<span className="sui-check__box" aria-hidden>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="10"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="sui-check__tick"
|
||||
>
|
||||
<polyline points="3 8.5 7 12 13 4" />
|
||||
</svg>
|
||||
<span className="sui-check__dash" />
|
||||
</span>
|
||||
{(label || description) && (
|
||||
<span className="sui-check__text">
|
||||
{label && <span className="sui-check__label">{label}</span>}
|
||||
{description && (
|
||||
<span className="sui-check__desc">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
.sui-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1_5);
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
border-color var(--motion-fast),
|
||||
transform var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-chip--sm {
|
||||
padding: var(--space-0_5) var(--space-2);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.sui-chip--md {
|
||||
padding: var(--space-1) var(--space-2_5, 0.5rem);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.sui-chip--md {
|
||||
padding: var(--space-1) 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.sui-chip--neutral {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-2);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.sui-chip--blue {
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-blue);
|
||||
border-color: var(--color-blue-border);
|
||||
}
|
||||
.sui-chip--purple {
|
||||
background: var(--color-purple-light);
|
||||
color: var(--color-purple);
|
||||
border-color: var(--color-purple-border);
|
||||
}
|
||||
.sui-chip--green {
|
||||
background: var(--color-green-light);
|
||||
color: var(--color-green);
|
||||
border-color: var(--color-green-border);
|
||||
}
|
||||
.sui-chip--amber {
|
||||
background: var(--color-amber-light);
|
||||
color: var(--color-amber-dark);
|
||||
border-color: var(--color-amber-border);
|
||||
}
|
||||
.sui-chip--red {
|
||||
background: var(--color-red-light);
|
||||
color: var(--color-red);
|
||||
border-color: var(--color-red-border);
|
||||
}
|
||||
|
||||
.sui-chip--interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sui-chip--interactive:hover {
|
||||
transform: translateY(-0.0625rem);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.sui-chip__dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.sui-chip__icon {
|
||||
display: inline-flex;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sui-chip__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.sui-chip__remove:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Chip } from "@shared/components/Chip";
|
||||
|
||||
const meta: Meta<typeof Chip> = {
|
||||
title: "Primitives/Chip",
|
||||
component: Chip,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
children: "us-east-1",
|
||||
tone: "neutral",
|
||||
size: "md",
|
||||
showDot: false,
|
||||
},
|
||||
argTypes: {
|
||||
tone: {
|
||||
control: "inline-radio",
|
||||
options: ["neutral", "blue", "purple", "green", "amber", "red"],
|
||||
},
|
||||
size: { control: "inline-radio", options: ["sm", "md"] },
|
||||
showDot: { control: "boolean" },
|
||||
onClick: { action: "clicked" },
|
||||
onRemove: { action: "removed" },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Chip>;
|
||||
|
||||
/** Flip tone / size / dot / interactive / removable in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const ToneRow: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["neutral", "blue", "purple", "green", "amber", "red"] as const).map(
|
||||
(t) => (
|
||||
<Chip key={t} tone={t}>
|
||||
{t}
|
||||
</Chip>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InContext_OpChain: Story = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 6,
|
||||
maxWidth: "32rem",
|
||||
}}
|
||||
>
|
||||
<Chip tone="green" showDot>
|
||||
ocr
|
||||
</Chip>
|
||||
<Chip tone="green" showDot>
|
||||
classify
|
||||
</Chip>
|
||||
<Chip tone="green" showDot>
|
||||
extract
|
||||
</Chip>
|
||||
<Chip tone="blue" showDot>
|
||||
validate
|
||||
</Chip>
|
||||
<Chip tone="red" showDot>
|
||||
redact
|
||||
</Chip>
|
||||
<Chip tone="red" showDot>
|
||||
encrypt-rest
|
||||
</Chip>
|
||||
<Chip tone="purple" showDot>
|
||||
store-primary
|
||||
</Chip>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/Chip.css";
|
||||
|
||||
export type ChipTone =
|
||||
| "neutral"
|
||||
| "blue"
|
||||
| "purple"
|
||||
| "green"
|
||||
| "amber"
|
||||
| "red";
|
||||
|
||||
export type ChipSize = "sm" | "md";
|
||||
|
||||
export interface ChipProps {
|
||||
tone?: ChipTone;
|
||||
size?: ChipSize;
|
||||
leadingIcon?: ReactNode;
|
||||
trailingIcon?: ReactNode;
|
||||
/** Show a `×` button. Calls `onRemove` when clicked. */
|
||||
onRemove?: () => void;
|
||||
/** Renders as a button when set. */
|
||||
onClick?: () => void;
|
||||
/** Show the leading dot affordance. Defaults to false (set true for status-style chips). */
|
||||
showDot?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic chip / tag — `StatusBadge` has a fixed taxonomy (success/warning/…),
|
||||
* `MethodBadge` is HTTP-method-only; this is the open-ended one for tag rows
|
||||
* (selected ops, document regions, kbd hints, sort chips, etc).
|
||||
*/
|
||||
export function Chip({
|
||||
tone = "neutral",
|
||||
size = "md",
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
onRemove,
|
||||
onClick,
|
||||
showDot,
|
||||
children,
|
||||
className,
|
||||
}: ChipProps) {
|
||||
const Tag = onClick ? "button" : "span";
|
||||
const classes = [
|
||||
"sui-chip",
|
||||
`sui-chip--${tone}`,
|
||||
`sui-chip--${size}`,
|
||||
onClick ? "sui-chip--interactive" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<Tag
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
type={onClick ? "button" : undefined}
|
||||
>
|
||||
{showDot && <span className="sui-chip__dot" aria-hidden />}
|
||||
{leadingIcon && (
|
||||
<span className="sui-chip__icon" aria-hidden>
|
||||
{leadingIcon}
|
||||
</span>
|
||||
)}
|
||||
<span className="sui-chip__label">{children}</span>
|
||||
{trailingIcon && !onRemove && (
|
||||
<span className="sui-chip__icon" aria-hidden>
|
||||
{trailingIcon}
|
||||
</span>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-chip__remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
aria-label="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.sui-code {
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--code-border);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.sui-code__chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
background: var(--code-bg-header);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--code-border);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--code-muted);
|
||||
}
|
||||
.sui-code__dots {
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.sui-code__dots > span {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--code-dot);
|
||||
}
|
||||
.sui-code__caption {
|
||||
color: var(--code-muted);
|
||||
margin-left: 0.25rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.sui-code__lang {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--code-dim);
|
||||
}
|
||||
.sui-code__copy {
|
||||
background: transparent;
|
||||
color: var(--code-text);
|
||||
border: 1px solid var(--code-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-code__copy:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.sui-code__pre {
|
||||
margin: 0;
|
||||
padding: 0.875rem 1rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78125rem;
|
||||
line-height: 1.55;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.sui-code__pre code {
|
||||
white-space: inherit;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CodeBlock } from "@shared/components/CodeBlock";
|
||||
|
||||
const CURL_EXAMPLE = `curl https://api.stirling.com/v1/coi \\
|
||||
-H "Authorization: Bearer sk_live_a3f8..." \\
|
||||
-F "[email protected]"`;
|
||||
|
||||
const JSON_RESULT = `{
|
||||
"schema": "coi.v2",
|
||||
"fields": {
|
||||
"carrier": "Travelers Casualty",
|
||||
"policy_number": "PHB-1108-2025",
|
||||
"gl_limit": 1000000,
|
||||
"umbrella_limit": 5000000,
|
||||
"effective": "2026-01-15",
|
||||
"expiry": "2027-01-15"
|
||||
},
|
||||
"confidence_avg": 0.96
|
||||
}`;
|
||||
|
||||
const PYTHON_EXAMPLE = `import stirling
|
||||
|
||||
client = stirling.Client(api_key="sk_live_a3f8...")
|
||||
result = client.extract(file="certificate.pdf", schema="coi.v2")
|
||||
print(result.fields)`;
|
||||
|
||||
const meta: Meta<typeof CodeBlock> = {
|
||||
title: "Primitives/CodeBlock",
|
||||
component: CodeBlock,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: { code: CURL_EXAMPLE, lang: "curl", copyable: true, maxHeight: 400 },
|
||||
argTypes: {
|
||||
lang: {
|
||||
control: "inline-radio",
|
||||
options: [
|
||||
"json",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"python",
|
||||
"bash",
|
||||
"curl",
|
||||
"http",
|
||||
"plain",
|
||||
],
|
||||
},
|
||||
copyable: { control: "boolean" },
|
||||
maxHeight: { control: "number" },
|
||||
code: { control: "text" },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CodeBlock>;
|
||||
|
||||
/** Flip lang / copyable / maxHeight / code in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const LongScrolling: Story = {
|
||||
args: {
|
||||
code: Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) => `line ${i + 1}: const x = ${i};`,
|
||||
).join("\n"),
|
||||
lang: "javascript",
|
||||
maxHeight: 240,
|
||||
},
|
||||
};
|
||||
|
||||
export const InContext_TwoUpComparison: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
<CodeBlock code={CURL_EXAMPLE} lang="curl" caption="Request" />
|
||||
<CodeBlock code={JSON_RESULT} lang="json" caption="Response" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InContext_Quickstart: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<CodeBlock code={PYTHON_EXAMPLE} lang="python" caption="quickstart.py" />
|
||||
<CodeBlock code={CURL_EXAMPLE} lang="curl" caption="curl" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import "@shared/components/CodeBlock.css";
|
||||
|
||||
export type CodeLang =
|
||||
| "json"
|
||||
| "javascript"
|
||||
| "typescript"
|
||||
| "python"
|
||||
| "bash"
|
||||
| "curl"
|
||||
| "http"
|
||||
| "plain";
|
||||
|
||||
export interface CodeBlockProps {
|
||||
/** The code content. */
|
||||
code: string;
|
||||
/** Language label shown in the chrome bar. Highlight wiring is out of scope here — bring Shiki/Prism. */
|
||||
lang?: CodeLang;
|
||||
/** Optional caption text shown in the chrome bar (e.g. a file path). */
|
||||
caption?: ReactNode;
|
||||
/** Show a copy-to-clipboard button. Defaults to true. */
|
||||
copyable?: boolean;
|
||||
/** Max height in pixels; longer content scrolls. */
|
||||
maxHeight?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-dark code block, matched to the prototype's CODE palette.
|
||||
*
|
||||
* Highlighting is intentionally not wired here — drop in Shiki at the call
|
||||
* site and feed pre-highlighted HTML through `dangerouslySetInnerHTML` on a
|
||||
* fork of this component if you need it. For most surfaces the raw
|
||||
* monospaced text plus copy button is sufficient (and ~80% lighter).
|
||||
*/
|
||||
export function CodeBlock({
|
||||
code,
|
||||
lang = "plain",
|
||||
caption,
|
||||
copyable = true,
|
||||
maxHeight = 400,
|
||||
className,
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Older browsers / non-secure contexts — silently fall through.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={["sui-code", className ?? ""].filter(Boolean).join(" ")}>
|
||||
<div className="sui-code__chrome">
|
||||
<span className="sui-code__dots" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
{caption && <span className="sui-code__caption">{caption}</span>}
|
||||
<span className="sui-code__lang">{lang}</span>
|
||||
{copyable && (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-code__copy"
|
||||
onClick={copy}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="sui-code__pre" style={{ maxHeight }}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
.sui-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.32);
|
||||
z-index: var(--z-drawer);
|
||||
animation: fadeIn 0.18s ease both;
|
||||
}
|
||||
|
||||
.sui-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
max-width: 100vw;
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: calc(var(--z-drawer) + 1);
|
||||
animation: slideInRight 0.22s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.sui-drawer--right {
|
||||
right: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.sui-drawer--left {
|
||||
left: 0;
|
||||
border-right: 1px solid var(--color-border);
|
||||
box-shadow: 4px 0 24px rgba(15, 23, 42, 0.12);
|
||||
animation-name: slideInLeft;
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sui-drawer--sm {
|
||||
width: 22rem;
|
||||
}
|
||||
.sui-drawer--md {
|
||||
width: 27.5rem;
|
||||
}
|
||||
.sui-drawer--lg {
|
||||
width: 36rem;
|
||||
}
|
||||
|
||||
.sui-drawer__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-4) var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.sui-drawer__header-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sui-drawer__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.sui-drawer__sub {
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.sui-drawer__close {
|
||||
flex: 0 0 auto;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-4);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.sui-drawer__close:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.sui-drawer__body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.sui-drawer__footer {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Drawer } from "@shared/components/Drawer";
|
||||
import { Button } from "@shared/components/Button";
|
||||
|
||||
const meta: Meta<typeof Drawer> = {
|
||||
title: "Primitives/Drawer",
|
||||
component: Drawer,
|
||||
parameters: { layout: "fullscreen" },
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
side: "right",
|
||||
width: "md",
|
||||
title: "Pipeline detail",
|
||||
subtitle: "COI Compliance · us-east-1",
|
||||
},
|
||||
argTypes: {
|
||||
side: { control: "inline-radio", options: ["left", "right"] },
|
||||
width: { control: "inline-radio", options: ["sm", "md", "lg"] },
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
padding: 24,
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: (args) => {
|
||||
function Bound() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open drawer</Button>
|
||||
<Drawer {...args} open={open} onClose={() => setOpen(false)}>
|
||||
<p style={{ color: "var(--color-text-3)" }}>
|
||||
The drawer body scrolls when its content overflows. The header and
|
||||
footer (when present) are sticky.
|
||||
</p>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Drawer>;
|
||||
|
||||
/** Flip side / width / title / subtitle in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithFooter: Story = {
|
||||
render: () => {
|
||||
function Bound() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open drawer</Button>
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
side="right"
|
||||
width="md"
|
||||
title="Pipeline detail"
|
||||
subtitle="COI Compliance · us-east-1"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="outline">Edit composition</Button>
|
||||
<Button variant="gradient">View runs</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p style={{ color: "var(--color-text-3)" }}>
|
||||
Sticky footer demo — scroll the body, footer stays anchored.
|
||||
</p>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "@shared/components/Drawer.css";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export type DrawerSide = "right" | "left";
|
||||
export type DrawerWidth = "sm" | "md" | "lg";
|
||||
|
||||
export interface DrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
side?: DrawerSide;
|
||||
/** Width preset. sm=22rem, md=27.5rem, lg=36rem. */
|
||||
width?: DrawerWidth;
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
/** Sticky footer slot below the body. */
|
||||
footer?: ReactNode;
|
||||
disableBackdropClose?: boolean;
|
||||
disableEscapeClose?: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Side drawer — sibling to {@link Modal}. Slides in from `side`, locks body
|
||||
* scroll, closes on backdrop click or Escape. PipelineDetailDrawer, doc
|
||||
* detail drawers, etc. should all sit on top of this primitive.
|
||||
*/
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
side = "right",
|
||||
width = "md",
|
||||
title,
|
||||
subtitle,
|
||||
footer,
|
||||
disableBackdropClose = false,
|
||||
disableEscapeClose = false,
|
||||
className,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const dialogRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || disableEscapeClose) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, disableEscapeClose, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const dialog = dialogRef.current;
|
||||
const first = dialog?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
first?.focus();
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab" || !dialog) return;
|
||||
const focusables =
|
||||
dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
if (focusables.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const firstEl = focusables[0];
|
||||
const lastEl = focusables[focusables.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && active === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
} else if (!e.shiftKey && active === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function onBackdropClick() {
|
||||
if (!disableBackdropClose) onClose();
|
||||
}
|
||||
|
||||
const hasTitle = title !== undefined && title !== null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="sui-drawer__backdrop"
|
||||
onClick={onBackdropClick}
|
||||
role="presentation"
|
||||
/>
|
||||
<aside
|
||||
ref={dialogRef}
|
||||
className={[
|
||||
"sui-drawer",
|
||||
`sui-drawer--${side}`,
|
||||
`sui-drawer--${width}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={hasTitle ? titleId : undefined}
|
||||
aria-label={!hasTitle ? ariaLabel : undefined}
|
||||
>
|
||||
{(title || subtitle) && (
|
||||
<header className="sui-drawer__header">
|
||||
<div className="sui-drawer__header-text">
|
||||
{title && (
|
||||
<div id={titleId} className="sui-drawer__title">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && <div className="sui-drawer__sub">{subtitle}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sui-drawer__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
<div className="sui-drawer__body">{children}</div>
|
||||
{footer && <footer className="sui-drawer__footer">{footer}</footer>}
|
||||
</aside>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.sui-dd {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sui-dd__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-1));
|
||||
min-width: 12rem;
|
||||
padding: var(--space-1);
|
||||
background: var(--color-dropdown-bg);
|
||||
border: 1px solid var(--color-dropdown-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.sui-dd__menu--start {
|
||||
left: 0;
|
||||
}
|
||||
.sui-dd__menu--end {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sui-dd__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-dd__item:hover:not(.is-disabled) {
|
||||
background: var(--color-dropdown-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.sui-dd__item.is-active {
|
||||
background: var(--color-nav-active);
|
||||
color: var(--color-nav-active-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sui-dd__item.is-disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sui-dd__item-leading,
|
||||
.sui-dd__item-trailing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sui-dd__item-trailing {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.sui-dd__divider {
|
||||
height: 1px;
|
||||
background: var(--color-divider);
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Dropdown } from "@shared/components/Dropdown";
|
||||
import { Button } from "@shared/components/Button";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Primitives/Dropdown",
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ padding: "4rem 2rem", minHeight: "24rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger>
|
||||
<Button variant="outline">Open menu</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onSelect={() => console.log("a")}>Item A</Dropdown.Item>
|
||||
<Dropdown.Item onSelect={() => console.log("b")}>Item B</Dropdown.Item>
|
||||
<Dropdown.Item onSelect={() => console.log("c")} active>
|
||||
Item C (active)
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithDivider: Story = {
|
||||
render: () => (
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger>
|
||||
<Button variant="outline">Account</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="13rem">
|
||||
<Dropdown.Item>Profile</Dropdown.Item>
|
||||
<Dropdown.Item>Workspace settings</Dropdown.Item>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item>Sign out</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTrailingHints: Story = {
|
||||
render: () => (
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger>
|
||||
<Button variant="outline">Commands</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="16rem">
|
||||
<Dropdown.Item trailing="⌘ K">Search</Dropdown.Item>
|
||||
<Dropdown.Item trailing="N P">New pipeline</Dropdown.Item>
|
||||
<Dropdown.Item trailing="N K">New API key</Dropdown.Item>
|
||||
<Dropdown.Item trailing="T" active>
|
||||
Toggle theme
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
),
|
||||
};
|
||||
|
||||
export const AlignStart: Story = {
|
||||
render: () => (
|
||||
<Dropdown.Root align="start">
|
||||
<Dropdown.Trigger>
|
||||
<Button variant="outline">Aligned to start</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item>One</Dropdown.Item>
|
||||
<Dropdown.Item>Two</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import "@shared/components/Dropdown.css";
|
||||
|
||||
type Alignment = "start" | "end";
|
||||
|
||||
interface DropdownContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<HTMLElement | null>;
|
||||
menuId: string;
|
||||
align: Alignment;
|
||||
}
|
||||
|
||||
const DropdownContext = createContext<DropdownContextValue | null>(null);
|
||||
|
||||
function useDropdownCtx(): DropdownContextValue {
|
||||
const ctx = useContext(DropdownContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"Dropdown subcomponents must be used inside <Dropdown.Root>",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export interface DropdownRootProps {
|
||||
/** Controlled open state. Omit for uncontrolled. */
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Alignment of the menu relative to the trigger. */
|
||||
align?: Alignment;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Root({
|
||||
open: openProp,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
align = "end",
|
||||
children,
|
||||
className,
|
||||
}: DropdownRootProps) {
|
||||
const [uncontrolled, setUncontrolled] = useState(defaultOpen ?? false);
|
||||
const isControlled = openProp !== undefined;
|
||||
const open = isControlled ? openProp : uncontrolled;
|
||||
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setUncontrolled(next);
|
||||
onOpenChange?.(next);
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
);
|
||||
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuId = useId();
|
||||
|
||||
// Click-outside + Escape close.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, setOpen]);
|
||||
|
||||
const value = useMemo<DropdownContextValue>(
|
||||
() => ({ open, setOpen, triggerRef, menuId, align }),
|
||||
[open, setOpen, menuId, align],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider value={value}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={["sui-dd", className ?? ""].filter(Boolean).join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DropdownTriggerProps {
|
||||
/** A single button-like element. Receives onClick + aria props. */
|
||||
children: ReactElement<{
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
"aria-haspopup"?: string;
|
||||
"aria-expanded"?: boolean;
|
||||
"aria-controls"?: string;
|
||||
ref?: React.Ref<unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
function Trigger({ children }: DropdownTriggerProps) {
|
||||
const { open, setOpen, triggerRef, menuId } = useDropdownCtx();
|
||||
if (!isValidElement(children)) {
|
||||
throw new Error(
|
||||
"Dropdown.Trigger requires exactly one React element child",
|
||||
);
|
||||
}
|
||||
return cloneElement(children, {
|
||||
ref: triggerRef as React.Ref<unknown>,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
children.props.onClick?.(e);
|
||||
setOpen(!open);
|
||||
},
|
||||
"aria-haspopup": "menu",
|
||||
"aria-expanded": open,
|
||||
"aria-controls": menuId,
|
||||
});
|
||||
}
|
||||
|
||||
export interface DropdownMenuProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Optional min-width override (px or CSS length). */
|
||||
width?: string | number;
|
||||
}
|
||||
|
||||
function Menu({ children, className, width }: DropdownMenuProps) {
|
||||
const { open, menuId, align } = useDropdownCtx();
|
||||
if (!open) return null;
|
||||
const style =
|
||||
width !== undefined
|
||||
? { minWidth: typeof width === "number" ? `${width}px` : width }
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
id={menuId}
|
||||
role="menu"
|
||||
className={["sui-dd__menu", `sui-dd__menu--${align}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DropdownItemProps {
|
||||
onSelect?: () => void;
|
||||
/** Active visual state (e.g. current value in a switcher). */
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Optional leading visual. */
|
||||
leading?: ReactNode;
|
||||
/** Optional trailing visual (kbd hint, badge, etc). */
|
||||
trailing?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Item({
|
||||
onSelect,
|
||||
active,
|
||||
disabled,
|
||||
leading,
|
||||
trailing,
|
||||
children,
|
||||
className,
|
||||
}: DropdownItemProps) {
|
||||
const { setOpen } = useDropdownCtx();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className={[
|
||||
"sui-dd__item",
|
||||
active ? "is-active" : "",
|
||||
disabled ? "is-disabled" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onSelect?.();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{leading && <span className="sui-dd__item-leading">{leading}</span>}
|
||||
<span className="sui-dd__item-label">{children}</span>
|
||||
{trailing && <span className="sui-dd__item-trailing">{trailing}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="sui-dd__divider" role="separator" aria-hidden />;
|
||||
}
|
||||
|
||||
export const Dropdown = {
|
||||
Root,
|
||||
Trigger,
|
||||
Menu,
|
||||
Item,
|
||||
Divider,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
.sui-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sui-empty--compact {
|
||||
padding: var(--space-4) var(--space-3);
|
||||
}
|
||||
|
||||
.sui-empty__icon {
|
||||
display: inline-flex;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.sui-empty__eyebrow {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.sui-empty__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.sui-empty--compact .sui-empty__title {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.sui-empty__copy {
|
||||
margin: 0;
|
||||
max-width: 32rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.sui-empty--compact .sui-empty__copy {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.sui-empty__actions {
|
||||
margin-top: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { EmptyState } from "@shared/components/EmptyState";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { Card } from "@shared/components/Card";
|
||||
|
||||
const meta: Meta<typeof EmptyState> = {
|
||||
title: "Primitives/EmptyState",
|
||||
component: EmptyState,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
title: "Nothing here yet",
|
||||
description: "When pipelines are deployed they'll appear in this list.",
|
||||
size: "default",
|
||||
},
|
||||
argTypes: {
|
||||
size: { control: "inline-radio", options: ["default", "compact"] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EmptyState>;
|
||||
|
||||
/** Flip title / description / eyebrow / size / actions in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithCTAs: Story = {
|
||||
args: {
|
||||
eyebrow: "No pipelines yet",
|
||||
title: "Start from a template — or compose from scratch.",
|
||||
description:
|
||||
"The fastest way in is forking a pre-bundled pipeline like PII Sweep or Compliance Pack.",
|
||||
actions: (
|
||||
<>
|
||||
<Button variant="gradient" trailingIcon={<span aria-hidden>→</span>}>
|
||||
Browse templates
|
||||
</Button>
|
||||
<Button variant="outline">Build from scratch</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const InCard: Story = {
|
||||
render: () => (
|
||||
<Card padding="loose" style={{ maxWidth: "40rem" }}>
|
||||
<EmptyState
|
||||
title="You're all caught up"
|
||||
description="No new notifications."
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/EmptyState.css";
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Eyebrow text shown above the title (e.g. "No pipelines yet"). */
|
||||
eyebrow?: ReactNode;
|
||||
/** Headline. */
|
||||
title: ReactNode;
|
||||
/** One- or two-line body copy. */
|
||||
description?: ReactNode;
|
||||
/** Optional visual at the top (icon, illustration, etc). */
|
||||
icon?: ReactNode;
|
||||
/** Primary + secondary CTAs. */
|
||||
actions?: ReactNode;
|
||||
/** Visual size. `compact` removes the icon row's padding for inline use. */
|
||||
size?: "compact" | "default";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered "nothing here yet" panel. Replaces the bespoke empty cards inline
|
||||
* in Pipelines (free tier), NotificationsDropdown (all caught up), and the
|
||||
* search modal (no matches).
|
||||
*/
|
||||
export function EmptyState({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions,
|
||||
size = "default",
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={["sui-empty", `sui-empty--${size}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{icon && (
|
||||
<div className="sui-empty__icon" aria-hidden>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{eyebrow && <div className="sui-empty__eyebrow">{eyebrow}</div>}
|
||||
<h2 className="sui-empty__title">{title}</h2>
|
||||
{description && <p className="sui-empty__copy">{description}</p>}
|
||||
{actions && <div className="sui-empty__actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.sui-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sui-field__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
}
|
||||
|
||||
.sui-field__required {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.sui-field__control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sui-field__help {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sui-field--error .sui-field__help {
|
||||
color: var(--color-red);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useId,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import "@shared/components/FormField.css";
|
||||
|
||||
export interface FormFieldProps {
|
||||
label?: ReactNode;
|
||||
/** Helper text shown under the control. Replaced by `error` when present. */
|
||||
helperText?: ReactNode;
|
||||
/** Error string. Causes the control + helper region to swap to the error tone. */
|
||||
error?: ReactNode;
|
||||
required?: boolean;
|
||||
/** A single form control as a React element. Receives id + aria-* props. */
|
||||
children: ReactElement<{
|
||||
id?: string;
|
||||
"aria-invalid"?: boolean;
|
||||
"aria-describedby"?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardised label + control + helper/error wrapper. Works with the bare
|
||||
* native controls in this design system (Input, Select, Checkbox, Radio,
|
||||
* Slider) or with any third-party control that accepts `id` + `aria-*`.
|
||||
*/
|
||||
export function FormField({
|
||||
label,
|
||||
helperText,
|
||||
error,
|
||||
required,
|
||||
children,
|
||||
className,
|
||||
}: FormFieldProps) {
|
||||
const autoId = useId();
|
||||
if (!isValidElement(children)) {
|
||||
throw new Error("FormField requires exactly one React element child");
|
||||
}
|
||||
const controlId = children.props.id ?? autoId;
|
||||
const describedById = error || helperText ? `${controlId}-help` : undefined;
|
||||
|
||||
const child = cloneElement(children, {
|
||||
id: controlId,
|
||||
"aria-invalid": error ? true : undefined,
|
||||
"aria-describedby": describedById,
|
||||
required: required ?? children.props.required,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={["sui-field", error ? "sui-field--error" : "", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{label && (
|
||||
<label htmlFor={controlId} className="sui-field__label">
|
||||
{label}
|
||||
{required && (
|
||||
<span className="sui-field__required" aria-hidden>
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<div className="sui-field__control">{child}</div>
|
||||
{(error || helperText) && (
|
||||
<div id={describedById} className="sui-field__help">
|
||||
{error ?? helperText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FormField } from "@shared/components/FormField";
|
||||
import { Input } from "@shared/components/Input";
|
||||
import { Select } from "@shared/components/Select";
|
||||
import { Checkbox } from "@shared/components/Checkbox";
|
||||
import { RadioGroup } from "@shared/components/Radio";
|
||||
import { Slider } from "@shared/components/Slider";
|
||||
import { Stack } from "@shared/components/Stack";
|
||||
import { Inline } from "@shared/components/Inline";
|
||||
|
||||
// Inline icon to avoid a cross-layer import; shared/ must not depend on portal/.
|
||||
function SearchIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Primitives/Forms",
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ width: "28rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Input_Default: Story = {
|
||||
render: () => (
|
||||
<FormField
|
||||
label="Pipeline name"
|
||||
required
|
||||
helperText="Lowercase letters and dashes only."
|
||||
>
|
||||
<Input placeholder="e.g. coi-compliance" />
|
||||
</FormField>
|
||||
),
|
||||
};
|
||||
|
||||
export const Input_WithIcon: Story = {
|
||||
render: () => (
|
||||
<FormField label="Search">
|
||||
<Input
|
||||
leadingIcon={<SearchIcon size={14} />}
|
||||
placeholder="Search Stirling…"
|
||||
/>
|
||||
</FormField>
|
||||
),
|
||||
};
|
||||
|
||||
export const Input_Error: Story = {
|
||||
render: () => (
|
||||
<FormField label="Email" error="Must be a valid email address" required>
|
||||
<Input value="not-an-email" onChange={() => {}} />
|
||||
</FormField>
|
||||
),
|
||||
};
|
||||
|
||||
export const Select_Default: Story = {
|
||||
render: () => (
|
||||
<FormField label="Retention period">
|
||||
<Select
|
||||
defaultValue="90"
|
||||
options={[
|
||||
{ value: "30", label: "30 days" },
|
||||
{ value: "60", label: "60 days" },
|
||||
{ value: "90", label: "90 days (default)" },
|
||||
{ value: "180", label: "180 days" },
|
||||
{ value: "never", label: "Never expire" },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
),
|
||||
};
|
||||
|
||||
export const Checkbox_Single: Story = {
|
||||
render: () => (
|
||||
<Stack gap="2">
|
||||
<Checkbox
|
||||
defaultChecked
|
||||
label="Notify on pipeline failure (webhook + email)"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Send low-confidence docs to review queue"
|
||||
description="Confidence below 0.85 routes to a reviewer."
|
||||
/>
|
||||
<Checkbox indeterminate label="Mixed state" />
|
||||
<Checkbox disabled label="Disabled (off)" />
|
||||
<Checkbox defaultChecked disabled label="Disabled (on)" />
|
||||
</Stack>
|
||||
),
|
||||
};
|
||||
|
||||
export const Checkbox_GridOfCategories: Story = {
|
||||
render: () => (
|
||||
<FormField label="PII categories">
|
||||
<Inline gap="2">
|
||||
{["SSN", "DOB", "Accounts", "Contacts", "Names", "Addresses"].map(
|
||||
(c) => (
|
||||
<Checkbox key={c} defaultChecked label={c} />
|
||||
),
|
||||
)}
|
||||
</Inline>
|
||||
</FormField>
|
||||
),
|
||||
};
|
||||
|
||||
export const Radio_Group: Story = {
|
||||
render: () => {
|
||||
function Bound() {
|
||||
const [mode, setMode] = useState<"stirling" | "byok" | "hyok">(
|
||||
"stirling",
|
||||
);
|
||||
return (
|
||||
<FormField label="Key mode">
|
||||
<RadioGroup
|
||||
name="keymode"
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{
|
||||
value: "stirling",
|
||||
label: "Stirling-managed",
|
||||
description: "Stirling generates and rotates keys.",
|
||||
},
|
||||
{
|
||||
value: "byok",
|
||||
label: "BYOK (AWS KMS)",
|
||||
description: "Bring your own KMS key.",
|
||||
},
|
||||
{
|
||||
value: "hyok",
|
||||
label: "HYOK (Enterprise)",
|
||||
description: "Stirling never sees the key material.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
|
||||
export const Radio_Horizontal: Story = {
|
||||
render: () => {
|
||||
function Bound() {
|
||||
const [v, setV] = useState("us");
|
||||
return (
|
||||
<FormField label="Region">
|
||||
<RadioGroup
|
||||
name="region"
|
||||
direction="horizontal"
|
||||
value={v}
|
||||
onChange={setV}
|
||||
options={[
|
||||
{ value: "us", label: "US" },
|
||||
{ value: "eu", label: "EU" },
|
||||
{ value: "apac", label: "APAC" },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
|
||||
export const Slider_Confidence: Story = {
|
||||
render: () => {
|
||||
function Bound() {
|
||||
const [v, setV] = useState(0.85);
|
||||
return (
|
||||
<FormField
|
||||
label="Minimum confidence"
|
||||
helperText="Default 0.85 — gates downstream ops below this threshold."
|
||||
>
|
||||
<Slider
|
||||
value={v}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={setV}
|
||||
formatValue={(x) => x.toFixed(2)}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
|
||||
export const Slider_Retention: Story = {
|
||||
render: () => {
|
||||
function Bound() {
|
||||
const [days, setDays] = useState(90);
|
||||
return (
|
||||
<FormField label="Retain artifacts for">
|
||||
<Slider
|
||||
value={days}
|
||||
min={7}
|
||||
max={365}
|
||||
step={1}
|
||||
onChange={setDays}
|
||||
formatValue={(d) => `${d} days`}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
|
||||
export const FullForm: Story = {
|
||||
render: () => {
|
||||
function Form() {
|
||||
const [name, setName] = useState("");
|
||||
const [retention, setRetention] = useState("90");
|
||||
const [mode, setMode] = useState<"stirling" | "byok" | "hyok">(
|
||||
"stirling",
|
||||
);
|
||||
const [conf, setConf] = useState(0.85);
|
||||
const [notify, setNotify] = useState(true);
|
||||
const [review, setReview] = useState(false);
|
||||
return (
|
||||
<Stack gap="4">
|
||||
<FormField label="Pipeline name" required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. coi-compliance"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Retention">
|
||||
<Select
|
||||
value={retention}
|
||||
onChange={(e) => setRetention(e.target.value)}
|
||||
options={[
|
||||
{ value: "30", label: "30 days" },
|
||||
{ value: "90", label: "90 days" },
|
||||
{ value: "365", label: "1 year" },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Key mode">
|
||||
<RadioGroup
|
||||
name="km"
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: "stirling", label: "Stirling-managed" },
|
||||
{ value: "byok", label: "BYOK" },
|
||||
{ value: "hyok", label: "HYOK" },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Confidence gate" helperText="Default 0.85.">
|
||||
<Slider
|
||||
value={conf}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={setConf}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Alerts">
|
||||
<Stack gap="2">
|
||||
<Checkbox
|
||||
checked={notify}
|
||||
onChange={(e) => setNotify(e.target.checked)}
|
||||
label="Notify on failure"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={review}
|
||||
onChange={(e) => setReview(e.target.checked)}
|
||||
label="Send low-confidence to review"
|
||||
/>
|
||||
</Stack>
|
||||
</FormField>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return <Form />;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
.sui-inline {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sui-inline--nowrap {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.sui-inline--gap-0 {
|
||||
gap: var(--space-0);
|
||||
}
|
||||
.sui-inline--gap-0_5 {
|
||||
gap: var(--space-0_5);
|
||||
}
|
||||
.sui-inline--gap-1 {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.sui-inline--gap-1_5 {
|
||||
gap: var(--space-1_5);
|
||||
}
|
||||
.sui-inline--gap-2 {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.sui-inline--gap-3 {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.sui-inline--gap-4 {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.sui-inline--gap-5 {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.sui-inline--gap-6 {
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.sui-inline--gap-8 {
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.sui-inline--align-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
.sui-inline--align-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sui-inline--align-center {
|
||||
align-items: center;
|
||||
}
|
||||
.sui-inline--align-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.sui-inline--align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.sui-inline--justify-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.sui-inline--justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.sui-inline--justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.sui-inline--justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sui-inline--justify-around {
|
||||
justify-content: space-around;
|
||||
}
|
||||
.sui-inline--justify-evenly {
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Inline } from "@shared/components/Inline";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { StatusBadge } from "@shared/components/StatusBadge";
|
||||
|
||||
const meta: Meta<typeof Inline> = {
|
||||
title: "Primitives/Layout/Inline",
|
||||
component: Inline,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Inline>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Inline gap="2">
|
||||
<Button variant="gradient">Primary</Button>
|
||||
<Button variant="outline">Secondary</Button>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</Inline>
|
||||
),
|
||||
};
|
||||
|
||||
export const SpaceBetween: Story = {
|
||||
render: () => (
|
||||
<Inline
|
||||
justify="between"
|
||||
style={{
|
||||
width: "30rem",
|
||||
padding: 12,
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span>Pipeline name</span>
|
||||
<StatusBadge tone="success" pulse>
|
||||
healthy
|
||||
</StatusBadge>
|
||||
</Inline>
|
||||
),
|
||||
};
|
||||
|
||||
export const Wrap: Story = {
|
||||
render: () => (
|
||||
<Inline gap="1" style={{ maxWidth: "20rem" }}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<StatusBadge key={i} tone="info" size="sm">
|
||||
chip-{i + 1}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</Inline>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ElementType, ReactNode, HTMLAttributes } from "react";
|
||||
import type {
|
||||
StackGap,
|
||||
StackAlign,
|
||||
StackJustify,
|
||||
} from "@shared/components/Stack";
|
||||
import "@shared/components/Inline.css";
|
||||
|
||||
export interface InlineProps extends HTMLAttributes<HTMLElement> {
|
||||
/** Token-aligned gap between children (maps to `--space-*`). */
|
||||
gap?: StackGap;
|
||||
align?: StackAlign;
|
||||
justify?: StackJustify;
|
||||
/** Wrap to a new line when children overflow. Defaults to true. */
|
||||
wrap?: boolean;
|
||||
as?: ElementType;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal flex row with token-aligned gap. Sister to {@link Stack}.
|
||||
* Wraps by default — pass `wrap={false}` for a strict single-line layout.
|
||||
*/
|
||||
export function Inline({
|
||||
gap = "2",
|
||||
align = "center",
|
||||
justify,
|
||||
wrap = true,
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: InlineProps) {
|
||||
const Tag: ElementType = as ?? "div";
|
||||
const classes = [
|
||||
"sui-inline",
|
||||
`sui-inline--gap-${gap}`,
|
||||
`sui-inline--align-${align}`,
|
||||
justify ? `sui-inline--justify-${justify}` : "",
|
||||
wrap ? "" : "sui-inline--nowrap",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<Tag className={classes} {...rest}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
.sui-input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-1);
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
box-shadow var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-input:focus-within {
|
||||
border-color: var(--color-blue);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
|
||||
}
|
||||
|
||||
.sui-input--sm {
|
||||
min-height: 1.75rem;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
.sui-input--md {
|
||||
min-height: 2.25rem;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.sui-input--invalid {
|
||||
border-color: var(--color-red);
|
||||
}
|
||||
.sui-input--invalid:focus-within {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-red) 16%, transparent);
|
||||
}
|
||||
|
||||
.sui-input--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sui-input__el {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sui-input--sm .sui-input__el {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.sui-input__el::placeholder {
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
|
||||
.sui-input__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
|
||||
import "@shared/components/Input.css";
|
||||
|
||||
export type InputSize = "sm" | "md";
|
||||
|
||||
export interface InputProps extends Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
"size"
|
||||
> {
|
||||
/** Visual size. Distinct from the HTML `size` attribute (which is for character width). */
|
||||
inputSize?: InputSize;
|
||||
leadingIcon?: ReactNode;
|
||||
trailingIcon?: ReactNode;
|
||||
/** Force the error tone independently of FormField. */
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ inputSize = "md", leadingIcon, trailingIcon, invalid, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"sui-input",
|
||||
`sui-input--${inputSize}`,
|
||||
invalid ? "sui-input--invalid" : "",
|
||||
rest.disabled ? "sui-input--disabled" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{leadingIcon && (
|
||||
<span className="sui-input__icon sui-input__icon--leading" aria-hidden>
|
||||
{leadingIcon}
|
||||
</span>
|
||||
)}
|
||||
<input ref={ref} className="sui-input__el" {...rest} />
|
||||
{trailingIcon && (
|
||||
<span className="sui-input__icon sui-input__icon--trailing" aria-hidden>
|
||||
{trailingIcon}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
.sui-method {
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sui-method--get {
|
||||
color: var(--color-green);
|
||||
background: var(--color-green-light);
|
||||
border-color: var(--color-green-border);
|
||||
}
|
||||
.sui-method--post {
|
||||
color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
border-color: var(--color-blue-border);
|
||||
}
|
||||
.sui-method--put {
|
||||
color: var(--color-amber-dark);
|
||||
background: var(--color-amber-light);
|
||||
border-color: var(--color-amber-border);
|
||||
}
|
||||
.sui-method--patch {
|
||||
color: var(--color-purple);
|
||||
background: var(--color-purple-light);
|
||||
border-color: var(--color-purple-border);
|
||||
}
|
||||
.sui-method--delete {
|
||||
color: var(--color-red);
|
||||
background: var(--color-red-light);
|
||||
border-color: var(--color-red-border);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MethodBadge, type HttpMethod } from "@shared/components/MethodBadge";
|
||||
|
||||
const meta: Meta<typeof MethodBadge> = {
|
||||
title: "Primitives/MethodBadge",
|
||||
component: MethodBadge,
|
||||
parameters: { layout: "centered" },
|
||||
args: { method: "POST" },
|
||||
argTypes: {
|
||||
method: {
|
||||
control: "inline-radio",
|
||||
options: ["GET", "POST", "PUT", "PATCH", "DELETE"] satisfies HttpMethod[],
|
||||
},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MethodBadge>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const InRow: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<MethodBadge method="POST" />
|
||||
<span
|
||||
style={{ fontFamily: "var(--font-mono)", color: "var(--color-text-2)" }}
|
||||
>
|
||||
/v1/coi
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Matrix: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["GET", "POST", "PUT", "PATCH", "DELETE"] as const).map((m) => (
|
||||
<MethodBadge key={m} method={m} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import "@shared/components/MethodBadge.css";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export interface MethodBadgeProps {
|
||||
method: HttpMethod;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method chip used in the docs and the pipeline row to label HTTP verbs.
|
||||
* Colours match the prototype: GET green, POST blue, PUT amber, DELETE red.
|
||||
*/
|
||||
export function MethodBadge({ method, className }: MethodBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"sui-method",
|
||||
`sui-method--${method.toLowerCase()}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{method}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
.sui-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.125rem 1.25rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition:
|
||||
box-shadow var(--motion-fast),
|
||||
transform var(--motion-fast),
|
||||
border-color var(--motion-fast);
|
||||
min-width: 10rem;
|
||||
/* When rendered as <button> in the interactive variant, neutralise UA chrome. */
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.sui-metric--primary {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
.sui-metric--interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sui-metric--interactive:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-0.0625rem);
|
||||
}
|
||||
.sui-metric--interactive:focus-visible {
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sui-metric__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.sui-metric__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.sui-metric__icon {
|
||||
color: var(--color-text-5);
|
||||
display: inline-flex;
|
||||
}
|
||||
.sui-metric__value {
|
||||
font-size: 1.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.sui-metric__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.sui-metric__delta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.1875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sui-metric__delta-arrow {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.sui-metric__delta--up {
|
||||
color: var(--color-green);
|
||||
}
|
||||
.sui-metric__delta--down {
|
||||
color: var(--color-red);
|
||||
}
|
||||
.sui-metric__delta--flat {
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
.sui-metric__desc {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MetricCard } from "@shared/components/MetricCard";
|
||||
|
||||
const meta: Meta<typeof MetricCard> = {
|
||||
title: "Primitives/MetricCard",
|
||||
component: MetricCard,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
label: "Docs / 24h",
|
||||
value: "2,481",
|
||||
delta: 0.07,
|
||||
emphasis: "default",
|
||||
},
|
||||
argTypes: {
|
||||
emphasis: { control: "inline-radio", options: ["default", "primary"] },
|
||||
deltaDirection: {
|
||||
control: "inline-radio",
|
||||
options: ["up", "down", "flat", undefined],
|
||||
},
|
||||
delta: { control: { type: "number", step: 0.01 } },
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ width: "16rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MetricCard>;
|
||||
|
||||
/** Flip value / delta / description / emphasis in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const ProTierStrip: Story = {
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ width: "100%" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<MetricCard label="Docs / 30d" value="76,318" delta={0.12} />
|
||||
<MetricCard label="Pipelines" value={12} delta={0.16} />
|
||||
<MetricCard label="Agents active" value={7} delta={0.4} />
|
||||
<MetricCard label="Eval pass rate" value="94.6%" delta={0.02} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const FreeTierStrip: Story = {
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ width: "100%" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<MetricCard
|
||||
label="Docs processed"
|
||||
value="247 / 500"
|
||||
description="Free plan cap"
|
||||
/>
|
||||
<MetricCard label="Operations" value={189} />
|
||||
<MetricCard label="Pipelines" value={3} />
|
||||
<MetricCard label="Agents" value={1} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/MetricCard.css";
|
||||
|
||||
export type DeltaDirection = "up" | "down" | "flat";
|
||||
|
||||
export interface MetricCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
/** Optional description shown under the delta line. */
|
||||
description?: string;
|
||||
/** Numeric delta as a fraction (0.12 = +12%). The sign drives direction unless `deltaDirection` is set. */
|
||||
delta?: number;
|
||||
/** Override the inferred direction — useful when you only want the colour, not the value. */
|
||||
deltaDirection?: DeltaDirection;
|
||||
/** Visual emphasis. `primary` = darker surface, used for hero metrics. */
|
||||
emphasis?: "default" | "primary";
|
||||
/** Optional icon shown in the top-right corner. */
|
||||
icon?: ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function inferDirection(delta?: number): DeltaDirection {
|
||||
if (delta === undefined || delta === 0) return "flat";
|
||||
return delta > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
function formatDelta(delta: number) {
|
||||
const pct = Math.round(Math.abs(delta) * 100);
|
||||
return `${pct}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stirling's standard KPI card. Used on Home, Sources, Documents, Audit and
|
||||
* every Infrastructure tab — the prototype calls these the metric strip.
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
delta,
|
||||
deltaDirection,
|
||||
emphasis = "default",
|
||||
icon,
|
||||
onClick,
|
||||
className,
|
||||
}: MetricCardProps) {
|
||||
const dir = deltaDirection ?? inferDirection(delta);
|
||||
const interactive = !!onClick;
|
||||
|
||||
const classes = [
|
||||
"sui-metric",
|
||||
emphasis === "primary" ? "sui-metric--primary" : "",
|
||||
interactive ? "sui-metric--interactive" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="sui-metric__header">
|
||||
<span className="sui-metric__label">{label}</span>
|
||||
{icon && (
|
||||
<span className="sui-metric__icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sui-metric__value">{value}</div>
|
||||
{(delta !== undefined || description) && (
|
||||
<div className="sui-metric__footer">
|
||||
{delta !== undefined && (
|
||||
<span className={`sui-metric__delta sui-metric__delta--${dir}`}>
|
||||
<span className="sui-metric__delta-arrow" aria-hidden>
|
||||
{dir === "up" ? "↑" : dir === "down" ? "↓" : "·"}
|
||||
</span>
|
||||
{formatDelta(delta)}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="sui-metric__desc">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (interactive) {
|
||||
return (
|
||||
<button type="button" className={classes} onClick={onClick}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={classes}>{body}</div>;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
.sui-modal__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 5rem 1.5rem 1.5rem;
|
||||
z-index: 100;
|
||||
animation: fadeIn 0.18s ease both;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.sui-modal {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow:
|
||||
0 1.25rem 3rem rgba(15, 23, 42, 0.35),
|
||||
0 0 0 1px var(--color-border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 6.5rem);
|
||||
overflow: hidden;
|
||||
animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.sui-modal--sm {
|
||||
max-width: 24rem;
|
||||
}
|
||||
.sui-modal--md {
|
||||
max-width: 32rem;
|
||||
}
|
||||
.sui-modal--lg {
|
||||
max-width: 48rem;
|
||||
}
|
||||
.sui-modal--xl {
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.sui-modal__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.125rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.sui-modal__header-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sui-modal__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.sui-modal__sub {
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.sui-modal__close {
|
||||
flex: 0 0 auto;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-4);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-modal__close:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.sui-modal__body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.125rem 1.125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.sui-modal__footer {
|
||||
padding: 0.875rem 1.125rem;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "@shared/components/Modal.css";
|
||||
|
||||
export type ModalWidth = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Optional heading slot rendered above the body. */
|
||||
title?: ReactNode;
|
||||
/** Optional sub-heading rendered under the title. */
|
||||
subtitle?: ReactNode;
|
||||
/** Optional footer slot rendered below the body. */
|
||||
footer?: ReactNode;
|
||||
/** Width preset. sm=24rem, md=32rem, lg=48rem, xl=64rem. Defaults to md. */
|
||||
width?: ModalWidth;
|
||||
/** Disable click-on-backdrop dismissal. Defaults to false. */
|
||||
disableBackdropClose?: boolean;
|
||||
/** Disable Escape-key dismissal. Defaults to false. */
|
||||
disableEscapeClose?: boolean;
|
||||
/** Accessible name when no visible `title` is provided. */
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Portal-rendered modal. Backdrop fades in, dialog scales in. ESC and
|
||||
* click-on-backdrop close by default. Each instance is self-contained — the
|
||||
* caller owns the open state and the close handler.
|
||||
*
|
||||
* Focus is trapped inside the dialog while open and returned to the
|
||||
* previously-focused element on close.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
footer,
|
||||
width = "md",
|
||||
disableBackdropClose = false,
|
||||
disableEscapeClose = false,
|
||||
ariaLabel,
|
||||
className,
|
||||
children,
|
||||
}: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || disableEscapeClose) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, disableEscapeClose, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const dialog = dialogRef.current;
|
||||
const first = dialog?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
first?.focus();
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab" || !dialog) return;
|
||||
const focusables =
|
||||
dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
if (focusables.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const firstEl = focusables[0];
|
||||
const lastEl = focusables[focusables.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && active === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
} else if (!e.shiftKey && active === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function onBackdropClick() {
|
||||
if (!disableBackdropClose) onClose();
|
||||
}
|
||||
|
||||
const hasTitle = title !== undefined && title !== null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="sui-modal__backdrop"
|
||||
onClick={onBackdropClick}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={["sui-modal", `sui-modal--${width}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={hasTitle ? titleId : undefined}
|
||||
aria-label={!hasTitle ? ariaLabel : undefined}
|
||||
>
|
||||
{(title || subtitle) && (
|
||||
<header className="sui-modal__header">
|
||||
<div className="sui-modal__header-text">
|
||||
{title && (
|
||||
<div id={titleId} className="sui-modal__title">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && <div className="sui-modal__sub">{subtitle}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sui-modal__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
<div className="sui-modal__body">{children}</div>
|
||||
{footer && <footer className="sui-modal__footer">{footer}</footer>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.sui-navitem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
margin: 0.0625rem 0.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-nav-text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
text-align: left;
|
||||
}
|
||||
.sui-navitem:hover {
|
||||
background: var(--color-nav-hover);
|
||||
color: var(--color-nav-hover-text);
|
||||
}
|
||||
.sui-navitem.is-active {
|
||||
background: var(--color-nav-active);
|
||||
color: var(--color-nav-active-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sui-navitem.is-active:hover {
|
||||
background: var(--color-nav-active);
|
||||
}
|
||||
.sui-navitem__icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sui-navitem__label {
|
||||
flex: 1;
|
||||
}
|
||||
.sui-navitem__trailing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.sui-navitem:focus-visible {
|
||||
outline: 0.125rem solid var(--color-blue);
|
||||
outline-offset: 0.125rem;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { NavItem } from "@shared/components/NavItem";
|
||||
import { SectionDivider } from "@shared/components/SectionDivider";
|
||||
|
||||
function Dot({ color = "var(--color-blue)" }: { color?: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
background: color,
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof NavItem> = {
|
||||
title: "Primitives/NavItem",
|
||||
component: NavItem,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: { id: "home", label: "Home", isActive: false },
|
||||
argTypes: { isActive: { control: "boolean" } },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
width: "15rem",
|
||||
background: "var(--color-sidebar-bg)",
|
||||
padding: 10,
|
||||
border: "1px solid var(--color-sidebar-border)",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NavItem>;
|
||||
|
||||
/** Flip isActive / label / icon / trailing in controls. */
|
||||
export const Playground: Story = {
|
||||
args: { icon: <Dot /> },
|
||||
};
|
||||
|
||||
export const WithTrailingBadge: Story = {
|
||||
args: {
|
||||
icon: <Dot />,
|
||||
trailing: (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-blue)",
|
||||
background: "var(--color-blue-light)",
|
||||
padding: "1px 6px",
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
3
|
||||
</span>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const InContext_SidebarGroup: Story = {
|
||||
render: () => {
|
||||
function Bound() {
|
||||
const [active, setActive] = useState("pipelines");
|
||||
const items = [
|
||||
{ id: "home", label: "Home" },
|
||||
{ id: "editor", label: "Editor" },
|
||||
{ id: "sources", label: "Sources" },
|
||||
{ id: "pipelines", label: "Pipelines" },
|
||||
{ id: "documents", label: "Documents" },
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<NavItem
|
||||
id="home"
|
||||
label="Home"
|
||||
icon={<Dot />}
|
||||
isActive={active === "home"}
|
||||
onClick={setActive}
|
||||
/>
|
||||
<SectionDivider />
|
||||
{items.slice(1).map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
icon={<Dot color="var(--color-purple)" />}
|
||||
isActive={active === item.id}
|
||||
onClick={setActive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/NavItem.css";
|
||||
|
||||
export interface NavItemProps {
|
||||
/** Stable view id passed to the click handler. */
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
/** Show the active highlight (navActive background, navActiveText colour). */
|
||||
isActive?: boolean;
|
||||
/** Optional trailing badge (e.g. unread count, "new"). */
|
||||
trailing?: ReactNode;
|
||||
onClick?: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar navigation row matching the prototype's hover + active styling.
|
||||
*
|
||||
* Active styling: navActive background, navActiveText colour, weight 500.
|
||||
* Hover styling: navHover background, navHoverText colour (only when not
|
||||
* already active).
|
||||
*/
|
||||
export function NavItem({
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
trailing,
|
||||
onClick,
|
||||
className,
|
||||
}: NavItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(id)}
|
||||
className={["sui-navitem", isActive ? "is-active" : "", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{icon && (
|
||||
<span className="sui-navitem__icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="sui-navitem__label">{label}</span>
|
||||
{trailing && <span className="sui-navitem__trailing">{trailing}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.sui-panelhdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.sui-panelhdr__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-panelhdr__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-3);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.sui-panelhdr__back:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.sui-panelhdr__text {
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-panelhdr__title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.sui-panelhdr__sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.sui-panelhdr__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { StatusBadge } from "@shared/components/StatusBadge";
|
||||
|
||||
const meta: Meta<typeof PanelHeader> = {
|
||||
title: "Primitives/PanelHeader",
|
||||
component: PanelHeader,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
title: "Pipeline detail",
|
||||
subtitle: "COI Compliance · us-east-1",
|
||||
},
|
||||
argTypes: { onBack: { action: "back" } },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PanelHeader>;
|
||||
|
||||
/** Toggle title / subtitle / onBack / actions in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithActions: Story = {
|
||||
args: {
|
||||
subtitle: "Last deploy 14m ago · golden set 48/48",
|
||||
actions: (
|
||||
<>
|
||||
<StatusBadge tone="success" pulse>
|
||||
Healthy
|
||||
</StatusBadge>
|
||||
<Button size="sm" variant="outline">
|
||||
Edit composition
|
||||
</Button>
|
||||
<Button size="sm" variant="gradient">
|
||||
View runs
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Everything: Story = {
|
||||
args: {
|
||||
title: "Pipeline detail — COI Compliance",
|
||||
subtitle: "Forked from Compliance Pack · 1,287 docs / 24h",
|
||||
onBack: () => {},
|
||||
actions: (
|
||||
<>
|
||||
<StatusBadge tone="success" pulse>
|
||||
Healthy
|
||||
</StatusBadge>
|
||||
<Button size="sm" variant="outline">
|
||||
Edit composition
|
||||
</Button>
|
||||
<Button size="sm" variant="gradient">
|
||||
View runs
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/PanelHeader.css";
|
||||
|
||||
export interface PanelHeaderProps {
|
||||
title: ReactNode;
|
||||
/** Sub-heading below the title. */
|
||||
subtitle?: ReactNode;
|
||||
/** Show a back chevron and trigger this callback when clicked. */
|
||||
onBack?: () => void;
|
||||
/** Right-aligned action buttons / chips. */
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header strip used by drill-down panels (admin tabs, agent detail, settings
|
||||
* sub-pages). Back chevron renders only when `onBack` is supplied.
|
||||
*/
|
||||
export function PanelHeader({
|
||||
title,
|
||||
subtitle,
|
||||
onBack,
|
||||
actions,
|
||||
className,
|
||||
}: PanelHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={["sui-panelhdr", className ?? ""].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div className="sui-panelhdr__left">
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-panelhdr__back"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={18}
|
||||
height={18}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="sui-panelhdr__text">
|
||||
<div className="sui-panelhdr__title">{title}</div>
|
||||
{subtitle && <div className="sui-panelhdr__sub">{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="sui-panelhdr__actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.sui-progress {
|
||||
width: 100%;
|
||||
background: var(--color-usage-track);
|
||||
border-radius: 62.4375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sui-progress__fill {
|
||||
height: 100%;
|
||||
border-radius: 62.4375rem;
|
||||
transition:
|
||||
width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background var(--motion-base);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ProgressBar } from "@shared/components/ProgressBar";
|
||||
|
||||
const meta: Meta<typeof ProgressBar> = {
|
||||
title: "Primitives/ProgressBar",
|
||||
component: ProgressBar,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: { value: 0.5, height: 6, thresholded: false },
|
||||
argTypes: {
|
||||
value: { control: { type: "range", min: 0, max: 1, step: 0.01 } },
|
||||
height: { control: { type: "number" } },
|
||||
thresholded: { control: "boolean" },
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ width: "24rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProgressBar>;
|
||||
|
||||
/** Drag the value slider, toggle thresholded, change height in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const ThresholdLadder: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{[0, 0.25, 0.5, 0.79, 0.85, 0.95, 0.98, 1].map((v) => (
|
||||
<div
|
||||
key={v}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "3rem 1fr",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{Math.round(v * 100)}%
|
||||
</span>
|
||||
<ProgressBar value={v} thresholded />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InContext_UsageMeter: Story = {
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
width: "16rem",
|
||||
padding: 14,
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: () => (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 12,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--color-text-4)" }}>Docs processed</span>
|
||||
<span style={{ color: "var(--color-text-2)", fontWeight: 500 }}>
|
||||
412 / 500
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={0.824} thresholded label="Docs processed" />
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 11,
|
||||
color: "var(--color-amber-dark)",
|
||||
}}
|
||||
>
|
||||
Approaching the free-plan cap
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import "@shared/components/ProgressBar.css";
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** 0–1. Values outside the range are clamped. */
|
||||
value: number;
|
||||
/** Height in pixels. Defaults to 6. */
|
||||
height?: number;
|
||||
/** When set, colour shifts to amber at 80% and red at 96% — the prototype's usage-meter behaviour. */
|
||||
thresholded?: boolean;
|
||||
/** Optional override colour (CSS gradient or solid). Disables threshold behaviour. */
|
||||
color?: string;
|
||||
className?: string;
|
||||
/** Accessible label for screen readers. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function clamp01(n: number) {
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress bar with optional threshold-based colouring.
|
||||
*
|
||||
* The prototype's sidebar usage meter uses `thresholded` so the bar turns
|
||||
* amber at 80% and red at 96% — a small visual hint that drives upgrade
|
||||
* conversion. Pipeline progress / storage bars typically pass a fixed colour.
|
||||
*/
|
||||
export function ProgressBar({
|
||||
value,
|
||||
height = 6,
|
||||
thresholded = false,
|
||||
color,
|
||||
className,
|
||||
label,
|
||||
}: ProgressBarProps) {
|
||||
const v = clamp01(value);
|
||||
let fill = color;
|
||||
if (!fill) {
|
||||
// Second stop is a lighter mix of the same token so the gradient keeps its
|
||||
// sheen in both themes (a hardcoded hex here flattened to a solid bar in
|
||||
// dark mode, where the token already equalled that hex).
|
||||
if (thresholded) {
|
||||
fill =
|
||||
v >= 0.96
|
||||
? "linear-gradient(90deg, var(--color-red), color-mix(in srgb, var(--color-red) 70%, white))"
|
||||
: v >= 0.8
|
||||
? "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))"
|
||||
: "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))";
|
||||
} else {
|
||||
fill =
|
||||
"linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))";
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={["sui-progress", className ?? ""].filter(Boolean).join(" ")}
|
||||
style={{ height }}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(v * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label}
|
||||
>
|
||||
<div
|
||||
className="sui-progress__fill"
|
||||
style={{ width: `${v * 100}%`, background: fill }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.sui-radio-group {
|
||||
display: flex;
|
||||
}
|
||||
.sui-radio-group--vertical {
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.sui-radio-group--horizontal {
|
||||
flex-direction: row;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sui-radio {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.sui-radio--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sui-radio__input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.sui-radio__dot {
|
||||
flex: 0 0 auto;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.0625rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--color-border-hover);
|
||||
background: var(--color-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color var(--motion-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sui-radio__input:focus-visible + .sui-radio__dot {
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sui-radio__input:checked + .sui-radio__dot {
|
||||
border-color: var(--color-blue);
|
||||
}
|
||||
|
||||
.sui-radio__input:checked + .sui-radio__dot::after {
|
||||
content: "";
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-blue);
|
||||
}
|
||||
|
||||
.sui-radio__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sui-radio__label {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sui-radio__desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { type InputHTMLAttributes, type ReactNode } from "react";
|
||||
import "@shared/components/Radio.css";
|
||||
|
||||
export interface RadioOption<V extends string = string> {
|
||||
value: V;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface RadioGroupProps<V extends string = string> {
|
||||
name: string;
|
||||
value: V;
|
||||
onChange: (value: V) => void;
|
||||
options: RadioOption<V>[];
|
||||
/** Layout. `vertical` stacks options; `horizontal` lays them inline. */
|
||||
direction?: "vertical" | "horizontal";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Standalone single radio — usually consumed via {@link RadioGroup}. */
|
||||
export function Radio({
|
||||
label,
|
||||
description,
|
||||
className,
|
||||
...rest
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
|
||||
label?: ReactNode;
|
||||
description?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={[
|
||||
"sui-radio",
|
||||
rest.disabled ? "sui-radio--disabled" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<input type="radio" className="sui-radio__input" {...rest} />
|
||||
<span className="sui-radio__dot" aria-hidden />
|
||||
{(label || description) && (
|
||||
<span className="sui-radio__text">
|
||||
{label && <span className="sui-radio__label">{label}</span>}
|
||||
{description && (
|
||||
<span className="sui-radio__desc">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function RadioGroup<V extends string = string>({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
direction = "vertical",
|
||||
className,
|
||||
}: RadioGroupProps<V>) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
className={[
|
||||
"sui-radio-group",
|
||||
`sui-radio-group--${direction}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<Radio
|
||||
key={opt.value}
|
||||
name={name}
|
||||
value={opt.value}
|
||||
checked={opt.value === value}
|
||||
onChange={() => onChange(opt.value)}
|
||||
disabled={opt.disabled}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.sui-divider {
|
||||
height: 0.0625rem;
|
||||
background: var(--color-sidebar-divider);
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SectionDivider } from "@shared/components/SectionDivider";
|
||||
|
||||
const meta: Meta<typeof SectionDivider> = {
|
||||
title: "Primitives/SectionDivider",
|
||||
component: SectionDivider,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SectionDivider>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: "24rem" }}>
|
||||
<p style={{ color: "var(--color-text-3)" }}>Section above</p>
|
||||
<SectionDivider />
|
||||
<p style={{ color: "var(--color-text-3)" }}>Section below</p>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InContext_SidebarGroups: Story = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
width: "15rem",
|
||||
background: "var(--color-sidebar-bg)",
|
||||
padding: 10,
|
||||
border: "1px solid var(--color-sidebar-border)",
|
||||
borderRadius: 6,
|
||||
color: "var(--color-text-2)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "8px 10px" }}>Home</div>
|
||||
<SectionDivider />
|
||||
<div style={{ padding: "8px 10px" }}>Editor</div>
|
||||
<div style={{ padding: "8px 10px" }}>Sources</div>
|
||||
<div style={{ padding: "8px 10px" }}>Pipelines</div>
|
||||
<SectionDivider />
|
||||
<div style={{ padding: "8px 10px" }}>Infrastructure</div>
|
||||
<div style={{ padding: "8px 10px" }}>Usage</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import "@shared/components/SectionDivider.css";
|
||||
|
||||
export interface SectionDividerProps {
|
||||
/** Vertical margin in px. */
|
||||
spacing?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hairline divider used between sidebar groups and inside settings panels.
|
||||
* No section label — the prototype's deliberate Supabase pattern is to group
|
||||
* via the divider alone, without forcing a category noun onto the surfaces.
|
||||
*/
|
||||
export function SectionDivider({
|
||||
spacing = 12,
|
||||
className,
|
||||
}: SectionDividerProps) {
|
||||
return (
|
||||
<div
|
||||
className={["sui-divider", className ?? ""].filter(Boolean).join(" ")}
|
||||
style={{ margin: `${spacing}px 0` }}
|
||||
role="separator"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
.sui-select {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-1);
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
box-shadow var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-select:focus-within {
|
||||
border-color: var(--color-blue);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
|
||||
}
|
||||
|
||||
.sui-select--sm {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
.sui-select--md {
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.sui-select--invalid {
|
||||
border-color: var(--color-red);
|
||||
}
|
||||
.sui-select--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sui-select__el {
|
||||
flex: 1 1 auto;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
padding: 0 var(--space-6) 0 var(--space-3);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.sui-select--sm .sui-select__el {
|
||||
font-size: 0.8125rem;
|
||||
padding-left: var(--space-2);
|
||||
padding-right: var(--space-5);
|
||||
}
|
||||
|
||||
.sui-select__caret {
|
||||
position: absolute;
|
||||
right: var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { forwardRef, type SelectHTMLAttributes } from "react";
|
||||
import "@shared/components/Select.css";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type SelectSize = "sm" | "md";
|
||||
|
||||
export interface SelectProps extends Omit<
|
||||
SelectHTMLAttributes<HTMLSelectElement>,
|
||||
"size"
|
||||
> {
|
||||
inputSize?: SelectSize;
|
||||
options: SelectOption[];
|
||||
/** Optional placeholder rendered as a disabled first option. */
|
||||
placeholder?: string;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
function Select(
|
||||
{ inputSize = "md", options, placeholder, invalid, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"sui-select",
|
||||
`sui-select--${inputSize}`,
|
||||
invalid ? "sui-select--invalid" : "",
|
||||
rest.disabled ? "sui-select--disabled" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<select ref={ref} className="sui-select__el" {...rest}>
|
||||
{placeholder && (
|
||||
<option value="" disabled hidden>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="sui-select__caret" aria-hidden>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
.sui-skel {
|
||||
display: inline-block;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-muted) 0%,
|
||||
var(--color-bg-hover) 50%,
|
||||
var(--color-bg-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sui-skel--text {
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.sui-skel--rect {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.sui-skel--circle {
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.sui-skel-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Skeleton } from "@shared/components/Skeleton";
|
||||
|
||||
const meta: Meta<typeof Skeleton> = {
|
||||
title: "Primitives/Skeleton",
|
||||
component: Skeleton,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: { width: "20rem", shape: "text", lines: 1 },
|
||||
argTypes: {
|
||||
shape: { control: "inline-radio", options: ["text", "rect", "circle"] },
|
||||
lines: { control: { type: "number", min: 1, max: 8 } },
|
||||
width: { control: "text" },
|
||||
height: { control: "text" },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Skeleton>;
|
||||
|
||||
/** Flip shape / lines / width / height in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const InContext_TableRow: Story = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "12rem 1fr 5rem 5rem",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
width: "44rem",
|
||||
}}
|
||||
>
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InContext_ActivityFeed: Story = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
width: "26rem",
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 10 }}>
|
||||
<Skeleton shape="circle" width="2rem" height="2rem" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton lines={2} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import "@shared/components/Skeleton.css";
|
||||
|
||||
export interface SkeletonProps {
|
||||
/** Width as a CSS length (`100%`, `12rem`, …). Defaults to 100%. */
|
||||
width?: string | number;
|
||||
/** Height as a CSS length. Defaults to 0.75rem. */
|
||||
height?: string | number;
|
||||
/** Shape preset. `text` = pill-rounded line, `rect` = card / image area. */
|
||||
shape?: "text" | "rect" | "circle";
|
||||
/** For text shape, the number of stacked lines to render. */
|
||||
lines?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shimmering placeholder for content that hasn't loaded yet. Replaces the
|
||||
* ad-hoc `linear-gradient(...) shimmer 1.4s` blocks that were duplicated
|
||||
* across the deployed-pipelines table, the activity feed, and the doc-type
|
||||
* grid.
|
||||
*/
|
||||
export function Skeleton({
|
||||
width = "100%",
|
||||
height,
|
||||
shape = "text",
|
||||
lines = 1,
|
||||
className,
|
||||
}: SkeletonProps) {
|
||||
const dim = (v: string | number | undefined) =>
|
||||
typeof v === "number" ? `${v}px` : v;
|
||||
|
||||
if (shape === "text" && lines > 1) {
|
||||
return (
|
||||
<span
|
||||
className={["sui-skel-lines", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="sui-skel sui-skel--text"
|
||||
style={{
|
||||
// Last line is slightly shorter — feels more natural.
|
||||
width: i === lines - 1 ? "70%" : (dim(width) ?? "100%"),
|
||||
height: dim(height) ?? "0.75rem",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={["sui-skel", `sui-skel--${shape}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={{
|
||||
width: dim(width),
|
||||
height:
|
||||
dim(height) ??
|
||||
(shape === "text" ? "0.75rem" : shape === "circle" ? "2rem" : "6rem"),
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.sui-slider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sui-slider--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sui-slider__input {
|
||||
flex: 1 1 auto;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
height: 0.5rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-blue) 0%,
|
||||
var(--color-blue) var(--slider-pct),
|
||||
var(--color-bg-muted) var(--slider-pct),
|
||||
var(--color-bg-muted) 100%
|
||||
);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sui-slider__input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid var(--color-blue);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
transition: transform var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-slider__input::-moz-range-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid var(--color-blue);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sui-slider__input:focus-visible::-webkit-slider-thumb {
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sui-slider__input:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.sui-slider__value {
|
||||
flex: 0 0 auto;
|
||||
min-width: 3rem;
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||
import "@shared/components/Slider.css";
|
||||
|
||||
export interface SliderProps extends Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
"type" | "value" | "onChange"
|
||||
> {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
/** Optional formatter for the value pill (e.g. "0.85", "30 days"). */
|
||||
formatValue?: (value: number) => string;
|
||||
/** Show the right-aligned value badge. Defaults to true. */
|
||||
showValue?: boolean;
|
||||
}
|
||||
|
||||
export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
|
||||
{
|
||||
value,
|
||||
min = 0,
|
||||
max = 1,
|
||||
step = 0.01,
|
||||
onChange,
|
||||
formatValue,
|
||||
showValue = true,
|
||||
className,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const pct = ((value - min) / (max - min)) * 100;
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"sui-slider",
|
||||
rest.disabled ? "sui-slider--disabled" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={{ "--slider-pct": `${pct}%` } as React.CSSProperties}
|
||||
>
|
||||
<input
|
||||
ref={ref}
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="sui-slider__input"
|
||||
{...rest}
|
||||
/>
|
||||
{showValue && (
|
||||
<span className="sui-slider__value" aria-hidden>
|
||||
{formatValue ? formatValue(value) : value.toString()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
.sui-spinner {
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
animation: spin 0.7s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sui-spinner--xs {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
.sui-spinner--sm {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.sui-spinner--md {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
.sui-spinner--lg {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-width: 2.5px;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Spinner } from "@shared/components/Spinner";
|
||||
|
||||
const meta: Meta<typeof Spinner> = {
|
||||
title: "Primitives/Spinner",
|
||||
component: Spinner,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "centered" },
|
||||
args: { size: "md" },
|
||||
argTypes: {
|
||||
size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Spinner>;
|
||||
|
||||
/** Flip size in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const SizeRow: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 24, alignItems: "center" }}>
|
||||
<Spinner size="xs" />
|
||||
<Spinner size="sm" />
|
||||
<Spinner size="md" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InheritsColor: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 24 }}>
|
||||
<span style={{ color: "var(--color-blue)" }}>
|
||||
<Spinner />
|
||||
</span>
|
||||
<span style={{ color: "var(--color-green)" }}>
|
||||
<Spinner />
|
||||
</span>
|
||||
<span style={{ color: "var(--color-red)" }}>
|
||||
<Spinner />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import "@shared/components/Spinner.css";
|
||||
|
||||
export type SpinnerSize = "xs" | "sm" | "md" | "lg";
|
||||
|
||||
export interface SpinnerProps {
|
||||
size?: SpinnerSize;
|
||||
/** Optional accessible label for screen readers. */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circular spinner. Inherits `currentColor` so it picks up the surrounding
|
||||
* text colour — drop it into a Button, a banner, or a code header without
|
||||
* any extra styling.
|
||||
*/
|
||||
export function Spinner({ size = "md", label, className }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
aria-busy="true"
|
||||
className={["sui-spinner", `sui-spinner--${size}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.sui-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sui-stack--gap-0 {
|
||||
gap: var(--space-0);
|
||||
}
|
||||
.sui-stack--gap-0_5 {
|
||||
gap: var(--space-0_5);
|
||||
}
|
||||
.sui-stack--gap-1 {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.sui-stack--gap-1_5 {
|
||||
gap: var(--space-1_5);
|
||||
}
|
||||
.sui-stack--gap-2 {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.sui-stack--gap-3 {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.sui-stack--gap-4 {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.sui-stack--gap-5 {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.sui-stack--gap-6 {
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.sui-stack--gap-8 {
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.sui-stack--align-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
.sui-stack--align-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sui-stack--align-center {
|
||||
align-items: center;
|
||||
}
|
||||
.sui-stack--align-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.sui-stack--align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.sui-stack--justify-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.sui-stack--justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.sui-stack--justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.sui-stack--justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sui-stack--justify-around {
|
||||
justify-content: space-around;
|
||||
}
|
||||
.sui-stack--justify-evenly {
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.sui-stack--fill {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Stack } from "@shared/components/Stack";
|
||||
import { Inline } from "@shared/components/Inline";
|
||||
import { Card } from "@shared/components/Card";
|
||||
|
||||
const meta: Meta<typeof Stack> = {
|
||||
title: "Primitives/Layout/Stack",
|
||||
component: Stack,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Stack>;
|
||||
|
||||
function Box({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
background: "var(--color-bg-muted)",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Stack gap="2" style={{ width: "20rem" }}>
|
||||
<Box>One</Box>
|
||||
<Box>Two</Box>
|
||||
<Box>Three</Box>
|
||||
</Stack>
|
||||
),
|
||||
};
|
||||
|
||||
export const GapSizes: Story = {
|
||||
render: () => (
|
||||
<Inline gap="6" align="start">
|
||||
{(["1", "2", "4", "6"] as const).map((gap) => (
|
||||
<Stack key={gap} gap={gap}>
|
||||
<div style={{ fontSize: 11, color: "var(--color-text-4)" }}>
|
||||
gap {gap}
|
||||
</div>
|
||||
<Box>A</Box>
|
||||
<Box>B</Box>
|
||||
<Box>C</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Inline>
|
||||
),
|
||||
};
|
||||
|
||||
export const InCard: Story = {
|
||||
render: () => (
|
||||
<Card padding="loose" style={{ width: "20rem" }}>
|
||||
<Stack gap="3">
|
||||
<div style={{ fontWeight: 600 }}>Card title</div>
|
||||
<div style={{ color: "var(--color-text-3)", fontSize: 13 }}>
|
||||
Stack is the default vertical container — it's how you compose every
|
||||
card body, list, and form section.
|
||||
</div>
|
||||
<Box>Action row</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ElementType, ReactNode, HTMLAttributes } from "react";
|
||||
import "@shared/components/Stack.css";
|
||||
|
||||
export type StackGap =
|
||||
| "0"
|
||||
| "0_5"
|
||||
| "1"
|
||||
| "1_5"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "8";
|
||||
|
||||
export type StackAlign = "stretch" | "start" | "center" | "end" | "baseline";
|
||||
export type StackJustify =
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "between"
|
||||
| "around"
|
||||
| "evenly";
|
||||
|
||||
export interface StackProps extends HTMLAttributes<HTMLElement> {
|
||||
/** Token-aligned gap between children (maps to `--space-*`). */
|
||||
gap?: StackGap;
|
||||
align?: StackAlign;
|
||||
justify?: StackJustify;
|
||||
/** Stretch to fill parent height. */
|
||||
fill?: boolean;
|
||||
/** Render as a custom element (defaults to div). */
|
||||
as?: ElementType;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical flex column with token-aligned gap. Replaces inline
|
||||
* `style={{ display: 'flex', flexDirection: 'column', gap: ... }}` everywhere.
|
||||
*/
|
||||
export function Stack({
|
||||
gap = "2",
|
||||
align,
|
||||
justify,
|
||||
fill,
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: StackProps) {
|
||||
const Tag: ElementType = as ?? "div";
|
||||
const classes = [
|
||||
"sui-stack",
|
||||
`sui-stack--gap-${gap}`,
|
||||
align ? `sui-stack--align-${align}` : "",
|
||||
justify ? `sui-stack--justify-${justify}` : "",
|
||||
fill ? "sui-stack--fill" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<Tag className={classes} {...rest}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.sui-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
border: 1px solid transparent;
|
||||
line-height: 1;
|
||||
}
|
||||
.sui-status--sm {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
}
|
||||
.sui-status--md {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1875rem 0.625rem;
|
||||
}
|
||||
.sui-status--lg {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.3125rem 0.75rem;
|
||||
}
|
||||
|
||||
.sui-status__dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
position: relative;
|
||||
}
|
||||
.sui-status__dot--pulse::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -0.125rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
animation: pulseRing 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.sui-status--neutral {
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-bg-muted);
|
||||
border-color: var(--color-border-light);
|
||||
}
|
||||
.sui-status--success {
|
||||
color: var(--color-green);
|
||||
background: var(--color-green-light);
|
||||
border-color: var(--color-green-border);
|
||||
}
|
||||
.sui-status--warning {
|
||||
color: var(--color-amber-dark);
|
||||
background: var(--color-amber-light);
|
||||
border-color: var(--color-amber-border);
|
||||
}
|
||||
.sui-status--danger {
|
||||
color: var(--color-red);
|
||||
background: var(--color-red-light);
|
||||
border-color: var(--color-red-border);
|
||||
}
|
||||
.sui-status--info {
|
||||
color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
border-color: var(--color-blue-border);
|
||||
}
|
||||
.sui-status--purple {
|
||||
color: var(--color-purple);
|
||||
background: var(--color-purple-light);
|
||||
border-color: var(--color-purple-border);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { StatusBadge } from "@shared/components/StatusBadge";
|
||||
|
||||
const meta: Meta<typeof StatusBadge> = {
|
||||
title: "Primitives/StatusBadge",
|
||||
component: StatusBadge,
|
||||
parameters: { layout: "centered" },
|
||||
args: { children: "Healthy", tone: "success" },
|
||||
argTypes: {
|
||||
tone: {
|
||||
control: "inline-radio",
|
||||
options: ["neutral", "success", "warning", "danger", "info", "purple"],
|
||||
},
|
||||
size: { control: "inline-radio", options: ["sm", "md", "lg"] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof StatusBadge>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const AllTones: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<StatusBadge tone="success">Processed</StatusBadge>
|
||||
<StatusBadge tone="warning">Needs review</StatusBadge>
|
||||
<StatusBadge tone="danger">Escalated</StatusBadge>
|
||||
<StatusBadge tone="info">In review</StatusBadge>
|
||||
<StatusBadge tone="purple">Resolved</StatusBadge>
|
||||
<StatusBadge tone="neutral">Paused</StatusBadge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Live: Story = {
|
||||
args: { tone: "success", pulse: true, children: "Live" },
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<StatusBadge tone="info" size="sm">
|
||||
Small
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="info" size="md">
|
||||
Medium
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="info" size="lg">
|
||||
Large
|
||||
</StatusBadge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/StatusBadge.css";
|
||||
|
||||
export type StatusTone =
|
||||
| "neutral"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "info"
|
||||
| "purple";
|
||||
|
||||
export type StatusSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
tone?: StatusTone;
|
||||
size?: StatusSize;
|
||||
/** Show a leading coloured dot. */
|
||||
showDot?: boolean;
|
||||
/** Render the dot with a pulse animation (active / live indicator). */
|
||||
pulse?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline status pill used across surfaces — pipeline rows, document status,
|
||||
* deployments, audit logs. Tone maps to semantic meaning, not raw colour.
|
||||
*/
|
||||
export function StatusBadge({
|
||||
tone = "neutral",
|
||||
size = "md",
|
||||
showDot = true,
|
||||
pulse = false,
|
||||
children,
|
||||
className,
|
||||
}: StatusBadgeProps) {
|
||||
const cls = [
|
||||
"sui-status",
|
||||
`sui-status--${tone}`,
|
||||
`sui-status--${size}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<span className={cls}>
|
||||
{showDot && (
|
||||
<span
|
||||
className={`sui-status__dot${pulse ? " sui-status__dot--pulse" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="sui-status__label">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.sui-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sui-tabs--pill {
|
||||
}
|
||||
.sui-tabs--underline {
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
padding-bottom: var(--space-1);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sui-tabs__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1_5);
|
||||
padding: var(--space-1_5) var(--space-3);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
color var(--motion-fast),
|
||||
background var(--motion-fast),
|
||||
border-color var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-tabs__tab:hover:not(.is-disabled) {
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.sui-tabs__tab.is-disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Pill variant — rounded chip, filled tint when active. */
|
||||
.sui-tabs--pill .sui-tabs__tab {
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.sui-tabs--pill .sui-tabs__tab.is-active {
|
||||
color: var(--sui-tab-accent, var(--color-blue));
|
||||
background: var(--color-blue-light);
|
||||
border-color: var(--sui-tab-accent, var(--color-blue-border));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Underline variant — line-under, no background fill. */
|
||||
.sui-tabs--underline .sui-tabs__tab {
|
||||
border-radius: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding-bottom: var(--space-2);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.sui-tabs--underline .sui-tabs__tab.is-active {
|
||||
color: var(--sui-tab-accent, var(--color-blue));
|
||||
border-bottom-color: var(--sui-tab-accent, var(--color-blue));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sui-tabs__dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sui-tabs__count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.sui-tabs__tab.is-active .sui-tabs__count {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Tabs, type TabItem } from "@shared/components/Tabs";
|
||||
|
||||
const baseItems: TabItem[] = [
|
||||
{ key: "deployed", label: "Deployed", count: 6 },
|
||||
{ key: "templates", label: "Templates", count: 4 },
|
||||
{ key: "archive", label: "Archive", count: 0 },
|
||||
];
|
||||
|
||||
function Bound({
|
||||
items = baseItems,
|
||||
variant = "pill" as const,
|
||||
}: {
|
||||
items?: TabItem[];
|
||||
variant?: "pill" | "underline";
|
||||
}) {
|
||||
const [active, setActive] = useState(items[0].key);
|
||||
return (
|
||||
<Tabs
|
||||
items={items}
|
||||
activeKey={active}
|
||||
onChange={setActive}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Tabs> = {
|
||||
title: "Primitives/Tabs",
|
||||
component: Tabs,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: { variant: "pill" },
|
||||
argTypes: {
|
||||
variant: { control: "inline-radio", options: ["pill", "underline"] },
|
||||
},
|
||||
render: (args) => <Bound items={baseItems} variant={args.variant} />,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Tabs>;
|
||||
|
||||
/** Flip variant in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithDisabledTab: Story = {
|
||||
render: () => (
|
||||
<Bound
|
||||
items={[
|
||||
{ key: "a", label: "Available" },
|
||||
{ key: "b", label: "Coming soon", disabled: true },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const InContext_DocumentVerticals: Story = {
|
||||
render: () => (
|
||||
<Bound
|
||||
items={[
|
||||
{ key: "all", label: "All" },
|
||||
{
|
||||
key: "insurance",
|
||||
label: "Insurance",
|
||||
count: 7,
|
||||
accentColor: "#0ea5e9",
|
||||
dotColor: "#0ea5e9",
|
||||
},
|
||||
{
|
||||
key: "finance",
|
||||
label: "Finance",
|
||||
count: 7,
|
||||
accentColor: "#10b981",
|
||||
dotColor: "#10b981",
|
||||
},
|
||||
{
|
||||
key: "legal",
|
||||
label: "Legal",
|
||||
count: 6,
|
||||
accentColor: "#3B82F6",
|
||||
dotColor: "#3B82F6",
|
||||
},
|
||||
{
|
||||
key: "healthcare",
|
||||
label: "Healthcare",
|
||||
count: 6,
|
||||
accentColor: "#8B5CF6",
|
||||
dotColor: "#8B5CF6",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/Tabs.css";
|
||||
|
||||
export interface TabItem<K extends string = string> {
|
||||
/** Stable identifier for the tab. */
|
||||
key: K;
|
||||
label: ReactNode;
|
||||
/** Optional count badge shown to the right of the label. */
|
||||
count?: number;
|
||||
/** Accent colour applied when this tab is active (override per-tab). */
|
||||
accentColor?: string;
|
||||
/** Leading dot before the label (used by Document type grid). */
|
||||
dotColor?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface TabsProps<K extends string = string> {
|
||||
items: TabItem<K>[];
|
||||
activeKey: K;
|
||||
onChange: (key: K) => void;
|
||||
/** Visual treatment. `pill` is rounded-chip style; `underline` is line-under tabs. */
|
||||
variant?: "pill" | "underline";
|
||||
/** Accessible label for the tablist. */
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-row tab strip. Generic over the key type so callers get strongly
|
||||
* typed `onChange` handlers without casting.
|
||||
*/
|
||||
export function Tabs<K extends string = string>({
|
||||
items,
|
||||
activeKey,
|
||||
onChange,
|
||||
variant = "pill",
|
||||
ariaLabel,
|
||||
className,
|
||||
}: TabsProps<K>) {
|
||||
return (
|
||||
// role="group" + aria-pressed is the right pattern for a filter strip
|
||||
// that does not have paired tabpanels. Switching to ARIA tab roles would
|
||||
// require ArrowLeft / ArrowRight handling and per-tab tabpanel wiring per
|
||||
// the APG — out of scope for what this primitive actually does.
|
||||
<div
|
||||
className={["sui-tabs", `sui-tabs--${variant}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isActive = item.key === activeKey;
|
||||
const styleVars =
|
||||
isActive && item.accentColor
|
||||
? ({ "--sui-tab-accent": item.accentColor } as React.CSSProperties)
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
disabled={item.disabled}
|
||||
className={
|
||||
"sui-tabs__tab" +
|
||||
(isActive ? " is-active" : "") +
|
||||
(item.disabled ? " is-disabled" : "")
|
||||
}
|
||||
style={styleVars}
|
||||
onClick={() => onChange(item.key)}
|
||||
>
|
||||
{item.dotColor && (
|
||||
<span
|
||||
className="sui-tabs__dot"
|
||||
style={{ background: item.dotColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="sui-tabs__label">{item.label}</span>
|
||||
{item.count !== undefined && (
|
||||
<span className="sui-tabs__count">{item.count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.sui-toast-viewport {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
right: var(--space-6);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
pointer-events: none;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.sui-toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
}
|
||||
|
||||
.sui-toast--info {
|
||||
border-left-color: var(--color-blue);
|
||||
}
|
||||
.sui-toast--success {
|
||||
border-left-color: var(--color-green);
|
||||
}
|
||||
.sui-toast--warning {
|
||||
border-left-color: var(--color-amber);
|
||||
}
|
||||
.sui-toast--danger {
|
||||
border-left-color: var(--color-red);
|
||||
}
|
||||
|
||||
.sui-toast__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.sui-toast__title {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.sui-toast__desc {
|
||||
color: var(--color-text-3);
|
||||
margin-top: 0.125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sui-toast__close {
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-4);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.sui-toast__close:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ToastProvider, useToast } from "@shared/components/Toast";
|
||||
import { Button } from "@shared/components/Button";
|
||||
|
||||
function Trigger() {
|
||||
const { toast } = useToast();
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast({
|
||||
tone: "info",
|
||||
title: "Info",
|
||||
description: "Just so you know.",
|
||||
})
|
||||
}
|
||||
>
|
||||
Info
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast({
|
||||
tone: "success",
|
||||
title: "Deployed",
|
||||
description: "Prior Auth v3.1.0",
|
||||
})
|
||||
}
|
||||
>
|
||||
Success
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast({
|
||||
tone: "warning",
|
||||
title: "Approaching cap",
|
||||
description: "389k / 500k",
|
||||
})
|
||||
}
|
||||
>
|
||||
Warning
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast({
|
||||
tone: "danger",
|
||||
title: "Run failed",
|
||||
description: "Schema mismatch",
|
||||
})
|
||||
}
|
||||
>
|
||||
Danger
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Primitives/Toast",
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<ToastProvider>
|
||||
<S />
|
||||
</ToastProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Triggers: Story = { render: () => <Trigger /> };
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "@shared/components/Toast.css";
|
||||
|
||||
export type ToastTone = "info" | "success" | "warning" | "danger";
|
||||
|
||||
export interface ToastOptions {
|
||||
tone?: ToastTone;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Auto-dismiss timeout in ms. Defaults to 4000. Pass 0 to keep open. */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface ToastEntry extends ToastOptions {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (options: ToastOptions) => number;
|
||||
dismiss: (id: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [entries, setEntries] = useState<ToastEntry[]>([]);
|
||||
const nextIdRef = useRef(1);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback(
|
||||
(options: ToastOptions) => {
|
||||
const id = nextIdRef.current++;
|
||||
const entry: ToastEntry = {
|
||||
tone: "info",
|
||||
durationMs: 4000,
|
||||
...options,
|
||||
id,
|
||||
};
|
||||
setEntries((prev) => [...prev, entry]);
|
||||
if ((entry.durationMs ?? 0) > 0) {
|
||||
window.setTimeout(() => dismiss(id), entry.durationMs);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const value = useMemo<ToastContextValue>(
|
||||
() => ({ toast, dismiss }),
|
||||
[toast, dismiss],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<ToastViewport entries={entries} onDismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastViewport({
|
||||
entries,
|
||||
onDismiss,
|
||||
}: {
|
||||
entries: ToastEntry[];
|
||||
onDismiss: (id: number) => void;
|
||||
}) {
|
||||
// Render through a portal so toasts always sit above any in-flow stacking
|
||||
// context. SSR-safe by checking for document existence.
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="sui-toast-viewport"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<ToastItem key={entry.id} entry={entry} onDismiss={onDismiss} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({
|
||||
entry,
|
||||
onDismiss,
|
||||
}: {
|
||||
entry: ToastEntry;
|
||||
onDismiss: (id: number) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Allow Escape to dismiss the most recently focused toast.
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onDismiss(entry.id);
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [entry.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={`sui-toast sui-toast--${entry.tone ?? "info"}`}
|
||||
>
|
||||
<div className="sui-toast__body">
|
||||
{entry.title && <div className="sui-toast__title">{entry.title}</div>}
|
||||
{entry.description && (
|
||||
<div className="sui-toast__desc">{entry.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sui-toast__close"
|
||||
onClick={() => onDismiss(entry.id)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.sui-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.sui-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0.0625rem;
|
||||
height: 0.0625rem;
|
||||
}
|
||||
|
||||
.sui-toggle__track {
|
||||
display: inline-block;
|
||||
width: 2.25rem;
|
||||
height: 1.25rem;
|
||||
background: var(--color-toggle-off);
|
||||
border-radius: 0.75rem;
|
||||
position: relative;
|
||||
transition: background var(--motion-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sui-toggle__thumb {
|
||||
position: absolute;
|
||||
top: 0.125rem;
|
||||
left: 0.125rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
transition: transform var(--motion-base);
|
||||
}
|
||||
.sui-toggle input:checked + .sui-toggle__track {
|
||||
background: var(--color-blue);
|
||||
}
|
||||
.sui-toggle input:checked + .sui-toggle__track .sui-toggle__thumb {
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
.sui-toggle--sm .sui-toggle__track {
|
||||
width: 1.875rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.sui-toggle--sm .sui-toggle__thumb {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.sui-toggle--sm input:checked + .sui-toggle__track .sui-toggle__thumb {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
|
||||
.sui-toggle.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sui-toggle__text {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.sui-toggle__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.sui-toggle__desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.sui-toggle input:focus-visible + .sui-toggle__track {
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
|
||||
|
||||
const meta: Meta<typeof ToggleSwitch> = {
|
||||
title: "Primitives/ToggleSwitch",
|
||||
component: ToggleSwitch,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
checked: true,
|
||||
label: "Encryption at rest",
|
||||
size: "md",
|
||||
disabled: false,
|
||||
},
|
||||
argTypes: {
|
||||
size: { control: "inline-radio", options: ["sm", "md"] },
|
||||
disabled: { control: "boolean" },
|
||||
checked: { control: "boolean" },
|
||||
},
|
||||
render: (args) => {
|
||||
function Bound() {
|
||||
const [on, setOn] = useState(args.checked);
|
||||
return <ToggleSwitch {...args} checked={on} onChange={setOn} />;
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ToggleSwitch>;
|
||||
|
||||
/** Flip size / checked / disabled / label / description in controls. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
label: "Auto-promote on golden set pass",
|
||||
description:
|
||||
"When the eval set passes, the new version is promoted automatically.",
|
||||
},
|
||||
};
|
||||
|
||||
export const InContext_SettingsRows: Story = {
|
||||
parameters: { layout: "padded" },
|
||||
render: () => {
|
||||
function Bound() {
|
||||
const [a, setA] = useState(true);
|
||||
const [b, setB] = useState(true);
|
||||
const [c, setC] = useState(false);
|
||||
const [d, setD] = useState(false);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
maxWidth: "32rem",
|
||||
}}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={a}
|
||||
onChange={setA}
|
||||
label="Encryption at rest"
|
||||
description="AES-256 with Stirling-managed keys"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
checked={b}
|
||||
onChange={setB}
|
||||
label="Sign every output"
|
||||
description="Tamper-evident manifest covers artifact + run metadata"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
checked={c}
|
||||
onChange={setC}
|
||||
label="Mirror to S3"
|
||||
description="Copy sealed artifacts to your bucket for compliance archival"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
checked={d}
|
||||
onChange={setD}
|
||||
label="Send low-confidence to review"
|
||||
description="Confidence under 0.85 triggers human review"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useId } from "react";
|
||||
import "@shared/components/ToggleSwitch.css";
|
||||
|
||||
export interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Accessible label associated to the control. */
|
||||
label?: string;
|
||||
/** Optional helper text rendered next to the label. */
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "md";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 36×20 pill toggle, matched to the prototype's SettingsRow control.
|
||||
*
|
||||
* Pair with the {@link SettingsRow} component (or a custom row) when you need
|
||||
* a label + description layout. Standalone, this is just the switch itself.
|
||||
*/
|
||||
export function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
disabled,
|
||||
size = "md",
|
||||
id,
|
||||
}: ToggleSwitchProps) {
|
||||
const autoId = useId();
|
||||
const controlId = id ?? autoId;
|
||||
return (
|
||||
<label
|
||||
className={`sui-toggle sui-toggle--${size}${disabled ? " is-disabled" : ""}`}
|
||||
htmlFor={controlId}
|
||||
>
|
||||
<input
|
||||
id={controlId}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className="sui-toggle__track" aria-hidden>
|
||||
<span className="sui-toggle__thumb" />
|
||||
</span>
|
||||
{(label || description) && (
|
||||
<span className="sui-toggle__text">
|
||||
{label && <span className="sui-toggle__label">{label}</span>}
|
||||
{description && (
|
||||
<span className="sui-toggle__desc">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export * from "@shared/components/Button";
|
||||
export * from "@shared/components/StatusBadge";
|
||||
export * from "@shared/components/MethodBadge";
|
||||
export * from "@shared/components/ToggleSwitch";
|
||||
export * from "@shared/components/ProgressBar";
|
||||
export * from "@shared/components/MetricCard";
|
||||
export * from "@shared/components/NavItem";
|
||||
export * from "@shared/components/PanelHeader";
|
||||
export * from "@shared/components/CodeBlock";
|
||||
export * from "@shared/components/SectionDivider";
|
||||
export * from "@shared/components/Card";
|
||||
export * from "@shared/components/Modal";
|
||||
|
||||
// Layout
|
||||
export * from "@shared/components/Stack";
|
||||
export * from "@shared/components/Inline";
|
||||
|
||||
// Feedback
|
||||
export * from "@shared/components/Spinner";
|
||||
export * from "@shared/components/Skeleton";
|
||||
export * from "@shared/components/Avatar";
|
||||
export * from "@shared/components/Chip";
|
||||
export * from "@shared/components/EmptyState";
|
||||
export * from "@shared/components/Banner";
|
||||
export * from "@shared/components/Toast";
|
||||
|
||||
// Compound
|
||||
export * from "@shared/components/Tabs";
|
||||
export * from "@shared/components/Dropdown";
|
||||
export * from "@shared/components/Drawer";
|
||||
|
||||
// Forms
|
||||
export * from "@shared/components/FormField";
|
||||
export * from "@shared/components/Input";
|
||||
export * from "@shared/components/Select";
|
||||
export * from "@shared/components/Checkbox";
|
||||
export * from "@shared/components/Radio";
|
||||
export * from "@shared/components/Slider";
|
||||
Reference in New Issue
Block a user