@ponti-studios/ui 0.1.2 → 0.4.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 (116) 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 +988 -640
  5. package/dist/index.js.map +1 -1
  6. package/package.json +41 -41
  7. package/src/animations.css +42 -383
  8. package/src/components/data-display/accordion.tsx +22 -10
  9. package/src/components/data-display/filter-chip.stories.tsx +38 -0
  10. package/src/components/data-display/filter-chip.tsx +10 -4
  11. package/src/components/data-display/index.ts +5 -0
  12. package/src/components/data-display/metric-card.stories.tsx +48 -0
  13. package/src/components/data-display/metric-card.tsx +20 -0
  14. package/src/components/data-display/sort-controls.stories.tsx +45 -0
  15. package/src/components/data-display/sort-controls.tsx +99 -0
  16. package/src/components/data-display/sort-row.tsx +74 -0
  17. package/src/components/data-display/table.tsx +5 -9
  18. package/src/components/feedback/alert.tsx +4 -4
  19. package/src/components/feedback/empty-state.tsx +16 -6
  20. package/src/components/feedback/file-upload-status-badge.stories.tsx +28 -0
  21. package/src/components/feedback/file-upload-status-badge.tsx +33 -0
  22. package/src/components/feedback/file-upload-status.stories.tsx +57 -0
  23. package/src/components/feedback/file-upload-status.tsx +152 -0
  24. package/src/components/feedback/index.ts +9 -0
  25. package/src/components/feedback/progress-bar.stories.tsx +39 -0
  26. package/src/components/feedback/progress-bar.tsx +21 -0
  27. package/src/components/feedback/progress.stories.tsx +8 -6
  28. package/src/components/feedback/progress.tsx +3 -3
  29. package/src/components/feedback/skeleton.stories.tsx +36 -0
  30. package/src/components/feedback/skeleton.tsx +7 -0
  31. package/src/components/feedback/spinner.tsx +2 -2
  32. package/src/components/feedback/update-guard.stories.tsx +32 -0
  33. package/src/components/feedback/update-guard.tsx +280 -0
  34. package/src/components/forms/calendar.tsx +14 -11
  35. package/src/components/forms/date-month-select.stories.tsx +26 -0
  36. package/src/components/forms/date-month-select.tsx +70 -0
  37. package/src/components/forms/date-picker.tsx +3 -3
  38. package/src/components/forms/drop-zone.stories.tsx +56 -0
  39. package/src/components/forms/drop-zone.tsx +146 -0
  40. package/src/components/forms/entity-select.stories.tsx +65 -0
  41. package/src/components/forms/entity-select.tsx +79 -0
  42. package/src/components/forms/field.tsx +3 -3
  43. package/src/components/forms/group-by-select.stories.tsx +26 -0
  44. package/src/components/forms/group-by-select.tsx +47 -0
  45. package/src/components/forms/index.ts +10 -0
  46. package/src/components/forms/input.stories.tsx +4 -4
  47. package/src/components/forms/input.tsx +1 -1
  48. package/src/components/forms/passkey-management.stories.tsx +81 -0
  49. package/src/components/forms/passkey-management.tsx +138 -0
  50. package/src/components/forms/radio-group.tsx +14 -13
  51. package/src/components/forms/select.stories.tsx +4 -4
  52. package/src/components/forms/select.tsx +47 -48
  53. package/src/components/forms/slider.stories.tsx +5 -2
  54. package/src/components/forms/slider.tsx +39 -17
  55. package/src/components/forms/stepper.tsx +4 -4
  56. package/src/components/forms/switch.stories.tsx +1 -1
  57. package/src/components/forms/switch.tsx +2 -2
  58. package/src/components/forms/textarea.stories.tsx +2 -2
  59. package/src/components/forms/textarea.tsx +1 -1
  60. package/src/components/layout/index.ts +3 -0
  61. package/src/components/layout/scroll-area.stories.tsx +4 -4
  62. package/src/components/layout/scroll-area.tsx +23 -21
  63. package/src/components/layout/section-intro.stories.tsx +29 -0
  64. package/src/components/layout/section-intro.tsx +31 -0
  65. package/src/components/layout/surface-panel.stories.tsx +32 -0
  66. package/src/components/layout/surface-panel.tsx +15 -0
  67. package/src/components/navigation/app-navigation.stories.tsx +2 -2
  68. package/src/components/navigation/app-navigation.tsx +13 -13
  69. package/src/components/navigation/index.ts +4 -0
  70. package/src/components/navigation/pagination-controls.stories.tsx +41 -0
  71. package/src/components/navigation/pagination-controls.tsx +50 -0
  72. package/src/components/navigation/route-link.stories.tsx +30 -0
  73. package/src/components/navigation/route-link.tsx +42 -0
  74. package/src/components/navigation/tabs.stories.tsx +5 -5
  75. package/src/components/navigation/tabs.tsx +8 -8
  76. package/src/components/overlays/alert-dialog.tsx +22 -25
  77. package/src/components/overlays/command.tsx +98 -26
  78. package/src/components/overlays/dialog.tsx +5 -5
  79. package/src/components/overlays/dropdown-menu.stories.tsx +0 -5
  80. package/src/components/overlays/dropdown-menu.tsx +49 -31
  81. package/src/components/overlays/popover.stories.tsx +2 -2
  82. package/src/components/overlays/popover.tsx +43 -14
  83. package/src/components/overlays/sheet.stories.tsx +2 -2
  84. package/src/components/overlays/sheet.tsx +51 -17
  85. package/src/components/primitives/avatar.tsx +9 -5
  86. package/src/components/primitives/badge.tsx +11 -12
  87. package/src/components/primitives/button.tsx +49 -23
  88. package/src/components/primitives/card.stories.tsx +2 -2
  89. package/src/components/primitives/card.tsx +2 -5
  90. package/src/components/primitives/index.ts +2 -0
  91. package/src/components/primitives/label.stories.tsx +1 -1
  92. package/src/components/primitives/label.tsx +5 -6
  93. package/src/components/primitives/status-badge.stories.tsx +52 -0
  94. package/src/components/primitives/status-badge.tsx +33 -0
  95. package/src/constants/chart-colors.ts +7 -7
  96. package/src/hooks/index.ts +1 -0
  97. package/src/hooks/use-sort.ts +20 -0
  98. package/src/index.ts +30 -11
  99. package/src/styles.css +100 -974
  100. package/src/tokens/chart-colors.stories.tsx +192 -0
  101. package/src/tokens/color-systems.stories.tsx +20 -112
  102. package/src/tokens/foundations.stories.tsx +58 -100
  103. package/src/tokens/generated/dark.colors.generated.ts +29 -0
  104. package/src/tokens/generated/dark.css +29 -0
  105. package/src/tokens/generated/foundations.css +148 -0
  106. package/src/tokens/generated/foundations.generated.ts +451 -0
  107. package/src/tokens/generated/light.colors.generated.ts +29 -0
  108. package/src/tokens/generated/light.css +29 -0
  109. package/src/tokens/index.ts +79 -16
  110. package/src/tokens/layout.stories.tsx +23 -91
  111. package/src/tokens/colors.ts +0 -156
  112. package/src/tokens/motion.ts +0 -51
  113. package/src/tokens/radii.ts +0 -10
  114. package/src/tokens/shadows.ts +0 -34
  115. package/src/tokens/spacing.ts +0 -30
  116. package/src/tokens/typography.ts +0 -42
