@protolabsai/ui 0.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protolabsai/ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -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
+ };
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }