@protolabsai/ui 0.6.0 → 0.8.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.6.0",
3
+ "version": "0.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -12,7 +12,15 @@
12
12
  "*.css"
13
13
  ],
14
14
  "exports": {
15
- ".": "./src/index.tsx",
15
+ "./primitives": "./src/primitives.tsx",
16
+ "./layout": "./src/layout.tsx",
17
+ "./marketing": "./src/marketing.tsx",
18
+ "./navigation": "./src/navigation.tsx",
19
+ "./forms": "./src/forms.tsx",
20
+ "./overlays": "./src/overlays.tsx",
21
+ "./data": "./src/data.tsx",
22
+ "./menu": "./src/menu.tsx",
23
+ "./app-shell": "./src/app-shell.tsx",
16
24
  "./styles.css": "./src/styles.css"
17
25
  },
18
26
  "files": [
@@ -0,0 +1,130 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { Button } from "./primitives";
4
+ import { PanelHeader } from "./navigation";
5
+ import { AppShell, MobileNav, SurfaceRail } from "./app-shell";
6
+ import type { MobileItem, RailItem } from "./app-shell";
7
+
8
+ const meta: Meta = { title: "Components/AppShell" };
9
+ export default meta;
10
+ type Story = StoryObj;
11
+
12
+ const G = () => (
13
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
14
+ <rect x="2.5" y="2.5" width="11" height="11" rx="2" />
15
+ </svg>
16
+ );
17
+
18
+ const LEFT: RailItem[] = [
19
+ { id: "chat", label: "Chat", icon: <G />, dot: true },
20
+ { id: "activity", label: "Activity", icon: <G />, badge: 12 },
21
+ { id: "flows", label: "Flows", icon: <G /> },
22
+ { id: "knowledge", label: "Know", icon: <G /> },
23
+ ];
24
+ const RIGHT: RailItem[] = [
25
+ { id: "inbox", label: "Inbox", icon: <G />, badge: 3 },
26
+ { id: "telemetry", label: "Stats", icon: <G /> },
27
+ ];
28
+
29
+ const surfaceBody = (label: string) => (
30
+ <div style={{ flex: 1, padding: 16, color: "var(--pl-color-fg-muted)", fontSize: 13, fontFamily: "var(--pl-font-mono)" }}>
31
+ {label} surface content…
32
+ </div>
33
+ );
34
+
35
+ export const Full: Story = {
36
+ render: () => {
37
+ function Demo() {
38
+ const [activeLeft, setActiveLeft] = useState("chat");
39
+ const [activeRight, setActiveRight] = useState("inbox");
40
+ const [rightWidth, setRightWidth] = useState(360);
41
+ const [collapsed, setCollapsed] = useState(false);
42
+ const mobile: MobileItem[] = [...LEFT, ...RIGHT].map((i) => ({ id: i.id, label: i.label, icon: i.icon }));
43
+ return (
44
+ <div style={{ height: 480, border: "1px solid var(--pl-color-border)", borderRadius: 4, overflow: "hidden" }}>
45
+ <AppShell
46
+ leftItems={LEFT}
47
+ rightItems={RIGHT}
48
+ activeLeft={activeLeft}
49
+ activeRight={activeRight}
50
+ onSelect={(side, id) => {
51
+ if (side === "left") setActiveLeft(id);
52
+ else {
53
+ setActiveRight(id);
54
+ setCollapsed(false);
55
+ }
56
+ }}
57
+ rightWidth={rightWidth}
58
+ onRightWidthChange={setRightWidth}
59
+ rightCollapsed={collapsed}
60
+ onCollapse={setCollapsed}
61
+ leftContent={
62
+ <>
63
+ <PanelHeader title={activeLeft} actions={<Button size="sm">Action</Button>} />
64
+ {surfaceBody(activeLeft)}
65
+ </>
66
+ }
67
+ rightContent={
68
+ <>
69
+ <PanelHeader
70
+ compact
71
+ title={activeRight}
72
+ actions={
73
+ <Button size="sm" variant="ghost" onClick={() => setCollapsed(true)}>
74
+ Collapse
75
+ </Button>
76
+ }
77
+ />
78
+ {surfaceBody(activeRight)}
79
+ </>
80
+ }
81
+ mobileItems={mobile}
82
+ mobileActiveId={activeLeft}
83
+ onMobileSelect={setActiveLeft}
84
+ quickBarIds={["chat", "activity", "flows", "inbox", "telemetry"]}
85
+ />
86
+ </div>
87
+ );
88
+ }
89
+ return <Demo />;
90
+ },
91
+ };
92
+
93
+ export const Rail: Story = {
94
+ render: () => {
95
+ function Demo() {
96
+ const [active, setActive] = useState("activity");
97
+ return (
98
+ <div style={{ height: 320, width: 64, border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
99
+ <SurfaceRail
100
+ side="left"
101
+ ariaLabel="Surfaces"
102
+ items={LEFT}
103
+ activeId={active}
104
+ onSelect={setActive}
105
+ onContextMenu={(e) => e.preventDefault()}
106
+ />
107
+ </div>
108
+ );
109
+ }
110
+ return <Demo />;
111
+ },
112
+ };
113
+
114
+ export const Mobile: Story = {
115
+ render: () => {
116
+ function Demo() {
117
+ const [active, setActive] = useState("chat");
118
+ const mobile: MobileItem[] = [...LEFT, ...RIGHT].map((i) => ({ id: i.id, label: i.label, icon: i.icon }));
119
+ return (
120
+ <div style={{ width: 360, border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
121
+ <div style={{ height: 220, padding: 16, color: "var(--pl-color-fg-muted)", fontFamily: "var(--pl-font-mono)", fontSize: 13 }}>
122
+ {active} surface — tap “More” for the full list
123
+ </div>
124
+ <MobileNav items={mobile} activeId={active} onSelect={setActive} quickBarIds={["chat", "activity", "flows", "inbox"]} />
125
+ </div>
126
+ );
127
+ }
128
+ return <Demo />;
129
+ },
130
+ };
@@ -1,8 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Badge, Button, PanelHeader, Tabs } from "./index";
3
+ import { Badge, Button } from "./primitives";
4
+ import { PanelHeader, Tabs } from "./navigation";
4
5
 
5
- const meta: Meta = { title: "Components/AppShell" };
6
+ const meta: Meta = { title: "Components/Navigation" };
6
7
  export default meta;
7
8
  type Story = StoryObj;
8
9
 
@@ -1,8 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Badge, type Status } from "./index";
2
+ import { Badge, type Status } from "./primitives";
3
3
 
4
4
  const meta: Meta<typeof Badge> = {
5
- title: "Components/Badge",
5
+ title: "Components/Primitives/Badge",
6
6
  component: Badge,
7
7
  args: { children: "released" },
8
8
  argTypes: { status: { control: "inline-radio", options: ["neutral", "success", "warning", "error", "info"] } },
@@ -1,7 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Container, Empty, PostItem, PostList, Prose } from "./index";
2
+ import { Empty } from "./primitives";
3
+ import { Container } from "./layout";
4
+ import { PostItem, PostList, Prose } from "./marketing";
3
5
 
4
- const meta: Meta = { title: "Components/Blog" };
6
+ const meta: Meta = { title: "Components/Marketing/Blog" };
5
7
  export default meta;
6
8
  type Story = StoryObj;
7
9
 
@@ -1,8 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Button } from "./index";
2
+ import { Button } from "./primitives";
3
3
 
4
4
  const meta: Meta<typeof Button> = {
5
- title: "Components/Button",
5
+ title: "Components/Primitives/Button",
6
6
  component: Button,
7
7
  args: { children: "Breakdowns" },
8
8
  argTypes: { variant: { control: "inline-radio", options: ["default", "primary", "ghost", "danger"] } },
@@ -1,7 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Button, Container, Eyebrow, GradientText, Hero, HeroActions, Heading, Lead, Section, SectionIntro } from "./index";
2
+ import { Button, Eyebrow } from "./primitives";
3
+ import { Container, Section } from "./layout";
4
+ import { GradientText, Hero, HeroActions, Heading, Lead, SectionIntro } from "./marketing";
3
5
 
4
- const meta: Meta = { title: "Components/Content" };
6
+ const meta: Meta = { title: "Components/Marketing/Content" };
5
7
  export default meta;
6
8
  type Story = StoryObj;
7
9
 
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Badge, ScrollArea, Spinner, StatusDot, TBody, THead, Table, Td, Th, Tr } from "./index";
3
+ import { Badge } from "./primitives";
4
+ import { ScrollArea, Spinner, StatusDot, TBody, THead, Table, Td, Th, Tr } from "./data";
4
5
 
5
6
  const meta: Meta = { title: "Components/Data" };
6
7
  export default meta;
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Checkbox, Field, Input, Select, Switch, Textarea } from "./index";
3
+ import { Checkbox, Field, Input, Select, Switch, Textarea } from "./forms";
4
4
 
