feat(ui): stabilize laboratory viewer primitives
This commit is contained in:
+125
-2
@@ -15,13 +15,13 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) {
|
||||
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible {
|
||||
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible {
|
||||
background-color: var(--nodedc-focus-surface);
|
||||
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
@@ -73,6 +73,10 @@
|
||||
border-radius: var(--nodedc-radius-modal);
|
||||
}
|
||||
|
||||
.nodedc-glass[data-radius="pill"] {
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
}
|
||||
|
||||
.nodedc-glass[data-padding="sm"] {
|
||||
padding: var(--nodedc-space-3);
|
||||
}
|
||||
@@ -255,6 +259,11 @@
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-icon-button[aria-pressed="true"] {
|
||||
background: var(--nodedc-glass-control-active);
|
||||
color: var(--nodedc-glass-control-active-text);
|
||||
}
|
||||
|
||||
.nodedc-icon-button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
@@ -2206,6 +2215,33 @@ textarea.nodedc-field__control {
|
||||
transform: translateY(2px) rotate(225deg);
|
||||
}
|
||||
|
||||
.nodedc-select-inline {
|
||||
display: inline-flex;
|
||||
min-height: var(--nodedc-control-height-compact);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--nodedc-space-2);
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0 var(--nodedc-space-3);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-select-inline:hover:not(:disabled),
|
||||
.nodedc-select-inline[aria-expanded="true"] {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-select-inline:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron {
|
||||
transform: translateY(2px) rotate(225deg);
|
||||
}
|
||||
@@ -2829,6 +2865,93 @@ textarea.nodedc-field__control {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-split-pane {
|
||||
--nodedc-split-pane-primary: 50%;
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nodedc-split-pane[data-orientation="vertical"] {
|
||||
grid-template-columns: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.nodedc-split-pane[data-orientation="horizontal"] {
|
||||
grid-template-rows: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.nodedc-split-pane__panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nodedc-split-pane__separator {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--nodedc-split-pane-primary);
|
||||
width: 0.9rem;
|
||||
cursor: col-resize;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator {
|
||||
right: 0;
|
||||
bottom: calc(100% - var(--nodedc-split-pane-primary));
|
||||
left: 0;
|
||||
height: 0.9rem;
|
||||
cursor: row-resize;
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.nodedc-split-pane__separator::before {
|
||||
position: absolute;
|
||||
background: var(--nodedc-glass-outline);
|
||||
content: "";
|
||||
transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard), box-shadow var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator::before {
|
||||
right: 0;
|
||||
bottom: 50%;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.nodedc-split-pane__separator:hover::before,
|
||||
.nodedc-split-pane__separator:focus-visible::before,
|
||||
.nodedc-split-pane[data-dragging="true"] > .nodedc-split-pane__separator::before {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
box-shadow: 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.16);
|
||||
}
|
||||
|
||||
.nodedc-split-pane[data-dragging="true"] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap {
|
||||
position: fixed;
|
||||
z-index: calc(var(--nodedc-layer-header) + 20);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user