@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/src/index.tsx
DELETED
|
@@ -1,1329 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @protolabsai/ui — React primitives on the @protolabsai/design token set.
|
|
3
|
-
* Every component is className-only over the --pl-* custom properties, so it
|
|
4
|
-
* inherits the locked brand and restyles for free when a token changes.
|
|
5
|
-
*/
|
|
6
|
-
import type {
|
|
7
|
-
ButtonHTMLAttributes,
|
|
8
|
-
HTMLAttributes,
|
|
9
|
-
InputHTMLAttributes,
|
|
10
|
-
KeyboardEvent as ReactKeyboardEvent,
|
|
11
|
-
MouseEvent as ReactMouseEvent,
|
|
12
|
-
PointerEvent as ReactPointerEvent,
|
|
13
|
-
ReactNode,
|
|
14
|
-
RefObject,
|
|
15
|
-
SelectHTMLAttributes,
|
|
16
|
-
TableHTMLAttributes,
|
|
17
|
-
TdHTMLAttributes,
|
|
18
|
-
TextareaHTMLAttributes,
|
|
19
|
-
ThHTMLAttributes,
|
|
20
|
-
} from "react";
|
|
21
|
-
import {
|
|
22
|
-
createContext,
|
|
23
|
-
forwardRef,
|
|
24
|
-
useCallback,
|
|
25
|
-
useContext,
|
|
26
|
-
useEffect,
|
|
27
|
-
useId,
|
|
28
|
-
useImperativeHandle,
|
|
29
|
-
useRef,
|
|
30
|
-
useState,
|
|
31
|
-
} from "react";
|
|
32
|
-
import * as RDropdown from "@radix-ui/react-dropdown-menu";
|
|
33
|
-
import "./styles.css";
|
|
34
|
-
|
|
35
|
-
const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
|
|
36
|
-
|
|
37
|
-
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
38
|
-
/** "primary"/"danger" read as a stronger border, not a fill (brand restraint);
|
|
39
|
-
* "ghost" is transparent until hover. */
|
|
40
|
-
variant?: "default" | "primary" | "ghost" | "danger";
|
|
41
|
-
size?: "sm" | "md";
|
|
42
|
-
/** Icon-only: square, centered glyph. Pass `aria-label` for a11y. */
|
|
43
|
-
icon?: boolean;
|
|
44
|
-
};
|
|
45
|
-
export function Button({ variant = "default", size = "md", icon, className, ...rest }: ButtonProps) {
|
|
46
|
-
return (
|
|
47
|
-
<button
|
|
48
|
-
className={cx(
|
|
49
|
-
"pl-btn",
|
|
50
|
-
variant !== "default" && `pl-btn--${variant}`,
|
|
51
|
-
size === "sm" && "pl-btn--sm",
|
|
52
|
-
icon && "pl-btn--icon",
|
|
53
|
-
className,
|
|
54
|
-
)}
|
|
55
|
-
{...rest}
|
|
56
|
-
/>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export type Status = "neutral" | "success" | "warning" | "error" | "info";
|
|
61
|
-
export function Badge({ status = "neutral", children }: { status?: Status; children: ReactNode }) {
|
|
62
|
-
return <span className={cx("pl-badge", status !== "neutral" && `pl-badge--${status}`)}>{children}</span>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
66
|
-
return <div className={cx("pl-card", className)} {...rest} />;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function Eyebrow({ children }: { children: ReactNode }) {
|
|
70
|
-
return <div className="pl-eyebrow">{children}</div>;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function Stat({ value, label }: { value: ReactNode; label: ReactNode }) {
|
|
74
|
-
return (
|
|
75
|
-
<div>
|
|
76
|
-
<div className="pl-stat__num">{value}</div>
|
|
77
|
-
<div className="pl-stat__label">{label}</div>
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function Container({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
83
|
-
return <div className={cx("pl-container", className)} {...rest} />;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function Section({ className, ...rest }: HTMLAttributes<HTMLElement>) {
|
|
87
|
-
return <section className={cx("pl-section", className)} {...rest} />;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Stats grid — wrap Stat children. Two columns, four at ≥640px. */
|
|
91
|
-
export function Stats({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
92
|
-
return <div className={cx("pl-stats", className)} {...rest} />;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export type RowProps = {
|
|
96
|
-
/** Left mono label / layer. */
|
|
97
|
-
label: string;
|
|
98
|
-
/** Optional mono name above the description. */
|
|
99
|
-
name?: ReactNode;
|
|
100
|
-
desc: ReactNode;
|
|
101
|
-
/** When present, the row widens to label | body | status. */
|
|
102
|
-
status?: ReactNode;
|
|
103
|
-
/** Renders as a link when set. */
|
|
104
|
-
href?: string;
|
|
105
|
-
external?: boolean;
|
|
106
|
-
};
|
|
107
|
-
export function Row({ label, name, desc, status, href, external }: RowProps) {
|
|
108
|
-
const cls = cx("pl-row", status != null && "pl-row--wide");
|
|
109
|
-
const inner = (
|
|
110
|
-
<>
|
|
111
|
-
<span className="pl-row__label">{label}</span>
|
|
112
|
-
<span>
|
|
113
|
-
{name != null && <div className="pl-row__name">{name}</div>}
|
|
114
|
-
<div className="pl-row__desc">{desc}</div>
|
|
115
|
-
</span>
|
|
116
|
-
{status != null && <span className="pl-row__status">{status}</span>}
|
|
117
|
-
</>
|
|
118
|
-
);
|
|
119
|
-
return href ? (
|
|
120
|
-
<a className={cls} href={href} {...(external ? { target: "_blank", rel: "noreferrer noopener" } : {})}>
|
|
121
|
-
{inner}
|
|
122
|
-
</a>
|
|
123
|
-
) : (
|
|
124
|
-
<div className={cls}>{inner}</div>
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** The one gradient — the tagline word treatment. Foundation §1 + §13. */
|
|
129
|
-
export function GradientText({ children }: { children: ReactNode }) {
|
|
130
|
-
return <span className="pl-gradient-text">{children}</span>;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Hero header — put an <h1> + <Lead> + <HeroActions> inside. */
|
|
134
|
-
export function Hero({ className, ...rest }: HTMLAttributes<HTMLElement>) {
|
|
135
|
-
return <header className={cx("pl-hero", className)} {...rest} />;
|
|
136
|
-
}
|
|
137
|
-
/** Button row under the hero lead. */
|
|
138
|
-
export function HeroActions({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
139
|
-
return <div className={cx("pl-hero__cta", className)} {...rest} />;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Large muted intro paragraph (hero size). */
|
|
143
|
-
export function Lead({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
|
|
144
|
-
return <p className={cx("pl-lead", className)} {...rest} />;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Section heading — self-contained h2 (doesn't rely on a global reset). */
|
|
148
|
-
export function Heading({ className, ...rest }: HTMLAttributes<HTMLHeadingElement>) {
|
|
149
|
-
return <h2 className={cx("pl-heading", className)} {...rest} />;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Muted paragraph that introduces a section (body size). */
|
|
153
|
-
export function SectionIntro({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
|
|
154
|
-
return <p className={cx("pl-section-intro", className)} {...rest} />;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Numbered process list — wrap Step children. */
|
|
158
|
-
export function Steps({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
159
|
-
return <div className={cx("pl-steps", className)} {...rest} />;
|
|
160
|
-
}
|
|
161
|
-
export function Step({ n, title, children }: { n: ReactNode; title: ReactNode; children: ReactNode }) {
|
|
162
|
-
return (
|
|
163
|
-
<div className="pl-step">
|
|
164
|
-
<div className="pl-step__num">{n}</div>
|
|
165
|
-
<div>
|
|
166
|
-
<div className="pl-step__title">{title}</div>
|
|
167
|
-
<div className="pl-step__body">{children}</div>
|
|
168
|
-
</div>
|
|
169
|
-
</div>
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Checklist — wrap Check children. The ✓ mark is rendered for you. */
|
|
174
|
-
export function Checks({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
175
|
-
return <div className={cx("pl-checks", className)} {...rest} />;
|
|
176
|
-
}
|
|
177
|
-
export function Check({ children, mark = "✓" }: { children: ReactNode; mark?: ReactNode }) {
|
|
178
|
-
return (
|
|
179
|
-
<div className="pl-check">
|
|
180
|
-
<span className="pl-check__mark" aria-hidden>
|
|
181
|
-
{mark}
|
|
182
|
-
</span>
|
|
183
|
-
<span>{children}</span>
|
|
184
|
-
</div>
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Two-column deliverable cards (left-border, mono title). */
|
|
189
|
-
export function Deliverables({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
190
|
-
return <div className={cx("pl-deliverables", className)} {...rest} />;
|
|
191
|
-
}
|
|
192
|
-
export function Deliverable({ title, children }: { title: ReactNode; children: ReactNode }) {
|
|
193
|
-
return (
|
|
194
|
-
<div className="pl-deliverable">
|
|
195
|
-
<div className="pl-deliverable__title">{title}</div>
|
|
196
|
-
<div className="pl-deliverable__body">{children}</div>
|
|
197
|
-
</div>
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Blog index list — wrap PostItem children. */
|
|
202
|
-
export function PostList({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
203
|
-
return <div className={cx("pl-post-list", className)} {...rest} />;
|
|
204
|
-
}
|
|
205
|
-
export type PostItemProps = { meta?: ReactNode; title: ReactNode; excerpt?: ReactNode; href: string };
|
|
206
|
-
export function PostItem({ meta, title, excerpt, href }: PostItemProps) {
|
|
207
|
-
return (
|
|
208
|
-
<a className="pl-post-item" href={href}>
|
|
209
|
-
{meta != null && <div className="pl-post-item__meta">{meta}</div>}
|
|
210
|
-
<div className="pl-post-item__title">{title}</div>
|
|
211
|
-
{excerpt != null && <div className="pl-post-item__excerpt">{excerpt}</div>}
|
|
212
|
-
</a>
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** Mono empty-state line. */
|
|
217
|
-
export function Empty({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
218
|
-
return <div className={cx("pl-empty", className)} {...rest} />;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Long-form rich-text wrapper (blog post body, docs). */
|
|
222
|
-
export function Prose({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
223
|
-
return <div className={cx("pl-prose", className)} {...rest} />;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Hairline rule. */
|
|
227
|
-
export function Divider({ className, ...rest }: HTMLAttributes<HTMLHRElement>) {
|
|
228
|
-
return <hr className={cx("pl-divider", className)} {...rest} />;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Bordered note block — left-accent keyed to the status tone. */
|
|
232
|
-
export function Callout({
|
|
233
|
-
tone = "neutral",
|
|
234
|
-
title,
|
|
235
|
-
children,
|
|
236
|
-
}: {
|
|
237
|
-
tone?: Status;
|
|
238
|
-
title?: ReactNode;
|
|
239
|
-
children: ReactNode;
|
|
240
|
-
}) {
|
|
241
|
-
return (
|
|
242
|
-
<div className={cx("pl-callout", tone !== "neutral" && `pl-callout--${tone}`)}>
|
|
243
|
-
{title != null && <div className="pl-callout__title">{title}</div>}
|
|
244
|
-
<div className="pl-callout__body">{children}</div>
|
|
245
|
-
</div>
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Keyboard / inline-token chip. */
|
|
250
|
-
export function Kbd({ children }: { children: ReactNode }) {
|
|
251
|
-
return <kbd className="pl-kbd">{children}</kbd>;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** Standalone styled link (underline-offset treatment). Use the app's router
|
|
255
|
-
* Link for internal navigation; this is for plain anchors. */
|
|
256
|
-
export function TextLink({
|
|
257
|
-
className,
|
|
258
|
-
external,
|
|
259
|
-
...rest
|
|
260
|
-
}: HTMLAttributes<HTMLAnchorElement> & { href?: string; external?: boolean }) {
|
|
261
|
-
return (
|
|
262
|
-
<a
|
|
263
|
-
className={cx("pl-link", className)}
|
|
264
|
-
{...(external ? { target: "_blank", rel: "noreferrer noopener" } : {})}
|
|
265
|
-
{...rest}
|
|
266
|
-
/>
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ── App primitives (cockpit + future studio apps) ────────────────────────────
|
|
271
|
-
|
|
272
|
-
export type TabItem = {
|
|
273
|
-
id: string;
|
|
274
|
-
label: ReactNode;
|
|
275
|
-
/** Leading icon (e.g. a Lucide glyph). */
|
|
276
|
-
icon?: ReactNode;
|
|
277
|
-
/** Trailing badge / count (e.g. unread inbox count). */
|
|
278
|
-
badge?: ReactNode;
|
|
279
|
-
disabled?: boolean;
|
|
280
|
-
locked?: boolean;
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
/** A horizontal tab strip with optional icon/badge slots + disabled/locked
|
|
284
|
-
* support (gated workflows). */
|
|
285
|
-
export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: string; onSelect: (id: string) => void }) {
|
|
286
|
-
return (
|
|
287
|
-
<div className="pl-tabs" role="tablist">
|
|
288
|
-
{items.map((t) => (
|
|
289
|
-
<button
|
|
290
|
-
key={t.id}
|
|
291
|
-
role="tab"
|
|
292
|
-
type="button"
|
|
293
|
-
aria-selected={t.id === active}
|
|
294
|
-
className={cx("pl-tab", t.id === active && "pl-tab--active")}
|
|
295
|
-
disabled={t.disabled}
|
|
296
|
-
onClick={() => onSelect(t.id)}
|
|
297
|
-
>
|
|
298
|
-
{t.icon != null && (
|
|
299
|
-
<span className="pl-tab__icon" aria-hidden>
|
|
300
|
-
{t.icon}
|
|
301
|
-
</span>
|
|
302
|
-
)}
|
|
303
|
-
<span className="pl-tab__label">{t.label}</span>
|
|
304
|
-
{t.badge != null && <span className="pl-tab__badge">{t.badge}</span>}
|
|
305
|
-
{t.locked ? (
|
|
306
|
-
<span className="pl-tab__lock" aria-hidden>
|
|
307
|
-
🔒
|
|
308
|
-
</span>
|
|
309
|
-
) : null}
|
|
310
|
-
</button>
|
|
311
|
-
))}
|
|
312
|
-
</div>
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** A horizontal kanban board. Wrap BoardColumn children. */
|
|
317
|
-
export function Board({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
318
|
-
return <div className={cx("pl-board", className)} {...rest} />;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export function BoardColumn({ title, count, children }: { title: ReactNode; count?: ReactNode; children: ReactNode }) {
|
|
322
|
-
return (
|
|
323
|
-
<div className="pl-board-col">
|
|
324
|
-
<div className="pl-board-col__head">
|
|
325
|
-
<span>{title}</span>
|
|
326
|
-
{count != null ? <span className="pl-board-col__count">{count}</span> : null}
|
|
327
|
-
</div>
|
|
328
|
-
<div className="pl-board-col__body">{children}</div>
|
|
329
|
-
</div>
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
export function BoardCard({ className, ...rest }: ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
334
|
-
return <button type="button" className={cx("pl-board-card", className)} {...rest} />;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** A labeled input/textarea bound to a string value (form fields, editors). */
|
|
338
|
-
export function Field({
|
|
339
|
-
label,
|
|
340
|
-
value,
|
|
341
|
-
multiline,
|
|
342
|
-
readOnly,
|
|
343
|
-
placeholder,
|
|
344
|
-
onValueChange,
|
|
345
|
-
className,
|
|
346
|
-
}: {
|
|
347
|
-
label: ReactNode;
|
|
348
|
-
value?: string;
|
|
349
|
-
multiline?: boolean;
|
|
350
|
-
readOnly?: boolean;
|
|
351
|
-
placeholder?: string;
|
|
352
|
-
onValueChange?: (value: string) => void;
|
|
353
|
-
className?: string;
|
|
354
|
-
}) {
|
|
355
|
-
const shared = {
|
|
356
|
-
className: "pl-field__input",
|
|
357
|
-
value,
|
|
358
|
-
readOnly,
|
|
359
|
-
placeholder,
|
|
360
|
-
onChange: (e: { target: { value: string } }) => onValueChange?.(e.target.value),
|
|
361
|
-
};
|
|
362
|
-
return (
|
|
363
|
-
<label className={cx("pl-field", className)}>
|
|
364
|
-
<span className="pl-field__label">{label}</span>
|
|
365
|
-
{multiline ? <textarea {...shared} /> : <input {...shared} />}
|
|
366
|
-
</label>
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** Dense operator-console panel header — title + optional kicker eyebrow +
|
|
371
|
-
* right-aligned actions slot. `compact` tightens it for nested/secondary
|
|
372
|
-
* panels. The most-used console composite. */
|
|
373
|
-
export function PanelHeader({
|
|
374
|
-
title,
|
|
375
|
-
kicker,
|
|
376
|
-
actions,
|
|
377
|
-
compact,
|
|
378
|
-
className,
|
|
379
|
-
}: {
|
|
380
|
-
title: ReactNode;
|
|
381
|
-
kicker?: ReactNode;
|
|
382
|
-
actions?: ReactNode;
|
|
383
|
-
compact?: boolean;
|
|
384
|
-
className?: string;
|
|
385
|
-
}) {
|
|
386
|
-
return (
|
|
387
|
-
<div className={cx("pl-panel-header", compact && "pl-panel-header--compact", className)}>
|
|
388
|
-
<div className="pl-panel-header__titles">
|
|
389
|
-
{kicker != null && <div className="pl-panel-header__kicker">{kicker}</div>}
|
|
390
|
-
<h2 className="pl-panel-header__title">{title}</h2>
|
|
391
|
-
</div>
|
|
392
|
-
{actions != null && <div className="pl-panel-header__actions">{actions}</div>}
|
|
393
|
-
</div>
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// ── Overlays & feedback ──────────────────────────────────────────────────────
|
|
398
|
-
|
|
399
|
-
/** Esc-to-close + body scroll-lock while `open`. Shared by Dialog + Drawer. */
|
|
400
|
-
function useOverlayDismiss(open: boolean, onClose?: () => void) {
|
|
401
|
-
useEffect(() => {
|
|
402
|
-
if (!open) return;
|
|
403
|
-
const onKey = (e: KeyboardEvent) => {
|
|
404
|
-
if (e.key === "Escape") onClose?.();
|
|
405
|
-
};
|
|
406
|
-
document.addEventListener("keydown", onKey);
|
|
407
|
-
const prev = document.body.style.overflow;
|
|
408
|
-
document.body.style.overflow = "hidden";
|
|
409
|
-
return () => {
|
|
410
|
-
document.removeEventListener("keydown", onKey);
|
|
411
|
-
document.body.style.overflow = prev;
|
|
412
|
-
};
|
|
413
|
-
}, [open, onClose]);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/** Move focus into `ref` on open and cycle Tab within it (a11y modal trap). */
|
|
417
|
-
function useFocusTrap(ref: RefObject<HTMLElement | null>, open: boolean) {
|
|
418
|
-
useEffect(() => {
|
|
419
|
-
const node = ref.current;
|
|
420
|
-
if (!open || !node) return;
|
|
421
|
-
const focusables = () =>
|
|
422
|
-
Array.from(
|
|
423
|
-
node.querySelectorAll<HTMLElement>(
|
|
424
|
-
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])',
|
|
425
|
-
),
|
|
426
|
-
).filter((el) => el.offsetParent !== null);
|
|
427
|
-
(focusables()[0] ?? node).focus();
|
|
428
|
-
const onKey = (e: KeyboardEvent) => {
|
|
429
|
-
if (e.key !== "Tab") return;
|
|
430
|
-
const items = focusables();
|
|
431
|
-
if (items.length === 0) return;
|
|
432
|
-
const first = items[0];
|
|
433
|
-
const last = items[items.length - 1];
|
|
434
|
-
if (e.shiftKey && document.activeElement === first) {
|
|
435
|
-
e.preventDefault();
|
|
436
|
-
last.focus();
|
|
437
|
-
} else if (!e.shiftKey && document.activeElement === last) {
|
|
438
|
-
e.preventDefault();
|
|
439
|
-
first.focus();
|
|
440
|
-
}
|
|
441
|
-
};
|
|
442
|
-
node.addEventListener("keydown", onKey);
|
|
443
|
-
return () => node.removeEventListener("keydown", onKey);
|
|
444
|
-
}, [ref, open]);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/** Modal dialog — scrim + centered card, Esc / backdrop close, focus trap.
|
|
448
|
-
* Controlled: render with `open` and handle `onClose`. */
|
|
449
|
-
export function Dialog({
|
|
450
|
-
open,
|
|
451
|
-
onClose,
|
|
452
|
-
title,
|
|
453
|
-
children,
|
|
454
|
-
footer,
|
|
455
|
-
width,
|
|
456
|
-
className,
|
|
457
|
-
}: {
|
|
458
|
-
open: boolean;
|
|
459
|
-
onClose?: () => void;
|
|
460
|
-
title?: ReactNode;
|
|
461
|
-
children?: ReactNode;
|
|
462
|
-
/** Action row pinned to the dialog foot (e.g. Cancel / Confirm buttons). */
|
|
463
|
-
footer?: ReactNode;
|
|
464
|
-
width?: number | string;
|
|
465
|
-
className?: string;
|
|
466
|
-
}) {
|
|
467
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
468
|
-
const labelId = useId();
|
|
469
|
-
useOverlayDismiss(open, onClose);
|
|
470
|
-
useFocusTrap(ref, open);
|
|
471
|
-
if (!open) return null;
|
|
472
|
-
return (
|
|
473
|
-
<div
|
|
474
|
-
className="pl-overlay"
|
|
475
|
-
onMouseDown={(e) => {
|
|
476
|
-
if (e.target === e.currentTarget) onClose?.();
|
|
477
|
-
}}
|
|
478
|
-
>
|
|
479
|
-
<div
|
|
480
|
-
ref={ref}
|
|
481
|
-
className={cx("pl-dialog", className)}
|
|
482
|
-
role="dialog"
|
|
483
|
-
aria-modal="true"
|
|
484
|
-
aria-labelledby={title != null ? labelId : undefined}
|
|
485
|
-
tabIndex={-1}
|
|
486
|
-
style={width != null ? { width, maxWidth: "100%" } : undefined}
|
|
487
|
-
>
|
|
488
|
-
{title != null && (
|
|
489
|
-
<div className="pl-dialog__head">
|
|
490
|
-
<div className="pl-dialog__title" id={labelId}>
|
|
491
|
-
{title}
|
|
492
|
-
</div>
|
|
493
|
-
{onClose && (
|
|
494
|
-
<button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
|
|
495
|
-
×
|
|
496
|
-
</button>
|
|
497
|
-
)}
|
|
498
|
-
</div>
|
|
499
|
-
)}
|
|
500
|
-
{children != null && <div className="pl-dialog__body">{children}</div>}
|
|
501
|
-
{footer != null && <div className="pl-dialog__foot">{footer}</div>}
|
|
502
|
-
</div>
|
|
503
|
-
</div>
|
|
504
|
-
);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** Destructive-confirm convenience over Dialog. `destructive` reddens confirm. */
|
|
508
|
-
export function ConfirmDialog({
|
|
509
|
-
open,
|
|
510
|
-
title,
|
|
511
|
-
children,
|
|
512
|
-
confirmLabel = "Confirm",
|
|
513
|
-
cancelLabel = "Cancel",
|
|
514
|
-
destructive,
|
|
515
|
-
onConfirm,
|
|
516
|
-
onClose,
|
|
517
|
-
}: {
|
|
518
|
-
open: boolean;
|
|
519
|
-
title?: ReactNode;
|
|
520
|
-
children?: ReactNode;
|
|
521
|
-
confirmLabel?: ReactNode;
|
|
522
|
-
cancelLabel?: ReactNode;
|
|
523
|
-
destructive?: boolean;
|
|
524
|
-
onConfirm?: () => void;
|
|
525
|
-
onClose?: () => void;
|
|
526
|
-
}) {
|
|
527
|
-
return (
|
|
528
|
-
<Dialog
|
|
529
|
-
open={open}
|
|
530
|
-
onClose={onClose}
|
|
531
|
-
title={title}
|
|
532
|
-
width={420}
|
|
533
|
-
footer={
|
|
534
|
-
<>
|
|
535
|
-
<Button onClick={onClose}>{cancelLabel}</Button>
|
|
536
|
-
<Button variant={destructive ? "danger" : "primary"} onClick={() => onConfirm?.()}>
|
|
537
|
-
{confirmLabel}
|
|
538
|
-
</Button>
|
|
539
|
-
</>
|
|
540
|
-
}
|
|
541
|
-
>
|
|
542
|
-
{children}
|
|
543
|
-
</Dialog>
|
|
544
|
-
);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/** Slide-in side panel / sheet. Esc / backdrop close, focus trap. */
|
|
548
|
-
export function Drawer({
|
|
549
|
-
open,
|
|
550
|
-
onClose,
|
|
551
|
-
side = "right",
|
|
552
|
-
title,
|
|
553
|
-
children,
|
|
554
|
-
footer,
|
|
555
|
-
width,
|
|
556
|
-
className,
|
|
557
|
-
}: {
|
|
558
|
-
open: boolean;
|
|
559
|
-
onClose?: () => void;
|
|
560
|
-
side?: "left" | "right";
|
|
561
|
-
title?: ReactNode;
|
|
562
|
-
children?: ReactNode;
|
|
563
|
-
footer?: ReactNode;
|
|
564
|
-
width?: number | string;
|
|
565
|
-
className?: string;
|
|
566
|
-
}) {
|
|
567
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
568
|
-
const labelId = useId();
|
|
569
|
-
useOverlayDismiss(open, onClose);
|
|
570
|
-
useFocusTrap(ref, open);
|
|
571
|
-
if (!open) return null;
|
|
572
|
-
return (
|
|
573
|
-
<div
|
|
574
|
-
className="pl-overlay pl-overlay--drawer"
|
|
575
|
-
onMouseDown={(e) => {
|
|
576
|
-
if (e.target === e.currentTarget) onClose?.();
|
|
577
|
-
}}
|
|
578
|
-
>
|
|
579
|
-
<div
|
|
580
|
-
ref={ref}
|
|
581
|
-
className={cx("pl-drawer", `pl-drawer--${side}`, className)}
|
|
582
|
-
role="dialog"
|
|
583
|
-
aria-modal="true"
|
|
584
|
-
aria-labelledby={title != null ? labelId : undefined}
|
|
585
|
-
tabIndex={-1}
|
|
586
|
-
style={width != null ? { width, maxWidth: "100%" } : undefined}
|
|
587
|
-
>
|
|
588
|
-
{title != null && (
|
|
589
|
-
<div className="pl-drawer__head">
|
|
590
|
-
<div className="pl-drawer__title" id={labelId}>
|
|
591
|
-
{title}
|
|
592
|
-
</div>
|
|
593
|
-
{onClose && (
|
|
594
|
-
<button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
|
|
595
|
-
×
|
|
596
|
-
</button>
|
|
597
|
-
)}
|
|
598
|
-
</div>
|
|
599
|
-
)}
|
|
600
|
-
<div className="pl-drawer__body">{children}</div>
|
|
601
|
-
{footer != null && <div className="pl-drawer__foot">{footer}</div>}
|
|
602
|
-
</div>
|
|
603
|
-
</div>
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
type ToastOptions = { tone?: Status; title?: ReactNode; message: ReactNode; duration?: number };
|
|
608
|
-
type ToastItem = Required<Pick<ToastOptions, "tone" | "message" | "duration">> & {
|
|
609
|
-
id: string;
|
|
610
|
-
title?: ReactNode;
|
|
611
|
-
};
|
|
612
|
-
|
|
613
|
-
const ToastContext = createContext<((opts: ToastOptions) => string) | null>(null);
|
|
614
|
-
|
|
615
|
-
/** Wrap the app once. Exposes `useToast()` to push transient notifications. */
|
|
616
|
-
export function ToastProvider({ children, max = 4 }: { children: ReactNode; max?: number }) {
|
|
617
|
-
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
618
|
-
const seq = useRef(0);
|
|
619
|
-
const dismiss = useCallback((id: string) => setToasts((ts) => ts.filter((t) => t.id !== id)), []);
|
|
620
|
-
const toast = useCallback(
|
|
621
|
-
(opts: ToastOptions) => {
|
|
622
|
-
const id = `t${(seq.current += 1)}`;
|
|
623
|
-
const item: ToastItem = {
|
|
624
|
-
id,
|
|
625
|
-
tone: opts.tone ?? "neutral",
|
|
626
|
-
title: opts.title,
|
|
627
|
-
message: opts.message,
|
|
628
|
-
duration: opts.duration ?? 4000,
|
|
629
|
-
};
|
|
630
|
-
setToasts((ts) => [...ts.slice(-(max - 1)), item]);
|
|
631
|
-
return id;
|
|
632
|
-
},
|
|
633
|
-
[max],
|
|
634
|
-
);
|
|
635
|
-
return (
|
|
636
|
-
<ToastContext.Provider value={toast}>
|
|
637
|
-
{children}
|
|
638
|
-
<div className="pl-toast-stack" role="region" aria-label="Notifications">
|
|
639
|
-
{toasts.map((t) => (
|
|
640
|
-
<ToastView key={t.id} toast={t} onDismiss={dismiss} />
|
|
641
|
-
))}
|
|
642
|
-
</div>
|
|
643
|
-
</ToastContext.Provider>
|
|
644
|
-
);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function ToastView({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: string) => void }) {
|
|
648
|
-
useEffect(() => {
|
|
649
|
-
if (toast.duration <= 0) return;
|
|
650
|
-
const h = setTimeout(() => onDismiss(toast.id), toast.duration);
|
|
651
|
-
return () => clearTimeout(h);
|
|
652
|
-
}, [toast, onDismiss]);
|
|
653
|
-
return (
|
|
654
|
-
<div className={cx("pl-toast", toast.tone !== "neutral" && `pl-toast--${toast.tone}`)} role="status">
|
|
655
|
-
<div className="pl-toast__body">
|
|
656
|
-
{toast.title != null && <div className="pl-toast__title">{toast.title}</div>}
|
|
657
|
-
<div className="pl-toast__msg">{toast.message}</div>
|
|
658
|
-
</div>
|
|
659
|
-
<button type="button" className="pl-toast__close" aria-label="Dismiss" onClick={() => onDismiss(toast.id)}>
|
|
660
|
-
×
|
|
661
|
-
</button>
|
|
662
|
-
</div>
|
|
663
|
-
);
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/** Returns `toast(opts)` — call to push a notification. Throws outside provider. */
|
|
667
|
-
export function useToast() {
|
|
668
|
-
const ctx = useContext(ToastContext);
|
|
669
|
-
if (!ctx) throw new Error("useToast must be used within a <ToastProvider>");
|
|
670
|
-
return ctx;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/** CSS-only hover/focus tooltip. Wrap the trigger; `label` is the bubble. */
|
|
674
|
-
export function Tooltip({
|
|
675
|
-
label,
|
|
676
|
-
side = "top",
|
|
677
|
-
children,
|
|
678
|
-
}: {
|
|
679
|
-
label: ReactNode;
|
|
680
|
-
side?: "top" | "bottom" | "left" | "right";
|
|
681
|
-
children: ReactNode;
|
|
682
|
-
}) {
|
|
683
|
-
return (
|
|
684
|
-
<span className="pl-tip-wrap">
|
|
685
|
-
{children}
|
|
686
|
-
<span className={cx("pl-tip", `pl-tip--${side}`)} role="tooltip">
|
|
687
|
-
{label}
|
|
688
|
-
</span>
|
|
689
|
-
</span>
|
|
690
|
-
);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// ── Data + status primitives ─────────────────────────────────────────────────
|
|
694
|
-
|
|
695
|
-
/** Dense data table on the 4px grid. Compose with THead/TBody/Tr/Th/Td. */
|
|
696
|
-
export function Table({ className, ...rest }: TableHTMLAttributes<HTMLTableElement>) {
|
|
697
|
-
return <table className={cx("pl-table", className)} {...rest} />;
|
|
698
|
-
}
|
|
699
|
-
export function THead(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
700
|
-
return <thead {...props} />;
|
|
701
|
-
}
|
|
702
|
-
export function TBody(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
703
|
-
return <tbody {...props} />;
|
|
704
|
-
}
|
|
705
|
-
/** Table row. `selected` highlights; an `onClick` makes it hover-interactive. */
|
|
706
|
-
export function Tr({
|
|
707
|
-
selected,
|
|
708
|
-
className,
|
|
709
|
-
...rest
|
|
710
|
-
}: HTMLAttributes<HTMLTableRowElement> & { selected?: boolean }) {
|
|
711
|
-
return (
|
|
712
|
-
<tr
|
|
713
|
-
className={cx(selected && "pl-tr--selected", rest.onClick && "pl-tr--interactive", className)}
|
|
714
|
-
{...rest}
|
|
715
|
-
/>
|
|
716
|
-
);
|
|
717
|
-
}
|
|
718
|
-
export function Th({ className, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
|
|
719
|
-
return <th className={className} {...rest} />;
|
|
720
|
-
}
|
|
721
|
-
export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
|
|
722
|
-
return <td className={className} {...rest} />;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/** Live/health indicator. `pulse` breathes on the 2s status cadence. */
|
|
726
|
-
export function StatusDot({
|
|
727
|
-
status = "neutral",
|
|
728
|
-
pulse,
|
|
729
|
-
label,
|
|
730
|
-
}: {
|
|
731
|
-
status?: Status;
|
|
732
|
-
pulse?: boolean;
|
|
733
|
-
label?: ReactNode;
|
|
734
|
-
}) {
|
|
735
|
-
const dot = (
|
|
736
|
-
<span
|
|
737
|
-
className={cx("pl-dot", status !== "neutral" && `pl-dot--${status}`, pulse && "pl-dot--pulse")}
|
|
738
|
-
aria-hidden
|
|
739
|
-
/>
|
|
740
|
-
);
|
|
741
|
-
if (label == null) return dot;
|
|
742
|
-
return (
|
|
743
|
-
<span className="pl-dot-row">
|
|
744
|
-
{dot}
|
|
745
|
-
<span className="pl-dot-row__label">{label}</span>
|
|
746
|
-
</span>
|
|
747
|
-
);
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/** Indeterminate spinner (1s linear, brand-restrained). */
|
|
751
|
-
export function Spinner({ size = 16, className }: { size?: number; className?: string }) {
|
|
752
|
-
return (
|
|
753
|
-
<span
|
|
754
|
-
className={cx("pl-spinner", className)}
|
|
755
|
-
style={{ width: size, height: size }}
|
|
756
|
-
role="status"
|
|
757
|
-
aria-label="Loading"
|
|
758
|
-
/>
|
|
759
|
-
);
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/** Scroll container with brand-styled thin scrollbars. Carries `min-height:0`
|
|
763
|
-
* (so it scrolls inside flex/grid parents) + overscroll containment. Pass
|
|
764
|
-
* `ariaLabel` to make it a keyboard-focusable, labelled scroll region. */
|
|
765
|
-
export function ScrollArea({
|
|
766
|
-
ariaLabel,
|
|
767
|
-
className,
|
|
768
|
-
...rest
|
|
769
|
-
}: HTMLAttributes<HTMLDivElement> & { ariaLabel?: string }) {
|
|
770
|
-
const a11y = ariaLabel != null ? { role: "region", "aria-label": ariaLabel, tabIndex: 0 } : {};
|
|
771
|
-
return <div className={cx("pl-scroll", className)} {...a11y} {...rest} />;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// ── Form controls (compose with Field, or use standalone) ────────────────────
|
|
775
|
-
|
|
776
|
-
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
|
|
777
|
-
return <input className={cx("pl-input", className)} {...rest} />;
|
|
778
|
-
}
|
|
779
|
-
export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|
780
|
-
return <textarea className={cx("pl-input", "pl-textarea", className)} {...rest} />;
|
|
781
|
-
}
|
|
782
|
-
export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
|
|
783
|
-
return (
|
|
784
|
-
<select className={cx("pl-input", "pl-select", className)} {...rest}>
|
|
785
|
-
{children}
|
|
786
|
-
</select>
|
|
787
|
-
);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/** Toggle switch. Controlled via `checked` / `onCheckedChange`. */
|
|
791
|
-
export function Switch({
|
|
792
|
-
checked,
|
|
793
|
-
onCheckedChange,
|
|
794
|
-
disabled,
|
|
795
|
-
label,
|
|
796
|
-
className,
|
|
797
|
-
}: {
|
|
798
|
-
checked?: boolean;
|
|
799
|
-
onCheckedChange?: (checked: boolean) => void;
|
|
800
|
-
disabled?: boolean;
|
|
801
|
-
label?: ReactNode;
|
|
802
|
-
className?: string;
|
|
803
|
-
}) {
|
|
804
|
-
return (
|
|
805
|
-
<label className={cx("pl-switch", disabled && "pl-switch--disabled", className)}>
|
|
806
|
-
<input
|
|
807
|
-
type="checkbox"
|
|
808
|
-
className="pl-switch__input"
|
|
809
|
-
checked={checked}
|
|
810
|
-
disabled={disabled}
|
|
811
|
-
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
|
812
|
-
/>
|
|
813
|
-
<span className="pl-switch__track" aria-hidden>
|
|
814
|
-
<span className="pl-switch__thumb" />
|
|
815
|
-
</span>
|
|
816
|
-
{label != null && <span className="pl-switch__label">{label}</span>}
|
|
817
|
-
</label>
|
|
818
|
-
);
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/** Checkbox. Controlled via `checked` / `onCheckedChange`. */
|
|
822
|
-
export function Checkbox({
|
|
823
|
-
checked,
|
|
824
|
-
onCheckedChange,
|
|
825
|
-
disabled,
|
|
826
|
-
label,
|
|
827
|
-
className,
|
|
828
|
-
}: {
|
|
829
|
-
checked?: boolean;
|
|
830
|
-
onCheckedChange?: (checked: boolean) => void;
|
|
831
|
-
disabled?: boolean;
|
|
832
|
-
label?: ReactNode;
|
|
833
|
-
className?: string;
|
|
834
|
-
}) {
|
|
835
|
-
return (
|
|
836
|
-
<label className={cx("pl-checkbox", disabled && "pl-checkbox--disabled", className)}>
|
|
837
|
-
<input
|
|
838
|
-
type="checkbox"
|
|
839
|
-
className="pl-checkbox__input"
|
|
840
|
-
checked={checked}
|
|
841
|
-
disabled={disabled}
|
|
842
|
-
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
|
843
|
-
/>
|
|
844
|
-
<span className="pl-checkbox__box" aria-hidden />
|
|
845
|
-
{label != null && <span className="pl-checkbox__label">{label}</span>}
|
|
846
|
-
</label>
|
|
847
|
-
);
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// ── Menu / DropdownMenu (Radix-backed) ───────────────────────────────────────
|
|
851
|
-
// Radix owns keyboard nav, focus management, and collision-aware positioning —
|
|
852
|
-
// the reason this lives in the DS rather than being re-rolled per app. Styling
|
|
853
|
-
// is token-only over --pl-*. Supports a standard click trigger AND imperative
|
|
854
|
-
// open-at-coordinates for right-click / context menus.
|
|
855
|
-
|
|
856
|
-
export type MenuHandle = {
|
|
857
|
-
/** Open the menu. Pass viewport coords (e.g. from a contextmenu event) to
|
|
858
|
-
* open at a point; omit to open at the default anchor. */
|
|
859
|
-
open: (coords?: { x: number; y: number }) => void;
|
|
860
|
-
close: () => void;
|
|
861
|
-
};
|
|
862
|
-
|
|
863
|
-
export type MenuProps = {
|
|
864
|
-
/** Standard trigger (click to open). Omit and drive via the ref's
|
|
865
|
-
* open({x,y}) for right-click / imperative menus. */
|
|
866
|
-
trigger?: ReactNode;
|
|
867
|
-
children: ReactNode;
|
|
868
|
-
align?: "start" | "center" | "end";
|
|
869
|
-
/** Fires on open/close — e.g. to clear the app's context state on dismiss. */
|
|
870
|
-
onOpenChange?: (open: boolean) => void;
|
|
871
|
-
};
|
|
872
|
-
|
|
873
|
-
export const Menu = forwardRef<MenuHandle, MenuProps>(function Menu(
|
|
874
|
-
{ trigger, children, align = "start", onOpenChange },
|
|
875
|
-
ref,
|
|
876
|
-
) {
|
|
877
|
-
const [open, setOpen] = useState(false);
|
|
878
|
-
const [coords, setCoords] = useState<{ x: number; y: number } | null>(null);
|
|
879
|
-
const setOpenState = useCallback(
|
|
880
|
-
(o: boolean) => {
|
|
881
|
-
setOpen(o);
|
|
882
|
-
onOpenChange?.(o);
|
|
883
|
-
},
|
|
884
|
-
[onOpenChange],
|
|
885
|
-
);
|
|
886
|
-
useImperativeHandle(
|
|
887
|
-
ref,
|
|
888
|
-
() => ({
|
|
889
|
-
open: (c) => {
|
|
890
|
-
setCoords(c ?? null);
|
|
891
|
-
setOpenState(true);
|
|
892
|
-
},
|
|
893
|
-
close: () => setOpenState(false),
|
|
894
|
-
}),
|
|
895
|
-
[setOpenState],
|
|
896
|
-
);
|
|
897
|
-
return (
|
|
898
|
-
<RDropdown.Root open={open} onOpenChange={setOpenState} modal={false}>
|
|
899
|
-
{trigger != null ? (
|
|
900
|
-
<RDropdown.Trigger asChild>{trigger}</RDropdown.Trigger>
|
|
901
|
-
) : (
|
|
902
|
-
<RDropdown.Trigger asChild>
|
|
903
|
-
<span
|
|
904
|
-
aria-hidden
|
|
905
|
-
className="pl-menu__anchor"
|
|
906
|
-
style={coords ? { position: "fixed", left: coords.x, top: coords.y } : undefined}
|
|
907
|
-
/>
|
|
908
|
-
</RDropdown.Trigger>
|
|
909
|
-
)}
|
|
910
|
-
<RDropdown.Portal>
|
|
911
|
-
<RDropdown.Content className="pl-menu" align={align} sideOffset={4} collisionPadding={8} loop>
|
|
912
|
-
{children}
|
|
913
|
-
</RDropdown.Content>
|
|
914
|
-
</RDropdown.Portal>
|
|
915
|
-
</RDropdown.Root>
|
|
916
|
-
);
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
export function MenuItem({
|
|
920
|
-
icon,
|
|
921
|
-
disabled,
|
|
922
|
-
destructive,
|
|
923
|
-
onSelect,
|
|
924
|
-
children,
|
|
925
|
-
}: {
|
|
926
|
-
icon?: ReactNode;
|
|
927
|
-
disabled?: boolean;
|
|
928
|
-
/** Error-toned (delete, remove, etc.). */
|
|
929
|
-
destructive?: boolean;
|
|
930
|
-
onSelect?: () => void;
|
|
931
|
-
children: ReactNode;
|
|
932
|
-
}) {
|
|
933
|
-
return (
|
|
934
|
-
<RDropdown.Item
|
|
935
|
-
className={cx("pl-menu__item", destructive && "pl-menu__item--destructive")}
|
|
936
|
-
disabled={disabled}
|
|
937
|
-
onSelect={onSelect}
|
|
938
|
-
>
|
|
939
|
-
{icon != null && (
|
|
940
|
-
<span className="pl-menu__icon" aria-hidden>
|
|
941
|
-
{icon}
|
|
942
|
-
</span>
|
|
943
|
-
)}
|
|
944
|
-
<span className="pl-menu__label">{children}</span>
|
|
945
|
-
</RDropdown.Item>
|
|
946
|
-
);
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
export function MenuSeparator() {
|
|
950
|
-
return <RDropdown.Separator className="pl-menu__sep" />;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
export function MenuLabel({ children }: { children: ReactNode }) {
|
|
954
|
-
return <RDropdown.Label className="pl-menu__group-label">{children}</RDropdown.Label>;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
/** Nested submenu — put MenuItem/MenuSeparator children inside. */
|
|
958
|
-
export function MenuSub({
|
|
959
|
-
label,
|
|
960
|
-
icon,
|
|
961
|
-
children,
|
|
962
|
-
}: {
|
|
963
|
-
label: ReactNode;
|
|
964
|
-
icon?: ReactNode;
|
|
965
|
-
children: ReactNode;
|
|
966
|
-
}) {
|
|
967
|
-
return (
|
|
968
|
-
<RDropdown.Sub>
|
|
969
|
-
<RDropdown.SubTrigger className="pl-menu__item pl-menu__subtrigger">
|
|
970
|
-
{icon != null && (
|
|
971
|
-
<span className="pl-menu__icon" aria-hidden>
|
|
972
|
-
{icon}
|
|
973
|
-
</span>
|
|
974
|
-
)}
|
|
975
|
-
<span className="pl-menu__label">{label}</span>
|
|
976
|
-
<span className="pl-menu__subarrow" aria-hidden>
|
|
977
|
-
›
|
|
978
|
-
</span>
|
|
979
|
-
</RDropdown.SubTrigger>
|
|
980
|
-
<RDropdown.Portal>
|
|
981
|
-
<RDropdown.SubContent className="pl-menu" sideOffset={2} alignOffset={-4} collisionPadding={8}>
|
|
982
|
-
{children}
|
|
983
|
-
</RDropdown.SubContent>
|
|
984
|
-
</RDropdown.Portal>
|
|
985
|
-
</RDropdown.Sub>
|
|
986
|
-
);
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// ── Skeleton (loading placeholder) ───────────────────────────────────────────
|
|
990
|
-
|
|
991
|
-
/** Shimmering content-shaped loading placeholder. `lines>1` stacks text bars
|
|
992
|
-
* (last one short). Token-driven; static fill under reduced-motion. */
|
|
993
|
-
export function Skeleton({
|
|
994
|
-
width,
|
|
995
|
-
height = 14,
|
|
996
|
-
lines,
|
|
997
|
-
className,
|
|
998
|
-
style,
|
|
999
|
-
...rest
|
|
1000
|
-
}: HTMLAttributes<HTMLDivElement> & {
|
|
1001
|
-
width?: number | string;
|
|
1002
|
-
height?: number | string;
|
|
1003
|
-
/** Stack N text-line bars instead of a single bar. */
|
|
1004
|
-
lines?: number;
|
|
1005
|
-
}) {
|
|
1006
|
-
if (lines != null && lines > 1) {
|
|
1007
|
-
return (
|
|
1008
|
-
<div className={cx("pl-skel-lines", className)} style={style} {...rest}>
|
|
1009
|
-
{Array.from({ length: lines }, (_, i) => (
|
|
1010
|
-
<div
|
|
1011
|
-
key={i}
|
|
1012
|
-
className="pl-skel"
|
|
1013
|
-
style={{ height, width: i === lines - 1 ? "65%" : (width ?? "100%") }}
|
|
1014
|
-
/>
|
|
1015
|
-
))}
|
|
1016
|
-
</div>
|
|
1017
|
-
);
|
|
1018
|
-
}
|
|
1019
|
-
return (
|
|
1020
|
-
<div className={cx("pl-skel", className)} style={{ width: width ?? "100%", height, ...style }} {...rest} />
|
|
1021
|
-
);
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
/** Optional wrapper to group related skeletons (shared layout gap). */
|
|
1025
|
-
export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
1026
|
-
return <div className={cx("pl-skel-group", className)} {...rest} />;
|
|
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
|
-
}
|