@bearmenu/ui 0.5.1 → 0.6.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 (225) hide show
  1. package/dist/{chunk-ERMIA7NP.js → chunk-7PPCH42H.js} +3 -3
  2. package/dist/{chunk-HLQ2IRE7.js → chunk-BAZFVSSG.js} +3 -3
  3. package/dist/chunk-D5QHEHXJ.js +26 -0
  4. package/dist/{chunk-UISA4CIG.js → chunk-N2KNBPAQ.js} +2 -2
  5. package/dist/{chunk-7H54PTVH.js → chunk-PSR7N36A.js} +38 -10
  6. package/dist/chunk-RIDXFY6M.js +38 -0
  7. package/dist/chunk-UUJVXN2O.js +82 -0
  8. package/dist/chunk-WKMLZDUT.js +122 -0
  9. package/dist/chunk-XK2ZDQVE.js +122 -0
  10. package/dist/{chunk-4KOMHDZD.js → chunk-Y5D5LP4A.js} +18 -3
  11. package/dist/chunk-YJ6OBKZJ.js +32 -0
  12. package/dist/{chunk-ESO2EM3L.js → chunk-ZBBUKXM5.js} +2 -2
  13. package/dist/chunk-ZFNOCP6W.js +106 -0
  14. package/dist/components/accordion.js +11 -5
  15. package/dist/components/alert-dialog.js +5 -3
  16. package/dist/components/alert.d.ts +5 -2
  17. package/dist/components/alert.js +25 -9
  18. package/dist/components/avatar.d.ts +17 -2
  19. package/dist/components/avatar.js +91 -12
  20. package/dist/components/badge.d.ts +1 -1
  21. package/dist/components/badge.js +1 -1
  22. package/dist/components/breadcrumb.js +5 -2
  23. package/dist/components/button-group.d.ts +14 -0
  24. package/dist/components/button-group.js +39 -0
  25. package/dist/components/button.d.ts +4 -2
  26. package/dist/components/button.js +1 -1
  27. package/dist/components/calendar.js +3 -3
  28. package/dist/components/card.d.ts +13 -5
  29. package/dist/components/card.js +2 -2
  30. package/dist/components/carousel.d.ts +20 -3
  31. package/dist/components/carousel.js +72 -12
  32. package/dist/components/checkbox.js +1 -1
  33. package/dist/components/circular-progress.d.ts +16 -0
  34. package/dist/components/circular-progress.js +105 -0
  35. package/dist/components/combobox.d.ts +27 -0
  36. package/dist/components/combobox.js +102 -0
  37. package/dist/components/command.d.ts +29 -16
  38. package/dist/components/command.js +2 -2
  39. package/dist/components/currency-input.d.ts +1 -0
  40. package/dist/components/currency-input.js +73 -52
  41. package/dist/components/date-input.d.ts +10 -0
  42. package/dist/components/date-input.js +40 -0
  43. package/dist/components/description-list.d.ts +15 -0
  44. package/dist/components/description-list.js +67 -0
  45. package/dist/components/dialog.d.ts +13 -2
  46. package/dist/components/dialog.js +1 -1
  47. package/dist/components/drawer.d.ts +5 -2
  48. package/dist/components/drawer.js +47 -9
  49. package/dist/components/dropdown-menu.d.ts +7 -2
  50. package/dist/components/dropdown-menu.js +22 -6
  51. package/dist/components/empty-state.js +2 -2
  52. package/dist/components/field.d.ts +22 -0
  53. package/dist/components/field.js +130 -0
  54. package/dist/components/form.d.ts +2 -1
  55. package/dist/components/form.js +11 -1
  56. package/dist/components/haptic-button.js +3 -3
  57. package/dist/components/input-group.d.ts +18 -0
  58. package/dist/components/input-group.js +92 -0
  59. package/dist/components/input.d.ts +8 -2
  60. package/dist/components/input.js +1 -1
  61. package/dist/components/item.d.ts +23 -0
  62. package/dist/components/item.js +120 -0
  63. package/dist/components/kbd.d.ts +13 -0
  64. package/dist/components/kbd.js +49 -0
  65. package/dist/components/lazy-markdown.d.ts +3 -1
  66. package/dist/components/lazy-markdown.js +7 -7
  67. package/dist/components/link-button.js +1 -1
  68. package/dist/components/multi-select-combobox.js +92 -83
  69. package/dist/components/multi-select-menu.d.ts +0 -4
  70. package/dist/components/multi-select-menu.js +28 -17
  71. package/dist/components/native-select.d.ts +12 -0
  72. package/dist/components/native-select.js +49 -0
  73. package/dist/components/pagination.d.ts +5 -2
  74. package/dist/components/pagination.js +66 -7
  75. package/dist/components/place-about-tab.js +4 -4
  76. package/dist/components/place-card.js +1 -1
  77. package/dist/components/place-details-mobile.js +4 -4
  78. package/dist/components/popover.d.ts +7 -1
  79. package/dist/components/popover.js +1 -1
  80. package/dist/components/progress.d.ts +5 -1
  81. package/dist/components/progress.js +17 -3
  82. package/dist/components/radio-group.js +12 -3
  83. package/dist/components/rating.d.ts +2 -0
  84. package/dist/components/rating.js +88 -70
  85. package/dist/components/search-bar.d.ts +1 -2
  86. package/dist/components/search-bar.js +37 -60
  87. package/dist/components/searchable-select.js +76 -72
  88. package/dist/components/select.d.ts +5 -1
  89. package/dist/components/select.js +30 -14
  90. package/dist/components/selection-bar.d.ts +11 -0
  91. package/dist/components/selection-bar.js +65 -0
  92. package/dist/components/separator.js +3 -2
  93. package/dist/components/sheet.d.ts +1 -1
  94. package/dist/components/sheet.js +1 -1
  95. package/dist/components/skeleton-card.js +1 -1
  96. package/dist/components/skeleton.js +1 -1
  97. package/dist/components/slider.js +21 -4
  98. package/dist/components/sliding-panels.js +15 -13
  99. package/dist/components/spinner.d.ts +17 -0
  100. package/dist/components/spinner.js +50 -0
  101. package/dist/components/stepper.d.ts +18 -0
  102. package/dist/components/stepper.js +199 -0
  103. package/dist/components/sticky-container.js +1 -0
  104. package/dist/components/switch.js +4 -3
  105. package/dist/components/table.d.ts +23 -5
  106. package/dist/components/table.js +121 -35
  107. package/dist/components/tabs.d.ts +3 -1
  108. package/dist/components/tabs.js +37 -9
  109. package/dist/components/tag-group.d.ts +9 -0
  110. package/dist/components/tag-group.js +26 -0
  111. package/dist/components/tag.d.ts +17 -0
  112. package/dist/components/tag.js +61 -0
  113. package/dist/components/text.d.ts +3 -3
  114. package/dist/components/text.js +1 -1
  115. package/dist/components/textarea.js +2 -1
  116. package/dist/components/toast.js +1 -1
  117. package/dist/components/toaster.js +1 -1
  118. package/dist/components/toggle-group.js +1 -1
  119. package/dist/components/toggle.js +1 -1
  120. package/dist/components/tooltip.d.ts +3 -2
  121. package/dist/components/tooltip.js +16 -2
  122. package/package.json +44 -26
  123. package/src/components/accordion.tsx +11 -6
  124. package/src/components/alert-dialog.tsx +4 -2
  125. package/src/components/alert.tsx +20 -8
  126. package/src/components/avatar.tsx +106 -13
  127. package/src/components/badge.tsx +12 -11
  128. package/src/components/breadcrumb.tsx +5 -2
  129. package/src/components/button-group.stories.tsx +52 -0
  130. package/src/components/button-group.tsx +40 -0
  131. package/src/components/button.test.tsx +174 -0
  132. package/src/components/button.tsx +42 -19
  133. package/src/components/calendar.tsx +2 -2
  134. package/src/components/card.tsx +55 -13
  135. package/src/components/carousel.tsx +85 -13
  136. package/src/components/checkbox.test.tsx +58 -0
  137. package/src/components/checkbox.tsx +8 -3
  138. package/src/components/circular-progress.stories.tsx +39 -0
  139. package/src/components/circular-progress.tsx +102 -0
  140. package/src/components/collapsible.stories.tsx +7 -3
  141. package/src/components/combobox.stories.tsx +82 -0
  142. package/src/components/combobox.test.tsx +118 -0
  143. package/src/components/combobox.tsx +136 -0
  144. package/src/components/command.test.tsx +91 -0
  145. package/src/components/command.tsx +38 -15
  146. package/src/components/currency-input.test.tsx +63 -0
  147. package/src/components/currency-input.tsx +84 -66
  148. package/src/components/date-input.stories.tsx +31 -0
  149. package/src/components/date-input.tsx +43 -0
  150. package/src/components/description-list.stories.tsx +62 -0
  151. package/src/components/description-list.tsx +68 -0
  152. package/src/components/dialog.test.tsx +84 -0
  153. package/src/components/dialog.tsx +64 -24
  154. package/src/components/drawer.tsx +72 -23
  155. package/src/components/dropdown-menu.test.tsx +180 -0
  156. package/src/components/dropdown-menu.tsx +31 -6
  157. package/src/components/field.stories.tsx +36 -0
  158. package/src/components/field.test.tsx +157 -0
  159. package/src/components/field.tsx +146 -0
  160. package/src/components/form.stories.tsx +3 -2
  161. package/src/components/form.test.tsx +95 -0
  162. package/src/components/form.tsx +9 -0
  163. package/src/components/haptic-button.tsx +1 -1
  164. package/src/components/input-group.stories.tsx +61 -0
  165. package/src/components/input-group.tsx +99 -0
  166. package/src/components/input.test.tsx +103 -0
  167. package/src/components/input.tsx +24 -7
  168. package/src/components/item.stories.tsx +52 -0
  169. package/src/components/item.tsx +115 -0
  170. package/src/components/kbd.stories.tsx +33 -0
  171. package/src/components/kbd.tsx +46 -0
  172. package/src/components/lazy-markdown.tsx +10 -7
  173. package/src/components/multi-select-combobox.test.tsx +101 -0
  174. package/src/components/multi-select-combobox.tsx +85 -82
  175. package/src/components/multi-select-menu.tsx +49 -41
  176. package/src/components/native-select.stories.tsx +52 -0
  177. package/src/components/native-select.tsx +45 -0
  178. package/src/components/pagination.tsx +64 -5
  179. package/src/components/popover.tsx +66 -3
  180. package/src/components/progress.tsx +27 -4
  181. package/src/components/radio-group.test.tsx +64 -0
  182. package/src/components/radio-group.tsx +11 -3
  183. package/src/components/rating.test.tsx +112 -0
  184. package/src/components/rating.tsx +97 -82
  185. package/src/components/search-bar.stories.tsx +1 -32
  186. package/src/components/search-bar.tsx +22 -59
  187. package/src/components/searchable-select.tsx +69 -73
  188. package/src/components/select.tsx +34 -19
  189. package/src/components/selection-bar.stories.tsx +37 -0
  190. package/src/components/selection-bar.tsx +65 -0
  191. package/src/components/separator.tsx +5 -2
  192. package/src/components/sheet.tsx +1 -1
  193. package/src/components/skeleton.tsx +2 -2
  194. package/src/components/slider.test.tsx +45 -0
  195. package/src/components/slider.tsx +31 -12
  196. package/src/components/sliding-panels.tsx +14 -12
  197. package/src/components/spinner.stories.tsx +35 -0
  198. package/src/components/spinner.tsx +50 -0
  199. package/src/components/stepper.stories.tsx +71 -0
  200. package/src/components/stepper.tsx +220 -0
  201. package/src/components/sticky-container.tsx +1 -0
  202. package/src/components/switch.test.tsx +52 -0
  203. package/src/components/switch.tsx +4 -3
  204. package/src/components/table.tsx +140 -35
  205. package/src/components/tabs.test.tsx +107 -0
  206. package/src/components/tabs.tsx +70 -23
  207. package/src/components/tag-group.stories.tsx +39 -0
  208. package/src/components/tag-group.tsx +28 -0
  209. package/src/components/tag.stories.tsx +50 -0
  210. package/src/components/tag.tsx +63 -0
  211. package/src/components/text.test.tsx +1 -1
  212. package/src/components/text.tsx +19 -3
  213. package/src/components/textarea.test.tsx +48 -0
  214. package/src/components/textarea.tsx +2 -1
  215. package/src/components/toast.tsx +4 -3
  216. package/src/components/toggle.tsx +2 -2
  217. package/src/components/tooltip.tsx +12 -2
  218. package/src/globals.css +142 -90
  219. package/dist/chunk-2BMJRXQX.js +0 -80
  220. package/dist/chunk-4AVS6CV4.js +0 -89
  221. package/dist/chunk-4UO73SKC.js +0 -26
  222. package/dist/chunk-CW24PLDW.js +0 -24
  223. package/dist/chunk-IQASCH2V.js +0 -25
  224. package/dist/chunk-NKHH23YK.js +0 -31
  225. package/dist/chunk-QJ54Z7TC.js +0 -83
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import {
4
+ Field,
5
+ FieldLabel,
6
+ FieldControl,
7
+ FieldDescription,
8
+ FieldError,
9
+ FieldSet,
10
+ FieldLegend,
11
+ } from "./field";
12
+ import { Input } from "./input";
13
+
14
+ describe("Field a11y wiring", () => {
15
+ it("auto-generates an id and wires label htmlFor to control id", () => {
16
+ render(
17
+ <Field>
18
+ <FieldLabel>Email</FieldLabel>
19
+ <FieldControl>
20
+ <Input data-testid="i" />
21
+ </FieldControl>
22
+ </Field>
23
+ );
24
+ const label = screen.getByText("Email") as HTMLLabelElement;
25
+ const input = screen.getByTestId("i");
26
+ const id = input.id;
27
+ expect(id).toBeTruthy();
28
+ expect(label.getAttribute("for")).toBe(id);
29
+ });
30
+
31
+ it("uses a provided id over the generated one", () => {
32
+ render(
33
+ <Field id="custom-id">
34
+ <FieldLabel>L</FieldLabel>
35
+ <FieldControl>
36
+ <Input data-testid="i" />
37
+ </FieldControl>
38
+ </Field>
39
+ );
40
+ expect(screen.getByTestId("i").id).toBe("custom-id");
41
+ });
42
+
43
+ it("wires aria-describedby to the description by default", () => {
44
+ render(
45
+ <Field id="f1">
46
+ <FieldControl>
47
+ <Input data-testid="i" />
48
+ </FieldControl>
49
+ <FieldDescription>Helper text</FieldDescription>
50
+ </Field>
51
+ );
52
+ const input = screen.getByTestId("i");
53
+ const desc = screen.getByText("Helper text");
54
+ expect(desc.id).toBe("f1-description");
55
+ expect(input.getAttribute("aria-describedby")).toBe("f1-description");
56
+ });
57
+
58
+ it("renders FieldError and wires aria-invalid + aria-describedby when invalid", () => {
59
+ render(
60
+ <Field id="f2" invalid>
61
+ <FieldControl>
62
+ <Input data-testid="i" />
63
+ </FieldControl>
64
+ <FieldDescription>Helper</FieldDescription>
65
+ <FieldError>Required</FieldError>
66
+ </Field>
67
+ );
68
+ const input = screen.getByTestId("i");
69
+ const err = screen.getByRole("alert");
70
+ expect(input.getAttribute("aria-invalid")).toBe("true");
71
+ expect(err.textContent).toBe("Required");
72
+ expect(err.id).toBe("f2-error");
73
+ expect(input.getAttribute("aria-describedby")).toBe("f2-description f2-error");
74
+ });
75
+
76
+ it("does not render FieldError when not invalid", () => {
77
+ render(
78
+ <Field id="f3">
79
+ <FieldControl>
80
+ <Input data-testid="i" />
81
+ </FieldControl>
82
+ <FieldError>Error msg</FieldError>
83
+ </Field>
84
+ );
85
+ expect(screen.queryByRole("alert")).toBeNull();
86
+ });
87
+
88
+ it("does not render FieldError when invalid but no children", () => {
89
+ render(
90
+ <Field id="f4" invalid>
91
+ <FieldControl>
92
+ <Input data-testid="i" />
93
+ </FieldControl>
94
+ <FieldError>{null}</FieldError>
95
+ </Field>
96
+ );
97
+ expect(screen.queryByRole("alert")).toBeNull();
98
+ });
99
+
100
+ it("applies destructive class to label when invalid", () => {
101
+ render(
102
+ <Field invalid>
103
+ <FieldLabel>L</FieldLabel>
104
+ <FieldControl>
105
+ <Input />
106
+ </FieldControl>
107
+ </Field>
108
+ );
109
+ expect(screen.getByText("L").className).toContain("text-destructive");
110
+ });
111
+
112
+ it("sets data-invalid on the field wrapper when invalid", () => {
113
+ render(
114
+ <Field invalid data-testid="wrapper">
115
+ <FieldControl>
116
+ <Input />
117
+ </FieldControl>
118
+ </Field>
119
+ );
120
+ expect(screen.getByTestId("wrapper").getAttribute("data-invalid")).toBe("true");
121
+ });
122
+
123
+ it("applies horizontal orientation class", () => {
124
+ render(
125
+ <Field orientation="horizontal" data-testid="wrapper">
126
+ <FieldControl>
127
+ <Input />
128
+ </FieldControl>
129
+ </Field>
130
+ );
131
+ expect(screen.getByTestId("wrapper").className).toContain("flex-row");
132
+ });
133
+
134
+ it("applies vertical orientation class by default", () => {
135
+ render(
136
+ <Field data-testid="wrapper">
137
+ <FieldControl>
138
+ <Input />
139
+ </FieldControl>
140
+ </Field>
141
+ );
142
+ expect(screen.getByTestId("wrapper").className).toContain("flex-col");
143
+ });
144
+ });
145
+
146
+ describe("FieldSet / FieldLegend", () => {
147
+ it("renders fieldset + legend correctly", () => {
148
+ render(
149
+ <FieldSet data-testid="fs">
150
+ <FieldLegend>Group</FieldLegend>
151
+ </FieldSet>
152
+ );
153
+ const fs = screen.getByTestId("fs");
154
+ expect(fs.tagName).toBe("FIELDSET");
155
+ expect(screen.getByText("Group").tagName).toBe("LEGEND");
156
+ });
157
+ });
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { cn } from "../lib/utils";
6
+ import { Label } from "./label";
7
+
8
+ const FieldContext = React.createContext<{
9
+ id: string;
10
+ descriptionId: string;
11
+ errorId: string;
12
+ hasError: boolean;
13
+ }>({ id: "", descriptionId: "", errorId: "", hasError: false });
14
+
15
+ type FieldProps = React.HTMLAttributes<HTMLDivElement> & {
16
+ /** Field id. Auto-generated if omitted. */
17
+ id?: string;
18
+ /** Marks the field invalid. */
19
+ invalid?: boolean;
20
+ /** Lay out content horizontally instead of stacked. */
21
+ orientation?: "vertical" | "horizontal";
22
+ };
23
+
24
+ const Field = React.forwardRef<HTMLDivElement, FieldProps>(
25
+ ({ id: providedId, invalid, orientation = "vertical", className, children, ...props }, ref) => {
26
+ const generatedId = React.useId();
27
+ const id = providedId ?? generatedId;
28
+ const descriptionId = `${id}-description`;
29
+ const errorId = `${id}-error`;
30
+
31
+ return (
32
+ <FieldContext.Provider value={{ id, descriptionId, errorId, hasError: !!invalid }}>
33
+ <div
34
+ ref={ref}
35
+ data-slot="field"
36
+ data-invalid={invalid || undefined}
37
+ className={cn(
38
+ "flex gap-2",
39
+ orientation === "vertical" ? "flex-col" : "flex-row items-center",
40
+ className
41
+ )}
42
+ {...props}
43
+ >
44
+ {children}
45
+ </div>
46
+ </FieldContext.Provider>
47
+ );
48
+ }
49
+ );
50
+ Field.displayName = "Field";
51
+
52
+ const FieldLabel = React.forwardRef<
53
+ HTMLLabelElement,
54
+ React.ComponentPropsWithoutRef<typeof Label>
55
+ >(({ className, ...props }, ref) => {
56
+ const { id, hasError } = React.useContext(FieldContext);
57
+ return (
58
+ <Label
59
+ ref={ref}
60
+ data-slot="field-label"
61
+ htmlFor={id}
62
+ className={cn(hasError && "text-destructive", className)}
63
+ {...props}
64
+ />
65
+ );
66
+ });
67
+ FieldLabel.displayName = "FieldLabel";
68
+
69
+ const FieldControl = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<typeof Slot>>(
70
+ ({ ...props }, ref) => {
71
+ const { id, descriptionId, errorId, hasError } = React.useContext(FieldContext);
72
+ return (
73
+ <Slot
74
+ ref={ref}
75
+ data-slot="field-control"
76
+ id={id}
77
+ aria-describedby={hasError ? `${descriptionId} ${errorId}` : descriptionId}
78
+ aria-invalid={hasError || undefined}
79
+ {...props}
80
+ />
81
+ );
82
+ }
83
+ );
84
+ FieldControl.displayName = "FieldControl";
85
+
86
+ const FieldDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
87
+ ({ className, ...props }, ref) => {
88
+ const { descriptionId } = React.useContext(FieldContext);
89
+ return (
90
+ <p
91
+ ref={ref}
92
+ id={descriptionId}
93
+ data-slot="field-description"
94
+ className={cn("text-sm text-muted-foreground", className)}
95
+ {...props}
96
+ />
97
+ );
98
+ }
99
+ );
100
+ FieldDescription.displayName = "FieldDescription";
101
+
102
+ const FieldError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
103
+ ({ className, children, ...props }, ref) => {
104
+ const { errorId, hasError } = React.useContext(FieldContext);
105
+ if (!hasError || !children) return null;
106
+ return (
107
+ <p
108
+ ref={ref}
109
+ id={errorId}
110
+ role="alert"
111
+ data-slot="field-error"
112
+ className={cn("text-sm text-destructive", className)}
113
+ {...props}
114
+ >
115
+ {children}
116
+ </p>
117
+ );
118
+ }
119
+ );
120
+ FieldError.displayName = "FieldError";
121
+
122
+ const FieldSet = React.forwardRef<HTMLFieldSetElement, React.FieldsetHTMLAttributes<HTMLFieldSetElement>>(
123
+ ({ className, ...props }, ref) => (
124
+ <fieldset
125
+ ref={ref}
126
+ data-slot="field-set"
127
+ className={cn("flex flex-col gap-3 rounded-xl border border-border p-4", className)}
128
+ {...props}
129
+ />
130
+ )
131
+ );
132
+ FieldSet.displayName = "FieldSet";
133
+
134
+ const FieldLegend = React.forwardRef<HTMLLegendElement, React.HTMLAttributes<HTMLLegendElement>>(
135
+ ({ className, ...props }, ref) => (
136
+ <legend
137
+ ref={ref}
138
+ data-slot="field-legend"
139
+ className={cn("px-1 text-sm font-medium text-foreground", className)}
140
+ {...props}
141
+ />
142
+ )
143
+ );
144
+ FieldLegend.displayName = "FieldLegend";
145
+
146
+ export { Field, FieldLabel, FieldControl, FieldDescription, FieldError, FieldSet, FieldLegend };
@@ -8,6 +8,7 @@ import {
8
8
  FormItem,
9
9
  FormLabel,
10
10
  FormMessage,
11
+ FormRoot,
11
12
  } from "./form";
