@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
@@ -4,18 +4,27 @@ import * as React from "react";
4
4
  import { Drawer as DrawerPrimitive } from "vaul";
5
5
  import { cn } from "../lib/utils";
6
6
 
7
+ type DrawerDirection = "top" | "bottom" | "left" | "right";
8
+
9
+ const DrawerDirectionContext = React.createContext<DrawerDirection>("bottom");
10
+
7
11
  const Drawer = ({
8
12
  shouldScaleBackground = false,
13
+ direction = "bottom",
9
14
  ...props
10
15
  }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
11
- <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
16
+ <DrawerDirectionContext.Provider value={direction}>
17
+ <DrawerPrimitive.Root
18
+ shouldScaleBackground={shouldScaleBackground}
19
+ direction={direction}
20
+ {...props}
21
+ />
22
+ </DrawerDirectionContext.Provider>
12
23
  );
13
24
  Drawer.displayName = "Drawer";
14
25
 
15
26
  const DrawerTrigger = DrawerPrimitive.Trigger;
16
-
17
27
  const DrawerPortal = DrawerPrimitive.Portal;
18
-
19
28
  const DrawerClose = DrawerPrimitive.Close;
20
29
 
21
30
  const DrawerOverlay = React.forwardRef<
@@ -24,40 +33,78 @@ const DrawerOverlay = React.forwardRef<
24
33
  >(({ className, ...props }, ref) => (
25
34
  <DrawerPrimitive.Overlay
26
35
  ref={ref}
36
+ data-slot="drawer-overlay"
27
37
  className={cn("fixed inset-0 z-[1000] bg-black/60", className)}
28
38
  {...props}
29
39
  />
30
40
  ));
31
41
  DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
32
42
 
43
+ type DrawerContentProps = React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
44
+ variant?: "flush" | "floating";
45
+ showHandle?: boolean;
46
+ };
47
+
33
48
  const DrawerContent = React.forwardRef<
34
49
  React.ElementRef<typeof DrawerPrimitive.Content>,
35
- React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
36
- >(({ className, children, ...props }, ref) => (
37
- <DrawerPortal>
38
- <DrawerOverlay />
39
- <DrawerPrimitive.Content
40
- ref={ref}
41
- className={cn(
42
- "fixed inset-x-0 bottom-0 z-[1000] flex h-auto max-h-[90vh] flex-col rounded-t-2xl bg-surface border-t border-border shadow-2xl",
43
- className
44
- )}
45
- {...props}
46
- >
47
- <div className="mx-auto mt-4 h-2 w-[100px] rounded-full dark:bg-muted bg-primary" />
48
- {children}
49
- </DrawerPrimitive.Content>
50
- </DrawerPortal>
51
- ));
50
+ DrawerContentProps
51
+ >(({ className, variant = "flush", showHandle, children, ...props }, ref) => {
52
+ const direction = React.useContext(DrawerDirectionContext);
53
+ const isVertical = direction === "top" || direction === "bottom";
54
+ const handleEnabled = showHandle ?? (direction === "bottom");
55
+
56
+ const positionClasses =
57
+ direction === "bottom"
58
+ ? "inset-x-0 bottom-0 flex-col rounded-t-2xl border-t [padding-bottom:max(env(safe-area-inset-bottom),0px)]"
59
+ : direction === "top"
60
+ ? "inset-x-0 top-0 flex-col rounded-b-2xl border-b [padding-top:max(env(safe-area-inset-top),0px)]"
61
+ : direction === "left"
62
+ ? "inset-y-0 left-0 h-full w-3/4 max-w-sm flex-col rounded-r-2xl border-r [padding-left:max(env(safe-area-inset-left),0px)]"
63
+ : "inset-y-0 right-0 h-full w-3/4 max-w-sm flex-col rounded-l-2xl border-l [padding-right:max(env(safe-area-inset-right),0px)]";
64
+
65
+ return (
66
+ <DrawerPortal>
67
+ <DrawerOverlay />
68
+ <DrawerPrimitive.Content
69
+ ref={ref}
70
+ data-slot="drawer-content"
71
+ className={cn(
72
+ "fixed z-[1000] flex border-border bg-card shadow-2xl",
73
+ positionClasses,
74
+ isVertical ? "max-h-[90vh]" : "",
75
+ variant === "floating" && "m-3 rounded-2xl border",
76
+ className
77
+ )}
78
+ {...props}
79
+ >
80
+ {handleEnabled && (
81
+ <div
82
+ aria-hidden
83
+ className="mx-auto mt-2 h-1.5 w-[60px] shrink-0 rounded-full bg-muted-foreground/30"
84
+ />
85
+ )}
86
+ {children}
87
+ </DrawerPrimitive.Content>
88
+ </DrawerPortal>
89
+ );
90
+ });
52
91
  DrawerContent.displayName = "DrawerContent";
