@protolabsai/ui 0.13.0 → 0.15.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.stories.tsx +28 -0
- package/src/Data.stories.tsx +46 -2
- package/src/Forms.stories.tsx +18 -1
- package/src/Navigation.stories.tsx +27 -0
- package/src/data.tsx +118 -0
- package/src/forms.tsx +66 -0
- package/src/navigation.tsx +95 -3
- package/src/styles/data.css +139 -0
- package/src/styles/forms.css +61 -0
- package/src/styles/navigation.css +84 -0
package/package.json
CHANGED
package/src/AppShell.stories.tsx
CHANGED
|
@@ -60,3 +60,31 @@ export const TabsWithSlots: Story = {
|
|
|
60
60
|
return <Demo />;
|
|
61
61
|
},
|
|
62
62
|
};
|
|
63
|
+
|
|
64
|
+
export const TabsResponsive: Story = {
|
|
65
|
+
name: "Tabs (responsive → dropdown)",
|
|
66
|
+
render: () => {
|
|
67
|
+
function Demo() {
|
|
68
|
+
const [active, setActive] = useState("activity");
|
|
69
|
+
const items = [
|
|
70
|
+
{ id: "overview", label: "Overview", icon: <Glyph /> },
|
|
71
|
+
{ id: "activity", label: "Activity", icon: <Glyph />, badge: 12 },
|
|
72
|
+
{ id: "knowledge", label: "Knowledge", icon: <Glyph /> },
|
|
73
|
+
{ id: "settings", label: "Settings", icon: <Glyph /> },
|
|
74
|
+
];
|
|
75
|
+
// A container query responds to the Tabs' own width — resize these boxes (not
|
|
76
|
+
// the window) to see the wide one stay a strip and the narrow one collapse.
|
|
77
|
+
return (
|
|
78
|
+
<div style={{ display: "grid", gap: 24 }}>
|
|
79
|
+
<div style={{ width: 520, border: "1px solid var(--pl-color-border)", borderRadius: 4, padding: 8 }}>
|
|
80
|
+
<Tabs responsive ariaLabel="Section" items={items} active={active} onSelect={setActive} />
|
|
81
|
+
</div>
|
|
82
|
+
<div style={{ width: 260, border: "1px solid var(--pl-color-border)", borderRadius: 4, padding: 8 }}>
|
|
83
|
+
<Tabs responsive ariaLabel="Section" items={items} active={active} onSelect={setActive} />
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return <Demo />;
|
|
89
|
+
},
|
|
90
|
+
};
|
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/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 = {
|
|
@@ -14,9 +15,25 @@ export type TabItem = {
|
|
|
14
15
|
|
|
15
16
|
/** A horizontal tab strip with optional icon/badge slots + disabled/locked
|
|
16
17
|
* support (gated workflows). */
|
|
17
|
-
export function Tabs({
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
export function Tabs({
|
|
19
|
+
items,
|
|
20
|
+
active,
|
|
21
|
+
onSelect,
|
|
22
|
+
responsive = false,
|
|
23
|
+
ariaLabel,
|
|
24
|
+
}: {
|
|
25
|
+
items: TabItem[];
|
|
26
|
+
active: string;
|
|
27
|
+
onSelect: (id: string) => void;
|
|
28
|
+
/** Collapse the strip to a `<select>` dropdown when the Tabs' *container* is narrow
|
|
29
|
+
* (a CSS container query — responds to its own width, not the viewport, so it works
|
|
30
|
+
* inside a narrow panel/split). Opt-in; non-responsive output is unchanged. */
|
|
31
|
+
responsive?: boolean;
|
|
32
|
+
/** Accessible name for the tablist + the collapsed select. */
|
|
33
|
+
ariaLabel?: string;
|
|
34
|
+
}) {
|
|
35
|
+
const strip = (
|
|
36
|
+
<div className="pl-tabs" role="tablist" aria-label={ariaLabel}>
|
|
20
37
|
{items.map((t) => (
|
|
21
38
|
<button
|
|
22
39
|
key={t.id}
|
|
@@ -43,6 +60,24 @@ export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: st
|
|
|
43
60
|
))}
|
|
44
61
|
</div>
|
|
45
62
|
);
|
|
63
|
+
if (!responsive) return strip;
|
|
64
|
+
return (
|
|
65
|
+
<div className="pl-tabs-wrap pl-tabs-wrap--responsive">
|
|
66
|
+
{strip}
|
|
67
|
+
<select
|
|
68
|
+
className="pl-tabs__select"
|
|
69
|
+
value={active}
|
|
70
|
+
aria-label={ariaLabel ?? "Select tab"}
|
|
71
|
+
onChange={(e) => onSelect(e.target.value)}
|
|
72
|
+
>
|
|
73
|
+
{items.map((t) => (
|
|
74
|
+
<option key={t.id} value={t.id} disabled={t.disabled || t.locked}>
|
|
75
|
+
{typeof t.label === "string" ? t.label : t.id}
|
|
76
|
+
</option>
|
|
77
|
+
))}
|
|
78
|
+
</select>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
46
81
|
}
|
|
47
82
|
|
|
48
83
|
/** A horizontal kanban board. Wrap BoardColumn children. */
|
|
@@ -92,3 +127,60 @@ export function PanelHeader({
|
|
|
92
127
|
</div>
|
|
93
128
|
);
|
|
94
129
|
}
|
|
130
|
+
|
|
131
|
+
/** Vertical disclosure stack. Wrap AccordionItem children. */
|
|
132
|
+
export function Accordion({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
133
|
+
return <div className={cx("pl-accordion", className)} {...rest} />;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One collapsible section. Uncontrolled via `defaultOpen`, or controlled via
|
|
137
|
+
* `open` + `onOpenChange`. */
|
|
138
|
+
export function AccordionItem({
|
|
139
|
+
title,
|
|
140
|
+
children,
|
|
141
|
+
defaultOpen = false,
|
|
142
|
+
open: openProp,
|
|
143
|
+
onOpenChange,
|
|
144
|
+
disabled,
|
|
145
|
+
className,
|
|
146
|
+
}: {
|
|
147
|
+
title: ReactNode;
|
|
148
|
+
children: ReactNode;
|
|
149
|
+
defaultOpen?: boolean;
|
|
150
|
+
open?: boolean;
|
|
151
|
+
onOpenChange?: (open: boolean) => void;
|
|
152
|
+
disabled?: boolean;
|
|
153
|
+
className?: string;
|
|
154
|
+
}) {
|
|
155
|
+
const [openState, setOpenState] = useState(defaultOpen);
|
|
156
|
+
const open = openProp ?? openState;
|
|
157
|
+
const id = useId();
|
|
158
|
+
const toggle = () => {
|
|
159
|
+
if (disabled) return;
|
|
160
|
+
onOpenChange?.(!open);
|
|
161
|
+
if (openProp === undefined) setOpenState(!open);
|
|
162
|
+
};
|
|
163
|
+
return (
|
|
164
|
+
<div className={cx("pl-accordion__item", open && "pl-accordion__item--open", className)}>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
id={`${id}-trigger`}
|
|
168
|
+
className="pl-accordion__trigger"
|
|
169
|
+
aria-expanded={open}
|
|
170
|
+
aria-controls={`${id}-panel`}
|
|
171
|
+
disabled={disabled}
|
|
172
|
+
onClick={toggle}
|
|
173
|
+
>
|
|
174
|
+
<span className="pl-accordion__title">{title}</span>
|
|
175
|
+
<svg className="pl-accordion__chevron" viewBox="0 0 16 16" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
176
|
+
<path d="M6 4l4 4-4 4" />
|
|
177
|
+
</svg>
|
|
178
|
+
</button>
|
|
179
|
+
{open && (
|
|
180
|
+
<div id={`${id}-panel`} className="pl-accordion__panel" role="region" aria-labelledby={`${id}-trigger`}>
|
|
181
|
+
{children}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
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
|
+
}
|
|
@@ -71,6 +71,32 @@
|
|
|
71
71
|
opacity: 0.6;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/* Responsive Tabs (opt-in via `responsive`): below ~30rem of CONTAINER width the
|
|
75
|
+
strip collapses to a <select>. A container query responds to the Tabs' own
|
|
76
|
+
container — not the viewport — so it collapses inside a narrow panel/split too. */
|
|
77
|
+
.pl-tabs-wrap--responsive {
|
|
78
|
+
container-type: inline-size;
|
|
79
|
+
width: 100%;
|
|
80
|
+
}
|
|
81
|
+
.pl-tabs__select {
|
|
82
|
+
display: none;
|
|
83
|
+
width: 100%;
|
|
84
|
+
padding: 6px 10px;
|
|
85
|
+
font: inherit;
|
|
86
|
+
color: var(--pl-color-fg);
|
|
87
|
+
background: var(--pl-color-bg-inset);
|
|
88
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
89
|
+
border-radius: var(--pl-radius);
|
|
90
|
+
}
|
|
91
|
+
@container (max-width: 30rem) {
|
|
92
|
+
.pl-tabs-wrap--responsive > .pl-tabs {
|
|
93
|
+
display: none;
|
|
94
|
+
}
|
|
95
|
+
.pl-tabs-wrap--responsive > .pl-tabs__select {
|
|
96
|
+
display: block;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
/* ── Board (kanban) ────────────────────────────────────────────────────────── */
|
|
75
101
|
.pl-board {
|
|
76
102
|
display: flex;
|
|
@@ -171,3 +197,61 @@
|
|
|
171
197
|
.pl-panel-header--compact .pl-panel-header__title {
|
|
172
198
|
font-size: 13px;
|
|
173
199
|
}
|
|
200
|
+
|
|
201
|
+
/* ── Accordion (disclosure) ───────────────────────────────────────────────────── */
|
|
202
|
+
.pl-accordion {
|
|
203
|
+
display: flex;
|
|
204
|
+
flex-direction: column;
|
|
205
|
+
overflow: hidden;
|
|
206
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
207
|
+
border-radius: var(--pl-radius);
|
|
208
|
+
}
|
|
209
|
+
.pl-accordion__item + .pl-accordion__item {
|
|
210
|
+
border-top: var(--pl-border-width) solid var(--pl-color-border);
|
|
211
|
+
}
|
|
212
|
+
.pl-accordion__trigger {
|
|
213
|
+
display: flex;
|
|
214
|
+
align-items: center;
|
|
215
|
+
justify-content: space-between;
|
|
216
|
+
gap: var(--pl-space-3);
|
|
217
|
+
width: 100%;
|
|
218
|
+
padding: var(--pl-space-3);
|
|
219
|
+
background: none;
|
|
220
|
+
border: none;
|
|
221
|
+
color: var(--pl-color-fg);
|
|
222
|
+
font: inherit;
|
|
223
|
+
font-size: 14px;
|
|
224
|
+
text-align: left;
|
|
225
|
+
cursor: pointer;
|
|
226
|
+
transition: background var(--pl-motion-fast) var(--pl-motion-ease);
|
|
227
|
+
}
|
|
228
|
+
.pl-accordion__trigger:hover {
|
|
229
|
+
background: var(--pl-color-bg-hover);
|
|
230
|
+
}
|
|
231
|
+
.pl-accordion__trigger:focus-visible {
|
|
232
|
+
outline: 2px solid var(--pl-color-focus);
|
|
233
|
+
outline-offset: -2px;
|
|
234
|
+
}
|
|
235
|
+
.pl-accordion__trigger:disabled {
|
|
236
|
+
opacity: 0.5;
|
|
237
|
+
cursor: not-allowed;
|
|
238
|
+
}
|
|
239
|
+
.pl-accordion__title {
|
|
240
|
+
font-weight: var(--pl-font-weight-medium);
|
|
241
|
+
}
|
|
242
|
+
.pl-accordion__chevron {
|
|
243
|
+
flex-shrink: 0;
|
|
244
|
+
width: 16px;
|
|
245
|
+
height: 16px;
|
|
246
|
+
color: var(--pl-color-fg-muted);
|
|
247
|
+
transition: transform var(--pl-motion-fast) var(--pl-motion-ease);
|
|
248
|
+
}
|
|
249
|
+
.pl-accordion__item--open .pl-accordion__chevron {
|
|
250
|
+
transform: rotate(90deg);
|
|
251
|
+
}
|
|
252
|
+
.pl-accordion__panel {
|
|
253
|
+
padding: 0 var(--pl-space-3) var(--pl-space-3);
|
|
254
|
+
font-size: 14px;
|
|
255
|
+
line-height: 1.55;
|
|
256
|
+
color: var(--pl-color-fg-muted);
|
|
257
|
+
}
|