@ponti-studios/ui 0.1.2 → 0.3.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.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -7
  3. package/THIRD_PARTY_NOTICES.md +17 -0
  4. package/dist/index.js +1028 -638
  5. package/dist/index.js.map +1 -1
  6. package/package.json +30 -32
  7. package/src/animations.css +16 -434
  8. package/src/components/data-display/accordion.tsx +10 -7
  9. package/src/components/data-display/filter-chip.tsx +2 -2
  10. package/src/components/data-display/index.ts +5 -0
  11. package/src/components/data-display/metric-card.stories.tsx +48 -0
  12. package/src/components/data-display/metric-card.tsx +20 -0
  13. package/src/components/data-display/sort-controls.stories.tsx +45 -0
  14. package/src/components/data-display/sort-controls.tsx +99 -0
  15. package/src/components/data-display/sort-row.tsx +74 -0
  16. package/src/components/data-display/table.tsx +5 -5
  17. package/src/components/feedback/alert.tsx +4 -4
  18. package/src/components/feedback/empty-state.tsx +6 -6
  19. package/src/components/feedback/file-upload-status-badge.stories.tsx +28 -0
  20. package/src/components/feedback/file-upload-status-badge.tsx +33 -0
  21. package/src/components/feedback/file-upload-status.stories.tsx +57 -0
  22. package/src/components/feedback/file-upload-status.tsx +152 -0
  23. package/src/components/feedback/index.ts +9 -0
  24. package/src/components/feedback/progress-bar.stories.tsx +39 -0
  25. package/src/components/feedback/progress-bar.tsx +21 -0
  26. package/src/components/feedback/progress.stories.tsx +8 -6
  27. package/src/components/feedback/progress.tsx +1 -1
  28. package/src/components/feedback/skeleton.stories.tsx +36 -0
  29. package/src/components/feedback/skeleton.tsx +7 -0
  30. package/src/components/feedback/spinner.tsx +2 -2
  31. package/src/components/feedback/update-guard.stories.tsx +32 -0
  32. package/src/components/feedback/update-guard.tsx +280 -0
  33. package/src/components/forms/calendar.tsx +11 -11
  34. package/src/components/forms/date-month-select.stories.tsx +26 -0
  35. package/src/components/forms/date-month-select.tsx +70 -0
  36. package/src/components/forms/date-picker.tsx +3 -3
  37. package/src/components/forms/drop-zone.stories.tsx +56 -0
  38. package/src/components/forms/drop-zone.tsx +146 -0
  39. package/src/components/forms/entity-select.stories.tsx +65 -0
  40. package/src/components/forms/entity-select.tsx +79 -0
  41. package/src/components/forms/field.tsx +3 -3
  42. package/src/components/forms/group-by-select.stories.tsx +26 -0
  43. package/src/components/forms/group-by-select.tsx +47 -0
  44. package/src/components/forms/index.ts +10 -0
  45. package/src/components/forms/input.stories.tsx +4 -4
  46. package/src/components/forms/input.tsx +1 -1
  47. package/src/components/forms/passkey-management.stories.tsx +81 -0
  48. package/src/components/forms/passkey-management.tsx +138 -0
  49. package/src/components/forms/radio-group.tsx +14 -13
  50. package/src/components/forms/select.stories.tsx +4 -4
  51. package/src/components/forms/select.tsx +45 -53
  52. package/src/components/forms/slider.stories.tsx +5 -2
  53. package/src/components/forms/slider.tsx +27 -17
  54. package/src/components/forms/stepper.tsx +4 -4
  55. package/src/components/forms/switch.stories.tsx +1 -1
  56. package/src/components/forms/switch.tsx +2 -2
  57. package/src/components/forms/textarea.stories.tsx +2 -2
  58. package/src/components/forms/textarea.tsx +1 -1
  59. package/src/components/layout/index.ts +3 -0
  60. package/src/components/layout/scroll-area.stories.tsx +4 -4
  61. package/src/components/layout/scroll-area.tsx +23 -21
  62. package/src/components/layout/section-intro.stories.tsx +29 -0
  63. package/src/components/layout/section-intro.tsx +31 -0
  64. package/src/components/layout/surface-panel.stories.tsx +32 -0
  65. package/src/components/layout/surface-panel.tsx +15 -0
  66. package/src/components/navigation/app-navigation.stories.tsx +2 -2
  67. package/src/components/navigation/app-navigation.tsx +13 -13
  68. package/src/components/navigation/index.ts +4 -0
  69. package/src/components/navigation/pagination-controls.stories.tsx +41 -0
  70. package/src/components/navigation/pagination-controls.tsx +50 -0
  71. package/src/components/navigation/route-link.stories.tsx +30 -0
  72. package/src/components/navigation/route-link.tsx +42 -0
  73. package/src/components/navigation/tabs.stories.tsx +5 -5
  74. package/src/components/navigation/tabs.tsx +7 -7
  75. package/src/components/overlays/alert-dialog.tsx +21 -46
  76. package/src/components/overlays/command.tsx +51 -82
  77. package/src/components/overlays/dialog.tsx +5 -5
  78. package/src/components/overlays/dropdown-menu.stories.tsx +0 -5
  79. package/src/components/overlays/dropdown-menu.tsx +44 -142
  80. package/src/components/overlays/popover.stories.tsx +2 -2
  81. package/src/components/overlays/popover.tsx +34 -16
  82. package/src/components/overlays/sheet.stories.tsx +2 -2
  83. package/src/components/overlays/sheet.tsx +35 -17
  84. package/src/components/primitives/avatar.tsx +3 -3
  85. package/src/components/primitives/badge.tsx +7 -11
  86. package/src/components/primitives/button.tsx +41 -13
  87. package/src/components/primitives/card.stories.tsx +2 -2
  88. package/src/components/primitives/card.tsx +2 -2
  89. package/src/components/primitives/index.ts +2 -0
  90. package/src/components/primitives/label.stories.tsx +1 -1
  91. package/src/components/primitives/label.tsx +5 -6
  92. package/src/components/primitives/status-badge.stories.tsx +52 -0
  93. package/src/components/primitives/status-badge.tsx +33 -0
  94. package/src/constants/chart-colors.ts +5 -5
  95. package/src/hooks/index.ts +1 -0
  96. package/src/hooks/use-sort.ts +20 -0
  97. package/src/index.ts +30 -11
  98. package/src/styles.css +76 -1040
  99. package/src/tokens/color-systems.stories.tsx +9 -119
  100. package/src/tokens/foundations.stories.tsx +21 -113
  101. package/src/tokens/generated/dark.colors.generated.ts +56 -0
  102. package/src/tokens/generated/dark.css +54 -0
  103. package/src/tokens/generated/foundations.css +148 -0
  104. package/src/tokens/generated/foundations.generated.ts +443 -0
  105. package/src/tokens/generated/light.colors.generated.ts +56 -0
  106. package/src/tokens/generated/light.css +54 -0
  107. package/src/tokens/index.ts +74 -16
  108. package/src/tokens/layout.stories.tsx +3 -108
  109. package/src/tokens/colors.ts +0 -156
  110. package/src/tokens/motion.ts +0 -51
  111. package/src/tokens/radii.ts +0 -10
  112. package/src/tokens/shadows.ts +0 -34
  113. package/src/tokens/spacing.ts +0 -30
  114. package/src/tokens/typography.ts +0 -42
