@ponti-studios/ui 0.7.0 → 0.8.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 (44) hide show
  1. package/README.md +13 -0
  2. package/package.json +22 -20
  3. package/src/components/data-display/accordion.tsx +1 -1
  4. package/src/components/data-display/sort-controls.tsx +3 -3
  5. package/src/components/feedback/index.ts +0 -2
  6. package/src/components/feedback/update-guard.tsx +5 -1
  7. package/src/components/filters/filter-select.tsx +1 -1
  8. package/src/components/forms/date-month-select.tsx +12 -3
  9. package/src/components/forms/date-picker.tsx +16 -12
  10. package/src/components/forms/drop-zone.tsx +1 -1
  11. package/src/components/forms/index.ts +4 -8
  12. package/src/components/forms/passkey-management.tsx +4 -10
  13. package/src/components/forms/radio-group.tsx +8 -4
  14. package/src/components/forms/select.tsx +1 -1
  15. package/src/components/forms/text-field.tsx +40 -5
  16. package/src/components/navigation/app-navigation.stories.tsx +0 -1
  17. package/src/components/navigation/app-navigation.tsx +67 -61
  18. package/src/components/navigation/index.ts +7 -4
  19. package/src/components/navigation/pagination-controls.stories.tsx +1 -1
  20. package/src/components/navigation/pagination-controls.tsx +10 -14
  21. package/src/components/overlays/dropdown-menu.stories.tsx +2 -2
  22. package/src/components/primitives/card.tsx +6 -10
  23. package/src/components/primitives/label.stories.tsx +0 -18
  24. package/src/components/primitives/label.tsx +12 -12
  25. package/src/constants/chart-colors.ts +16 -16
  26. package/src/hooks/use-media-query.ts +5 -1
  27. package/src/styles/animations.css +0 -22
  28. package/src/styles/index.css +60 -6
  29. package/src/styles/tokens/color-systems.stories.tsx +2 -1
  30. package/src/styles/tokens/generated/light.colors.generated.ts +3 -3
  31. package/src/styles/tokens/generated/light.css +3 -3
  32. package/src/styles/tokens/source/light.tokens.json +3 -3
  33. package/src/styles/tokens/style-dictionary.light-storybook.json +16 -0
  34. package/src/components/feedback/file-upload-status-badge.stories.tsx +0 -28
  35. package/src/components/feedback/file-upload-status-badge.tsx +0 -33
  36. package/src/components/forms/field.stories.tsx +0 -62
  37. package/src/components/forms/field.tsx +0 -65
  38. package/src/components/forms/field.types.ts +0 -7
  39. package/src/components/forms/group-by-select.stories.tsx +0 -26
  40. package/src/components/forms/group-by-select.tsx +0 -47
  41. package/src/components/navigation/route-link.stories.tsx +0 -30
  42. package/src/components/navigation/route-link.tsx +0 -42
  43. package/src/hooks/use-derived-filter-state.stories.tsx +0 -136
  44. package/src/hooks/use-filter-state.stories.tsx +0 -102
@@ -8,41 +8,37 @@ export interface PaginationControlsProps {
8
8
  onPageChange: (page: number) => void;
9
9
  }
10
10
 
