feat(ui): stabilize laboratory viewer primitives

This commit is contained in:
Codex
2026-08-24 22:31:35 +03:00
parent c7e136cc14
commit 6e7255ecdb
17 changed files with 590 additions and 30 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn.js";
export type GlassTone = "default" | "strong" | "soft";
export type GlassRadius = "card" | "panel" | "modal";
export type GlassRadius = "card" | "panel" | "modal" | "pill";
export type GlassPadding = "none" | "sm" | "md" | "lg";
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
+12 -2
View File
@@ -3,6 +3,7 @@ import {
AlertTriangle,
Boxes,
Building2,
Camera,
Check,
ChevronDown,
ChevronLeft,
@@ -26,11 +27,13 @@ import {
LocateFixed,
LockKeyhole,
MailPlus,
Map,
Maximize2,
Minimize2,
Network,
PanelTop,
Pencil,
Play,
Plus,
RefreshCw,
Save,
@@ -38,6 +41,7 @@ import {
Settings,
ShieldCheck,
SlidersHorizontal,
Square,
Trash2,
UploadCloud,
UserCircle,
@@ -53,6 +57,7 @@ const icons = {
alert: AlertTriangle,
apps: Boxes,
building: Building2,
camera: Camera,
check: Check,
"chevron-down": ChevronDown,
"chevron-left": ChevronLeft,
@@ -82,6 +87,8 @@ const icons = {
minimize: Minimize2,
network: Network,
panel: PanelTop,
plan: Map,
play: Play,
plus: Plus,
profile: UserCircle,
refresh: RefreshCw,
@@ -90,6 +97,7 @@ const icons = {
settings: Settings,
shield: ShieldCheck,
sliders: SlidersHorizontal,
stop: Square,
trash: Trash2,
upload: UploadCloud,
users: UsersRound,
@@ -103,13 +111,15 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
label?: string;
}
export function Icon({ name, label, size = 16, strokeWidth = 1.6, ...props }: IconProps) {
export function Icon({ name, label, size = 16, strokeWidth = 1.6, fill, ...props }: IconProps) {
const IconComponent = icons[name];
const filledTransport = name === "play" || name === "stop";
return (
<IconComponent
size={size}
strokeWidth={strokeWidth}
strokeWidth={filledTransport ? 0 : strokeWidth}
fill={fill ?? (filledTransport ? "currentColor" : "none")}
aria-hidden={label ? undefined : "true"}
aria-label={label}
role={label ? "img" : undefined}
+21 -1
View File
@@ -12,7 +12,7 @@ export interface SelectOption<T extends string> {
disabled?: boolean;
}
export type SelectVariant = "integrated" | "split";
export type SelectVariant = "integrated" | "split" | "inline";
export interface SelectProps<T extends string> {
value: T;
@@ -102,6 +102,26 @@ export function Select<T extends string>({
);
}
if (resolvedVariant === "inline") {
return (
<button
ref={setTriggerRef}
type="button"
className={cn("nodedc-select-inline", triggerClassName)}
aria-label={label}
aria-haspopup="listbox"
aria-controls={surfaceId}
aria-expanded={open}
disabled={disabled}
onClick={toggle}
onKeyDown={handleKeyDown}
>
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
<span>{selected?.label ?? "—"}</span>
</button>
);
}
return (
<button
ref={setTriggerRef}
+156
View File
@@ -0,0 +1,156 @@
import {
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { cn } from "./cn.js";
export type SplitPaneOrientation = "vertical" | "horizontal";
export interface SplitPaneProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
primary: ReactNode;
secondary: ReactNode;
primarySize: number;
onPrimarySizeChange: (primarySize: number) => void;
orientation?: SplitPaneOrientation;
minPrimarySize?: number;
minSecondarySize?: number;
step?: number;
resizable?: boolean;
separatorLabel: string;
}
const clamp = (value: number, minimum: number, maximum: number) => (
Math.min(Math.max(value, minimum), maximum)
);
const normalizedLimits = (minPrimarySize: number, minSecondarySize: number) => {
const primary = clamp(minPrimarySize, 0, 100);
const secondary = clamp(minSecondarySize, 0, 100);
if (primary + secondary <= 100) return { minimum: primary, maximum: 100 - secondary };
const scale = 100 / (primary + secondary);
return { minimum: primary * scale, maximum: 100 - secondary * scale };
};
export function SplitPane({
primary,
secondary,
primarySize,
onPrimarySizeChange,
orientation = "vertical",
minPrimarySize = 20,
minSecondarySize = 20,
step = 2,
resizable = true,
separatorLabel,
className,
style,
...props
}: SplitPaneProps) {
const rootRef = useRef<HTMLDivElement>(null);
const activePointerIdRef = useRef<number | null>(null);
const [dragging, setDragging] = useState(false);
const limits = normalizedLimits(minPrimarySize, minSecondarySize);
const size = clamp(Number.isFinite(primarySize) ? primarySize : 50, limits.minimum, limits.maximum);
const keyboardIncrement = Number.isFinite(step) && step > 0 ? step : 2;
const emitPointerSize = (event: PointerEvent<HTMLElement>) => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const available = orientation === "vertical" ? rect.width : rect.height;
if (available <= 0) return;
const offset = orientation === "vertical"
? event.clientX - rect.left
: event.clientY - rect.top;
onPrimarySizeChange(clamp(offset / available * 100, limits.minimum, limits.maximum));
};
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
const multiplier = event.shiftKey ? 5 : 1;
const decrement = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
const increment = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
let next: number | null = null;
if (event.key === decrement) next = size - keyboardIncrement * multiplier;
if (event.key === increment) next = size + keyboardIncrement * multiplier;
if (event.key === "Home") next = limits.minimum;
if (event.key === "End") next = limits.maximum;
if (next === null) return;
event.preventDefault();
onPrimarySizeChange(clamp(next, limits.minimum, limits.maximum));
};
const splitStyle = {
...style,
"--nodedc-split-pane-primary": `${size}%`,
} as CSSProperties;
return (
<div
ref={rootRef}
className={cn("nodedc-split-pane", className)}
data-orientation={orientation}
data-dragging={dragging ? "true" : undefined}
style={splitStyle}
{...props}
>
<div className="nodedc-split-pane__panel" data-pane="primary">
{primary}
</div>
<div className="nodedc-split-pane__panel" data-pane="secondary">
{secondary}
</div>
{resizable ? (
<div
className="nodedc-split-pane__separator"
role="separator"
tabIndex={0}
aria-label={separatorLabel}
aria-orientation={orientation}
aria-valuemin={Math.round(limits.minimum)}
aria-valuemax={Math.round(limits.maximum)}
aria-valuenow={Math.round(size)}
aria-valuetext={`${Math.round(size)}% / ${Math.round(100 - size)}%`}
onKeyDown={handleKeyDown}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.focus();
activePointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
emitPointerSize(event);
event.preventDefault();
}}
onPointerMove={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
emitPointerSize(event);
event.preventDefault();
}}
onPointerUp={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setDragging(false);
}}
onPointerCancel={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
setDragging(false);
}}
onLostPointerCapture={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
setDragging(false);
}}
/>
) : null}
</div>
);
}
+23 -12
View File
@@ -1,4 +1,4 @@
import { useEffect, type HTMLAttributes } from "react";
import { useEffect, useRef, type HTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.js";
@@ -21,6 +21,8 @@ const toastIcons: Record<ToastTone, IconName> = {
loading: "refresh",
};
const DEFAULT_TOAST_DURATION_MS = 10_000;
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
item: ToastItem;
onDismiss?: (id: string) => void;
@@ -48,21 +50,30 @@ export function ToastCard({ item, onDismiss, className, ...props }: ToastCardPro
);
}
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
useEffect(() => {
const timers = items.flatMap((item) => {
const duration = item.durationMs === undefined ? 4200 : item.durationMs;
return typeof duration === "number" && duration > 0
? [window.setTimeout(() => onDismiss(item.id), duration)]
: [];
});
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [items, onDismiss]);
function TimedToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) {
const dismissRef = useRef(onDismiss);
useEffect(() => {
dismissRef.current = onDismiss;
}, [onDismiss]);
useEffect(() => {
const duration = item.durationMs === undefined
? item.tone === "loading" ? null : DEFAULT_TOAST_DURATION_MS
: item.durationMs;
if (typeof duration !== "number" || duration <= 0) return undefined;
const timer = window.setTimeout(() => dismissRef.current(item.id), duration);
return () => window.clearTimeout(timer);
}, [item.durationMs, item.id, item.tone]);
return <ToastCard item={item} onDismiss={onDismiss} />;
}
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
if (typeof document === "undefined" || items.length === 0) return null;
return createPortal(
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
{items.map((item) => <ToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
{items.map((item) => <TimedToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
</div>,
document.body,
);
+1
View File
@@ -21,6 +21,7 @@ export * from "./Select.js";
export * from "./StatusBadge.js";
export * from "./Settings.js";
export * from "./SharingModals.js";
export * from "./SplitPane.js";
export * from "./Toolbar.js";
export * from "./Toast.js";
export * from "./UserProfileMenu.js";