@protolabsai/ui 0.8.0 → 0.9.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.8.0",
3
+ "version": "0.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -27,10 +27,13 @@
27
27
  "src"
28
28
  ],
29
29
  "dependencies": {
30
+ "@dnd-kit/core": "^6.3.1",
31
+ "@dnd-kit/sortable": "^10.0.0",
32
+ "@dnd-kit/utilities": "^3.2.2",
30
33
  "@radix-ui/react-dropdown-menu": "^2.1.17",
31
34
  "@types/react": "^19.0.0",
32
35
  "@types/react-dom": "^19.0.0",
33
- "@protolabsai/design": "0.4.0"
36
+ "@protolabsai/design": "0.5.0"
34
37
  },
35
38
  "peerDependencies": {
36
39
  "react": "^19.0.0",
@@ -25,6 +25,7 @@ const RIGHT: RailItem[] = [
25
25
  { id: "inbox", label: "Inbox", icon: <G />, badge: 3 },
26
26
  { id: "telemetry", label: "Stats", icon: <G /> },
27
27
  ];
28
+ const BY_ID = new Map([...LEFT, ...RIGHT].map((i) => [i.id, i] as const));
28
29
 
29
30
  const surfaceBody = (label: string) => (
30
31
  <div style={{ flex: 1, padding: 16, color: "var(--pl-color-fg-muted)", fontSize: 13, fontFamily: "var(--pl-font-mono)" }}>
@@ -35,16 +36,20 @@ const surfaceBody = (label: string) => (
35
36
  export const Full: Story = {
36
37
  render: () => {
37
38
  function Demo() {
39
+ const [order, setOrder] = useState({ left: LEFT.map((i) => i.id), right: RIGHT.map((i) => i.id) });
38
40
  const [activeLeft, setActiveLeft] = useState("chat");
39
41
  const [activeRight, setActiveRight] = useState("inbox");
40
42
  const [rightWidth, setRightWidth] = useState(360);
41
43
  const [collapsed, setCollapsed] = useState(false);
44
+ const leftItems = order.left.map((id) => BY_ID.get(id)!);
45
+ const rightItems = order.right.map((id) => BY_ID.get(id)!);
42
46
  const mobile: MobileItem[] = [...LEFT, ...RIGHT].map((i) => ({ id: i.id, label: i.label, icon: i.icon }));
43
47
  return (
44
48
  <div style={{ height: 480, border: "1px solid var(--pl-color-border)", borderRadius: 4, overflow: "hidden" }}>
45
49
  <AppShell
46
- leftItems={LEFT}
47
- rightItems={RIGHT}
50
+ leftItems={leftItems}
51
+ rightItems={rightItems}
52
+ onRailReorder={setOrder}
48
53
  activeLeft={activeLeft}
49
54
  activeRight={activeRight}
50
55
  onSelect={(side, id) => {
@@ -5,14 +5,27 @@ export default meta;
5
5
  type Story = StoryObj;
6
6
 
7
7
  const COLORS: Array<[string, string]> = [
8
- ["Brand lavender", "--pl-color-brand-lavender"],
9
8
  ["Lavender light", "--pl-color-brand-lavender-light"],
10
- ["Indigo", "--pl-color-brand-indigo"],
9
+ ["Brand lavender", "--pl-color-brand-lavender"],
10
+ ["Lavender deep", "--pl-color-brand-lavender-deep"],
11
11
  ["Indigo bright", "--pl-color-brand-indigo-bright"],
12
+ ["Indigo", "--pl-color-brand-indigo"],
13
+ ["Indigo deep", "--pl-color-brand-indigo-deep"],
14
+ ["Accent", "--pl-color-accent"],
15
+ ["Accent hover", "--pl-color-accent-hover"],
16
+ ["Accent fg", "--pl-color-accent-fg"],
17
+ ["Focus ring", "--pl-color-focus"],
12
18
  ["Ground", "--pl-color-bg"],
13
19
  ["Raised", "--pl-color-bg-raised"],
20
+ ["Subtle", "--pl-color-bg-subtle"],
21
+ ["Hover", "--pl-color-bg-hover"],
22
+ ["Inset", "--pl-color-bg-inset"],
14
23
  ["Foreground", "--pl-color-fg"],
15
24
  ["Muted", "--pl-color-fg-muted"],
25
+ ["Subtle text", "--pl-color-fg-subtle"],
26
+ ["On accent", "--pl-color-fg-on-accent"],
27
+ ["Border", "--pl-color-border"],
28
+ ["Border strong", "--pl-color-border-strong"],
16
29
  ["Success", "--pl-color-status-success"],
17
30
  ["Warning", "--pl-color-status-warning"],
18
31
  ["Error", "--pl-color-status-error"],
package/src/app-shell.tsx CHANGED
@@ -7,7 +7,26 @@ import type {
7
7
  PointerEvent as ReactPointerEvent,
8
8
  ReactNode,
9
9
  } from "react";
10
- import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
+ import {
12
+ DndContext,
13
+ DragOverlay,
14
+ KeyboardSensor,
15
+ PointerSensor,
16
+ closestCenter,
17
+ useDroppable,
18
+ useSensor,
19
+ useSensors,
20
+ } from "@dnd-kit/core";
21
+ import type { DragEndEvent, DragOverEvent, DragStartEvent } from "@dnd-kit/core";
22
+ import {
23
+ SortableContext,
24
+ arrayMove,
25
+ sortableKeyboardCoordinates,
26
+ useSortable,
27
+ verticalListSortingStrategy,
28
+ } from "@dnd-kit/sortable";
29
+ import { CSS } from "@dnd-kit/utilities";
11
30
  import { cx } from "./internal";
12
31
  import { Drawer } from "./overlays";
13
32
 
@@ -21,6 +40,31 @@ export type RailItem = {
21
40
  dot?: boolean;
22
41
  };
23
42
 
43
+ /** Icon + label + badge/dot — shared by the rail buttons and the drag overlay
44
+ * (so a dragged item keeps its count/dot). */
45
+ function RailItemInner({ item }: { item: RailItem }) {
46
+ return (
47
+ <>
48
+ <span className="pl-rail__icon" aria-hidden>
49
+ {item.icon}
50
+ </span>
51
+ <span className="pl-rail__label">{item.label}</span>
52
+ {item.badge ? (
53
+ <span className="pl-rail__badge">{item.badge > 9 ? "9+" : item.badge}</span>
54
+ ) : item.dot ? (
55
+ <span className="pl-rail__dot" aria-label="active" />
56
+ ) : null}
57
+ </>
58
+ );
59
+ }
60
+
61
+ type RailButtonProps = {
62
+ item: RailItem;
63
+ active: boolean;
64
+ onSelect: (id: string) => void;
65
+ onContextMenu?: (e: ReactMouseEvent, id: string) => void;
66
+ };
67
+
24
68
  /** Vertical icon rail — both the left and right rails render through this.
25
69
  * `onContextMenu` is the integration point for the DS `Menu` (right-click →
26
70
  * host calls menuRef.open({x,y})); the menu's registry/keying stays app-side. */
@@ -52,21 +96,75 @@ export function SurfaceRail({
52
96
  onClick={() => onSelect(it.id)}
53
97
  onContextMenu={onContextMenu ? (e) => onContextMenu(e, it.id) : undefined}
54
98
  >
55
- <span className="pl-rail__icon" aria-hidden>
56
- {it.icon}
57
- </span>
58
- <span className="pl-rail__label">{it.label}</span>
59
- {it.badge ? (
60
- <span className="pl-rail__badge">{it.badge > 9 ? "9+" : it.badge}</span>
61
- ) : it.dot ? (
62
- <span className="pl-rail__dot" aria-label="active" />
63
- ) : null}
99
+ <RailItemInner item={it} />
64
100
  </button>
65
101
  ))}
66
102
  </aside>
67
103
  );
68
104
  }
69
105
 
106
+ // ── Drag-and-drop rails (dnd-kit) — used by AppShell when onRailReorder is set ──
107
+
108
+ const railDropId = (side: "left" | "right") => `__rail__:${side}`;
109
+
110
+ function SortableRailButton({ item, active, onSelect, onContextMenu }: RailButtonProps) {
111
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
112
+ return (
113
+ <button
114
+ ref={setNodeRef}
115
+ type="button"
116
+ style={{ transform: CSS.Translate.toString(transform), transition }}
117
+ className={cx("pl-rail__btn", active && "pl-rail__btn--active", isDragging && "pl-rail__btn--dragging")}
118
+ title={item.label}
119
+ aria-label={item.label}
120
+ aria-current={active ? "page" : undefined}
121
+ onClick={() => onSelect(item.id)}
122
+ onContextMenu={onContextMenu ? (e) => onContextMenu(e, item.id) : undefined}
123
+ {...attributes}
124
+ {...listeners}
125
+ >
126
+ <RailItemInner item={item} />
127
+ </button>
128
+ );
129
+ }
130
+
131
+ function SortableRail({
132
+ side,
133
+ ariaLabel,
134
+ items,
135
+ activeId,
136
+ onSelect,
137
+ onContextMenu,
138
+ }: {
139
+ side: "left" | "right";
140
+ ariaLabel: string;
141
+ items: RailItem[];
142
+ activeId: string;
143
+ onSelect: (id: string) => void;
144
+ onContextMenu?: (e: ReactMouseEvent, id: string) => void;
145
+ }) {
146
+ const { setNodeRef } = useDroppable({ id: railDropId(side) });
147
+ return (
148
+ <aside
149
+ ref={setNodeRef}
150
+ className={cx("pl-rail", "pl-rail--sortable", side === "right" && "pl-rail--right")}
151
+ aria-label={ariaLabel}
152
+ >
153
+ <SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
154
+ {items.map((it) => (
155
+ <SortableRailButton
156
+ key={it.id}
157
+ item={it}
158
+ active={it.id === activeId}
159
+ onSelect={onSelect}
160
+ onContextMenu={onContextMenu}
161
+ />
162
+ ))}
163
+ </SortableContext>
164
+ </aside>
165
+ );
166
+ }
167
+
70
168
  export type MobileItem = { id: string; label: string; icon: ReactNode };
71
169
 
72
170
  /** Mobile shell (<768px): a bottom quick-bar (first 5 pinned surfaces) + a
@@ -153,6 +251,8 @@ function useIsMobile(breakpoint: number) {
153
251
  return mobile;
154
252
  }
155
253
 
254
+ type RailOrder = { left: string[]; right: string[] };
255
+
156
256
  export type AppShellProps = {
157
257
  leftItems: RailItem[];
158
258
  rightItems: RailItem[];
@@ -161,6 +261,10 @@ export type AppShellProps = {
161
261
  onSelect: (side: "left" | "right", id: string) => void;
162
262
  /** Right-click on a rail icon — wire to a DS `Menu` for move/reorder etc. */
163
263
  onRailContextMenu?: (side: "left" | "right", e: ReactMouseEvent, id: string) => void;
264
+ /** Provide to make the rails drag-to-reorder + cross-rail draggable (dnd-kit).
265
+ * Controlled: the host receives the new id order per side and persists it.
266
+ * A dragged item keeps its badge/dot in the drag overlay. */
267
+ onRailReorder?: (next: RailOrder) => void;
164
268
  leftContent: ReactNode;
165
269
  rightContent: ReactNode;
166
270
  /** Controlled right-column width (px). */
@@ -190,6 +294,7 @@ export function AppShell({
190
294
  activeRight,
191
295
  onSelect,
192
296
  onRailContextMenu,
297
+ onRailReorder,
193
298
  leftContent,
194
299
  rightContent,
195
300
  rightWidth,
@@ -206,12 +311,13 @@ export function AppShell({
206
311
  className,
207
312
  }: AppShellProps) {
208
313
  const isMobile = useIsMobile(mobileBreakpoint);
314
+
315
+ // ── resize handle ──
209
316
  const drag = useRef<{ startX: number; startW: number } | null>(null);
210
317
  const clamp = useCallback(
211
318
  (w: number) => Math.min(maxRightWidth, Math.max(minRightWidth, w)),
212
319
  [minRightWidth, maxRightWidth],
213
320
  );
214
-
215
321
  const onPointerDown = useCallback(
216
322
  (e: ReactPointerEvent<HTMLDivElement>) => {
217
323
  e.preventDefault();
@@ -245,6 +351,81 @@ export function AppShell({
245
351
  [rightWidth, clamp, onRightWidthChange],
246
352
  );
247
353
 
354
+ // ── rail drag-and-drop (only when onRailReorder is provided) ──
355
+ const dndEnabled = !!onRailReorder;
356
+ const propOrder = useMemo<RailOrder>(
357
+ () => ({ left: leftItems.map((i) => i.id), right: rightItems.map((i) => i.id) }),
358
+ [leftItems, rightItems],
359
+ );
360
+ const byId = useMemo(() => {
361
+ const m = new Map<string, RailItem>();
362
+ [...leftItems, ...rightItems].forEach((i) => m.set(i.id, i));
363
+ return m;
364
+ }, [leftItems, rightItems]);
365
+ const [order, setOrder] = useState<RailOrder>(propOrder);
366
+ const [activeId, setActiveId] = useState<string | null>(null);
367
+ // Re-sync from props while not mid-drag (host is the source of truth at rest).
368
+ useEffect(() => {
369
+ if (!activeId) setOrder(propOrder);
370
+ }, [propOrder, activeId]);
371
+
372
+ const sensors = useSensors(
373
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
374
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
375
+ );
376
+ const sideOf = (id: string, o: RailOrder): "left" | "right" | null => {
377
+ if (id === railDropId("left")) return "left";
378
+ if (id === railDropId("right")) return "right";
379
+ if (o.left.includes(id)) return "left";
380
+ if (o.right.includes(id)) return "right";
381
+ return null;
382
+ };
383
+ const handleDragStart = (e: DragStartEvent) => setActiveId(String(e.active.id));
384
+ const handleDragOver = (e: DragOverEvent) => {
385
+ const { active, over } = e;
386
+ if (!over) return;
387
+ const activeIdStr = String(active.id);
388
+ const overId = String(over.id);
389
+ setOrder((o) => {
390
+ const from = sideOf(activeIdStr, o);
391
+ const to = sideOf(overId, o);
392
+ if (!from || !to || from === to) return o;
393
+ const fromArr = o[from].filter((id) => id !== activeIdStr);
394
+ const toArr = [...o[to]];
395
+ const overIdx = overId.startsWith("__rail__:") ? toArr.length : toArr.indexOf(overId);
396
+ toArr.splice(overIdx < 0 ? toArr.length : overIdx, 0, activeIdStr);
397
+ return { ...o, [from]: fromArr, [to]: toArr };
398
+ });
399
+ };
400
+ const handleDragEnd = (e: DragEndEvent) => {
401
+ const { active, over } = e;
402
+ setActiveId(null);
403
+ if (!over) {
404
+ setOrder(propOrder);
405
+ return;
406
+ }
407
+ const activeIdStr = String(active.id);
408
+ const overId = String(over.id);
409
+ const from = sideOf(activeIdStr, order);
410
+ let next = order;
411
+ if (from && from === sideOf(overId, order) && !overId.startsWith("__rail__:")) {
412
+ const arr = order[from];
413
+ const oldIdx = arr.indexOf(activeIdStr);
414
+ const newIdx = arr.indexOf(overId);
415
+ if (oldIdx >= 0 && newIdx >= 0 && oldIdx !== newIdx) {
416
+ next = { ...order, [from]: arrayMove(arr, oldIdx, newIdx) };
417
+ setOrder(next);
418
+ }
419
+ }
420
+ if (next.left.join() !== propOrder.left.join() || next.right.join() !== propOrder.right.join()) {
421
+ onRailReorder?.(next);
422
+ }
423
+ };
424
+ const handleDragCancel = () => {
425
+ setActiveId(null);
426
+ setOrder(propOrder);
427
+ };
428
+
248
429
  if (isMobile && mobileItems && onMobileSelect && quickBarIds) {
249
430
  return (
250
431
  <div className={cx("pl-appshell", "pl-appshell--mobile", className)}>
@@ -260,16 +441,12 @@ export function AppShell({
260
441
  }
261
442
 
262
443
  const showRight = !rightCollapsed && rightItems.length > 0;
263
- return (
444
+ const ctxLeft = onRailContextMenu ? (e: ReactMouseEvent, id: string) => onRailContextMenu("left", e, id) : undefined;
445
+ const ctxRight = onRailContextMenu ? (e: ReactMouseEvent, id: string) => onRailContextMenu("right", e, id) : undefined;
446
+
447
+ const renderShell = (leftRail: ReactNode, rightRail: ReactNode) => (
264
448
  <div className={cx("pl-appshell", className)}>
265
- <SurfaceRail
266
- side="left"
267
- ariaLabel="Left surfaces"
268
- items={leftItems}
269
- activeId={activeLeft}
270
- onSelect={(id) => onSelect("left", id)}
271
- onContextMenu={onRailContextMenu ? (e, id) => onRailContextMenu("left", e, id) : undefined}
272
- />
449
+ {leftRail}
273
450
  <main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
274
451
  {showRight && (
275
452
  <div
@@ -296,14 +473,41 @@ export function AppShell({
296
473
  {rightContent}
297
474
  </aside>
298
475
  )}
299
- <SurfaceRail
300
- side="right"
301
- ariaLabel="Right surfaces"
302
- items={rightItems}
303
- activeId={activeRight}
304
- onSelect={(id) => onSelect("right", id)}
305
- onContextMenu={onRailContextMenu ? (e, id) => onRailContextMenu("right", e, id) : undefined}
306
- />
476
+ {rightRail}
307
477
  </div>
308
478
  );
479
+
480
+ if (!dndEnabled) {
481
+ return renderShell(
482
+ <SurfaceRail side="left" ariaLabel="Left surfaces" items={leftItems} activeId={activeLeft} onSelect={(id) => onSelect("left", id)} onContextMenu={ctxLeft} />,
483
+ <SurfaceRail side="right" ariaLabel="Right surfaces" items={rightItems} activeId={activeRight} onSelect={(id) => onSelect("right", id)} onContextMenu={ctxRight} />,
484
+ );
485
+ }
486
+
487
+ const leftRailItems = order.left.map((id) => byId.get(id)).filter((i): i is RailItem => Boolean(i));
488
+ const rightRailItems = order.right.map((id) => byId.get(id)).filter((i): i is RailItem => Boolean(i));
489
+ const activeItem = activeId ? byId.get(activeId) : null;
490
+
491
+ return (
492
+ <DndContext
493
+ sensors={sensors}
494
+ collisionDetection={closestCenter}
495
+ onDragStart={handleDragStart}
496
+ onDragOver={handleDragOver}
497
+ onDragEnd={handleDragEnd}
498
+ onDragCancel={handleDragCancel}
499
+ >
500
+ {renderShell(
501
+ <SortableRail side="left" ariaLabel="Left surfaces" items={leftRailItems} activeId={activeLeft} onSelect={(id) => onSelect("left", id)} onContextMenu={ctxLeft} />,
502
+ <SortableRail side="right" ariaLabel="Right surfaces" items={rightRailItems} activeId={activeRight} onSelect={(id) => onSelect("right", id)} onContextMenu={ctxRight} />,
503
+ )}
504
+ <DragOverlay>
505
+ {activeItem ? (
506
+ <div className="pl-rail__btn pl-rail__btn--overlay">
507
+ <RailItemInner item={activeItem} />
508
+ </div>
509
+ ) : null}
510
+ </DragOverlay>
511
+ </DndContext>
512
+ );
309
513
  }
@@ -210,3 +210,31 @@
210
210
  min-height: 0;
211
211
  overflow: auto;
212
212
  }
213
+
214
+ /* ── Rail drag-and-drop (dnd-kit) ─────────────────────────────────────────────── */
215
+ .pl-rail--sortable .pl-rail__btn {
216
+ cursor: grab;
217
+ touch-action: none;
218
+ }
219
+ .pl-rail--sortable .pl-rail__btn:active {
220
+ cursor: grabbing;
221
+ }
222
+ .pl-rail__btn--dragging {
223
+ opacity: 0.4;
224
+ }
225
+ /* The floating drag preview — same shape as a rail button, keeps icon + badge/dot. */
226
+ .pl-rail__btn--overlay {
227
+ position: relative;
228
+ display: flex;
229
+ flex-direction: column;
230
+ align-items: center;
231
+ gap: 3px;
232
+ width: 56px;
233
+ padding: 8px 2px;
234
+ color: var(--pl-color-fg);
235
+ background: var(--pl-color-bg-raised);
236
+ border: var(--pl-border-width) solid var(--pl-color-border-strong);
237
+ border-radius: var(--pl-radius);
238
+ box-shadow: var(--pl-shadow-popover);
239
+ cursor: grabbing;
240
+ }