@@ -13,24 +13,30 @@ export interface FilterChipProps {
13
13
  /** An applied filter with an optional edit action and an explicit remove action. */
14
14
  export function FilterChip({ label, onRemove, onClick, className }: FilterChipProps) {
15
15
  const labelContent = onClick ? (
16
- <button type="button" className="min-h-11 px-3 text-left" onClick={onClick}>
16
+ <button
17
+ type="button"
18
+ className="focus-visible:ring-ring min-h-9 rounded-full px-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
19
+ onClick={onClick}
20
+ >
17
21
  {label}
18
22
  </button>
19
23
  ) : (
20
- <span className="flex min-h-11 items-center px-3">{label}</span>
24
+ <span className="flex min-h-9 items-center px-3">{label}</span>
21
25
  );
22
26
 
23
27
  return (
24
28
  <span
29
+ role="group"
30
+ aria-label={typeof label === "string" ? `${label} filter` : "filter"}
25
31
  className={cn(
26
- "border-subtle bg-panel text-primary inline-flex items-center rounded-full border text-sm font-medium",
32
+ "border-border bg-secondary text-secondary-foreground inline-flex items-center rounded-full border text-sm font-medium",
27
33
  className,
28
34
  )}
29
35
  >
30
36
  {labelContent}
31
37
  <button
32
38
  type="button"
33
- className="text-secondary hover:text-primary mr-1 flex size-10 items-center justify-center rounded-full"
39
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring mr-1 flex size-9 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
34
40
  onClick={onRemove}
35
41
  aria-label={`Remove filter: ${typeof label === "string" ? label : "selected filter"}`}
36
42
  >
@@ -1,6 +1,11 @@
1
1
  export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion";
2
2
  export { FilterChip } from "./filter-chip";
3
3
  export type { FilterChipProps } from "./filter-chip";
4
+ export { MetricCard } from "./metric-card";
5
+ export { SortControls } from "./sort-controls";
6
+ export type { SortControlsProps } from "./sort-controls";
7
+ export { SortRow } from "./sort-row";
8
+ export type { SortRowProps } from "./sort-row";
4
9
  export {
5
10
  Table,
6
11
  TableBody,
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { MetricCard } from "./metric-card";
4
+
5
+ const meta = {
6
+ title: "DataDisplay/MetricCard",
7
+ component: MetricCard,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof MetricCard>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ export const Default: Story = {
15
+ args: {
16
+ label: "Net worth",
17
+ value: "$48,210",
18
+ },
19
+ };
20
+
21
+ export const WithChange: Story = {
22
+ args: {
23
+ label: "Monthly spend",
24
+ value: "$3,120",
25
+ change: "+4.2% vs last month",
26
+ },
27
+ };
28
+
29
+ export const Empty: Story = {
30
+ args: {
31
+ label: "Runway",
32
+ value: undefined,
33
+ },
34
+ };
35
+
36
+ export const Grid: Story = {
37
+ args: {
38
+ label: "Net worth",
39
+ value: "$48,210",
40
+ },
41
+ render: () => (
42
+ <div className="grid w-[520px] grid-cols-3 gap-3">
43
+ <MetricCard label="Net worth" value="$48,210" change="+2.1%" />
44
+ <MetricCard label="Income" value="$6,400" />
45
+ <MetricCard label="Expenses" value="$3,120" change="-1.4%" />
46
+ </div>
47
+ ),
48
+ };
@@ -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-muted" : ""}`}
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,17 +31,13 @@ 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
 
38
38
  const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
39
39
  ({ className, ...props }, ref) => (
40
- <tr
41
- ref={ref}
42
- className={cn("data-[state=selected]:bg-inset border-default border-b", className)}
43
- {...props}
44
- />
40
+ <tr ref={ref} className={cn("data-[state=selected]:bg-muted border-b", className)} {...props} />
45
41
  ),
46
42
  );
47
43
  TableRow.displayName = "TableRow";
@@ -53,7 +49,7 @@ const TableHead = React.forwardRef<
53
49
  <th
54
50
  ref={ref}
55
51
  className={cn(
56
- "ui-eyebrow h-9 px-3 text-left align-middle [&:has([role=checkbox])]:pr-0",
52
+ "text-muted-foreground h-9 px-3 text-left align-middle text-xs font-medium [&:has([role=checkbox])]:pr-0",
57
53
  className,
58
54
  )}
59
55
  {...props}
@@ -77,7 +73,7 @@ const TableCaption = React.forwardRef<
77
73
  HTMLTableCaptionElement,
78
74
  React.HTMLAttributes<HTMLTableCaptionElement>
79
75
  >(({ className, ...props }, ref) => (
80
- <caption ref={ref} className={cn("body-4 text-secondary mt-4", className)} {...props} />
76
+ <caption ref={ref} className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} />
81
77
  ));
82
78
  TableCaption.displayName = "TableCaption";
83
79
 
@@ -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" && "bg-card border",
41
+ variant === "dashed" && "bg-card border border-dashed",
42
+ variant === "search" && "bg-muted border border-dashed",
43
43
  variant === "quiet" && "bg-surface",
44
44
  centered
45
45
  ? cn(
@@ -51,13 +51,23 @@ 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
55
+ className="text-muted-foreground mb-3 flex size-11 items-center justify-center"
56
+ aria-hidden
57
+ >
55
58
  {icon}
56
59
  </div>
57
60
  ) : null}
58
61
  <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}
62
+ <h2
63
+ className={cn(
64
+ "text-foreground font-semibold tracking-tight",
65
+ large ? "text-lg" : "text-base",
66
+ )}
67
+ >
68
+ {title}
69
+ </h2>
70
+ {description ? <p className="text-muted-foreground text-sm">{description}</p> : null}
61
71
  </div>
62
72
  {children ? <div className="mt-3">{children}</div> : null}
63
73
  {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-muted", text: "text-muted-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
+ };