@bearmenu/ui 0.5.2 → 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 (223) 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 +9 -6
  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/switch.js +4 -3
  104. package/dist/components/table.d.ts +23 -5
  105. package/dist/components/table.js +121 -35
  106. package/dist/components/tabs.d.ts +3 -1
  107. package/dist/components/tabs.js +37 -9
  108. package/dist/components/tag-group.d.ts +9 -0
  109. package/dist/components/tag-group.js +26 -0
  110. package/dist/components/tag.d.ts +17 -0
  111. package/dist/components/tag.js +61 -0
  112. package/dist/components/text.d.ts +3 -3
  113. package/dist/components/text.js +1 -1
  114. package/dist/components/textarea.js +2 -1
  115. package/dist/components/toast.js +1 -1
  116. package/dist/components/toaster.js +1 -1
  117. package/dist/components/toggle-group.js +1 -1
  118. package/dist/components/toggle.js +1 -1
  119. package/dist/components/tooltip.d.ts +3 -2
  120. package/dist/components/tooltip.js +16 -2
  121. package/package.json +44 -26
  122. package/src/components/accordion.tsx +11 -6
  123. package/src/components/alert-dialog.tsx +4 -2
  124. package/src/components/alert.tsx +20 -8
  125. package/src/components/avatar.tsx +106 -13
  126. package/src/components/badge.tsx +12 -11
  127. package/src/components/breadcrumb.tsx +5 -2
  128. package/src/components/button-group.stories.tsx +52 -0
  129. package/src/components/button-group.tsx +40 -0
  130. package/src/components/button.test.tsx +174 -0
  131. package/src/components/button.tsx +42 -19
  132. package/src/components/calendar.tsx +2 -2
  133. package/src/components/card.tsx +55 -13
  134. package/src/components/carousel.tsx +85 -13
  135. package/src/components/checkbox.test.tsx +58 -0
  136. package/src/components/checkbox.tsx +8 -3
  137. package/src/components/circular-progress.stories.tsx +39 -0
  138. package/src/components/circular-progress.tsx +102 -0
  139. package/src/components/collapsible.stories.tsx +7 -3
  140. package/src/components/combobox.stories.tsx +82 -0
  141. package/src/components/combobox.test.tsx +118 -0
  142. package/src/components/combobox.tsx +136 -0
  143. package/src/components/command.test.tsx +91 -0
  144. package/src/components/command.tsx +38 -15
  145. package/src/components/currency-input.test.tsx +63 -0
  146. package/src/components/currency-input.tsx +84 -66
  147. package/src/components/date-input.stories.tsx +31 -0
  148. package/src/components/date-input.tsx +43 -0
  149. package/src/components/description-list.stories.tsx +62 -0
  150. package/src/components/description-list.tsx +68 -0
  151. package/src/components/dialog.test.tsx +84 -0
  152. package/src/components/dialog.tsx +64 -24
  153. package/src/components/drawer.tsx +72 -23
  154. package/src/components/dropdown-menu.test.tsx +180 -0
  155. package/src/components/dropdown-menu.tsx +31 -6
  156. package/src/components/field.stories.tsx +36 -0
  157. package/src/components/field.test.tsx +157 -0
  158. package/src/components/field.tsx +146 -0
  159. package/src/components/form.stories.tsx +3 -2
  160. package/src/components/form.test.tsx +95 -0
  161. package/src/components/form.tsx +9 -0
  162. package/src/components/haptic-button.tsx +1 -1
  163. package/src/components/input-group.stories.tsx +61 -0
  164. package/src/components/input-group.tsx +99 -0
  165. package/src/components/input.test.tsx +103 -0
  166. package/src/components/input.tsx +24 -7
  167. package/src/components/item.stories.tsx +52 -0
  168. package/src/components/item.tsx +115 -0
  169. package/src/components/kbd.stories.tsx +33 -0
  170. package/src/components/kbd.tsx +46 -0
  171. package/src/components/lazy-markdown.tsx +10 -7
  172. package/src/components/multi-select-combobox.test.tsx +101 -0
  173. package/src/components/multi-select-combobox.tsx +85 -82
  174. package/src/components/multi-select-menu.tsx +49 -41
  175. package/src/components/native-select.stories.tsx +52 -0
  176. package/src/components/native-select.tsx +45 -0
  177. package/src/components/pagination.tsx +64 -5
  178. package/src/components/popover.tsx +66 -3
  179. package/src/components/progress.tsx +27 -4
  180. package/src/components/radio-group.test.tsx +64 -0
  181. package/src/components/radio-group.tsx +11 -3
  182. package/src/components/rating.test.tsx +112 -0
  183. package/src/components/rating.tsx +97 -82
  184. package/src/components/search-bar.stories.tsx +1 -32
  185. package/src/components/search-bar.tsx +22 -59
  186. package/src/components/searchable-select.tsx +69 -73
  187. package/src/components/select.tsx +34 -19
  188. package/src/components/selection-bar.stories.tsx +37 -0
  189. package/src/components/selection-bar.tsx +65 -0
  190. package/src/components/separator.tsx +5 -2
  191. package/src/components/sheet.tsx +1 -1
  192. package/src/components/skeleton.tsx +2 -2
  193. package/src/components/slider.test.tsx +45 -0
  194. package/src/components/slider.tsx +31 -12
  195. package/src/components/sliding-panels.tsx +8 -5
  196. package/src/components/spinner.stories.tsx +35 -0
  197. package/src/components/spinner.tsx +50 -0
  198. package/src/components/stepper.stories.tsx +71 -0
  199. package/src/components/stepper.tsx +220 -0
  200. package/src/components/switch.test.tsx +52 -0
  201. package/src/components/switch.tsx +4 -3
  202. package/src/components/table.tsx +140 -35
  203. package/src/components/tabs.test.tsx +107 -0
  204. package/src/components/tabs.tsx +70 -23
  205. package/src/components/tag-group.stories.tsx +39 -0
  206. package/src/components/tag-group.tsx +28 -0
  207. package/src/components/tag.stories.tsx +50 -0
  208. package/src/components/tag.tsx +63 -0
  209. package/src/components/text.test.tsx +1 -1
  210. package/src/components/text.tsx +19 -3
  211. package/src/components/textarea.test.tsx +48 -0
  212. package/src/components/textarea.tsx +2 -1
  213. package/src/components/toast.tsx +4 -3
  214. package/src/components/toggle.tsx +2 -2
  215. package/src/components/tooltip.tsx +12 -2
  216. package/src/globals.css +126 -90
  217. package/dist/chunk-2BMJRXQX.js +0 -80
  218. package/dist/chunk-4AVS6CV4.js +0 -89
  219. package/dist/chunk-4UO73SKC.js +0 -26
  220. package/dist/chunk-CW24PLDW.js +0 -24
  221. package/dist/chunk-IQASCH2V.js +0 -25
  222. package/dist/chunk-NKHH23YK.js +0 -31
  223. package/dist/chunk-QJ54Z7TC.js +0 -83
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogTrigger,
12
+ DialogClose,
13
+ } from "./dialog";
14
+
15
+ function TestDialog({ defaultOpen, showCloseButton }: { defaultOpen?: boolean; showCloseButton?: boolean }) {
16
+ return (
17
+ <Dialog defaultOpen={defaultOpen}>
18
+ <DialogTrigger>Open</DialogTrigger>
19
+ <DialogContent showCloseButton={showCloseButton}>
20
+ <DialogHeader>
21
+ <DialogTitle>Title</DialogTitle>
22
+ <DialogDescription>Description</DialogDescription>
23
+ </DialogHeader>
24
+ <DialogFooter>
25
+ <DialogClose data-testid="footer-close">Dismiss</DialogClose>
26
+ </DialogFooter>
27
+ </DialogContent>
28
+ </Dialog>
29
+ );
30
+ }
31
+
32
+ describe("Dialog", () => {
33
+ it("is closed by default", () => {
34
+ render(<TestDialog />);
35
+ expect(screen.queryByRole("dialog")).toBeNull();
36
+ });
37
+
38
+ it("opens on trigger click", async () => {
39
+ const user = userEvent.setup();
40
+ render(<TestDialog />);
41
+ await user.click(screen.getByText("Open"));
42
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
43
+ });
44
+
45
+ it("renders title and description with aria links", () => {
46
+ render(<TestDialog defaultOpen />);
47
+ const dialog = screen.getByRole("dialog");
48
+ const title = screen.getByText("Title");
49
+ const desc = screen.getByText("Description");
50
+ expect(dialog.getAttribute("aria-labelledby")).toBe(title.id);
51
+ expect(dialog.getAttribute("aria-describedby")).toBe(desc.id);
52
+ });
53
+
54
+ it("renders close button by default", () => {
55
+ render(<TestDialog defaultOpen />);
56
+ expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument();
57
+ });
58
+
59
+ it("hides X close button when showCloseButton=false", () => {
60
+ render(<TestDialog defaultOpen showCloseButton={false} />);
61
+ expect(screen.queryByRole("button", { name: /close/i })).toBeNull();
62
+ expect(screen.getByTestId("footer-close")).toBeInTheDocument();
63
+ });
64
+
65
+ it("closes on Escape key", async () => {
66
+ const user = userEvent.setup();
67
+ render(<TestDialog defaultOpen />);
68
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
69
+ await user.keyboard("{Escape}");
70
+ expect(screen.queryByRole("dialog")).toBeNull();
71
+ });
72
+
73
+ it("closes when DialogClose is clicked", async () => {
74
+ const user = userEvent.setup();
75
+ render(<TestDialog defaultOpen />);
76
+ await user.click(screen.getByTestId("footer-close"));
77
+ expect(screen.queryByRole("dialog")).toBeNull();
78
+ });
79
+
80
+ it("applies data-slot attributes for styling hooks", () => {
81
+ render(<TestDialog defaultOpen />);
82
+ expect(screen.getByRole("dialog").getAttribute("data-slot")).toBe("dialog-content");
83
+ });
84
+ });
@@ -2,15 +2,13 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { cva, type VariantProps } from "class-variance-authority";
5
6
  import { X } from "lucide-react";
