@alpic-ai/ui 0.0.0-dev.fffc79a → 0.0.0-dev.g00dcac1

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.
Files changed (75) hide show
  1. package/dist/components/accordion-card.d.mts +5 -6
  2. package/dist/components/accordion.d.mts +5 -6
  3. package/dist/components/alert.d.mts +9 -11
  4. package/dist/components/attachment-tile.d.mts +1 -3
  5. package/dist/components/avatar.d.mts +8 -10
  6. package/dist/components/badge.d.mts +2 -4
  7. package/dist/components/breadcrumb.d.mts +10 -11
  8. package/dist/components/button.d.mts +6 -8
  9. package/dist/components/card.d.mts +9 -10
  10. package/dist/components/checkbox.d.mts +2 -3
  11. package/dist/components/collapsible.d.mts +4 -5
  12. package/dist/components/combobox.d.mts +12 -11
  13. package/dist/components/combobox.mjs +7 -4
  14. package/dist/components/command.d.mts +9 -10
  15. package/dist/components/copyable.d.mts +2 -3
  16. package/dist/components/description-list.d.mts +5 -6
  17. package/dist/components/dialog.d.mts +15 -17
  18. package/dist/components/dropdown-menu.d.mts +18 -20
  19. package/dist/components/form.d.mts +71 -19
  20. package/dist/components/form.mjs +120 -10
  21. package/dist/components/github-button.d.mts +1 -2
  22. package/dist/components/input-group.d.mts +5 -7
  23. package/dist/components/input.d.mts +4 -5
  24. package/dist/components/input.mjs +2 -2
  25. package/dist/components/label.d.mts +2 -3
  26. package/dist/components/page-loader.d.mts +1 -3
  27. package/dist/components/pagination.d.mts +3 -4
  28. package/dist/components/popover.d.mts +5 -6
  29. package/dist/components/radio-group.d.mts +3 -4
  30. package/dist/components/scroll-area.d.mts +3 -4
  31. package/dist/components/select-trigger-variants.d.mts +1 -3
  32. package/dist/components/select.d.mts +9 -10
  33. package/dist/components/separator.d.mts +2 -3
  34. package/dist/components/sheet.d.mts +11 -12
  35. package/dist/components/shimmer-text.d.mts +12 -0
  36. package/dist/components/shimmer-text.mjs +22 -0
  37. package/dist/components/sidebar.d.mts +34 -36
  38. package/dist/components/sidebar.mjs +10 -10
  39. package/dist/components/skeleton.d.mts +2 -4
  40. package/dist/components/sonner.d.mts +5 -6
  41. package/dist/components/spinner.d.mts +3 -5
  42. package/dist/components/status-dot.d.mts +2 -4
  43. package/dist/components/switch.d.mts +2 -3
  44. package/dist/components/table.d.mts +10 -11
  45. package/dist/components/table.mjs +3 -3
  46. package/dist/components/tabs.d.mts +12 -14
  47. package/dist/components/tabs.mjs +4 -4
  48. package/dist/components/tag.d.mts +3 -5
  49. package/dist/components/task-progress.d.mts +25 -0
  50. package/dist/components/task-progress.mjs +66 -0
  51. package/dist/components/textarea.d.mts +3 -4
  52. package/dist/components/textarea.mjs +2 -2
  53. package/dist/components/toggle-group.d.mts +4 -6
  54. package/dist/components/toggle-group.mjs +3 -3
  55. package/dist/components/tooltip-icon-button.d.mts +1 -2
  56. package/dist/components/tooltip.d.mts +5 -6
  57. package/dist/components/tooltip.mjs +1 -1
  58. package/dist/components/typography.d.mts +4 -5
  59. package/dist/components/wizard.d.mts +33 -0
  60. package/dist/components/wizard.mjs +46 -0
  61. package/dist/hooks/use-mobile.mjs +3 -3
  62. package/package.json +30 -30
  63. package/src/components/combobox.tsx +9 -2
  64. package/src/components/form.tsx +164 -3
  65. package/src/components/shimmer-text.tsx +23 -0
  66. package/src/components/table.tsx +2 -3
  67. package/src/components/tabs.tsx +4 -4
  68. package/src/components/task-progress.tsx +107 -0
  69. package/src/components/tooltip.tsx +1 -1
  70. package/src/components/wizard.tsx +69 -0
  71. package/src/stories/form.stories.tsx +64 -2
  72. package/src/stories/tabs.stories.tsx +4 -2
  73. package/src/stories/task-progress.stories.tsx +81 -0
  74. package/src/stories/wizard.stories.tsx +64 -0
  75. package/src/styles/tokens.css +11 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alpic-ai/ui",