5
5
  const meta: Meta = { title: "Components/Forms" };
6
6
  export default meta;
@@ -1,17 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import {
3
- Callout,
4
- Container,
5
- Divider,
6
- Eyebrow,
7
- GradientText,
8
- Heading,
9
- Hero,
10
- Lead,
11
- Row,
12
- Section,
13
- SectionIntro,
14
- } from "./index";
2
+ import { Callout, Divider, Eyebrow } from "./primitives";
3
+ import { Container, Row, Section } from "./layout";
4
+ import { GradientText, Heading, Hero, Lead, SectionIntro } from "./marketing";
15
5
 
16
6
  const meta: Meta = {
17
7
  title: "Introduction",
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useRef } from "react";
3
- import { Button, Menu, MenuHandle, MenuItem, MenuLabel, MenuSeparator, MenuSub } from "./index";
3
+ import { Button } from "./primitives";
4
+ import { Menu, MenuHandle, MenuItem, MenuLabel, MenuSeparator, MenuSub } from "./menu";
4
5
 
5
6
  const meta: Meta = { title: "Components/Menu" };
6
7
  export default meta;
@@ -1,15 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import {
4
- Button,
5
- ConfirmDialog,
6
- Dialog,
7
- Drawer,
8
- Field,
9
- Tooltip,
10
- ToastProvider,
11
- useToast,
12
- } from "./index";
3
+ import { Button } from "./primitives";
4
+ import { Field } from "./forms";
5
+ import { ConfirmDialog, Dialog, Drawer, Tooltip, ToastProvider, useToast } from "./overlays";
13
6
 
