mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
Portal: full mock-driven surfaces, demonolithed components, backend-ready mocks (#6686)
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useId, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FocusTrap } from "@mantine/core";
|
||||
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";
|
||||
|
||||
@@ -26,9 +24,10 @@ export interface DrawerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Side drawer — sibling to {@link Modal}, with our own brand shell. Tab focus
|
||||
* trapping, initial focus, and focus restoration on close are delegated to
|
||||
* Mantine's <FocusTrap>; we keep the slide-in chrome, scroll lock, and ESC /
|
||||
* backdrop dismissal.
|
||||
*/
|
||||
export function Drawer({
|
||||
open,
|
||||
@@ -44,7 +43,6 @@ export function Drawer({
|
||||
ariaLabel,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const dialogRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,40 +63,6 @@ export function Drawer({
|
||||
};
|
||||
}, [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() {
|
||||
@@ -114,57 +78,59 @@ export function Drawer({
|
||||
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
|
||||
<FocusTrap active>
|
||||
<aside
|
||||
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}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{(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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</FocusTrap>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.sui-metric-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.sui-metric-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MetricStrip } from "@shared/components/MetricStrip";
|
||||
import { MetricCard } from "@shared/components/MetricCard";
|
||||
|
||||
const meta: Meta<typeof MetricStrip> = {
|
||||
title: "Layout/MetricStrip",
|
||||
component: MetricStrip,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MetricStrip>;
|
||||
|
||||
/** Four-up KPI row; collapses to two columns below 50rem. */
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<MetricStrip>
|
||||
<MetricCard label="Docs / 30d" value="48,210" 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} />
|
||||
</MetricStrip>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/MetricStrip.css";
|
||||
|
||||
export interface MetricStripProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive grid wrapper for a row of {@link MetricCard}s — the prototype's
|
||||
* "metric strip" (Home, Sources, Usage, Infrastructure all use it). Four-up on
|
||||
* wide screens, two-up below 50rem.
|
||||
*/
|
||||
export function MetricStrip({ children, className }: MetricStripProps) {
|
||||
return (
|
||||
<div
|
||||
className={["sui-metric-strip", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useId, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FocusTrap } from "@mantine/core";
|
||||
import "@shared/components/Modal.css";
|
||||
|
||||
export type ModalWidth = "sm" | "md" | "lg" | "xl";
|
||||
@@ -25,16 +26,12 @@ export interface ModalProps {
|
||||
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.
|
||||
* Portal-rendered modal with our own brand shell (header / body / footer,
|
||||
* width presets, backdrop). The hard part — trapping Tab focus inside the
|
||||
* dialog, initial focus, and restoring focus to the opener on close — is
|
||||
* delegated to Mantine's <FocusTrap> rather than hand-rolled. ESC and
|
||||
* backdrop click close by default; the caller owns the open state.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
@@ -49,7 +46,6 @@ export function Modal({
|
||||
className,
|
||||
children,
|
||||
}: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,40 +66,6 @@ export function Modal({
|
||||
};
|
||||
}, [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() {
|
||||
@@ -118,53 +80,55 @@ export function Modal({
|
||||
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
|
||||
<FocusTrap active>
|
||||
<div
|
||||
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}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{(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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</FocusTrap>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.sui-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sui-stat__label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sui-stat__value {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sui-stat__value--success {
|
||||
color: var(--color-green-dark);
|
||||
}
|
||||
.sui-stat__value--warning {
|
||||
color: var(--color-amber-dark);
|
||||
}
|
||||
.sui-stat__value--danger {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
/* Inline code in a value (masked keys, model ids, URLs) reads as mono meta. */
|
||||
.sui-stat__value code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StatTile } from "@shared/components/StatTile";
|
||||
|
||||
const meta: Meta<typeof StatTile> = {
|
||||
title: "Primitives/StatTile",
|
||||
component: StatTile,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: { label: "Error rate", value: "0.4%", tone: "default" },
|
||||
argTypes: {
|
||||
tone: {
|
||||
control: "inline-radio",
|
||||
options: ["default", "success", "warning", "danger"],
|
||||
},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof StatTile>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const ToneRow: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 32 }}>
|
||||
<StatTile label="Uptime" value="99.98%" tone="success" />
|
||||
<StatTile label="Error rate" value="1.4%" tone="warning" />
|
||||
<StatTile label="Error rate" value="6.2%" tone="danger" />
|
||||
<StatTile label="P95 latency" value="412 ms" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/StatTile.css";
|
||||
|
||||
export type StatTileTone = "default" | "success" | "warning" | "danger";
|
||||
|
||||
export interface StatTileProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
/** Colours the value (e.g. error rate over threshold). */
|
||||
tone?: StatTileTone;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact label-over-value stat used inside detail panels, cards, and metric
|
||||
* grids (the small sibling of {@link MetricCard}). Value uses tabular figures
|
||||
* so columns of stats stay aligned.
|
||||
*/
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
tone = "default",
|
||||
className,
|
||||
}: StatTileProps) {
|
||||
return (
|
||||
<div className={["sui-stat", className ?? ""].filter(Boolean).join(" ")}>
|
||||
<span className="sui-stat__label">{label}</span>
|
||||
<span
|
||||
className={
|
||||
"sui-stat__value" +
|
||||
(tone !== "default" ? ` sui-stat__value--${tone}` : "")
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.sui-table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.sui-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.sui-table__th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-4);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sui-table__th--right,
|
||||
.sui-table__td--right {
|
||||
text-align: right;
|
||||
}
|
||||
.sui-table__th--center,
|
||||
.sui-table__td--center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sui-table__td {
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: var(--color-text-2);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sui-table tbody tr:last-child .sui-table__td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sui-table__row--interactive {
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-table__row--interactive:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
.sui-table__row--interactive:focus-visible {
|
||||
outline: 0.125rem solid var(--color-blue);
|
||||
outline-offset: -0.125rem;
|
||||
}
|
||||
|
||||
.sui-table__empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Table, type TableColumn } from "@shared/components/Table";
|
||||
import { StatusBadge } from "@shared/components/StatusBadge";
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: "healthy" | "degraded";
|
||||
docs: number;
|
||||
latency: string;
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "US East",
|
||||
code: "us-east-1",
|
||||
status: "healthy",
|
||||
docs: 12481,
|
||||
latency: "41 ms",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "US West",
|
||||
code: "us-west-2",
|
||||
status: "healthy",
|
||||
docs: 8210,
|
||||
latency: "63 ms",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "EU West",
|
||||
code: "eu-west-1",
|
||||
status: "degraded",
|
||||
docs: 3044,
|
||||
latency: "190 ms",
|
||||
},
|
||||
];
|
||||
|
||||
const COLUMNS: TableColumn<Region>[] = [
|
||||
{ key: "name", header: "Region", render: (r) => r.name },
|
||||
{
|
||||
key: "code",
|
||||
header: "Code",
|
||||
render: (r) => (
|
||||
<code style={{ fontFamily: "var(--font-mono)" }}>{r.code}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (r) => (
|
||||
<StatusBadge
|
||||
tone={r.status === "healthy" ? "success" : "warning"}
|
||||
size="sm"
|
||||
>
|
||||
{r.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
header: "Docs 24h",
|
||||
align: "right",
|
||||
render: (r) => r.docs.toLocaleString(),
|
||||
},
|
||||
{ key: "latency", header: "P95", align: "right", render: (r) => r.latency },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof Table> = {
|
||||
title: "Compound/Table",
|
||||
component: Table,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Table>;
|
||||
|
||||
/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<Table<Region> columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} />
|
||||
),
|
||||
};
|
||||
|
||||
/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */
|
||||
export const Interactive: Story = {
|
||||
render: () => (
|
||||
<Table<Region>
|
||||
columns={COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={() => {}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Empty body slot. */
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<Table<Region>
|
||||
columns={COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.id}
|
||||
empty="No regions deployed yet."
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/Table.css";
|
||||
|
||||
export interface TableColumn<T> {
|
||||
/** Stable column id. */
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
/** Cell renderer for a row. */
|
||||
render: (row: T) => ReactNode;
|
||||
align?: "left" | "right" | "center";
|
||||
/** Optional fixed/min width (any CSS length). */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface TableProps<T> {
|
||||
columns: TableColumn<T>[];
|
||||
rows: T[];
|
||||
/** Stable key per row. */
|
||||
rowKey: (row: T) => string;
|
||||
/** Makes rows interactive (hover + click + keyboard). */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** Rendered in place of the body when there are no rows. */
|
||||
empty?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal data table primitive. Columns own their own cell renderers, so the
|
||||
* table stays presentational — callers pre-sort/filter and pass the rows they
|
||||
* want shown. Rows become focusable buttons-in-disguise when `onRowClick` is
|
||||
* set.
|
||||
*/
|
||||
export function Table<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
empty,
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
const interactive = Boolean(onRowClick);
|
||||
return (
|
||||
<div
|
||||
className={["sui-table-wrap", className ?? ""].filter(Boolean).join(" ")}
|
||||
>
|
||||
<table className="sui-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
scope="col"
|
||||
className={`sui-table__th sui-table__th--${c.align ?? "left"}`}
|
||||
style={c.width ? { width: c.width } : undefined}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="sui-table__empty" colSpan={columns.length}>
|
||||
{empty ?? "No data"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={
|
||||
interactive
|
||||
? "sui-table__row sui-table__row--interactive"
|
||||
: "sui-table__row"
|
||||
}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
role={interactive ? "button" : undefined}
|
||||
onKeyDown={
|
||||
interactive
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick?.(row);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
|
||||
>
|
||||
{c.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export * from "@shared/components/Modal";
|
||||
// Layout
|
||||
export * from "@shared/components/Stack";
|
||||
export * from "@shared/components/Inline";
|
||||
export * from "@shared/components/MetricStrip";
|
||||
export * from "@shared/components/StatTile";
|
||||
|
||||
// Feedback
|
||||
export * from "@shared/components/Spinner";
|
||||
@@ -28,6 +30,7 @@ export * from "@shared/components/Toast";
|
||||
export * from "@shared/components/Tabs";
|
||||
export * from "@shared/components/Dropdown";
|
||||
export * from "@shared/components/Drawer";
|
||||
export * from "@shared/components/Table";
|
||||
|
||||
// Forms
|
||||
export * from "@shared/components/FormField";
|
||||
|
||||
Reference in New Issue
Block a user