@protolabsai/ui 0.2.0 → 0.4.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 CHANGED
@@ -3,7 +3,19 @@
3
3
  * Every component is className-only over the --pl-* custom properties, so it
4
4
  * inherits the locked brand and restyles for free when a token changes.
5
5
  */
6
- import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
6
+ import type {
7
+ ButtonHTMLAttributes,
8
+ HTMLAttributes,
9
+ InputHTMLAttributes,
10
+ ReactNode,
11
+ RefObject,
12
+ SelectHTMLAttributes,
13
+ TableHTMLAttributes,
14
+ TdHTMLAttributes,
15
+ TextareaHTMLAttributes,
16
+ ThHTMLAttributes,
17
+ } from "react";
18
+ import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
7
19
  import "./styles.css";
8
20
 
9
21
  const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
@@ -225,3 +237,537 @@ export function TextLink({
225
237
  />
226
238
  );
227
239
  }
240
+
241
+ // ── App primitives (cockpit + future studio apps) ────────────────────────────
242
+
243
+ export type TabItem = { id: string; label: ReactNode; disabled?: boolean; locked?: boolean };
244
+
245
+ /** A horizontal tab strip with disabled/locked support (gated workflows). */
246
+ export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: string; onSelect: (id: string) => void }) {
247
+ return (
248
+ <div className="pl-tabs" role="tablist">
249
+ {items.map((t) => (
250
+ <button
251
+ key={t.id}
252
+ role="tab"
253
+ type="button"
254
+ aria-selected={t.id === active}
255
+ className={cx("pl-tab", t.id === active && "pl-tab--active")}
256
+ disabled={t.disabled}
257
+ onClick={() => onSelect(t.id)}
258
+ >
259
+ {t.label}
260
+ {t.locked ? (
261
+ <span className="pl-tab__lock" aria-hidden>
262
+ 🔒
263
+ </span>
264
+ ) : null}
265
+ </button>
266
+ ))}
267
+ </div>
268
+ );
269
+ }
270
+
271
+ /** A horizontal kanban board. Wrap BoardColumn children. */
272
+ export function Board({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
273
+ return <div className={cx("pl-board", className)} {...rest} />;
274
+ }
275
+
276
+ export function BoardColumn({ title, count, children }: { title: ReactNode; count?: ReactNode; children: ReactNode }) {
277
+ return (
278
+ <div className="pl-board-col">
279
+ <div className="pl-board-col__head">
280
+ <span>{title}</span>
281
+ {count != null ? <span className="pl-board-col__count">{count}</span> : null}
282
+ </div>
283
+ <div className="pl-board-col__body">{children}</div>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ export function BoardCard({ className, ...rest }: ButtonHTMLAttributes<HTMLButtonElement>) {
289
+ return <button type="button" className={cx("pl-board-card", className)} {...rest} />;
290
+ }
291
+
292
+ /** A labeled input/textarea bound to a string value (form fields, editors). */
293
+ export function Field({
294
+ label,
295
+ value,
296
+ multiline,
297
+ readOnly,
298
+ placeholder,
299
+ onValueChange,
300
+ className,
301
+ }: {
302
+ label: ReactNode;
303
+ value?: string;
304
+ multiline?: boolean;
305
+ readOnly?: boolean;
306
+ placeholder?: string;
307
+ onValueChange?: (value: string) => void;
308
+ className?: string;
309
+ }) {
310
+ const shared = {
311
+ className: "pl-field__input",
312
+ value,
313
+ readOnly,
314
+ placeholder,
315
+ onChange: (e: { target: { value: string } }) => onValueChange?.(e.target.value),
316
+ };
317
+ return (
318
+ <label className={cx("pl-field", className)}>
319
+ <span className="pl-field__label">{label}</span>
320
+ {multiline ? <textarea {...shared} /> : <input {...shared} />}
321
+ </label>
322
+ );
323
+ }
324
+
325
+ // ── Overlays & feedback ──────────────────────────────────────────────────────
326
+
327
+ /** Esc-to-close + body scroll-lock while `open`. Shared by Dialog + Drawer. */
328
+ function useOverlayDismiss(open: boolean, onClose?: () => void) {
329
+ useEffect(() => {
330
+ if (!open) return;
331
+ const onKey = (e: KeyboardEvent) => {
332
+ if (e.key === "Escape") onClose?.();
333
+ };
334
+ document.addEventListener("keydown", onKey);
335
+ const prev = document.body.style.overflow;
336
+ document.body.style.overflow = "hidden";
337
+ return () => {
338
+ document.removeEventListener("keydown", onKey);
339
+ document.body.style.overflow = prev;
340
+ };
341
+ }, [open, onClose]);
342
+ }
343
+
344
+ /** Move focus into `ref` on open and cycle Tab within it (a11y modal trap). */
345
+ function useFocusTrap(ref: RefObject<HTMLElement | null>, open: boolean) {
346
+ useEffect(() => {
347
+ const node = ref.current;
348
+ if (!open || !node) return;
349
+ const focusables = () =>
350
+ Array.from(
351
+ node.querySelectorAll<HTMLElement>(
352
+ 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])',
353
+ ),
354
+ ).filter((el) => el.offsetParent !== null);
355
+ (focusables()[0] ?? node).focus();
356
+ const onKey = (e: KeyboardEvent) => {
357
+ if (e.key !== "Tab") return;
358
+ const items = focusables();
359
+ if (items.length === 0) return;
360
+ const first = items[0];
361
+ const last = items[items.length - 1];
362
+ if (e.shiftKey && document.activeElement === first) {
363
+ e.preventDefault();
364
+ last.focus();
365
+ } else if (!e.shiftKey && document.activeElement === last) {
366
+ e.preventDefault();
367
+ first.focus();
368
+ }
369
+ };
370
+ node.addEventListener("keydown", onKey);
371
+ return () => node.removeEventListener("keydown", onKey);
372
+ }, [ref, open]);
373
+ }
374
+
375
+ /** Modal dialog — scrim + centered card, Esc / backdrop close, focus trap.
376
+ * Controlled: render with `open` and handle `onClose`. */
377
+ export function Dialog({
378
+ open,
379
+ onClose,
380
+ title,
381
+ children,
382
+ footer,
383
+ width,
384
+ className,
385
+ }: {
386
+ open: boolean;
387
+ onClose?: () => void;
388
+ title?: ReactNode;
389
+ children?: ReactNode;
390
+ /** Action row pinned to the dialog foot (e.g. Cancel / Confirm buttons). */
391
+ footer?: ReactNode;
392
+ width?: number | string;
393
+ className?: string;
394
+ }) {
395
+ const ref = useRef<HTMLDivElement>(null);
396
+ const labelId = useId();
397
+ useOverlayDismiss(open, onClose);
398
+ useFocusTrap(ref, open);
399
+ if (!open) return null;
400
+ return (
401
+ <div
402
+ className="pl-overlay"
403
+ onMouseDown={(e) => {
404
+ if (e.target === e.currentTarget) onClose?.();
405
+ }}
406
+ >
407
+ <div
408
+ ref={ref}
409
+ className={cx("pl-dialog", className)}
410
+ role="dialog"
411
+ aria-modal="true"
412
+ aria-labelledby={title != null ? labelId : undefined}
413
+ tabIndex={-1}
414
+ style={width != null ? { width, maxWidth: "100%" } : undefined}
415
+ >
416
+ {title != null && (
417
+ <div className="pl-dialog__head">
418
+ <div className="pl-dialog__title" id={labelId}>
419
+ {title}
420
+ </div>
421
+ {onClose && (
422
+ <button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
423
+ ×
424
+ </button>
425
+ )}
426
+ </div>
427
+ )}
428
+ {children != null && <div className="pl-dialog__body">{children}</div>}
429
+ {footer != null && <div className="pl-dialog__foot">{footer}</div>}
430
+ </div>
431
+ </div>
432
+ );
433
+ }
434
+
435
+ /** Destructive-confirm convenience over Dialog. `destructive` reddens confirm. */
436
+ export function ConfirmDialog({
437
+ open,
438
+ title,
439
+ children,
440
+ confirmLabel = "Confirm",
441
+ cancelLabel = "Cancel",
442
+ destructive,
443
+ onConfirm,
444
+ onClose,
445
+ }: {
446
+ open: boolean;
447
+ title?: ReactNode;
448
+ children?: ReactNode;
449
+ confirmLabel?: ReactNode;
450
+ cancelLabel?: ReactNode;
451
+ destructive?: boolean;
452
+ onConfirm?: () => void;
453
+ onClose?: () => void;
454
+ }) {
455
+ return (
456
+ <Dialog
457
+ open={open}
458
+ onClose={onClose}
459
+ title={title}
460
+ width={420}
461
+ footer={
462
+ <>
463
+ <Button onClick={onClose}>{cancelLabel}</Button>
464
+ <Button
465
+ variant="primary"
466
+ className={cx(destructive && "pl-btn--danger")}
467
+ onClick={() => onConfirm?.()}
468
+ >
469
+ {confirmLabel}
470
+ </Button>
471
+ </>
472
+ }
473
+ >
474
+ {children}
475
+ </Dialog>
476
+ );
477
+ }
478
+
479
+ /** Slide-in side panel / sheet. Esc / backdrop close, focus trap. */
480
+ export function Drawer({
481
+ open,
482
+ onClose,
483
+ side = "right",
484
+ title,
485
+ children,
486
+ footer,
487
+ width,
488
+ className,
489
+ }: {
490
+ open: boolean;
491
+ onClose?: () => void;
492
+ side?: "left" | "right";
493
+ title?: ReactNode;
494
+ children?: ReactNode;
495
+ footer?: ReactNode;
496
+ width?: number | string;
497
+ className?: string;
498
+ }) {
499
+ const ref = useRef<HTMLDivElement>(null);
500
+ const labelId = useId();
501
+ useOverlayDismiss(open, onClose);
502
+ useFocusTrap(ref, open);
503
+ if (!open) return null;
504
+ return (
505
+ <div
506
+ className="pl-overlay pl-overlay--drawer"
507
+ onMouseDown={(e) => {
508
+ if (e.target === e.currentTarget) onClose?.();
509
+ }}
510
+ >
511
+ <div
512
+ ref={ref}
513
+ className={cx("pl-drawer", `pl-drawer--${side}`, className)}
514
+ role="dialog"
515
+ aria-modal="true"
516
+ aria-labelledby={title != null ? labelId : undefined}
517
+ tabIndex={-1}
518
+ style={width != null ? { width, maxWidth: "100%" } : undefined}
519
+ >
520
+ {title != null && (
521
+ <div className="pl-drawer__head">
522
+ <div className="pl-drawer__title" id={labelId}>
523
+ {title}
524
+ </div>
525
+ {onClose && (
526
+ <button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
527
+ ×
528
+ </button>
529
+ )}
530
+ </div>
531
+ )}
532
+ <div className="pl-drawer__body">{children}</div>
533
+ {footer != null && <div className="pl-drawer__foot">{footer}</div>}
534
+ </div>
535
+ </div>
536
+ );
537
+ }
538
+
539
+ type ToastOptions = { tone?: Status; title?: ReactNode; message: ReactNode; duration?: number };
540
+ type ToastItem = Required<Pick<ToastOptions, "tone" | "message" | "duration">> & {
541
+ id: string;
542
+ title?: ReactNode;
543
+ };
544
+
545
+ const ToastContext = createContext<((opts: ToastOptions) => string) | null>(null);
546
+
547
+ /** Wrap the app once. Exposes `useToast()` to push transient notifications. */
548
+ export function ToastProvider({ children, max = 4 }: { children: ReactNode; max?: number }) {
549
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
550
+ const seq = useRef(0);
551
+ const dismiss = useCallback((id: string) => setToasts((ts) => ts.filter((t) => t.id !== id)), []);
552
+ const toast = useCallback(
553
+ (opts: ToastOptions) => {
554
+ const id = `t${(seq.current += 1)}`;
555
+ const item: ToastItem = {
556
+ id,
557
+ tone: opts.tone ?? "neutral",
558
+ title: opts.title,
559
+ message: opts.message,
560
+ duration: opts.duration ?? 4000,
561
+ };
562
+ setToasts((ts) => [...ts.slice(-(max - 1)), item]);
563
+ return id;
564
+ },
565
+ [max],
566
+ );
567
+ return (
568
+ <ToastContext.Provider value={toast}>
569
+ {children}
570
+ <div className="pl-toast-stack" role="region" aria-label="Notifications">
571
+ {toasts.map((t) => (
572
+ <ToastView key={t.id} toast={t} onDismiss={dismiss} />
573
+ ))}
574
+ </div>
575
+ </ToastContext.Provider>
576
+ );
577
+ }
578
+
579
+ function ToastView({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: string) => void }) {
580
+ useEffect(() => {
581
+ if (toast.duration <= 0) return;
582
+ const h = setTimeout(() => onDismiss(toast.id), toast.duration);
583
+ return () => clearTimeout(h);
584
+ }, [toast, onDismiss]);
585
+ return (
586
+ <div className={cx("pl-toast", toast.tone !== "neutral" && `pl-toast--${toast.tone}`)} role="status">
587
+ <div className="pl-toast__body">
588
+ {toast.title != null && <div className="pl-toast__title">{toast.title}</div>}
589
+ <div className="pl-toast__msg">{toast.message}</div>
590
+ </div>
591
+ <button type="button" className="pl-toast__close" aria-label="Dismiss" onClick={() => onDismiss(toast.id)}>
592
+ ×
593
+ </button>
594
+ </div>
595
+ );
596
+ }
597
+
598
+ /** Returns `toast(opts)` — call to push a notification. Throws outside provider. */
599
+ export function useToast() {
600
+ const ctx = useContext(ToastContext);
601
+ if (!ctx) throw new Error("useToast must be used within a <ToastProvider>");
602
+ return ctx;
603
+ }
604
+
605
+ /** CSS-only hover/focus tooltip. Wrap the trigger; `label` is the bubble. */
606
+ export function Tooltip({
607
+ label,
608
+ side = "top",
609
+ children,
610
+ }: {
611
+ label: ReactNode;
612
+ side?: "top" | "bottom" | "left" | "right";
613
+ children: ReactNode;
614
+ }) {
615
+ return (
616
+ <span className="pl-tip-wrap">
617
+ {children}
618
+ <span className={cx("pl-tip", `pl-tip--${side}`)} role="tooltip">
619
+ {label}
620
+ </span>
621
+ </span>
622
+ );
623
+ }
624
+
625
+ // ── Data + status primitives ─────────────────────────────────────────────────
626
+
627
+ /** Dense data table on the 4px grid. Compose with THead/TBody/Tr/Th/Td. */
628
+ export function Table({ className, ...rest }: TableHTMLAttributes<HTMLTableElement>) {
629
+ return <table className={cx("pl-table", className)} {...rest} />;
630
+ }
631
+ export function THead(props: HTMLAttributes<HTMLTableSectionElement>) {
632
+ return <thead {...props} />;
633
+ }
634
+ export function TBody(props: HTMLAttributes<HTMLTableSectionElement>) {
635
+ return <tbody {...props} />;
636
+ }
637
+ /** Table row. `selected` highlights; an `onClick` makes it hover-interactive. */
638
+ export function Tr({
639
+ selected,
640
+ className,
641
+ ...rest
642
+ }: HTMLAttributes<HTMLTableRowElement> & { selected?: boolean }) {
643
+ return (
644
+ <tr
645
+ className={cx(selected && "pl-tr--selected", rest.onClick && "pl-tr--interactive", className)}
646
+ {...rest}
647
+ />
648
+ );
649
+ }
650
+ export function Th({ className, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
651
+ return <th className={className} {...rest} />;
652
+ }
653
+ export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
654
+ return <td className={className} {...rest} />;
655
+ }
656
+
657
+ /** Live/health indicator. `pulse` breathes on the 2s status cadence. */
658
+ export function StatusDot({
659
+ status = "neutral",
660
+ pulse,
661
+ label,
662
+ }: {
663
+ status?: Status;
664
+ pulse?: boolean;
665
+ label?: ReactNode;
666
+ }) {
667
+ const dot = (
668
+ <span
669
+ className={cx("pl-dot", status !== "neutral" && `pl-dot--${status}`, pulse && "pl-dot--pulse")}
670
+ aria-hidden
671
+ />
672
+ );
673
+ if (label == null) return dot;
674
+ return (
675
+ <span className="pl-dot-row">
676
+ {dot}
677
+ <span className="pl-dot-row__label">{label}</span>
678
+ </span>
679
+ );
680
+ }
681
+
682
+ /** Indeterminate spinner (1s linear, brand-restrained). */
683
+ export function Spinner({ size = 16, className }: { size?: number; className?: string }) {
684
+ return (
685
+ <span
686
+ className={cx("pl-spinner", className)}
687
+ style={{ width: size, height: size }}
688
+ role="status"
689
+ aria-label="Loading"
690
+ />
691
+ );
692
+ }
693
+
694
+ /** Scroll container with brand-styled thin scrollbars. */
695
+ export function ScrollArea({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
696
+ return <div className={cx("pl-scroll", className)} {...rest} />;
697
+ }
698
+
699
+ // ── Form controls (compose with Field, or use standalone) ────────────────────
700
+
701
+ export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
702
+ return <input className={cx("pl-input", className)} {...rest} />;
703
+ }
704
+ export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
705
+ return <textarea className={cx("pl-input", "pl-textarea", className)} {...rest} />;
706
+ }
707
+ export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
708
+ return (
709
+ <select className={cx("pl-input", "pl-select", className)} {...rest}>
710
+ {children}
711
+ </select>
712
+ );
713
+ }
714
+
715
+ /** Toggle switch. Controlled via `checked` / `onCheckedChange`. */
716
+ export function Switch({
717
+ checked,
718
+ onCheckedChange,
719
+ disabled,
720
+ label,
721
+ className,
722
+ }: {
723
+ checked?: boolean;
724
+ onCheckedChange?: (checked: boolean) => void;
725
+ disabled?: boolean;
726
+ label?: ReactNode;
727
+ className?: string;
728
+ }) {
729
+ return (
730
+ <label className={cx("pl-switch", disabled && "pl-switch--disabled", className)}>
731
+ <input
732
+ type="checkbox"
733
+ className="pl-switch__input"
734
+ checked={checked}
735
+ disabled={disabled}
736
+ onChange={(e) => onCheckedChange?.(e.target.checked)}
737
+ />
738
+ <span className="pl-switch__track" aria-hidden>
739
+ <span className="pl-switch__thumb" />
740
+ </span>
741
+ {label != null && <span className="pl-switch__label">{label}</span>}
742
+ </label>
743
+ );
744
+ }
745
+
746
+ /** Checkbox. Controlled via `checked` / `onCheckedChange`. */
747
+ export function Checkbox({
748
+ checked,
749
+ onCheckedChange,
750
+ disabled,
751
+ label,
752
+ className,
753
+ }: {
754
+ checked?: boolean;
755
+ onCheckedChange?: (checked: boolean) => void;
756
+ disabled?: boolean;
757
+ label?: ReactNode;
758
+ className?: string;
759
+ }) {
760
+ return (
761
+ <label className={cx("pl-checkbox", disabled && "pl-checkbox--disabled", className)}>
762
+ <input
763
+ type="checkbox"
764
+ className="pl-checkbox__input"
765
+ checked={checked}
766
+ disabled={disabled}
767
+ onChange={(e) => onCheckedChange?.(e.target.checked)}
768
+ />
769
+ <span className="pl-checkbox__box" aria-hidden />
770
+ {label != null && <span className="pl-checkbox__label">{label}</span>}
771
+ </label>
772
+ );
773
+ }