@bearmenu/ui 0.5.2 → 0.6.1

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 (227) 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 +4 -1
  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 +3 -1
  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 +16 -3
  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/scroll-fade.d.ts +15 -0
  86. package/dist/components/scroll-fade.js +116 -0
  87. package/dist/components/search-bar.d.ts +1 -2
  88. package/dist/components/search-bar.js +37 -60
  89. package/dist/components/searchable-select.js +76 -72
  90. package/dist/components/select.d.ts +5 -1
  91. package/dist/components/select.js +30 -14
  92. package/dist/components/selection-bar.d.ts +11 -0
  93. package/dist/components/selection-bar.js +65 -0
  94. package/dist/components/separator.js +3 -2
  95. package/dist/components/sheet.d.ts +1 -1
  96. package/dist/components/sheet.js +1 -1
  97. package/dist/components/skeleton-card.js +1 -1
  98. package/dist/components/skeleton.js +1 -1
  99. package/dist/components/slider.js +21 -4
  100. package/dist/components/sliding-panels.js +9 -6
  101. package/dist/components/spinner.d.ts +17 -0
  102. package/dist/components/spinner.js +50 -0
  103. package/dist/components/stepper.d.ts +18 -0
  104. package/dist/components/stepper.js +199 -0
  105. package/dist/components/switch.js +4 -3
  106. package/dist/components/table.d.ts +23 -5
  107. package/dist/components/table.js +121 -35
  108. package/dist/components/tabs.d.ts +3 -1
  109. package/dist/components/tabs.js +37 -9
  110. package/dist/components/tag-group.d.ts +9 -0
  111. package/dist/components/tag-group.js +26 -0
  112. package/dist/components/tag.d.ts +17 -0
  113. package/dist/components/tag.js +61 -0
  114. package/dist/components/text.d.ts +1 -1
  115. package/dist/components/text.js +1 -1
  116. package/dist/components/textarea.js +2 -1
  117. package/dist/components/toast.js +1 -1
  118. package/dist/components/toaster.js +1 -1
  119. package/dist/components/toggle-group.js +1 -1
  120. package/dist/components/toggle.js +1 -1
  121. package/dist/components/tooltip.d.ts +3 -2
  122. package/dist/components/tooltip.js +16 -2
  123. package/package.json +45 -26
  124. package/src/components/accordion.tsx +11 -6
  125. package/src/components/alert-dialog.tsx +4 -2
  126. package/src/components/alert.tsx +20 -8
  127. package/src/components/avatar.tsx +106 -13
  128. package/src/components/badge.tsx +12 -11
  129. package/src/components/breadcrumb.tsx +5 -2
  130. package/src/components/button-group.stories.tsx +52 -0
  131. package/src/components/button-group.tsx +40 -0
  132. package/src/components/button.test.tsx +174 -0
  133. package/src/components/button.tsx +42 -19
  134. package/src/components/calendar.tsx +2 -2
  135. package/src/components/card.tsx +55 -13
  136. package/src/components/carousel.tsx +85 -13
  137. package/src/components/checkbox.test.tsx +58 -0
  138. package/src/components/checkbox.tsx +8 -3
  139. package/src/components/circular-progress.stories.tsx +39 -0
  140. package/src/components/circular-progress.tsx +102 -0
  141. package/src/components/collapsible.stories.tsx +7 -3
  142. package/src/components/combobox.stories.tsx +82 -0
  143. package/src/components/combobox.test.tsx +118 -0
  144. package/src/components/combobox.tsx +136 -0
  145. package/src/components/command.test.tsx +91 -0
  146. package/src/components/command.tsx +38 -15
  147. package/src/components/currency-input.test.tsx +63 -0
  148. package/src/components/currency-input.tsx +84 -66
  149. package/src/components/date-input.stories.tsx +31 -0
  150. package/src/components/date-input.tsx +43 -0
  151. package/src/components/description-list.stories.tsx +62 -0
  152. package/src/components/description-list.tsx +68 -0
  153. package/src/components/dialog.test.tsx +84 -0
  154. package/src/components/dialog.tsx +64 -24
  155. package/src/components/drawer.tsx +72 -23
  156. package/src/components/dropdown-menu.test.tsx +180 -0
  157. package/src/components/dropdown-menu.tsx +31 -6
  158. package/src/components/field.stories.tsx +36 -0
  159. package/src/components/field.test.tsx +157 -0
  160. package/src/components/field.tsx +146 -0
  161. package/src/components/form.stories.tsx +3 -2
  162. package/src/components/form.test.tsx +95 -0
  163. package/src/components/form.tsx +9 -0
  164. package/src/components/haptic-button.tsx +1 -1
  165. package/src/components/input-group.stories.tsx +61 -0
  166. package/src/components/input-group.tsx +99 -0
  167. package/src/components/input.test.tsx +103 -0
  168. package/src/components/input.tsx +24 -7
  169. package/src/components/item.stories.tsx +52 -0
  170. package/src/components/item.tsx +115 -0
  171. package/src/components/kbd.stories.tsx +33 -0
  172. package/src/components/kbd.tsx +46 -0
  173. package/src/components/lazy-markdown.tsx +10 -7
  174. package/src/components/multi-select-combobox.test.tsx +101 -0
  175. package/src/components/multi-select-combobox.tsx +85 -82
  176. package/src/components/multi-select-menu.tsx +49 -41
  177. package/src/components/native-select.stories.tsx +52 -0
  178. package/src/components/native-select.tsx +45 -0
  179. package/src/components/pagination.tsx +64 -5
  180. package/src/components/popover.tsx +66 -3
  181. package/src/components/progress.tsx +27 -4
  182. package/src/components/radio-group.test.tsx +64 -0
  183. package/src/components/radio-group.tsx +11 -3
  184. package/src/components/rating.test.tsx +112 -0
  185. package/src/components/rating.tsx +97 -82
  186. package/src/components/scroll-fade.stories.tsx +98 -0
  187. package/src/components/scroll-fade.tsx +127 -0
  188. package/src/components/search-bar.stories.tsx +1 -32
  189. package/src/components/search-bar.tsx +22 -59
  190. package/src/components/searchable-select.tsx +69 -73
  191. package/src/components/select.tsx +34 -19
  192. package/src/components/selection-bar.stories.tsx +37 -0
  193. package/src/components/selection-bar.tsx +65 -0
  194. package/src/components/separator.tsx +5 -2
  195. package/src/components/sheet.tsx +1 -1
  196. package/src/components/skeleton.tsx +2 -2
  197. package/src/components/slider.test.tsx +45 -0
  198. package/src/components/slider.tsx +31 -12
  199. package/src/components/sliding-panels.tsx +8 -5
  200. package/src/components/spinner.stories.tsx +35 -0
  201. package/src/components/spinner.tsx +50 -0
  202. package/src/components/stepper.stories.tsx +71 -0
  203. package/src/components/stepper.tsx +220 -0
  204. package/src/components/switch.test.tsx +52 -0
  205. package/src/components/switch.tsx +4 -3
  206. package/src/components/table.tsx +140 -35
  207. package/src/components/tabs.test.tsx +107 -0
  208. package/src/components/tabs.tsx +70 -23
  209. package/src/components/tag-group.stories.tsx +39 -0
  210. package/src/components/tag-group.tsx +28 -0
  211. package/src/components/tag.stories.tsx +50 -0
  212. package/src/components/tag.tsx +63 -0
  213. package/src/components/text.test.tsx +1 -1
  214. package/src/components/text.tsx +19 -3
  215. package/src/components/textarea.test.tsx +48 -0
  216. package/src/components/textarea.tsx +2 -1
  217. package/src/components/toast.tsx +4 -3
  218. package/src/components/toggle.tsx +2 -2
  219. package/src/components/tooltip.tsx +12 -2
  220. package/src/globals.css +126 -90
  221. package/dist/chunk-2BMJRXQX.js +0 -80
  222. package/dist/chunk-4AVS6CV4.js +0 -89
  223. package/dist/chunk-4UO73SKC.js +0 -26
  224. package/dist/chunk-CW24PLDW.js +0 -24
  225. package/dist/chunk-IQASCH2V.js +0 -25
  226. package/dist/chunk-NKHH23YK.js +0 -31
  227. package/dist/chunk-QJ54Z7TC.js +0 -83
