@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.
- package/LICENSE +21 -0
- package/README.md +7 -7
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/dist/index.js +1028 -638
- package/dist/index.js.map +1 -1
- package/package.json +30 -32
- package/src/animations.css +16 -434
- package/src/components/data-display/accordion.tsx +10 -7
- package/src/components/data-display/filter-chip.tsx +2 -2
- package/src/components/data-display/index.ts +5 -0
- package/src/components/data-display/metric-card.stories.tsx +48 -0
- package/src/components/data-display/metric-card.tsx +20 -0
- package/src/components/data-display/sort-controls.stories.tsx +45 -0
- package/src/components/data-display/sort-controls.tsx +99 -0
- package/src/components/data-display/sort-row.tsx +74 -0
- package/src/components/data-display/table.tsx +5 -5
- package/src/components/feedback/alert.tsx +4 -4
- package/src/components/feedback/empty-state.tsx +6 -6
- package/src/components/feedback/file-upload-status-badge.stories.tsx +28 -0
- package/src/components/feedback/file-upload-status-badge.tsx +33 -0
- package/src/components/feedback/file-upload-status.stories.tsx +57 -0
- package/src/components/feedback/file-upload-status.tsx +152 -0
- package/src/components/feedback/index.ts +9 -0
- package/src/components/feedback/progress-bar.stories.tsx +39 -0
- package/src/components/feedback/progress-bar.tsx +21 -0
- package/src/components/feedback/progress.stories.tsx +8 -6
- package/src/components/feedback/progress.tsx +1 -1
- package/src/components/feedback/skeleton.stories.tsx +36 -0
- package/src/components/feedback/skeleton.tsx +7 -0
- package/src/components/feedback/spinner.tsx +2 -2
- package/src/components/feedback/update-guard.stories.tsx +32 -0
- package/src/components/feedback/update-guard.tsx +280 -0
- package/src/components/forms/calendar.tsx +11 -11
- package/src/components/forms/date-month-select.stories.tsx +26 -0
- package/src/components/forms/date-month-select.tsx +70 -0
- package/src/components/forms/date-picker.tsx +3 -3
- package/src/components/forms/drop-zone.stories.tsx +56 -0
- package/src/components/forms/drop-zone.tsx +146 -0
- package/src/components/forms/entity-select.stories.tsx +65 -0
- package/src/components/forms/entity-select.tsx +79 -0
- package/src/components/forms/field.tsx +3 -3
- package/src/components/forms/group-by-select.stories.tsx +26 -0
- package/src/components/forms/group-by-select.tsx +47 -0
- package/src/components/forms/index.ts +10 -0
- package/src/components/forms/input.stories.tsx +4 -4
- package/src/components/forms/input.tsx +1 -1
- package/src/components/forms/passkey-management.stories.tsx +81 -0
- package/src/components/forms/passkey-management.tsx +138 -0
- package/src/components/forms/radio-group.tsx +14 -13
- package/src/components/forms/select.stories.tsx +4 -4
- package/src/components/forms/select.tsx +45 -53
- package/src/components/forms/slider.stories.tsx +5 -2
- package/src/components/forms/slider.tsx +27 -17
- package/src/components/forms/stepper.tsx +4 -4
- package/src/components/forms/switch.stories.tsx +1 -1
- package/src/components/forms/switch.tsx +2 -2
- package/src/components/forms/textarea.stories.tsx +2 -2
- package/src/components/forms/textarea.tsx +1 -1
- package/src/components/layout/index.ts +3 -0
- package/src/components/layout/scroll-area.stories.tsx +4 -4
- package/src/components/layout/scroll-area.tsx +23 -21
- package/src/components/layout/section-intro.stories.tsx +29 -0
- package/src/components/layout/section-intro.tsx +31 -0
- package/src/components/layout/surface-panel.stories.tsx +32 -0
- package/src/components/layout/surface-panel.tsx +15 -0
- package/src/components/navigation/app-navigation.stories.tsx +2 -2
- package/src/components/navigation/app-navigation.tsx +13 -13
- package/src/components/navigation/index.ts +4 -0
- package/src/components/navigation/pagination-controls.stories.tsx +41 -0
- package/src/components/navigation/pagination-controls.tsx +50 -0
- package/src/components/navigation/route-link.stories.tsx +30 -0
- package/src/components/navigation/route-link.tsx +42 -0
- package/src/components/navigation/tabs.stories.tsx +5 -5
- package/src/components/navigation/tabs.tsx +7 -7
- package/src/components/overlays/alert-dialog.tsx +21 -46
- package/src/components/overlays/command.tsx +51 -82
- package/src/components/overlays/dialog.tsx +5 -5
- package/src/components/overlays/dropdown-menu.stories.tsx +0 -5
- package/src/components/overlays/dropdown-menu.tsx +44 -142
- package/src/components/overlays/popover.stories.tsx +2 -2
- package/src/components/overlays/popover.tsx +34 -16
- package/src/components/overlays/sheet.stories.tsx +2 -2
- package/src/components/overlays/sheet.tsx +35 -17
- package/src/components/primitives/avatar.tsx +3 -3
- package/src/components/primitives/badge.tsx +7 -11
- package/src/components/primitives/button.tsx +41 -13
- package/src/components/primitives/card.stories.tsx +2 -2
- package/src/components/primitives/card.tsx +2 -2
- package/src/components/primitives/index.ts +2 -0
- package/src/components/primitives/label.stories.tsx +1 -1
- package/src/components/primitives/label.tsx +5 -6
- package/src/components/primitives/status-badge.stories.tsx +52 -0
- package/src/components/primitives/status-badge.tsx +33 -0
- package/src/constants/chart-colors.ts +5 -5
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-sort.ts +20 -0
- package/src/index.ts +30 -11
- package/src/styles.css +76 -1040
- package/src/tokens/color-systems.stories.tsx +9 -119
- package/src/tokens/foundations.stories.tsx +21 -113
- package/src/tokens/generated/dark.colors.generated.ts +56 -0
- package/src/tokens/generated/dark.css +54 -0
- package/src/tokens/generated/foundations.css +148 -0
- package/src/tokens/generated/foundations.generated.ts +443 -0
- package/src/tokens/generated/light.colors.generated.ts +56 -0
- package/src/tokens/generated/light.css +54 -0
- package/src/tokens/index.ts +74 -16
- package/src/tokens/layout.stories.tsx +3 -108
- package/src/tokens/colors.ts +0 -156
- package/src/tokens/motion.ts +0 -51
- package/src/tokens/radii.ts +0 -10
- package/src/tokens/shadows.ts +0 -34
- package/src/tokens/spacing.ts +0 -30
- 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("
|
|
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-
|
|
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-
|
|
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
|
-
"
|
|
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("
|
|
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-
|
|
11
|
+
default: "bg-card text-card-foreground",
|
|
12
12
|
destructive:
|
|
13
|
-
"text-destructive bg-
|
|
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("
|
|
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-
|
|
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
|
|
41
|
-
variant === "dashed" && "border
|
|
42
|
-
variant === "search" && "border
|
|
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-
|
|
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-
|
|
60
|
-
{description ? <p className="
|
|
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
|
+
};
|