6
7
  import { cn } from "../lib/utils";
7
8
 
8
9
  const Dialog = DialogPrimitive.Root;
9
-
10
10
  const DialogTrigger = DialogPrimitive.Trigger;
11
-
12
11
  const DialogPortal = DialogPrimitive.Portal;
13
-
14
12
  const DialogClose = DialogPrimitive.Close;
15
13
 
16
14
  const DialogOverlay = React.forwardRef<
@@ -19,6 +17,7 @@ const DialogOverlay = React.forwardRef<
19
17
  >(({ className, ...props }, ref) => (
20
18
  <DialogPrimitive.Overlay
21
19
  ref={ref}
20
+ data-slot="dialog-overlay"
22
21
  className={cn(
23
22
  "fixed inset-0 z-[1000] bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
24
23
  className
@@ -28,37 +27,75 @@ const DialogOverlay = React.forwardRef<
28
27
  ));
29
28
  DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
30
29
 
30
+ const dialogContentVariants = cva(
31
+ "fixed left-[50%] top-[50%] z-[1000] grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 bg-card p-6 shadow-2xl duration-200 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 rounded-2xl border border-border",
32
+ {
33
+ variants: {
34
+ size: {
35
+ sm: "max-w-sm",
36
+ default: "max-w-lg",
37
+ lg: "max-w-2xl",
38
+ xl: "max-w-4xl",
39
+ full: "max-w-[95vw] max-h-[95vh]",
40
+ },
41
+ scrollBehavior: {
42
+ inside: "max-h-[85vh] overflow-hidden",
43
+ outside: "",
44
+ },
45
+ },
46
+ defaultVariants: {
47
+ size: "default",
48
+ scrollBehavior: "outside",
49
+ },
50
+ }
51
+ );
52
+
53
+ type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
54
+ VariantProps<typeof dialogContentVariants> & {
55
+ showCloseButton?: boolean;
56
+ };
57
+
31
58
  const DialogContent = React.forwardRef<
32
59
  React.ElementRef<typeof DialogPrimitive.Content>,
33
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
34
- >(({ className, children, ...props }, ref) => (
35
- <DialogPortal>
36
- <DialogOverlay />
37
- <DialogPrimitive.Content
38
- ref={ref}
39
- className={cn(
40
- "fixed left-[50%] top-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-card p-6 shadow-2xl duration-300 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 rounded-2xl border border-border",
41
- className
42
- )}
43
- {...props}
44
- >
45
- {children}
46
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:bg-muted p-2">
47
- <X className="h-4 w-4" />
48
- <span className="sr-only">Close</span>
49
- </DialogPrimitive.Close>
50
- </DialogPrimitive.Content>
51
- </DialogPortal>
52
- ));
60
+ DialogContentProps
61
+ >(
62
+ (
63
+ { className, children, size, scrollBehavior, showCloseButton = true, ...props },
64
+ ref
65
+ ) => (
66
+ <DialogPortal>
67
+ <DialogOverlay />
68
+ <DialogPrimitive.Content
69
+ ref={ref}
70
+ data-slot="dialog-content"
71
+ className={cn(dialogContentVariants({ size, scrollBehavior }), className)}
72
+ {...props}
73
+ >
74
+ {children}
75
+ {showCloseButton && (
76
+ <DialogPrimitive.Close className="absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-accent-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent">
77
+ <X className="size-4" />
78
+ <span className="sr-only">Close</span>
79
+ </DialogPrimitive.Close>
80
+ )}
81
+ </DialogPrimitive.Content>
82
+ </DialogPortal>
83
+ )
84
+ );
53
85
  DialogContent.displayName = DialogPrimitive.Content.displayName;
54
86
 
55
87
  const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
56
- <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
88
+ <div
89
+ data-slot="dialog-header"
90
+ className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
91
+ {...props}
92
+ />
57
93
  );
58
94
  DialogHeader.displayName = "DialogHeader";
59
95
 
60
96
  const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
61
97
  <div
98
+ data-slot="dialog-footer"
62
99
  className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
63
100
  {...props}
64
101
  />
@@ -71,6 +108,7 @@ const DialogTitle = React.forwardRef<
71
108
  >(({ className, ...props }, ref) => (
72
109
  <DialogPrimitive.Title
73
110
  ref={ref}
111
+ data-slot="dialog-title"
74
112
  className={cn("text-lg font-semibold leading-none tracking-tight", className)}
75
113
  {...props}
76
114
  />
@@ -83,6 +121,7 @@ const DialogDescription = React.forwardRef<
83
121
  >(({ className, ...props }, ref) => (
84
122
  <DialogPrimitive.Description
85
123
  ref={ref}
124
+ data-slot="dialog-description"
86
125
  className={cn("text-sm text-muted-foreground", className)}
87
126
  {...props}
88
127
  />
@@ -100,4 +139,5 @@ export {
100
139
  DialogFooter,
101
140
  DialogTitle,
102
141
  DialogDescription,
142
+ dialogContentVariants,
103
143
  };
@@ -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
+ };