@@ -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 };
@@ -0,0 +1,103 @@
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 { Input, inputVariants } from "./input";
5
+
6
+ // ─── inputVariants CVA ────────────────────────────────────────────────────────
7
+
8
+ describe("inputVariants", () => {
9
+ it("returns default size classes when called with no args", () => {
10
+ const result = inputVariants({});
11
+ expect(result).toContain("h-11");
12
+ });
13
+
14
+ it("maps size prop to correct classes", () => {
15
+ expect(inputVariants({ size: "sm" })).toContain("h-9");
16
+ expect(inputVariants({ size: "default" })).toContain("h-11");
17
+ expect(inputVariants({ size: "lg" })).toContain("h-12");
18
+ });
19
+
20
+ it("includes aria-invalid styles in base classes", () => {
21
+ const result = inputVariants({});
22
+ expect(result).toContain("aria-invalid:border-destructive");
23
+ });
24
+ });
25
+
26
+ // ─── <Input> component ────────────────────────────────────────────────────────
27
+
28
+ describe("Input component", () => {
29
+ it("renders as <input> by default", () => {
30
+ render(<Input placeholder="enter" />);
31
+ const el = screen.getByPlaceholderText("enter");
32
+ expect(el.tagName).toBe("INPUT");
33
+ });
34
+
35
+ it("applies data-slot=input attribute", () => {
36
+ render(<Input data-testid="i" />);
37
+ expect(screen.getByTestId("i").getAttribute("data-slot")).toBe("input");
38
+ });
39
+
40
+ it("forwards ref to the underlying input element", () => {
41
+ const ref = { current: null as HTMLInputElement | null };
42
+ render(<Input ref={ref} />);
43
+ expect(ref.current).toBeInstanceOf(HTMLInputElement);
44
+ });
45
+
46
+ it("respects type prop", () => {
47
+ render(<Input type="email" data-testid="i" />);
48
+ expect((screen.getByTestId("i") as HTMLInputElement).type).toBe("email");
49
+ });
50
+
51
+ it("accepts user input", async () => {
52
+ const user = userEvent.setup();
53
+ render(<Input data-testid="i" />);
54
+ const el = screen.getByTestId("i") as HTMLInputElement;
55
+ await user.type(el, "hello");
56
+ expect(el.value).toBe("hello");
57
+ });
58
+
59
+ it("calls onChange handler", async () => {
60
+ const onChange = vi.fn();
61
+ const user = userEvent.setup();
62
+ render(<Input data-testid="i" onChange={onChange} />);
63
+ await user.type(screen.getByTestId("i"), "a");
64
+ expect(onChange).toHaveBeenCalled();
65
+ });
66
+
67
+ it("respects disabled prop", async () => {
68
+ const onChange = vi.fn();
69
+ const user = userEvent.setup();
70
+ render(<Input data-testid="i" disabled onChange={onChange} />);
71
+ const el = screen.getByTestId("i") as HTMLInputElement;
72
+ expect(el.disabled).toBe(true);
73
+ await user.type(el, "x");
74
+ expect(onChange).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("wires aria-invalid for invalid state", () => {
78
+ render(<Input data-testid="i" aria-invalid="true" />);
79
+ expect(screen.getByTestId("i").getAttribute("aria-invalid")).toBe("true");
80
+ });
81
+
82
+ it("respects controlled value", () => {
83
+ render(<Input data-testid="i" value="hello" onChange={() => {}} />);
84
+ expect((screen.getByTestId("i") as HTMLInputElement).value).toBe("hello");
85
+ });
86
+
87
+ it("applies size class from size prop", () => {
88
+ render(<Input data-testid="i" size="sm" />);
89
+ expect(screen.getByTestId("i").className).toContain("h-9");
90
+ });
91
+
92
+ it("merges className via twMerge", () => {
93
+ render(<Input data-testid="i" className="text-red-500" />);
94
+ expect(screen.getByTestId("i").className).toContain("text-red-500");
95
+ });
96
+
97
+ it("passes through name and id", () => {
98
+ render(<Input id="email" name="email" data-testid="i" />);
99
+ const el = screen.getByTestId("i");
100
+ expect(el.id).toBe("email");
101
+ expect(el.getAttribute("name")).toBe("email");
102
+ });
103
+ });
@@ -1,17 +1,34 @@
1
1
  import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
2
3
  import { cn } from "../lib/utils";
3
4
 
4
- export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
5
+ const inputVariants = cva(
6
+ "flex w-full rounded-xl border border-border bg-input/50 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:bg-input disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/40",
7
+ {
8
+ variants: {
9
+ size: {
10
+ sm: "h-9 px-3 text-base md:text-sm",
11
+ default: "h-11 px-4 py-2",
12
+ lg: "h-12 px-5 text-base",
13
+ },
14
+ },
15
+ defaultVariants: {
16
+ size: "default",
17
+ },
18
+ }
19
+ );
20
+
21
+ export interface InputProps
22
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
23
+ VariantProps<typeof inputVariants> {}
5
24
 
6
25
  const Input = React.forwardRef<HTMLInputElement, InputProps>(
7
- ({ className, type, ...props }, ref) => {
26
+ ({ className, type, size, ...props }, ref) => {
8
27
  return (
9
28
  <input
10
29
  type={type}
11
- className={cn(
12
- "flex h-11 w-full rounded-xl border border-border bg-input/50 backdrop-blur-md px-4 py-2 text-base transition-all duration-300 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:bg-input focus-visible:border-accent-foreground/30 focus-visible:shadow-lg focus-visible:shadow-accent/10 disabled:cursor-not-allowed disabled:opacity-50",
13
- className
14
- )}
30
+ data-slot="input"
31
+ className={cn(inputVariants({ size }), className)}
15
32
  ref={ref}
16
33
  {...props}
17
34
  />
@@ -20,4 +37,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
20
37
  );
21
38
  Input.displayName = "Input";
22
39
 
23
- export { Input };
40
+ export { Input, inputVariants };
@@ -0,0 +1,52 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Mail, ChevronRight } from "lucide-react";
3
+ import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "./item";
4
+ import { Avatar, AvatarFallback } from "./avatar";
5
+
6
+ const meta = {
7
+ title: "Components/Item",
8
+ component: Item,
9
+ tags: ["autodocs"],
10
+ } satisfies Meta<typeof Item>;
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ export const Default: Story = {
16
+ render: () => (
17
+ <Item interactive className="w-80">
18
+ <ItemMedia>
19
+ <Mail className="size-5" />
20
+ </ItemMedia>
21
+ <ItemContent>
22
+ <ItemTitle>Inbox</ItemTitle>
23
+ <ItemDescription>3 new messages</ItemDescription>
24
+ </ItemContent>
25
+ <ItemActions>
26
+ <ChevronRight className="size-4 text-muted-foreground" />
27
+ </ItemActions>
28
+ </Item>
29
+ ),
30
+ };
31
+
32
+ export const Variants: Story = {
33
+ render: () => (
34
+ <div className="flex w-80 flex-col gap-2">
35
+ <Item variant="default" interactive>
36
+ <ItemMedia>
37
+ <Avatar size="sm"><AvatarFallback>AS</AvatarFallback></Avatar>
38
+ </ItemMedia>
39
+ <ItemContent>
40
+ <ItemTitle>Alice Smith</ItemTitle>
41
+ <ItemDescription>Online</ItemDescription>
42
+ </ItemContent>
43
+ </Item>
44
+ <Item variant="muted">
45
+ <ItemContent><ItemTitle>Muted background</ItemTitle></ItemContent>
46
+ </Item>
47
+ <Item variant="outline">
48
+ <ItemContent><ItemTitle>Outline border</ItemTitle></ItemContent>
49
+ </Item>
50
+ </div>
51
+ ),
52
+ };