@emara/ui 1.1.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 (218) hide show
  1. package/components/ui/.gitkeep +0 -0
  2. package/components/ui/accordion.stories.tsx +231 -0
  3. package/components/ui/accordion.tsx +250 -0
  4. package/components/ui/app-shell.stories.tsx +270 -0
  5. package/components/ui/app-shell.tsx +491 -0
  6. package/components/ui/avatar.stories.tsx +174 -0
  7. package/components/ui/avatar.tsx +257 -0
  8. package/components/ui/badge.stories.tsx +127 -0
  9. package/components/ui/badge.tsx +146 -0
  10. package/components/ui/breadcrumb.stories.tsx +92 -0
  11. package/components/ui/breadcrumb.tsx +302 -0
  12. package/components/ui/button.stories.tsx +186 -0
  13. package/components/ui/button.tsx +128 -0
  14. package/components/ui/card.stories.tsx +279 -0
  15. package/components/ui/card.tsx +250 -0
  16. package/components/ui/checkbox.stories.tsx +93 -0
  17. package/components/ui/checkbox.tsx +131 -0
  18. package/components/ui/combobox.stories.tsx +489 -0
  19. package/components/ui/combobox.tsx +874 -0
  20. package/components/ui/context-menu.stories.tsx +202 -0
  21. package/components/ui/context-menu.tsx +309 -0
  22. package/components/ui/data-table.stories.tsx +227 -0
  23. package/components/ui/data-table.tsx +539 -0
  24. package/components/ui/date-picker.stories.tsx +225 -0
  25. package/components/ui/date-picker.tsx +597 -0
  26. package/components/ui/dialog.stories.tsx +193 -0
  27. package/components/ui/dialog.tsx +262 -0
  28. package/components/ui/divider.stories.tsx +84 -0
  29. package/components/ui/divider.tsx +135 -0
  30. package/components/ui/drawer.stories.tsx +218 -0
  31. package/components/ui/drawer.tsx +329 -0
  32. package/components/ui/dropdown-menu.stories.tsx +270 -0
  33. package/components/ui/dropdown-menu.tsx +353 -0
  34. package/components/ui/empty-state.stories.tsx +121 -0
  35. package/components/ui/empty-state.tsx +289 -0
  36. package/components/ui/field-group.stories.tsx +201 -0
  37. package/components/ui/field-group.tsx +276 -0
  38. package/components/ui/form.stories.tsx +219 -0
  39. package/components/ui/form.tsx +542 -0
  40. package/components/ui/input.stories.tsx +154 -0
  41. package/components/ui/input.tsx +208 -0
  42. package/components/ui/label.stories.tsx +84 -0
  43. package/components/ui/label.tsx +98 -0
  44. package/components/ui/page-header.stories.tsx +136 -0
  45. package/components/ui/page-header.tsx +315 -0
  46. package/components/ui/pagination.stories.tsx +136 -0
  47. package/components/ui/pagination.tsx +427 -0
  48. package/components/ui/popover.stories.tsx +212 -0
  49. package/components/ui/popover.tsx +167 -0
  50. package/components/ui/radio-group.stories.tsx +96 -0
  51. package/components/ui/radio-group.tsx +250 -0
  52. package/components/ui/select.stories.tsx +203 -0
  53. package/components/ui/select.tsx +318 -0
  54. package/components/ui/sidebar.stories.tsx +186 -0
  55. package/components/ui/sidebar.tsx +623 -0
  56. package/components/ui/skeleton.stories.tsx +131 -0
  57. package/components/ui/skeleton.tsx +311 -0
  58. package/components/ui/switch.stories.tsx +74 -0
  59. package/components/ui/switch.tsx +186 -0
  60. package/components/ui/table.stories.tsx +107 -0
  61. package/components/ui/table.tsx +285 -0
  62. package/components/ui/tabs.stories.tsx +222 -0
  63. package/components/ui/tabs.tsx +287 -0
  64. package/components/ui/textarea.stories.tsx +96 -0
  65. package/components/ui/textarea.tsx +182 -0
  66. package/components/ui/toast.stories.tsx +169 -0
  67. package/components/ui/toast.tsx +250 -0
  68. package/components/ui/tooltip.stories.tsx +146 -0
  69. package/components/ui/tooltip.tsx +156 -0
  70. package/components/ui/top-bar.stories.tsx +182 -0
  71. package/components/ui/top-bar.tsx +155 -0
  72. package/dist/components/ui/accordion.d.ts +45 -0
  73. package/dist/components/ui/accordion.d.ts.map +1 -0
  74. package/dist/components/ui/accordion.js +99 -0
  75. package/dist/components/ui/accordion.js.map +1 -0
  76. package/dist/components/ui/app-shell.d.ts +70 -0
  77. package/dist/components/ui/app-shell.d.ts.map +1 -0
  78. package/dist/components/ui/app-shell.js +199 -0
  79. package/dist/components/ui/app-shell.js.map +1 -0
  80. package/dist/components/ui/avatar.d.ts +41 -0
  81. package/dist/components/ui/avatar.d.ts.map +1 -0
  82. package/dist/components/ui/avatar.js +104 -0
  83. package/dist/components/ui/avatar.js.map +1 -0
  84. package/dist/components/ui/badge.d.ts +27 -0
  85. package/dist/components/ui/badge.d.ts.map +1 -0
  86. package/dist/components/ui/badge.js +65 -0
  87. package/dist/components/ui/badge.js.map +1 -0
  88. package/dist/components/ui/breadcrumb.d.ts +35 -0
  89. package/dist/components/ui/breadcrumb.d.ts.map +1 -0
  90. package/dist/components/ui/breadcrumb.js +88 -0
  91. package/dist/components/ui/breadcrumb.js.map +1 -0
  92. package/dist/components/ui/button.d.ts +26 -0
  93. package/dist/components/ui/button.d.ts.map +1 -0
  94. package/dist/components/ui/button.js +73 -0
  95. package/dist/components/ui/button.js.map +1 -0
  96. package/dist/components/ui/card.d.ts +52 -0
  97. package/dist/components/ui/card.d.ts.map +1 -0
  98. package/dist/components/ui/card.js +96 -0
  99. package/dist/components/ui/card.js.map +1 -0
  100. package/dist/components/ui/checkbox.d.ts +18 -0
  101. package/dist/components/ui/checkbox.d.ts.map +1 -0
  102. package/dist/components/ui/checkbox.js +59 -0
  103. package/dist/components/ui/checkbox.js.map +1 -0
  104. package/dist/components/ui/combobox.d.ts +194 -0
  105. package/dist/components/ui/combobox.d.ts.map +1 -0
  106. package/dist/components/ui/combobox.js +361 -0
  107. package/dist/components/ui/combobox.js.map +1 -0
  108. package/dist/components/ui/context-menu.d.ts +46 -0
  109. package/dist/components/ui/context-menu.d.ts.map +1 -0
  110. package/dist/components/ui/context-menu.js +95 -0
  111. package/dist/components/ui/context-menu.js.map +1 -0
  112. package/dist/components/ui/data-table.d.ts +53 -0
  113. package/dist/components/ui/data-table.d.ts.map +1 -0
  114. package/dist/components/ui/data-table.js +163 -0
  115. package/dist/components/ui/data-table.js.map +1 -0
  116. package/dist/components/ui/date-picker.d.ts +103 -0
  117. package/dist/components/ui/date-picker.d.ts.map +1 -0
  118. package/dist/components/ui/date-picker.js +306 -0
  119. package/dist/components/ui/date-picker.js.map +1 -0
  120. package/dist/components/ui/dialog.d.ts +40 -0
  121. package/dist/components/ui/dialog.d.ts.map +1 -0
  122. package/dist/components/ui/dialog.js +110 -0
  123. package/dist/components/ui/dialog.js.map +1 -0
  124. package/dist/components/ui/divider.d.ts +30 -0
  125. package/dist/components/ui/divider.d.ts.map +1 -0
  126. package/dist/components/ui/divider.js +62 -0
  127. package/dist/components/ui/divider.js.map +1 -0
  128. package/dist/components/ui/drawer.d.ts +56 -0
  129. package/dist/components/ui/drawer.d.ts.map +1 -0
  130. package/dist/components/ui/drawer.js +147 -0
  131. package/dist/components/ui/drawer.js.map +1 -0
  132. package/dist/components/ui/dropdown-menu.d.ts +63 -0
  133. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  134. package/dist/components/ui/dropdown-menu.js +116 -0
  135. package/dist/components/ui/dropdown-menu.js.map +1 -0
  136. package/dist/components/ui/empty-state.d.ts +43 -0
  137. package/dist/components/ui/empty-state.d.ts.map +1 -0
  138. package/dist/components/ui/empty-state.js +128 -0
  139. package/dist/components/ui/empty-state.js.map +1 -0
  140. package/dist/components/ui/field-group.d.ts +38 -0
  141. package/dist/components/ui/field-group.d.ts.map +1 -0
  142. package/dist/components/ui/field-group.js +107 -0
  143. package/dist/components/ui/field-group.js.map +1 -0
  144. package/dist/components/ui/form.d.ts +67 -0
  145. package/dist/components/ui/form.d.ts.map +1 -0
  146. package/dist/components/ui/form.js +286 -0
  147. package/dist/components/ui/form.js.map +1 -0
  148. package/dist/components/ui/input.d.ts +36 -0
  149. package/dist/components/ui/input.d.ts.map +1 -0
  150. package/dist/components/ui/input.js +99 -0
  151. package/dist/components/ui/input.js.map +1 -0
  152. package/dist/components/ui/label.d.ts +37 -0
  153. package/dist/components/ui/label.d.ts.map +1 -0
  154. package/dist/components/ui/label.js +34 -0
  155. package/dist/components/ui/label.js.map +1 -0
  156. package/dist/components/ui/page-header.d.ts +65 -0
  157. package/dist/components/ui/page-header.d.ts.map +1 -0
  158. package/dist/components/ui/page-header.js +140 -0
  159. package/dist/components/ui/page-header.js.map +1 -0
  160. package/dist/components/ui/pagination.d.ts +67 -0
  161. package/dist/components/ui/pagination.d.ts.map +1 -0
  162. package/dist/components/ui/pagination.js +109 -0
  163. package/dist/components/ui/pagination.js.map +1 -0
  164. package/dist/components/ui/popover.d.ts +28 -0
  165. package/dist/components/ui/popover.d.ts.map +1 -0
  166. package/dist/components/ui/popover.js +85 -0
  167. package/dist/components/ui/popover.js.map +1 -0
  168. package/dist/components/ui/radio-group.d.ts +35 -0
  169. package/dist/components/ui/radio-group.d.ts.map +1 -0
  170. package/dist/components/ui/radio-group.js +103 -0
  171. package/dist/components/ui/radio-group.js.map +1 -0
  172. package/dist/components/ui/select.d.ts +42 -0
  173. package/dist/components/ui/select.d.ts.map +1 -0
  174. package/dist/components/ui/select.js +86 -0
  175. package/dist/components/ui/select.js.map +1 -0
  176. package/dist/components/ui/sidebar.d.ts +59 -0
  177. package/dist/components/ui/sidebar.d.ts.map +1 -0
  178. package/dist/components/ui/sidebar.js +189 -0
  179. package/dist/components/ui/sidebar.js.map +1 -0
  180. package/dist/components/ui/skeleton.d.ts +77 -0
  181. package/dist/components/ui/skeleton.d.ts.map +1 -0
  182. package/dist/components/ui/skeleton.js +115 -0
  183. package/dist/components/ui/skeleton.js.map +1 -0
  184. package/dist/components/ui/switch.d.ts +26 -0
  185. package/dist/components/ui/switch.d.ts.map +1 -0
  186. package/dist/components/ui/switch.js +84 -0
  187. package/dist/components/ui/switch.js.map +1 -0
  188. package/dist/components/ui/table.d.ts +52 -0
  189. package/dist/components/ui/table.d.ts.map +1 -0
  190. package/dist/components/ui/table.js +109 -0
  191. package/dist/components/ui/table.js.map +1 -0
  192. package/dist/components/ui/tabs.d.ts +42 -0
  193. package/dist/components/ui/tabs.d.ts.map +1 -0
  194. package/dist/components/ui/tabs.js +163 -0
  195. package/dist/components/ui/tabs.js.map +1 -0
  196. package/dist/components/ui/textarea.d.ts +26 -0
  197. package/dist/components/ui/textarea.d.ts.map +1 -0
  198. package/dist/components/ui/textarea.js +96 -0
  199. package/dist/components/ui/textarea.js.map +1 -0
  200. package/dist/components/ui/toast.d.ts +77 -0
  201. package/dist/components/ui/toast.d.ts.map +1 -0
  202. package/dist/components/ui/toast.js +141 -0
  203. package/dist/components/ui/toast.js.map +1 -0
  204. package/dist/components/ui/tooltip.d.ts +31 -0
  205. package/dist/components/ui/tooltip.d.ts.map +1 -0
  206. package/dist/components/ui/tooltip.js +71 -0
  207. package/dist/components/ui/tooltip.js.map +1 -0
  208. package/dist/components/ui/top-bar.d.ts +30 -0
  209. package/dist/components/ui/top-bar.d.ts.map +1 -0
  210. package/dist/components/ui/top-bar.js +64 -0
  211. package/dist/components/ui/top-bar.js.map +1 -0
  212. package/dist/lib/utils.d.ts +3 -0
  213. package/dist/lib/utils.d.ts.map +1 -0
  214. package/dist/lib/utils.js +6 -0
  215. package/dist/lib/utils.js.map +1 -0
  216. package/lib/utils.ts +6 -0
  217. package/package.json +112 -0
  218. package/styles/globals.css +685 -0