11
- /** Zero-indexed prev/next pagination control. Renders nothing for a single page. */
11
+ /** Zero-indexed prev/next pagination control. */
12
12
  export function PaginationControls({
13
13
  currentPage,
14
14
  totalPages,
15
15
  onPageChange,
16
16
  }: PaginationControlsProps) {
17
- if (totalPages <= 1) {
18
- return null;
19
- }
20
-
21
17
  return (
22
- <div className="text-muted-foreground flex items-center justify-end gap-2 text-sm">
18
+ <div className="border-border bg-background text-foreground inline-flex items-center overflow-hidden rounded-full border text-sm">
23
19
  <Button
24
20
  type="button"
25
21
  variant="outline"
26
- size="sm"
27
- className="border-dashed"
22
+ size="icon"
23
+ className="rounded-none border-0 border-r bg-transparent shadow-none"
28
24
  onClick={() => onPageChange(currentPage - 1)}
29
25
  disabled={currentPage === 0}
26
+ aria-label="Previous page"
30
27
  >
31
28
  <ChevronLeft className="size-4" aria-hidden />
32
- Previous
33
29
  </Button>
34
- <span className="text-foreground text-xs">
35
- Page {currentPage + 1} of {totalPages}
30
+ <span className="min-w-20 px-3 text-center text-xs tabular-nums">
31
+ {currentPage + 1} of {totalPages}
36
32
  </span>
37
33
  <Button
38
34
  type="button"
39
35
  variant="outline"
40
- size="sm"
41
- className="border-dashed"
36
+ size="icon"
37
+ className="rounded-none border-0 border-l bg-transparent shadow-none"
42
38
  onClick={() => onPageChange(currentPage + 1)}
43
39
  disabled={currentPage >= totalPages - 1}
40
+ aria-label="Next page"
44
41
  >
45
- Next
46
42
  <ChevronRight className="size-4" aria-hidden />
47
43
  </Button>
48
44
  </div>
@@ -44,9 +44,9 @@ export const Default: Story = {
44
44
  <Button variant="outline">Open Menu</Button>
45
45
  </DropdownMenuTrigger>
46
46
  <DropdownMenuContent className="w-56">
47
- <DropdownMenuLabel>My Account</DropdownMenuLabel>
48
- <DropdownMenuSeparator />
49
47
  <DropdownMenuGroup>
48
+ <DropdownMenuLabel>My Account</DropdownMenuLabel>
49
+ <DropdownMenuSeparator />
50
50
  <DropdownMenuItem>
51
51
  <User className="mr-2 size-4" />
52
52
  <span>Profile</span>
@@ -6,7 +6,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
6
6
  return (
7
7
  <div
8
8
  data-slot="card"
9
- className={cn("bg-card text-card-foreground flex flex-col rounded-xl border", className)}
9
+ className={cn("bg-card text-card-foreground flex flex-col gap-4 rounded border", className)}
10
10
  {...props}
11
11
  />
12
12
  );
@@ -17,7 +17,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
17
17
  <div
18
18
  data-slot="card-header"
19
19
  className={cn(
20
- "flex items-center justify-between gap-2 border-b px-4 py-3 has-data-[slot=card-action]:grid has-data-[slot=card-action]:grid-cols-[1fr_auto]",
20
+ "flex flex-col items-start justify-between gap-1 p-4 has-data-[slot=card-action]:grid has-data-[slot=card-action]:grid-cols-[1fr_auto]",
21
21
  className,
22
22
  )}
23
23
  {...props}
@@ -26,14 +26,14 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
26
26
  }
27
27
 
28
28
  function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
29
- return <div data-slot="card-title" className={cn(className)} {...props} />;
29
+ return <div data-slot="card-title" className={cn("text-xl", className)} {...props} />;
30
30
  }
31
31
 
32
32
  function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
33
33
  return (
34
34
  <div
35
35
  data-slot="card-description"
36
- className={cn("text-muted-foreground text-sm", className)}
36
+ className={cn("text-muted-foreground text-xs", className)}
37
37
  {...props}
38
38
  />
39
39
  );
@@ -55,12 +55,8 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
55
55
 
56
56
  function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
57
57
  return (
58
- <div
59
- data-slot="card-footer"
60
- className={cn("flex items-center border-t px-4 py-3", className)}
61
- {...props}
62
- />
58
+ <div data-slot="card-footer" className={cn("flex items-center p-4", className)} {...props} />
63
59
  );
64
60
  }
65
61
 
66
- export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
62
+ export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
@@ -27,21 +27,3 @@ export const WithInput: Story = {
27
27
  </div>
28
28
  ),
29
29
  };
30
-
31
- export const Disabled: Story = {
32
- render: () => (
33
- <div className="flex w-full max-w-sm flex-col gap-2">
34
- <Label htmlFor="disabled-field">Subscription plan</Label>
35
- <div className="flex items-center gap-3">
36
- <Input id="disabled-field" className="peer" disabled placeholder="Enterprise" />
37
- <Label htmlFor="disabled-field" className="shrink-0">
38
- (locked)
39
- </Label>
40
- </div>
41
- <p className="text-muted-foreground text-xs">
42
- The <code className="font-mono">peer-disabled</code> variant mutes the label when its paired
43
- input is disabled.
44
- </p>
45
- </div>
46
- ),
47
- };
@@ -1,20 +1,20 @@
1
- "use client";
2
-
3
- import { cva, type VariantProps } from "class-variance-authority";
4
1
  import * as React from "react";
