@protolabsai/ui 0.16.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protolabsai/ui",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -35,6 +35,7 @@
35
35
  "@radix-ui/react-popover": "^1.1.16",
36
36
  "@types/react": "^19.0.0",
37
37
  "@types/react-dom": "^19.0.0",
38
+ "culori": "^4.0.2",
38
39
  "@protolabsai/design": "0.5.0"
39
40
  },
40
41
  "peerDependencies": {
@@ -44,6 +45,7 @@
44
45
  "devDependencies": {
45
46
  "@storybook/react": "^10.4.1",
46
47
  "@storybook/react-vite": "^10.4.1",
48
+ "@types/culori": "^4.0.1",
47
49
  "@vitejs/plugin-react": "^4.3.4",
48
50
  "react": "^19.0.0",
49
51
  "react-dom": "^19.0.0",
@@ -46,6 +46,7 @@ export const Full: Story = {
46
46
  const [activeRight, setActiveRight] = useState("inbox");
47
47
  const [rightWidth, setRightWidth] = useState(360);
48
48
  const [collapsed, setCollapsed] = useState(false);
49
+ const [leftCollapsed, setLeftCollapsed] = useState(false);
49
50
  const leftItems = order.left.map((id) => BY_ID.get(id)!);
50
51
  const rightItems = order.right.map((id) => BY_ID.get(id)!);
51
52
  const mobile: MobileItem[] = [...LEFT, ...RIGHT].map((i) => ({ id: i.id, label: i.label, icon: i.icon }));
@@ -58,8 +59,10 @@ export const Full: Story = {
58
59
  activeLeft={activeLeft}
59
60
  activeRight={activeRight}
60
61
  onSelect={(side, id) => {
61
- if (side === "left") setActiveLeft(id);
62
- else {
62
+ if (side === "left") {
63
+ setActiveLeft(id);
64
+ setLeftCollapsed(false);
65
+ } else {
63
66
  setActiveRight(id);
64
67
  setCollapsed(false);
65
68
  }
@@ -68,9 +71,21 @@ export const Full: Story = {
68
71
  onRightWidthChange={setRightWidth}
69
72
  rightCollapsed={collapsed}
70
73
  onCollapse={setCollapsed}
74
+ leftCollapsed={leftCollapsed}
75
+ onLeftCollapse={setLeftCollapsed}
71
76
  leftContent={
72
77
  <>
73
- <PanelHeader title={activeLeft} actions={<Button size="sm">Action</Button>} />
78
+ <PanelHeader
79
+ title={activeLeft}
80
+ actions={
81
+ <>
82
+ <Button size="sm">Action</Button>
83
+ <Button size="sm" variant="ghost" onClick={() => setLeftCollapsed(true)}>
84
+ Close
85
+ </Button>
86
+ </>
87
+ }
88
+ />
74
89
  {surfaceBody(activeLeft)}
75
90
  </>
76
91
  }
package/src/app-shell.tsx CHANGED
@@ -274,6 +274,14 @@ export type AppShellProps = {
274
274
  onCollapse?: (collapsed: boolean) => void;
275
275
  minRightWidth?: number;
276
276
  maxRightWidth?: number;
277
+ /** Controlled left-column collapse — drives the left close button and the
278
+ * overdrag-left snap. Mirrors `rightCollapsed`/`onCollapse`. */
279
+ leftCollapsed?: boolean;
280
+ onLeftCollapse?: (collapsed: boolean) => void;
281
+ /** Min left-column width (px). The single divider holds the left column here
282
+ * on the way in; dragging `OVERDRAG` px further snaps the left column closed
283
+ * (it stays `leftCollapsed` until the host restores it). */
284
+ minLeftWidth?: number;
277
285
  /** Mobile (<breakpoint) config. Omit to disable the mobile shell. */
278
286
  mobileItems?: MobileItem[];
279
287
  mobileActiveId?: string;
@@ -309,6 +317,9 @@ export function AppShell({
309
317
  onCollapse,
310
318
  minRightWidth = 280,
311
319
  maxRightWidth = 720,
320
+ leftCollapsed = false,
321
+ onLeftCollapse,
322
+ minLeftWidth = 280,
312
323
  mobileItems,
313
324
  mobileActiveId,
314
325
  onMobileSelect,
@@ -321,25 +332,54 @@ export function AppShell({
321
332
  const isMobile = useIsMobile(mobileBreakpoint);
322
333
 
323
334
  // ── resize handle ──
324
- const drag = useRef<{ startX: number; startW: number } | null>(null);
325
- const clamp = useCallback(
326
- (w: number) => Math.min(maxRightWidth, Math.max(minRightWidth, w)),
327
- [minRightWidth, maxRightWidth],
335
+ // One divider, zero-sum: the right column is width-controlled and the left
336
+ // flexes to fill, so dragging grows one column as it shrinks the other. Each
337
+ // side is held at its min on the way in; OVERDRAG px past that min snaps that
338
+ // side closed (collapse is controlled — the host restores it).
339
+ const OVERDRAG = 64;
340
+ const leftColRef = useRef<HTMLElement | null>(null);
341
+ const drag = useRef<{ startX: number; startW: number; startLeftW: number } | null>(null);
342
+ /** Resize `rightWidth` to `raw` px, holding both columns at their minimums. */
343
+ const commit = useCallback(
344
+ (raw: number, startLeftW: number, startW: number) => {
345
+ const upper = Math.max(minRightWidth, Math.min(maxRightWidth, startLeftW + startW - minLeftWidth));
346
+ onRightWidthChange(Math.min(upper, Math.max(minRightWidth, raw)));
347
+ },
348
+ [minRightWidth, maxRightWidth, minLeftWidth, onRightWidthChange],
328
349
  );
329
350
  const onPointerDown = useCallback(
330
351
  (e: ReactPointerEvent<HTMLDivElement>) => {
331
352
  e.preventDefault();
332
- drag.current = { startX: e.clientX, startW: rightWidth };
353
+ drag.current = { startX: e.clientX, startW: rightWidth, startLeftW: leftColRef.current?.clientWidth ?? 0 };
333
354
  e.currentTarget.setPointerCapture(e.pointerId);
334
355
  },
335
356
  [rightWidth],
336
357
  );
337
358
  const onPointerMove = useCallback(
338
359
  (e: ReactPointerEvent<HTMLDivElement>) => {
339
- if (!drag.current) return;
340
- onRightWidthChange(clamp(drag.current.startW + (drag.current.startX - e.clientX)));
360
+ const d = drag.current;
361
+ if (!d) return;
362
+ const raw = d.startW + (d.startX - e.clientX);
363
+ const predictedLeft = d.startLeftW - (raw - d.startW);
364
+ const endDrag = () => {
365
+ drag.current = null;
366
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
367
+ };
368
+ // Overdrag toward the right rail → snap the right column closed.
369
+ if (onCollapse && raw < minRightWidth - OVERDRAG) {
370
+ endDrag();
371
+ onCollapse(true);
372
+ return;
373
+ }
374
+ // Overdrag toward the left rail → snap the left column closed.
375
+ if (onLeftCollapse && predictedLeft < minLeftWidth - OVERDRAG) {
376
+ endDrag();
377
+ onLeftCollapse(true);
378
+ return;
379
+ }
380
+ commit(raw, d.startLeftW, d.startW);
341
381
  },
342
- [clamp, onRightWidthChange],
382
+ [minRightWidth, minLeftWidth, onCollapse, onLeftCollapse, commit],
343
383
  );
344
384
  const onPointerUp = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
345
385
  drag.current = null;
@@ -347,16 +387,14 @@ export function AppShell({
347
387
  }, []);
348
388
  const onKeyDown = useCallback(
349
389
  (e: ReactKeyboardEvent<HTMLDivElement>) => {
390
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
391
+ e.preventDefault();
350
392
  const step = e.shiftKey ? 48 : 16;
351
- if (e.key === "ArrowLeft") {
352
- e.preventDefault();
353
- onRightWidthChange(clamp(rightWidth + step));
354
- } else if (e.key === "ArrowRight") {
355
- e.preventDefault();
356
- onRightWidthChange(clamp(rightWidth - step));
357
- }
393
+ const startLeftW = leftColRef.current?.clientWidth ?? 0;
394
+ // ArrowLeft grows the right column (shrinks the left); ArrowRight reverses.
395
+ commit(rightWidth + (e.key === "ArrowLeft" ? step : -step), startLeftW, rightWidth);
358
396
  },
359
- [rightWidth, clamp, onRightWidthChange],
397
+ [rightWidth, commit],
360
398
  );
361
399
 
362
400
  // ── rail drag-and-drop (only when onRailReorder is provided) ──
@@ -450,6 +488,8 @@ export function AppShell({
450
488
  }
451
489
 
452
490
  const showRight = !rightCollapsed && rightItems.length > 0;
491
+ const showLeft = !leftCollapsed;
492
+ const showHandle = showLeft && showRight;
453
493
  const ctxLeft = onRailContextMenu ? (e: ReactMouseEvent, id: string) => onRailContextMenu("left", e, id) : undefined;
454
494
  const ctxRight = onRailContextMenu ? (e: ReactMouseEvent, id: string) => onRailContextMenu("right", e, id) : undefined;
455
495
 
@@ -458,16 +498,21 @@ export function AppShell({
458
498
  {header != null && <div className="pl-appshell__header">{header}</div>}
459
499
  <div className="pl-appshell">
460
500
  {leftRail}
461
- <main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
462
- {showRight && (
501
+ {showLeft && (
502
+ <main ref={leftColRef} className="pl-appshell__col pl-appshell__col--left">
503
+ {leftContent}
504
+ </main>
505
+ )}
506
+ {showHandle && (
463
507
  <div
464
508
  className="pl-appshell__handle"
465
509
  role="separator"
466
510
  aria-orientation="vertical"
467
- aria-label="Resize right panel"
511
+ aria-label="Resize panels"
468
512
  aria-valuenow={rightWidth}
469
513
  aria-valuemin={minRightWidth}
470
514
  aria-valuemax={maxRightWidth}
515
+ aria-valuetext={`Right panel ${rightWidth}px`}
471
516
  tabIndex={0}
472
517
  onPointerDown={onPointerDown}
473
518
  onPointerMove={onPointerMove}
@@ -478,8 +523,8 @@ export function AppShell({
478
523
  )}
479
524
  {showRight && (
480
525
  <aside
481
- className="pl-appshell__col pl-appshell__col--right"
482
- style={{ flex: `0 0 ${rightWidth}px`, width: rightWidth }}
526
+ className={cx("pl-appshell__col pl-appshell__col--right", !showLeft && "pl-appshell__col--fill")}
527
+ style={showLeft ? { flex: `0 0 ${rightWidth}px`, width: rightWidth } : undefined}
483
528
  >
484
529
  {rightContent}
485
530
  </aside>
@@ -222,6 +222,13 @@
222
222
  border-left: var(--pl-border-width) solid var(--pl-color-border);
223
223
  }
224
224
 
225
+ /* When the left column is collapsed, the surviving column fills the stage
226
+ (no fixed width, no divider) instead of sitting at its controlled width. */
227
+ .pl-appshell__col--fill {
228
+ flex: 1 1 auto;
229
+ border-left: none;
230
+ }
231
+
225
232
  .pl-appshell__handle {
226
233
  flex: 0 0 auto;
227
234
  width: 5px;
@@ -116,7 +116,8 @@
116
116
  border: none;
117
117
  border-radius: calc(var(--pl-radius) - 1px);
118
118
  }
119
- .pl-theme-row__swatch--static {
119
+ .pl-theme-row__swatch--none {
120
+ border-color: transparent;
120
121
  cursor: default;
121
122
  }
122
123
  .pl-theme-row__label {
package/src/theming.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { converter, formatHex, formatRgb, parse } from "culori";
2
3
  import tokens from "@protolabsai/design/tokens.json";
3
4
  import { Button } from "./primitives";
4
5
  import { cx } from "./internal";
@@ -54,7 +55,59 @@ const prettyLabel = (v: string) =>
54
55
  .trim()
55
56
  .replace(/\b\w/g, (c) => c.toUpperCase()) || v.replace(/^--pl-/, "");
56
57
 
57
- const isHex6 = (val: string) => /^#[0-9a-f]{6}$/i.test(val.trim());
58
+ // ── Color format handling every color token is pickable; a pick is written
59
+ // back in the token's OWN format (hex / rgb(a) / hsl / oklch / oklab), alpha kept.
60
+ const toRgb = converter("rgb");
61
+ const toHsl = converter("hsl");
62
+ const toOklch = converter("oklch");
63
+ const toOklab = converter("oklab");
64
+ const round = (n: number, d: number) => Math.round(n * 10 ** d) / 10 ** d;
65
+
66
+ /** A token value culori can read as a color (vs a length like radius/border-width). */
67
+ const isColor = (val: string) => !!parse(val);
68
+
69
+ /** Hex for the native `<input type="color">` (drops alpha, sRGB-clamped — display only). */
70
+ function toSwatchHex(val: string): string {
71
+ const c = parse(val);
72
+ return c ? formatHex(c) : "#000000";
73
+ }
74
+
75
+ function formatOf(val: string): "hex" | "rgb" | "hsl" | "oklch" | "oklab" | "other" {
76
+ const v = val.trim().toLowerCase();
77
+ if (v.startsWith("#")) return "hex";
78
+ if (v.startsWith("rgb")) return "rgb";
79
+ if (v.startsWith("hsl")) return "hsl";
80
+ if (v.startsWith("oklch")) return "oklch";
81
+ if (v.startsWith("oklab")) return "oklab";
82
+ return "other";
83
+ }
84
+
85
+ /** Reproject a freshly-picked hex into `original`'s color format, preserving its alpha. */
86
+ function reformat(pickedHex: string, original: string): string {
87
+ const picked = parse(pickedHex);
88
+ if (!picked) return pickedHex;
89
+ const alpha = parse(original)?.alpha;
90
+ const hasA = alpha != null && alpha < 1;
91
+ const aTail = hasA ? ` / ${alpha}` : "";
92
+ switch (formatOf(original)) {
93
+ case "rgb":
94
+ return formatRgb({ ...toRgb(picked), alpha: hasA ? alpha : undefined });
95
+ case "hsl": {
96
+ const h = toHsl(picked);
97
+ return `hsl(${round(h.h ?? 0, 1)} ${round((h.s ?? 0) * 100, 1)}% ${round((h.l ?? 0) * 100, 1)}%${aTail})`;
98
+ }
99
+ case "oklch": {
100
+ const o = toOklch(picked);
101
+ return `oklch(${round(o.l ?? 0, 4)} ${round(o.c ?? 0, 4)} ${round(o.h ?? 0, 2)}${aTail})`;
102
+ }
103
+ case "oklab": {
104
+ const o = toOklab(picked);
105
+ return `oklab(${round(o.l ?? 0, 4)} ${round(o.a ?? 0, 4)} ${round(o.b ?? 0, 4)}${aTail})`;
106
+ }
107
+ default:
108
+ return formatHex(picked);
109
+ }
110
+ }
58
111
 
59
112
  // ── Presets ─────────────────────────────────────────────────────────────────────
60
113
  export type ThemePreset = {
@@ -294,20 +347,20 @@ export function ThemePanel({
294
347
  <legend className="pl-theme-group__legend">{g.label}</legend>
295
348
  {vars.map((v) => {
296
349
  const val = valueOf(v);
297
- const hex = isHex6(val);
350
+ const color = isColor(val);
298
351
  const changed = v in overrides;
299
352
  return (
300
353
  <label key={v} className={cx("pl-theme-row", changed && "pl-theme-row--changed")}>
301
- {hex ? (
354
+ {color ? (
302
355
  <input
303
356
  type="color"
304
357
  className="pl-theme-row__swatch"
305
- value={val}
306
- onInput={(e) => setVar(v, (e.target as HTMLInputElement).value)}
358
+ value={toSwatchHex(val)}
359
+ onInput={(e) => setVar(v, reformat((e.target as HTMLInputElement).value, val))}
307
360
  aria-label={prettyLabel(v)}
308
361
  />
309
362
  ) : (
310
- <span className="pl-theme-row__swatch pl-theme-row__swatch--static" style={{ background: val }} aria-hidden />
363
+ <span className="pl-theme-row__swatch pl-theme-row__swatch--none" aria-hidden />
311
364
  )}
312
365
  <span className="pl-theme-row__label" title={v}>
313
366
  {prettyLabel(v)}