12
13
  import { Input } from "./input";
13
14
  import { Button } from "./button";
@@ -31,7 +32,7 @@ function FormDemo() {
31
32
 
32
33
  return (
33
34
  <Form {...form}>
34
- <form onSubmit={form.handleSubmit(() => {})} className="space-y-6 w-[400px]">
35
+ <FormRoot onSubmit={form.handleSubmit(() => {})} className="space-y-6 w-[400px]">
35
36
  <FormField
36
37
  control={form.control}
37
38
  name="username"
@@ -63,7 +64,7 @@ function FormDemo() {
63
64
  )}
64
65
  />
65
66
  <Button type="submit">Submit</Button>
66
- </form>
67
+ </FormRoot>
67
68
  </Form>
68
69
  );
69
70
  }
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { useForm } from "react-hook-form";
5
+ import {
6
+ Form,
7
+ FormRoot,
8
+ FormField,
9
+ FormItem,
10
+ FormLabel,
11
+ FormControl,
12
+ FormDescription,
13
+ FormMessage,
14
+ } from "./form";
15
+ import { Input } from "./input";
16
+
17
+ function Wrapper({
18
+ onSubmit,
19
+ defaultValues,
20
+ }: {
21
+ onSubmit?: (data: { name: string }) => void;
22
+ defaultValues?: { name: string };
23
+ }) {
24
+ const form = useForm<{ name: string }>({
25
+ defaultValues: defaultValues ?? { name: "" },
26
+ });
27
+ return (
28
+ <Form {...form}>
29
+ <FormRoot onSubmit={form.handleSubmit((data) => onSubmit?.(data))}>
30
+ <FormField
31
+ control={form.control}
32
+ name="name"
33
+ rules={{ required: "Name is required" }}
34
+ render={({ field }) => (
35
+ <FormItem>
36
+ <FormLabel>Name</FormLabel>
37
+ <FormControl>
38
+ <Input {...field} />
39
+ </FormControl>
40
+ <FormDescription>Pick a name</FormDescription>
41
+ <FormMessage />
42
+ </FormItem>
43
+ )}
44
+ />
45
+ <button type="submit">submit</button>
46
+ </FormRoot>
47
+ </Form>
48
+ );
49
+ }
50
+
51
+ describe("Form", () => {
52
+ it("FormRoot renders a form element with noValidate by default", () => {
53
+ const { container } = render(<Wrapper />);
54
+ const form = container.querySelector("form");
55
+ expect(form).not.toBeNull();
56
+ expect(form?.noValidate).toBe(true);
57
+ });
58
+
59
+ it("FormLabel links to the input via htmlFor / id", () => {
60
+ render(<Wrapper />);
61
+ const label = screen.getByText("Name");
62
+ const input = screen.getByRole("textbox");
63
+ expect(label.getAttribute("for")).toBe(input.id);
64
+ });
65
+
66
+ it("FormDescription is wired through aria-describedby", () => {
67
+ render(<Wrapper />);
68
+ const input = screen.getByRole("textbox");
69
+ const description = screen.getByText("Pick a name");
70
+ expect(input.getAttribute("aria-describedby") ?? "").toContain(description.id);
71
+ });
72
+
73
+ it("submits valid values via onSubmit", async () => {
74
+ const user = userEvent.setup();
75
+ const onSubmit = vi.fn();
76
+ render(<Wrapper onSubmit={onSubmit} defaultValues={{ name: "ada" }} />);
77
+ await user.click(screen.getByRole("button", { name: /submit/i }));
78
+ expect(onSubmit).toHaveBeenCalledWith({ name: "ada" });
79
+ });
80
+
81
+ it("renders an error message and sets aria-invalid when validation fails", async () => {
82
+ const user = userEvent.setup();
83
+ const onSubmit = vi.fn();
84
+ render(<Wrapper onSubmit={onSubmit} />);
85
+ await user.click(screen.getByRole("button", { name: /submit/i }));
86
+ expect(onSubmit).not.toHaveBeenCalled();
87
+ expect(await screen.findByText("Name is required")).toBeDefined();
88
+ expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
89
+ });
90
+
91
+ it("FormRoot allows overriding noValidate to false", () => {
92
+ const { container } = render(<FormRoot noValidate={false} />);
93
+ expect(container.querySelector("form")?.noValidate).toBe(false);
94
+ });
95
+ });
@@ -18,6 +18,14 @@ import { Label } from "./label";
18
18
 
19
19
  const Form = FormProvider;
20
20
 
21
+ function FormRoot({
22
+ noValidate = true,
23
+ className,
24
+ ...props
25
+ }: React.FormHTMLAttributes<HTMLFormElement>) {
26
+ return <form data-slot="form-root" noValidate={noValidate} className={className} {...props} />;
27
+ }
28
+
21
29
  type FormFieldContextValue<
22
30
  TFieldValues extends FieldValues = FieldValues,
23
31
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
@@ -143,6 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
143
151
  export {
144
152
  useFormField,
145
153
  Form,
154
+ FormRoot,
146
155
  FormItem,
147
156
  FormLabel,
148
157
  FormControl,
@@ -15,5 +15,5 @@ export function HapticButton({ hapticStyle = "light", onClick, ...props }: Hapti
15
15
  onClick?.(e);
16
16
  };
17
17
 
18
- return <Button onClick={handleClick} {...props} />;
18
+ return <Button {...props} onClick={handleClick} haptic={false} />;
19
19
  }
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Mail, Search } from "lucide-react";
3
+ import {
4
+ InputGroup,
5
+ InputGroupAddon,
6
+ InputGroupButton,
7
+ InputGroupInput,
8
+ } from "./input-group";
9
+
10
+ const meta = {
11
+ title: "Components/InputGroup",
12
+ component: InputGroup,
13
+ tags: ["autodocs"],
14
+ } satisfies Meta<typeof InputGroup>;
15
+
16
+ export default meta;
17
+ type Story = StoryObj<typeof meta>;
18
+
19
+ export const WithLeadingIcon: Story = {
20
+ render: () => (
21
+ <InputGroup className="w-72">
22
+ <InputGroupAddon>
23
+ <Search className="size-4" />
24
+ </InputGroupAddon>
25
+ <InputGroupInput placeholder="Search…" />
26
+ </InputGroup>
27
+ ),
28
+ };
29
+
30
+ export const WithTrailingButton: Story = {
31
+ render: () => (
32
+ <InputGroup className="w-72">
33
+ <InputGroupAddon>
34
+ <Mail className="size-4" />
35
+ </InputGroupAddon>
36
+ <InputGroupInput type="email" placeholder="you@example.com" />
37
+ <InputGroupAddon align="end">
38
+ <InputGroupButton>Send</InputGroupButton>
39
+ </InputGroupAddon>
40
+ </InputGroup>
41
+ ),
42
+ };
43
+
44
+ export const Sizes: Story = {
45
+ render: () => (
46
+ <div className="flex flex-col gap-3 w-72">
47
+ <InputGroup size="sm">
48
+ <InputGroupAddon><Search className="size-4" /></InputGroupAddon>
49
+ <InputGroupInput placeholder="Small" />
50
+ </InputGroup>
51
+ <InputGroup size="default">
52
+ <InputGroupAddon><Search className="size-4" /></InputGroupAddon>
53
+ <InputGroupInput placeholder="Default" />
54
+ </InputGroup>
55
+ <InputGroup size="lg">
56
+ <InputGroupAddon><Search className="size-4" /></InputGroupAddon>
57
+ <InputGroupInput placeholder="Large" />
58
+ </InputGroup>
59
+ </div>
60
+ ),
61
+ };
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+ import { cn } from "../lib/utils";
7
+
8
+ const inputGroupVariants = cva(
9
+ "flex items-center w-full rounded-xl border border-border bg-background text-base md:text-sm text-foreground transition-colors focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 aria-invalid:border-destructive aria-invalid:focus-within:ring-destructive/40",
10
+ {
11
+ variants: {
12
+ size: {
13
+ sm: "h-9",
14
+ default: "h-11",
15
+ lg: "h-12",
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ size: "default",
20
+ },
21
+ }
22
+ );
23
+
24
+ type InputGroupContextValue = VariantProps<typeof inputGroupVariants>;
25
+ const InputGroupContext = React.createContext<InputGroupContextValue>({});
26
+
27
+ const InputGroup = React.forwardRef<
28
+ HTMLDivElement,
29
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof inputGroupVariants>
30
+ >(({ className, size, children, ...props }, ref) => (
31
+ <InputGroupContext.Provider value={{ size }}>
32
+ <div
33
+ ref={ref}
34
+ data-slot="input-group"
35
+ className={cn(inputGroupVariants({ size }), className)}
36
+ {...props}
37
+ >
38
+ {children}
39
+ </div>
40
+ </InputGroupContext.Provider>
41
+ ));
42
+ InputGroup.displayName = "InputGroup";
43
+
44
+ const InputGroupInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
45
+ ({ className, ...props }, ref) => (
46
+ <input
47
+ ref={ref}
48
+ data-slot="input-group-input"
49
+ className={cn(
50
+ "h-full min-w-0 flex-1 bg-transparent px-3 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
51
+ className
52
+ )}
53
+ {...props}
54
+ />
55
+ )
56
+ );
57
+ InputGroupInput.displayName = "InputGroupInput";
58
+
59
+ const InputGroupAddon = React.forwardRef<
60
+ HTMLDivElement,
61
+ React.HTMLAttributes<HTMLDivElement> & {
62
+ align?: "start" | "end";
63
+ asChild?: boolean;
64
+ }
65
+ >(({ className, align = "start", asChild, ...props }, ref) => {
66
+ const Comp = asChild ? Slot : "div";
67
+ return (
68
+ <Comp
69
+ ref={ref}
70
+ data-slot="input-group-addon"
71
+ data-align={align}
72
+ className={cn(
73
+ "inline-flex h-full items-center gap-1 text-sm text-muted-foreground",
74
+ align === "start" ? "pl-3 pr-1" : "pl-1 pr-3",
75
+ className
76
+ )}
77
+ {...props}
78
+ />
79
+ );
80
+ });
81
+ InputGroupAddon.displayName = "InputGroupAddon";
82
+
83
+ const InputGroupButton = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
84
+ ({ className, type = "button", ...props }, ref) => (
85
+ <button
86
+ ref={ref}
87
+ type={type}
88
+ data-slot="input-group-button"
89
+ className={cn(
90
+ "inline-flex h-7 min-w-7 cursor-pointer items-center justify-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&:has(svg:only-child)]:w-7 [&:has(svg:only-child)]:px-0 [&_svg]:size-4",
91
+ className
92
+ )}
93
+ {...props}
94
+ />
95
+ )
96
+ );
97
+ InputGroupButton.displayName = "InputGroupButton";
98
+
99
+ export { InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton, inputGroupVariants };