5
2
 
6
3
  import { cn } from "../../lib/utils";
7
4
 
8
- const labelVariants = cva(
9
- "text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
5
+ const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
6
+ ({ className, ...props }, ref) => (
7
+ <label
8
+ htmlFor={props.htmlFor}
9
+ ref={ref}
10
+ className={cn(
11
+ "text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ ),
10
17
  );
11
-
12
- const Label = React.forwardRef<
13
- HTMLLabelElement,
14
- React.LabelHTMLAttributes<HTMLLabelElement> & VariantProps<typeof labelVariants>
15
- >(({ className, ...props }, ref) => (
16
- <label ref={ref} className={cn(labelVariants(), className)} {...props} />
17
- ));
18
18
  Label.displayName = "Label";
19
19
 
20
20
  export { Label };
@@ -4,15 +4,15 @@
4
4
  */
5
5
 
6
6
  export const CHART_COLORS = {
7
- chart1: "var(--color-chart-1)",
8
- chart2: "var(--color-chart-2)",
9
- chart3: "var(--color-chart-3)",
10
- chart4: "var(--color-chart-4)",
11
- chart5: "var(--color-chart-5)",
7
+ chart1: "var(--chart-1)",
8
+ chart2: "var(--chart-2)",
9
+ chart3: "var(--chart-3)",
10
+ chart4: "var(--chart-4)",
11
+ chart5: "var(--chart-5)",
12
12
 
13
- positive: "var(--color-chart-positive)",
14
- negative: "var(--color-chart-negative)",
15
- neutral: "var(--color-chart-neutral)",
13
+ positive: "var(--chart-positive)",
14
+ negative: "var(--chart-negative)",
15
+ neutral: "var(--chart-neutral)",
16
16
 
17
17
  background: "var(--color-background)",
18
18
  grid: "var(--color-border)",
@@ -28,12 +28,12 @@ export const CHART_COLORS = {
28
28
  } as const;
29
29
 
30
30
  export const CHART_CSS_VARS = {
31
- positive: "var(--color-chart-positive)",
32
- negative: "var(--color-chart-negative)",
33
- neutral: "var(--color-chart-neutral)",
34
- chart1: "var(--color-chart-1)",
35
- chart2: "var(--color-chart-2)",
36
- chart3: "var(--color-chart-3)",
37
- chart4: "var(--color-chart-4)",
38
- chart5: "var(--color-chart-5)",
31
+ positive: "var(--chart-positive)",
32
+ negative: "var(--chart-negative)",
33
+ neutral: "var(--chart-neutral)",
34
+ chart1: "var(--chart-1)",
35
+ chart2: "var(--chart-2)",
36
+ chart3: "var(--chart-3)",
37
+ chart4: "var(--chart-4)",
38
+ chart5: "var(--chart-5)",
39
39
  } as const;
@@ -29,6 +29,10 @@ function mediaQuery(query: string, options?: MediaQueryOptions) {
29
29
  return mql.matches;
30
30
  }
31
31
 
32
+ function getServerSnapshot() {
33
+ return false;
34
+ }
35
+
32
36
  /**
33
37
  * Listens for changes to a media query and returns a boolean indicating whether the query matches.
34
38
  * @param query - The media query string to listen for.
@@ -61,5 +65,5 @@ export function useMediaQuery(query: string) {
61
65
  return mediaQuery(query);
62
66
  }, [query]);
63
67
 
64
- return useSyncExternalStore(subscribe, getSnapshot, () => false);
68
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
65
69
  }
@@ -51,28 +51,6 @@
51
51
  }
52
52
  }
53
53
 
54
- .animate-in {
55
- animation:
56
- fade-in 150ms ease-out,
57
- zoom-in 150ms ease-out;
58
- }
59
- .animate-out {
60
- animation:
61
- fade-out 120ms ease-in,
62
- zoom-out 120ms ease-in;
63
- }
64
- .fade-in-0 {
65
- animation-name: fade-in;
66
- }
67
- .fade-out-0 {
68
- animation-name: fade-out;
69
- }
70
- .zoom-in-95 {
71
- animation-name: zoom-in;
72
- }
73
- .zoom-out-95 {
74
- animation-name: zoom-out;
75
- }
76
54
  .data-open\:animate-in[data-open],
77
55
  .data-open\:animate-in[data-state="open"] {
78
56
  animation:
@@ -19,6 +19,30 @@
19
19
  @import "./tokens/generated/dark.css" (prefers-color-scheme: dark);
20
20
  @import "./animations.css";
21
21
 
22
+ @theme {
23
+ --animate-accordion-down: accordion-down 200ms ease-out;
24
+ --animate-accordion-up: accordion-up 200ms ease-out;
25
+ --animate-in: fade-in 150ms ease-out, zoom-in 150ms ease-out;
26
+ --animate-out: fade-out 120ms ease-in, zoom-out 120ms ease-in;
27
+ --animate-fade-in: fade-in 150ms ease-out;
28
+ --animate-fade-out: fade-out 120ms ease-in;
29
+ --animate-zoom-in: zoom-in 150ms ease-out;
30
+ --animate-zoom-out: zoom-out 120ms ease-in;
31
+ }
32
+
33
+ @utility fade-in-0 {
34
+ animation-name: fade-in;
35
+ }
36
+ @utility fade-out-0 {
37
+ animation-name: fade-out;
38
+ }
39
+ @utility zoom-in-95 {
40
+ animation-name: zoom-in;
41
+ }
42
+ @utility zoom-out-95 {
43
+ animation-name: zoom-out;
44
+ }
45
+
22
46
  /* shadcn New York semantic contract. Values are generated from the DTCG
23
47
  * source; this file only names the roles consumed by web components. */
24
48
  @theme inline {
@@ -101,22 +125,52 @@
101
125
  * Default fade size: 16 px (scoped to the element via inline var).
102
126
  * Disable with scroll-fade-none (sets --scroll-fade-mask: none). */
103
127
  @utility scroll-fade-x {
104
- mask-image: var(--scroll-fade-mask, linear-gradient(to right, transparent, black var(--scroll-fade-size, 16px), black calc(100% - var(--scroll-fade-size, 16px)), transparent));
128
+ mask-image: var(
129
+ --scroll-fade-mask,
130
+ linear-gradient(
131
+ to right,
132
+ transparent,
133
+ black var(--scroll-fade-size, 16px),
134
+ black calc(100% - var(--scroll-fade-size, 16px)),
135
+ transparent
136
+ )
137
+ );
105
138
  }
106
139
  @utility scroll-fade-y {
107
- mask-image: var(--scroll-fade-mask, linear-gradient(to bottom, transparent, black var(--scroll-fade-size, 16px), black calc(100% - var(--scroll-fade-size, 16px)), transparent));
140
+ mask-image: var(
141
+ --scroll-fade-mask,
142
+ linear-gradient(
143
+ to bottom,
144
+ transparent,
145
+ black var(--scroll-fade-size, 16px),
146
+ black calc(100% - var(--scroll-fade-size, 16px)),
147
+ transparent
148
+ )
149
+ );
108
150
  }
109
151
  @utility scroll-fade-l {
110
- mask-image: var(--scroll-fade-mask, linear-gradient(to right, transparent, black var(--scroll-fade-size, 16px)));
152
+ mask-image: var(
153
+ --scroll-fade-mask,
154
+ linear-gradient(to right, transparent, black var(--scroll-fade-size, 16px))
155
+ );
111
156
  }
112
157
  @utility scroll-fade-r {
113
- mask-image: var(--scroll-fade-mask, linear-gradient(to left, transparent, black var(--scroll-fade-size, 16px)));
158
+ mask-image: var(
159
+ --scroll-fade-mask,
160
+ linear-gradient(to left, transparent, black var(--scroll-fade-size, 16px))
161
+ );
114
162
  }
115
163
  @utility scroll-fade-t {
116
- mask-image: var(--scroll-fade-mask, linear-gradient(to bottom, transparent, black var(--scroll-fade-size, 16px)));
164
+ mask-image: var(
165
+ --scroll-fade-mask,
166
+ linear-gradient(to bottom, transparent, black var(--scroll-fade-size, 16px))
167
+ );
117
168
  }
118
169
  @utility scroll-fade-b {
119
- mask-image: var(--scroll-fade-mask, linear-gradient(to top, transparent, black var(--scroll-fade-size, 16px)));
170
+ mask-image: var(
171
+ --scroll-fade-mask,
172
+ linear-gradient(to top, transparent, black var(--scroll-fade-size, 16px))
173
+ );
120
174
  }
121
175
  @utility scroll-fade-none {
122
176
  --scroll-fade-mask: none;
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
 
3
3
  import { Button } from "../../components/primitives/button";
4
+ import { colors } from "./css-vars";
4
5
  import { colorTokenNames } from "./index";
5
6
 
6
7
  const meta: Meta = { title: "Foundations/Colors", parameters: { layout: "fullscreen" } };
@@ -28,7 +29,7 @@ export const SystemAppearance: Story = {
28
29
  <div
29
30
  key={token}
30
31
  className="border-border grid min-h-20 content-end rounded-md border p-3"
31
- style={{ backgroundColor: `var(--color-${token})` }}
32
+ style={{ backgroundColor: colors[token] }}
32
33
  >
33
34
  <span className="bg-background text-foreground w-fit rounded px-1 font-mono text-xs">
34
35
  {token}
@@ -24,8 +24,8 @@ export default {
24
24
  "chart-3": "#12a594",
25
25
  "chart-4": "#f76b15",
26
26
  "chart-5": "#8b8d98",
27
- "chart-positive": "#30a46c",
28
- "chart-negative": "#e5484d",
29
- "chart-neutral": "#b9bbc6",
27
+ "chart-positive": "#1f7a50",
28
+ "chart-negative": "#be2e33",
29
+ "chart-neutral": "#626a73",
30
30
  "overlay-scrim": "#000000",
31
31
  };
@@ -24,8 +24,8 @@
24
24
  --chart-3: #12a594;
25
25
  --chart-4: #f76b15;
26
26
  --chart-5: #8b8d98;
27
- --chart-positive: #30a46c;
28
- --chart-negative: #e5484d;
29
- --chart-neutral: #b9bbc6;
27
+ --chart-positive: #1f7a50;
28
+ --chart-negative: #be2e33;
29
+ --chart-neutral: #626a73;
30
30
  --overlay-scrim: #000000;
31
31
  }
@@ -88,15 +88,15 @@
88
88
  },
89
89
  "chart-positive": {
90
90
  "$type": "color",
91
- "$value": "rgb(48, 164, 108)"
91
+ "$value": "rgb(31, 122, 80)"
92
92
  },
93
93
  "chart-negative": {
94
94
  "$type": "color",
95
- "$value": "rgb(229, 72, 77)"
95
+ "$value": "rgb(190, 46, 51)"
96
96
  },
97
97
  "chart-neutral": {
98
98
  "$type": "color",
99
- "$value": "rgb(185, 187, 198)"
99
+ "$value": "rgb(98, 106, 115)"
100
100
  },
101
101
  "overlay-scrim": {
102
102
  "$type": "color",
@@ -0,0 +1,16 @@
1
+ {
2
+ "source": ["src/styles/tokens/source/light.tokens.json"],
3
+ "platforms": {
4
+ "css": {
5
+ "transformGroup": "css",
6
+ "buildPath": ".storybook/generated/",
7
+ "files": [
8
+ {
9
+ "destination": "light-theme-class.css",
10
+ "format": "css/variables",
11
+ "options": { "selector": ":root.storybook-theme-light" }
12
+ }
13
+ ]
14
+ }
15
+ }
16
+ }
@@ -1,28 +0,0 @@
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
- };
@@ -1,33 +0,0 @@
1
- import { memo } from "react";
2
-
3
- import { cn } from "../../lib/utils";
4
-
5
- export type FileUploadStatusValue = "uploading" | "processing" | "queued" | "done" | "error";
6
-
7
- const STATUS_CONFIG: Record<FileUploadStatusValue, { bg: string; text: string; label: string }> = {
8
- uploading: { bg: "bg-muted", text: "text-foreground", label: "Uploading" },
9
- processing: { bg: "bg-muted", text: "text-foreground", label: "Processing" },
10
- queued: { bg: "bg-secondary", text: "text-secondary-foreground", label: "Queued" },
11
- done: { bg: "bg-muted", text: "text-muted-foreground", label: "Complete" },
12
- error: { bg: "bg-destructive/10", text: "text-destructive-text", 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
- });
@@ -1,62 +0,0 @@
1
- import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import { expect, userEvent, within } from "storybook/test";
3
-
4
- import { Field } from "./field";
5
- import { Input } from "./input";
6
-
7
- const meta = {
8
- title: "Forms/Field",
9
- component: Field,
10
- tags: ["autodocs"],
11
- args: {
12
- children: <Input placeholder="you@example.com" />,
13
- },
14
- } satisfies Meta<typeof Field>;
15
-
16
- export default meta;
17
- type Story = StoryObj<typeof meta>;
18
-
19
- export const Default: Story = {
20
- render: () => (
21
- <Field label="Email" helpText="We will never share your address.">
22
- <Input placeholder="you@example.com" />
23
- </Field>
24
- ),
25
- play: async ({ canvasElement }) => {
26
- const canvas = within(canvasElement);
27
- const input = canvas.getByLabelText("Email");
28
-
29
- await userEvent.type(input, "you@example.com");
30
-
31
- await expect(input).toHaveValue("you@example.com");
32
- await expect(canvas.getByText("We will never share your address.")).toBeInTheDocument();
33
- },
34
- };
35
-
36
- export const Error: Story = {
37
- render: () => (
38
- <Field label="Email" error="Email is required">
39
- <Input placeholder="you@example.com" />
40
- </Field>
41
- ),
42
- play: async ({ canvasElement }) => {
43
- const canvas = within(canvasElement);
44
-
45
- await expect(canvas.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true");
46
- await expect(canvas.getByRole("alert")).toHaveTextContent("Email is required");
47
- },
48
- };
49
-
50
- export const Textarea: Story = {
51
- render: () => (
52
- <Field label="Notes" helpText="Markdown supported">
53
- <textarea rows={5} placeholder="Write something..." />
54
- </Field>
55
- ),
56
- play: async ({ canvasElement }) => {
57
- const canvas = within(canvasElement);
58
-
59
- await expect(canvas.getByLabelText("Notes")).toBeInTheDocument();
60
- await expect(canvas.getByText("Markdown supported")).toBeInTheDocument();
61
- },
62
- };
@@ -1,65 +0,0 @@
1
- import * as React from "react";
2
-
3
- import { cn } from "../../lib/utils";
4
- import type { FieldBaseProps } from "./field.types";
5
-
6
- interface FieldProps extends FieldBaseProps {
7
- children: React.ReactNode;
8
- }
9
-
10
- /**
11
- * Field — standalone label + control + helper/error slot.
12
- * Wires label ↔ control association automatically via a generated id.
13
- * Does NOT require react-hook-form — works with any controlled or uncontrolled input.
14
- *
15
- * @example
16
- * <Field label="Email" error={errors.email}>
17
- * <Input type="email" />
18
- * </Field>
19
- */
20
- function Field({ label, helpText, error, required, children, id: externalId }: FieldProps) {
21
- const generatedId = React.useId();
22
- const id = externalId ?? generatedId;
23
- const descId = `${id}-desc`;
24
- const errorId = `${id}-error`;
25
-
26
- return (
27
- <div className="flex flex-col gap-1.5">
28
- {label && (
29
- <label
30
- htmlFor={id}
31
- className={cn(
32
- "text-foreground text-sm font-medium",
33
- required && "after:text-destructive-text after:ml-0.5 after:content-['*']",
34
- )}
35
- >
36
- {label}
37
- </label>
38
- )}
39
-
40
- {/* Clone child to inject id and aria attributes */}
41
- {React.isValidElement(children)
42
- ? React.cloneElement(
43
- children as React.ReactElement<React.HTMLAttributes<HTMLElement>>,
44
- {
45
- id,
46
- "aria-describedby": error ? errorId : helpText ? descId : undefined,
47
- "aria-invalid": error ? true : undefined,
48
- } as React.HTMLAttributes<HTMLElement>,
49
- )
50
- : children}
51
-
52
- {error ? (
53
- <p id={errorId} role="alert" className="text-destructive-text text-sm">
54
- {error}
55
- </p>
56
- ) : helpText ? (
57
- <p id={descId} className="text-muted-foreground text-sm">
58
- {helpText}
59
- </p>
60
- ) : null}
61
- </div>
62
- );
63
- }
64
-
65
- export { Field, type FieldProps };
@@ -1,7 +0,0 @@
1
- export interface FieldBaseProps {
2
- error?: string | undefined;
3
- helpText?: string | undefined;
4
- id?: string | undefined;
5
- label?: string | undefined;
6
- required?: boolean | undefined;
7
- }
@@ -1,26 +0,0 @@
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
- };