@protolabsai/ui 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/AppShell.full.stories.tsx +19 -2
- package/src/Data.stories.tsx +46 -2
- package/src/Forms.stories.tsx +18 -1
- package/src/Navigation.stories.tsx +27 -0
- package/src/app-shell.tsx +44 -0
- package/src/data.tsx +118 -0
- package/src/forms.tsx +66 -0
- package/src/navigation.tsx +58 -0
- package/src/primitives.tsx +16 -0
- package/src/styles/app-shell.css +56 -0
- package/src/styles/data.css +139 -0
- package/src/styles/forms.css +61 -0
- package/src/styles/navigation.css +58 -0
- package/src/styles/primitives.css +7 -0
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { Badge, Button } from "./primitives";
|
|
3
|
+
import { Badge, Button, Logo } from "./primitives";
|
|
4
4
|
import { PanelHeader } from "./navigation";
|
|
5
5
|
import { StatusDot } from "./data";
|
|
6
|
-
import { AppShell, MobileNav, SurfaceRail, UtilityBar } from "./app-shell";
|
|
6
|
+
import { AppShell, Header, MobileNav, SurfaceRail, UtilityBar } from "./app-shell";
|
|
7
7
|
import type { MobileItem, RailItem } from "./app-shell";
|
|
8
8
|
|
|
9
|
+
const LOGO =
|
|
10
|
+
"data:image/svg+xml," +
|
|
11
|
+
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#9b87f2"/></svg>');
|
|
12
|
+
|
|
9
13
|
const meta: Meta = { title: "Components/AppShell" };
|
|
10
14
|
export default meta;
|
|
11
15
|
type Story = StoryObj;
|
|
@@ -84,6 +88,19 @@ export const Full: Story = {
|
|
|
84
88
|
{surfaceBody(activeRight)}
|
|
85
89
|
</>
|
|
86
90
|
}
|
|
91
|
+
header={
|
|
92
|
+
<Header
|
|
93
|
+
logo={<Logo src={LOGO} alt="" />}
|
|
94
|
+
name="protoAgent"
|
|
95
|
+
org="protoLabs.studio"
|
|
96
|
+
status={<StatusDot status="success" pulse />}
|
|
97
|
+
actions={
|
|
98
|
+
<Button size="sm" variant="ghost">
|
|
99
|
+
Settings
|
|
100
|
+
</Button>
|
|
101
|
+
}
|
|
102
|
+
/>
|
|
103
|
+
}
|
|
87
104
|
utilityBar={
|
|
88
105
|
<UtilityBar
|
|
89
106
|
start={<StatusDot status="success" pulse label="connected · 3 agents" />}
|
package/src/Data.stories.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { Badge } from "./primitives";
|
|
4
|
-
import { ScrollArea, Spinner, StatusDot, TBody, THead, Table, Td, Th, Tr } from "./data";
|
|
3
|
+
import { Badge, Button } from "./primitives";
|
|
4
|
+
import { Alert, Progress, ScrollArea, Spinner, StatusDot, TBody, THead, Table, Td, Th, Tr } from "./data";
|
|
5
5
|
|
|
6
6
|
const meta: Meta = { title: "Components/Data" };
|
|
7
7
|
export default meta;
|
|
@@ -83,3 +83,47 @@ export const Scroll: Story = {
|
|
|
83
83
|
</ScrollArea>
|
|
84
84
|
),
|
|
85
85
|
};
|
|
86
|
+
|
|
87
|
+
export const Progresses: Story = {
|
|
88
|
+
render: () => (
|
|
89
|
+
<div style={{ display: "grid", gap: 20, maxWidth: 360 }}>
|
|
90
|
+
<Progress value={72} label="Indexing corpus" showValue />
|
|
91
|
+
<Progress value={45} status="success" label="Upload" showValue />
|
|
92
|
+
<Progress value={88} status="warning" label="Rate-limit budget" showValue />
|
|
93
|
+
<Progress value={20} status="error" label="Failed shards" showValue />
|
|
94
|
+
<Progress indeterminate label="Connecting…" />
|
|
95
|
+
</div>
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const Alerts: Story = {
|
|
100
|
+
render: () => {
|
|
101
|
+
function Demo() {
|
|
102
|
+
const [open, setOpen] = useState(true);
|
|
103
|
+
return (
|
|
104
|
+
<div style={{ display: "grid", gap: 12, maxWidth: 520 }}>
|
|
105
|
+
<Alert status="info" title="Heads up">main is protected — every change lands via PR, admins included.</Alert>
|
|
106
|
+
<Alert status="success" title="Published">@protolabsai/ui@0.13.0 is live on npm.</Alert>
|
|
107
|
+
<Alert status="warning" title="Rate limit near">88% of the hourly budget consumed.</Alert>
|
|
108
|
+
<Alert
|
|
109
|
+
status="error"
|
|
110
|
+
title="Build failed"
|
|
111
|
+
action={
|
|
112
|
+
<Button size="sm" variant="ghost">
|
|
113
|
+
Retry
|
|
114
|
+
</Button>
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
The deps stage copied the wrong node_modules.
|
|
118
|
+
</Alert>
|
|
119
|
+
{open && (
|
|
120
|
+
<Alert status="neutral" title="Dismissible" onDismiss={() => setOpen(false)}>
|
|
121
|
+
Tap the × to dismiss this one.
|
|
122
|
+
</Alert>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return <Demo />;
|
|
128
|
+
},
|
|
129
|
+
};
|
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 "./forms";
|
|
3
|
+
import { Checkbox, Field, Input, Radio, RadioGroup, Select, Switch, Textarea } from "./forms";
|
|
4
4
|
|
|
5
5
|
const meta: Meta = { title: "Components/Forms" };
|
|
6
6
|
export default meta;
|
|
@@ -58,3 +58,20 @@ export const Toggles: Story = {
|
|
|
58
58
|
return <Demo />;
|
|
59
59
|
},
|
|
60
60
|
};
|
|
61
|
+
|
|
62
|
+
export const Radios: Story = {
|
|
63
|
+
render: () => {
|
|
64
|
+
function Demo() {
|
|
65
|
+
const [transport, setTransport] = useState("a2a");
|
|
66
|
+
return (
|
|
67
|
+
<RadioGroup value={transport} onValueChange={setTransport} name="transport">
|
|
68
|
+
<Radio value="in-process" label="In-process" />
|
|
69
|
+
<Radio value="a2a" label="Remote A2A" />
|
|
70
|
+
<Radio value="fn" label="Function handler" />
|
|
71
|
+
<Radio value="managed" label="Managed (disabled)" disabled />
|
|
72
|
+
</RadioGroup>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return <Demo />;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Accordion, AccordionItem } from "./navigation";
|
|
3
|
+
|
|
4
|
+
const meta: Meta = { title: "Components/Navigation/Accordion" };
|
|
5
|
+
export default meta;
|
|
6
|
+
type Story = StoryObj;
|
|
7
|
+
|
|
8
|
+
export const Default: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<div style={{ maxWidth: 480 }}>
|
|
11
|
+
<Accordion>
|
|
12
|
+
<AccordionItem title="What ships in the open core?" defaultOpen>
|
|
13
|
+
The substrate — schemas, specs, the content pipeline. MIT, free to fork.
|
|
14
|
+
</AccordionItem>
|
|
15
|
+
<AccordionItem title="How does voice context load?">
|
|
16
|
+
Per surface, via the MCP server — composed into the system prompt of any drafting call.
|
|
17
|
+
</AccordionItem>
|
|
18
|
+
<AccordionItem title="Where do the paid edges sit?">
|
|
19
|
+
Hosted version, premium features, guided courses — riding on top. The core is never paywalled.
|
|
20
|
+
</AccordionItem>
|
|
21
|
+
<AccordionItem title="Locked section (disabled)" disabled>
|
|
22
|
+
Not available.
|
|
23
|
+
</AccordionItem>
|
|
24
|
+
</Accordion>
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
};
|
package/src/app-shell.tsx
CHANGED
|
@@ -280,6 +280,9 @@ export type AppShellProps = {
|
|
|
280
280
|
onMobileSelect?: (id: string) => void;
|
|
281
281
|
quickBarIds?: string[];
|
|
282
282
|
mobileBreakpoint?: number;
|
|
283
|
+
/** Top 48px bar — brand lockup + status/actions. Presentation only; the host
|
|
284
|
+
* fills it (compose a `Header`). Renders on both desktop and mobile. */
|
|
285
|
+
header?: ReactNode;
|
|
283
286
|
/** Bottom 40px track — global utility actions / status / tickers. Presentation
|
|
284
287
|
* only; the host fills it (compose a `UtilityBar`). Desktop only. */
|
|
285
288
|
utilityBar?: ReactNode;
|
|
@@ -311,6 +314,7 @@ export function AppShell({
|
|
|
311
314
|
onMobileSelect,
|
|
312
315
|
quickBarIds,
|
|
313
316
|
mobileBreakpoint = 768,
|
|
317
|
+
header,
|
|
314
318
|
utilityBar,
|
|
315
319
|
className,
|
|
316
320
|
}: AppShellProps) {
|
|
@@ -433,6 +437,7 @@ export function AppShell({
|
|
|
433
437
|
if (isMobile && mobileItems && onMobileSelect && quickBarIds) {
|
|
434
438
|
return (
|
|
435
439
|
<div className={cx("pl-appshell", "pl-appshell--mobile", className)}>
|
|
440
|
+
{header != null && <div className="pl-appshell__header">{header}</div>}
|
|
436
441
|
<div className="pl-appshell__mobile-stage">{leftContent}</div>
|
|
437
442
|
<MobileNav
|
|
438
443
|
items={mobileItems}
|
|
@@ -450,6 +455,7 @@ export function AppShell({
|
|
|
450
455
|
|
|
451
456
|
const renderShell = (leftRail: ReactNode, rightRail: ReactNode) => (
|
|
452
457
|
<div className={cx("pl-appshell-frame", className)}>
|
|
458
|
+
{header != null && <div className="pl-appshell__header">{header}</div>}
|
|
453
459
|
<div className="pl-appshell">
|
|
454
460
|
{leftRail}
|
|
455
461
|
<main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
|
|
@@ -539,3 +545,41 @@ export function UtilityBar({
|
|
|
539
545
|
</div>
|
|
540
546
|
);
|
|
541
547
|
}
|
|
548
|
+
|
|
549
|
+
/** White-label top-bar chrome (48px header row) — brand lockup (logo · name ·
|
|
550
|
+
* org) on the left, status + actions on the right. Pass to AppShell's `header`
|
|
551
|
+
* slot. `dragRegion` marks it as the desktop window drag region (Tauri). */
|
|
552
|
+
export function Header({
|
|
553
|
+
logo,
|
|
554
|
+
name,
|
|
555
|
+
org,
|
|
556
|
+
status,
|
|
557
|
+
actions,
|
|
558
|
+
dragRegion,
|
|
559
|
+
className,
|
|
560
|
+
}: {
|
|
561
|
+
logo?: ReactNode;
|
|
562
|
+
name?: ReactNode;
|
|
563
|
+
org?: ReactNode;
|
|
564
|
+
status?: ReactNode;
|
|
565
|
+
actions?: ReactNode;
|
|
566
|
+
dragRegion?: boolean;
|
|
567
|
+
className?: string;
|
|
568
|
+
}) {
|
|
569
|
+
return (
|
|
570
|
+
<div className={cx("pl-header", className)} {...(dragRegion ? { "data-tauri-drag-region": "" } : {})}>
|
|
571
|
+
<div className="pl-header__brand">
|
|
572
|
+
{logo}
|
|
573
|
+
{(name != null || org != null) && (
|
|
574
|
+
<div className="pl-header__lockup">
|
|
575
|
+
{name != null && <span className="pl-header__name">{name}</span>}
|
|
576
|
+
{org != null && <span className="pl-header__org">{org}</span>}
|
|
577
|
+
</div>
|
|
578
|
+
)}
|
|
579
|
+
</div>
|
|
580
|
+
<div className="pl-header__spacer" />
|
|
581
|
+
{status != null && <div className="pl-header__status">{status}</div>}
|
|
582
|
+
{actions != null && <div className="pl-header__actions">{actions}</div>}
|
|
583
|
+
</div>
|
|
584
|
+
);
|
|
585
|
+
}
|
package/src/data.tsx
CHANGED
|
@@ -124,3 +124,121 @@ export function Skeleton({
|
|
|
124
124
|
export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
125
125
|
return <div className={cx("pl-skel-group", className)} {...rest} />;
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
/** Determinate or indeterminate progress bar. `status` tints the fill; pass
|
|
129
|
+
* `label` / `showValue` for a caption row. */
|
|
130
|
+
export function Progress({
|
|
131
|
+
value = 0,
|
|
132
|
+
max = 100,
|
|
133
|
+
status = "neutral",
|
|
134
|
+
label,
|
|
135
|
+
showValue,
|
|
136
|
+
indeterminate,
|
|
137
|
+
className,
|
|
138
|
+
}: {
|
|
139
|
+
value?: number;
|
|
140
|
+
max?: number;
|
|
141
|
+
status?: Status;
|
|
142
|
+
label?: ReactNode;
|
|
143
|
+
showValue?: boolean;
|
|
144
|
+
indeterminate?: boolean;
|
|
145
|
+
className?: string;
|
|
146
|
+
}) {
|
|
147
|
+
const pct = Math.max(0, Math.min(100, (value / max) * 100));
|
|
148
|
+
return (
|
|
149
|
+
<div className={cx("pl-progress", className)}>
|
|
150
|
+
{(label != null || showValue) && (
|
|
151
|
+
<div className="pl-progress__caption">
|
|
152
|
+
{label != null && <span>{label}</span>}
|
|
153
|
+
{showValue && !indeterminate && <span className="pl-progress__value">{Math.round(pct)}%</span>}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
<div
|
|
157
|
+
className="pl-progress__track"
|
|
158
|
+
role="progressbar"
|
|
159
|
+
aria-valuemin={0}
|
|
160
|
+
aria-valuemax={max}
|
|
161
|
+
aria-valuenow={indeterminate ? undefined : value}
|
|
162
|
+
>
|
|
163
|
+
<div
|
|
164
|
+
className={cx(
|
|
165
|
+
"pl-progress__fill",
|
|
166
|
+
status !== "neutral" && `pl-progress__fill--${status}`,
|
|
167
|
+
indeterminate && "pl-progress__fill--indeterminate",
|
|
168
|
+
)}
|
|
169
|
+
style={indeterminate ? undefined : { width: `${pct}%` }}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function alertIcon(status: Status) {
|
|
177
|
+
switch (status) {
|
|
178
|
+
case "success":
|
|
179
|
+
return <path d="M3.5 8.5l2.8 2.8L12.5 5" />;
|
|
180
|
+
case "warning":
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
183
|
+
<path d="M8 2.5l5.8 10.5H2.2z" />
|
|
184
|
+
<path d="M8 6.5v3.2M8 11.6h.01" />
|
|
185
|
+
</>
|
|
186
|
+
);
|
|
187
|
+
case "error":
|
|
188
|
+
return (
|
|
189
|
+
<>
|
|
190
|
+
<circle cx="8" cy="8" r="6.2" />
|
|
191
|
+
<path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8" />
|
|
192
|
+
</>
|
|
193
|
+
);
|
|
194
|
+
default:
|
|
195
|
+
return (
|
|
196
|
+
<>
|
|
197
|
+
<circle cx="8" cy="8" r="6.2" />
|
|
198
|
+
<path d="M8 7.4v3.4M8 5.2h.01" />
|
|
199
|
+
</>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Inline status banner — icon + title/body, optional `action` + dismiss. A
|
|
205
|
+
* prominent, persistent counterpart to the transient Toast (and to the prose
|
|
206
|
+
* Callout). `status` tints the whole surface. */
|
|
207
|
+
export function Alert({
|
|
208
|
+
status = "info",
|
|
209
|
+
title,
|
|
210
|
+
children,
|
|
211
|
+
action,
|
|
212
|
+
onDismiss,
|
|
213
|
+
className,
|
|
214
|
+
}: {
|
|
215
|
+
status?: Status;
|
|
216
|
+
title?: ReactNode;
|
|
217
|
+
children?: ReactNode;
|
|
218
|
+
action?: ReactNode;
|
|
219
|
+
onDismiss?: () => void;
|
|
220
|
+
className?: string;
|
|
221
|
+
}) {
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
224
|
+
className={cx("pl-alert", `pl-alert--${status}`, className)}
|
|
225
|
+
role={status === "error" || status === "warning" ? "alert" : "status"}
|
|
226
|
+
>
|
|
227
|
+
<svg className="pl-alert__icon" viewBox="0 0 16 16" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
|
228
|
+
{alertIcon(status)}
|
|
229
|
+
</svg>
|
|
230
|
+
<div className="pl-alert__body">
|
|
231
|
+
{title != null && <div className="pl-alert__title">{title}</div>}
|
|
232
|
+
{children != null && <div className="pl-alert__text">{children}</div>}
|
|
233
|
+
</div>
|
|
234
|
+
{action != null && <div className="pl-alert__action">{action}</div>}
|
|
235
|
+
{onDismiss && (
|
|
236
|
+
<button type="button" className="pl-alert__dismiss" aria-label="Dismiss" onClick={onDismiss}>
|
|
237
|
+
<svg viewBox="0 0 16 16" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
238
|
+
<path d="M4 4l8 8M12 4l-8 8" />
|
|
239
|
+
</svg>
|
|
240
|
+
</button>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
package/src/forms.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
2
3
|
import { cx } from "./internal";
|
|
3
4
|
|
|
4
5
|
/** A labeled input/textarea bound to a string value (form fields, editors). */
|
|
@@ -105,3 +106,68 @@ export function Checkbox({
|
|
|
105
106
|
</label>
|
|
106
107
|
);
|
|
107
108
|
}
|
|
109
|
+
|
|
110
|
+
type RadioCtx = {
|
|
111
|
+
name?: string;
|
|
112
|
+
value?: string;
|
|
113
|
+
onValueChange?: (value: string) => void;
|
|
114
|
+
disabled?: boolean;
|
|
115
|
+
};
|
|
116
|
+
const RadioContext = createContext<RadioCtx | null>(null);
|
|
117
|
+
|
|
118
|
+
/** Groups radios — owns the selected `value`, a shared `name`, and the change
|
|
119
|
+
* handler. Wrap Radio children. */
|
|
120
|
+
export function RadioGroup({
|
|
121
|
+
value,
|
|
122
|
+
onValueChange,
|
|
123
|
+
name,
|
|
124
|
+
disabled,
|
|
125
|
+
className,
|
|
126
|
+
children,
|
|
127
|
+
}: {
|
|
128
|
+
value?: string;
|
|
129
|
+
onValueChange?: (value: string) => void;
|
|
130
|
+
name?: string;
|
|
131
|
+
disabled?: boolean;
|
|
132
|
+
className?: string;
|
|
133
|
+
children: ReactNode;
|
|
134
|
+
}) {
|
|
135
|
+
return (
|
|
136
|
+
<div className={cx("pl-radiogroup", className)} role="radiogroup">
|
|
137
|
+
<RadioContext.Provider value={{ name, value, onValueChange, disabled }}>
|
|
138
|
+
{children}
|
|
139
|
+
</RadioContext.Provider>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A single radio option. Reads selection + name from the enclosing RadioGroup. */
|
|
145
|
+
export function Radio({
|
|
146
|
+
value,
|
|
147
|
+
label,
|
|
148
|
+
disabled,
|
|
149
|
+
className,
|
|
150
|
+
}: {
|
|
151
|
+
value: string;
|
|
152
|
+
label?: ReactNode;
|
|
153
|
+
disabled?: boolean;
|
|
154
|
+
className?: string;
|
|
155
|
+
}) {
|
|
156
|
+
const ctx = useContext(RadioContext);
|
|
157
|
+
const isDisabled = disabled || ctx?.disabled;
|
|
158
|
+
return (
|
|
159
|
+
<label className={cx("pl-radio", isDisabled && "pl-radio--disabled", className)}>
|
|
160
|
+
<input
|
|
161
|
+
type="radio"
|
|
162
|
+
className="pl-radio__input"
|
|
163
|
+
name={ctx?.name}
|
|
164
|
+
value={value}
|
|
165
|
+
checked={ctx?.value === value}
|
|
166
|
+
disabled={isDisabled}
|
|
167
|
+
onChange={() => ctx?.onValueChange?.(value)}
|
|
168
|
+
/>
|
|
169
|
+
<span className="pl-radio__control" aria-hidden />
|
|
170
|
+
{label != null && <span className="pl-radio__label">{label}</span>}
|
|
171
|
+
</label>
|
|
172
|
+
);
|
|
173
|
+
}
|
package/src/navigation.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { useId, useState } from "react";
|
|
2
3
|
import { cx } from "./internal";
|
|
3
4
|
|
|
4
5
|
export type TabItem = {
|
|
@@ -92,3 +93,60 @@ export function PanelHeader({
|
|
|
92
93
|
</div>
|
|
93
94
|
);
|
|
94
95
|
}
|
|
96
|
+
|
|
97
|
+
/** Vertical disclosure stack. Wrap AccordionItem children. */
|
|
98
|
+
export function Accordion({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
99
|
+
return <div className={cx("pl-accordion", className)} {...rest} />;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One collapsible section. Uncontrolled via `defaultOpen`, or controlled via
|
|
103
|
+
* `open` + `onOpenChange`. */
|
|
104
|
+
export function AccordionItem({
|
|
105
|
+
title,
|
|
106
|
+
children,
|
|
107
|
+
defaultOpen = false,
|
|
108
|
+
open: openProp,
|
|
109
|
+
onOpenChange,
|
|
110
|
+
disabled,
|
|
111
|
+
className,
|
|
112
|
+
}: {
|
|
113
|
+
title: ReactNode;
|
|
114
|
+
children: ReactNode;
|
|
115
|
+
defaultOpen?: boolean;
|
|
116
|
+
open?: boolean;
|
|
117
|
+
onOpenChange?: (open: boolean) => void;
|
|
118
|
+
disabled?: boolean;
|
|
119
|
+
className?: string;
|
|
120
|
+
}) {
|
|
121
|
+
const [openState, setOpenState] = useState(defaultOpen);
|
|
122
|
+
const open = openProp ?? openState;
|
|
123
|
+
const id = useId();
|
|
124
|
+
const toggle = () => {
|
|
125
|
+
if (disabled) return;
|
|
126
|
+
onOpenChange?.(!open);
|
|
127
|
+
if (openProp === undefined) setOpenState(!open);
|
|
128
|
+
};
|
|
129
|
+
return (
|
|
130
|
+
<div className={cx("pl-accordion__item", open && "pl-accordion__item--open", className)}>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
id={`${id}-trigger`}
|
|
134
|
+
className="pl-accordion__trigger"
|
|
135
|
+
aria-expanded={open}
|
|
136
|
+
aria-controls={`${id}-panel`}
|
|
137
|
+
disabled={disabled}
|
|
138
|
+
onClick={toggle}
|
|
139
|
+
>
|
|
140
|
+
<span className="pl-accordion__title">{title}</span>
|
|
141
|
+
<svg className="pl-accordion__chevron" viewBox="0 0 16 16" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
142
|
+
<path d="M6 4l4 4-4 4" />
|
|
143
|
+
</svg>
|
|
144
|
+
</button>
|
|
145
|
+
{open && (
|
|
146
|
+
<div id={`${id}-panel`} className="pl-accordion__panel" role="region" aria-labelledby={`${id}-trigger`}>
|
|
147
|
+
{children}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
package/src/primitives.tsx
CHANGED
|
@@ -154,3 +154,19 @@ export function Tag({
|
|
|
154
154
|
</span>
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
|
+
|
|
158
|
+
/** Brand mark — a token-sized, contained `<img>` (square by default). The
|
|
159
|
+
* white-label logo primitive (vs Avatar, which is a person/initials affordance). */
|
|
160
|
+
export function Logo({
|
|
161
|
+
src,
|
|
162
|
+
alt = "",
|
|
163
|
+
size = 22,
|
|
164
|
+
className,
|
|
165
|
+
}: {
|
|
166
|
+
src: string;
|
|
167
|
+
alt?: string;
|
|
168
|
+
size?: number;
|
|
169
|
+
className?: string;
|
|
170
|
+
}) {
|
|
171
|
+
return <img className={cx("pl-logo", className)} src={src} alt={alt} width={size} height={size} />;
|
|
172
|
+
}
|
package/src/styles/app-shell.css
CHANGED
|
@@ -274,3 +274,59 @@
|
|
|
274
274
|
box-shadow: var(--pl-shadow-popover);
|
|
275
275
|
cursor: grabbing;
|
|
276
276
|
}
|
|
277
|
+
|
|
278
|
+
/* ── Header (top bar) ─────────────────────────────────────────────────────────── */
|
|
279
|
+
.pl-appshell__header {
|
|
280
|
+
flex: 0 0 48px;
|
|
281
|
+
height: 48px;
|
|
282
|
+
display: flex;
|
|
283
|
+
align-items: center;
|
|
284
|
+
border-bottom: var(--pl-border-width) solid var(--pl-color-border);
|
|
285
|
+
background: var(--pl-color-bg-raised);
|
|
286
|
+
}
|
|
287
|
+
.pl-header {
|
|
288
|
+
display: flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
gap: var(--pl-space-3);
|
|
291
|
+
width: 100%;
|
|
292
|
+
height: 100%;
|
|
293
|
+
padding: 0 var(--pl-space-3);
|
|
294
|
+
}
|
|
295
|
+
.pl-header__brand {
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
gap: var(--pl-space-2);
|
|
299
|
+
min-width: 0;
|
|
300
|
+
}
|
|
301
|
+
.pl-header__lockup {
|
|
302
|
+
display: flex;
|
|
303
|
+
flex-direction: column;
|
|
304
|
+
justify-content: center;
|
|
305
|
+
min-width: 0;
|
|
306
|
+
line-height: 1.15;
|
|
307
|
+
}
|
|
308
|
+
.pl-header__name {
|
|
309
|
+
overflow: hidden;
|
|
310
|
+
font-size: 13px;
|
|
311
|
+
font-weight: var(--pl-font-weight-medium);
|
|
312
|
+
color: var(--pl-color-fg);
|
|
313
|
+
text-overflow: ellipsis;
|
|
314
|
+
white-space: nowrap;
|
|
315
|
+
}
|
|
316
|
+
.pl-header__org {
|
|
317
|
+
font-family: var(--pl-font-mono);
|
|
318
|
+
font-size: 10px;
|
|
319
|
+
color: var(--pl-color-fg-muted);
|
|
320
|
+
}
|
|
321
|
+
.pl-header__spacer {
|
|
322
|
+
flex: 1;
|
|
323
|
+
}
|
|
324
|
+
.pl-header__status {
|
|
325
|
+
display: flex;
|
|
326
|
+
align-items: center;
|
|
327
|
+
}
|
|
328
|
+
.pl-header__actions {
|
|
329
|
+
display: flex;
|
|
330
|
+
align-items: center;
|
|
331
|
+
gap: var(--pl-space-2);
|
|
332
|
+
}
|
package/src/styles/data.css
CHANGED
|
@@ -183,3 +183,142 @@
|
|
|
183
183
|
background-image: none;
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
+
|
|
187
|
+
/* ── Progress ─────────────────────────────────────────────────────────────────── */
|
|
188
|
+
.pl-progress {
|
|
189
|
+
display: grid;
|
|
190
|
+
gap: var(--pl-space-2);
|
|
191
|
+
width: 100%;
|
|
192
|
+
}
|
|
193
|
+
.pl-progress__caption {
|
|
194
|
+
display: flex;
|
|
195
|
+
align-items: center;
|
|
196
|
+
justify-content: space-between;
|
|
197
|
+
gap: var(--pl-space-2);
|
|
198
|
+
font-size: 12px;
|
|
199
|
+
color: var(--pl-color-fg-muted);
|
|
200
|
+
}
|
|
201
|
+
.pl-progress__value {
|
|
202
|
+
font-family: var(--pl-font-mono);
|
|
203
|
+
color: var(--pl-color-fg);
|
|
204
|
+
}
|
|
205
|
+
.pl-progress__track {
|
|
206
|
+
position: relative;
|
|
207
|
+
height: 6px;
|
|
208
|
+
overflow: hidden;
|
|
209
|
+
background: var(--pl-color-bg-inset);
|
|
210
|
+
border-radius: 999px;
|
|
211
|
+
}
|
|
212
|
+
.pl-progress__fill {
|
|
213
|
+
height: 100%;
|
|
214
|
+
background: var(--pl-color-accent);
|
|
215
|
+
border-radius: inherit;
|
|
216
|
+
transition: width var(--pl-motion-base) var(--pl-motion-ease);
|
|
217
|
+
}
|
|
218
|
+
.pl-progress__fill--success {
|
|
219
|
+
background: var(--pl-color-status-success);
|
|
220
|
+
}
|
|
221
|
+
.pl-progress__fill--warning {
|
|
222
|
+
background: var(--pl-color-status-warning);
|
|
223
|
+
}
|
|
224
|
+
.pl-progress__fill--error {
|
|
225
|
+
background: var(--pl-color-status-error);
|
|
226
|
+
}
|
|
227
|
+
.pl-progress__fill--info {
|
|
228
|
+
background: var(--pl-color-status-info);
|
|
229
|
+
}
|
|
230
|
+
.pl-progress__fill--indeterminate {
|
|
231
|
+
width: 40%;
|
|
232
|
+
animation: pl-progress-slide 1.1s var(--pl-motion-ease-in-out) infinite;
|
|
233
|
+
}
|
|
234
|
+
@keyframes pl-progress-slide {
|
|
235
|
+
0% {
|
|
236
|
+
transform: translateX(-120%);
|
|
237
|
+
}
|
|
238
|
+
100% {
|
|
239
|
+
transform: translateX(320%);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ── Alert (inline status banner) ─────────────────────────────────────────────── */
|
|
244
|
+
.pl-alert {
|
|
245
|
+
display: flex;
|
|
246
|
+
align-items: flex-start;
|
|
247
|
+
gap: var(--pl-space-3);
|
|
248
|
+
padding: var(--pl-space-3);
|
|
249
|
+
background: var(--pl-color-bg-raised);
|
|
250
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
251
|
+
border-left-width: 3px;
|
|
252
|
+
border-radius: var(--pl-radius);
|
|
253
|
+
color: var(--pl-color-fg);
|
|
254
|
+
}
|
|
255
|
+
.pl-alert__icon {
|
|
256
|
+
flex-shrink: 0;
|
|
257
|
+
width: 16px;
|
|
258
|
+
height: 16px;
|
|
259
|
+
margin-top: 1px;
|
|
260
|
+
}
|
|
261
|
+
.pl-alert__body {
|
|
262
|
+
flex: 1;
|
|
263
|
+
min-width: 0;
|
|
264
|
+
font-size: 13px;
|
|
265
|
+
line-height: 1.5;
|
|
266
|
+
}
|
|
267
|
+
.pl-alert__title {
|
|
268
|
+
font-weight: var(--pl-font-weight-medium);
|
|
269
|
+
}
|
|
270
|
+
.pl-alert__text {
|
|
271
|
+
color: var(--pl-color-fg-muted);
|
|
272
|
+
}
|
|
273
|
+
.pl-alert__title + .pl-alert__text {
|
|
274
|
+
margin-top: 2px;
|
|
275
|
+
}
|
|
276
|
+
.pl-alert__action {
|
|
277
|
+
flex-shrink: 0;
|
|
278
|
+
}
|
|
279
|
+
.pl-alert__dismiss {
|
|
280
|
+
flex-shrink: 0;
|
|
281
|
+
display: inline-flex;
|
|
282
|
+
padding: 2px;
|
|
283
|
+
margin: -2px -2px 0 0;
|
|
284
|
+
background: none;
|
|
285
|
+
border: none;
|
|
286
|
+
border-radius: var(--pl-radius);
|
|
287
|
+
color: var(--pl-color-fg-subtle);
|
|
288
|
+
cursor: pointer;
|
|
289
|
+
transition: color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
290
|
+
}
|
|
291
|
+
.pl-alert__dismiss:hover {
|
|
292
|
+
color: var(--pl-color-fg);
|
|
293
|
+
}
|
|
294
|
+
.pl-alert__dismiss svg {
|
|
295
|
+
width: 14px;
|
|
296
|
+
height: 14px;
|
|
297
|
+
}
|
|
298
|
+
.pl-alert--success {
|
|
299
|
+
border-left-color: var(--pl-color-status-success);
|
|
300
|
+
}
|
|
301
|
+
.pl-alert--success .pl-alert__icon {
|
|
302
|
+
color: var(--pl-color-status-success);
|
|
303
|
+
}
|
|
304
|
+
.pl-alert--warning {
|
|
305
|
+
border-left-color: var(--pl-color-status-warning);
|
|
306
|
+
}
|
|
307
|
+
.pl-alert--warning .pl-alert__icon {
|
|
308
|
+
color: var(--pl-color-status-warning);
|
|
309
|
+
}
|
|
310
|
+
.pl-alert--error {
|
|
311
|
+
border-left-color: var(--pl-color-status-error);
|
|
312
|
+
}
|
|
313
|
+
.pl-alert--error .pl-alert__icon {
|
|
314
|
+
color: var(--pl-color-status-error);
|
|
315
|
+
}
|
|
316
|
+
.pl-alert--info {
|
|
317
|
+
border-left-color: var(--pl-color-status-info);
|
|
318
|
+
}
|
|
319
|
+
.pl-alert--info .pl-alert__icon {
|
|
320
|
+
color: var(--pl-color-status-info);
|
|
321
|
+
}
|
|
322
|
+
.pl-alert--neutral .pl-alert__icon {
|
|
323
|
+
color: var(--pl-color-fg-muted);
|
|
324
|
+
}
|
package/src/styles/forms.css
CHANGED
|
@@ -191,3 +191,64 @@
|
|
|
191
191
|
font-size: 13px;
|
|
192
192
|
color: var(--pl-color-fg);
|
|
193
193
|
}
|
|
194
|
+
|
|
195
|
+
/* ── Radio ───────────────────────────────────────────────────────────────────── */
|
|
196
|
+
.pl-radiogroup {
|
|
197
|
+
display: grid;
|
|
198
|
+
gap: var(--pl-space-2);
|
|
199
|
+
}
|
|
200
|
+
.pl-radio {
|
|
201
|
+
display: inline-flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
gap: 0.5rem;
|
|
204
|
+
cursor: pointer;
|
|
205
|
+
}
|
|
206
|
+
.pl-radio--disabled {
|
|
207
|
+
opacity: 0.5;
|
|
208
|
+
cursor: not-allowed;
|
|
209
|
+
}
|
|
210
|
+
.pl-radio__input {
|
|
211
|
+
position: absolute;
|
|
212
|
+
width: 0;
|
|
213
|
+
height: 0;
|
|
214
|
+
opacity: 0;
|
|
215
|
+
}
|
|
216
|
+
.pl-radio__control {
|
|
217
|
+
position: relative;
|
|
218
|
+
display: inline-block;
|
|
219
|
+
flex-shrink: 0;
|
|
220
|
+
width: 16px;
|
|
221
|
+
height: 16px;
|
|
222
|
+
background: var(--pl-color-bg-raised);
|
|
223
|
+
border: var(--pl-border-width) solid var(--pl-color-border-strong);
|
|
224
|
+
border-radius: 50%;
|
|
225
|
+
transition:
|
|
226
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
227
|
+
border-color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
228
|
+
}
|
|
229
|
+
.pl-radio__control::after {
|
|
230
|
+
content: "";
|
|
231
|
+
position: absolute;
|
|
232
|
+
inset: 0;
|
|
233
|
+
width: 6px;
|
|
234
|
+
height: 6px;
|
|
235
|
+
margin: auto;
|
|
236
|
+
background: var(--pl-color-fg);
|
|
237
|
+
border-radius: 50%;
|
|
238
|
+
transform: scale(0);
|
|
239
|
+
transition: transform var(--pl-motion-fast) var(--pl-motion-ease);
|
|
240
|
+
}
|
|
241
|
+
.pl-radio__input:checked + .pl-radio__control {
|
|
242
|
+
border-color: var(--pl-color-fg);
|
|
243
|
+
}
|
|
244
|
+
.pl-radio__input:checked + .pl-radio__control::after {
|
|
245
|
+
transform: scale(1);
|
|
246
|
+
}
|
|
247
|
+
.pl-radio__input:focus-visible + .pl-radio__control {
|
|
248
|
+
outline: 2px solid var(--pl-color-border-strong);
|
|
249
|
+
outline-offset: 2px;
|
|
250
|
+
}
|
|
251
|
+
.pl-radio__label {
|
|
252
|
+
font-size: 13px;
|
|
253
|
+
color: var(--pl-color-fg);
|
|
254
|
+
}
|
|
@@ -171,3 +171,61 @@
|
|
|
171
171
|
.pl-panel-header--compact .pl-panel-header__title {
|
|
172
172
|
font-size: 13px;
|
|
173
173
|
}
|
|
174
|
+
|
|
175
|
+
/* ── Accordion (disclosure) ───────────────────────────────────────────────────── */
|
|
176
|
+
.pl-accordion {
|
|
177
|
+
display: flex;
|
|
178
|
+
flex-direction: column;
|
|
179
|
+
overflow: hidden;
|
|
180
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
181
|
+
border-radius: var(--pl-radius);
|
|
182
|
+
}
|
|
183
|
+
.pl-accordion__item + .pl-accordion__item {
|
|
184
|
+
border-top: var(--pl-border-width) solid var(--pl-color-border);
|
|
185
|
+
}
|
|
186
|
+
.pl-accordion__trigger {
|
|
187
|
+
display: flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
justify-content: space-between;
|
|
190
|
+
gap: var(--pl-space-3);
|
|
191
|
+
width: 100%;
|
|
192
|
+
padding: var(--pl-space-3);
|
|
193
|
+
background: none;
|
|
194
|
+
border: none;
|
|
195
|
+
color: var(--pl-color-fg);
|
|
196
|
+
font: inherit;
|
|
197
|
+
font-size: 14px;
|
|
198
|
+
text-align: left;
|
|
199
|
+
cursor: pointer;
|
|
200
|
+
transition: background var(--pl-motion-fast) var(--pl-motion-ease);
|
|
201
|
+
}
|
|
202
|
+
.pl-accordion__trigger:hover {
|
|
203
|
+
background: var(--pl-color-bg-hover);
|
|
204
|
+
}
|
|
205
|
+
.pl-accordion__trigger:focus-visible {
|
|
206
|
+
outline: 2px solid var(--pl-color-focus);
|
|
207
|
+
outline-offset: -2px;
|
|
208
|
+
}
|
|
209
|
+
.pl-accordion__trigger:disabled {
|
|
210
|
+
opacity: 0.5;
|
|
211
|
+
cursor: not-allowed;
|
|
212
|
+
}
|
|
213
|
+
.pl-accordion__title {
|
|
214
|
+
font-weight: var(--pl-font-weight-medium);
|
|
215
|
+
}
|
|
216
|
+
.pl-accordion__chevron {
|
|
217
|
+
flex-shrink: 0;
|
|
218
|
+
width: 16px;
|
|
219
|
+
height: 16px;
|
|
220
|
+
color: var(--pl-color-fg-muted);
|
|
221
|
+
transition: transform var(--pl-motion-fast) var(--pl-motion-ease);
|
|
222
|
+
}
|
|
223
|
+
.pl-accordion__item--open .pl-accordion__chevron {
|
|
224
|
+
transform: rotate(90deg);
|
|
225
|
+
}
|
|
226
|
+
.pl-accordion__panel {
|
|
227
|
+
padding: 0 var(--pl-space-3) var(--pl-space-3);
|
|
228
|
+
font-size: 14px;
|
|
229
|
+
line-height: 1.55;
|
|
230
|
+
color: var(--pl-color-fg-muted);
|
|
231
|
+
}
|
|
@@ -285,3 +285,10 @@
|
|
|
285
285
|
color: var(--pl-color-fg);
|
|
286
286
|
background: var(--pl-color-bg-hover);
|
|
287
287
|
}
|
|
288
|
+
|
|
289
|
+
/* ── Logo ────────────────────────────────────────────────────────────────────── */
|
|
290
|
+
.pl-logo {
|
|
291
|
+
display: block;
|
|
292
|
+
flex-shrink: 0;
|
|
293
|
+
object-fit: contain;
|
|
294
|
+
}
|