3
- "version": "0.0.0-dev.fffc79a",
3
+ "version": "0.0.0-dev.g00dcac1",
4
4
  "description": "Alpic design system — shared UI components",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,48 +23,48 @@
23
23
  "src"
24
24
  ],
25
25
  "peerDependencies": {
26
- "lucide-react": "^1.11.0",
27
- "react": "^19.2.5",
28
- "react-dom": "^19.2.5",
29
- "react-hook-form": "^7.74.0",
26
+ "lucide-react": "^1.18.0",
27
+ "react": "^19.2.7",
28
+ "react-dom": "^19.2.7",
29
+ "react-hook-form": "^7.79.0",
30
30
  "sonner": "^2.0.7",
31
- "tailwindcss": "^4.2.4",
31
+ "tailwindcss": "^4.3.1",
32
32
  "tw-animate-css": "^1.4.0"
33
33
  },
34
34
  "dependencies": {
35
- "@radix-ui/react-accordion": "^1.2.12",
36
- "@radix-ui/react-avatar": "^1.1.11",
37
- "@radix-ui/react-checkbox": "^1.3.3",
38
- "@radix-ui/react-collapsible": "^1.1.12",
39
- "@radix-ui/react-dialog": "^1.1.15",
40
- "@radix-ui/react-dropdown-menu": "^2.1.16",
41
- "@radix-ui/react-label": "^2.1.8",
42
- "@radix-ui/react-popover": "^1.1.15",
43
- "@radix-ui/react-radio-group": "^1.3.8",
44
- "@radix-ui/react-scroll-area": "^1.2.10",
45
- "@radix-ui/react-select": "^2.2.6",
46
- "@radix-ui/react-separator": "^1.1.8",
47
- "@radix-ui/react-slot": "^1.2.4",
48
- "@radix-ui/react-switch": "^1.2.6",
49
- "@radix-ui/react-tabs": "^1.1.13",
50
- "@radix-ui/react-toggle-group": "^1.1.11",
51
- "@radix-ui/react-tooltip": "^1.2.8",
35
+ "@radix-ui/react-accordion": "^1.2.13",
36
+ "@radix-ui/react-avatar": "^1.1.12",
37
+ "@radix-ui/react-checkbox": "^1.3.4",
38
+ "@radix-ui/react-collapsible": "^1.1.13",
39
+ "@radix-ui/react-dialog": "^1.1.16",
40
+ "@radix-ui/react-dropdown-menu": "^2.1.17",
41
+ "@radix-ui/react-label": "^2.1.9",
42
+ "@radix-ui/react-popover": "^1.1.16",
43
+ "@radix-ui/react-radio-group": "^1.4.0",
44
+ "@radix-ui/react-scroll-area": "^1.2.11",
45
+ "@radix-ui/react-select": "^2.3.0",
46
+ "@radix-ui/react-separator": "^1.1.9",
47
+ "@radix-ui/react-slot": "^1.2.5",
48
+ "@radix-ui/react-switch": "^1.3.0",
49
+ "@radix-ui/react-tabs": "^1.1.14",
50
+ "@radix-ui/react-toggle-group": "^1.1.12",
51
+ "@radix-ui/react-tooltip": "^1.2.9",
52
52
  "class-variance-authority": "^0.7.1",
53
53
  "clsx": "^2.1.1",
54
54
  "cmdk": "^1.1.1",
55
- "tailwind-merge": "^3.5.0"
55
+ "tailwind-merge": "^3.6.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@ladle/react": "^5.1.1",
59
- "@tailwindcss/postcss": "^4.2.4",
60
- "@types/react": "19.2.14",
59
+ "@tailwindcss/postcss": "^4.3.1",
60
+ "@types/react": "19.2.17",
61
61
  "@types/react-dom": "19.2.3",
62
- "lucide-react": "^1.11.0",
63
- "react-hook-form": "^7.74.0",
62
+ "lucide-react": "^1.18.0",
63
+ "react-hook-form": "^7.79.0",
64
64
  "shx": "^0.4.0",
65
65
  "sonner": "^2.0.7",
66
- "tailwindcss": "^4.2.4",
67
- "tsdown": "^0.21.10",
66
+ "tailwindcss": "^4.3.1",
67
+ "tsdown": "^0.22.2",
68
68
  "tw-animate-css": "^1.4.0",
69
69
  "typescript": "^6.0.3"
70
70
  },