@@ -0,0 +1,20 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+
3
+ import { cn } from "../../lib/utils";
4
+
5
+ interface MetricCardProps extends ComponentProps<"div"> {
6
+ label: string;
7
+ value: ReactNode;
8
+ change?: ReactNode;
9
+ }
10
+
11
+ /** Label/value/change stat tile. */
12
+ export function MetricCard({ label, value, change, className, ...props }: MetricCardProps) {
13
+ return (
14
+ <div className={cn("ui-flat-card", className)} {...props}>
15
+ <p className="ui-data-label mb-2">{label}</p>
16
+ <p className="ui-data-value">{value ?? "—"}</p>
17
+ {change != null ? <p className="text-muted-foreground mt-1.5 text-xs">{change}</p> : null}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+
4
+ import type { SortOption } from "../../hooks/sort.types";
5
+ import { SortControls } from "./sort-controls";
6
+
7
+ const meta = {
8
+ title: "DataDisplay/SortControls",
9
+ component: SortControls,
10
+ tags: ["autodocs"],
11
+ } satisfies Meta<typeof SortControls>;
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ const sortableFields = ["amount", "date", "description", "category"];
17
+
18
+ function SortControlsPreview() {
19
+ const [sortOptions, setSortOptions] = useState<SortOption[]>([
20
+ { field: "date", direction: "desc" },
21
+ ]);
22
+
23
+ return (
24
+ <SortControls
25
+ sortOptions={sortOptions}
26
+ sortableFields={sortableFields}
27
+ addSortOption={(option) => setSortOptions((prev) => [...prev, option])}
28
+ updateSortOption={(index, option) =>
29
+ setSortOptions((prev) => prev.map((o, i) => (i === index ? option : o)))
30
+ }
31
+ removeSortOption={(index) => setSortOptions((prev) => prev.filter((_, i) => i !== index))}
32
+ />
33
+ );
34
+ }
35
+
36
+ export const Default: Story = {
37
+ args: {
38
+ sortOptions: [],
39
+ sortableFields,
40
+ addSortOption: () => {},
41
+ updateSortOption: () => {},
42
+ removeSortOption: () => {},
43
+ },
44
+ render: () => <SortControlsPreview />,
45
+ };
@@ -0,0 +1,99 @@
1
+ import { ListOrdered, PlusCircle } from "lucide-react";
2
+
3
+ import type { SortField, SortOption } from "../../hooks/sort.types";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuGroup,
8
+ DropdownMenuItem,
9
+ DropdownMenuLabel,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from "../overlays/dropdown-menu";
13
+ import { Button } from "../primitives/button";
14
+ import { SortRow } from "./sort-row";
15
+
16
+ export interface SortControlsProps {
17
+ sortOptions: SortOption[];
18
+ sortableFields: SortField[];
19
+ addSortOption: (option: SortOption) => void;
20
+ updateSortOption: (index: number, option: SortOption) => void;
21
+ removeSortOption: (index: number) => void;
22
+ open?: boolean | undefined;
23
+ onOpenChange?: ((open: boolean) => void) | undefined;
24
+ focusedSortIndex?: (number | null) | undefined;
25
+ }
26
+
27
+ /** Multi-field sort builder, driven by the `SortOption`/`SortField` shared types. */
28
+ export function SortControls({
29
+ sortOptions,
30
+ sortableFields,
31
+ addSortOption,
32
+ updateSortOption,
33
+ removeSortOption,
34
+ open,
35
+ onOpenChange,
36
+ focusedSortIndex,
37
+ }: SortControlsProps) {
38
+ const availableFieldsToAdd = sortableFields.filter(
39
+ (field) => !sortOptions.some((option) => option.field === field),
40
+ );
41
+
42
+ return (
43
+ <DropdownMenu {...(open !== undefined && { open })} {...(onOpenChange && { onOpenChange })}>
44
+ <DropdownMenuTrigger asChild>
45
+ <Button variant="outline">
46
+ <ListOrdered className="mr-2 size-4" />
47
+ Sort
48
+ </Button>
49
+ </DropdownMenuTrigger>
50
+ <DropdownMenuContent className="w-80 space-y-2 p-2">
51
+ <DropdownMenuLabel>Define Sort Order</DropdownMenuLabel>
52
+ <DropdownMenuSeparator />
53
+
54
+ <DropdownMenuGroup className="space-y-1">
55
+ {sortOptions.map((sort, index) => {
56
+ const usedFields = sortOptions
57
+ .filter((_, i) => i !== index)
58
+ .map((option) => option.field);
59
+ return (
60
+ <DropdownMenuItem
61
+ key={sort.field}
62
+ onSelect={(e) => e.preventDefault()}
63
+ className={`p-0 focus:bg-transparent ${index === focusedSortIndex ? "bg-accent" : ""}`}
64
+ >
65
+ <SortRow
66
+ sortOption={sort}
67
+ index={index}
68
+ sortableFields={sortableFields}
69
+ usedFields={usedFields}
70
+ updateSortOption={updateSortOption}
71
+ removeSortOption={removeSortOption}
72
+ />
73
+ </DropdownMenuItem>
74
+ );
75
+ })}
76
+ </DropdownMenuGroup>
77
+ {availableFieldsToAdd.length > 0 && (
78
+ <>
79
+ <DropdownMenuSeparator />
80
+ <DropdownMenuItem
81
+ onSelect={(e) => {
82
+ e.preventDefault();
83
+ const firstAvailable = availableFieldsToAdd[0];
84
+ if (firstAvailable) {
85
+ addSortOption({ field: firstAvailable, direction: "desc" });
86
+ }
87
+ }}
88
+ disabled={availableFieldsToAdd.length === 0}
89
+ className="mt-1"
90
+ >
91
+ <PlusCircle className="mr-2 size-4" />
92
+ Add Sort Criterion
93
+ </DropdownMenuItem>
94
+ </>
95
+ )}
96
+ </DropdownMenuContent>
97
+ </DropdownMenu>
98
+ );
99
+ }
@@ -0,0 +1,74 @@
1
+ import type { SortField, SortOption } from "../../hooks/sort.types";
2
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../forms/select";
3
+ import { Button } from "../primitives/button";
4
+ import { X } from "lucide-react";
5
+
6
+ export interface SortRowProps {
7
+ sortOption: SortOption;
8
+ index: number;
9
+ sortableFields: SortField[];
10
+ usedFields: SortField[];
11
+ updateSortOption: (index: number, option: SortOption) => void;
12
+ removeSortOption: (index: number) => void;
13
+ }
14
+
15
+ export function SortRow({
16
+ sortOption,
17
+ index,
18
+ sortableFields,
19
+ usedFields,
20
+ updateSortOption,
21
+ removeSortOption,
22
+ }: SortRowProps) {
23
+ const availableFieldsForCurrentSelect = sortableFields.filter(
24
+ (field) => !usedFields.includes(field) || field === sortOption.field,
25
+ );
26
+
27
+ return (
28
+ <div key={sortOption.field} className="flex items-center gap-2">
29
+ <Select
30
+ value={sortOption.field}
31
+ onValueChange={(value) => updateSortOption(index, { ...sortOption, field: value })}
32
+ >
33
+ <SelectTrigger className="h-8 w-[130px] text-xs">
34
+ <SelectValue placeholder="Select field" />
35
+ </SelectTrigger>
36
+ <SelectContent>
37
+ {availableFieldsForCurrentSelect.map((field) => (
38
+ <SelectItem key={field} value={field} className="text-xs">
39
+ {field.charAt(0).toUpperCase() + field.slice(1)}
40
+ </SelectItem>
41
+ ))}
42
+ </SelectContent>
43
+ </Select>
44
+ <Select
45
+ value={sortOption.direction}
46
+ onValueChange={(value) => {
47
+ if (value === "asc" || value === "desc") {
48
+ updateSortOption(index, { ...sortOption, direction: value });
49
+ }
50
+ }}
51
+ >
52
+ <SelectTrigger className="h-8 w-[110px] text-xs">
53
+ <SelectValue placeholder="Direction" />
54
+ </SelectTrigger>
55
+ <SelectContent>
56
+ <SelectItem value="asc" className="text-xs">
57
+ Ascending
58
+ </SelectItem>
59
+ <SelectItem value="desc" className="text-xs">
60
+ Descending
61
+ </SelectItem>
62
+ </SelectContent>
63
+ </Select>
64
+ <Button
65
+ variant="ghost"
66
+ size="sm"
67
+ onClick={() => removeSortOption(index)}
68
+ className="size-8 p-0"
69
+ >
70
+ <X className="text-muted-foreground size-4" />
71
+ </Button>
72
+ </div>
73
+ );
74
+ }
@@ -5,7 +5,7 @@ import { cn } from "../../lib/utils";
5
5
  const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
6
6
  ({ className, ...props }, ref) => (
7
7
  <div className="w-full overflow-auto">
8
- <table ref={ref} className={cn("body-3 w-full caption-bottom", className)} {...props} />
8
+ <table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
9
9
  </div>
10
10
  ),
11
11
  );
@@ -31,7 +31,7 @@ const TableFooter = React.forwardRef<
31
31
  HTMLTableSectionElement,
32
32
  React.HTMLAttributes<HTMLTableSectionElement>
33
33
  >(({ className, ...props }, ref) => (
34
- <tfoot ref={ref} className={cn("bg-accent text-on-accent font-medium", className)} {...props} />
34
+ <tfoot ref={ref} className={cn("bg-muted font-medium", className)} {...props} />
35
35
  ));
36
36
  TableFooter.displayName = "TableFooter";
37
37
 
@@ -39,7 +39,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
39
39
  ({ className, ...props }, ref) => (
40
40
  <tr
41
41
  ref={ref}
42
- className={cn("data-[state=selected]:bg-inset border-default border-b", className)}
42
+ className={cn("data-[state=selected]:bg-muted border-b", className)}
43
43
  {...props}
44
44
  />
45
45
  ),
@@ -53,7 +53,7 @@ const TableHead = React.forwardRef<
53
53
  <th
54
54
  ref={ref}
55
55
  className={cn(
56
- "ui-eyebrow h-9 px-3 text-left align-middle [&:has([role=checkbox])]:pr-0",
56
+ "h-9 px-3 text-left align-middle text-xs font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
57
57
  className,
58
58
  )}
59
59
  {...props}
@@ -77,7 +77,7 @@ const TableCaption = React.forwardRef<
77
77
  HTMLTableCaptionElement,
78
78
  React.HTMLAttributes<HTMLTableCaptionElement>
79
79
  >(({ className, ...props }, ref) => (
80
- <caption ref={ref} className={cn("body-4 text-secondary mt-4", className)} {...props} />
80
+ <caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
81
81
  ));
82
82
  TableCaption.displayName = "TableCaption";
83
83
 
@@ -8,9 +8,9 @@ const alertVariants = cva(
8
8
  {
9
9
  variants: {
10
10
  variant: {
11
- default: "bg-panel text-primary",
11
+ default: "bg-card text-card-foreground",
12
12
  destructive:
13
- "text-destructive bg-panel [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
13
+ "border-destructive/50 text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14
14
  },
15
15
  },
16
16
  defaultVariants: {
@@ -38,7 +38,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38
38
  return (
39
39
  <div
40
40
  data-slot="alert-title"
41
- className={cn("subheading-4 col-start-2 line-clamp-1 min-h-4", className)}
41
+ className={cn("col-start-2 line-clamp-1 min-h-4 text-sm font-medium", className)}
42
42
  {...props}
43
43
  />
44
44
  );
@@ -49,7 +49,7 @@ function AlertDescription({ className, ...props }: React.ComponentProps<"div">)
49
49
  <div
50
50
  data-slot="alert-description"
51
51
  className={cn(
52
- "text-secondary body-3 col-start-2 grid justify-items-start gap-1 [&_p]:leading-relaxed",
52
+ "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
53
53
  className,
54
54
  )}
55
55
  {...props}
@@ -37,9 +37,9 @@ export function EmptyState({
37
37
  <section
38
38
  className={cn(
39
39
  "rounded-lg px-5 py-8",
40
- variant === "default" && "border-subtle bg-panel border",
41
- variant === "dashed" && "border-subtle bg-panel border border-dashed",
42
- variant === "search" && "border-subtle bg-surface border border-dashed",
40
+ variant === "default" && "border bg-card",
41
+ variant === "dashed" && "border bg-card border-dashed",
42
+ variant === "search" && "border bg-muted border-dashed",
43
43
  variant === "quiet" && "bg-surface",
44
44
  centered
45
45
  ? cn(
@@ -51,13 +51,13 @@ export function EmptyState({
51
51
  )}
52
52
  >
53
53
  {icon ? (
54
- <div className="text-secondary mb-3 flex size-11 items-center justify-center" aria-hidden>
54
+ <div className="text-muted-foreground mb-3 flex size-11 items-center justify-center" aria-hidden>
55
55
  {icon}
56
56
  </div>
57
57
  ) : null}
58
58
  <div className={cn("space-y-2", centered && "max-w-[34ch]")}>
59
- <h2 className={cn("text-primary", large ? "heading-3" : "heading-4")}>{title}</h2>
60
- {description ? <p className="body-3 text-secondary">{description}</p> : null}
59
+ <h2 className={cn("text-foreground font-semibold tracking-tight", large ? "text-lg" : "text-base")}>{title}</h2>
60
+ {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
61
61
  </div>
62
62
  {children ? <div className="mt-3">{children}</div> : null}
63
63
  {action ? <div className="mt-4">{action}</div> : null}
@@ -0,0 +1,28 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { FileUploadStatusBadge } from "./file-upload-status-badge";
4
+
5
+ const meta = {
6
+ title: "Feedback/FileUploadStatusBadge",
7
+ component: FileUploadStatusBadge,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof FileUploadStatusBadge>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ export const Default: Story = {
15
+ args: { status: "uploading" },
16
+ };
17
+
18
+ export const AllStatuses: Story = {
19
+ render: () => (
20
+ <div className="flex flex-wrap gap-2">
21
+ <FileUploadStatusBadge status="uploading" />
22
+ <FileUploadStatusBadge status="processing" />
23
+ <FileUploadStatusBadge status="queued" />
24
+ <FileUploadStatusBadge status="done" />
25
+ <FileUploadStatusBadge status="error" />
26
+ </div>
27
+ ),
28
+ };
@@ -0,0 +1,33 @@
1
+ import { memo } from "react";
2
+
3
+ import { cn } from "../../lib/utils";
4
+
5
+ export type FileUploadStatusValue = "uploading" | "processing" | "queued" | "done" | "error";
6
+
7
+ const STATUS_CONFIG: Record<FileUploadStatusValue, { bg: string; text: string; label: string }> = {
8
+ uploading: { bg: "bg-muted", text: "text-foreground", label: "Uploading" },
9
+ processing: { bg: "bg-muted", text: "text-foreground", label: "Processing" },
10
+ queued: { bg: "bg-secondary", text: "text-secondary-foreground", label: "Queued" },
11
+ done: { bg: "bg-accent", text: "text-accent-foreground", label: "Complete" },
12
+ error: { bg: "bg-destructive/10", text: "text-destructive", label: "Error" },
13
+ };
14
+
15
+ export const FileUploadStatusBadge = memo(function FileUploadStatusBadge({
16
+ status,
17
+ }: {
18
+ status?: string | undefined;
19
+ }) {
20
+ if (!status) return null;
21
+
22
+ const config = STATUS_CONFIG[status as FileUploadStatusValue] ?? {
23
+ bg: "bg-muted",
24
+ text: "text-foreground",
25
+ label: status,
26
+ };
27
+
28
+ return (
29
+ <span className={cn("px-2 py-1 text-xs font-medium", config.bg, config.text)}>
30
+ {config.label}
31
+ </span>
32
+ );
33
+ });
@@ -0,0 +1,57 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { FileUploadStatus } from "./file-upload-status";
4
+
5
+ const meta = {
6
+ title: "Feedback/FileUploadStatus",
7
+ component: FileUploadStatus,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof FileUploadStatus>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ export const Uploading: Story = {
15
+ render: () => (
16
+ <div className="w-80">
17
+ <FileUploadStatus uploadStatus={{ status: "uploading", stats: { progress: 42 } }} />
18
+ </div>
19
+ ),
20
+ };
21
+
22
+ export const Queued: Story = {
23
+ render: () => (
24
+ <div className="w-80">
25
+ <FileUploadStatus uploadStatus={{ status: "queued" }} />
26
+ </div>
27
+ ),
28
+ };
29
+
30
+ export const Done: Story = {
31
+ render: () => (
32
+ <div className="w-80">
33
+ <FileUploadStatus
34
+ uploadStatus={{
35
+ status: "done",
36
+ stats: {
37
+ processingTime: 4200,
38
+ total: 128,
39
+ created: 90,
40
+ updated: 30,
41
+ skipped: 8,
42
+ },
43
+ }}
44
+ />
45
+ </div>
46
+ ),
47
+ };
48
+
49
+ export const Error: Story = {
50
+ render: () => (
51
+ <div className="w-80">
52
+ <FileUploadStatus
53
+ uploadStatus={{ status: "error", error: "Could not parse the CSV file." }}
54
+ />
55
+ </div>
56
+ ),
57
+ };
@@ -0,0 +1,152 @@
1
+ import { XIcon } from "lucide-react";
2
+ import { memo } from "react";
3
+
4
+ import { cn } from "../../lib/utils";
5
+ import { ProgressBar } from "./progress-bar";
6
+
7
+ export interface FileUploadStats {
8
+ progress?: number;
9
+ processingTime?: number;
10
+ total?: number;
11
+ created?: number;
12
+ updated?: number;
13
+ skipped?: number;
14
+ merged?: number;
15
+ invalid?: number;
16
+ errors?: unknown[];
17
+ }
18
+
19
+ export interface FileUploadState {
20
+ status: "uploading" | "processing" | "queued" | "done" | "error" | (string & {});
21
+ error?: string;
22
+ stats?: FileUploadStats;
23
+ }
24
+
25
+ export const FileUploadStatus = memo(function FileUploadStatus({
26
+ uploadStatus,
27
+ }: {
28
+ uploadStatus?: FileUploadState;
29
+ }) {
30
+ if (!uploadStatus) return null;
31
+ const { status, error, stats } = uploadStatus;
32
+
33
+ if (status === "uploading" || status === "processing") {
34
+ const progress = stats?.progress;
35
+
36
+ return (
37
+ <output className="w-full">
38
+ <div className="mb-1.5 flex items-center justify-between">
39
+ <span className="text-muted-foreground text-sm font-medium">
40
+ {status === "uploading" ? "Uploading" : "Processing"}
41
+ </span>
42
+ <span className="text-foreground text-sm font-medium">
43
+ {progress !== undefined ? `${Math.round(progress)}%` : ""}
44
+ </span>
45
+ </div>
46
+ <div className="w-full">
47
+ {typeof progress === "number" ? (
48
+ <ProgressBar
49
+ progress={progress}
50
+ className={cn("bg-muted h-2", "before:bg-emphasis-high")}
51
+ />
52
+ ) : (
53
+ <div className="bg-muted h-2 overflow-hidden">
54
+ <div className="bg-emphasis-high void-anim-breezy-progress h-full" />
55
+ </div>
56
+ )}
57
+ </div>
58
+ </output>
59
+ );
60
+ }
61
+
62
+ if (status === "queued") {
63
+ return (
64
+ <output className="w-full">
65
+ <div className="mb-1.5 flex items-center justify-between">
66
+ <span className="text-muted-foreground text-sm font-medium">Queue position</span>
67
+ </div>
68
+ <div className="w-full">
69
+ <ProgressBar progress={0} className="bg-warning-subtle before:bg-warning h-2" />
70
+ </div>
71
+ </output>
72
+ );
73
+ }
74
+
75
+ if (status === "done") {
76
+ return (
77
+ <output className="space-y-3">
78
+ <div className="w-full">
79
+ <ProgressBar progress={100} className="bg-emphasis-minimal before:bg-emphasis-high h-2" />
80
+ </div>
81
+ {stats && <ProcessingStats stats={stats} />}
82
+ </output>
83
+ );
84
+ }
85
+
86
+ if (status === "error") {
87
+ return (
88
+ <output className="mt-2" role="alert">
89
+ <div className="bg-destructive/10 text-destructive flex items-start gap-2 p-3">
90
+ <XIcon className="mt-0.5 h-5 w-5 flex-shrink-0" aria-hidden="true" />
91
+ <span className="text-sm">{error}</span>
92
+ </div>
93
+ </output>
94
+ );
95
+ }
96
+
97
+ return <output className="text-muted-foreground text-sm">{status}</output>;
98
+ });
99
+
100
+ const ProcessingStats = memo(function ProcessingStats({ stats }: { stats: FileUploadStats }) {
101
+ if (!stats || typeof stats !== "object") return null;
102
+
103
+ return (
104
+ <dl className="divide-border mt-3 divide-y">
105
+ {stats.processingTime !== undefined && (
106
+ <div className="py-2 first:pt-0 last:pb-0">
107
+ <dt className="sr-only">Processing time</dt>
108
+ <dd className="text-foreground text-sm font-medium">
109
+ Completed in {(stats.processingTime / 1000).toFixed(1)}s
110
+ </dd>
111
+ </div>
112
+ )}
113
+ {stats.total !== undefined && Object.values(stats).length ? (
114
+ <>
115
+ <ProcessingStat label="Total" value={stats.total} />
116
+ <ProcessingStat label="Created" value={stats.created} />
117
+ <ProcessingStat label="Updated" value={stats.updated} />
118
+ <ProcessingStat label="Skipped" value={stats.skipped} />
119
+ <ProcessingStat label="Merged" value={stats.merged} />
120
+ <ProcessingStat label="Invalid" value={stats.invalid} />
121
+ </>
122
+ ) : null}
123
+
124
+ {stats.errors?.length && stats.errors.length > 0 ? (
125
+ <div className="py-2">
126
+ <dt className="text-foreground text-sm font-medium">Errors</dt>
127
+ <dd className="mt-1">
128
+ <span className="bg-destructive/10 text-destructive inline-flex items-center px-2 py-1 text-xs font-medium">
129
+ {stats.errors.length} error{stats.errors.length > 1 ? "s" : ""}
130
+ </span>
131
+ </dd>
132
+ </div>
133
+ ) : null}
134
+ </dl>
135
+ );
136
+ });
137
+
138
+ const ProcessingStat = memo(function ProcessingStat({
139
+ label,
140
+ value,
141
+ }: {
142
+ label: string;
143
+ value?: number | undefined;
144
+ }) {
145
+ if (value === undefined) return null;
146
+ return (
147
+ <div className="py-2">
148
+ <dt className="text-muted-foreground text-sm font-medium">{label}</dt>
149
+ <dd className="text-foreground mt-1 text-sm">{value.toLocaleString()}</dd>
150
+ </div>
151
+ );
152
+ });
@@ -1,6 +1,15 @@
1
1
  export { Alert, AlertDescription, AlertTitle } from "./alert";
2
2
  export { EmptyState } from "./empty-state";
3
3
  export type { EmptyStateProps } from "./empty-state";
4
+ export { FileUploadStatus } from "./file-upload-status";
5
+ export type { FileUploadState, FileUploadStats } from "./file-upload-status";
6
+ export { FileUploadStatusBadge } from "./file-upload-status-badge";
7
+ export type { FileUploadStatusValue } from "./file-upload-status-badge";
4
8
  export { Progress } from "./progress";
9
+ export { ProgressBar } from "./progress-bar";
10
+ export type { ProgressBarProps } from "./progress-bar";
11
+ export { Skeleton } from "./skeleton";
5
12
  export { Spinner } from "./spinner";
6
13
  export type { SpinnerProps } from "./spinner";
14
+ export { UpdateGuard } from "./update-guard";
15
+ export type { UpdateGuardCopy, UpdateGuardProps } from "./update-guard";
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { ProgressBar } from "./progress-bar";
4
+
5
+ const meta = {
6
+ title: "Feedback/ProgressBar",
7
+ component: ProgressBar,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof ProgressBar>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ export const Default: Story = {
15
+ args: { progress: 60 },
16
+ render: (args) => (
17
+ <div className="w-64">
18
+ <ProgressBar {...args} />
19
+ </div>
20
+ ),
21
+ };
22
+
23
+ export const Empty: Story = {
24
+ args: { progress: 0 },
25
+ render: (args) => (
26
+ <div className="w-64">
27
+ <ProgressBar {...args} />
28
+ </div>
29
+ ),
30
+ };
31
+
32
+ export const Complete: Story = {
33
+ args: { progress: 100 },
34
+ render: (args) => (
35
+ <div className="w-64">
36
+ <ProgressBar {...args} />
37
+ </div>
38
+ ),
39
+ };