@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 +5 -2
- package/src/AppShell.full.stories.tsx +7 -2
- package/src/Foundations.stories.tsx +15 -2
- package/src/app-shell.tsx +232 -28
- package/src/styles/app-shell.css +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protolabsai/ui",
|
|
3
|
-
"version": "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.
|
|
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={
|
|
47
|
-
rightItems={
|
|
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
|
-
["
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/styles/app-shell.css
CHANGED
|
@@ -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
|
+
}
|