@@ -21,6 +21,8 @@ interface ComboboxContextValue {
21
21
  isSelected: (itemValue: string) => boolean;
22
22
  open: boolean;
23
23
  onOpenChange: (open: boolean) => void;
24
+ /** Resolves a selected value to a display label (e.g. for multi-select tags). Defaults to the value. */
25
+ getOptionLabel?: (value: string) => string;
24
26
  }
25
27
 
26
28
  const ComboboxContext = createContext<ComboboxContextValue | null>(null);
@@ -40,6 +42,8 @@ interface ComboboxBaseProps {
40
42
  open?: boolean;
41
43
  defaultOpen?: boolean;
42
44
  onOpenChange?: (open: boolean) => void;
45
+ /** Resolves a selected value to a display label (e.g. for multi-select tags). Defaults to the value. */
46
+ getOptionLabel?: (value: string) => string;
43
47
  }
44
48
 
45
49
  interface ComboboxSingleProps extends ComboboxBaseProps {
@@ -65,6 +69,7 @@ function Combobox(props: ComboboxProps) {
65
69
  open: controlledOpen,
66
70
  defaultOpen = false,
67
71
  onOpenChange: controlledOnOpenChange,
72
+ getOptionLabel,
68
73
  } = props;
69
74
 
70
75
  // Single mode state
@@ -175,8 +180,9 @@ function Combobox(props: ComboboxProps) {
175
180
  isSelected,
176
181
  open,
177
182
  onOpenChange,
183
+ getOptionLabel,
178
184
  }),
179
- [multiple, singleValue, multiValues, onSelect, onDeselect, isSelected, open, onOpenChange],
185
+ [multiple, singleValue, multiValues, onSelect, onDeselect, isSelected, open, onOpenChange, getOptionLabel],
180
186
  );
181
187
 
182
188
  return (
@@ -229,6 +235,7 @@ function ComboboxTrigger({ className, size, placeholder, children, ...props }: C
229
235
  /* ── Tags (internal, for multi-select trigger) ────────────────────────────── */
230
236
 
231
237
  function ComboboxTags({ values, onDeselect }: { values: string[]; onDeselect: (value: string) => void }) {
238
+ const { getOptionLabel } = useComboboxContext();
232
239
  return (
233
240
  <>
234
241
  {values.map((tagValue) => (
@@ -237,7 +244,7 @@ function ComboboxTags({ values, onDeselect }: { values: string[]; onDeselect: (v
237
244
  onClick={(event) => event.stopPropagation()}
238
245
  onDismiss={() => onDeselect(tagValue)}
239
246
  >
240
- {tagValue}
247
+ {getOptionLabel ? getOptionLabel(tagValue) : tagValue}
241
248
  </TagDismissible>
242
249
  ))}
243
250
  </>
@@ -14,8 +14,10 @@ import {
14
14
  } from "react-hook-form";
15
15
 
16
16
  import { cn } from "../lib/cn";
17
+ import { Checkbox } from "./checkbox";
17
18
  import { Input, type InputProps } from "./input";
18
19
  import { Label } from "./label";
20
+ import { RadioGroup, RadioGroupItem } from "./radio-group";
19
21
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
20
22
  import { Textarea, type TextareaProps } from "./textarea";
21
23
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
@@ -141,7 +143,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
141
143
  <p
142
144
  data-slot="form-description"
143
145
  id={formDescriptionId}
144
- className={cn("text-muted-foreground text-sm", className)}
146
+ className={cn("text-muted-foreground type-text-sm whitespace-pre-line", className)}
145
147
  {...props}
146
148
  />
147
149
  );
@@ -220,10 +222,10 @@ function InputField<TFieldValues extends FieldValues, TName extends FieldPath<TF
220
222
  {label}
221
223
  </FormLabel>
222
224
  )}
225
+ {description && <FormDescription>{description}</FormDescription>}
223
226
  <FormControl>
224
227
  <Input {...inputProps} {...field} />
225
228
  </FormControl>
226
- {description && <FormDescription>{description}</FormDescription>}
227
229
  <FormMessage />
228
230
  </FormItem>
229
231
  )}
@@ -257,10 +259,10 @@ function TextareaField<TFieldValues extends FieldValues, TName extends FieldPath
257
259
  {label}
258
260
  </FormLabel>
259
261
  )}
