feat(ui): add operational workflow primitives
This commit is contained in:
parent
aad91f3b9d
commit
4d172879f4
@ -16,6 +16,54 @@
|
||||
"types": "./dist/components/Button.d.ts",
|
||||
"import": "./dist/components/Button.js"
|
||||
},
|
||||
"./page-header": {
|
||||
"types": "./dist/components/PageHeader.d.ts",
|
||||
"import": "./dist/components/PageHeader.js"
|
||||
},
|
||||
"./section": {
|
||||
"types": "./dist/components/Section.d.ts",
|
||||
"import": "./dist/components/Section.js"
|
||||
},
|
||||
"./toolbar": {
|
||||
"types": "./dist/components/Toolbar.d.ts",
|
||||
"import": "./dist/components/Toolbar.js"
|
||||
},
|
||||
"./filter-bar": {
|
||||
"types": "./dist/components/FilterBar.d.ts",
|
||||
"import": "./dist/components/FilterBar.js"
|
||||
},
|
||||
"./form-section": {
|
||||
"types": "./dist/components/FormSection.d.ts",
|
||||
"import": "./dist/components/FormSection.js"
|
||||
},
|
||||
"./field-grid": {
|
||||
"types": "./dist/components/FieldGrid.d.ts",
|
||||
"import": "./dist/components/FieldGrid.js"
|
||||
},
|
||||
"./alert-banner": {
|
||||
"types": "./dist/components/AlertBanner.d.ts",
|
||||
"import": "./dist/components/AlertBanner.js"
|
||||
},
|
||||
"./skeleton": {
|
||||
"types": "./dist/components/Skeleton.d.ts",
|
||||
"import": "./dist/components/Skeleton.js"
|
||||
},
|
||||
"./entity-card": {
|
||||
"types": "./dist/components/EntityCard.d.ts",
|
||||
"import": "./dist/components/EntityCard.js"
|
||||
},
|
||||
"./metric-card": {
|
||||
"types": "./dist/components/MetricCard.d.ts",
|
||||
"import": "./dist/components/MetricCard.js"
|
||||
},
|
||||
"./action-menu": {
|
||||
"types": "./dist/components/ActionMenu.d.ts",
|
||||
"import": "./dist/components/ActionMenu.js"
|
||||
},
|
||||
"./drawer": {
|
||||
"types": "./dist/components/Drawer.d.ts",
|
||||
"import": "./dist/components/Drawer.js"
|
||||
},
|
||||
"./app-shell": {
|
||||
"types": "./dist/components/AppShell.d.ts",
|
||||
"import": "./dist/components/AppShell.js"
|
||||
|
||||
50
packages/ui/src/components/ActionMenu.tsx
Normal file
50
packages/ui/src/components/ActionMenu.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { Button } from './Button.js';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './DropdownMenu.js';
|
||||
|
||||
export interface ActionMenuItem {
|
||||
id: string;
|
||||
label: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export interface ActionMenuProps {
|
||||
label?: string;
|
||||
items: ActionMenuItem[];
|
||||
}
|
||||
|
||||
export function ActionMenu({ label = 'Open actions', items }: ActionMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" aria-label={label}>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{items.map(item => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
disabled={item.disabled}
|
||||
className={item.destructive ? 'text-[var(--bl-danger)]' : undefined}
|
||||
onSelect={item.onSelect}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
55
packages/ui/src/components/AlertBanner.tsx
Normal file
55
packages/ui/src/components/AlertBanner.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export type AlertBannerTone = 'info' | 'success' | 'warning' | 'error' | 'neutral';
|
||||
|
||||
export interface AlertBannerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
tone?: AlertBannerTone;
|
||||
title?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const toneClass: Record<AlertBannerTone, string> = {
|
||||
info: 'border-[var(--bl-info-border,var(--bl-border))] bg-[var(--bl-info-muted,var(--bl-surface-muted))] text-[var(--bl-info,var(--bl-accent))]',
|
||||
success:
|
||||
'border-[var(--bl-success-border,var(--bl-border))] bg-[var(--bl-success-muted,var(--bl-surface-muted))] text-[var(--bl-success)]',
|
||||
warning:
|
||||
'border-[var(--bl-warning-border,var(--bl-border))] bg-[var(--bl-warning-muted,var(--bl-surface-muted))] text-[var(--bl-warning)]',
|
||||
error:
|
||||
'border-[var(--bl-danger-border,var(--bl-border))] bg-[var(--bl-danger-muted,var(--bl-surface-muted))] text-[var(--bl-danger)]',
|
||||
neutral: 'border-[var(--bl-border)] bg-[var(--bl-surface-muted)] text-[var(--bl-text-secondary)]',
|
||||
};
|
||||
|
||||
const iconByTone: Record<AlertBannerTone, React.ReactNode> = {
|
||||
info: <Info className="h-4 w-4" aria-hidden="true" />,
|
||||
success: <CheckCircle2 className="h-4 w-4" aria-hidden="true" />,
|
||||
warning: <TriangleAlert className="h-4 w-4" aria-hidden="true" />,
|
||||
error: <AlertCircle className="h-4 w-4" aria-hidden="true" />,
|
||||
neutral: <Info className="h-4 w-4" aria-hidden="true" />,
|
||||
};
|
||||
|
||||
export function AlertBanner({
|
||||
tone = 'info',
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AlertBannerProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('flex gap-3 rounded-xl border px-4 py-3 text-sm', toneClass[tone], className)}
|
||||
role={tone === 'error' || tone === 'warning' ? 'alert' : 'status'}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">{icon ?? iconByTone[tone]}</div>
|
||||
<div className="min-w-0">
|
||||
{title && (
|
||||
<div className="font-semibold leading-5 text-[var(--bl-text-primary)]">{title}</div>
|
||||
)}
|
||||
<div className="leading-6 text-[var(--bl-text-secondary)]">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
packages/ui/src/components/Drawer.tsx
Normal file
56
packages/ui/src/components/Drawer.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Button } from './Button.js';
|
||||
|
||||
export interface DrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
side?: 'right' | 'left';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
side = 'right',
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-[9998] bg-black/50 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
className={clsx(
|
||||
'fixed top-0 z-[9999] flex h-dvh w-full max-w-xl flex-col border-[var(--bl-border)] bg-[var(--bl-bg-elevated)] text-[var(--bl-text-primary)] shadow-xl focus:outline-none',
|
||||
side === 'right' ? 'right-0 border-l' : 'left-0 border-r'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--bl-border)] p-5">
|
||||
<div className="min-w-0">
|
||||
<Dialog.Title className="m-0 text-lg font-semibold leading-7">{title}</Dialog.Title>
|
||||
{description && (
|
||||
<Dialog.Description className="mt-1 text-sm leading-6 text-[var(--bl-text-secondary)]">
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
)}
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<Button type="button" variant="ghost" size="sm" aria-label="Close drawer">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">{children}</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
63
packages/ui/src/components/EntityCard.tsx
Normal file
63
packages/ui/src/components/EntityCard.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import * as React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface EntityCardProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
eyebrow?: React.ReactNode;
|
||||
status?: React.ReactNode;
|
||||
metadata?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function EntityCard({
|
||||
title,
|
||||
subtitle,
|
||||
eyebrow,
|
||||
status,
|
||||
metadata,
|
||||
actions,
|
||||
footer,
|
||||
selected,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EntityCardProps) {
|
||||
return (
|
||||
<article
|
||||
className={clsx(
|
||||
'grid gap-4 rounded-xl border bg-[var(--bl-surface-card)] p-5 shadow-sm shadow-black/[0.03]',
|
||||
selected
|
||||
? 'border-[var(--bl-accent)] ring-2 ring-[var(--bl-focus-ring-muted)]'
|
||||
: 'border-[var(--bl-border)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{eyebrow && (
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-[0.08em] text-[var(--bl-text-secondary)]">
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="m-0 min-w-0 text-base font-semibold leading-6 text-[var(--bl-text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
{status}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm leading-6 text-[var(--bl-text-secondary)]">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{metadata && <div className="flex min-w-0 flex-wrap gap-2">{metadata}</div>}
|
||||
{children}
|
||||
{footer && <div className="border-t border-[var(--bl-border)] pt-3">{footer}</div>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/components/FieldGrid.tsx
Normal file
16
packages/ui/src/components/FieldGrid.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface FieldGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
columns?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
const columnClass: Record<NonNullable<FieldGridProps['columns']>, string> = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3',
|
||||
};
|
||||
|
||||
export function FieldGrid({ columns = 2, className, ...props }: FieldGridProps) {
|
||||
return <div className={clsx('grid min-w-0 gap-4', columnClass[columns], className)} {...props} />;
|
||||
}
|
||||
72
packages/ui/src/components/FilterBar.tsx
Normal file
72
packages/ui/src/components/FilterBar.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Button } from './Button.js';
|
||||
import { Input } from './Input.js';
|
||||
|
||||
export interface FilterBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
searchLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
filters?: React.ReactNode;
|
||||
chips?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
searchLabel = 'Search',
|
||||
searchPlaceholder = 'Search...',
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
filters,
|
||||
chips,
|
||||
actions,
|
||||
onReset,
|
||||
className,
|
||||
...props
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'grid gap-3 rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3 shadow-sm shadow-black/[0.03]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-2 lg:flex-row lg:items-center">
|
||||
{onSearchChange && (
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--bl-text-tertiary,var(--bl-text-secondary))]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
aria-label={searchLabel}
|
||||
className="pl-9"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue ?? ''}
|
||||
onChange={event =>
|
||||
onSearchChange((event.currentTarget as unknown as { value: string }).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filters && <div className="flex min-w-0 flex-wrap items-center gap-2">{filters}</div>}
|
||||
{(actions || onReset) && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{onReset && (
|
||||
<Button type="button" variant="subtle" size="sm" onClick={onReset}>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{chips && <div className="flex min-w-0 flex-wrap gap-2">{chips}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
packages/ui/src/components/FormSection.tsx
Normal file
44
packages/ui/src/components/FormSection.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { AlertBanner } from './AlertBanner.js';
|
||||
|
||||
export interface FormSectionProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
error?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FormSection({
|
||||
title,
|
||||
description,
|
||||
error,
|
||||
actions,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FormSectionProps) {
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
'grid gap-4 rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-5 shadow-sm shadow-black/[0.03]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h3 className="m-0 text-base font-semibold leading-6 text-[var(--bl-text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm leading-6 text-[var(--bl-text-secondary)]">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{error && <AlertBanner tone="error">{error}</AlertBanner>}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
48
packages/ui/src/components/MetricCard.tsx
Normal file
48
packages/ui/src/components/MetricCard.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface MetricCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
helper?: React.ReactNode;
|
||||
trend?: React.ReactNode;
|
||||
tone?: 'neutral' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
const toneClass: Record<NonNullable<MetricCardProps['tone']>, string> = {
|
||||
neutral: 'text-[var(--bl-text-secondary)]',
|
||||
success: 'text-[var(--bl-success)]',
|
||||
warning: 'text-[var(--bl-warning)]',
|
||||
danger: 'text-[var(--bl-danger)]',
|
||||
info: 'text-[var(--bl-info,var(--bl-accent))]',
|
||||
};
|
||||
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
helper,
|
||||
trend,
|
||||
tone = 'neutral',
|
||||
className,
|
||||
...props
|
||||
}: MetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4 shadow-sm shadow-black/[0.03]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.08em] text-[var(--bl-text-secondary)]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold leading-8 text-[var(--bl-text-primary)]">
|
||||
{value}
|
||||
</div>
|
||||
{(helper || trend) && (
|
||||
<div className={clsx('mt-2 text-sm leading-5', toneClass[tone])}>{trend ?? helper}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
305
packages/ui/src/components/OperationalPreview.stories.tsx
Normal file
305
packages/ui/src/components/OperationalPreview.stories.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Activity, Copy, MoreHorizontal, Plus, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { ActionMenu } from './ActionMenu.js';
|
||||
import { AlertBanner } from './AlertBanner.js';
|
||||
import { Badge } from './Badge.js';
|
||||
import { Button } from './Button.js';
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
} from './DataTable.js';
|
||||
import { EmptyState } from './EmptyState.js';
|
||||
import { EntityCard } from './EntityCard.js';
|
||||
import { FieldGrid } from './FieldGrid.js';
|
||||
import { FilterBar } from './FilterBar.js';
|
||||
import { FormSection } from './FormSection.js';
|
||||
import { Input } from './Input.js';
|
||||
import { MetricCard } from './MetricCard.js';
|
||||
import { PageHeader } from './PageHeader.js';
|
||||
import { Panel, PanelBody, PanelDescription, PanelHeader, PanelTitle } from './Panel.js';
|
||||
import { Section } from './Section.js';
|
||||
import { Select } from './Select.js';
|
||||
import { Skeleton, TableSkeleton } from './Skeleton.js';
|
||||
import { StatusBadge } from './StatusBadge.js';
|
||||
import { Textarea } from './Textarea.js';
|
||||
import { Toolbar } from './Toolbar.js';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Examples/Operational Preview',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const LaunchReadyConsole: Story = {
|
||||
render: () => (
|
||||
<main className="min-h-screen bg-[var(--bl-bg-canvas)] p-6 text-[var(--bl-text-primary)]">
|
||||
<div className="mx-auto grid max-w-7xl gap-6">
|
||||
<PageHeader
|
||||
eyebrow="ByteLyst platform preview"
|
||||
title="Operational trading console"
|
||||
description="A realistic composition for validating shared tokens, primitives, density, states, and responsive behavior before product teams adopt the system."
|
||||
metadata={
|
||||
<>
|
||||
<Badge variant="success" dot>
|
||||
Local packages
|
||||
</Badge>
|
||||
<Badge variant="info" dot>
|
||||
Strict audit clean
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" size="sm">
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
New setup
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<AlertBanner tone="warning" title="Production bar">
|
||||
Every route should look deliberate in populated, loading, empty, error, disabled, and
|
||||
destructive-action states.
|
||||
</AlertBanner>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard label="Portfolio value" value="$128,420" trend="+4.8% today" tone="success" />
|
||||
<MetricCard label="Open setups" value="18" trend="3 need review" tone="warning" />
|
||||
<MetricCard
|
||||
label="Execution state"
|
||||
value="Paper"
|
||||
trend="Live trading locked"
|
||||
tone="info"
|
||||
/>
|
||||
<MetricCard label="Risk budget" value="62%" trend="Within limits" tone="success" />
|
||||
</section>
|
||||
|
||||
<FilterBar
|
||||
searchValue="BTC"
|
||||
onSearchChange={() => undefined}
|
||||
searchPlaceholder="Search symbol, setup, or owner..."
|
||||
chips={
|
||||
<>
|
||||
<Badge variant="accent">Crypto</Badge>
|
||||
<Badge variant="neutral">Active only</Badge>
|
||||
</>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<Select
|
||||
aria-label="Status"
|
||||
value="active"
|
||||
options={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'waiting', label: 'Waiting' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
aria-label="Strategy"
|
||||
value="all"
|
||||
options={[
|
||||
{ value: 'all', label: 'All strategies' },
|
||||
{ value: 'simple', label: 'Simple auto' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button variant="subtle" size="sm">
|
||||
Export
|
||||
</Button>
|
||||
}
|
||||
onReset={() => undefined}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||||
<Section
|
||||
title="Active setups"
|
||||
description="Dense data surfaces should scan cleanly and recover gracefully from loading, empty, and error states."
|
||||
actions={
|
||||
<Toolbar>
|
||||
<Button variant="ghost" size="sm">
|
||||
All
|
||||
</Button>
|
||||
<Button variant="subtle" size="sm">
|
||||
Needs attention
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
Closed
|
||||
</Button>
|
||||
</Toolbar>
|
||||
}
|
||||
>
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
<div>
|
||||
<PanelTitle>Runtime order state</PanelTitle>
|
||||
<PanelDescription>Responsive table with compact row actions.</PanelDescription>
|
||||
</div>
|
||||
<StatusBadge tone="success" dot>
|
||||
Synced
|
||||
</StatusBadge>
|
||||
</PanelHeader>
|
||||
<PanelBody>
|
||||
<DataTable>
|
||||
<DataTableHeader>
|
||||
<DataTableRow>
|
||||
<DataTableHead>Symbol</DataTableHead>
|
||||
<DataTableHead>State</DataTableHead>
|
||||
<DataTableHead>Budget</DataTableHead>
|
||||
<DataTableHead>P/L</DataTableHead>
|
||||
<DataTableHead>Action</DataTableHead>
|
||||
</DataTableRow>
|
||||
</DataTableHeader>
|
||||
<DataTableBody>
|
||||
{[
|
||||
['BTC/USD', 'Waiting', '$120.00', '+2.4%'],
|
||||
['ETH/USD', 'Armed', '$90.00', '-0.7%'],
|
||||
['AAPL', 'Review', '$250.00', '+0.8%'],
|
||||
].map(([symbol, state, budget, pnl]) => (
|
||||
<DataTableRow key={symbol}>
|
||||
<DataTableCell className="font-semibold">{symbol}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<StatusBadge tone={state === 'Review' ? 'warning' : 'info'} dot>
|
||||
{state}
|
||||
</StatusBadge>
|
||||
</DataTableCell>
|
||||
<DataTableCell>{budget}</DataTableCell>
|
||||
<DataTableCell>{pnl}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<ActionMenu
|
||||
items={[
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Clone setup',
|
||||
icon: <Copy className="h-4 w-4" />,
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
{
|
||||
id: 'archive',
|
||||
label: 'Archive',
|
||||
icon: <MoreHorizontal className="h-4 w-4" />,
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Delete',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
destructive: true,
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</PanelBody>
|
||||
</Panel>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<EntityCard
|
||||
eyebrow="Strategy"
|
||||
title="Breakout confirmation"
|
||||
subtitle="BTC/USD paper setup with managed profit exit."
|
||||
status={<StatusBadge tone="success">Ready</StatusBadge>}
|
||||
metadata={
|
||||
<>
|
||||
<Badge>Budget $120</Badge>
|
||||
<Badge variant="info">Short term</Badge>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button variant="secondary" size="sm">
|
||||
Open
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<span className="text-sm text-[var(--bl-text-secondary)]">
|
||||
Updated 4 minutes ago
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<EmptyState
|
||||
title="No critical alerts"
|
||||
description="This compact empty state keeps low-volume surfaces feeling complete."
|
||||
icon={<ShieldCheck className="h-10 w-10" />}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<aside className="grid gap-6">
|
||||
<FormSection
|
||||
title="Plan builder"
|
||||
description="Forms use consistent spacing, labels, hints, and review sections."
|
||||
>
|
||||
<FieldGrid columns={1}>
|
||||
<Input label="Symbol" value="BTC/USD" readOnly />
|
||||
<Select
|
||||
label="Setup type"
|
||||
value="dip"
|
||||
options={[
|
||||
{ value: 'dip', label: 'Buy the dip' },
|
||||
{ value: 'breakout', label: 'Breakout' },
|
||||
]}
|
||||
/>
|
||||
<Textarea label="Notes" placeholder="Optional context" />
|
||||
</FieldGrid>
|
||||
<Button>Save setup</Button>
|
||||
</FormSection>
|
||||
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
<div>
|
||||
<PanelTitle>Loading shape</PanelTitle>
|
||||
<PanelDescription>Skeletons should match final layout.</PanelDescription>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
<PanelBody>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton shape="circle" />
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Skeleton shape="text" />
|
||||
<Skeleton shape="text" className="w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
<TableSkeleton rows={3} />
|
||||
</PanelBody>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
<div>
|
||||
<PanelTitle>System health</PanelTitle>
|
||||
<PanelDescription>State should be visible without reading logs.</PanelDescription>
|
||||
</div>
|
||||
<Activity className="h-5 w-5 text-[var(--bl-success)]" aria-hidden="true" />
|
||||
</PanelHeader>
|
||||
<PanelBody>
|
||||
<AlertBanner tone="success">
|
||||
All shared primitives render from common platform.
|
||||
</AlertBanner>
|
||||
</PanelBody>
|
||||
</Panel>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
),
|
||||
};
|
||||
57
packages/ui/src/components/PageHeader.tsx
Normal file
57
packages/ui/src/components/PageHeader.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import * as React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface PageHeaderProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
|
||||
eyebrow?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
metadata?: React.ReactNode;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
metadata,
|
||||
compact,
|
||||
className,
|
||||
...props
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={clsx(
|
||||
'flex min-w-0 flex-col gap-4 border-b border-[var(--bl-border)]',
|
||||
compact ? 'pb-4' : 'pb-6',
|
||||
'sm:flex-row sm:items-end sm:justify-between',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{eyebrow && (
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.08em] text-[var(--bl-text-secondary)]">
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<h1
|
||||
className={clsx(
|
||||
'm-0 text-[var(--bl-text-primary)]',
|
||||
compact ? 'text-2xl font-semibold leading-8' : 'text-3xl font-semibold leading-10'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--bl-text-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{metadata && <div className="mt-3 flex flex-wrap gap-2">{metadata}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
48
packages/ui/src/components/Section.tsx
Normal file
48
packages/ui/src/components/Section.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface SectionProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
density?: 'compact' | 'normal' | 'spacious';
|
||||
}
|
||||
|
||||
const densityClass: Record<NonNullable<SectionProps['density']>, string> = {
|
||||
compact: 'gap-3',
|
||||
normal: 'gap-4',
|
||||
spacious: 'gap-6',
|
||||
};
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
density = 'normal',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<section className={clsx('grid min-w-0', densityClass[density], className)} {...props}>
|
||||
{(title || description || actions) && (
|
||||
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{title && (
|
||||
<h2 className="m-0 text-base font-semibold leading-6 text-[var(--bl-text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-[var(--bl-text-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
packages/ui/src/components/Skeleton.tsx
Normal file
32
packages/ui/src/components/Skeleton.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
shape?: 'block' | 'text' | 'circle';
|
||||
}
|
||||
|
||||
export function Skeleton({ shape = 'block', className, ...props }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'animate-pulse bg-[var(--bl-surface-muted)]',
|
||||
shape === 'text' && 'h-4 rounded-full',
|
||||
shape === 'block' && 'min-h-20 rounded-xl',
|
||||
shape === 'circle' && 'h-10 w-10 rounded-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 min-h-0" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
packages/ui/src/components/Toolbar.tsx
Normal file
25
packages/ui/src/components/Toolbar.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface ToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
align?: 'start' | 'between' | 'end';
|
||||
}
|
||||
|
||||
const alignClass: Record<NonNullable<ToolbarProps['align']>, string> = {
|
||||
start: 'justify-start',
|
||||
between: 'justify-between',
|
||||
end: 'justify-end',
|
||||
};
|
||||
|
||||
export function Toolbar({ align = 'start', className, ...props }: ToolbarProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex min-w-0 flex-wrap items-center gap-2 rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-2 shadow-sm shadow-black/[0.03]',
|
||||
alignClass[align],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,20 @@
|
||||
export { Button, type ButtonProps } from './components/Button.js';
|
||||
export { PageHeader, type PageHeaderProps } from './components/PageHeader.js';
|
||||
export { Section, type SectionProps } from './components/Section.js';
|
||||
export { Toolbar, type ToolbarProps } from './components/Toolbar.js';
|
||||
export { FilterBar, type FilterBarProps } from './components/FilterBar.js';
|
||||
export { FormSection, type FormSectionProps } from './components/FormSection.js';
|
||||
export { FieldGrid, type FieldGridProps } from './components/FieldGrid.js';
|
||||
export {
|
||||
AlertBanner,
|
||||
type AlertBannerProps,
|
||||
type AlertBannerTone,
|
||||
} from './components/AlertBanner.js';
|
||||
export { Skeleton, TableSkeleton, type SkeletonProps } from './components/Skeleton.js';
|
||||
export { EntityCard, type EntityCardProps } from './components/EntityCard.js';
|
||||
export { MetricCard, type MetricCardProps } from './components/MetricCard.js';
|
||||
export { ActionMenu, type ActionMenuItem, type ActionMenuProps } from './components/ActionMenu.js';
|
||||
export { Drawer, type DrawerProps } from './components/Drawer.js';
|
||||
export {
|
||||
AppShell,
|
||||
AppShellMain,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user