@protolabsai/ui 0.4.0 → 0.6.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 +2 -1
- package/src/AppShell.stories.tsx +61 -0
- package/src/Button.stories.tsx +46 -1
- package/src/Menu.stories.tsx +76 -0
- package/src/Skeleton.stories.tsx +37 -0
- package/src/index.tsx +267 -16
- package/src/styles.css +214 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protolabsai/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"src"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
+
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
|
22
23
|
"@types/react": "^19.0.0",
|
|
23
24
|
"@types/react-dom": "^19.0.0",
|
|
24
25
|
"@protolabsai/design": "0.4.0"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Badge, Button, PanelHeader, Tabs } from "./index";
|
|
4
|
+
|
|
5
|
+
const meta: Meta = { title: "Components/AppShell" };
|
|
6
|
+
export default meta;
|
|
7
|
+
type Story = StoryObj;
|
|
8
|
+
|
|
9
|
+
const Glyph = () => (
|
|
10
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
11
|
+
<rect x="2.5" y="2.5" width="11" height="11" rx="2" />
|
|
12
|
+
</svg>
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export const Panel: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<div style={{ display: "grid", gap: 20, maxWidth: 560 }}>
|
|
18
|
+
<div style={{ border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
|
|
19
|
+
<PanelHeader
|
|
20
|
+
kicker="agent · streaming-router"
|
|
21
|
+
title="Activity"
|
|
22
|
+
actions={
|
|
23
|
+
<>
|
|
24
|
+
<Button>Filter</Button>
|
|
25
|
+
<Button variant="primary">New run</Button>
|
|
26
|
+
</>
|
|
27
|
+
}
|
|
28
|
+
/>
|
|
29
|
+
<div style={{ padding: 16, color: "var(--pl-color-fg-muted)", fontSize: 13 }}>panel body…</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div style={{ border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
|
|
33
|
+
<PanelHeader compact title="Inbox" actions={<Badge status="warning">3 unread</Badge>} />
|
|
34
|
+
<div style={{ padding: 12, color: "var(--pl-color-fg-muted)", fontSize: 13 }}>compact, nested panel…</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const TabsWithSlots: Story = {
|
|
41
|
+
render: () => {
|
|
42
|
+
function Demo() {
|
|
43
|
+
const [active, setActive] = useState("activity");
|
|
44
|
+
return (
|
|
45
|
+
<Tabs
|
|
46
|
+
active={active}
|
|
47
|
+
onSelect={setActive}
|
|
48
|
+
items={[
|
|
49
|
+
{ id: "chat", label: "Chat", icon: <Glyph /> },
|
|
50
|
+
{ id: "activity", label: "Activity", icon: <Glyph />, badge: 12 },
|
|
51
|
+
{ id: "knowledge", label: "Knowledge", icon: <Glyph /> },
|
|
52
|
+
{ id: "plugins", label: "Plugins", icon: <Glyph />, badge: "new" },
|
|
53
|
+
{ id: "settings", label: "Settings", icon: <Glyph /> },
|
|
54
|
+
{ id: "billing", label: "Billing", icon: <Glyph />, locked: true, disabled: true },
|
|
55
|
+
]}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return <Demo />;
|
|
60
|
+
},
|
|
61
|
+
};
|
package/src/Button.stories.tsx
CHANGED
|
@@ -5,7 +5,7 @@ const meta: Meta<typeof Button> = {
|
|
|
5
5
|
title: "Components/Button",
|
|
6
6
|
component: Button,
|
|
7
7
|
args: { children: "Breakdowns" },
|
|
8
|
-
argTypes: { variant: { control: "inline-radio", options: ["default", "primary"] } },
|
|
8
|
+
argTypes: { variant: { control: "inline-radio", options: ["default", "primary", "ghost", "danger"] } },
|
|
9
9
|
};
|
|
10
10
|
export default meta;
|
|
11
11
|
type Story = StoryObj<typeof Button>;
|
|
@@ -13,3 +13,48 @@ type Story = StoryObj<typeof Button>;
|
|
|
13
13
|
export const Default: Story = {};
|
|
14
14
|
export const Primary: Story = { args: { variant: "primary", children: "Get started" } };
|
|
15
15
|
export const Disabled: Story = { args: { disabled: true, children: "Disabled" } };
|
|
16
|
+
|
|
17
|
+
const Sq = () => (
|
|
18
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
19
|
+
<path d="M8 3v10M3 8h10" strokeLinecap="round" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const Variants: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
26
|
+
<Button>default</Button>
|
|
27
|
+
<Button variant="primary">primary</Button>
|
|
28
|
+
<Button variant="ghost">ghost</Button>
|
|
29
|
+
<Button variant="danger">danger</Button>
|
|
30
|
+
</div>
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const Sizes: Story = {
|
|
35
|
+
render: () => (
|
|
36
|
+
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
37
|
+
<Button size="sm">small</Button>
|
|
38
|
+
<Button>medium</Button>
|
|
39
|
+
<Button size="sm" variant="primary">
|
|
40
|
+
small primary
|
|
41
|
+
</Button>
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const IconOnly: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
49
|
+
<Button icon aria-label="Add">
|
|
50
|
+
<Sq />
|
|
51
|
+
</Button>
|
|
52
|
+
<Button icon variant="ghost" aria-label="Add">
|
|
53
|
+
<Sq />
|
|
54
|
+
</Button>
|
|
55
|
+
<Button icon size="sm" aria-label="Add">
|
|
56
|
+
<Sq />
|
|
57
|
+
</Button>
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
import { Button, Menu, MenuHandle, MenuItem, MenuLabel, MenuSeparator, MenuSub } from "./index";
|
|
4
|
+
|
|
5
|
+
const meta: Meta = { title: "Components/Menu" };
|
|
6
|
+
export default meta;
|
|
7
|
+
type Story = StoryObj;
|
|
8
|
+
|
|
9
|
+
// Tiny inline icon so stories stay dependency-free.
|
|
10
|
+
const Dot = () => (
|
|
11
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
12
|
+
<circle cx="8" cy="8" r="3" />
|
|
13
|
+
</svg>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const Items = () => (
|
|
17
|
+
<>
|
|
18
|
+
<MenuLabel>Rail surface</MenuLabel>
|
|
19
|
+
<MenuItem icon={<Dot />}>Move up</MenuItem>
|
|
20
|
+
<MenuItem icon={<Dot />}>Move down</MenuItem>
|
|
21
|
+
<MenuSub label="Move to other rail" icon={<Dot />}>
|
|
22
|
+
<MenuItem>Left rail</MenuItem>
|
|
23
|
+
<MenuItem>Right rail</MenuItem>
|
|
24
|
+
</MenuSub>
|
|
25
|
+
<MenuSeparator />
|
|
26
|
+
<MenuItem disabled icon={<Dot />}>
|
|
27
|
+
Pin (coming soon)
|
|
28
|
+
</MenuItem>
|
|
29
|
+
<MenuItem destructive icon={<Dot />}>
|
|
30
|
+
Remove surface
|
|
31
|
+
</MenuItem>
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const Trigger: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<Menu trigger={<Button>Surface actions ▾</Button>}>
|
|
38
|
+
<Items />
|
|
39
|
+
</Menu>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const RightClickAtCoords: Story = {
|
|
44
|
+
render: () => {
|
|
45
|
+
function Demo() {
|
|
46
|
+
const menu = useRef<MenuHandle>(null);
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<div
|
|
50
|
+
onContextMenu={(e) => {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
menu.current?.open({ x: e.clientX, y: e.clientY });
|
|
53
|
+
}}
|
|
54
|
+
style={{
|
|
55
|
+
height: 180,
|
|
56
|
+
display: "grid",
|
|
57
|
+
placeItems: "center",
|
|
58
|
+
border: "1px dashed var(--pl-color-border-strong)",
|
|
59
|
+
borderRadius: 4,
|
|
60
|
+
color: "var(--pl-color-fg-muted)",
|
|
61
|
+
fontFamily: "var(--pl-font-mono)",
|
|
62
|
+
fontSize: 13,
|
|
63
|
+
userSelect: "none",
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
right-click anywhere in this box
|
|
67
|
+
</div>
|
|
68
|
+
<Menu ref={menu}>
|
|
69
|
+
<Items />
|
|
70
|
+
</Menu>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return <Demo />;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Skeleton, SkeletonGroup } from "./index";
|
|
3
|
+
|
|
4
|
+
const meta: Meta = { title: "Components/Skeleton" };
|
|
5
|
+
export default meta;
|
|
6
|
+
type Story = StoryObj;
|
|
7
|
+
|
|
8
|
+
export const Bars: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<div style={{ display: "grid", gap: 12, maxWidth: 360 }}>
|
|
11
|
+
<Skeleton />
|
|
12
|
+
<Skeleton width={200} />
|
|
13
|
+
<Skeleton width={120} height={28} />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const TextLines: Story = {
|
|
19
|
+
render: () => (
|
|
20
|
+
<div style={{ maxWidth: 360 }}>
|
|
21
|
+
<Skeleton lines={4} />
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const PanelSkeleton: Story = {
|
|
27
|
+
render: () => (
|
|
28
|
+
<SkeletonGroup style={{ maxWidth: 420, padding: 16, border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
|
|
29
|
+
<Skeleton width={140} height={16} />
|
|
30
|
+
<Skeleton lines={3} />
|
|
31
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
32
|
+
<Skeleton width={80} height={28} />
|
|
33
|
+
<Skeleton width={80} height={28} />
|
|
34
|
+
</div>
|
|
35
|
+
</SkeletonGroup>
|
|
36
|
+
),
|
|
37
|
+
};
|
package/src/index.tsx
CHANGED
|
@@ -15,17 +15,43 @@ import type {
|
|
|
15
15
|
TextareaHTMLAttributes,
|
|
16
16
|
ThHTMLAttributes,
|
|
17
17
|
} from "react";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
createContext,
|
|
20
|
+
forwardRef,
|
|
21
|
+
useCallback,
|
|
22
|
+
useContext,
|
|
23
|
+
useEffect,
|
|
24
|
+
useId,
|
|
25
|
+
useImperativeHandle,
|
|
26
|
+
useRef,
|
|
27
|
+
useState,
|
|
28
|
+
} from "react";
|
|
29
|
+
import * as RDropdown from "@radix-ui/react-dropdown-menu";
|
|
19
30
|
import "./styles.css";
|
|
20
31
|
|
|
21
32
|
const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
|
|
22
33
|
|
|
23
34
|
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
24
|
-
/** "primary"
|
|
25
|
-
|
|
35
|
+
/** "primary"/"danger" read as a stronger border, not a fill (brand restraint);
|
|
36
|
+
* "ghost" is transparent until hover. */
|
|
37
|
+
variant?: "default" | "primary" | "ghost" | "danger";
|
|
38
|
+
size?: "sm" | "md";
|
|
39
|
+
/** Icon-only: square, centered glyph. Pass `aria-label` for a11y. */
|
|
40
|
+
icon?: boolean;
|
|
26
41
|
};
|
|
27
|
-
export function Button({ variant = "default", className, ...rest }: ButtonProps) {
|
|
28
|
-
return
|
|
42
|
+
export function Button({ variant = "default", size = "md", icon, className, ...rest }: ButtonProps) {
|
|
43
|
+
return (
|
|
44
|
+
<button
|
|
45
|
+
className={cx(
|
|
46
|
+
"pl-btn",
|
|
47
|
+
variant !== "default" && `pl-btn--${variant}`,
|
|
48
|
+
size === "sm" && "pl-btn--sm",
|
|
49
|
+
icon && "pl-btn--icon",
|
|
50
|
+
className,
|
|
51
|
+
)}
|
|
52
|
+
{...rest}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
29
55
|
}
|
|
30
56
|
|
|
31
57
|
export type Status = "neutral" | "success" | "warning" | "error" | "info";
|
|
@@ -240,9 +266,19 @@ export function TextLink({
|
|
|
240
266
|
|
|
241
267
|
// ── App primitives (cockpit + future studio apps) ────────────────────────────
|
|
242
268
|
|
|
243
|
-
export type TabItem = {
|
|
269
|
+
export type TabItem = {
|
|
270
|
+
id: string;
|
|
271
|
+
label: ReactNode;
|
|
272
|
+
/** Leading icon (e.g. a Lucide glyph). */
|
|
273
|
+
icon?: ReactNode;
|
|
274
|
+
/** Trailing badge / count (e.g. unread inbox count). */
|
|
275
|
+
badge?: ReactNode;
|
|
276
|
+
disabled?: boolean;
|
|
277
|
+
locked?: boolean;
|
|
278
|
+
};
|
|
244
279
|
|
|
245
|
-
/** A horizontal tab strip with
|
|
280
|
+
/** A horizontal tab strip with optional icon/badge slots + disabled/locked
|
|
281
|
+
* support (gated workflows). */
|
|
246
282
|
export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: string; onSelect: (id: string) => void }) {
|
|
247
283
|
return (
|
|
248
284
|
<div className="pl-tabs" role="tablist">
|
|
@@ -256,7 +292,13 @@ export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: st
|
|
|
256
292
|
disabled={t.disabled}
|
|
257
293
|
onClick={() => onSelect(t.id)}
|
|
258
294
|
>
|
|
259
|
-
{t.
|
|
295
|
+
{t.icon != null && (
|
|
296
|
+
<span className="pl-tab__icon" aria-hidden>
|
|
297
|
+
{t.icon}
|
|
298
|
+
</span>
|
|
299
|
+
)}
|
|
300
|
+
<span className="pl-tab__label">{t.label}</span>
|
|
301
|
+
{t.badge != null && <span className="pl-tab__badge">{t.badge}</span>}
|
|
260
302
|
{t.locked ? (
|
|
261
303
|
<span className="pl-tab__lock" aria-hidden>
|
|
262
304
|
🔒
|
|
@@ -322,6 +364,33 @@ export function Field({
|
|
|
322
364
|
);
|
|
323
365
|
}
|
|
324
366
|
|
|
367
|
+
/** Dense operator-console panel header — title + optional kicker eyebrow +
|
|
368
|
+
* right-aligned actions slot. `compact` tightens it for nested/secondary
|
|
369
|
+
* panels. The most-used console composite. */
|
|
370
|
+
export function PanelHeader({
|
|
371
|
+
title,
|
|
372
|
+
kicker,
|
|
373
|
+
actions,
|
|
374
|
+
compact,
|
|
375
|
+
className,
|
|
376
|
+
}: {
|
|
377
|
+
title: ReactNode;
|
|
378
|
+
kicker?: ReactNode;
|
|
379
|
+
actions?: ReactNode;
|
|
380
|
+
compact?: boolean;
|
|
381
|
+
className?: string;
|
|
382
|
+
}) {
|
|
383
|
+
return (
|
|
384
|
+
<div className={cx("pl-panel-header", compact && "pl-panel-header--compact", className)}>
|
|
385
|
+
<div className="pl-panel-header__titles">
|
|
386
|
+
{kicker != null && <div className="pl-panel-header__kicker">{kicker}</div>}
|
|
387
|
+
<h2 className="pl-panel-header__title">{title}</h2>
|
|
388
|
+
</div>
|
|
389
|
+
{actions != null && <div className="pl-panel-header__actions">{actions}</div>}
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
325
394
|
// ── Overlays & feedback ──────────────────────────────────────────────────────
|
|
326
395
|
|
|
327
396
|
/** Esc-to-close + body scroll-lock while `open`. Shared by Dialog + Drawer. */
|
|
@@ -461,11 +530,7 @@ export function ConfirmDialog({
|
|
|
461
530
|
footer={
|
|
462
531
|
<>
|
|
463
532
|
<Button onClick={onClose}>{cancelLabel}</Button>
|
|
464
|
-
<Button
|
|
465
|
-
variant="primary"
|
|
466
|
-
className={cx(destructive && "pl-btn--danger")}
|
|
467
|
-
onClick={() => onConfirm?.()}
|
|
468
|
-
>
|
|
533
|
+
<Button variant={destructive ? "danger" : "primary"} onClick={() => onConfirm?.()}>
|
|
469
534
|
{confirmLabel}
|
|
470
535
|
</Button>
|
|
471
536
|
</>
|
|
@@ -691,9 +756,16 @@ export function Spinner({ size = 16, className }: { size?: number; className?: s
|
|
|
691
756
|
);
|
|
692
757
|
}
|
|
693
758
|
|
|
694
|
-
/** Scroll container with brand-styled thin scrollbars.
|
|
695
|
-
|
|
696
|
-
|
|
759
|
+
/** Scroll container with brand-styled thin scrollbars. Carries `min-height:0`
|
|
760
|
+
* (so it scrolls inside flex/grid parents) + overscroll containment. Pass
|
|
761
|
+
* `ariaLabel` to make it a keyboard-focusable, labelled scroll region. */
|
|
762
|
+
export function ScrollArea({
|
|
763
|
+
ariaLabel,
|
|
764
|
+
className,
|
|
765
|
+
...rest
|
|
766
|
+
}: HTMLAttributes<HTMLDivElement> & { ariaLabel?: string }) {
|
|
767
|
+
const a11y = ariaLabel != null ? { role: "region", "aria-label": ariaLabel, tabIndex: 0 } : {};
|
|
768
|
+
return <div className={cx("pl-scroll", className)} {...a11y} {...rest} />;
|
|
697
769
|
}
|
|
698
770
|
|
|
699
771
|
// ── Form controls (compose with Field, or use standalone) ────────────────────
|
|
@@ -771,3 +843,182 @@ export function Checkbox({
|
|
|
771
843
|
</label>
|
|
772
844
|
);
|
|
773
845
|
}
|
|
846
|
+
|
|
847
|
+
// ── Menu / DropdownMenu (Radix-backed) ───────────────────────────────────────
|
|
848
|
+
// Radix owns keyboard nav, focus management, and collision-aware positioning —
|
|
849
|
+
// the reason this lives in the DS rather than being re-rolled per app. Styling
|
|
850
|
+
// is token-only over --pl-*. Supports a standard click trigger AND imperative
|
|
851
|
+
// open-at-coordinates for right-click / context menus.
|
|
852
|
+
|
|
853
|
+
export type MenuHandle = {
|
|
854
|
+
/** Open the menu. Pass viewport coords (e.g. from a contextmenu event) to
|
|
855
|
+
* open at a point; omit to open at the default anchor. */
|
|
856
|
+
open: (coords?: { x: number; y: number }) => void;
|
|
857
|
+
close: () => void;
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
export type MenuProps = {
|
|
861
|
+
/** Standard trigger (click to open). Omit and drive via the ref's
|
|
862
|
+
* open({x,y}) for right-click / imperative menus. */
|
|
863
|
+
trigger?: ReactNode;
|
|
864
|
+
children: ReactNode;
|
|
865
|
+
align?: "start" | "center" | "end";
|
|
866
|
+
/** Fires on open/close — e.g. to clear the app's context state on dismiss. */
|
|
867
|
+
onOpenChange?: (open: boolean) => void;
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
export const Menu = forwardRef<MenuHandle, MenuProps>(function Menu(
|
|
871
|
+
{ trigger, children, align = "start", onOpenChange },
|
|
872
|
+
ref,
|
|
873
|
+
) {
|
|
874
|
+
const [open, setOpen] = useState(false);
|
|
875
|
+
const [coords, setCoords] = useState<{ x: number; y: number } | null>(null);
|
|
876
|
+
const setOpenState = useCallback(
|
|
877
|
+
(o: boolean) => {
|
|
878
|
+
setOpen(o);
|
|
879
|
+
onOpenChange?.(o);
|
|
880
|
+
},
|
|
881
|
+
[onOpenChange],
|
|
882
|
+
);
|
|
883
|
+
useImperativeHandle(
|
|
884
|
+
ref,
|
|
885
|
+
() => ({
|
|
886
|
+
open: (c) => {
|
|
887
|
+
setCoords(c ?? null);
|
|
888
|
+
setOpenState(true);
|
|
889
|
+
},
|
|
890
|
+
close: () => setOpenState(false),
|
|
891
|
+
}),
|
|
892
|
+
[setOpenState],
|
|
893
|
+
);
|
|
894
|
+
return (
|
|
895
|
+
<RDropdown.Root open={open} onOpenChange={setOpenState} modal={false}>
|
|
896
|
+
{trigger != null ? (
|
|
897
|
+
<RDropdown.Trigger asChild>{trigger}</RDropdown.Trigger>
|
|
898
|
+
) : (
|
|
899
|
+
<RDropdown.Trigger asChild>
|
|
900
|
+
<span
|
|
901
|
+
aria-hidden
|
|
902
|
+
className="pl-menu__anchor"
|
|
903
|
+
style={coords ? { position: "fixed", left: coords.x, top: coords.y } : undefined}
|
|
904
|
+
/>
|
|
905
|
+
</RDropdown.Trigger>
|
|
906
|
+
)}
|
|
907
|
+
<RDropdown.Portal>
|
|
908
|
+
<RDropdown.Content className="pl-menu" align={align} sideOffset={4} collisionPadding={8} loop>
|
|
909
|
+
{children}
|
|
910
|
+
</RDropdown.Content>
|
|
911
|
+
</RDropdown.Portal>
|
|
912
|
+
</RDropdown.Root>
|
|
913
|
+
);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
export function MenuItem({
|
|
917
|
+
icon,
|
|
918
|
+
disabled,
|
|
919
|
+
destructive,
|
|
920
|
+
onSelect,
|
|
921
|
+
children,
|
|
922
|
+
}: {
|
|
923
|
+
icon?: ReactNode;
|
|
924
|
+
disabled?: boolean;
|
|
925
|
+
/** Error-toned (delete, remove, etc.). */
|
|
926
|
+
destructive?: boolean;
|
|
927
|
+
onSelect?: () => void;
|
|
928
|
+
children: ReactNode;
|
|
929
|
+
}) {
|
|
930
|
+
return (
|
|
931
|
+
<RDropdown.Item
|
|
932
|
+
className={cx("pl-menu__item", destructive && "pl-menu__item--destructive")}
|
|
933
|
+
disabled={disabled}
|
|
934
|
+
onSelect={onSelect}
|
|
935
|
+
>
|
|
936
|
+
{icon != null && (
|
|
937
|
+
<span className="pl-menu__icon" aria-hidden>
|
|
938
|
+
{icon}
|
|
939
|
+
</span>
|
|
940
|
+
)}
|
|
941
|
+
<span className="pl-menu__label">{children}</span>
|
|
942
|
+
</RDropdown.Item>
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
export function MenuSeparator() {
|
|
947
|
+
return <RDropdown.Separator className="pl-menu__sep" />;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
export function MenuLabel({ children }: { children: ReactNode }) {
|
|
951
|
+
return <RDropdown.Label className="pl-menu__group-label">{children}</RDropdown.Label>;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Nested submenu — put MenuItem/MenuSeparator children inside. */
|
|
955
|
+
export function MenuSub({
|
|
956
|
+
label,
|
|
957
|
+
icon,
|
|
958
|
+
children,
|
|
959
|
+
}: {
|
|
960
|
+
label: ReactNode;
|
|
961
|
+
icon?: ReactNode;
|
|
962
|
+
children: ReactNode;
|
|
963
|
+
}) {
|
|
964
|
+
return (
|
|
965
|
+
<RDropdown.Sub>
|
|
966
|
+
<RDropdown.SubTrigger className="pl-menu__item pl-menu__subtrigger">
|
|
967
|
+
{icon != null && (
|
|
968
|
+
<span className="pl-menu__icon" aria-hidden>
|
|
969
|
+
{icon}
|
|
970
|
+
</span>
|
|
971
|
+
)}
|
|
972
|
+
<span className="pl-menu__label">{label}</span>
|
|
973
|
+
<span className="pl-menu__subarrow" aria-hidden>
|
|
974
|
+
›
|
|
975
|
+
</span>
|
|
976
|
+
</RDropdown.SubTrigger>
|
|
977
|
+
<RDropdown.Portal>
|
|
978
|
+
<RDropdown.SubContent className="pl-menu" sideOffset={2} alignOffset={-4} collisionPadding={8}>
|
|
979
|
+
{children}
|
|
980
|
+
</RDropdown.SubContent>
|
|
981
|
+
</RDropdown.Portal>
|
|
982
|
+
</RDropdown.Sub>
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ── Skeleton (loading placeholder) ───────────────────────────────────────────
|
|
987
|
+
|
|
988
|
+
/** Shimmering content-shaped loading placeholder. `lines>1` stacks text bars
|
|
989
|
+
* (last one short). Token-driven; static fill under reduced-motion. */
|
|
990
|
+
export function Skeleton({
|
|
991
|
+
width,
|
|
992
|
+
height = 14,
|
|
993
|
+
lines,
|
|
994
|
+
className,
|
|
995
|
+
style,
|
|
996
|
+
...rest
|
|
997
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
998
|
+
width?: number | string;
|
|
999
|
+
height?: number | string;
|
|
1000
|
+
/** Stack N text-line bars instead of a single bar. */
|
|
1001
|
+
lines?: number;
|
|
1002
|
+
}) {
|
|
1003
|
+
if (lines != null && lines > 1) {
|
|
1004
|
+
return (
|
|
1005
|
+
<div className={cx("pl-skel-lines", className)} style={style} {...rest}>
|
|
1006
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
1007
|
+
<div
|
|
1008
|
+
key={i}
|
|
1009
|
+
className="pl-skel"
|
|
1010
|
+
style={{ height, width: i === lines - 1 ? "65%" : (width ?? "100%") }}
|
|
1011
|
+
/>
|
|
1012
|
+
))}
|
|
1013
|
+
</div>
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
return (
|
|
1017
|
+
<div className={cx("pl-skel", className)} style={{ width: width ?? "100%", height, ...style }} {...rest} />
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** Optional wrapper to group related skeletons (shared layout gap). */
|
|
1022
|
+
export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
1023
|
+
return <div className={cx("pl-skel-group", className)} {...rest} />;
|
|
1024
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -472,6 +472,9 @@ a.pl-row:hover {
|
|
|
472
472
|
border-bottom: var(--pl-border-width) solid var(--pl-color-border);
|
|
473
473
|
}
|
|
474
474
|
.pl-tab {
|
|
475
|
+
display: inline-flex;
|
|
476
|
+
align-items: center;
|
|
477
|
+
gap: 6px;
|
|
475
478
|
background: none;
|
|
476
479
|
border: none;
|
|
477
480
|
border-bottom: 2px solid transparent;
|
|
@@ -482,6 +485,32 @@ a.pl-row:hover {
|
|
|
482
485
|
cursor: pointer;
|
|
483
486
|
border-radius: var(--pl-radius) var(--pl-radius) 0 0;
|
|
484
487
|
}
|
|
488
|
+
.pl-tab__icon {
|
|
489
|
+
display: inline-flex;
|
|
490
|
+
align-items: center;
|
|
491
|
+
}
|
|
492
|
+
.pl-tab__icon svg {
|
|
493
|
+
width: 15px;
|
|
494
|
+
height: 15px;
|
|
495
|
+
}
|
|
496
|
+
.pl-tab__badge {
|
|
497
|
+
display: inline-flex;
|
|
498
|
+
align-items: center;
|
|
499
|
+
justify-content: center;
|
|
500
|
+
min-width: 16px;
|
|
501
|
+
height: 16px;
|
|
502
|
+
padding: 0 5px;
|
|
503
|
+
font-family: var(--pl-font-mono);
|
|
504
|
+
font-size: 10px;
|
|
505
|
+
line-height: 1;
|
|
506
|
+
color: var(--pl-color-fg-muted);
|
|
507
|
+
background: var(--pl-color-bg-subtle);
|
|
508
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
509
|
+
border-radius: 999px;
|
|
510
|
+
}
|
|
511
|
+
.pl-tab--active .pl-tab__badge {
|
|
512
|
+
color: var(--pl-color-fg);
|
|
513
|
+
}
|
|
485
514
|
.pl-tab:hover:not(:disabled) {
|
|
486
515
|
color: var(--pl-color-fg);
|
|
487
516
|
}
|
|
@@ -579,6 +608,43 @@ a.pl-row:hover {
|
|
|
579
608
|
border-color: var(--pl-color-fg);
|
|
580
609
|
}
|
|
581
610
|
|
|
611
|
+
/* ── PanelHeader (console panel header) ──────────────────────────────────────── */
|
|
612
|
+
.pl-panel-header {
|
|
613
|
+
display: flex;
|
|
614
|
+
align-items: center;
|
|
615
|
+
justify-content: space-between;
|
|
616
|
+
gap: var(--pl-space-3);
|
|
617
|
+
padding: var(--pl-space-3) var(--pl-space-4);
|
|
618
|
+
border-bottom: var(--pl-border-width) solid var(--pl-color-border);
|
|
619
|
+
}
|
|
620
|
+
.pl-panel-header__kicker {
|
|
621
|
+
font-family: var(--pl-font-mono);
|
|
622
|
+
font-size: 11px;
|
|
623
|
+
text-transform: uppercase;
|
|
624
|
+
letter-spacing: 0.06em;
|
|
625
|
+
color: var(--pl-color-fg-muted);
|
|
626
|
+
margin-bottom: 2px;
|
|
627
|
+
}
|
|
628
|
+
.pl-panel-header__title {
|
|
629
|
+
margin: 0;
|
|
630
|
+
font-size: 15px;
|
|
631
|
+
font-weight: var(--pl-font-weight-medium);
|
|
632
|
+
line-height: 1.2;
|
|
633
|
+
color: var(--pl-color-fg);
|
|
634
|
+
}
|
|
635
|
+
.pl-panel-header__actions {
|
|
636
|
+
display: flex;
|
|
637
|
+
align-items: center;
|
|
638
|
+
gap: var(--pl-space-2);
|
|
639
|
+
flex-shrink: 0;
|
|
640
|
+
}
|
|
641
|
+
.pl-panel-header--compact {
|
|
642
|
+
padding: var(--pl-space-2) var(--pl-space-3);
|
|
643
|
+
}
|
|
644
|
+
.pl-panel-header--compact .pl-panel-header__title {
|
|
645
|
+
font-size: 13px;
|
|
646
|
+
}
|
|
647
|
+
|
|
582
648
|
/* ── Overlay scrim (Dialog + Drawer) ─────────────────────────────────────────── */
|
|
583
649
|
.pl-overlay {
|
|
584
650
|
position: fixed;
|
|
@@ -668,6 +734,33 @@ a.pl-row:hover {
|
|
|
668
734
|
border-color: var(--pl-color-status-error);
|
|
669
735
|
color: var(--pl-color-bg);
|
|
670
736
|
}
|
|
737
|
+
/* Ghost — transparent until hover, for toolbars / inline actions. */
|
|
738
|
+
.pl-btn--ghost {
|
|
739
|
+
border-color: transparent;
|
|
740
|
+
}
|
|
741
|
+
.pl-btn--ghost:hover {
|
|
742
|
+
border-color: var(--pl-color-border-strong);
|
|
743
|
+
background: var(--pl-color-bg-hover);
|
|
744
|
+
}
|
|
745
|
+
.pl-btn--sm {
|
|
746
|
+
padding: 0.3rem 0.6rem;
|
|
747
|
+
font-size: 12px;
|
|
748
|
+
}
|
|
749
|
+
/* Icon-only — square, centered glyph. */
|
|
750
|
+
.pl-btn--icon {
|
|
751
|
+
padding: 0;
|
|
752
|
+
width: 30px;
|
|
753
|
+
height: 30px;
|
|
754
|
+
justify-content: center;
|
|
755
|
+
}
|
|
756
|
+
.pl-btn--icon.pl-btn--sm {
|
|
757
|
+
width: 26px;
|
|
758
|
+
height: 26px;
|
|
759
|
+
}
|
|
760
|
+
.pl-btn--icon svg {
|
|
761
|
+
width: 16px;
|
|
762
|
+
height: 16px;
|
|
763
|
+
}
|
|
671
764
|
|
|
672
765
|
/* ── Drawer (slide-in sheet) ─────────────────────────────────────────────────── */
|
|
673
766
|
.pl-drawer {
|
|
@@ -941,9 +1034,18 @@ a.pl-row:hover {
|
|
|
941
1034
|
/* ── ScrollArea (thin brand scrollbars) ──────────────────────────────────────── */
|
|
942
1035
|
.pl-scroll {
|
|
943
1036
|
overflow: auto;
|
|
1037
|
+
/* min-height:0 lets the region actually scroll inside a flex/grid parent
|
|
1038
|
+
(the classic min-size trap) instead of overflowing it. */
|
|
1039
|
+
min-height: 0;
|
|
1040
|
+
overscroll-behavior: contain;
|
|
1041
|
+
scrollbar-gutter: stable;
|
|
944
1042
|
scrollbar-width: thin;
|
|
945
1043
|
scrollbar-color: var(--pl-color-border-strong) transparent;
|
|
946
1044
|
}
|
|
1045
|
+
.pl-scroll:focus-visible {
|
|
1046
|
+
outline: 1px solid var(--pl-color-fg);
|
|
1047
|
+
outline-offset: -2px;
|
|
1048
|
+
}
|
|
947
1049
|
.pl-scroll::-webkit-scrollbar {
|
|
948
1050
|
width: 8px;
|
|
949
1051
|
height: 8px;
|
|
@@ -1097,6 +1199,113 @@ a.pl-row:hover {
|
|
|
1097
1199
|
color: var(--pl-color-fg);
|
|
1098
1200
|
}
|
|
1099
1201
|
|
|
1202
|
+
/* ── Menu / DropdownMenu (Radix-backed) ──────────────────────────────────────── */
|
|
1203
|
+
.pl-menu__anchor {
|
|
1204
|
+
width: 0;
|
|
1205
|
+
height: 0;
|
|
1206
|
+
pointer-events: none;
|
|
1207
|
+
}
|
|
1208
|
+
.pl-menu {
|
|
1209
|
+
min-width: 180px;
|
|
1210
|
+
padding: 4px;
|
|
1211
|
+
background: var(--pl-color-bg-raised);
|
|
1212
|
+
border: var(--pl-border-width) solid var(--pl-color-border-strong);
|
|
1213
|
+
border-radius: var(--pl-radius);
|
|
1214
|
+
box-shadow: var(--pl-shadow-popover);
|
|
1215
|
+
z-index: 1200;
|
|
1216
|
+
}
|
|
1217
|
+
.pl-menu__item {
|
|
1218
|
+
display: flex;
|
|
1219
|
+
align-items: center;
|
|
1220
|
+
gap: 8px;
|
|
1221
|
+
padding: 6px 8px;
|
|
1222
|
+
font-size: 13px;
|
|
1223
|
+
color: var(--pl-color-fg);
|
|
1224
|
+
border-radius: calc(var(--pl-radius) - 1px);
|
|
1225
|
+
cursor: pointer;
|
|
1226
|
+
outline: none;
|
|
1227
|
+
user-select: none;
|
|
1228
|
+
}
|
|
1229
|
+
/* Radix sets data-highlighted on the keyboard/pointer-focused item. */
|
|
1230
|
+
.pl-menu__item[data-highlighted] {
|
|
1231
|
+
background: var(--pl-color-bg-hover);
|
|
1232
|
+
}
|
|
1233
|
+
.pl-menu__item[data-disabled] {
|
|
1234
|
+
color: var(--pl-color-fg-muted);
|
|
1235
|
+
opacity: 0.5;
|
|
1236
|
+
pointer-events: none;
|
|
1237
|
+
}
|
|
1238
|
+
.pl-menu__item--destructive {
|
|
1239
|
+
color: var(--pl-color-status-error);
|
|
1240
|
+
}
|
|
1241
|
+
.pl-menu__item--destructive[data-highlighted] {
|
|
1242
|
+
background: color-mix(in oklch, var(--pl-color-status-error) 16%, transparent);
|
|
1243
|
+
}
|
|
1244
|
+
.pl-menu__icon {
|
|
1245
|
+
display: inline-flex;
|
|
1246
|
+
align-items: center;
|
|
1247
|
+
color: var(--pl-color-fg-muted);
|
|
1248
|
+
}
|
|
1249
|
+
.pl-menu__icon svg {
|
|
1250
|
+
width: 15px;
|
|
1251
|
+
height: 15px;
|
|
1252
|
+
}
|
|
1253
|
+
.pl-menu__item--destructive .pl-menu__icon {
|
|
1254
|
+
color: var(--pl-color-status-error);
|
|
1255
|
+
}
|
|
1256
|
+
.pl-menu__label {
|
|
1257
|
+
flex: 1;
|
|
1258
|
+
}
|
|
1259
|
+
.pl-menu__subarrow {
|
|
1260
|
+
color: var(--pl-color-fg-muted);
|
|
1261
|
+
font-size: 14px;
|
|
1262
|
+
line-height: 1;
|
|
1263
|
+
}
|
|
1264
|
+
.pl-menu__sep {
|
|
1265
|
+
height: 1px;
|
|
1266
|
+
margin: 4px 0;
|
|
1267
|
+
background: var(--pl-color-border);
|
|
1268
|
+
}
|
|
1269
|
+
.pl-menu__group-label {
|
|
1270
|
+
padding: 4px 8px;
|
|
1271
|
+
font-family: var(--pl-font-mono);
|
|
1272
|
+
font-size: 10px;
|
|
1273
|
+
text-transform: uppercase;
|
|
1274
|
+
letter-spacing: 0.06em;
|
|
1275
|
+
color: var(--pl-color-fg-muted);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/* ── Skeleton (loading placeholder) ──────────────────────────────────────────── */
|
|
1279
|
+
.pl-skel {
|
|
1280
|
+
display: block;
|
|
1281
|
+
border-radius: var(--pl-radius);
|
|
1282
|
+
background-color: var(--pl-color-bg-subtle);
|
|
1283
|
+
background-image: linear-gradient(
|
|
1284
|
+
90deg,
|
|
1285
|
+
var(--pl-color-bg-subtle) 25%,
|
|
1286
|
+
var(--pl-color-bg-hover) 50%,
|
|
1287
|
+
var(--pl-color-bg-subtle) 75%
|
|
1288
|
+
);
|
|
1289
|
+
background-size: 200% 100%;
|
|
1290
|
+
animation: pl-skel-shimmer 1.4s var(--pl-motion-ease-in-out) infinite;
|
|
1291
|
+
}
|
|
1292
|
+
@keyframes pl-skel-shimmer {
|
|
1293
|
+
from {
|
|
1294
|
+
background-position: 200% 0;
|
|
1295
|
+
}
|
|
1296
|
+
to {
|
|
1297
|
+
background-position: -200% 0;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
.pl-skel-lines {
|
|
1301
|
+
display: grid;
|
|
1302
|
+
gap: 8px;
|
|
1303
|
+
}
|
|
1304
|
+
.pl-skel-group {
|
|
1305
|
+
display: grid;
|
|
1306
|
+
gap: var(--pl-space-3);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1100
1309
|
/* Respect reduced-motion for every animation this package introduces. */
|
|
1101
1310
|
@media (prefers-reduced-motion: reduce) {
|
|
1102
1311
|
.pl-drawer,
|
|
@@ -1105,4 +1314,9 @@ a.pl-row:hover {
|
|
|
1105
1314
|
.pl-spinner {
|
|
1106
1315
|
animation: none;
|
|
1107
1316
|
}
|
|
1317
|
+
/* Skeletons go static (solid fill, no shimmer). */
|
|
1318
|
+
.pl-skel {
|
|
1319
|
+
animation: none;
|
|
1320
|
+
background-image: none;
|
|
1321
|
+
}
|
|
1108
1322
|
}
|