262
+ {description && <FormDescription>{description}</FormDescription>}
260
263
  <FormControl>
261
264
  <Textarea {...textareaProps} {...field} />
262
265
  </FormControl>
263
- {description && <FormDescription>{description}</FormDescription>}
264
266
  <FormMessage />
265
267
  </FormItem>
266
268
  )}
@@ -303,6 +305,7 @@ function SelectField<TFieldValues extends FieldValues, TName extends FieldPath<T
303
305
  {label}
304
306
  </FormLabel>
305
307
  )}
308
+ {description && <FormDescription>{description}</FormDescription>}
306
309
  <Select value={field.value} onValueChange={field.onChange}>
307
310
  <FormControl>
308
311
  <SelectTrigger>
@@ -317,7 +320,56 @@ function SelectField<TFieldValues extends FieldValues, TName extends FieldPath<T
317
320
  ))}
318
321
  </SelectContent>
319
322
  </Select>
323
+ <FormMessage />
324
+ </FormItem>
325
+ )}
326
+ />
327
+ );
328
+ }
329
+
330
+ interface RadioFieldProps<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>>
331
+ extends FormFieldBaseProps<TFieldValues, TName> {
332
+ options: SelectFieldOption[];
333
+ }
334
+
335
+ function RadioField<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>>({
336
+ control,
337
+ name,
338
+ rules,
339
+ required,
340
+ label,
341
+ description,
342
+ tooltip,
343
+ options,
344
+ }: RadioFieldProps<TFieldValues, TName>) {
345
+ return (
346
+ <FormField
347
+ control={control}
348
+ name={name}
349
+ rules={rules}
350
+ render={({ field }) => (
351
+ <FormItem>
352
+ {label && (
353
+ <FormLabel required={required} tooltip={tooltip}>
354
+ {label}
355
+ </FormLabel>
356
+ )}
320
357
  {description && <FormDescription>{description}</FormDescription>}
358
+ <FormControl>
359
+ <RadioGroup value={field.value ?? ""} onValueChange={field.onChange} onBlur={field.onBlur}>
360
+ {options.map((option) => {
361
+ const itemId = `${field.name}-${option.value}`;
362
+ return (
363
+ <div key={option.value} className="flex items-center gap-2">
364
+ <RadioGroupItem id={itemId} value={option.value} disabled={option.disabled} />
365
+ <Label htmlFor={itemId} className="type-text-sm font-normal">
366
+ {option.label}
367
+ </Label>
368
+ </div>
369
+ );
370
+ })}
371
+ </RadioGroup>
372
+ </FormControl>
321
373
  <FormMessage />
322
374
  </FormItem>
323
375
  )}
@@ -325,8 +377,116 @@ function SelectField<TFieldValues extends FieldValues, TName extends FieldPath<T
325
377
  );
326
378
  }
327
379
 
380
+ interface ChecklistFieldProps<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>>
381
+ extends FormFieldBaseProps<TFieldValues, TName> {
382
+ options: SelectFieldOption[];
383
+ }
384
+
385
+ function ChecklistField<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>>({
386
+ control,
387
+ name,
388
+ rules,
389
+ required,
390
+ label,
391
+ description,
392
+ tooltip,
393
+ options,
394
+ }: ChecklistFieldProps<TFieldValues, TName>) {
395
+ return (
396
+ <FormField
397
+ control={control}
398
+ name={name}
399
+ rules={rules}
400
+ render={({ field }) => {
401
+ const selected: string[] = Array.isArray(field.value) ? field.value : [];
402
+ const toggle = (value: string, checked: boolean) => {
403
+ if (checked) {
404
+ field.onChange([...selected, value]);
405
+ } else {
406
+ field.onChange(selected.filter((existing) => existing !== value));
407
+ }
408
+ };
409
+ return (
410
+ <FormItem>
411
+ {label && (
412
+ <FormLabel required={required} tooltip={tooltip}>
413
+ {label}
414
+ </FormLabel>
415
+ )}
416
+ {description && <FormDescription>{description}</FormDescription>}
417
+ <FormControl>
418
+ <fieldset className="m-0 flex flex-col gap-2 border-0 p-0" onBlur={field.onBlur}>
419
+ {options.map((option) => {
420
+ const itemId = `${field.name}-${option.value}`;
421
+ const checked = selected.includes(option.value);
422
+ return (
423
+ <div key={option.value} className="flex items-start gap-2">
424
+ <Checkbox
425
+ id={itemId}
426
+ checked={checked}
427
+ onCheckedChange={(next) => toggle(option.value, Boolean(next))}
428
+ disabled={option.disabled}
429
+ />
430
+ <Label htmlFor={itemId} className="type-text-sm font-normal leading-tight">
431
+ {option.label}
432
+ </Label>
433
+ </div>
434
+ );
435
+ })}
436
+ </fieldset>
437
+ </FormControl>
438
+ <FormMessage />
439
+ </FormItem>
440
+ );
441
+ }}
442
+ />
443
+ );
444
+ }
445
+
446
+ interface CheckboxFieldProps<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>>
447
+ extends Omit<FormFieldBaseProps<TFieldValues, TName>, "required"> {}
448
+
449
+ function CheckboxField<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>>({
450
+ control,
451
+ name,
452
+ rules,
453
+ label,
454
+ description,
455
+ tooltip,
456
+ }: CheckboxFieldProps<TFieldValues, TName>) {
457
+ return (
458
+ <FormField
459
+ control={control}
460
+ name={name}
461
+ rules={rules}
462
+ render={({ field }) => (
463
+ <FormItem className="gap-1.5">
464
+ <div className="flex items-start gap-2">
465
+ <FormControl>
466
+ <Checkbox
467
+ checked={field.value ?? false}
468
+ onCheckedChange={(next) => field.onChange(Boolean(next))}
469
+ onBlur={field.onBlur}
470
+ />
471
+ </FormControl>
472
+ {label && (
473
+ <FormLabel tooltip={tooltip} className="font-normal">
474
+ {label}
475
+ </FormLabel>
476
+ )}
477
+ </div>
478
+ {description && <FormDescription className="ml-6">{description}</FormDescription>}
479
+ <FormMessage className="ml-6" />
480
+ </FormItem>
481
+ )}
482
+ />
483
+ );
484
+ }
485
+
328
486
  export type { SelectFieldOption };
329
487
  export {
488
+ CheckboxField,
489
+ ChecklistField,
330
490
  Form,
331
491
  FormControl,
332
492
  FormDescription,
@@ -337,6 +497,7 @@ export {
337
497
  FormLabel,
338
498
  FormMessage,
339
499
  InputField,
500
+ RadioField,
340
501
  SelectField,
341
502
  TextareaField,
342
503
  useFormField,
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import type React from "react";
4
+
5
+ import { cn } from "../lib/cn";
6
+
7
+ const shimmerStyle: React.CSSProperties = {
8
+ background: `linear-gradient(90deg, #0000 calc(50% - 60px), var(--color-foreground), #0000 calc(50% + 60px)), linear-gradient(color-mix(in oklab, var(--color-muted-foreground) 70%, transparent), color-mix(in oklab, var(--color-muted-foreground) 70%, transparent))`,
9
+ backgroundSize: "250% 100%, auto",
10
+ backgroundRepeat: "no-repeat, padding-box",
11
+ WebkitBackgroundClip: "text",
12
+ WebkitTextFillColor: "transparent",
13
+ backgroundClip: "text",
14
+ animation: "shimmer-text 2.5s linear infinite",
15
+ };
16
+
17
+ export function ShimmerText({ children, className }: { children: string | number; className?: string }) {
18
+ return (
19
+ <span className={cn(className)} style={shimmerStyle}>
20
+ {children}
21
+ </span>
22
+ );
23
+ }
@@ -44,7 +44,6 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
44
44
  "border-b border-border-secondary transition-colors",
45
45
  "data-[state=selected]:bg-muted",
46
46
  "[@media(hover:hover)]:hover:bg-background-hover dark:[@media(hover:hover)]:hover:bg-muted",
47
- "[@media(hover:hover)]:[&:hover_button:hover]:bg-subtle",
48
47
  className,
49
48
  )}
50
49
  {...props}
@@ -58,7 +57,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
58
57
  data-slot="table-head"
59
58
  className={cn(
60
59
  "h-11 px-6 py-3 bg-muted text-left align-middle type-text-xs font-semibold text-placeholder dark:text-subtle-foreground whitespace-nowrap",
61
- "[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:pr-3",
60
+ "[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:px-0",
62
61
  className,
63
62
  )}
