@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,193 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { Button } from "./button";
4
+ import {
5
+ Dialog,
6
+ DialogBody,
7
+ DialogClose,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogTrigger,
14
+ } from "./dialog";
15
+
16
+ const meta: Meta<typeof Dialog> = {
17
+ title: "Overlays/Dialog",
18
+ component: Dialog,
19
+ parameters: { layout: "centered" },
20
+ };
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof Dialog>;
24
+
25
+ export const Default: Story = {
26
+ render: () => (
27
+ <Dialog>
28
+ <DialogTrigger asChild>
29
+ <Button>Open dialog</Button>
30
+ </DialogTrigger>
31
+ <DialogContent>
32
+ <DialogHeader>
33
+ <DialogTitle>Delete this item?</DialogTitle>
34
+ <DialogDescription>
35
+ This action cannot be undone. The item will be permanently removed.
36
+ </DialogDescription>
37
+ </DialogHeader>
38
+ <DialogBody>
39
+ <p className="text-sm">All related references will be cleared as well.</p>
40
+ </DialogBody>
41
+ <DialogFooter>
42
+ <DialogClose asChild>
43
+ <Button variant="ghost">Cancel</Button>
44
+ </DialogClose>
45
+ <Button variant="destructive">Delete</Button>
46
+ </DialogFooter>
47
+ </DialogContent>
48
+ </Dialog>
49
+ ),
50
+ };
51
+
52
+ export const Sizes: Story = {
53
+ render: () => (
54
+ <div className="flex flex-wrap gap-2">
55
+ {(["xs", "sm", "md", "lg", "xl", "full"] as const).map((s) => (
56
+ <Dialog key={s}>
57
+ <DialogTrigger asChild>
58
+ <Button variant="outline" size="sm">
59
+ {s}
60
+ </Button>
61
+ </DialogTrigger>
62
+ <DialogContent size={s}>
63
+ <DialogHeader>
64
+ <DialogTitle>size=&quot;{s}&quot;</DialogTitle>
65
+ <DialogDescription>Max width follows the size scale.</DialogDescription>
66
+ </DialogHeader>
67
+ <DialogBody>
68
+ <p className="text-sm">
69
+ Body content. xs = 320px, sm = 420px, md = 560px (default), lg = 720px, xl = 960px,
70
+ full = viewport-fill with margin.
71
+ </p>
72
+ </DialogBody>
73
+ <DialogFooter>
74
+ <DialogClose asChild>
75
+ <Button>OK</Button>
76
+ </DialogClose>
77
+ </DialogFooter>
78
+ </DialogContent>
79
+ </Dialog>
80
+ ))}
81
+ </div>
82
+ ),
83
+ };
84
+
85
+ export const Scrollable: Story = {
86
+ render: () => (
87
+ <Dialog>
88
+ <DialogTrigger asChild>
89
+ <Button>Long content</Button>
90
+ </DialogTrigger>
91
+ <DialogContent scrollable>
92
+ <DialogHeader>
93
+ <DialogTitle>Terms of service</DialogTitle>
94
+ <DialogDescription>Scroll to read everything.</DialogDescription>
95
+ </DialogHeader>
96
+ <DialogBody>
97
+ {Array.from({ length: 30 }, (_, i) => (
98
+ <p key={i} className="mb-3 text-sm">
99
+ Paragraph {i + 1}: lorem ipsum dolor sit amet, consectetur adipiscing elit.
100
+ </p>
101
+ ))}
102
+ </DialogBody>
103
+ <DialogFooter>
104
+ <DialogClose asChild>
105
+ <Button variant="ghost">Decline</Button>
106
+ </DialogClose>
107
+ <Button>I agree</Button>
108
+ </DialogFooter>
109
+ </DialogContent>
110
+ </Dialog>
111
+ ),
112
+ };
113
+
114
+ export const ConfirmBeforeClose: Story = {
115
+ render: () => (
116
+ <Dialog>
117
+ <DialogTrigger asChild>
118
+ <Button>Open (confirm on close)</Button>
119
+ </DialogTrigger>
120
+ <DialogContent confirmBeforeClose>
121
+ <DialogHeader>
122
+ <DialogTitle>Edit profile</DialogTitle>
123
+ <DialogDescription>
124
+ Closing this dialog will prompt to confirm — useful when fields are dirty.
125
+ </DialogDescription>
126
+ </DialogHeader>
127
+ <DialogBody>
128
+ <p className="text-sm">Try clicking outside or pressing Escape.</p>
129
+ </DialogBody>
130
+ </DialogContent>
131
+ </Dialog>
132
+ ),
133
+ };
134
+
135
+ export const Loading: Story = {
136
+ render: () => (
137
+ <Dialog>
138
+ <DialogTrigger asChild>
139
+ <Button>Open (loading)</Button>
140
+ </DialogTrigger>
141
+ <DialogContent loading />
142
+ </Dialog>
143
+ ),
144
+ };
145
+
146
+ export const NoCloseButton: Story = {
147
+ render: () => (
148
+ <Dialog>
149
+ <DialogTrigger asChild>
150
+ <Button>Open (no X)</Button>
151
+ </DialogTrigger>
152
+ <DialogContent hideCloseButton>
153
+ <DialogHeader>
154
+ <DialogTitle>Are you sure?</DialogTitle>
155
+ <DialogDescription>
156
+ Use the explicit footer buttons to dismiss this dialog.
157
+ </DialogDescription>
158
+ </DialogHeader>
159
+ <DialogFooter>
160
+ <DialogClose asChild>
161
+ <Button variant="ghost">Cancel</Button>
162
+ </DialogClose>
163
+ <DialogClose asChild>
164
+ <Button>Confirm</Button>
165
+ </DialogClose>
166
+ </DialogFooter>
167
+ </DialogContent>
168
+ </Dialog>
169
+ ),
170
+ };
171
+
172
+ export const DisableOverlayClick: Story = {
173
+ render: () => (
174
+ <Dialog>
175
+ <DialogTrigger asChild>
176
+ <Button>Click outside won&apos;t close</Button>
177
+ </DialogTrigger>
178
+ <DialogContent closeOnOverlayClick={false}>
179
+ <DialogHeader>
180
+ <DialogTitle>Sticky dialog</DialogTitle>
181
+ <DialogDescription>
182
+ Use the X or Cancel button to dismiss. Outside-clicks are ignored.
183
+ </DialogDescription>
184
+ </DialogHeader>
185
+ <DialogFooter>
186
+ <DialogClose asChild>
187
+ <Button>OK</Button>
188
+ </DialogClose>
189
+ </DialogFooter>
190
+ </DialogContent>
191
+ </Dialog>
192
+ ),
193
+ };
@@ -0,0 +1,262 @@
1
+ "use client";
2
+
3
+ import { forwardRef } 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
+ // Per docs/emara-ui-phase-3-components.md §1.
12
+
13
+ const Dialog = DialogPrimitive.Root;
14
+ const DialogTrigger = DialogPrimitive.Trigger;
15
+ const DialogPortal = DialogPrimitive.Portal;
16
+ const DialogClose = DialogPrimitive.Close;
17
+
18
+ // ----------------------------------------------------------------------------
19
+ // DialogOverlay
20
+ // ----------------------------------------------------------------------------
21
+
22
+ const DialogOverlay = forwardRef<
23
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
24
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
25
+ >(function DialogOverlay({ className, ...props }, ref) {
26
+ return (
27
+ <DialogPrimitive.Overlay
28
+ ref={ref}
29
+ className={cn(
30
+ "z-modal bg-foreground/50 fixed inset-0",
31
+ "data-[state=open]:animate-[fade-in_var(--duration-normal)_var(--ease-out)]",
32
+ "data-[state=closed]:animate-[fade-out_var(--duration-fast)_var(--ease-in)]",
33
+ className,
34
+ )}
35
+ {...props}
36
+ />
37
+ );
38
+ });
39
+ DialogOverlay.displayName = "DialogOverlay";
40
+
41
+ // ----------------------------------------------------------------------------
42
+ // DialogContent
43
+ // ----------------------------------------------------------------------------
44
+
45
+ const dialogContentVariants = cva(
46
+ [
47
+ "fixed start-1/2 top-1/2 z-modal grid w-full -translate-x-1/2 -translate-y-1/2 gap-0",
48
+ "rounded-lg border border-border bg-card text-card-foreground shadow-xl",
49
+ "data-[state=open]:animate-[scale-in_var(--duration-normal)_var(--ease-out)]",
50
+ "data-[state=closed]:animate-[scale-out_var(--duration-fast)_var(--ease-in)]",
51
+ // The start/-translate-x trick centers via logical start in LTR. RTL flips:
52
+ "rtl:start-auto rtl:end-1/2 rtl:translate-x-1/2",
53
+ ].join(" "),
54
+ {
55
+ variants: {
56
+ size: {
57
+ xs: "max-w-dialog-xs",
58
+ sm: "max-w-dialog-sm",
59
+ md: "max-w-dialog-md",
60
+ lg: "max-w-dialog-lg",
61
+ xl: "max-w-dialog-xl",
62
+ full: "max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)]",
63
+ },
64
+ },
65
+ defaultVariants: { size: "md" },
66
+ },
67
+ );
68
+
69
+ type DialogContentVariants = VariantProps<typeof dialogContentVariants>;
70
+
71
+ type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
72
+ DialogContentVariants & {
73
+ closeOnOverlayClick?: boolean;
74
+ closeOnEscape?: boolean;
75
+ confirmBeforeClose?: boolean | (() => boolean | Promise<boolean>);
76
+ loading?: boolean;
77
+ scrollable?: boolean;
78
+ hideCloseButton?: boolean;
79
+ };
80
+
81
+ const DialogContent = forwardRef<
82
+ React.ElementRef<typeof DialogPrimitive.Content>,
83
+ DialogContentProps
84
+ >(function DialogContent(
85
+ {
86
+ className,
87
+ size,
88
+ closeOnOverlayClick = true,
89
+ closeOnEscape = true,
90
+ confirmBeforeClose,
91
+ loading = false,
92
+ scrollable = false,
93
+ hideCloseButton = false,
94
+ onPointerDownOutside,
95
+ onEscapeKeyDown,
96
+ onInteractOutside,
97
+ children,
98
+ ...props
99
+ },
100
+ ref,
101
+ ) {
102
+ const guardedClose = async (e: Event): Promise<boolean> => {
103
+ if (!confirmBeforeClose) return false;
104
+ if (typeof confirmBeforeClose === "function") {
105
+ const ok = await confirmBeforeClose();
106
+ if (!ok) {
107
+ e.preventDefault();
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ const ok = window.confirm("Discard unsaved changes?");
113
+ if (!ok) {
114
+ e.preventDefault();
115
+ return true;
116
+ }
117
+ return false;
118
+ };
119
+
120
+ return (
121
+ <DialogPortal>
122
+ <DialogOverlay />
123
+ <DialogPrimitive.Content
124
+ ref={ref}
125
+ onPointerDownOutside={(e) => {
126
+ if (!closeOnOverlayClick) e.preventDefault();
127
+ onPointerDownOutside?.(e);
128
+ }}
129
+ onEscapeKeyDown={(e) => {
130
+ if (!closeOnEscape) e.preventDefault();
131
+ onEscapeKeyDown?.(e);
132
+ }}
133
+ onInteractOutside={async (e) => {
134
+ if (confirmBeforeClose) await guardedClose(e as unknown as Event);
135
+ onInteractOutside?.(e);
136
+ }}
137
+ className={cn(
138
+ dialogContentVariants({ size }),
139
+ // Optional internal scroll. When scrollable, the body slot becomes
140
+ // the overflow region (see DialogBody).
141
+ scrollable ? "max-h-[85vh]" : "",
142
+ className,
143
+ )}
144
+ {...props}
145
+ >
146
+ {loading ? (
147
+ <div className="space-y-3 p-6" aria-busy="true" aria-live="polite">
148
+ <Skeleton className="h-6 w-2/5" />
149
+ <Skeleton className="h-4 w-3/5" />
150
+ <Skeleton className="h-4 w-full" />
151
+ <Skeleton className="h-4 w-10/12" />
152
+ <Skeleton className="h-4 w-4/5" />
153
+ </div>
154
+ ) : (
155
+ children
156
+ )}
157
+
158
+ {!hideCloseButton && !loading ? (
159
+ <DialogPrimitive.Close
160
+ aria-label="Close"
161
+ className={cn(
162
+ "absolute end-3 top-3 inline-flex size-7 items-center justify-center rounded-md",
163
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
164
+ "focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
165
+ "[&_svg]:size-4 [&_svg]:shrink-0",
166
+ )}
167
+ >
168
+ <RiCloseLine />
169
+ </DialogPrimitive.Close>
170
+ ) : null}
171
+ </DialogPrimitive.Content>
172
+ </DialogPortal>
173
+ );
174
+ });
175
+ DialogContent.displayName = "DialogContent";
176
+
177
+ // ----------------------------------------------------------------------------
178
+ // DialogHeader / DialogTitle / DialogDescription / DialogBody / DialogFooter
179
+ // ----------------------------------------------------------------------------
180
+
181
+ const DialogHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
182
+ function DialogHeader({ className, ...props }, ref) {
183
+ return (
184
+ <div ref={ref} className={cn("space-y-1.5 p-6 pe-12 text-start", className)} {...props} />
185
+ );
186
+ },
187
+ );
188
+ DialogHeader.displayName = "DialogHeader";
189
+
190
+ const DialogTitle = forwardRef<
191
+ React.ElementRef<typeof DialogPrimitive.Title>,
192
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
193
+ >(function DialogTitle({ className, ...props }, ref) {
194
+ return (
195
+ <DialogPrimitive.Title
196
+ ref={ref}
197
+ className={cn("text-lg leading-snug font-semibold tracking-tight", className)}
198
+ {...props}
199
+ />
200
+ );
201
+ });
202
+ DialogTitle.displayName = "DialogTitle";
203
+
204
+ const DialogDescription = forwardRef<
205
+ React.ElementRef<typeof DialogPrimitive.Description>,
206
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
207
+ >(function DialogDescription({ className, ...props }, ref) {
208
+ return (
209
+ <DialogPrimitive.Description
210
+ ref={ref}
211
+ className={cn("text-muted-foreground text-sm", className)}
212
+ {...props}
213
+ />
214
+ );
215
+ });
216
+ DialogDescription.displayName = "DialogDescription";
217
+
218
+ /**
219
+ * Emara addition — explicit body slot that owns padding + scroll behavior.
220
+ * Use inside DialogContent so the header and footer stay fixed while the body
221
+ * scrolls when content overflows.
222
+ */
223
+ const DialogBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
224
+ function DialogBody({ className, ...props }, ref) {
225
+ return (
226
+ <div ref={ref} className={cn("flex-1 overflow-y-auto px-6 pb-6", className)} {...props} />
227
+ );
228
+ },
229
+ );
230
+ DialogBody.displayName = "DialogBody";
231
+
232
+ const DialogFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
233
+ function DialogFooter({ className, ...props }, ref) {
234
+ return (
235
+ <div
236
+ ref={ref}
237
+ className={cn(
238
+ "border-border flex flex-col-reverse items-stretch gap-2 border-t p-4 sm:flex-row sm:items-center sm:justify-end",
239
+ className,
240
+ )}
241
+ {...props}
242
+ />
243
+ );
244
+ },
245
+ );
246
+ DialogFooter.displayName = "DialogFooter";
247
+
248
+ export {
249
+ Dialog,
250
+ DialogTrigger,
251
+ DialogPortal,
252
+ DialogOverlay,
253
+ DialogContent,
254
+ DialogHeader,
255
+ DialogTitle,
256
+ DialogDescription,
257
+ DialogBody,
258
+ DialogFooter,
259
+ DialogClose,
260
+ dialogContentVariants,
261
+ };
262
+ export type { DialogContentProps };
@@ -0,0 +1,84 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { Divider } from "./divider";
4
+
5
+ const meta: Meta<typeof Divider> = {
6
+ title: "Foundations/Divider",
7
+ component: Divider,
8
+ parameters: { layout: "centered" },
9
+ argTypes: {
10
+ orientation: { control: "select", options: ["horizontal", "vertical"] },
11
+ variant: { control: "select", options: ["solid", "dashed", "dotted"] },
12
+ spacing: { control: "select", options: ["none", "sm", "md", "lg"] },
13
+ labelPosition: { control: "select", options: ["start", "center", "end"] },
14
+ decorative: { control: "boolean" },
15
+ },
16
+ };
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof Divider>;
20
+
21
+ export const Default: Story = {
22
+ render: (args) => (
23
+ <div className="w-80">
24
+ <Divider {...args} />
25
+ </div>
26
+ ),
27
+ };
28
+
29
+ export const Variants: Story = {
30
+ render: () => (
31
+ <div className="w-80 space-y-4">
32
+ <Divider variant="solid" />
33
+ <Divider variant="dashed" />
34
+ <Divider variant="dotted" />
35
+ </div>
36
+ ),
37
+ };
38
+
39
+ export const SpacingScale: Story = {
40
+ render: () => (
41
+ <div className="w-80 rounded-md border border-border p-2">
42
+ <p className="text-sm">none</p>
43
+ <Divider spacing="none" />
44
+ <p className="text-sm">sm</p>
45
+ <Divider spacing="sm" />
46
+ <p className="text-sm">md</p>
47
+ <Divider spacing="md" />
48
+ <p className="text-sm">lg</p>
49
+ <Divider spacing="lg" />
50
+ <p className="text-sm">end</p>
51
+ </div>
52
+ ),
53
+ };
54
+
55
+ export const Vertical: Story = {
56
+ render: () => (
57
+ <div className="flex h-12 items-center gap-3 text-sm">
58
+ <span>Section A</span>
59
+ <Divider orientation="vertical" />
60
+ <span>Section B</span>
61
+ <Divider orientation="vertical" variant="dashed" />
62
+ <span>Section C</span>
63
+ </div>
64
+ ),
65
+ };
66
+
67
+ export const WithLabel: Story = {
68
+ render: () => (
69
+ <div className="w-80 space-y-6">
70
+ <Divider label="OR" />
71
+ <Divider label="Section" labelPosition="start" />
72
+ <Divider label="Section" labelPosition="end" />
73
+ </div>
74
+ ),
75
+ };
76
+
77
+ export const NonDecorative: Story = {
78
+ args: { decorative: false },
79
+ render: (args) => (
80
+ <div className="w-80">
81
+ <Divider {...args} />
82
+ </div>
83
+ ),
84
+ };
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
4
+ import * as SeparatorPrimitive from "@radix-ui/react-separator";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ // Per docs/emara-ui-phase-1-components.md §8.
10
+
11
+ const lineVariants = cva("shrink-0 bg-transparent", {
12
+ variants: {
13
+ orientation: {
14
+ horizontal: "w-full h-px border-t",
15
+ vertical: "h-full w-px border-s",
16
+ },
17
+ variant: {
18
+ solid: "border-solid border-border",
19
+ dashed: "border-dashed border-border",
20
+ dotted: "border-dotted border-border",
21
+ },
22
+ },
23
+ defaultVariants: {
24
+ orientation: "horizontal",
25
+ variant: "solid",
26
+ },
27
+ });
28
+
29
+ const spacingVariants = cva("", {
30
+ variants: {
31
+ orientation: {
32
+ horizontal: "",
33
+ vertical: "",
34
+ },
35
+ spacing: {
36
+ none: "",
37
+ sm: "",
38
+ md: "",
39
+ lg: "",
40
+ },
41
+ },
42
+ compoundVariants: [
43
+ { orientation: "horizontal", spacing: "sm", class: "my-2" },
44
+ { orientation: "horizontal", spacing: "md", class: "my-4" },
45
+ { orientation: "horizontal", spacing: "lg", class: "my-6" },
46
+ { orientation: "vertical", spacing: "sm", class: "mx-2" },
47
+ { orientation: "vertical", spacing: "md", class: "mx-4" },
48
+ { orientation: "vertical", spacing: "lg", class: "mx-6" },
49
+ ],
50
+ defaultVariants: {
51
+ orientation: "horizontal",
52
+ spacing: "none",
53
+ },
54
+ });
55
+
56
+ type DividerVariants = VariantProps<typeof lineVariants> & VariantProps<typeof spacingVariants>;
57
+
58
+ type DividerProps = Omit<
59
+ React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>,
60
+ "orientation" | "decorative"
61
+ > &
62
+ DividerVariants & {
63
+ decorative?: boolean;
64
+ label?: React.ReactNode;
65
+ labelPosition?: "start" | "center" | "end";
66
+ };
67
+
68
+ const Divider = forwardRef<
69
+ React.ElementRef<typeof SeparatorPrimitive.Root>,
70
+ DividerProps
71
+ >(function Divider(
72
+ {
73
+ className,
74
+ orientation = "horizontal",
75
+ variant = "solid",
76
+ spacing = "none",
77
+ decorative = true,
78
+ label,
79
+ labelPosition = "center",
80
+ ...props
81
+ },
82
+ ref,
83
+ ) {
84
+ const hasLabel = label !== undefined && label !== null && orientation === "horizontal";
85
+
86
+ if (!hasLabel) {
87
+ return (
88
+ <SeparatorPrimitive.Root
89
+ ref={ref}
90
+ orientation={orientation ?? "horizontal"}
91
+ decorative={decorative}
92
+ className={cn(
93
+ lineVariants({ orientation, variant }),
94
+ spacingVariants({ orientation, spacing }),
95
+ className,
96
+ )}
97
+ {...props}
98
+ />
99
+ );
100
+ }
101
+
102
+ // Labeled divider — render label between two flex-grown lines. We provide
103
+ // role/aria ourselves because we're no longer a single <hr>/<div role=separator>.
104
+ return (
105
+ <div
106
+ ref={ref as unknown as React.Ref<HTMLDivElement>}
107
+ role={decorative ? "presentation" : "separator"}
108
+ aria-orientation={decorative ? undefined : "horizontal"}
109
+ className={cn(
110
+ "flex w-full items-center gap-3 text-sm text-muted-foreground",
111
+ spacingVariants({ orientation: "horizontal", spacing }),
112
+ className,
113
+ )}
114
+ {...props}
115
+ >
116
+ {labelPosition !== "start" ? (
117
+ <span
118
+ aria-hidden="true"
119
+ className={cn(lineVariants({ orientation: "horizontal", variant }), "flex-1")}
120
+ />
121
+ ) : null}
122
+ <span>{label}</span>
123
+ {labelPosition !== "end" ? (
124
+ <span
125
+ aria-hidden="true"
126
+ className={cn(lineVariants({ orientation: "horizontal", variant }), "flex-1")}
127
+ />
128
+ ) : null}
129
+ </div>
130
+ );
131
+ });
132
+ Divider.displayName = "Divider";
133
+
134
+ export { Divider, lineVariants };
135
+ export type { DividerProps };