53
92
 
54
93
  const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
55
- <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
94
+ <div
95
+ data-slot="drawer-header"
96
+ className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
97
+ {...props}
98
+ />
56
99
  );
57
100
  DrawerHeader.displayName = "DrawerHeader";
58
101
 
59
102
  const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
60
- <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
103
+ <div
104
+ data-slot="drawer-footer"
105
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
106
+ {...props}
107
+ />
61
108
  );
62
109
  DrawerFooter.displayName = "DrawerFooter";
63
110
 
@@ -67,6 +114,7 @@ const DrawerTitle = React.forwardRef<
67
114
  >(({ className, ...props }, ref) => (
68
115
  <DrawerPrimitive.Title
69
116
  ref={ref}
117
+ data-slot="drawer-title"
70
118
  className={cn("text-lg font-semibold leading-none tracking-tight", className)}
71
119
  {...props}
72
120
  />
@@ -79,7 +127,8 @@ const DrawerDescription = React.forwardRef<
79
127
  >(({ className, ...props }, ref) => (
80
128
  <DrawerPrimitive.Description
81
129
  ref={ref}
82
- className={cn("text-sm dark:text-muted-foreground light:text-dark", className)}
130
+ data-slot="drawer-description"
131
+ className={cn("text-sm text-muted-foreground", className)}
83
132
  {...props}
84
133
  />
85
134
  ));
@@ -0,0 +1,180 @@
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 {
5
+ DropdownMenu,
6
+ DropdownMenuTrigger,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuCheckboxItem,
10
+ DropdownMenuRadioGroup,
11
+ DropdownMenuRadioItem,
12
+ DropdownMenuLabel,
13
+ DropdownMenuSeparator,
14
+ } from "./dropdown-menu";
15
+ import * as React from "react";
16
+
17
+ describe("DropdownMenu", () => {
18
+ it("is closed by default", () => {
19
+ render(
20
+ <DropdownMenu>
21
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
22
+ <DropdownMenuContent>
23
+ <DropdownMenuItem>One</DropdownMenuItem>
24
+ </DropdownMenuContent>
25
+ </DropdownMenu>
26
+ );
27
+ expect(screen.queryByRole("menu")).toBeNull();
28
+ });
29
+
30
+ it("opens on trigger click and renders items with role=menuitem", async () => {
31
+ const user = userEvent.setup();
32
+ render(
33
+ <DropdownMenu>
34
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
35
+ <DropdownMenuContent>
36
+ <DropdownMenuItem>One</DropdownMenuItem>
37
+ <DropdownMenuItem>Two</DropdownMenuItem>
38
+ </DropdownMenuContent>
39
+ </DropdownMenu>
40
+ );
41
+ await user.click(screen.getByText("Open"));
42
+ expect(screen.getByRole("menu")).toBeInTheDocument();
43
+ expect(screen.getAllByRole("menuitem")).toHaveLength(2);
44
+ });
45
+
46
+ it("fires onSelect when item is clicked", async () => {
47
+ const user = userEvent.setup();
48
+ const onSelect = vi.fn();
49
+ render(
50
+ <DropdownMenu>
51
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
52
+ <DropdownMenuContent>
53
+ <DropdownMenuItem onSelect={onSelect}>One</DropdownMenuItem>
54
+ </DropdownMenuContent>
55
+ </DropdownMenu>
56
+ );
57
+ await user.click(screen.getByText("Open"));
58
+ await user.click(screen.getByRole("menuitem", { name: "One" }));
59
+ expect(onSelect).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it("checkbox item has role=menuitemcheckbox and reflects checked state", async () => {
63
+ const user = userEvent.setup();
64
+ function Wrap() {
65
+ const [checked, setChecked] = React.useState(false);
66
+ return (
67
+ <DropdownMenu>
68
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
69
+ <DropdownMenuContent>
70
+ <DropdownMenuCheckboxItem checked={checked} onCheckedChange={setChecked}>
71
+ Notify
72
+ </DropdownMenuCheckboxItem>
73
+ </DropdownMenuContent>
74
+ </DropdownMenu>
75
+ );
76
+ }
77
+ render(<Wrap />);
78
+ await user.click(screen.getByText("Open"));
79
+ const item = screen.getByRole("menuitemcheckbox");
80
+ expect(item.getAttribute("aria-checked")).toBe("false");
81
+ await user.click(item);
82
+ // Menu closes on select but state mutates — reopen to check
83
+ await user.click(screen.getByText("Open"));
84
+ expect(screen.getByRole("menuitemcheckbox").getAttribute("aria-checked")).toBe("true");
85
+ });
86
+
87
+ it("radio group exposes role=menuitemradio with single selection", async () => {
88
+ const user = userEvent.setup();
89
+ function Wrap() {
90
+ const [value, setValue] = React.useState("a");
91
+ return (
92
+ <DropdownMenu>
93
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
94
+ <DropdownMenuContent>
95
+ <DropdownMenuRadioGroup value={value} onValueChange={setValue}>
96
+ <DropdownMenuRadioItem value="a">A</DropdownMenuRadioItem>
97
+ <DropdownMenuRadioItem value="b">B</DropdownMenuRadioItem>
98
+ </DropdownMenuRadioGroup>
99
+ </DropdownMenuContent>
100
+ </DropdownMenu>
101
+ );
102
+ }
103
+ render(<Wrap />);
104
+ await user.click(screen.getByText("Open"));
105
+ const items = screen.getAllByRole("menuitemradio");
106
+ expect(items).toHaveLength(2);
107
+ expect(items[0].getAttribute("aria-checked")).toBe("true");
108
+ expect(items[1].getAttribute("aria-checked")).toBe("false");
109
+ });
110
+
111
+ it("closes on Escape", async () => {
112
+ const user = userEvent.setup();
113
+ render(
114
+ <DropdownMenu>
115
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
116
+ <DropdownMenuContent>
117
+ <DropdownMenuItem>One</DropdownMenuItem>
118
+ </DropdownMenuContent>
119
+ </DropdownMenu>
120
+ );
121
+ await user.click(screen.getByText("Open"));
122
+ await user.keyboard("{Escape}");
123
+ expect(screen.queryByRole("menu")).toBeNull();
124
+ });
125
+
126
+ it("supports keyboard navigation through menu items", async () => {
127
+ const user = userEvent.setup();
128
+ render(
129
+ <DropdownMenu>
130
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
131
+ <DropdownMenuContent>
132
+ <DropdownMenuItem>One</DropdownMenuItem>
133
+ <DropdownMenuItem>Two</DropdownMenuItem>
134
+ </DropdownMenuContent>
135
+ </DropdownMenu>
136
+ );
137
+ await user.click(screen.getByText("Open"));
138
+ await user.keyboard("{ArrowDown}");
139
+ expect(screen.getByRole("menuitem", { name: "One" })).toHaveFocus();
140
+ await user.keyboard("{ArrowDown}");
141
+ expect(screen.getByRole("menuitem", { name: "Two" })).toHaveFocus();
142
+ });
143
+
144
+ it("disabled item has data-disabled and does not fire onSelect", async () => {
145
+ const user = userEvent.setup();
146
+ const onSelect = vi.fn();
147
+ render(
148
+ <DropdownMenu>
149
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
150
+ <DropdownMenuContent>
151
+ <DropdownMenuItem disabled onSelect={onSelect}>
152
+ One
153
+ </DropdownMenuItem>
154
+ </DropdownMenuContent>
155
+ </DropdownMenu>
156
+ );
157
+ await user.click(screen.getByText("Open"));
158
+ const item = screen.getByRole("menuitem", { name: "One" });
159
+ expect(item.getAttribute("data-disabled")).not.toBeNull();
160
+ await user.click(item);
161
+ expect(onSelect).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("renders label and separator as structural children", async () => {
165
+ const user = userEvent.setup();
166
+ render(
167
+ <DropdownMenu>
168
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
169
+ <DropdownMenuContent>
170
+ <DropdownMenuLabel>Group</DropdownMenuLabel>
171
+ <DropdownMenuSeparator />
172
+ <DropdownMenuItem>One</DropdownMenuItem>
173
+ </DropdownMenuContent>
174
+ </DropdownMenu>
175
+ );
176
+ await user.click(screen.getByText("Open"));
177
+ expect(screen.getByText("Group")).toBeInTheDocument();
178
+ expect(document.querySelector("[data-slot=dropdown-menu-separator]")).not.toBeNull();
179
+ });
180
+ });
@@ -25,15 +25,21 @@ function DropdownMenuTrigger({
25
25
  function DropdownMenuContent({
26
26
  className,
27
27
  sideOffset = 4,
28
+ size = "default",
28
29
  ...props
29
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
30
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Content> & {
31
+ size?: "sm" | "default" | "lg";
32
+ }) {
30
33
  return (
31
34
  <DropdownMenuPrimitive.Portal>
32
35
  <DropdownMenuPrimitive.Content
33
36
  data-slot="dropdown-menu-content"
34
37
  sideOffset={sideOffset}
35
38
  className={cn(
36
- "bg-card text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border border-border p-1 shadow-lg",
39
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) overflow-x-hidden overflow-y-auto rounded-xl border border-border p-1 shadow-xl origin-(--radix-dropdown-menu-content-transform-origin)",
40
+ size === "sm" && "min-w-[6rem] text-xs [&_[data-slot=dropdown-menu-item]]:py-1",
41
+ size === "default" && "min-w-[8rem]",
42
+ size === "lg" && "min-w-[12rem] [&_[data-slot=dropdown-menu-item]]:py-2",
37
43
  className
38
44
  )}
39
45
  {...props}
@@ -73,8 +79,12 @@ function DropdownMenuCheckboxItem({
73
79
  className,
74
80
  children,
75
81
  checked = false,
82
+ appearance = "indicator",
76
83
  ...props
77
- }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
84
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
85
+ /** "indicator" shows a check icon (default). "control" renders a styled checkbox box. */
86
+ appearance?: "indicator" | "control";
87
+ }) {
78
88
  return (
79
89
  <DropdownMenuPrimitive.CheckboxItem
80
90
  data-slot="dropdown-menu-checkbox-item"
@@ -86,9 +96,24 @@ function DropdownMenuCheckboxItem({
86
96
  {...props}
87
97
  >
88
98
  <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
89
- <DropdownMenuPrimitive.ItemIndicator>
90
- <CheckIcon className="size-4" />
91
- </DropdownMenuPrimitive.ItemIndicator>
99
+ {appearance === "control" ? (
100
+ <span
101
+ className={cn(
102
+ "inline-flex size-3.5 items-center justify-center rounded-sm border",
103
+ checked === true
104
+ ? "border-primary bg-primary text-primary-foreground"
105
+ : "border-border bg-background"
106
+ )}
107
+ >
108
+ <DropdownMenuPrimitive.ItemIndicator>
109
+ <CheckIcon className="size-3" />
110
+ </DropdownMenuPrimitive.ItemIndicator>
111
+ </span>
112
+ ) : (
113
+ <DropdownMenuPrimitive.ItemIndicator>
114
+ <CheckIcon className="size-4" />
115
+ </DropdownMenuPrimitive.ItemIndicator>
116
+ )}
92
117
  </span>
93
118
  {children}
94
119
  </DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,36 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Field, FieldControl, FieldDescription, FieldError, FieldLabel } from "./field";
3
+ import { Input } from "./input";
4
+
5
+ const meta = {
6
+ title: "Components/Field",
7
+ component: Field,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof Field>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ export const Default: Story = {
15
+ render: () => (
16
+ <Field className="w-80">
17
+ <FieldLabel>Email</FieldLabel>
18
+ <FieldControl>
19
+ <Input placeholder="you@example.com" />
20
+ </FieldControl>
21
+ <FieldDescription>We'll never share your email.</FieldDescription>
22
+ </Field>
23
+ ),
24
+ };
25
+
26
+ export const Invalid: Story = {
27
+ render: () => (
28
+ <Field className="w-80" invalid>
29
+ <FieldLabel>Email</FieldLabel>
30
+ <FieldControl>
31
+ <Input placeholder="you@example.com" defaultValue="not-an-email" />
32
+ </FieldControl>
33
+ <FieldError>Please enter a valid email address.</FieldError>
34
+ </Field>
35
+ ),
36
+ };
@@ -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
+ });