64
63
  {...props}
@@ -82,7 +81,7 @@ function TableCell({ className, interactive = false, ...props }: TableCellProps)
82
81
  className={cn(
83
82
  "align-middle",
84
83
  interactive ? "h-px p-0" : "px-6 py-2",
85
- "[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:pr-3",
84
+ "[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:px-0",
86
85
  className,
87
86
  )}
88
87
  {...props}
@@ -44,10 +44,10 @@ const tabsTriggerVariants = cva(
44
44
  "data-[state=active]:border-b-2 data-[state=active]:border-foreground data-[state=active]:text-foreground",
45
45
  ],
46
46
  pill: [
47
- "rounded-lg px-4 py-2",
48
- "text-muted-foreground",
49
- "[@media(hover:hover)]:hover:bg-accent [@media(hover:hover)]:hover:text-accent-foreground",
50
- "data-[state=active]:bg-accent data-[state=active]:text-accent-foreground",
47
+ "rounded-md px-2 py-1.5",
48
+ "text-quaternary-foreground",
49
+ "[@media(hover:hover)]:hover:bg-accent [@media(hover:hover)]:hover:text-muted-foreground",
50
+ "data-[state=active]:bg-accent data-[state=active]:text-muted-foreground",
51
51
  ],
52
52
  },