14
7
  const meta: Meta = { title: "Components/Overlays" };
15
8
  export default meta;
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Callout, Divider, Kbd, TextLink } from "./index";
2
+ import { Callout, Divider, Kbd, TextLink } from "./primitives";
3
3
 
4
- const meta: Meta = { title: "Components/Primitives" };
4
+ const meta: Meta = { title: "Components/Primitives/Atoms" };
5
5
  export default meta;
6
6
  type Story = StoryObj;
7
7
 
@@ -1,7 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Check, Checks, Deliverable, Deliverables, Heading, Section, Step, Steps } from "./index";
2
+ import { Section } from "./layout";
3
+ import { Check, Checks, Deliverable, Deliverables, Heading, Step, Steps } from "./marketing";
3
4
 
4
- const meta: Meta = { title: "Components/Process" };
5
+ const meta: Meta = { title: "Components/Marketing/Process" };
5
6
  export default meta;
6
7
  type Story = StoryObj;
7
8
 
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Row, Stats, Stat } from "./index";
2
+ import { Row, Stats, Stat } from "./layout";
3
3
 
4
- const meta: Meta<typeof Row> = { title: "Components/Row", component: Row };
4
+ const meta: Meta<typeof Row> = { title: "Components/Layout/Row", component: Row };
5
5
  export default meta;
6
6
  type Story = StoryObj<typeof Row>;
7
7
 
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Skeleton, SkeletonGroup } from "./index";
2
+ import { Skeleton, SkeletonGroup } from "./data";
3
3
 
