@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.
- package/LICENSE +21 -0
- package/README.md +7 -7
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/dist/index.js +988 -640
- package/dist/index.js.map +1 -1
- package/package.json +41 -41
- package/src/animations.css +42 -383
- package/src/components/data-display/accordion.tsx +22 -10
- package/src/components/data-display/filter-chip.stories.tsx +38 -0
- package/src/components/data-display/filter-chip.tsx +10 -4
- 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 -9
- package/src/components/feedback/alert.tsx +4 -4
- package/src/components/feedback/empty-state.tsx +16 -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 +3 -3
- 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 +14 -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 +47 -48
- package/src/components/forms/slider.stories.tsx +5 -2
- package/src/components/forms/slider.tsx +39 -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 +8 -8
- package/src/components/overlays/alert-dialog.tsx +22 -25
- package/src/components/overlays/command.tsx +98 -26
- 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 +49 -31
- package/src/components/overlays/popover.stories.tsx +2 -2
- package/src/components/overlays/popover.tsx +43 -14
- package/src/components/overlays/sheet.stories.tsx +2 -2
- package/src/components/overlays/sheet.tsx +51 -17
- package/src/components/primitives/avatar.tsx +9 -5
- package/src/components/primitives/badge.tsx +11 -12
- package/src/components/primitives/button.tsx +49 -23
- package/src/components/primitives/card.stories.tsx +2 -2
- package/src/components/primitives/card.tsx +2 -5
- 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 +7 -7
- 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 +100 -974
- package/src/tokens/chart-colors.stories.tsx +192 -0
- package/src/tokens/color-systems.stories.tsx +20 -112
- package/src/tokens/foundations.stories.tsx +58 -100
- package/src/tokens/generated/dark.colors.generated.ts +29 -0
- package/src/tokens/generated/dark.css +29 -0
- package/src/tokens/generated/foundations.css +148 -0
- package/src/tokens/generated/foundations.generated.ts +451 -0
- package/src/tokens/generated/light.colors.generated.ts +29 -0
- package/src/tokens/generated/light.css +29 -0
- package/src/tokens/index.ts +79 -16
- package/src/tokens/layout.stories.tsx +23 -91
- 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
|
@@ -12,31 +12,34 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
|
|
12
12
|
showOutsideDays={showOutsideDays}
|
|
13
13
|
className={cn("relative w-fit rounded-md p-4", className)}
|
|
14
14
|
classNames={{
|
|
15
|
-
root: "text-
|
|
15
|
+
root: "text-foreground",
|
|
16
16
|
months: "flex flex-col gap-4 sm:flex-row",
|
|
17
17
|
month: "flex flex-col gap-4",
|
|
18
18
|
month_caption: "relative flex h-8 items-center justify-center",
|
|
19
19
|
caption_label: "text-sm font-medium",
|
|
20
20
|
nav: "absolute inset-x-4 top-4 flex items-center justify-between",
|
|
21
21
|
button_previous:
|
|
22
|
-
"inline-flex size-8 p-2 items-center justify-center rounded-full border
|
|
22
|
+
"inline-flex size-8 p-2 items-center justify-center rounded-full border bg-background text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
|
23
23
|
button_next:
|
|
24
|
-
"inline-flex size-8 p-2 items-center justify-center rounded-full border
|
|
24
|
+
"inline-flex size-8 p-2 items-center justify-center rounded-full border bg-background text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
|
25
25
|
month_grid: "w-full border-collapse",
|
|
26
26
|
weekdays: "flex",
|
|
27
|
-
weekday:
|
|
27
|
+
weekday:
|
|
28
|
+
"flex size-8 items-center justify-center text-[0.8rem] font-normal text-muted-foreground",
|
|
28
29
|
week: "mt-1 flex w-full",
|
|
29
30
|
day: "relative size-8 p-0 text-center text-lg focus-within:z-20",
|
|
30
31
|
day_button:
|
|
31
32
|
"inline-flex size-6 items-center justify-center rounded-md text-sm font-normal disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
|
32
33
|
selected:
|
|
33
|
-
"[&>button]:bg-
|
|
34
|
-
today: "[&>button]:bg-
|
|
35
|
-
outside: "text-
|
|
36
|
-
disabled: "text-
|
|
37
|
-
range_start:
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
"[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary/90",
|
|
35
|
+
today: "[&>button]:bg-primary [&>button]:text-primary-foreground",
|
|
36
|
+
outside: "text-muted-foreground [&>button]:text-muted-foreground",
|
|
37
|
+
disabled: "text-muted-foreground opacity-50",
|
|
38
|
+
range_start:
|
|
39
|
+
"rounded-l-md bg-primary [&>button]:bg-primary [&>button]:text-primary-foreground",
|
|
40
|
+
range_middle: "bg-primary [&>button]:rounded-none [&>button]:text-primary-foreground",
|
|
41
|
+
range_end:
|
|
42
|
+
"rounded-r-md bg-primary [&>button]:bg-primary [&>button]:text-primary-foreground",
|
|
40
43
|
hidden: "invisible",
|
|
41
44
|
...classNames,
|
|
42
45
|
}}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { DateMonthSelect, getCurrentMonthYear } from "./date-month-select";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Forms/DateMonthSelect",
|
|
8
|
+
component: DateMonthSelect,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof DateMonthSelect>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
function DateMonthSelectPreview() {
|
|
16
|
+
const [value, setValue] = useState(getCurrentMonthYear());
|
|
17
|
+
return <DateMonthSelect selectedMonthYear={value} onMonthChange={setValue} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {
|
|
21
|
+
args: {
|
|
22
|
+
selectedMonthYear: getCurrentMonthYear(),
|
|
23
|
+
onMonthChange: () => {},
|
|
24
|
+
},
|
|
25
|
+
render: () => <DateMonthSelectPreview />,
|
|
26
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Calendar } from "lucide-react";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
|
|
4
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
5
|
+
|
|
6
|
+
interface MonthOption {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DateMonthSelectProps {
|
|
12
|
+
selectedMonthYear: string;
|
|
13
|
+
onMonthChange: (value: string) => void;
|
|
14
|
+
monthsBack?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Select a `YYYY-MM` month from the last `monthsBack` months. */
|
|
20
|
+
export function DateMonthSelect({
|
|
21
|
+
selectedMonthYear,
|
|
22
|
+
onMonthChange,
|
|
23
|
+
monthsBack = 12,
|
|
24
|
+
className = "w-[200px]",
|
|
25
|
+
placeholder = "Select month",
|
|
26
|
+
}: DateMonthSelectProps) {
|
|
27
|
+
const monthOptions = useMemo<MonthOption[]>(() => {
|
|
28
|
+
const options: MonthOption[] = [];
|
|
29
|
+
const today = new Date();
|
|
30
|
+
for (let i = 0; i < monthsBack; i++) {
|
|
31
|
+
const date = new Date(today.getFullYear(), today.getMonth() - i, 1);
|
|
32
|
+
const year = date.getFullYear();
|
|
33
|
+
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
|
34
|
+
options.push({
|
|
35
|
+
value: `${year}-${month}`,
|
|
36
|
+
label: date.toLocaleString("default", { month: "long", year: "numeric" }),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return options;
|
|
40
|
+
}, [monthsBack]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-center gap-2">
|
|
44
|
+
<Calendar className="size-4" />
|
|
45
|
+
<Select
|
|
46
|
+
value={selectedMonthYear}
|
|
47
|
+
onValueChange={(v: string | null) => v != null && onMonthChange(v)}
|
|
48
|
+
>
|
|
49
|
+
<SelectTrigger className={className} aria-label={placeholder}>
|
|
50
|
+
<SelectValue placeholder={placeholder} />
|
|
51
|
+
</SelectTrigger>
|
|
52
|
+
<SelectContent>
|
|
53
|
+
{monthOptions.map((option) => (
|
|
54
|
+
<SelectItem key={option.value} value={option.value}>
|
|
55
|
+
{option.label}
|
|
56
|
+
</SelectItem>
|
|
57
|
+
))}
|
|
58
|
+
</SelectContent>
|
|
59
|
+
</Select>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Current month in `YYYY-MM` format. */
|
|
65
|
+
export const getCurrentMonthYear = () => {
|
|
66
|
+
const now = new Date();
|
|
67
|
+
const year = now.getFullYear();
|
|
68
|
+
const month = (now.getMonth() + 1).toString().padStart(2, "0");
|
|
69
|
+
return `${year}-${month}`;
|
|
70
|
+
};
|
|
@@ -35,7 +35,7 @@ export function DatePicker({
|
|
|
35
35
|
return (
|
|
36
36
|
<div className={containerClassName}>
|
|
37
37
|
{showLabel && label ? (
|
|
38
|
-
<label htmlFor={id} className="text-
|
|
38
|
+
<label htmlFor={id} className="text-foreground mb-2 block text-sm font-medium">
|
|
39
39
|
{label}
|
|
40
40
|
</label>
|
|
41
41
|
) : null}
|
|
@@ -45,13 +45,13 @@ export function DatePicker({
|
|
|
45
45
|
id={id}
|
|
46
46
|
variant={variant}
|
|
47
47
|
disabled={disabled}
|
|
48
|
-
className="bg-
|
|
48
|
+
className="bg-muted placeholder:text-muted-foreground w-full justify-start text-left font-normal"
|
|
49
49
|
>
|
|
50
50
|
<CalendarIcon className="mr-2 size-4" />
|
|
51
51
|
{value ? format(value, dateFormat) : <span>{placeholder}</span>}
|
|
52
52
|
</Button>
|
|
53
53
|
</PopoverTrigger>
|
|
54
|
-
<PopoverContent className="bg-
|
|
54
|
+
<PopoverContent className="bg-popover w-auto p-0" align={popoverAlign}>
|
|
55
55
|
<Calendar mode="single" selected={value} onSelect={onSelect} />
|
|
56
56
|
</PopoverContent>
|
|
57
57
|
</Popover>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { DropZone } from "./drop-zone";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Forms/DropZone",
|
|
8
|
+
component: DropZone,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof DropZone>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
function DropZonePreview({ isImporting = false }: { isImporting?: boolean }) {
|
|
16
|
+
const [dragActive, setDragActive] = useState(false);
|
|
17
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="w-96 space-y-3">
|
|
21
|
+
<DropZone
|
|
22
|
+
isImporting={isImporting}
|
|
23
|
+
dragActive={dragActive}
|
|
24
|
+
onDragOver={() => setDragActive(true)}
|
|
25
|
+
onDragLeave={() => setDragActive(false)}
|
|
26
|
+
onDrop={setFiles}
|
|
27
|
+
onChange={setFiles}
|
|
28
|
+
accept=".csv"
|
|
29
|
+
helpText="Supported formats: CSV"
|
|
30
|
+
/>
|
|
31
|
+
{files.length > 0 ? (
|
|
32
|
+
<p className="text-muted-foreground text-sm">
|
|
33
|
+
{files.length} file{files.length > 1 ? "s" : ""} selected
|
|
34
|
+
</p>
|
|
35
|
+
) : null}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const baseArgs = {
|
|
41
|
+
isImporting: false,
|
|
42
|
+
dragActive: false,
|
|
43
|
+
onDrop: () => {},
|
|
44
|
+
onDragOver: () => {},
|
|
45
|
+
onDragLeave: () => {},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const Default: Story = {
|
|
49
|
+
args: baseArgs,
|
|
50
|
+
render: () => <DropZonePreview />,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const Importing: Story = {
|
|
54
|
+
args: { ...baseArgs, isImporting: true },
|
|
55
|
+
render: () => <DropZonePreview isImporting />,
|
|
56
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useCallback, useId } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/utils";
|
|
5
|
+
|
|
6
|
+
export interface DropZoneProps {
|
|
7
|
+
isImporting: boolean;
|
|
8
|
+
dragActive: boolean;
|
|
9
|
+
onDrop: (files: File[]) => void;
|
|
10
|
+
onDragOver: () => void;
|
|
11
|
+
onDragLeave: () => void;
|
|
12
|
+
onChange?: (files: File[]) => void;
|
|
13
|
+
accept?: string;
|
|
14
|
+
multiple?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
helpText?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Drag-and-drop file picker with a click-to-browse fallback. */
|
|
21
|
+
export function DropZone({
|
|
22
|
+
isImporting,
|
|
23
|
+
dragActive,
|
|
24
|
+
onDrop,
|
|
25
|
+
onDragOver,
|
|
26
|
+
onDragLeave,
|
|
27
|
+
onChange,
|
|
28
|
+
accept,
|
|
29
|
+
multiple = true,
|
|
30
|
+
className,
|
|
31
|
+
label = "Drag and drop files here, or",
|
|
32
|
+
helpText,
|
|
33
|
+
}: DropZoneProps) {
|
|
34
|
+
const inputId = useId();
|
|
35
|
+
|
|
36
|
+
const handleDrop = useCallback(
|
|
37
|
+
(e: React.DragEvent<HTMLButtonElement>) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
e.stopPropagation();
|
|
40
|
+
onDragLeave();
|
|
41
|
+
|
|
42
|
+
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
43
|
+
onDrop(Array.from(e.dataTransfer.files));
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[onDrop, onDragLeave],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const triggerFileInput = useCallback(() => {
|
|
50
|
+
if (!isImporting) {
|
|
51
|
+
document.getElementById(inputId)?.click();
|
|
52
|
+
}
|
|
53
|
+
}, [isImporting, inputId]);
|
|
54
|
+
|
|
55
|
+
const handleDragOver = useCallback(
|
|
56
|
+
(e: React.DragEvent<HTMLButtonElement>) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
e.stopPropagation();
|
|
59
|
+
onDragOver();
|
|
60
|
+
},
|
|
61
|
+
[onDragOver],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const handleDragLeave = useCallback(
|
|
65
|
+
(e: React.DragEvent<HTMLButtonElement>) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
onDragLeave();
|
|
69
|
+
},
|
|
70
|
+
[onDragLeave],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const handleClick = useCallback(
|
|
74
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
triggerFileInput();
|
|
77
|
+
},
|
|
78
|
+
[triggerFileInput],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const handleKeyDown = useCallback(
|
|
82
|
+
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
83
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
e.stopPropagation();
|
|
86
|
+
triggerFileInput();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[triggerFileInput],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const handleInputChange = useCallback(
|
|
93
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
94
|
+
e.stopPropagation();
|
|
95
|
+
if (onChange) {
|
|
96
|
+
onChange(e.target.files ? Array.from(e.target.files) : []);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
[onChange],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
className={cn(
|
|
107
|
+
"relative border-2 border-dashed p-12",
|
|
108
|
+
{
|
|
109
|
+
"border-primary bg-primary/5": dragActive,
|
|
110
|
+
"border-border bg-muted": !dragActive,
|
|
111
|
+
},
|
|
112
|
+
"flex flex-col items-center justify-center gap-4",
|
|
113
|
+
{
|
|
114
|
+
"pointer-events-none cursor-not-allowed": isImporting,
|
|
115
|
+
},
|
|
116
|
+
className,
|
|
117
|
+
)}
|
|
118
|
+
onDragOver={handleDragOver}
|
|
119
|
+
onDragLeave={handleDragLeave}
|
|
120
|
+
onDrop={handleDrop}
|
|
121
|
+
onClick={handleClick}
|
|
122
|
+
onKeyDown={handleKeyDown}
|
|
123
|
+
tabIndex={isImporting ? -1 : 0}
|
|
124
|
+
aria-disabled={isImporting}
|
|
125
|
+
aria-label="Upload file area. Press Enter or Space to open file browser"
|
|
126
|
+
>
|
|
127
|
+
<div className="space-y-2 text-center">
|
|
128
|
+
<p className="text-muted-foreground">
|
|
129
|
+
{label} <span className="text-foreground font-medium">click to browse</span>
|
|
130
|
+
</p>
|
|
131
|
+
{helpText ? <p className="text-muted-foreground text-sm">{helpText}</p> : null}
|
|
132
|
+
</div>
|
|
133
|
+
</button>
|
|
134
|
+
<input
|
|
135
|
+
id={inputId}
|
|
136
|
+
type="file"
|
|
137
|
+
multiple={multiple}
|
|
138
|
+
accept={accept}
|
|
139
|
+
onChange={handleInputChange}
|
|
140
|
+
className="sr-only"
|
|
141
|
+
disabled={isImporting}
|
|
142
|
+
aria-label="File input"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { EntitySelect } from "./entity-select";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Forms/EntitySelect",
|
|
8
|
+
component: EntitySelect,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof EntitySelect>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
const options = [
|
|
16
|
+
{ id: "checking", name: "Checking" },
|
|
17
|
+
{ id: "savings", name: "Savings" },
|
|
18
|
+
{ id: "credit-card", name: "Credit Card" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function EntitySelectPreview() {
|
|
22
|
+
const [value, setValue] = useState("all");
|
|
23
|
+
return (
|
|
24
|
+
<EntitySelect
|
|
25
|
+
value={value}
|
|
26
|
+
onValueChange={setValue}
|
|
27
|
+
options={options}
|
|
28
|
+
allOptionLabel="All accounts"
|
|
29
|
+
placeholder="All accounts"
|
|
30
|
+
label="Account"
|
|
31
|
+
showLabel
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Default: Story = {
|
|
37
|
+
args: {
|
|
38
|
+
value: "all",
|
|
39
|
+
onValueChange: () => {},
|
|
40
|
+
options,
|
|
41
|
+
},
|
|
42
|
+
render: () => <EntitySelectPreview />,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Loading: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
value: "all",
|
|
48
|
+
onValueChange: () => {},
|
|
49
|
+
options: [],
|
|
50
|
+
isLoading: true,
|
|
51
|
+
label: "Account",
|
|
52
|
+
showLabel: true,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const Empty: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
value: "all",
|
|
59
|
+
onValueChange: () => {},
|
|
60
|
+
options: [],
|
|
61
|
+
label: "Account",
|
|
62
|
+
showLabel: true,
|
|
63
|
+
emptyLabel: "No accounts available",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
|
|
3
|
+
import { Label } from "../primitives/label";
|
|
4
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
5
|
+
|
|
6
|
+
export interface EntitySelectOption {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface EntitySelectProps {
|
|
12
|
+
value: string;
|
|
13
|
+
onValueChange: (value: string) => void;
|
|
14
|
+
options: EntitySelectOption[];
|
|
15
|
+
isLoading?: boolean;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
showLabel?: boolean;
|
|
19
|
+
allOptionLabel?: string;
|
|
20
|
+
emptyLabel?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Labeled `Select` over an `{ id, name }[]` list, with loading/empty states and an "all" option. */
|
|
25
|
+
export function EntitySelect({
|
|
26
|
+
value,
|
|
27
|
+
onValueChange,
|
|
28
|
+
options,
|
|
29
|
+
isLoading = false,
|
|
30
|
+
placeholder = "All",
|
|
31
|
+
label = "Filter",
|
|
32
|
+
showLabel = false,
|
|
33
|
+
allOptionLabel = "All",
|
|
34
|
+
emptyLabel = "No options available",
|
|
35
|
+
className,
|
|
36
|
+
}: EntitySelectProps) {
|
|
37
|
+
const id = useId();
|
|
38
|
+
|
|
39
|
+
const selectElement = (
|
|
40
|
+
<Select
|
|
41
|
+
name={label}
|
|
42
|
+
value={value}
|
|
43
|
+
onValueChange={(v: string | null) => v != null && onValueChange(v)}
|
|
44
|
+
>
|
|
45
|
+
<SelectTrigger id={id} className={className}>
|
|
46
|
+
<SelectValue placeholder={placeholder} />
|
|
47
|
+
</SelectTrigger>
|
|
48
|
+
<SelectContent className="max-h-[250px] overflow-y-auto">
|
|
49
|
+
<SelectItem value="all">{allOptionLabel}</SelectItem>
|
|
50
|
+
{isLoading ? (
|
|
51
|
+
<SelectItem value="loading" disabled>
|
|
52
|
+
Loading...
|
|
53
|
+
</SelectItem>
|
|
54
|
+
) : options.length === 0 ? (
|
|
55
|
+
<SelectItem value="empty" disabled>
|
|
56
|
+
{emptyLabel}
|
|
57
|
+
</SelectItem>
|
|
58
|
+
) : (
|
|
59
|
+
options.map((option) => (
|
|
60
|
+
<SelectItem key={option.id} value={option.id}>
|
|
61
|
+
{option.name}
|
|
62
|
+
</SelectItem>
|
|
63
|
+
))
|
|
64
|
+
)}
|
|
65
|
+
</SelectContent>
|
|
66
|
+
</Select>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (showLabel) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
<Label htmlFor={id}>{label}</Label>
|
|
73
|
+
{selectElement}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return selectElement;
|
|
79
|
+
}
|
|
@@ -29,7 +29,7 @@ function Field({ label, helpText, error, required, children, id: externalId }: F
|
|
|
29
29
|
<label
|
|
30
30
|
htmlFor={id}
|
|
31
31
|
className={cn(
|
|
32
|
-
"
|
|
32
|
+
"text-foreground text-sm font-medium",
|
|
33
33
|
required && "after:text-destructive after:ml-0.5 after:content-['*']",
|
|
34
34
|
)}
|
|
35
35
|
>
|
|
@@ -50,11 +50,11 @@ function Field({ label, helpText, error, required, children, id: externalId }: F
|
|
|
50
50
|
: children}
|
|
51
51
|
|
|
52
52
|
{error ? (
|
|
53
|
-
<p id={errorId} role="alert" className="
|
|
53
|
+
<p id={errorId} role="alert" className="text-destructive text-sm">
|
|
54
54
|
{error}
|
|
55
55
|
</p>
|
|
56
56
|
) : helpText ? (
|
|
57
|
-
<p id={descId} className="
|
|
57
|
+
<p id={descId} className="text-muted-foreground text-sm">
|
|
58
58
|
{helpText}
|
|
59
59
|
</p>
|
|
60
60
|
) : null}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { GroupBySelect } from "./group-by-select";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Forms/GroupBySelect",
|
|
8
|
+
component: GroupBySelect,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof GroupBySelect>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
function GroupBySelectPreview() {
|
|
16
|
+
const [groupBy, setGroupBy] = useState<"month" | "week" | "day">("month");
|
|
17
|
+
return <GroupBySelect groupBy={groupBy} onGroupByChange={setGroupBy} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {
|
|
21
|
+
args: {
|
|
22
|
+
groupBy: "month",
|
|
23
|
+
onGroupByChange: () => {},
|
|
24
|
+
},
|
|
25
|
+
render: () => <GroupBySelectPreview />,
|
|
26
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
|
|
3
|
+
import { Label } from "../primitives/label";
|
|
4
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
5
|
+
|
|
6
|
+
type GroupByOption = "month" | "week" | "day";
|
|
7
|
+
|
|
8
|
+
export interface GroupBySelectProps {
|
|
9
|
+
groupBy: GroupByOption;
|
|
10
|
+
onGroupByChange: (value: GroupByOption) => void;
|
|
11
|
+
label?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Month/week/day grouping select. */
|
|
16
|
+
export function GroupBySelect({
|
|
17
|
+
groupBy,
|
|
18
|
+
onGroupByChange,
|
|
19
|
+
label = "Group By",
|
|
20
|
+
className,
|
|
21
|
+
}: GroupBySelectProps) {
|
|
22
|
+
const id = useId();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className={`space-y-2 ${className || ""}`}>
|
|
26
|
+
<Label htmlFor={id}>{label}</Label>
|
|
27
|
+
<Select
|
|
28
|
+
name="groupBy"
|
|
29
|
+
value={groupBy}
|
|
30
|
+
onValueChange={(value) => {
|
|
31
|
+
if (value === "month" || value === "week" || value === "day") {
|
|
32
|
+
onGroupByChange(value);
|
|
33
|
+
}
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<SelectTrigger id={id}>
|
|
37
|
+
<SelectValue />
|
|
38
|
+
</SelectTrigger>
|
|
39
|
+
<SelectContent>
|
|
40
|
+
<SelectItem value="month">Month</SelectItem>
|
|
41
|
+
<SelectItem value="week">Week</SelectItem>
|
|
42
|
+
<SelectItem value="day">Day</SelectItem>
|
|
43
|
+
</SelectContent>
|
|
44
|
+
</Select>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -2,9 +2,19 @@ export { Calendar } from "./calendar";
|
|
|
2
2
|
export type { CalendarProps } from "./calendar";
|
|
3
3
|
export { DatePicker } from "./date-picker";
|
|
4
4
|
export type { DatePickerProps } from "./date-picker";
|
|
5
|
+
export { DateMonthSelect, getCurrentMonthYear } from "./date-month-select";
|
|
6
|
+
export type { DateMonthSelectProps } from "./date-month-select";
|
|
7
|
+
export { DropZone } from "./drop-zone";
|
|
8
|
+
export type { DropZoneProps } from "./drop-zone";
|
|
9
|
+
export { EntitySelect } from "./entity-select";
|
|
10
|
+
export type { EntitySelectOption, EntitySelectProps } from "./entity-select";
|
|
5
11
|
export { Field } from "./field";
|
|
6
12
|
export type { FieldProps } from "./field";
|
|
13
|
+
export { GroupBySelect } from "./group-by-select";
|
|
14
|
+
export type { GroupBySelectProps } from "./group-by-select";
|
|
7
15
|
export { Input } from "./input";
|
|
16
|
+
export { PasskeyManagement } from "./passkey-management";
|
|
17
|
+
export type { PasskeyManagementProps, PasskeyRecord } from "./passkey-management";
|
|
8
18
|
export { RadioGroup, RadioGroupItem } from "./radio-group";
|
|
9
19
|
export {
|
|
10
20
|
Select,
|
|
@@ -28,7 +28,7 @@ export const Default: Story = {
|
|
|
28
28
|
export const WithLabel: Story = {
|
|
29
29
|
render: () => (
|
|
30
30
|
<div className="flex w-72 flex-col gap-2">
|
|
31
|
-
<label htmlFor="email" className="text-
|
|
31
|
+
<label htmlFor="email" className="text-foreground text-sm leading-none font-medium">
|
|
32
32
|
Email
|
|
33
33
|
</label>
|
|
34
34
|
<Input id="email" type="email" placeholder="you@example.com" />
|
|
@@ -39,9 +39,9 @@ export const WithLabel: Story = {
|
|
|
39
39
|
export const States: Story = {
|
|
40
40
|
render: () => (
|
|
41
41
|
<div className="flex w-72 flex-col gap-4">
|
|
42
|
-
<Input placeholder="Default" />
|
|
43
|
-
<Input placeholder="Disabled" disabled />
|
|
44
|
-
<Input defaultValue="With value" />
|
|
42
|
+
<Input aria-label="Default input" placeholder="Default" />
|
|
43
|
+
<Input aria-label="Disabled input" placeholder="Disabled" disabled />
|
|
44
|
+
<Input aria-label="Input with value" defaultValue="With value" />
|
|
45
45
|
</div>
|
|
46
46
|
),
|
|
47
47
|
};
|
|
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
|
8
8
|
type={type}
|
|
9
9
|
data-slot="input"
|
|
10
10
|
className={cn(
|
|
11
|
-
"file:text-
|
|
11
|
+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background focus-visible:ring-ring border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base transition-colors file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
12
12
|
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
|
13
13
|
className,
|
|
14
14
|
)}
|