@@ -0,0 +1,218 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { Button } from "./button";
4
+ import {
5
+ Drawer,
6
+ DrawerBody,
7
+ DrawerClose,
8
+ DrawerContent,
9
+ DrawerDescription,
10
+ DrawerFooter,
11
+ DrawerHeader,
12
+ DrawerTitle,
13
+ DrawerTrigger,
14
+ } from "./drawer";
15
+
16
+ const meta: Meta<typeof Drawer> = {
17
+ title: "Overlays/Drawer",
18
+ component: Drawer,
19
+ parameters: { layout: "centered" },
20
+ };
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof Drawer>;
24
+
25
+ export const Default: Story = {
26
+ render: () => (
27
+ <Drawer>
28
+ <DrawerTrigger asChild>
29
+ <Button>Open drawer</Button>
30
+ </DrawerTrigger>
31
+ <DrawerContent>
32
+ <DrawerHeader>
33
+ <DrawerTitle>Filters</DrawerTitle>
34
+ <DrawerDescription>Refine results without leaving the page.</DrawerDescription>
35
+ </DrawerHeader>
36
+ <DrawerBody>
37
+ <p className="text-sm">Filter controls go here.</p>
38
+ </DrawerBody>
39
+ <DrawerFooter>
40
+ <DrawerClose asChild>
41
+ <Button variant="ghost">Cancel</Button>
42
+ </DrawerClose>
43
+ <Button>Apply</Button>
44
+ </DrawerFooter>
45
+ </DrawerContent>
46
+ </Drawer>
47
+ ),
48
+ };
49
+
50
+ export const Positions: Story = {
51
+ render: () => (
52
+ <div className="flex flex-wrap gap-2">
53
+ {(["start", "end", "top", "bottom"] as const).map((p) => (
54
+ <Drawer key={p}>
55
+ <DrawerTrigger asChild>
56
+ <Button variant="outline" size="sm">
57
+ {p}
58
+ </Button>
59
+ </DrawerTrigger>
60
+ <DrawerContent position={p}>
61
+ <DrawerHeader>
62
+ <DrawerTitle>position=&quot;{p}&quot;</DrawerTitle>
63
+ <DrawerDescription>
64
+ Slides in from the {p === "start" ? "leading" : p === "end" ? "trailing" : p} edge.
65
+ {p === "start" || p === "end" ? " Flips in RTL." : ""}
66
+ </DrawerDescription>
67
+ </DrawerHeader>
68
+ <DrawerBody>
69
+ <p className="text-sm">Content for the {p} drawer.</p>
70
+ </DrawerBody>
71
+ </DrawerContent>
72
+ </Drawer>
73
+ ))}
74
+ </div>
75
+ ),
76
+ };
77
+
78
+ export const Sizes: Story = {
79
+ render: () => (
80
+ <div className="flex flex-wrap gap-2">
81
+ {(["xs", "sm", "md", "lg", "xl", "full"] as const).map((s) => (
82
+ <Drawer key={s}>
83
+ <DrawerTrigger asChild>
84
+ <Button variant="outline" size="sm">
85
+ {s}
86
+ </Button>
87
+ </DrawerTrigger>
88
+ <DrawerContent size={s}>
89
+ <DrawerHeader>
90
+ <DrawerTitle>size=&quot;{s}&quot;</DrawerTitle>
91
+ <DrawerDescription>End-positioned drawer.</DrawerDescription>
92
+ </DrawerHeader>
93
+ <DrawerBody>
94
+ <p className="text-sm">
95
+ Horizontal widths: xs 280, sm 360, md 440 (default), lg 560, xl 720, full
96
+ full-screen.
97
+ </p>
98
+ </DrawerBody>
99
+ </DrawerContent>
100
+ </Drawer>
101
+ ))}
102
+ </div>
103
+ ),
104
+ };
105
+
106
+ export const Scrollable: Story = {
107
+ render: () => (
108
+ <Drawer>
109
+ <DrawerTrigger asChild>
110
+ <Button>Long content</Button>
111
+ </DrawerTrigger>
112
+ <DrawerContent scrollable>
113
+ <DrawerHeader>
114
+ <DrawerTitle>Filters</DrawerTitle>
115
+ <DrawerDescription>Scroll the body when needed.</DrawerDescription>
116
+ </DrawerHeader>
117
+ <DrawerBody>
118
+ {Array.from({ length: 40 }, (_, i) => (
119
+ <p key={i} className="mb-2 text-sm">
120
+ Filter option {i + 1}
121
+ </p>
122
+ ))}
123
+ </DrawerBody>
124
+ <DrawerFooter>
125
+ <DrawerClose asChild>
126
+ <Button variant="ghost">Reset</Button>
127
+ </DrawerClose>
128
+ <Button>Apply</Button>
129
+ </DrawerFooter>
130
+ </DrawerContent>
131
+ </Drawer>
132
+ ),
133
+ };
134
+
135
+ /**
136
+ * Opt out of body scroll. The whole drawer becomes a single scroll surface
137
+ * — header + body + footer flow together. Useful for short forms or when
138
+ * you want the entire content to scroll as one (e.g. a fixed-footer CTA
139
+ * that should scroll away with the body).
140
+ */
141
+ export const NonScrollableBody: Story = {
142
+ render: () => (
143
+ <Drawer>
144
+ <DrawerTrigger asChild>
145
+ <Button>Non-scrollable body</Button>
146
+ </DrawerTrigger>
147
+ <DrawerContent scrollable={false}>
148
+ <DrawerHeader>
149
+ <DrawerTitle>Short form</DrawerTitle>
150
+ <DrawerDescription>Body sizes to content; no inner scroll.</DrawerDescription>
151
+ </DrawerHeader>
152
+ <DrawerBody>
153
+ <p className="text-sm">Just two lines.</p>
154
+ <p className="mt-2 text-sm">No overflow region.</p>
155
+ </DrawerBody>
156
+ <DrawerFooter>
157
+ <Button>OK</Button>
158
+ </DrawerFooter>
159
+ </DrawerContent>
160
+ </Drawer>
161
+ ),
162
+ };
163
+
164
+ export const Dismissible: Story = {
165
+ render: () => (
166
+ <Drawer>
167
+ <DrawerTrigger asChild>
168
+ <Button>Non-dismissible drawer</Button>
169
+ </DrawerTrigger>
170
+ <DrawerContent dismissible={false}>
171
+ <DrawerHeader>
172
+ <DrawerTitle>Action required</DrawerTitle>
173
+ <DrawerDescription>
174
+ No X, no Esc, no overlay click — use the explicit action below to close.
175
+ </DrawerDescription>
176
+ </DrawerHeader>
177
+ <DrawerBody />
178
+ <DrawerFooter>
179
+ <DrawerClose asChild>
180
+ <Button>Acknowledge</Button>
181
+ </DrawerClose>
182
+ </DrawerFooter>
183
+ </DrawerContent>
184
+ </Drawer>
185
+ ),
186
+ };
187
+
188
+ export const ConfirmBeforeClose: Story = {
189
+ render: () => (
190
+ <Drawer>
191
+ <DrawerTrigger asChild>
192
+ <Button>Open (confirm on close)</Button>
193
+ </DrawerTrigger>
194
+ <DrawerContent confirmBeforeClose>
195
+ <DrawerHeader>
196
+ <DrawerTitle>Edit profile</DrawerTitle>
197
+ <DrawerDescription>
198
+ Outside-click prompts confirmation — useful for dirty forms.
199
+ </DrawerDescription>
200
+ </DrawerHeader>
201
+ <DrawerBody>
202
+ <p className="text-sm">Try clicking on the overlay.</p>
203
+ </DrawerBody>
204
+ </DrawerContent>
205
+ </Drawer>
206
+ ),
207
+ };
208
+
209
+ export const Loading: Story = {
210
+ render: () => (
211
+ <Drawer>
212
+ <DrawerTrigger asChild>
213
+ <Button>Open (loading)</Button>
214
+ </DrawerTrigger>
215
+ <DrawerContent loading />
216
+ </Drawer>
217
+ ),
218
+ };
@@ -0,0 +1,329 @@
1
+ "use client";
2
+
3
+ import { createContext, forwardRef, useContext, useMemo } from "react";
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { RiCloseLine } from "@remixicon/react";
6
+ import { cva, type VariantProps } from "class-variance-authority";
7
+
8
+ import { cn } from "@/lib/utils";
9
+ import { Skeleton } from "./skeleton";
10
+
11
+ // Context lets DrawerBody pick up the `scrollable` flag set on DrawerContent
12
+ // without consumers having to thread it through.
13
+ type DrawerContextValue = { scrollable: boolean };
14
+ const DrawerContext = createContext<DrawerContextValue>({ scrollable: true });
15
+ const useDrawerContext = () => useContext(DrawerContext);
16
+
17
+ // Per docs/emara-ui-phase-3-components.md §2. Built on Radix Dialog (not Vaul)
18
+ // so the slide animation, focus trap, and Esc handling stay consistent with
19
+ // the rest of the system.
20
+
21
+ const Drawer = DialogPrimitive.Root;
22
+ const DrawerTrigger = DialogPrimitive.Trigger;
23
+ const DrawerPortal = DialogPrimitive.Portal;
24
+ const DrawerClose = DialogPrimitive.Close;
25
+
26
+ // ----------------------------------------------------------------------------
27
+ // DrawerOverlay — same as Dialog's overlay.
28
+ // ----------------------------------------------------------------------------
29
+
30
+ const DrawerOverlay = forwardRef<
31
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
32
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
33
+ >(function DrawerOverlay({ className, ...props }, ref) {
34
+ return (
35
+ <DialogPrimitive.Overlay
36
+ ref={ref}
37
+ className={cn(
38
+ "z-modal bg-foreground/50 fixed inset-0",
39
+ "data-[state=open]:animate-[fade-in_var(--duration-normal)_var(--ease-out)]",
40
+ "data-[state=closed]:animate-[fade-out_var(--duration-fast)_var(--ease-in)]",
41
+ className,
42
+ )}
43
+ {...props}
44
+ />
45
+ );
46
+ });
47
+ DrawerOverlay.displayName = "DrawerOverlay";
48
+
49
+ // ----------------------------------------------------------------------------
50
+ // DrawerContent
51
+ // ----------------------------------------------------------------------------
52
+
53
+ type DrawerPosition = "start" | "end" | "top" | "bottom";
54
+
55
+ const drawerContentVariants = cva(
56
+ ["fixed z-modal flex flex-col bg-card text-card-foreground shadow-xl", "border-border"].join(" "),
57
+ {
58
+ variants: {
59
+ position: {
60
+ // Logical sides — `start-0` is left in LTR, right in RTL.
61
+ start:
62
+ "inset-y-0 start-0 h-full border-e " +
63
+ "data-[state=open]:animate-[slide-in-from-start_var(--duration-normal)_var(--ease-out)] " +
64
+ "data-[state=closed]:animate-[slide-in-from-start_var(--duration-fast)_var(--ease-in)_reverse]",
65
+ end:
66
+ "inset-y-0 end-0 h-full border-s " +
67
+ "data-[state=open]:animate-[slide-in-from-end_var(--duration-normal)_var(--ease-out)] " +
68
+ "data-[state=closed]:animate-[slide-in-from-end_var(--duration-fast)_var(--ease-in)_reverse]",
69
+ top:
70
+ "inset-x-0 top-0 w-full border-b " +
71
+ "data-[state=open]:animate-[slide-in-from-top_var(--duration-normal)_var(--ease-out)] " +
72
+ "data-[state=closed]:animate-[slide-in-from-top_var(--duration-fast)_var(--ease-in)_reverse]",
73
+ bottom:
74
+ "inset-x-0 bottom-0 w-full border-t " +
75
+ "data-[state=open]:animate-[slide-in-from-bottom_var(--duration-normal)_var(--ease-out)] " +
76
+ "data-[state=closed]:animate-[slide-in-from-bottom_var(--duration-fast)_var(--ease-in)_reverse]",
77
+ },
78
+ size: {
79
+ xs: "",
80
+ sm: "",
81
+ md: "",
82
+ lg: "",
83
+ xl: "",
84
+ full: "",
85
+ },
86
+ },
87
+ compoundVariants: [
88
+ // Horizontal (start/end) — width scale (tokens in design-tokens §11b)
89
+ { position: "start", size: "xs", class: "w-drawer-xs" },
90
+ { position: "start", size: "sm", class: "w-drawer-sm" },
91
+ { position: "start", size: "md", class: "w-drawer-md" },
92
+ { position: "start", size: "lg", class: "w-drawer-lg" },
93
+ { position: "start", size: "xl", class: "w-drawer-xl" },
94
+ { position: "start", size: "full", class: "w-screen" },
95
+ { position: "end", size: "xs", class: "w-drawer-xs" },
96
+ { position: "end", size: "sm", class: "w-drawer-sm" },
97
+ { position: "end", size: "md", class: "w-drawer-md" },
98
+ { position: "end", size: "lg", class: "w-drawer-lg" },
99
+ { position: "end", size: "xl", class: "w-drawer-xl" },
100
+ { position: "end", size: "full", class: "w-screen" },
101
+ // Vertical (top/bottom) — height scale
102
+ { position: "top", size: "xs", class: "h-drawer-xs" },
103
+ { position: "top", size: "sm", class: "h-drawer-sm" },
104
+ { position: "top", size: "md", class: "h-drawer-md" },
105
+ { position: "top", size: "lg", class: "h-drawer-lg" },
106
+ { position: "top", size: "xl", class: "h-drawer-xl" },
107
+ { position: "top", size: "full", class: "h-screen" },
108
+ { position: "bottom", size: "xs", class: "h-drawer-xs" },
109
+ { position: "bottom", size: "sm", class: "h-drawer-sm" },
110
+ { position: "bottom", size: "md", class: "h-drawer-md" },
111
+ { position: "bottom", size: "lg", class: "h-drawer-lg" },
112
+ { position: "bottom", size: "xl", class: "h-drawer-xl" },
113
+ { position: "bottom", size: "full", class: "h-screen" },
114
+ ],
115
+ defaultVariants: {
116
+ position: "end",
117
+ size: "md",
118
+ },
119
+ },
120
+ );
121
+
122
+ type DrawerContentVariants = VariantProps<typeof drawerContentVariants>;
123
+
124
+ type DrawerContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
125
+ DrawerContentVariants & {
126
+ closeOnOverlayClick?: boolean;
127
+ closeOnEscape?: boolean;
128
+ confirmBeforeClose?: boolean | (() => boolean | Promise<boolean>);
129
+ loading?: boolean;
130
+ scrollable?: boolean;
131
+ hideCloseButton?: boolean;
132
+ /**
133
+ * When `false`, the drawer can only be closed programmatically:
134
+ * - hides the close X
135
+ * - blocks overlay click
136
+ * - blocks Escape
137
+ */
138
+ dismissible?: boolean;
139
+ };
140
+
141
+ const DrawerContent = forwardRef<
142
+ React.ElementRef<typeof DialogPrimitive.Content>,
143
+ DrawerContentProps
144
+ >(function DrawerContent(
145
+ {
146
+ className,
147
+ position,
148
+ size,
149
+ closeOnOverlayClick = true,
150
+ closeOnEscape = true,
151
+ confirmBeforeClose,
152
+ loading = false,
153
+ scrollable = true,
154
+ hideCloseButton = false,
155
+ dismissible = true,
156
+ onPointerDownOutside,
157
+ onEscapeKeyDown,
158
+ onInteractOutside,
159
+ children,
160
+ ...props
161
+ },
162
+ ref,
163
+ ) {
164
+ const guardedClose = async (e: Event): Promise<void> => {
165
+ if (!confirmBeforeClose) return;
166
+ if (typeof confirmBeforeClose === "function") {
167
+ const ok = await confirmBeforeClose();
168
+ if (!ok) e.preventDefault();
169
+ return;
170
+ }
171
+ const ok = window.confirm("Discard unsaved changes?");
172
+ if (!ok) e.preventDefault();
173
+ };
174
+
175
+ const allowOverlayClose = dismissible && closeOnOverlayClick;
176
+ const allowEscape = dismissible && closeOnEscape;
177
+
178
+ const drawerCtx = useMemo<DrawerContextValue>(() => ({ scrollable }), [scrollable]);
179
+
180
+ return (
181
+ <DrawerContext.Provider value={drawerCtx}>
182
+ <DrawerPortal>
183
+ <DrawerOverlay />
184
+ <DialogPrimitive.Content
185
+ ref={ref}
186
+ onPointerDownOutside={(e) => {
187
+ if (!allowOverlayClose) e.preventDefault();
188
+ onPointerDownOutside?.(e);
189
+ }}
190
+ onEscapeKeyDown={(e) => {
191
+ if (!allowEscape) e.preventDefault();
192
+ onEscapeKeyDown?.(e);
193
+ }}
194
+ onInteractOutside={async (e) => {
195
+ if (confirmBeforeClose && allowOverlayClose) {
196
+ await guardedClose(e as unknown as Event);
197
+ }
198
+ onInteractOutside?.(e);
199
+ }}
200
+ className={cn(drawerContentVariants({ position, size }), className)}
201
+ {...props}
202
+ >
203
+ {loading ? (
204
+ <div className="space-y-3 p-6" aria-busy="true" aria-live="polite">
205
+ <Skeleton className="h-6 w-2/5" />
206
+ <Skeleton className="h-4 w-3/5" />
207
+ <Skeleton className="h-4 w-full" />
208
+ <Skeleton className="h-4 w-10/12" />
209
+ <Skeleton className="h-4 w-4/5" />
210
+ </div>
211
+ ) : (
212
+ children
213
+ )}
214
+
215
+ {dismissible && !hideCloseButton && !loading ? (
216
+ <DialogPrimitive.Close
217
+ aria-label="Close"
218
+ className={cn(
219
+ "absolute end-3 top-3 inline-flex size-7 items-center justify-center rounded-md",
220
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
221
+ "focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
222
+ "[&_svg]:size-4 [&_svg]:shrink-0",
223
+ )}
224
+ >
225
+ <RiCloseLine />
226
+ </DialogPrimitive.Close>
227
+ ) : null}
228
+
229
+ {/* Overflow + scroll containment is owned by DrawerBody — the
230
+ `scrollable` flag on DrawerContent gates how DrawerBody styles
231
+ itself via DrawerContext. */}
232
+ </DialogPrimitive.Content>
233
+ </DrawerPortal>
234
+ </DrawerContext.Provider>
235
+ );
236
+ });
237
+ DrawerContent.displayName = "DrawerContent";
238
+
239
+ // ----------------------------------------------------------------------------
240
+ // DrawerHeader / Title / Description / Body / Footer
241
+ // ----------------------------------------------------------------------------
242
+
243
+ const DrawerHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
244
+ function DrawerHeader({ className, ...props }, ref) {
245
+ return (
246
+ <div ref={ref} className={cn("space-y-1.5 p-6 pe-12 text-start", className)} {...props} />
247
+ );
248
+ },
249
+ );
250
+ DrawerHeader.displayName = "DrawerHeader";
251
+
252
+ const DrawerTitle = forwardRef<
253
+ React.ElementRef<typeof DialogPrimitive.Title>,
254
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
255
+ >(function DrawerTitle({ className, ...props }, ref) {
256
+ return (
257
+ <DialogPrimitive.Title
258
+ ref={ref}
259
+ className={cn("text-lg leading-snug font-semibold tracking-tight", className)}
260
+ {...props}
261
+ />
262
+ );
263
+ });
264
+ DrawerTitle.displayName = "DrawerTitle";
265
+
266
+ const DrawerDescription = forwardRef<
267
+ React.ElementRef<typeof DialogPrimitive.Description>,
268
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
269
+ >(function DrawerDescription({ className, ...props }, ref) {
270
+ return (
271
+ <DialogPrimitive.Description
272
+ ref={ref}
273
+ className={cn("text-muted-foreground text-sm", className)}
274
+ {...props}
275
+ />
276
+ );
277
+ });
278
+ DrawerDescription.displayName = "DrawerDescription";
279
+
280
+ /**
281
+ * Scroll region between header and footer. Always flex-1; overflow behavior
282
+ * follows DrawerContent's `scrollable` flag (default true) — when false, the
283
+ * body sizes to its content and DrawerContent's own height controls overflow.
284
+ */
285
+ const DrawerBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
286
+ function DrawerBody({ className, ...props }, ref) {
287
+ const { scrollable } = useDrawerContext();
288
+ return (
289
+ <div
290
+ ref={ref}
291
+ className={cn("flex-1 px-6 pb-6", scrollable && "overflow-y-auto", className)}
292
+ {...props}
293
+ />
294
+ );
295
+ },
296
+ );
297
+ DrawerBody.displayName = "DrawerBody";
298
+
299
+ const DrawerFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
300
+ function DrawerFooter({ className, ...props }, ref) {
301
+ return (
302
+ <div
303
+ ref={ref}
304
+ className={cn(
305
+ "border-border flex flex-col-reverse items-stretch gap-2 border-t p-4 sm:flex-row sm:items-center sm:justify-end",
306
+ className,
307
+ )}
308
+ {...props}
309
+ />
310
+ );
311
+ },
312
+ );
313
+ DrawerFooter.displayName = "DrawerFooter";
314
+
315
+ export {
316
+ Drawer,
317
+ DrawerTrigger,
318
+ DrawerPortal,
319
+ DrawerOverlay,
320
+ DrawerContent,
321
+ DrawerHeader,
322
+ DrawerTitle,
323
+ DrawerDescription,
324
+ DrawerBody,
325
+ DrawerFooter,
326
+ DrawerClose,
327
+ drawerContentVariants,
328
+ };
329
+ export type { DrawerContentProps, DrawerPosition };