@protolabsai/ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx DELETED
@@ -1,1329 +0,0 @@
1
- /**
2
- * @protolabsai/ui — React primitives on the @protolabsai/design token set.
3
- * Every component is className-only over the --pl-* custom properties, so it
4
- * inherits the locked brand and restyles for free when a token changes.
5
- */
6
- import type {
7
- ButtonHTMLAttributes,
8
- HTMLAttributes,
9
- InputHTMLAttributes,
10
- KeyboardEvent as ReactKeyboardEvent,
11
- MouseEvent as ReactMouseEvent,
12
- PointerEvent as ReactPointerEvent,
13
- ReactNode,
14
- RefObject,
15
- SelectHTMLAttributes,
16
- TableHTMLAttributes,
17
- TdHTMLAttributes,
18
- TextareaHTMLAttributes,
19
- ThHTMLAttributes,
20
- } from "react";
21
- import {
22
- createContext,
23
- forwardRef,
24
- useCallback,
25
- useContext,
26
- useEffect,
27
- useId,
28
- useImperativeHandle,
29
- useRef,
30
- useState,
31
- } from "react";
32
- import * as RDropdown from "@radix-ui/react-dropdown-menu";
33
- import "./styles.css";
34
-
35
- const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
36
-
37
- export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
38
- /** "primary"/"danger" read as a stronger border, not a fill (brand restraint);
39
- * "ghost" is transparent until hover. */
40
- variant?: "default" | "primary" | "ghost" | "danger";
41
- size?: "sm" | "md";
42
- /** Icon-only: square, centered glyph. Pass `aria-label` for a11y. */
43
- icon?: boolean;
44
- };
45
- export function Button({ variant = "default", size = "md", icon, className, ...rest }: ButtonProps) {
46
- return (
47
- <button
48
- className={cx(
49
- "pl-btn",
50
- variant !== "default" && `pl-btn--${variant}`,
51
- size === "sm" && "pl-btn--sm",
52
- icon && "pl-btn--icon",
53
- className,
54
- )}
55
- {...rest}
56
- />
57
- );
58
- }
59
-
60
- export type Status = "neutral" | "success" | "warning" | "error" | "info";
61
- export function Badge({ status = "neutral", children }: { status?: Status; children: ReactNode }) {
62
- return <span className={cx("pl-badge", status !== "neutral" && `pl-badge--${status}`)}>{children}</span>;
63
- }
64
-
65
- export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
66
- return <div className={cx("pl-card", className)} {...rest} />;
67
- }
68
-
69
- export function Eyebrow({ children }: { children: ReactNode }) {
70
- return <div className="pl-eyebrow">{children}</div>;
71
- }
72
-
73
- export function Stat({ value, label }: { value: ReactNode; label: ReactNode }) {
74
- return (
75
- <div>
76
- <div className="pl-stat__num">{value}</div>
77
- <div className="pl-stat__label">{label}</div>
78
- </div>
79
- );
80
- }
81
-
82
- export function Container({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
83
- return <div className={cx("pl-container", className)} {...rest} />;
84
- }
85
-
86
- export function Section({ className, ...rest }: HTMLAttributes<HTMLElement>) {
87
- return <section className={cx("pl-section", className)} {...rest} />;
88
- }
89
-
90
- /** Stats grid — wrap Stat children. Two columns, four at ≥640px. */
91
- export function Stats({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
92
- return <div className={cx("pl-stats", className)} {...rest} />;
93
- }
94
-
95
- export type RowProps = {
96
- /** Left mono label / layer. */
97
- label: string;
98
- /** Optional mono name above the description. */
99
- name?: ReactNode;
100
- desc: ReactNode;
101
- /** When present, the row widens to label | body | status. */
102
- status?: ReactNode;
103
- /** Renders as a link when set. */
104
- href?: string;
105
- external?: boolean;
106
- };
107
- export function Row({ label, name, desc, status, href, external }: RowProps) {
108
- const cls = cx("pl-row", status != null && "pl-row--wide");
109
- const inner = (
110
- <>
111
- <span className="pl-row__label">{label}</span>
112
- <span>
113
- {name != null && <div className="pl-row__name">{name}</div>}
114
- <div className="pl-row__desc">{desc}</div>
115
- </span>
116
- {status != null && <span className="pl-row__status">{status}</span>}
117
- </>
118
- );
119
- return href ? (
120
- <a className={cls} href={href} {...(external ? { target: "_blank", rel: "noreferrer noopener" } : {})}>
121
- {inner}
122
- </a>
123
- ) : (
124
- <div className={cls}>{inner}</div>
125
- );
126
- }
127
-
128
- /** The one gradient — the tagline word treatment. Foundation §1 + §13. */
129
- export function GradientText({ children }: { children: ReactNode }) {
130
- return <span className="pl-gradient-text">{children}</span>;
131
- }
132
-
133
- /** Hero header — put an <h1> + <Lead> + <HeroActions> inside. */
134
- export function Hero({ className, ...rest }: HTMLAttributes<HTMLElement>) {
135
- return <header className={cx("pl-hero", className)} {...rest} />;
136
- }
137
- /** Button row under the hero lead. */
138
- export function HeroActions({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
139
- return <div className={cx("pl-hero__cta", className)} {...rest} />;
140
- }
141
-
142
- /** Large muted intro paragraph (hero size). */
143
- export function Lead({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
144
- return <p className={cx("pl-lead", className)} {...rest} />;
145
- }
146
-
147
- /** Section heading — self-contained h2 (doesn't rely on a global reset). */
148
- export function Heading({ className, ...rest }: HTMLAttributes<HTMLHeadingElement>) {
149
- return <h2 className={cx("pl-heading", className)} {...rest} />;
150
- }
151
-
152
- /** Muted paragraph that introduces a section (body size). */
153
- export function SectionIntro({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
154
- return <p className={cx("pl-section-intro", className)} {...rest} />;
155
- }
156
-
157
- /** Numbered process list — wrap Step children. */
158
- export function Steps({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
159
- return <div className={cx("pl-steps", className)} {...rest} />;
160
- }
161
- export function Step({ n, title, children }: { n: ReactNode; title: ReactNode; children: ReactNode }) {
162
- return (
163
- <div className="pl-step">
164
- <div className="pl-step__num">{n}</div>
165
- <div>
166
- <div className="pl-step__title">{title}</div>
167
- <div className="pl-step__body">{children}</div>
168
- </div>
169
- </div>
170
- );
171
- }
172
-
173
- /** Checklist — wrap Check children. The ✓ mark is rendered for you. */
174
- export function Checks({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
175
- return <div className={cx("pl-checks", className)} {...rest} />;
176
- }
177
- export function Check({ children, mark = "✓" }: { children: ReactNode; mark?: ReactNode }) {
178
- return (
179
- <div className="pl-check">
180
- <span className="pl-check__mark" aria-hidden>
181
- {mark}
182
- </span>
183
- <span>{children}</span>
184
- </div>
185
- );
186
- }
187
-
188
- /** Two-column deliverable cards (left-border, mono title). */
189
- export function Deliverables({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
190
- return <div className={cx("pl-deliverables", className)} {...rest} />;
191
- }
192
- export function Deliverable({ title, children }: { title: ReactNode; children: ReactNode }) {
193
- return (
194
- <div className="pl-deliverable">
195
- <div className="pl-deliverable__title">{title}</div>
196
- <div className="pl-deliverable__body">{children}</div>
197
- </div>
198
- );
199
- }
200
-
201
- /** Blog index list — wrap PostItem children. */
202
- export function PostList({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
203
- return <div className={cx("pl-post-list", className)} {...rest} />;
204
- }
205
- export type PostItemProps = { meta?: ReactNode; title: ReactNode; excerpt?: ReactNode; href: string };
206
- export function PostItem({ meta, title, excerpt, href }: PostItemProps) {
207
- return (
208
- <a className="pl-post-item" href={href}>
209
- {meta != null && <div className="pl-post-item__meta">{meta}</div>}
210
- <div className="pl-post-item__title">{title}</div>
211
- {excerpt != null && <div className="pl-post-item__excerpt">{excerpt}</div>}
212
- </a>
213
- );
214
- }
215
-
216
- /** Mono empty-state line. */
217
- export function Empty({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
218
- return <div className={cx("pl-empty", className)} {...rest} />;
219
- }
220
-
221
- /** Long-form rich-text wrapper (blog post body, docs). */
222
- export function Prose({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
223
- return <div className={cx("pl-prose", className)} {...rest} />;
224
- }
225
-
226
- /** Hairline rule. */
227
- export function Divider({ className, ...rest }: HTMLAttributes<HTMLHRElement>) {
228
- return <hr className={cx("pl-divider", className)} {...rest} />;
229
- }
230
-
231
- /** Bordered note block — left-accent keyed to the status tone. */
232
- export function Callout({
233
- tone = "neutral",
234
- title,
235
- children,
236
- }: {
237
- tone?: Status;
238
- title?: ReactNode;
239
- children: ReactNode;
240
- }) {
241
- return (
242
- <div className={cx("pl-callout", tone !== "neutral" && `pl-callout--${tone}`)}>
243
- {title != null && <div className="pl-callout__title">{title}</div>}
244
- <div className="pl-callout__body">{children}</div>
245
- </div>
246
- );
247
- }
248
-
249
- /** Keyboard / inline-token chip. */
250
- export function Kbd({ children }: { children: ReactNode }) {
251
- return <kbd className="pl-kbd">{children}</kbd>;
252
- }
253
-
254
- /** Standalone styled link (underline-offset treatment). Use the app's router
255
- * Link for internal navigation; this is for plain anchors. */
256
- export function TextLink({
257
- className,
258
- external,
259
- ...rest
260
- }: HTMLAttributes<HTMLAnchorElement> & { href?: string; external?: boolean }) {
261
- return (
262
- <a
263
- className={cx("pl-link", className)}
264
- {...(external ? { target: "_blank", rel: "noreferrer noopener" } : {})}
265
- {...rest}
266
- />
267
- );
268
- }
269
-
270
- // ── App primitives (cockpit + future studio apps) ────────────────────────────
271
-
272
- export type TabItem = {
273
- id: string;
274
- label: ReactNode;
275
- /** Leading icon (e.g. a Lucide glyph). */
276
- icon?: ReactNode;
277
- /** Trailing badge / count (e.g. unread inbox count). */
278
- badge?: ReactNode;
279
- disabled?: boolean;
280
- locked?: boolean;
281
- };
282
-
283
- /** A horizontal tab strip with optional icon/badge slots + disabled/locked
284
- * support (gated workflows). */
285
- export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: string; onSelect: (id: string) => void }) {
286
- return (
287
- <div className="pl-tabs" role="tablist">
288
- {items.map((t) => (
289
- <button
290
- key={t.id}
291
- role="tab"
292
- type="button"
293
- aria-selected={t.id === active}
294
- className={cx("pl-tab", t.id === active && "pl-tab--active")}
295
- disabled={t.disabled}
296
- onClick={() => onSelect(t.id)}
297
- >
298
- {t.icon != null && (
299
- <span className="pl-tab__icon" aria-hidden>
300
- {t.icon}
301
- </span>
302
- )}
303
- <span className="pl-tab__label">{t.label}</span>
304
- {t.badge != null && <span className="pl-tab__badge">{t.badge}</span>}
305
- {t.locked ? (
306
- <span className="pl-tab__lock" aria-hidden>
307
- 🔒
308
- </span>
309
- ) : null}
310
- </button>
311
- ))}
312
- </div>
313
- );
314
- }
315
-
316
- /** A horizontal kanban board. Wrap BoardColumn children. */
317
- export function Board({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
318
- return <div className={cx("pl-board", className)} {...rest} />;
319
- }
320
-
321
- export function BoardColumn({ title, count, children }: { title: ReactNode; count?: ReactNode; children: ReactNode }) {
322
- return (
323
- <div className="pl-board-col">
324
- <div className="pl-board-col__head">
325
- <span>{title}</span>
326
- {count != null ? <span className="pl-board-col__count">{count}</span> : null}
327
- </div>
328
- <div className="pl-board-col__body">{children}</div>
329
- </div>
330
- );
331
- }
332
-
333
- export function BoardCard({ className, ...rest }: ButtonHTMLAttributes<HTMLButtonElement>) {
334
- return <button type="button" className={cx("pl-board-card", className)} {...rest} />;
335
- }
336
-
337
- /** A labeled input/textarea bound to a string value (form fields, editors). */
338
- export function Field({
339
- label,
340
- value,
341
- multiline,
342
- readOnly,
343
- placeholder,
344
- onValueChange,
345
- className,
346
- }: {
347
- label: ReactNode;
348
- value?: string;
349
- multiline?: boolean;
350
- readOnly?: boolean;
351
- placeholder?: string;
352
- onValueChange?: (value: string) => void;
353
- className?: string;
354
- }) {
355
- const shared = {
356
- className: "pl-field__input",
357
- value,
358
- readOnly,
359
- placeholder,
360
- onChange: (e: { target: { value: string } }) => onValueChange?.(e.target.value),
361
- };
362
- return (
363
- <label className={cx("pl-field", className)}>
364
- <span className="pl-field__label">{label}</span>
365
- {multiline ? <textarea {...shared} /> : <input {...shared} />}
366
- </label>
367
- );
368
- }
369
-
370
- /** Dense operator-console panel header — title + optional kicker eyebrow +
371
- * right-aligned actions slot. `compact` tightens it for nested/secondary
372
- * panels. The most-used console composite. */
373
- export function PanelHeader({
374
- title,
375
- kicker,
376
- actions,
377
- compact,
378
- className,
379
- }: {
380
- title: ReactNode;
381
- kicker?: ReactNode;
382
- actions?: ReactNode;
383
- compact?: boolean;
384
- className?: string;
385
- }) {
386
- return (
387
- <div className={cx("pl-panel-header", compact && "pl-panel-header--compact", className)}>
388
- <div className="pl-panel-header__titles">
389
- {kicker != null && <div className="pl-panel-header__kicker">{kicker}</div>}
390
- <h2 className="pl-panel-header__title">{title}</h2>
391
- </div>
392
- {actions != null && <div className="pl-panel-header__actions">{actions}</div>}
393
- </div>
394
- );
395
- }
396
-
397
- // ── Overlays & feedback ──────────────────────────────────────────────────────
398
-
399
- /** Esc-to-close + body scroll-lock while `open`. Shared by Dialog + Drawer. */
400
- function useOverlayDismiss(open: boolean, onClose?: () => void) {
401
- useEffect(() => {
402
- if (!open) return;
403
- const onKey = (e: KeyboardEvent) => {
404
- if (e.key === "Escape") onClose?.();
405
- };
406
- document.addEventListener("keydown", onKey);
407
- const prev = document.body.style.overflow;
408
- document.body.style.overflow = "hidden";
409
- return () => {
410
- document.removeEventListener("keydown", onKey);
411
- document.body.style.overflow = prev;
412
- };
413
- }, [open, onClose]);
414
- }
415
-
416
- /** Move focus into `ref` on open and cycle Tab within it (a11y modal trap). */
417
- function useFocusTrap(ref: RefObject<HTMLElement | null>, open: boolean) {
418
- useEffect(() => {
419
- const node = ref.current;
420
- if (!open || !node) return;
421
- const focusables = () =>
422
- Array.from(
423
- node.querySelectorAll<HTMLElement>(
424
- 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])',
425
- ),
426
- ).filter((el) => el.offsetParent !== null);
427
- (focusables()[0] ?? node).focus();
428
- const onKey = (e: KeyboardEvent) => {
429
- if (e.key !== "Tab") return;
430
- const items = focusables();
431
- if (items.length === 0) return;
432
- const first = items[0];
433
- const last = items[items.length - 1];
434
- if (e.shiftKey && document.activeElement === first) {
435
- e.preventDefault();
436
- last.focus();
437
- } else if (!e.shiftKey && document.activeElement === last) {
438
- e.preventDefault();
439
- first.focus();
440
- }
441
- };
442
- node.addEventListener("keydown", onKey);
443
- return () => node.removeEventListener("keydown", onKey);
444
- }, [ref, open]);
445
- }
446
-
447
- /** Modal dialog — scrim + centered card, Esc / backdrop close, focus trap.
448
- * Controlled: render with `open` and handle `onClose`. */
449
- export function Dialog({
450
- open,
451
- onClose,
452
- title,
453
- children,
454
- footer,
455
- width,
456
- className,
457
- }: {
458
- open: boolean;
459
- onClose?: () => void;
460
- title?: ReactNode;
461
- children?: ReactNode;
462
- /** Action row pinned to the dialog foot (e.g. Cancel / Confirm buttons). */
463
- footer?: ReactNode;
464
- width?: number | string;
465
- className?: string;
466
- }) {
467
- const ref = useRef<HTMLDivElement>(null);
468
- const labelId = useId();
469
- useOverlayDismiss(open, onClose);
470
- useFocusTrap(ref, open);
471
- if (!open) return null;
472
- return (
473
- <div
474
- className="pl-overlay"
475
- onMouseDown={(e) => {
476
- if (e.target === e.currentTarget) onClose?.();
477
- }}
478
- >
479
- <div
480
- ref={ref}
481
- className={cx("pl-dialog", className)}
482
- role="dialog"
483
- aria-modal="true"
484
- aria-labelledby={title != null ? labelId : undefined}
485
- tabIndex={-1}
486
- style={width != null ? { width, maxWidth: "100%" } : undefined}
487
- >
488
- {title != null && (
489
- <div className="pl-dialog__head">
490
- <div className="pl-dialog__title" id={labelId}>
491
- {title}
492
- </div>
493
- {onClose && (
494
- <button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
495
- ×
496
- </button>
497
- )}
498
- </div>
499
- )}
500
- {children != null && <div className="pl-dialog__body">{children}</div>}
501
- {footer != null && <div className="pl-dialog__foot">{footer}</div>}
502
- </div>
503
- </div>
504
- );
505
- }
506
-
507
- /** Destructive-confirm convenience over Dialog. `destructive` reddens confirm. */
508
- export function ConfirmDialog({
509
- open,
510
- title,
511
- children,
512
- confirmLabel = "Confirm",
513
- cancelLabel = "Cancel",
514
- destructive,
515
- onConfirm,
516
- onClose,
517
- }: {
518
- open: boolean;
519
- title?: ReactNode;
520
- children?: ReactNode;
521
- confirmLabel?: ReactNode;
522
- cancelLabel?: ReactNode;
523
- destructive?: boolean;
524
- onConfirm?: () => void;
525
- onClose?: () => void;
526
- }) {
527
- return (
528
- <Dialog
529
- open={open}
530
- onClose={onClose}
531
- title={title}
532
- width={420}
533
- footer={
534
- <>
535
- <Button onClick={onClose}>{cancelLabel}</Button>
536
- <Button variant={destructive ? "danger" : "primary"} onClick={() => onConfirm?.()}>
537
- {confirmLabel}
538
- </Button>
539
- </>
540
- }
541
- >
542
- {children}
543
- </Dialog>
544
- );
545
- }
546
-
547
- /** Slide-in side panel / sheet. Esc / backdrop close, focus trap. */
548
- export function Drawer({
549
- open,
550
- onClose,
551
- side = "right",
552
- title,
553
- children,
554
- footer,
555
- width,
556
- className,
557
- }: {
558
- open: boolean;
559
- onClose?: () => void;
560
- side?: "left" | "right";
561
- title?: ReactNode;
562
- children?: ReactNode;
563
- footer?: ReactNode;
564
- width?: number | string;
565
- className?: string;
566
- }) {
567
- const ref = useRef<HTMLDivElement>(null);
568
- const labelId = useId();
569
- useOverlayDismiss(open, onClose);
570
- useFocusTrap(ref, open);
571
- if (!open) return null;
572
- return (
573
- <div
574
- className="pl-overlay pl-overlay--drawer"
575
- onMouseDown={(e) => {
576
- if (e.target === e.currentTarget) onClose?.();
577
- }}
578
- >
579
- <div
580
- ref={ref}
581
- className={cx("pl-drawer", `pl-drawer--${side}`, className)}
582
- role="dialog"
583
- aria-modal="true"
584
- aria-labelledby={title != null ? labelId : undefined}
585
- tabIndex={-1}
586
- style={width != null ? { width, maxWidth: "100%" } : undefined}
587
- >
588
- {title != null && (
589
- <div className="pl-drawer__head">
590
- <div className="pl-drawer__title" id={labelId}>
591
- {title}
592
- </div>
593
- {onClose && (
594
- <button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
595
- ×
596
- </button>
597
- )}
598
- </div>
599
- )}
600
- <div className="pl-drawer__body">{children}</div>
601
- {footer != null && <div className="pl-drawer__foot">{footer}</div>}
602
- </div>
603
- </div>
604
- );
605
- }
606
-
607
- type ToastOptions = { tone?: Status; title?: ReactNode; message: ReactNode; duration?: number };
608
- type ToastItem = Required<Pick<ToastOptions, "tone" | "message" | "duration">> & {
609
- id: string;
610
- title?: ReactNode;
611
- };
612
-
613
- const ToastContext = createContext<((opts: ToastOptions) => string) | null>(null);
614
-
615
- /** Wrap the app once. Exposes `useToast()` to push transient notifications. */
616
- export function ToastProvider({ children, max = 4 }: { children: ReactNode; max?: number }) {
617
- const [toasts, setToasts] = useState<ToastItem[]>([]);
618
- const seq = useRef(0);
619
- const dismiss = useCallback((id: string) => setToasts((ts) => ts.filter((t) => t.id !== id)), []);
620
- const toast = useCallback(
621
- (opts: ToastOptions) => {
622
- const id = `t${(seq.current += 1)}`;
623
- const item: ToastItem = {
624
- id,
625
- tone: opts.tone ?? "neutral",
626
- title: opts.title,
627
- message: opts.message,
628
- duration: opts.duration ?? 4000,
629
- };
630
- setToasts((ts) => [...ts.slice(-(max - 1)), item]);
631
- return id;
632
- },
633
- [max],
634
- );
635
- return (
636
- <ToastContext.Provider value={toast}>
637
- {children}
638
- <div className="pl-toast-stack" role="region" aria-label="Notifications">
639
- {toasts.map((t) => (
640
- <ToastView key={t.id} toast={t} onDismiss={dismiss} />
641
- ))}
642
- </div>
643
- </ToastContext.Provider>
644
- );
645
- }
646
-
647
- function ToastView({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: string) => void }) {
648
- useEffect(() => {
649
- if (toast.duration <= 0) return;
650
- const h = setTimeout(() => onDismiss(toast.id), toast.duration);
651
- return () => clearTimeout(h);
652
- }, [toast, onDismiss]);
653
- return (
654
- <div className={cx("pl-toast", toast.tone !== "neutral" && `pl-toast--${toast.tone}`)} role="status">
655
- <div className="pl-toast__body">
656
- {toast.title != null && <div className="pl-toast__title">{toast.title}</div>}
657
- <div className="pl-toast__msg">{toast.message}</div>
658
- </div>
659
- <button type="button" className="pl-toast__close" aria-label="Dismiss" onClick={() => onDismiss(toast.id)}>
660
- ×
661
- </button>
662
- </div>
663
- );
664
- }
665
-
666
- /** Returns `toast(opts)` — call to push a notification. Throws outside provider. */
667
- export function useToast() {
668
- const ctx = useContext(ToastContext);
669
- if (!ctx) throw new Error("useToast must be used within a <ToastProvider>");
670
- return ctx;
671
- }
672
-
673
- /** CSS-only hover/focus tooltip. Wrap the trigger; `label` is the bubble. */
674
- export function Tooltip({
675
- label,
676
- side = "top",
677
- children,
678
- }: {
679
- label: ReactNode;
680
- side?: "top" | "bottom" | "left" | "right";
681
- children: ReactNode;
682
- }) {
683
- return (
684
- <span className="pl-tip-wrap">
685
- {children}
686
- <span className={cx("pl-tip", `pl-tip--${side}`)} role="tooltip">
687
- {label}
688
- </span>
689
- </span>
690
- );
691
- }
692
-
693
- // ── Data + status primitives ─────────────────────────────────────────────────
694
-
695
- /** Dense data table on the 4px grid. Compose with THead/TBody/Tr/Th/Td. */
696
- export function Table({ className, ...rest }: TableHTMLAttributes<HTMLTableElement>) {
697
- return <table className={cx("pl-table", className)} {...rest} />;
698
- }
699
- export function THead(props: HTMLAttributes<HTMLTableSectionElement>) {
700
- return <thead {...props} />;
701
- }
702
- export function TBody(props: HTMLAttributes<HTMLTableSectionElement>) {
703
- return <tbody {...props} />;
704
- }
705
- /** Table row. `selected` highlights; an `onClick` makes it hover-interactive. */
706
- export function Tr({
707
- selected,
708
- className,
709
- ...rest
710
- }: HTMLAttributes<HTMLTableRowElement> & { selected?: boolean }) {
711
- return (
712
- <tr
713
- className={cx(selected && "pl-tr--selected", rest.onClick && "pl-tr--interactive", className)}
714
- {...rest}
715
- />
716
- );
717
- }
718
- export function Th({ className, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
719
- return <th className={className} {...rest} />;
720
- }
721
- export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
722
- return <td className={className} {...rest} />;
723
- }
724
-
725
- /** Live/health indicator. `pulse` breathes on the 2s status cadence. */
726
- export function StatusDot({
727
- status = "neutral",
728
- pulse,
729
- label,
730
- }: {
731
- status?: Status;
732
- pulse?: boolean;
733
- label?: ReactNode;
734
- }) {
735
- const dot = (
736
- <span
737
- className={cx("pl-dot", status !== "neutral" && `pl-dot--${status}`, pulse && "pl-dot--pulse")}
738
- aria-hidden
739
- />
740
- );
741
- if (label == null) return dot;
742
- return (
743
- <span className="pl-dot-row">
744
- {dot}
745
- <span className="pl-dot-row__label">{label}</span>
746
- </span>
747
- );
748
- }
749
-
750
- /** Indeterminate spinner (1s linear, brand-restrained). */
751
- export function Spinner({ size = 16, className }: { size?: number; className?: string }) {
752
- return (
753
- <span
754
- className={cx("pl-spinner", className)}
755
- style={{ width: size, height: size }}
756
- role="status"
757
- aria-label="Loading"
758
- />
759
- );
760
- }
761
-
762
- /** Scroll container with brand-styled thin scrollbars. Carries `min-height:0`
763
- * (so it scrolls inside flex/grid parents) + overscroll containment. Pass
764
- * `ariaLabel` to make it a keyboard-focusable, labelled scroll region. */
765
- export function ScrollArea({
766
- ariaLabel,
767
- className,
768
- ...rest
769
- }: HTMLAttributes<HTMLDivElement> & { ariaLabel?: string }) {
770
- const a11y = ariaLabel != null ? { role: "region", "aria-label": ariaLabel, tabIndex: 0 } : {};
771
- return <div className={cx("pl-scroll", className)} {...a11y} {...rest} />;
772
- }
773
-
774
- // ── Form controls (compose with Field, or use standalone) ────────────────────
775
-
776
- export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
777
- return <input className={cx("pl-input", className)} {...rest} />;
778
- }
779
- export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
780
- return <textarea className={cx("pl-input", "pl-textarea", className)} {...rest} />;
781
- }
782
- export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
783
- return (
784
- <select className={cx("pl-input", "pl-select", className)} {...rest}>
785
- {children}
786
- </select>
787
- );
788
- }
789
-
790
- /** Toggle switch. Controlled via `checked` / `onCheckedChange`. */
791
- export function Switch({
792
- checked,
793
- onCheckedChange,
794
- disabled,
795
- label,
796
- className,
797
- }: {
798
- checked?: boolean;
799
- onCheckedChange?: (checked: boolean) => void;
800
- disabled?: boolean;
801
- label?: ReactNode;
802
- className?: string;
803
- }) {
804
- return (
805
- <label className={cx("pl-switch", disabled && "pl-switch--disabled", className)}>
806
- <input
807
- type="checkbox"
808
- className="pl-switch__input"
809
- checked={checked}
810
- disabled={disabled}
811
- onChange={(e) => onCheckedChange?.(e.target.checked)}
812
- />
813
- <span className="pl-switch__track" aria-hidden>
814
- <span className="pl-switch__thumb" />
815
- </span>
816
- {label != null && <span className="pl-switch__label">{label}</span>}
817
- </label>
818
- );
819
- }
820
-
821
- /** Checkbox. Controlled via `checked` / `onCheckedChange`. */
822
- export function Checkbox({
823
- checked,
824
- onCheckedChange,
825
- disabled,
826
- label,
827
- className,
828
- }: {
829
- checked?: boolean;
830
- onCheckedChange?: (checked: boolean) => void;
831
- disabled?: boolean;
832
- label?: ReactNode;
833
- className?: string;
834
- }) {
835
- return (
836
- <label className={cx("pl-checkbox", disabled && "pl-checkbox--disabled", className)}>
837
- <input
838
- type="checkbox"
839
- className="pl-checkbox__input"
840
- checked={checked}
841
- disabled={disabled}
842
- onChange={(e) => onCheckedChange?.(e.target.checked)}
843
- />
844
- <span className="pl-checkbox__box" aria-hidden />
845
- {label != null && <span className="pl-checkbox__label">{label}</span>}
846
- </label>
847
- );
848
- }
849
-
850
- // ── Menu / DropdownMenu (Radix-backed) ───────────────────────────────────────
851
- // Radix owns keyboard nav, focus management, and collision-aware positioning —
852
- // the reason this lives in the DS rather than being re-rolled per app. Styling
853
- // is token-only over --pl-*. Supports a standard click trigger AND imperative
854
- // open-at-coordinates for right-click / context menus.
855
-
856
- export type MenuHandle = {
857
- /** Open the menu. Pass viewport coords (e.g. from a contextmenu event) to
858
- * open at a point; omit to open at the default anchor. */
859
- open: (coords?: { x: number; y: number }) => void;
860
- close: () => void;
861
- };
862
-
863
- export type MenuProps = {
864
- /** Standard trigger (click to open). Omit and drive via the ref's
865
- * open({x,y}) for right-click / imperative menus. */
866
- trigger?: ReactNode;
867
- children: ReactNode;
868
- align?: "start" | "center" | "end";
869
- /** Fires on open/close — e.g. to clear the app's context state on dismiss. */
870
- onOpenChange?: (open: boolean) => void;
871
- };
872
-
873
- export const Menu = forwardRef<MenuHandle, MenuProps>(function Menu(
874
- { trigger, children, align = "start", onOpenChange },
875
- ref,
876
- ) {
877
- const [open, setOpen] = useState(false);
878
- const [coords, setCoords] = useState<{ x: number; y: number } | null>(null);
879
- const setOpenState = useCallback(
880
- (o: boolean) => {
881
- setOpen(o);
882
- onOpenChange?.(o);
883
- },
884
- [onOpenChange],
885
- );
886
- useImperativeHandle(
887
- ref,
888
- () => ({
889
- open: (c) => {
890
- setCoords(c ?? null);
891
- setOpenState(true);
892
- },
893
- close: () => setOpenState(false),
894
- }),
895
- [setOpenState],
896
- );
897
- return (
898
- <RDropdown.Root open={open} onOpenChange={setOpenState} modal={false}>
899
- {trigger != null ? (
900
- <RDropdown.Trigger asChild>{trigger}</RDropdown.Trigger>
901
- ) : (
902
- <RDropdown.Trigger asChild>
903
- <span
904
- aria-hidden
905
- className="pl-menu__anchor"
906
- style={coords ? { position: "fixed", left: coords.x, top: coords.y } : undefined}
907
- />
908
- </RDropdown.Trigger>
909
- )}
910
- <RDropdown.Portal>
911
- <RDropdown.Content className="pl-menu" align={align} sideOffset={4} collisionPadding={8} loop>
912
- {children}
913
- </RDropdown.Content>
914
- </RDropdown.Portal>
915
- </RDropdown.Root>
916
- );
917
- });
918
-
919
- export function MenuItem({
920
- icon,
921
- disabled,
922
- destructive,
923
- onSelect,
924
- children,
925
- }: {
926
- icon?: ReactNode;
927
- disabled?: boolean;
928
- /** Error-toned (delete, remove, etc.). */
929
- destructive?: boolean;
930
- onSelect?: () => void;
931
- children: ReactNode;
932
- }) {
933
- return (
934
- <RDropdown.Item
935
- className={cx("pl-menu__item", destructive && "pl-menu__item--destructive")}
936
- disabled={disabled}
937
- onSelect={onSelect}
938
- >
939
- {icon != null && (
940
- <span className="pl-menu__icon" aria-hidden>
941
- {icon}
942
- </span>
943
- )}
944
- <span className="pl-menu__label">{children}</span>
945
- </RDropdown.Item>
946
- );
947
- }
948
-
949
- export function MenuSeparator() {
950
- return <RDropdown.Separator className="pl-menu__sep" />;
951
- }
952
-
953
- export function MenuLabel({ children }: { children: ReactNode }) {
954
- return <RDropdown.Label className="pl-menu__group-label">{children}</RDropdown.Label>;
955
- }
956
-
957
- /** Nested submenu — put MenuItem/MenuSeparator children inside. */
958
- export function MenuSub({
959
- label,
960
- icon,
961
- children,
962
- }: {
963
- label: ReactNode;
964
- icon?: ReactNode;
965
- children: ReactNode;
966
- }) {
967
- return (
968
- <RDropdown.Sub>
969
- <RDropdown.SubTrigger className="pl-menu__item pl-menu__subtrigger">
970
- {icon != null && (
971
- <span className="pl-menu__icon" aria-hidden>
972
- {icon}
973
- </span>
974
- )}
975
- <span className="pl-menu__label">{label}</span>
976
- <span className="pl-menu__subarrow" aria-hidden>
977
-
978
- </span>
979
- </RDropdown.SubTrigger>
980
- <RDropdown.Portal>
981
- <RDropdown.SubContent className="pl-menu" sideOffset={2} alignOffset={-4} collisionPadding={8}>
982
- {children}
983
- </RDropdown.SubContent>
984
- </RDropdown.Portal>
985
- </RDropdown.Sub>
986
- );
987
- }
988
-
989
- // ── Skeleton (loading placeholder) ───────────────────────────────────────────
990
-
991
- /** Shimmering content-shaped loading placeholder. `lines>1` stacks text bars
992
- * (last one short). Token-driven; static fill under reduced-motion. */
993
- export function Skeleton({
994
- width,
995
- height = 14,
996
- lines,
997
- className,
998
- style,
999
- ...rest
1000
- }: HTMLAttributes<HTMLDivElement> & {
1001
- width?: number | string;
1002
- height?: number | string;
1003
- /** Stack N text-line bars instead of a single bar. */
1004
- lines?: number;
1005
- }) {
1006
- if (lines != null && lines > 1) {
1007
- return (
1008
- <div className={cx("pl-skel-lines", className)} style={style} {...rest}>
1009
- {Array.from({ length: lines }, (_, i) => (
1010
- <div
1011
- key={i}
1012
- className="pl-skel"
1013
- style={{ height, width: i === lines - 1 ? "65%" : (width ?? "100%") }}
1014
- />
1015
- ))}
1016
- </div>
1017
- );
1018
- }
1019
- return (
1020
- <div className={cx("pl-skel", className)} style={{ width: width ?? "100%", height, ...style }} {...rest} />
1021
- );
1022
- }
1023
-
1024
- /** Optional wrapper to group related skeletons (shared layout gap). */
1025
- export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
1026
- return <div className={cx("pl-skel-group", className)} {...rest} />;
1027
- }
1028
-
1029
- // ── App shell (SurfaceRail · MobileNav · AppShell) ───────────────────────────
1030
- // The operator-console shell, converged from protoAgent's production ADR 0035
1031
- // dual-rail layout. All three are dumb + props-driven; persistence (rail order,
1032
- // widths, active surfaces) stays app-side — AppShell is controlled.
1033
-
1034
- export type RailItem = {
1035
- id: string;
1036
- label: string;
1037
- icon: ReactNode;
1038
- /** Count badge (caps at "9+"). Mutually exclusive with `dot`. */
1039
- badge?: number;
1040
- /** Pulsing indicator, no count (e.g. a background stream). */
1041
- dot?: boolean;
1042
- };
1043
-
1044
- /** Vertical icon rail — both the left and right rails render through this.
1045
- * `onContextMenu` is the integration point for the DS `Menu` (right-click →
1046
- * host calls menuRef.open({x,y})); the menu's registry/keying stays app-side. */
1047
- export function SurfaceRail({
1048
- side,
1049
- ariaLabel,
1050
- items,
1051
- activeId,
1052
- onSelect,
1053
- onContextMenu,
1054
- }: {
1055
- side: "left" | "right";
1056
- ariaLabel: string;
1057
- items: RailItem[];
1058
- activeId: string;
1059
- onSelect: (id: string) => void;
1060
- onContextMenu?: (e: ReactMouseEvent, id: string) => void;
1061
- }) {
1062
- return (
1063
- <aside className={cx("pl-rail", side === "right" && "pl-rail--right")} aria-label={ariaLabel}>
1064
- {items.map((it) => (
1065
- <button
1066
- key={it.id}
1067
- type="button"
1068
- className={cx("pl-rail__btn", it.id === activeId && "pl-rail__btn--active")}
1069
- title={it.label}
1070
- aria-label={it.label}
1071
- aria-current={it.id === activeId ? "page" : undefined}
1072
- onClick={() => onSelect(it.id)}
1073
- onContextMenu={onContextMenu ? (e) => onContextMenu(e, it.id) : undefined}
1074
- >
1075
- <span className="pl-rail__icon" aria-hidden>
1076
- {it.icon}
1077
- </span>
1078
- <span className="pl-rail__label">{it.label}</span>
1079
- {it.badge ? (
1080
- <span className="pl-rail__badge">{it.badge > 9 ? "9+" : it.badge}</span>
1081
- ) : it.dot ? (
1082
- <span className="pl-rail__dot" aria-label="active" />
1083
- ) : null}
1084
- </button>
1085
- ))}
1086
- </aside>
1087
- );
1088
- }
1089
-
1090
- export type MobileItem = { id: string; label: string; icon: ReactNode };
1091
-
1092
- /** Mobile shell (<768px): a bottom quick-bar (first 5 pinned surfaces) + a
1093
- * "More" button that opens the full surface list in a DS `Drawer`. */
1094
- export function MobileNav({
1095
- items,
1096
- activeId,
1097
- onSelect,
1098
- quickBarIds,
1099
- }: {
1100
- items: MobileItem[];
1101
- activeId: string;
1102
- onSelect: (id: string) => void;
1103
- /** Surfaces pinned to the bottom bar (first 5 used). */
1104
- quickBarIds: string[];
1105
- }) {
1106
- const [open, setOpen] = useState(false);
1107
- const byId = new Map(items.map((i) => [i.id, i] as const));
1108
- const quick = quickBarIds
1109
- .map((id) => byId.get(id))
1110
- .filter((i): i is MobileItem => Boolean(i))
1111
- .slice(0, 5);
1112
- const pick = (id: string) => {
1113
- onSelect(id);
1114
- setOpen(false);
1115
- };
1116
- return (
1117
- <>
1118
- <nav className="pl-mobilenav" aria-label="Quick surfaces">
1119
- {quick.map((it) => (
1120
- <button
1121
- key={it.id}
1122
- type="button"
1123
- className={cx("pl-mobilenav__tab", it.id === activeId && "pl-mobilenav__tab--active")}
1124
- onClick={() => pick(it.id)}
1125
- >
1126
- <span className="pl-mobilenav__icon" aria-hidden>
1127
- {it.icon}
1128
- </span>
1129
- <span>{it.label}</span>
1130
- </button>
1131
- ))}
1132
- <button type="button" className="pl-mobilenav__tab" onClick={() => setOpen(true)} aria-label="All surfaces">
1133
- <span className="pl-mobilenav__icon" aria-hidden>
1134
- <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
1135
- <path d="M3 6h18M3 12h18M3 18h18" />
1136
- </svg>
1137
- </span>
1138
- <span>More</span>
1139
- </button>
1140
- </nav>
1141
- <Drawer open={open} onClose={() => setOpen(false)} side="right" title="Surfaces" width={280}>
1142
- <div className="pl-mobilenav__list">
1143
- {items.map((it) => (
1144
- <button
1145
- key={it.id}
1146
- type="button"
1147
- className={cx("pl-mobilenav__list-item", it.id === activeId && "pl-mobilenav__list-item--active")}
1148
- onClick={() => pick(it.id)}
1149
- >
1150
- <span className="pl-mobilenav__icon" aria-hidden>
1151
- {it.icon}
1152
- </span>
1153
- <span>{it.label}</span>
1154
- </button>
1155
- ))}
1156
- </div>
1157
- </Drawer>
1158
- </>
1159
- );
1160
- }
1161
-
1162
- /** True below `breakpoint` px (client-only; false on first paint). */
1163
- function useIsMobile(breakpoint: number) {
1164
- const [mobile, setMobile] = useState(false);
1165
- useEffect(() => {
1166
- if (typeof window === "undefined" || !window.matchMedia) return;
1167
- const mq = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
1168
- const update = () => setMobile(mq.matches);
1169
- update();
1170
- mq.addEventListener("change", update);
1171
- return () => mq.removeEventListener("change", update);
1172
- }, [breakpoint]);
1173
- return mobile;
1174
- }
1175
-
1176
- export type AppShellProps = {
1177
- leftItems: RailItem[];
1178
- rightItems: RailItem[];
1179
- activeLeft: string;
1180
- activeRight: string;
1181
- onSelect: (side: "left" | "right", id: string) => void;
1182
- /** Right-click on a rail icon — wire to a DS `Menu` for move/reorder etc. */
1183
- onRailContextMenu?: (side: "left" | "right", e: ReactMouseEvent, id: string) => void;
1184
- leftContent: ReactNode;
1185
- rightContent: ReactNode;
1186
- /** Controlled right-column width (px). */
1187
- rightWidth: number;
1188
- onRightWidthChange: (width: number) => void;
1189
- rightCollapsed?: boolean;
1190
- onCollapse?: (collapsed: boolean) => void;
1191
- minRightWidth?: number;
1192
- maxRightWidth?: number;
1193
- /** Mobile (<breakpoint) config. Omit to disable the mobile shell. */
1194
- mobileItems?: MobileItem[];
1195
- mobileActiveId?: string;
1196
- onMobileSelect?: (id: string) => void;
1197
- quickBarIds?: string[];
1198
- mobileBreakpoint?: number;
1199
- className?: string;
1200
- };
1201
-
1202
- /** The full dual-rail operator shell:
1203
- * `[left rail][left column][resize handle][right column][right rail]`,
1204
- * collapsing to `MobileNav` below `mobileBreakpoint`. Controlled — the host
1205
- * owns the surface registry and persists rail order / widths / active state. */
1206
- export function AppShell({
1207
- leftItems,
1208
- rightItems,
1209
- activeLeft,
1210
- activeRight,
1211
- onSelect,
1212
- onRailContextMenu,
1213
- leftContent,
1214
- rightContent,
1215
- rightWidth,
1216
- onRightWidthChange,
1217
- rightCollapsed = false,
1218
- onCollapse,
1219
- minRightWidth = 280,
1220
- maxRightWidth = 720,
1221
- mobileItems,
1222
- mobileActiveId,
1223
- onMobileSelect,
1224
- quickBarIds,
1225
- mobileBreakpoint = 768,
1226
- className,
1227
- }: AppShellProps) {
1228
- const isMobile = useIsMobile(mobileBreakpoint);
1229
- const drag = useRef<{ startX: number; startW: number } | null>(null);
1230
- const clamp = useCallback(
1231
- (w: number) => Math.min(maxRightWidth, Math.max(minRightWidth, w)),
1232
- [minRightWidth, maxRightWidth],
1233
- );
1234
-
1235
- const onPointerDown = useCallback(
1236
- (e: ReactPointerEvent<HTMLDivElement>) => {
1237
- e.preventDefault();
1238
- drag.current = { startX: e.clientX, startW: rightWidth };
1239
- e.currentTarget.setPointerCapture(e.pointerId);
1240
- },
1241
- [rightWidth],
1242
- );
1243
- const onPointerMove = useCallback(
1244
- (e: ReactPointerEvent<HTMLDivElement>) => {
1245
- if (!drag.current) return;
1246
- onRightWidthChange(clamp(drag.current.startW + (drag.current.startX - e.clientX)));
1247
- },
1248
- [clamp, onRightWidthChange],
1249
- );
1250
- const onPointerUp = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
1251
- drag.current = null;
1252
- e.currentTarget.releasePointerCapture?.(e.pointerId);
1253
- }, []);
1254
- const onKeyDown = useCallback(
1255
- (e: ReactKeyboardEvent<HTMLDivElement>) => {
1256
- const step = e.shiftKey ? 48 : 16;
1257
- if (e.key === "ArrowLeft") {
1258
- e.preventDefault();
1259
- onRightWidthChange(clamp(rightWidth + step));
1260
- } else if (e.key === "ArrowRight") {
1261
- e.preventDefault();
1262
- onRightWidthChange(clamp(rightWidth - step));
1263
- }
1264
- },
1265
- [rightWidth, clamp, onRightWidthChange],
1266
- );
1267
-
1268
- if (isMobile && mobileItems && onMobileSelect && quickBarIds) {
1269
- return (
1270
- <div className={cx("pl-appshell", "pl-appshell--mobile", className)}>
1271
- <div className="pl-appshell__mobile-stage">{leftContent}</div>
1272
- <MobileNav
1273
- items={mobileItems}
1274
- activeId={mobileActiveId ?? activeLeft}
1275
- onSelect={onMobileSelect}
1276
- quickBarIds={quickBarIds}
1277
- />
1278
- </div>
1279
- );
1280
- }
1281
-
1282
- const showRight = !rightCollapsed && rightItems.length > 0;
1283
- return (
1284
- <div className={cx("pl-appshell", className)}>
1285
- <SurfaceRail
1286
- side="left"
1287
- ariaLabel="Left surfaces"
1288
- items={leftItems}
1289
- activeId={activeLeft}
1290
- onSelect={(id) => onSelect("left", id)}
1291
- onContextMenu={onRailContextMenu ? (e, id) => onRailContextMenu("left", e, id) : undefined}
1292
- />
1293
- <main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
1294
- {showRight && (
1295
- <div
1296
- className="pl-appshell__handle"
1297
- role="separator"
1298
- aria-orientation="vertical"
1299
- aria-label="Resize right panel"
1300
- aria-valuenow={rightWidth}
1301
- aria-valuemin={minRightWidth}
1302
- aria-valuemax={maxRightWidth}
1303
- tabIndex={0}
1304
- onPointerDown={onPointerDown}
1305
- onPointerMove={onPointerMove}
1306
- onPointerUp={onPointerUp}
1307
- onKeyDown={onKeyDown}
1308
- onDoubleClick={() => onCollapse?.(true)}
1309
- />
1310
- )}
1311
- {showRight && (
1312
- <aside
1313
- className="pl-appshell__col pl-appshell__col--right"
1314
- style={{ flex: `0 0 ${rightWidth}px`, width: rightWidth }}
1315
- >
1316
- {rightContent}
1317
- </aside>
1318
- )}
1319
- <SurfaceRail
1320
- side="right"
1321
- ariaLabel="Right surfaces"
1322
- items={rightItems}
1323
- activeId={activeRight}
1324
- onSelect={(id) => onSelect("right", id)}
1325
- onContextMenu={onRailContextMenu ? (e, id) => onRailContextMenu("right", e, id) : undefined}
1326
- />
1327
- </div>
1328
- );
1329
- }