53
53
  },
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ /*
4
+ * TaskProgress — multi-step async progress primitive.
5
+ *
6
+ * Dumb component: the consumer owns orchestration (timer, websocket, polling) and
7
+ * passes `steps` with each step's status. Renders a percentage bar at the top
8
+ * and one row per step with done/running/pending state badges.
9
+ *
10
+ * `trailingLabel` is for "still working on something not in the step list" —
11
+ * shows after the last step as a running row that isn't counted in the percent.
12
+ */
13
+
14
+ import { CheckIcon } from "lucide-react";
15
+
16
+ import { cn } from "../lib/cn";
17
+ import { Separator } from "./separator";
18
+
19
+ type TaskProgressStatus = "pending" | "running" | "done";
20
+
21
+ interface TaskProgressStep {
22
+ id: string;
23
+ label: string;
24
+ status: TaskProgressStatus;
25
+ }
26
+
27
+ interface TaskProgressProps extends Omit<React.ComponentProps<"div">, "children"> {
28
+ steps: readonly TaskProgressStep[];
29
+ trailingLabel?: string;
30
+ /**
31
+ * Optional mount-time stagger: each row fades + slides in with `animation-delay = index × stepRevealDelayMs`.
32
+ * Pure CSS, runs once on mount. Omit for no animation.
33
+ */
34
+ stepRevealDelayMs?: number;
35
+ }
36
+
37
+ const REVEAL_CLASSES = "animate-in fade-in slide-in-from-bottom-2 duration-300";
38
+
39
+ function TaskProgress({ steps, trailingLabel, stepRevealDelayMs, className, ...props }: TaskProgressProps) {
40
+ const total = steps.length;
41
+ const doneCount = steps.filter((step) => step.status === "done").length;
42
+ const percent = total > 0 ? Math.round((doneCount / total) * 100) : 0;
43
+ const stagger = stepRevealDelayMs != null;
44
+
45
+ return (
46
+ <div className={cn("flex flex-col gap-4", className)} {...props}>
47
+ <div className="bg-muted h-2 w-full overflow-hidden rounded-full">
48
+ <div className="h-full rounded-full bg-success transition-all duration-500" style={{ width: `${percent}%` }} />
49
+ </div>
50
+ <div>
51
+ {steps.map((step, idx) => (
52
+ <div
53
+ key={step.id}
54
+ className={stagger ? REVEAL_CLASSES : undefined}
55
+ style={
56
+ stagger ? { animationDelay: `${idx * (stepRevealDelayMs ?? 0)}ms`, animationFillMode: "both" } : undefined
57
+ }
58
+ >
59
+ <TaskProgressRow label={step.label} status={step.status} />
60
+ {idx < steps.length - 1 && <Separator />}
61
+ </div>
62
+ ))}
63
+ {trailingLabel && (
64
+ <div className={stagger ? REVEAL_CLASSES : undefined}>
65
+ <Separator />
66
+ <TaskProgressRow label={trailingLabel} status="running" muted />
67
+ </div>
68
+ )}
69
+ </div>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ interface TaskProgressRowProps {
75
+ label: string;
76
+ status: TaskProgressStatus;
77
+ muted?: boolean;
78
+ }
79
+
80
+ function TaskProgressRow({ label, status, muted }: TaskProgressRowProps) {
81
+ return (
82
+ <div className="flex items-center justify-between py-2">
83
+ <span className={cn("type-text-sm", muted && "text-muted-foreground")}>{label}</span>
84
+ {status === "done" && (
85
+ <span className="flex items-center gap-1 type-text-sm text-success">
86
+ <CheckIcon className="size-3.5" />
87
+ <span>done</span>
88
+ </span>
89
+ )}
90
+ {status === "running" && (
91
+ <span className="flex items-center gap-1.5 type-text-sm text-warning">
92
+ <span className="size-2 rounded-full bg-warning" />
93
+ <span>running…</span>
94
+ </span>
95
+ )}
96
+ {status === "pending" && (
97
+ <span className="flex items-center gap-1.5 type-text-sm text-muted-foreground">
98
+ <span className="size-2 rounded-full border border-muted-foreground" />
99
+ <span>pending</span>
100
+ </span>
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ export type { TaskProgressStatus, TaskProgressStep };
107
+ export { TaskProgress };
@@ -35,7 +35,7 @@ function TooltipContent({
35
35
  className={cn(
36
36
  "bg-inverted text-inverted-foreground dark:bg-subtle dark:text-foreground",
37
37
  "z-50 w-fit rounded-md px-3 py-2 shadow-lg dark:shadow-none dark:drop-shadow-[0_0_0.5px_var(--color-border)]",
38
- "type-text-xs font-semibold text-balance text-center",
38
+ "type-text-xs font-semibold text-balance text-center whitespace-pre-line",
39
39
  "animate-in fade-in-0 zoom-in-95",
40
40
  "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
41
41
  "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ /*
4
+ * Wizard family — primitives for multi-step flows.
5
+ *
6
+ * - WizardSteps — vertical step rail (controlled by activeIdx + onSelect)
7
+ * - WizardProgress — step counter + progress bar
8
+ *
9
+ * Consumers compose these inside whatever container they need (sticky aside, modal, etc.).
10
+ */
11
+
12
+ import type * as React from "react";
13
+
14
+ import { cn } from "../lib/cn";
15
+ import { TabsNav, TabsNavList, TabsNavTrigger } from "./tabs";
16
+
17
+ interface WizardStep {
18
+ id: string;
19
+ label: string;
20
+ }
21
+
22
+ interface WizardStepsProps {
23
+ steps: readonly WizardStep[];
24
+ activeIdx: number;
25
+ onSelect: (idx: number) => void;
26
+ ariaLabel?: string;
27
+ className?: string;
28
+ }
29
+
30
+ function WizardSteps({ steps, activeIdx, onSelect, ariaLabel = "Wizard steps", className }: WizardStepsProps) {
31
+ return (
32
+ <TabsNav orientation="vertical" aria-label={ariaLabel} className={className}>
33
+ <TabsNavList>
34
+ {steps.map((step, idx) => (
35
+ <TabsNavTrigger key={step.id} active={idx === activeIdx} asChild>
36
+ <button type="button" onClick={() => onSelect(idx)} className="w-full justify-start text-left">
37
+ {step.label}
38
+ </button>
39
+ </TabsNavTrigger>
40
+ ))}
41
+ </TabsNavList>
42
+ </TabsNav>
43
+ );
44
+ }
45
+
46
+ interface WizardProgressProps extends React.ComponentProps<"div"> {
47
+ current: number;
48
+ total: number;
49
+ }
50
+
51
+ function WizardProgress({ current, total, className, ...props }: WizardProgressProps) {
52
+ const percent = total > 0 ? Math.round((current / total) * 100) : 0;
53
+ return (
54
+ <div className={cn("flex flex-col gap-1.5 px-2", className)} {...props}>
55
+ <div className="text-muted-foreground flex items-center justify-between text-xs">
56
+ <span>
57
+ Step {current}/{total}
58
+ </span>
59
+ <span>{percent}%</span>
60
+ </div>
61
+ <div className="bg-muted h-1.5 overflow-hidden rounded-full">
62
+ <div className="bg-primary h-full transition-all" style={{ width: `${percent}%` }} />
63
+ </div>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ export type { WizardStep };
69
+ export { WizardProgress, WizardSteps };