4
- const meta: Meta = { title: "Components/Skeleton" };
4
+ const meta: Meta = { title: "Components/Data/Skeleton" };
5
5
  export default meta;
6
6
  type Story = StoryObj;
7
7
 
@@ -1,7 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Card, Eyebrow, Stat } from "./index";
2
+ import { Card, Eyebrow } from "./primitives";
3
+ import { Stat } from "./layout";
3
4
 
4
- const meta: Meta = { title: "Components/Surface" };
5
+ const meta: Meta = { title: "Components/Primitives/Surface" };
5
6
  export default meta;
6
7
  type Story = StoryObj;
7
8
 
@@ -0,0 +1,309 @@
1
+ // The operator-console shell, converged from protoAgent's production ADR 0035
2
+ // dual-rail layout. All three are dumb + props-driven; persistence (rail order,
3
+ // widths, active surfaces) stays app-side — AppShell is controlled.
4
+ import type {
5
+ KeyboardEvent as ReactKeyboardEvent,
6
+ MouseEvent as ReactMouseEvent,
7
+ PointerEvent as ReactPointerEvent,
8
+ ReactNode,
9
+ } from "react";
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { cx } from "./internal";
12
+ import { Drawer } from "./overlays";
13
+
14
+ export type RailItem = {
15
+ id: string;
16
+ label: string;
17
+ icon: ReactNode;
18
+ /** Count badge (caps at "9+"). Mutually exclusive with `dot`. */
19
+ badge?: number;
20
+ /** Pulsing indicator, no count (e.g. a background stream). */
21
+ dot?: boolean;
22
+ };
23
+
24
+ /** Vertical icon rail — both the left and right rails render through this.
25
+ * `onContextMenu` is the integration point for the DS `Menu` (right-click →
26
+ * host calls menuRef.open({x,y})); the menu's registry/keying stays app-side. */
27
+ export function SurfaceRail({
28
+ side,
29
+ ariaLabel,
30
+ items,
31
+ activeId,
32
+ onSelect,
33
+ onContextMenu,
34
+ }: {
35
+ side: "left" | "right";
36
+ ariaLabel: string;
37
+ items: RailItem[];
38
+ activeId: string;
39
+ onSelect: (id: string) => void;
40
+ onContextMenu?: (e: ReactMouseEvent, id: string) => void;
41
+ }) {
42
+ return (
43
+ <aside className={cx("pl-rail", side === "right" && "pl-rail--right")} aria-label={ariaLabel}>
44
+ {items.map((it) => (
45
+ <button
46
+ key={it.id}
47
+ type="button"
48
+ className={cx("pl-rail__btn", it.id === activeId && "pl-rail__btn--active")}
49
+ title={it.label}
50
+ aria-label={it.label}
51
+ aria-current={it.id === activeId ? "page" : undefined}
52
+ onClick={() => onSelect(it.id)}
53
+ onContextMenu={onContextMenu ? (e) => onContextMenu(e, it.id) : undefined}
54
+ >
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}
64
+ </button>
65
+ ))}
66
+ </aside>
67
+ );
68
+ }
69
+
70
+ export type MobileItem = { id: string; label: string; icon: ReactNode };
71
+
72
+ /** Mobile shell (<768px): a bottom quick-bar (first 5 pinned surfaces) + a
73
+ * "More" button that opens the full surface list in a DS `Drawer`. */
74
+ export function MobileNav({
75
+ items,
76
+ activeId,
77
+ onSelect,
78
+ quickBarIds,
79
+ }: {
80
+ items: MobileItem[];
81
+ activeId: string;
82
+ onSelect: (id: string) => void;
83
+ /** Surfaces pinned to the bottom bar (first 5 used). */
84
+ quickBarIds: string[];
85
+ }) {
86
+ const [open, setOpen] = useState(false);
87
+ const byId = new Map(items.map((i) => [i.id, i] as const));
88
+ const quick = quickBarIds
89
+ .map((id) => byId.get(id))
90
+ .filter((i): i is MobileItem => Boolean(i))
91
+ .slice(0, 5);
92
+ const pick = (id: string) => {
93
+ onSelect(id);
94
+ setOpen(false);
95
+ };
96
+ return (
97
+ <>
98
+ <nav className="pl-mobilenav" aria-label="Quick surfaces">
99
+ {quick.map((it) => (
100
+ <button
101
+ key={it.id}
102
+ type="button"
103
+ className={cx("pl-mobilenav__tab", it.id === activeId && "pl-mobilenav__tab--active")}
104
+ onClick={() => pick(it.id)}
105
+ >
106
+ <span className="pl-mobilenav__icon" aria-hidden>
107
+ {it.icon}
108
+ </span>
109
+ <span>{it.label}</span>
110
+ </button>
111
+ ))}
112
+ <button type="button" className="pl-mobilenav__tab" onClick={() => setOpen(true)} aria-label="All surfaces">
113
+ <span className="pl-mobilenav__icon" aria-hidden>
114
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
115
+ <path d="M3 6h18M3 12h18M3 18h18" />
116
+ </svg>
117
+ </span>
118
+ <span>More</span>
119
+ </button>
120
+ </nav>
121
+ <Drawer open={open} onClose={() => setOpen(false)} side="right" title="Surfaces" width={280}>
122
+ <div className="pl-mobilenav__list">
123
+ {items.map((it) => (
124
+ <button
125
+ key={it.id}
126
+ type="button"
127
+ className={cx("pl-mobilenav__list-item", it.id === activeId && "pl-mobilenav__list-item--active")}
128
+ onClick={() => pick(it.id)}
129
+ >
130
+ <span className="pl-mobilenav__icon" aria-hidden>
131
+ {it.icon}
132
+ </span>
133
+ <span>{it.label}</span>
134
+ </button>
135
+ ))}
136
+ </div>
137
+ </Drawer>
138
+ </>
139
+ );
140
+ }
141
+
142
+ /** True below `breakpoint` px (client-only; false on first paint). */
143
+ function useIsMobile(breakpoint: number) {
144
+ const [mobile, setMobile] = useState(false);
145
+ useEffect(() => {
146
+ if (typeof window === "undefined" || !window.matchMedia) return;
147
+ const mq = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
148
+ const update = () => setMobile(mq.matches);
149
+ update();
150
+ mq.addEventListener("change", update);
151
+ return () => mq.removeEventListener("change", update);
152
+ }, [breakpoint]);
153
+ return mobile;
154
+ }
155
+
156
+ export type AppShellProps = {
157
+ leftItems: RailItem[];
158
+ rightItems: RailItem[];
159
+ activeLeft: string;
160
+ activeRight: string;
161
+ onSelect: (side: "left" | "right", id: string) => void;
162
+ /** Right-click on a rail icon — wire to a DS `Menu` for move/reorder etc. */
163
+ onRailContextMenu?: (side: "left" | "right", e: ReactMouseEvent, id: string) => void;
164
+ leftContent: ReactNode;
165
+ rightContent: ReactNode;
166
+ /** Controlled right-column width (px). */
167
+ rightWidth: number;
168
+ onRightWidthChange: (width: number) => void;
169
+ rightCollapsed?: boolean;
170
+ onCollapse?: (collapsed: boolean) => void;
171
+ minRightWidth?: number;
172
+ maxRightWidth?: number;
173
+ /** Mobile (<breakpoint) config. Omit to disable the mobile shell. */
174
+ mobileItems?: MobileItem[];
175
+ mobileActiveId?: string;
176
+ onMobileSelect?: (id: string) => void;
177
+ quickBarIds?: string[];
178
+ mobileBreakpoint?: number;
179
+ className?: string;
180
+ };
181
+
182
+ /** The full dual-rail operator shell:
183
+ * `[left rail][left column][resize handle][right column][right rail]`,
184
+ * collapsing to `MobileNav` below `mobileBreakpoint`. Controlled — the host
185
+ * owns the surface registry and persists rail order / widths / active state. */
186
+ export function AppShell({
187
+ leftItems,
188
+ rightItems,
189
+ activeLeft,
190
+ activeRight,
191
+ onSelect,
192
+ onRailContextMenu,
193
+ leftContent,
194
+ rightContent,
195
+ rightWidth,
196
+ onRightWidthChange,
197
+ rightCollapsed = false,
198
+ onCollapse,
199
+ minRightWidth = 280,
200
+ maxRightWidth = 720,
201
+ mobileItems,
202
+ mobileActiveId,
203
+ onMobileSelect,
204
+ quickBarIds,
205
+ mobileBreakpoint = 768,
206
+ className,
207
+ }: AppShellProps) {
208
+ const isMobile = useIsMobile(mobileBreakpoint);
209
+ const drag = useRef<{ startX: number; startW: number } | null>(null);
210
+ const clamp = useCallback(
211
+ (w: number) => Math.min(maxRightWidth, Math.max(minRightWidth, w)),
212
+ [minRightWidth, maxRightWidth],
213
+ );
214
+
215
+ const onPointerDown = useCallback(
216
+ (e: ReactPointerEvent<HTMLDivElement>) => {
217
+ e.preventDefault();
218
+ drag.current = { startX: e.clientX, startW: rightWidth };
219
+ e.currentTarget.setPointerCapture(e.pointerId);
220
+ },
221
+ [rightWidth],
222
+ );
223
+ const onPointerMove = useCallback(
224
+ (e: ReactPointerEvent<HTMLDivElement>) => {
225
+ if (!drag.current) return;
226
+ onRightWidthChange(clamp(drag.current.startW + (drag.current.startX - e.clientX)));
227
+ },
228
+ [clamp, onRightWidthChange],
229
+ );
230
+ const onPointerUp = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
231
+ drag.current = null;
232
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
233
+ }, []);
234
+ const onKeyDown = useCallback(
235
+ (e: ReactKeyboardEvent<HTMLDivElement>) => {
236
+ const step = e.shiftKey ? 48 : 16;
237
+ if (e.key === "ArrowLeft") {
238
+ e.preventDefault();
239
+ onRightWidthChange(clamp(rightWidth + step));
240
+ } else if (e.key === "ArrowRight") {
241
+ e.preventDefault();
242
+ onRightWidthChange(clamp(rightWidth - step));
243
+ }
244
+ },
245
+ [rightWidth, clamp, onRightWidthChange],
246
+ );
247
+
248
+ if (isMobile && mobileItems && onMobileSelect && quickBarIds) {
249
+ return (
250
+ <div className={cx("pl-appshell", "pl-appshell--mobile", className)}>
251
+ <div className="pl-appshell__mobile-stage">{leftContent}</div>
252
+ <MobileNav
253
+ items={mobileItems}
254
+ activeId={mobileActiveId ?? activeLeft}
255
+ onSelect={onMobileSelect}
256
+ quickBarIds={quickBarIds}
257
+ />
258
+ </div>
259
+ );
260
+ }
261
+
262
+ const showRight = !rightCollapsed && rightItems.length > 0;
263
+ return (
264
+ <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
+ />
273
+ <main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
274
+ {showRight && (
275
+ <div
276
+ className="pl-appshell__handle"
277
+ role="separator"
278
+ aria-orientation="vertical"
279
+ aria-label="Resize right panel"
280
+ aria-valuenow={rightWidth}
281
+ aria-valuemin={minRightWidth}
282
+ aria-valuemax={maxRightWidth}
283
+ tabIndex={0}
284
+ onPointerDown={onPointerDown}
285
+ onPointerMove={onPointerMove}
286
+ onPointerUp={onPointerUp}
287
+ onKeyDown={onKeyDown}
288
+ onDoubleClick={() => onCollapse?.(true)}
289
+ />
290
+ )}
291
+ {showRight && (
292
+ <aside
293
+ className="pl-appshell__col pl-appshell__col--right"
294
+ style={{ flex: `0 0 ${rightWidth}px`, width: rightWidth }}
295
+ >
296
+ {rightContent}
297
+ </aside>
298
+ )}
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
+ />
307
+ </div>
308
+ );
309
+ }