@protolabsai/ui 0.6.0 → 0.7.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 +1 -1
- package/src/AppShell.full.stories.tsx +128 -0
- package/src/index.tsx +305 -0
- package/src/styles.css +188 -0
package/package.json
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { AppShell, Button, MobileNav, PanelHeader, SurfaceRail } from "./index";
|
|
4
|
+
import type { MobileItem, RailItem } from "./index";
|
|
5
|
+
|
|
6
|
+
const meta: Meta = { title: "Components/AppShell" };
|
|
7
|
+
export default meta;
|
|
8
|
+
type Story = StoryObj;
|
|
9
|
+
|
|
10
|
+
const G = () => (
|
|
11
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
12
|
+
<rect x="2.5" y="2.5" width="11" height="11" rx="2" />
|
|
13
|
+
</svg>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const LEFT: RailItem[] = [
|
|
17
|
+
{ id: "chat", label: "Chat", icon: <G />, dot: true },
|
|
18
|
+
{ id: "activity", label: "Activity", icon: <G />, badge: 12 },
|
|
19
|
+
{ id: "flows", label: "Flows", icon: <G /> },
|
|
20
|
+
{ id: "knowledge", label: "Know", icon: <G /> },
|
|
21
|
+
];
|
|
22
|
+
const RIGHT: RailItem[] = [
|
|
23
|
+
{ id: "inbox", label: "Inbox", icon: <G />, badge: 3 },
|
|
24
|
+
{ id: "telemetry", label: "Stats", icon: <G /> },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const surfaceBody = (label: string) => (
|
|
28
|
+
<div style={{ flex: 1, padding: 16, color: "var(--pl-color-fg-muted)", fontSize: 13, fontFamily: "var(--pl-font-mono)" }}>
|
|
29
|
+
{label} surface content…
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const Full: Story = {
|
|
34
|
+
render: () => {
|
|
35
|
+
function Demo() {
|
|
36
|
+
const [activeLeft, setActiveLeft] = useState("chat");
|
|
37
|
+
const [activeRight, setActiveRight] = useState("inbox");
|
|
38
|
+
const [rightWidth, setRightWidth] = useState(360);
|
|
39
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
40
|
+
const mobile: MobileItem[] = [...LEFT, ...RIGHT].map((i) => ({ id: i.id, label: i.label, icon: i.icon }));
|
|
41
|
+
return (
|
|
42
|
+
<div style={{ height: 480, border: "1px solid var(--pl-color-border)", borderRadius: 4, overflow: "hidden" }}>
|
|
43
|
+
<AppShell
|
|
44
|
+
leftItems={LEFT}
|
|
45
|
+
rightItems={RIGHT}
|
|
46
|
+
activeLeft={activeLeft}
|
|
47
|
+
activeRight={activeRight}
|
|
48
|
+
onSelect={(side, id) => {
|
|
49
|
+
if (side === "left") setActiveLeft(id);
|
|
50
|
+
else {
|
|
51
|
+
setActiveRight(id);
|
|
52
|
+
setCollapsed(false);
|
|
53
|
+
}
|
|
54
|
+
}}
|
|
55
|
+
rightWidth={rightWidth}
|
|
56
|
+
onRightWidthChange={setRightWidth}
|
|
57
|
+
rightCollapsed={collapsed}
|
|
58
|
+
onCollapse={setCollapsed}
|
|
59
|
+
leftContent={
|
|
60
|
+
<>
|
|
61
|
+
<PanelHeader title={activeLeft} actions={<Button size="sm">Action</Button>} />
|
|
62
|
+
{surfaceBody(activeLeft)}
|
|
63
|
+
</>
|
|
64
|
+
}
|
|
65
|
+
rightContent={
|
|
66
|
+
<>
|
|
67
|
+
<PanelHeader
|
|
68
|
+
compact
|
|
69
|
+
title={activeRight}
|
|
70
|
+
actions={
|
|
71
|
+
<Button size="sm" variant="ghost" onClick={() => setCollapsed(true)}>
|
|
72
|
+
Collapse
|
|
73
|
+
</Button>
|
|
74
|
+
}
|
|
75
|
+
/>
|
|
76
|
+
{surfaceBody(activeRight)}
|
|
77
|
+
</>
|
|
78
|
+
}
|
|
79
|
+
mobileItems={mobile}
|
|
80
|
+
mobileActiveId={activeLeft}
|
|
81
|
+
onMobileSelect={setActiveLeft}
|
|
82
|
+
quickBarIds={["chat", "activity", "flows", "inbox", "telemetry"]}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return <Demo />;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const Rail: Story = {
|
|
92
|
+
render: () => {
|
|
93
|
+
function Demo() {
|
|
94
|
+
const [active, setActive] = useState("activity");
|
|
95
|
+
return (
|
|
96
|
+
<div style={{ height: 320, width: 64, border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
|
|
97
|
+
<SurfaceRail
|
|
98
|
+
side="left"
|
|
99
|
+
ariaLabel="Surfaces"
|
|
100
|
+
items={LEFT}
|
|
101
|
+
activeId={active}
|
|
102
|
+
onSelect={setActive}
|
|
103
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return <Demo />;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const Mobile: Story = {
|
|
113
|
+
render: () => {
|
|
114
|
+
function Demo() {
|
|
115
|
+
const [active, setActive] = useState("chat");
|
|
116
|
+
const mobile: MobileItem[] = [...LEFT, ...RIGHT].map((i) => ({ id: i.id, label: i.label, icon: i.icon }));
|
|
117
|
+
return (
|
|
118
|
+
<div style={{ width: 360, border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
|
|
119
|
+
<div style={{ height: 220, padding: 16, color: "var(--pl-color-fg-muted)", fontFamily: "var(--pl-font-mono)", fontSize: 13 }}>
|
|
120
|
+
{active} surface — tap “More” for the full list
|
|
121
|
+
</div>
|
|
122
|
+
<MobileNav items={mobile} activeId={active} onSelect={setActive} quickBarIds={["chat", "activity", "flows", "inbox"]} />
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return <Demo />;
|
|
127
|
+
},
|
|
128
|
+
};
|
package/src/index.tsx
CHANGED
|
@@ -7,6 +7,9 @@ import type {
|
|
|
7
7
|
ButtonHTMLAttributes,
|
|
8
8
|
HTMLAttributes,
|
|
9
9
|
InputHTMLAttributes,
|
|
10
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
11
|
+
MouseEvent as ReactMouseEvent,
|
|
12
|
+
PointerEvent as ReactPointerEvent,
|
|
10
13
|
ReactNode,
|
|
11
14
|
RefObject,
|
|
12
15
|
SelectHTMLAttributes,
|
|
@@ -1022,3 +1025,305 @@ export function Skeleton({
|
|
|
1022
1025
|
export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
1023
1026
|
return <div className={cx("pl-skel-group", className)} {...rest} />;
|
|
1024
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
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -1275,6 +1275,193 @@ a.pl-row:hover {
|
|
|
1275
1275
|
color: var(--pl-color-fg-muted);
|
|
1276
1276
|
}
|
|
1277
1277
|
|
|
1278
|
+
/* ── SurfaceRail (icon rail) ─────────────────────────────────────────────────── */
|
|
1279
|
+
.pl-rail {
|
|
1280
|
+
display: flex;
|
|
1281
|
+
flex-direction: column;
|
|
1282
|
+
align-items: stretch;
|
|
1283
|
+
gap: 2px;
|
|
1284
|
+
width: 64px;
|
|
1285
|
+
flex-shrink: 0;
|
|
1286
|
+
padding: 6px;
|
|
1287
|
+
background: var(--pl-color-bg-raised);
|
|
1288
|
+
border-right: var(--pl-border-width) solid var(--pl-color-border);
|
|
1289
|
+
}
|
|
1290
|
+
.pl-rail--right {
|
|
1291
|
+
border-right: none;
|
|
1292
|
+
border-left: var(--pl-border-width) solid var(--pl-color-border);
|
|
1293
|
+
}
|
|
1294
|
+
.pl-rail__btn {
|
|
1295
|
+
position: relative;
|
|
1296
|
+
display: flex;
|
|
1297
|
+
flex-direction: column;
|
|
1298
|
+
align-items: center;
|
|
1299
|
+
gap: 3px;
|
|
1300
|
+
padding: 8px 2px;
|
|
1301
|
+
background: none;
|
|
1302
|
+
border: none;
|
|
1303
|
+
border-radius: var(--pl-radius);
|
|
1304
|
+
color: var(--pl-color-fg-muted);
|
|
1305
|
+
cursor: pointer;
|
|
1306
|
+
font: inherit;
|
|
1307
|
+
transition:
|
|
1308
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
1309
|
+
color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
1310
|
+
}
|
|
1311
|
+
.pl-rail__btn:hover {
|
|
1312
|
+
background: var(--pl-color-bg-hover);
|
|
1313
|
+
color: var(--pl-color-fg);
|
|
1314
|
+
}
|
|
1315
|
+
.pl-rail__btn--active {
|
|
1316
|
+
background: var(--pl-color-bg-subtle);
|
|
1317
|
+
color: var(--pl-color-fg);
|
|
1318
|
+
}
|
|
1319
|
+
.pl-rail__icon {
|
|
1320
|
+
display: inline-flex;
|
|
1321
|
+
}
|
|
1322
|
+
.pl-rail__icon svg {
|
|
1323
|
+
width: 18px;
|
|
1324
|
+
height: 18px;
|
|
1325
|
+
}
|
|
1326
|
+
.pl-rail__label {
|
|
1327
|
+
max-width: 100%;
|
|
1328
|
+
overflow: hidden;
|
|
1329
|
+
font-size: 9px;
|
|
1330
|
+
line-height: 1.1;
|
|
1331
|
+
text-align: center;
|
|
1332
|
+
text-overflow: ellipsis;
|
|
1333
|
+
white-space: nowrap;
|
|
1334
|
+
}
|
|
1335
|
+
.pl-rail__badge {
|
|
1336
|
+
position: absolute;
|
|
1337
|
+
top: 4px;
|
|
1338
|
+
right: 9px;
|
|
1339
|
+
display: inline-flex;
|
|
1340
|
+
align-items: center;
|
|
1341
|
+
justify-content: center;
|
|
1342
|
+
min-width: 15px;
|
|
1343
|
+
height: 15px;
|
|
1344
|
+
padding: 0 4px;
|
|
1345
|
+
font-family: var(--pl-font-mono);
|
|
1346
|
+
font-size: 9px;
|
|
1347
|
+
color: var(--pl-color-fg);
|
|
1348
|
+
background: var(--pl-color-bg-subtle);
|
|
1349
|
+
border: var(--pl-border-width) solid var(--pl-color-border-strong);
|
|
1350
|
+
border-radius: 999px;
|
|
1351
|
+
}
|
|
1352
|
+
.pl-rail__dot {
|
|
1353
|
+
position: absolute;
|
|
1354
|
+
top: 6px;
|
|
1355
|
+
right: 14px;
|
|
1356
|
+
width: 7px;
|
|
1357
|
+
height: 7px;
|
|
1358
|
+
border-radius: 50%;
|
|
1359
|
+
background: var(--pl-color-status-info);
|
|
1360
|
+
animation: pl-dot-pulse var(--pl-motion-status) var(--pl-motion-ease-in-out) infinite;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/* ── MobileNav (bottom bar + drawer list) ────────────────────────────────────── */
|
|
1364
|
+
.pl-mobilenav {
|
|
1365
|
+
display: flex;
|
|
1366
|
+
align-items: stretch;
|
|
1367
|
+
background: var(--pl-color-bg-raised);
|
|
1368
|
+
border-top: var(--pl-border-width) solid var(--pl-color-border);
|
|
1369
|
+
}
|
|
1370
|
+
.pl-mobilenav__tab {
|
|
1371
|
+
flex: 1;
|
|
1372
|
+
display: flex;
|
|
1373
|
+
flex-direction: column;
|
|
1374
|
+
align-items: center;
|
|
1375
|
+
gap: 3px;
|
|
1376
|
+
padding: 8px 2px;
|
|
1377
|
+
background: none;
|
|
1378
|
+
border: none;
|
|
1379
|
+
color: var(--pl-color-fg-muted);
|
|
1380
|
+
font: inherit;
|
|
1381
|
+
font-size: 10px;
|
|
1382
|
+
cursor: pointer;
|
|
1383
|
+
}
|
|
1384
|
+
.pl-mobilenav__tab--active {
|
|
1385
|
+
color: var(--pl-color-fg);
|
|
1386
|
+
}
|
|
1387
|
+
.pl-mobilenav__icon {
|
|
1388
|
+
display: inline-flex;
|
|
1389
|
+
}
|
|
1390
|
+
.pl-mobilenav__icon svg {
|
|
1391
|
+
width: 18px;
|
|
1392
|
+
height: 18px;
|
|
1393
|
+
}
|
|
1394
|
+
.pl-mobilenav__list {
|
|
1395
|
+
display: grid;
|
|
1396
|
+
gap: 2px;
|
|
1397
|
+
}
|
|
1398
|
+
.pl-mobilenav__list-item {
|
|
1399
|
+
display: flex;
|
|
1400
|
+
align-items: center;
|
|
1401
|
+
gap: 10px;
|
|
1402
|
+
width: 100%;
|
|
1403
|
+
padding: 10px 8px;
|
|
1404
|
+
background: none;
|
|
1405
|
+
border: none;
|
|
1406
|
+
border-radius: var(--pl-radius);
|
|
1407
|
+
color: var(--pl-color-fg);
|
|
1408
|
+
font: inherit;
|
|
1409
|
+
font-size: 14px;
|
|
1410
|
+
text-align: left;
|
|
1411
|
+
cursor: pointer;
|
|
1412
|
+
}
|
|
1413
|
+
.pl-mobilenav__list-item:hover {
|
|
1414
|
+
background: var(--pl-color-bg-hover);
|
|
1415
|
+
}
|
|
1416
|
+
.pl-mobilenav__list-item--active {
|
|
1417
|
+
background: var(--pl-color-bg-subtle);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/* ── AppShell (dual-rail + 3-column) ─────────────────────────────────────────── */
|
|
1421
|
+
.pl-appshell {
|
|
1422
|
+
display: flex;
|
|
1423
|
+
align-items: stretch;
|
|
1424
|
+
height: 100%;
|
|
1425
|
+
min-height: 0;
|
|
1426
|
+
background: var(--pl-color-bg);
|
|
1427
|
+
color: var(--pl-color-fg);
|
|
1428
|
+
}
|
|
1429
|
+
.pl-appshell__col {
|
|
1430
|
+
display: flex;
|
|
1431
|
+
flex-direction: column;
|
|
1432
|
+
min-width: 0;
|
|
1433
|
+
min-height: 0;
|
|
1434
|
+
overflow: hidden;
|
|
1435
|
+
}
|
|
1436
|
+
.pl-appshell__col--left {
|
|
1437
|
+
flex: 1 1 auto;
|
|
1438
|
+
}
|
|
1439
|
+
.pl-appshell__col--right {
|
|
1440
|
+
flex: 0 0 auto;
|
|
1441
|
+
border-left: var(--pl-border-width) solid var(--pl-color-border);
|
|
1442
|
+
}
|
|
1443
|
+
.pl-appshell__handle {
|
|
1444
|
+
flex: 0 0 auto;
|
|
1445
|
+
width: 5px;
|
|
1446
|
+
cursor: col-resize;
|
|
1447
|
+
background: transparent;
|
|
1448
|
+
touch-action: none;
|
|
1449
|
+
transition: background var(--pl-motion-fast) var(--pl-motion-ease);
|
|
1450
|
+
}
|
|
1451
|
+
.pl-appshell__handle:hover,
|
|
1452
|
+
.pl-appshell__handle:focus-visible {
|
|
1453
|
+
background: var(--pl-color-border-strong);
|
|
1454
|
+
outline: none;
|
|
1455
|
+
}
|
|
1456
|
+
.pl-appshell--mobile {
|
|
1457
|
+
flex-direction: column;
|
|
1458
|
+
}
|
|
1459
|
+
.pl-appshell__mobile-stage {
|
|
1460
|
+
flex: 1 1 auto;
|
|
1461
|
+
min-height: 0;
|
|
1462
|
+
overflow: auto;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1278
1465
|
/* ── Skeleton (loading placeholder) ──────────────────────────────────────────── */
|
|
1279
1466
|
.pl-skel {
|
|
1280
1467
|
display: block;
|
|
@@ -1311,6 +1498,7 @@ a.pl-row:hover {
|
|
|
1311
1498
|
.pl-drawer,
|
|
1312
1499
|
.pl-toast,
|
|
1313
1500
|
.pl-dot--pulse,
|
|
1501
|
+
.pl-rail__dot,
|
|
1314
1502
|
.pl-spinner {
|
|
1315
1503
|
animation: none;
|
|
1316
1504
|
}
|