Files
sencho/frontend/src/components/ui/system-sheet.tsx
T
Anso 4d9617a5c6 feat(frontend): add SystemSheet primitive and migrate mesh sheets to §9.11 chrome (#960)
DESIGN.md §9.11 codifies one canonical right-side detail-sheet shell (cyan
rail, mono crumb, italic serif name, mono meta, ESC chip + close glyph,
fixed three-slot toolbar, cyan-underline tabs, ScrollArea body, footer
freshness band). Today the 16 sheet consumers each render their own
header chrome with stock shadcn SheetHeader/SheetTitle.

Introduce <SystemSheet> + <SheetSection> in
frontend/src/components/ui/system-sheet.tsx, composing the existing
<Sheet>/<SheetContent> primitive. Add a backward-compatible showClose
prop to SheetContent so SystemSheet can render its own ESC chip + close
glyph instead of the stock cyan square close.

Migrate the four mesh sheets as the first batch:
* MeshActivitySheet: crumb Fleet › Mesh › Activity, footer freshness from
  most-recent event timestamp.
* MeshOptInSheet: crumb Fleet › Mesh › {nodeName}, meta of opted-in
  count, drops the redundant bottom Close button (ESC chip dismisses).
* MeshDiagnosticsSheet: removes the icon-prefixed title (forbidden by
  §9.11), lifts Refresh/Restart buttons from the body into the toolbar
  band, three SheetSection blocks for sidecar status, streams, cache.
* MeshRouteDetailSheet: adds Overview/Events/Raw tabs, lifts Test probe
  into the toolbar primary slot, footer surfaces last probe latency.
2026-05-06 23:18:40 -04:00

304 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react';
import { X, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
const KICKER_CLASS = 'font-mono text-[10px] uppercase tracking-[0.22em]';
const CRUMB_CLASS = `${KICKER_CLASS} text-stat-subtitle`;
type SystemSheetSize = 'sm' | 'md' | 'lg' | 'xl';
const SIZE_CLASS: Record<SystemSheetSize, string> = {
sm: 'sm:max-w-[420px]',
md: 'sm:max-w-[560px]',
lg: 'sm:max-w-[720px]',
xl: 'sm:max-w-[960px]',
};
export interface SystemSheetAction {
label: React.ReactNode;
onClick: () => void;
disabled?: boolean;
icon?: LucideIcon;
}
export interface SystemSheetTab {
id: string;
label: string;
count?: number;
}
export interface SystemSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
// Header
crumb: string[];
name: React.ReactNode;
meta?: React.ReactNode;
// Toolbar band (omit entirely when no actions provided)
primaryAction?: SystemSheetAction;
secondaryActions?: SystemSheetAction[];
destructiveAction?: SystemSheetAction;
// Tabs band (omit entirely when undefined)
tabs?: SystemSheetTab[];
activeTab?: string;
onTabChange?: (id: string) => void;
// Footer freshness band (omit entirely when undefined)
footerContext?: React.ReactNode;
size?: SystemSheetSize;
children?: React.ReactNode;
}
export function SystemSheet({
open,
onOpenChange,
crumb,
name,
meta,
primaryAction,
secondaryActions,
destructiveAction,
tabs,
activeTab,
onTabChange,
footerContext,
size = 'md',
children,
}: SystemSheetProps) {
const hasToolbar = !!(primaryAction || (secondaryActions && secondaryActions.length > 0) || destructiveAction);
const hasTabs = !!(tabs && tabs.length > 0);
const hasFooter = footerContext !== undefined && footerContext !== null;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showClose={false}
className={cn(
'w-full p-0 flex flex-col gap-0 border-glass-border',
SIZE_CLASS[size],
)}
>
<SheetDescription className="sr-only">{crumb.join(' ')}</SheetDescription>
<SheetHeaderBand
crumb={crumb}
name={name}
meta={meta}
onDismiss={() => onOpenChange(false)}
/>
{hasToolbar && (
<ToolbarBand
primary={primaryAction}
secondaries={secondaryActions}
destructive={destructiveAction}
/>
)}
{hasTabs && (
<TabsBand
tabs={tabs}
activeTab={activeTab ?? tabs[0].id}
onTabChange={onTabChange}
/>
)}
<ScrollArea className="flex-1">
<div className="px-6 py-5">{children}</div>
</ScrollArea>
{hasFooter && <FooterBand context={footerContext} />}
</SheetContent>
</Sheet>
);
}
interface SheetHeaderBandProps {
crumb: string[];
name: React.ReactNode;
meta?: React.ReactNode;
onDismiss: () => void;
}
function SheetHeaderBand({ crumb, name, meta, onDismiss }: SheetHeaderBandProps) {
const lastIdx = crumb.length - 1;
return (
<div className="relative bg-popover/95 backdrop-blur-md border-b border-card-border/60 px-6 pt-5 pb-4 pr-14">
<span aria-hidden className="absolute inset-y-0 left-0 w-[2px] bg-brand" />
<nav aria-label="Sheet location" className={cn(CRUMB_CLASS, 'flex items-center gap-1.5 leading-none')}>
{crumb.map((segment, idx) => {
const isFirst = idx === 0;
const isLast = idx === lastIdx;
return (
<React.Fragment key={`${segment}-${idx}`}>
{isFirst ? (
<button
type="button"
onClick={onDismiss}
className="hover:text-stat-value transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50 rounded-sm"
>
{segment}
</button>
) : (
<span className={cn(isLast && 'text-stat-value')}>{segment}</span>
)}
{!isLast && <span aria-hidden className="text-stat-subtitle/60"></span>}
</React.Fragment>
);
})}
</nav>
<SheetTitle className="mt-2 font-display italic text-[1.5rem] leading-tight text-stat-value text-left">
{name}
</SheetTitle>
{meta && (
<div className="mt-1.5 font-mono text-xs text-stat-subtitle tabular-nums">{meta}</div>
)}
<CloseSlot onDismiss={onDismiss} />
</div>
);
}
function CloseSlot({ onDismiss }: { onDismiss: () => void }) {
return (
<div className="absolute right-4 top-4 flex items-center gap-1.5">
<span
aria-hidden
className="font-mono text-[9px] tracking-[0.18em] uppercase border border-card-border rounded px-1.5 py-0.5 text-stat-subtitle leading-none"
>
ESC
</span>
<button
type="button"
onClick={onDismiss}
aria-label="Close"
className="inline-flex items-center justify-center h-6 w-6 rounded-sm text-stat-subtitle hover:text-stat-value hover:bg-glass-highlight transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50"
>
<X className="h-4 w-4" strokeWidth={1.5} />
</button>
</div>
);
}
interface ToolbarBandProps {
primary?: SystemSheetAction;
secondaries?: SystemSheetAction[];
destructive?: SystemSheetAction;
}
function ToolbarBand({ primary, secondaries, destructive }: ToolbarBandProps) {
return (
<div className="bg-popover/95 backdrop-blur-md flex items-center gap-2 border-b border-card-border/60 px-6 py-3">
{primary && (
<Button size="sm" onClick={primary.onClick} disabled={primary.disabled} className="gap-1.5">
{primary.icon && <primary.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
{primary.label}
</Button>
)}
{secondaries?.map((action, idx) => (
<Button
key={idx}
size="sm"
variant="outline"
onClick={action.onClick}
disabled={action.disabled}
className="gap-1.5"
>
{action.icon && <action.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
{action.label}
</Button>
))}
<span className="flex-1" />
{destructive && (
<Button
size="sm"
variant="ghost"
onClick={destructive.onClick}
disabled={destructive.disabled}
className="gap-1.5 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
>
{destructive.icon && <destructive.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
{destructive.label}
</Button>
)}
</div>
);
}
interface TabsBandProps {
tabs: SystemSheetTab[];
activeTab: string;
onTabChange?: (id: string) => void;
}
function TabsBand({ tabs, activeTab, onTabChange }: TabsBandProps) {
return (
<div role="tablist" className="bg-popover/95 backdrop-blur-md flex items-stretch gap-0 border-b border-card-border/60 px-4">
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onTabChange?.(tab.id)}
className={cn(
'relative inline-flex items-center gap-1.5 px-3 py-2.5',
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50',
isActive ? 'text-stat-value' : 'text-stat-subtitle hover:text-stat-value',
)}
>
{tab.label}
{tab.count !== undefined && (
<span className="font-mono text-[10px] tabular-nums text-stat-subtitle">{tab.count}</span>
)}
{isActive && (
<span aria-hidden className="pointer-events-none absolute inset-x-2 -bottom-px h-[2px] bg-brand" />
)}
</button>
);
})}
</div>
);
}
function FooterBand({ context }: { context: React.ReactNode }) {
return (
<div className="bg-popover/95 backdrop-blur-md border-t border-card-border/60 px-6 py-3">
<div className={cn(KICKER_CLASS, 'text-stat-subtitle leading-none')}>{context}</div>
</div>
);
}
export interface SheetSectionProps {
title: string;
/** Hide the title rule + label (useful when a section is the only one in a tab). */
hideHeader?: boolean;
className?: string;
children: React.ReactNode;
}
export function SheetSection({ title, hideHeader, className, children }: SheetSectionProps) {
return (
<section className={cn('first:pt-0 first:border-t-0 -mx-6 px-6 py-4 border-t border-card-border/40', className)}>
{!hideHeader && (
<h3 className={cn(KICKER_CLASS, 'text-stat-subtitle mb-3 leading-none')}>{title}</h3>
)}
{children}
</section>
);
}