@protolabsai/ui 0.7.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 +10 -2
- package/src/AppShell.full.stories.tsx +4 -2
- package/src/AppShell.stories.tsx +3 -2
- package/src/Badge.stories.tsx +2 -2
- package/src/Blog.stories.tsx +4 -2
- package/src/Button.stories.tsx +2 -2
- package/src/Content.stories.tsx +4 -2
- package/src/Data.stories.tsx +2 -1
- package/src/Forms.stories.tsx +1 -1
- package/src/Introduction.stories.tsx +3 -13
- package/src/Menu.stories.tsx +2 -1
- package/src/Overlays.stories.tsx +3 -10
- package/src/Primitives.stories.tsx +2 -2
- package/src/Process.stories.tsx +3 -2
- package/src/Row.stories.tsx +2 -2
- package/src/Skeleton.stories.tsx +2 -2
- package/src/Surface.stories.tsx +3 -2
- package/src/app-shell.tsx +309 -0
- package/src/data.tsx +126 -0
- package/src/forms.tsx +109 -0
- package/src/internal.ts +11 -0
- package/src/layout.tsx +57 -0
- package/src/marketing.tsx +95 -0
- package/src/menu.tsx +141 -0
- package/src/navigation.tsx +94 -0
- package/src/overlays.tsx +299 -0
- package/src/primitives.tsx +90 -0
- package/src/styles/app-shell.css +212 -0
- package/src/styles/data.css +185 -0
- package/src/styles/forms.css +193 -0
- package/src/styles/layout.css +98 -0
- package/src/styles/marketing.css +249 -0
- package/src/styles/menu.css +90 -0
- package/src/styles/navigation.css +173 -0
- package/src/styles/overlays.css +295 -0
- package/src/styles/primitives.css +219 -0
- package/src/styles.css +12 -1510
- package/src/index.tsx +0 -1329
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protolabsai/ui",
|
|
3
|
-
"version": "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
|
-
"
|
|
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": [
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
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";
|
|
5
7
|
|
|
6
8
|
const meta: Meta = { title: "Components/AppShell" };
|
|
7
9
|
export default meta;
|
package/src/AppShell.stories.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { Badge, Button
|
|
3
|
+
import { Badge, Button } from "./primitives";
|
|
4
|
+
import { PanelHeader, Tabs } from "./navigation";
|
|
4
5
|
|
|
5
|
-
const meta: Meta = { title: "Components/
|
|
6
|
+
const meta: Meta = { title: "Components/Navigation" };
|
|
6
7
|
export default meta;
|
|
7
8
|
type Story = StoryObj;
|
|
8
9
|
|
package/src/Badge.stories.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Badge, type Status } from "./
|
|
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"] } },
|
package/src/Blog.stories.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import {
|
|
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
|
|
package/src/Button.stories.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Button } from "./
|
|
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"] } },
|
package/src/Content.stories.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Button,
|
|
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
|
|
package/src/Data.stories.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { Badge
|
|
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;
|
package/src/Forms.stories.tsx
CHANGED
|
@@ -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 "./
|
|
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
|
-
|
|
4
|
-
|
|
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",
|
package/src/Menu.stories.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useRef } from "react";
|
|
3
|
-
import { Button
|
|
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;
|
package/src/Overlays.stories.tsx
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
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 "./
|
|
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
|
|
package/src/Process.stories.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import {
|
|
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
|
|
package/src/Row.stories.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Row, Stats, Stat } from "./
|
|
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
|
|
package/src/Skeleton.stories.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Skeleton, SkeletonGroup } from "./
|
|
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
|
|
package/src/Surface.stories.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Card, Eyebrow
|
|
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
|
+
}
|
package/src/data.tsx
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HTMLAttributes,
|
|
3
|
+
ReactNode,
|
|
4
|
+
TableHTMLAttributes,
|
|
5
|
+
TdHTMLAttributes,
|
|
6
|
+
ThHTMLAttributes,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { cx } from "./internal";
|
|
9
|
+
import type { Status } from "./internal";
|
|
10
|
+
|
|
11
|
+
/** Dense data table on the 4px grid. Compose with THead/TBody/Tr/Th/Td. */
|
|
12
|
+
export function Table({ className, ...rest }: TableHTMLAttributes<HTMLTableElement>) {
|
|
13
|
+
return <table className={cx("pl-table", className)} {...rest} />;
|
|
14
|
+
}
|
|
15
|
+
export function THead(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
16
|
+
return <thead {...props} />;
|
|
17
|
+
}
|
|
18
|
+
export function TBody(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
19
|
+
return <tbody {...props} />;
|
|
20
|
+
}
|
|
21
|
+
/** Table row. `selected` highlights; an `onClick` makes it hover-interactive. */
|
|
22
|
+
export function Tr({
|
|
23
|
+
selected,
|
|
24
|
+
className,
|
|
25
|
+
...rest
|
|
26
|
+
}: HTMLAttributes<HTMLTableRowElement> & { selected?: boolean }) {
|
|
27
|
+
return (
|
|
28
|
+
<tr
|
|
29
|
+
className={cx(selected && "pl-tr--selected", rest.onClick && "pl-tr--interactive", className)}
|
|
30
|
+
{...rest}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
export function Th({ className, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
|
|
35
|
+
return <th className={className} {...rest} />;
|
|
36
|
+
}
|
|
37
|
+
export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
|
|
38
|
+
return <td className={className} {...rest} />;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Live/health indicator. `pulse` breathes on the 2s status cadence. */
|
|
42
|
+
export function StatusDot({
|
|
43
|
+
status = "neutral",
|
|
44
|
+
pulse,
|
|
45
|
+
label,
|
|
46
|
+
}: {
|
|
47
|
+
status?: Status;
|
|
48
|
+
pulse?: boolean;
|
|
49
|
+
label?: ReactNode;
|
|
50
|
+
}) {
|
|
51
|
+
const dot = (
|
|
52
|
+
<span
|
|
53
|
+
className={cx("pl-dot", status !== "neutral" && `pl-dot--${status}`, pulse && "pl-dot--pulse")}
|
|
54
|
+
aria-hidden
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
if (label == null) return dot;
|
|
58
|
+
return (
|
|
59
|
+
<span className="pl-dot-row">
|
|
60
|
+
{dot}
|
|
61
|
+
<span className="pl-dot-row__label">{label}</span>
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Indeterminate spinner (1s linear, brand-restrained). */
|
|
67
|
+
export function Spinner({ size = 16, className }: { size?: number; className?: string }) {
|
|
68
|
+
return (
|
|
69
|
+
<span
|
|
70
|
+
className={cx("pl-spinner", className)}
|
|
71
|
+
style={{ width: size, height: size }}
|
|
72
|
+
role="status"
|
|
73
|
+
aria-label="Loading"
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Scroll container with brand-styled thin scrollbars. Carries `min-height:0`
|
|
79
|
+
* (so it scrolls inside flex/grid parents) + overscroll containment. Pass
|
|
80
|
+
* `ariaLabel` to make it a keyboard-focusable, labelled scroll region. */
|
|
81
|
+
export function ScrollArea({
|
|
82
|
+
ariaLabel,
|
|
83
|
+
className,
|
|
84
|
+
...rest
|
|
85
|
+
}: HTMLAttributes<HTMLDivElement> & { ariaLabel?: string }) {
|
|
86
|
+
const a11y = ariaLabel != null ? { role: "region", "aria-label": ariaLabel, tabIndex: 0 } : {};
|
|
87
|
+
return <div className={cx("pl-scroll", className)} {...a11y} {...rest} />;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Shimmering content-shaped loading placeholder. `lines>1` stacks text bars
|
|
91
|
+
* (last one short). Token-driven; static fill under reduced-motion. */
|
|
92
|
+
export function Skeleton({
|
|
93
|
+
width,
|
|
94
|
+
height = 14,
|
|
95
|
+
lines,
|
|
96
|
+
className,
|
|
97
|
+
style,
|
|
98
|
+
...rest
|
|
99
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
100
|
+
width?: number | string;
|
|
101
|
+
height?: number | string;
|
|
102
|
+
/** Stack N text-line bars instead of a single bar. */
|
|
103
|
+
lines?: number;
|
|
104
|
+
}) {
|
|
105
|
+
if (lines != null && lines > 1) {
|
|
106
|
+
return (
|
|
107
|
+
<div className={cx("pl-skel-lines", className)} style={style} {...rest}>
|
|
108
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
109
|
+
<div
|
|
110
|
+
key={i}
|
|
111
|
+
className="pl-skel"
|
|
112
|
+
style={{ height, width: i === lines - 1 ? "65%" : (width ?? "100%") }}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return (
|
|
119
|
+
<div className={cx("pl-skel", className)} style={{ width: width ?? "100%", height, ...style }} {...rest} />
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Optional wrapper to group related skeletons (shared layout gap). */
|
|
124
|
+
export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
125
|
+
return <div className={cx("pl-skel-group", className)} {...rest} />;
|
|
126
|
+
}
|