@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
@@ -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-primary",
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 border-default bg-canvas text-primary disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
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 border-default bg-canvas text-primary disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
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: "flex size-8 items-center justify-center text-[0.8rem] font-normal text-secondary",
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-accent [&>button]:text-on-accent [&>button]:hover:bg-accent [&>button]:hover:text-on-accent",
34
- today: "[&>button]:bg-accent [&>button]:text-on-accent",
35
- outside: "text-secondary opacity-50 [&>button]:text-secondary",
36
- disabled: "text-secondary opacity-50",
37
- range_start: "rounded-l-md bg-accent [&>button]:bg-accent [&>button]:text-on-accent",
38
- range_middle: "bg-accent [&>button]:rounded-none [&>button]:text-on-accent",
39
- range_end: "rounded-r-md bg-accent [&>button]:bg-accent [&>button]:text-on-accent",
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-primary mb-2 block text-sm font-medium">
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-inset placeholder:text-secondary w-full justify-start text-left font-normal backdrop-blur-sm"
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-canvas w-auto p-0" align={popoverAlign}>
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
- "body-3 text-primary font-medium",
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="body-4 text-destructive">
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="body-4 text-tertiary">
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-primary text-sm leading-none font-medium">
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-primary placeholder:text-secondary selection:bg-accent selection:text-on-accent bg-canvas focus-visible:ring-focus 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",
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
  )}