@thangnqdev/ui 0.2.1 → 0.2.2

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 (190) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +144 -142
  3. package/bin/cli.js +125 -125
  4. package/dist/bento/index.cjs.map +1 -1
  5. package/dist/bento/index.js +2 -2
  6. package/dist/buttons/index.cjs.map +1 -1
  7. package/dist/buttons/index.js +3 -3
  8. package/dist/{chunk-77KTYOMQ.js → chunk-2OXZK6IP.js} +3 -3
  9. package/dist/chunk-2OXZK6IP.js.map +1 -0
  10. package/dist/{chunk-4CRFNALF.js → chunk-4GNFT2ZJ.js} +7 -7
  11. package/dist/chunk-4GNFT2ZJ.js.map +1 -0
  12. package/dist/{chunk-2YJBW4E2.js → chunk-4S6L5V7X.js} +2 -2
  13. package/dist/chunk-4S6L5V7X.js.map +1 -0
  14. package/dist/{chunk-I2FQBMVW.js → chunk-66VWUJEI.js} +3 -3
  15. package/dist/chunk-66VWUJEI.js.map +1 -0
  16. package/dist/{chunk-YOCGBORV.js → chunk-7NMB2Y64.js} +4 -4
  17. package/dist/chunk-7NMB2Y64.js.map +1 -0
  18. package/dist/{chunk-V52JOSDF.js → chunk-A7P3O5Z5.js} +3 -3
  19. package/dist/chunk-A7P3O5Z5.js.map +1 -0
  20. package/dist/{chunk-ME4XILG5.js → chunk-ATYGDTMK.js} +5 -5
  21. package/dist/chunk-ATYGDTMK.js.map +1 -0
  22. package/dist/{chunk-S6XJMYME.js → chunk-C4SUXMI3.js} +3 -3
  23. package/dist/chunk-C4SUXMI3.js.map +1 -0
  24. package/dist/{chunk-EXFR2BFJ.js → chunk-DUPPPGXY.js} +7 -7
  25. package/dist/chunk-DUPPPGXY.js.map +1 -0
  26. package/dist/{chunk-I5O2WCGF.js → chunk-EU2LPUYP.js} +3 -3
  27. package/dist/chunk-EU2LPUYP.js.map +1 -0
  28. package/dist/{chunk-D7FB3TCA.js → chunk-GXI2H5XA.js} +3 -3
  29. package/dist/chunk-GXI2H5XA.js.map +1 -0
  30. package/dist/{chunk-ADO3SVUA.js → chunk-IABPUCAQ.js} +2 -2
  31. package/dist/chunk-IABPUCAQ.js.map +1 -0
  32. package/dist/{chunk-PC5SZFZC.js → chunk-JOKEKOP3.js} +8 -8
  33. package/dist/chunk-JOKEKOP3.js.map +1 -0
  34. package/dist/{chunk-GXWB6BSY.js → chunk-KUQWUTZA.js} +4 -4
  35. package/dist/chunk-KUQWUTZA.js.map +1 -0
  36. package/dist/{chunk-ZEDXARXW.js → chunk-U3J4UPV3.js} +2 -2
  37. package/dist/chunk-U3J4UPV3.js.map +1 -0
  38. package/dist/{chunk-DSEL7ELZ.js → chunk-VE2PNSDQ.js} +4 -4
  39. package/dist/chunk-VE2PNSDQ.js.map +1 -0
  40. package/dist/{chunk-YJ64RI7J.js → chunk-WAFHC4DQ.js} +3 -3
  41. package/dist/chunk-WAFHC4DQ.js.map +1 -0
  42. package/dist/{chunk-W5WNWTQC.js → chunk-YJ67WYVW.js} +5 -5
  43. package/dist/chunk-YJ67WYVW.js.map +1 -0
  44. package/dist/{chunk-4MS6ZHFQ.js → chunk-Z427NPMU.js} +7 -7
  45. package/dist/chunk-Z427NPMU.js.map +1 -0
  46. package/dist/core/index.cjs +4 -4
  47. package/dist/core/index.cjs.map +1 -1
  48. package/dist/core/index.js +2 -2
  49. package/dist/data-display/index.cjs.map +1 -1
  50. package/dist/data-display/index.js +5 -5
  51. package/dist/feedback/index.cjs.map +1 -1
  52. package/dist/feedback/index.js +2 -2
  53. package/dist/forms/index.cjs.map +1 -1
  54. package/dist/forms/index.js +8 -8
  55. package/dist/glass/index.cjs.map +1 -1
  56. package/dist/glass/index.js +2 -2
  57. package/dist/index.cjs +4 -4
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.js +19 -19
  60. package/dist/layout/index.cjs.map +1 -1
  61. package/dist/layout/index.js +2 -2
  62. package/dist/navigation/index.cjs.map +1 -1
  63. package/dist/navigation/index.js +6 -6
  64. package/dist/overlays/index.cjs.map +1 -1
  65. package/dist/overlays/index.js +7 -7
  66. package/dist/reset.css +45 -45
  67. package/dist/tokens.css +11 -11
  68. package/dist/typography/index.cjs.map +1 -1
  69. package/dist/typography/index.js +4 -4
  70. package/llms.txt +3 -3
  71. package/package.json +165 -165
  72. package/scripts/postinstall.js +125 -125
  73. package/src/bento/BentoCard.tsx +140 -140
  74. package/src/bento/BentoGrid.tsx +50 -50
  75. package/src/bento/index.ts +2 -2
  76. package/src/bento/tokens.css +46 -46
  77. package/src/buttons/Button.tsx +104 -104
  78. package/src/buttons/ButtonGroup.tsx +43 -43
  79. package/src/buttons/IconButton.tsx +79 -79
  80. package/src/buttons/SplitButton.tsx +186 -186
  81. package/src/buttons/ToggleButton.tsx +157 -157
  82. package/src/buttons/index.ts +5 -5
  83. package/src/buttons/tokens.css +6 -6
  84. package/src/core/ThemeProvider.tsx +133 -133
  85. package/src/core/index.ts +2 -2
  86. package/src/core/tokens.css +134 -134
  87. package/src/data-display/Accordion.tsx +147 -147
  88. package/src/data-display/Avatar.tsx +134 -134
  89. package/src/data-display/Card.tsx +112 -112
  90. package/src/data-display/Chip.tsx +74 -74
  91. package/src/data-display/DataTablePro.tsx +534 -534
  92. package/src/data-display/StatCard.tsx +60 -60
  93. package/src/data-display/Table.tsx +74 -74
  94. package/src/data-display/Timeline.tsx +62 -62
  95. package/src/data-display/TreeView.tsx +279 -279
  96. package/src/data-display/tokens.css +4 -4
  97. package/src/feedback/Alert.tsx +80 -80
  98. package/src/feedback/EmptyState.tsx +45 -45
  99. package/src/feedback/Skeleton.tsx +66 -66
  100. package/src/feedback/Spinner.tsx +65 -65
  101. package/src/feedback/index.ts +5 -5
  102. package/src/feedback/tokens.css +4 -4
  103. package/src/forms/Checkbox.tsx +149 -149
  104. package/src/forms/CheckboxGroup.tsx +76 -76
  105. package/src/forms/Combobox.tsx +512 -512
  106. package/src/forms/DatePicker.tsx +784 -784
  107. package/src/forms/FileUpload.tsx +266 -266
  108. package/src/forms/FormField.tsx +115 -115
  109. package/src/forms/Input.tsx +151 -151
  110. package/src/forms/NumberInput.tsx +262 -262
  111. package/src/forms/PasswordInput.tsx +35 -35
  112. package/src/forms/PinInput.tsx +139 -139
  113. package/src/forms/Radio.tsx +142 -142
  114. package/src/forms/RangeSlider.tsx +237 -237
  115. package/src/forms/SearchInput.tsx +34 -34
  116. package/src/forms/Select.tsx +103 -103
  117. package/src/forms/Slider.tsx +85 -85
  118. package/src/forms/Textarea.tsx +114 -114
  119. package/src/forms/tokens.css +6 -6
  120. package/src/glass/GlassButton.tsx +59 -59
  121. package/src/glass/GlassCard.tsx +71 -71
  122. package/src/glass/index.ts +3 -3
  123. package/src/glass/tokens.css +81 -81
  124. package/src/index.ts +26 -26
  125. package/src/layout/AspectRatio.tsx +39 -39
  126. package/src/layout/Container.tsx +53 -53
  127. package/src/layout/Divider.tsx +61 -61
  128. package/src/layout/Flex.tsx +79 -79
  129. package/src/layout/Grid.tsx +124 -124
  130. package/src/layout/PageLayout.tsx +59 -59
  131. package/src/layout/Section.tsx +47 -47
  132. package/src/layout/Spacer.tsx +30 -30
  133. package/src/layout/Stack.tsx +88 -88
  134. package/src/layout/tokens.css +8 -8
  135. package/src/navigation/Breadcrumb.tsx +73 -73
  136. package/src/navigation/CommandPalette.tsx +142 -142
  137. package/src/navigation/DropdownMenu.tsx +244 -244
  138. package/src/navigation/Pagination.tsx +114 -114
  139. package/src/navigation/Stepper.tsx +93 -93
  140. package/src/navigation/Tabs.tsx +265 -265
  141. package/src/navigation/index.ts +6 -6
  142. package/src/navigation/tokens.css +4 -4
  143. package/src/overlays/AlertDialog.tsx +63 -63
  144. package/src/overlays/BottomSheet.tsx +143 -143
  145. package/src/overlays/ContextMenu.tsx +161 -161
  146. package/src/overlays/Drawer.tsx +160 -160
  147. package/src/overlays/FocusTrap.tsx +122 -122
  148. package/src/overlays/Modal.tsx +296 -296
  149. package/src/overlays/Popover.tsx +156 -156
  150. package/src/overlays/Portal.tsx +23 -23
  151. package/src/overlays/Toast.tsx +175 -175
  152. package/src/overlays/Tooltip.tsx +125 -125
  153. package/src/overlays/tokens.css +8 -8
  154. package/src/registry/components.ts +1368 -1368
  155. package/src/styles/components.css +39 -39
  156. package/src/styles/index.css +5 -5
  157. package/src/styles/reset.css +45 -45
  158. package/src/styles/tokens.css +11 -11
  159. package/src/typography/Blockquote.tsx +32 -32
  160. package/src/typography/Code.tsx +89 -89
  161. package/src/typography/Heading.tsx +56 -56
  162. package/src/typography/Kbd.tsx +25 -25
  163. package/src/typography/Label.tsx +47 -47
  164. package/src/typography/Link.tsx +56 -56
  165. package/src/typography/Text.tsx +62 -62
  166. package/src/typography/index.ts +7 -7
  167. package/src/typography/tokens.css +5 -5
  168. package/src/utils/cn.ts +6 -6
  169. package/src/utils/scroll-lock.ts +19 -19
  170. package/src/utils/useControllableState.ts +54 -54
  171. package/ui-contract.json +1 -1
  172. package/dist/chunk-2YJBW4E2.js.map +0 -1
  173. package/dist/chunk-4CRFNALF.js.map +0 -1
  174. package/dist/chunk-4MS6ZHFQ.js.map +0 -1
  175. package/dist/chunk-77KTYOMQ.js.map +0 -1
  176. package/dist/chunk-ADO3SVUA.js.map +0 -1
  177. package/dist/chunk-D7FB3TCA.js.map +0 -1
  178. package/dist/chunk-DSEL7ELZ.js.map +0 -1
  179. package/dist/chunk-EXFR2BFJ.js.map +0 -1
  180. package/dist/chunk-GXWB6BSY.js.map +0 -1
  181. package/dist/chunk-I2FQBMVW.js.map +0 -1
  182. package/dist/chunk-I5O2WCGF.js.map +0 -1
  183. package/dist/chunk-ME4XILG5.js.map +0 -1
  184. package/dist/chunk-PC5SZFZC.js.map +0 -1
  185. package/dist/chunk-S6XJMYME.js.map +0 -1
  186. package/dist/chunk-V52JOSDF.js.map +0 -1
  187. package/dist/chunk-W5WNWTQC.js.map +0 -1
  188. package/dist/chunk-YJ64RI7J.js.map +0 -1
  189. package/dist/chunk-YOCGBORV.js.map +0 -1
  190. package/dist/chunk-ZEDXARXW.js.map +0 -1
@@ -1,296 +1,296 @@
1
- import React, { forwardRef, createContext, useContext, useEffect, useId, useState, useCallback } from 'react';
2
- import { cva, type VariantProps } from 'class-variance-authority';
3
- import { X } from 'lucide-react';
4
- import { Portal } from './Portal';
5
- import { FocusTrap } from './FocusTrap';
6
- import { lockBodyScroll, unlockBodyScroll } from '../utils/scroll-lock';
7
- import { cn } from '../utils/cn';
8
-
9
- interface ModalContextType {
10
- isOpen: boolean;
11
- onClose: () => void;
12
- closeOnOverlayClick?: boolean;
13
- titleId: string;
14
- descriptionId: string;
15
- registerTitle: (id?: string) => () => void;
16
- registerDescription: (id?: string) => () => void;
17
- }
18
-
19
- const ModalContext = createContext<ModalContextType | null>(null);
20
-
21
- export function useModal() {
22
- const context = useContext(ModalContext);
23
- if (!context) {
24
- throw new Error('Modal compound components must be used within a <Modal />');
25
- }
26
- return context;
27
- }
28
-
29
- export interface ModalProps {
30
- isOpen: boolean;
31
- onClose: () => void;
32
- children: React.ReactNode;
33
- closeOnEsc?: boolean;
34
- closeOnOverlayClick?: boolean;
35
- role?: 'dialog' | 'alertdialog';
36
- 'aria-label'?: string;
37
- 'aria-labelledby'?: string;
38
- 'aria-describedby'?: string;
39
- }
40
-
41
- export function Modal({
42
- isOpen,
43
- onClose,
44
- children,
45
- closeOnEsc = true,
46
- closeOnOverlayClick = true,
47
- role = 'dialog',
48
- 'aria-label': ariaLabel,
49
- 'aria-labelledby': customLabelledBy,
50
- 'aria-describedby': customDescribedBy,
51
- }: ModalProps) {
52
- const generatedId = useId();
53
- const titleId = customLabelledBy || `modal-title-${generatedId}`;
54
- const descriptionId = customDescribedBy || `modal-desc-${generatedId}`;
55
-
56
- const [mountedTitleId, setMountedTitleId] = useState<string | null>(null);
57
- const [mountedDescId, setMountedDescId] = useState<string | null>(null);
58
-
59
- const registerTitle = useCallback(
60
- (id?: string) => {
61
- const activeId = id || titleId;
62
- setMountedTitleId(activeId);
63
- return () => setMountedTitleId(null);
64
- },
65
- [titleId]
66
- );
67
-
68
- const registerDescription = useCallback(
69
- (id?: string) => {
70
- const activeId = id || descriptionId;
71
- setMountedDescId(activeId);
72
- return () => setMountedDescId(null);
73
- },
74
- [descriptionId]
75
- );
76
-
77
- useEffect(() => {
78
- if (!isOpen) return;
79
-
80
- lockBodyScroll();
81
-
82
- const handleKeyDown = (e: KeyboardEvent) => {
83
- if (closeOnEsc && e.key === 'Escape') {
84
- onClose();
85
- }
86
- };
87
-
88
- window.addEventListener('keydown', handleKeyDown);
89
- return () => {
90
- window.removeEventListener('keydown', handleKeyDown);
91
- unlockBodyScroll();
92
- };
93
- }, [isOpen, closeOnEsc, onClose]);
94
-
95
- if (!isOpen) return null;
96
-
97
- const resolvedLabelledBy = customLabelledBy || (ariaLabel ? undefined : (mountedTitleId || undefined));
98
- const resolvedDescribedBy = customDescribedBy || (mountedDescId || undefined);
99
-
100
- return (
101
- <Portal>
102
- <ModalContext.Provider
103
- value={{
104
- isOpen,
105
- onClose,
106
- closeOnOverlayClick,
107
- titleId,
108
- descriptionId,
109
- registerTitle,
110
- registerDescription,
111
- }}
112
- >
113
- <FocusTrap active={isOpen} restoreFocus={true}>
114
- <div
115
- className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
116
- role={role}
117
- aria-modal="true"
118
- aria-label={ariaLabel}
119
- aria-labelledby={resolvedLabelledBy}
120
- aria-describedby={resolvedDescribedBy}
121
- >
122
- {children}
123
- </div>
124
- </FocusTrap>
125
- </ModalContext.Provider>
126
- </Portal>
127
- );
128
- }
129
-
130
- export interface ModalOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
131
-
132
- export const ModalOverlay = forwardRef<HTMLDivElement, ModalOverlayProps>(
133
- ({ className, onClick, ...props }, ref) => {
134
- const { onClose, closeOnOverlayClick } = useModal();
135
-
136
- return (
137
- <div
138
- ref={ref}
139
- aria-hidden="true"
140
- onClick={(e) => {
141
- onClick?.(e);
142
- if (closeOnOverlayClick) onClose();
143
- }}
144
- className={cn(
145
- 'fixed inset-0 bg-[var(--ui-bg-overlay)] backdrop-blur-sm transition-opacity animate-in fade-in duration-200',
146
- className
147
- )}
148
- {...props}
149
- />
150
- );
151
- }
152
- );
153
- ModalOverlay.displayName = 'ModalOverlay';
154
-
155
- export const modalContentVariants = cva(
156
- 'relative w-full max-w-lg rounded-[var(--ui-radius-xl)] bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] p-6 text-[var(--ui-text-primary)] shadow-2xl transition-all animate-in zoom-in-95 duration-200 z-10',
157
- {
158
- variants: {
159
- size: {
160
- sm: 'max-w-sm',
161
- md: 'max-w-md',
162
- lg: 'max-w-lg',
163
- xl: 'max-w-xl',
164
- '2xl': 'max-w-2xl',
165
- full: 'max-w-[calc(100vw-2rem)] h-[calc(100vh-2rem)]',
166
- },
167
- },
168
- defaultVariants: {
169
- size: 'md',
170
- },
171
- }
172
- );
173
-
174
- export interface ModalContentProps
175
- extends React.HTMLAttributes<HTMLDivElement>,
176
- VariantProps<typeof modalContentVariants> {}
177
-
178
- export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
179
- ({ className, size, children, ...props }, ref) => {
180
- return (
181
- <div
182
- ref={ref}
183
- className={cn(modalContentVariants({ size }), className)}
184
- {...props}
185
- >
186
- {children}
187
- </div>
188
- );
189
- }
190
- );
191
- ModalContent.displayName = 'ModalContent';
192
-
193
- export const ModalHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
194
- ({ className, children, ...props }, ref) => {
195
- return (
196
- <div ref={ref} className={cn('flex flex-col space-y-1.5 mb-4', className)} {...props}>
197
- {children}
198
- </div>
199
- );
200
- }
201
- );
202
- ModalHeader.displayName = 'ModalHeader';
203
-
204
- export const ModalTitle = forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
205
- ({ className, children, id, ...props }, ref) => {
206
- const { titleId, registerTitle } = useModal();
207
- const activeId = id || titleId;
208
-
209
- useEffect(() => {
210
- return registerTitle(activeId);
211
- }, [registerTitle, activeId]);
212
-
213
- return (
214
- <h2
215
- ref={ref}
216
- id={activeId}
217
- className={cn('text-lg sm:text-xl font-bold tracking-tight text-[var(--ui-text-primary)]', className)}
218
- {...props}
219
- >
220
- {children}
221
- </h2>
222
- );
223
- }
224
- );
225
- ModalTitle.displayName = 'ModalTitle';
226
-
227
- export const ModalDescription = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
228
- ({ className, children, id, ...props }, ref) => {
229
- const { descriptionId, registerDescription } = useModal();
230
- const activeId = id || descriptionId;
231
-
232
- useEffect(() => {
233
- return registerDescription(activeId);
234
- }, [registerDescription, activeId]);
235
-
236
- return (
237
- <p
238
- ref={ref}
239
- id={activeId}
240
- className={cn('text-sm text-[var(--ui-text-muted)]', className)}
241
- {...props}
242
- >
243
- {children}
244
- </p>
245
- );
246
- }
247
- );
248
- ModalDescription.displayName = 'ModalDescription';
249
-
250
- export const ModalBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
251
- ({ className, children, ...props }, ref) => {
252
- return (
253
- <div ref={ref} className={cn('py-2', className)} {...props}>
254
- {children}
255
- </div>
256
- );
257
- }
258
- );
259
- ModalBody.displayName = 'ModalBody';
260
-
261
- export const ModalFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
262
- ({ className, children, ...props }, ref) => {
263
- return (
264
- <div ref={ref} className={cn('flex items-center justify-end gap-3 mt-6 pt-4 border-t border-[var(--ui-border-subtle)]', className)} {...props}>
265
- {children}
266
- </div>
267
- );
268
- }
269
- );
270
- ModalFooter.displayName = 'ModalFooter';
271
-
272
- export const ModalCloseButton = forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
273
- ({ className, onClick, ...props }, ref) => {
274
- const { onClose } = useModal();
275
-
276
- return (
277
- <button
278
- ref={ref}
279
- type="button"
280
- onClick={(e) => {
281
- onClick?.(e);
282
- onClose();
283
- }}
284
- aria-label="Close dialog"
285
- className={cn(
286
- 'absolute top-4 right-4 p-1.5 rounded-full text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] hover:bg-[var(--ui-bg-tertiary)] transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
287
- className
288
- )}
289
- {...props}
290
- >
291
- <X className="w-4 h-4" />
292
- </button>
293
- );
294
- }
295
- );
296
- ModalCloseButton.displayName = 'ModalCloseButton';
1
+ import React, { forwardRef, createContext, useContext, useEffect, useId, useState, useCallback } from 'react';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import { X } from 'lucide-react';
4
+ import { Portal } from './Portal';
5
+ import { FocusTrap } from './FocusTrap';
6
+ import { lockBodyScroll, unlockBodyScroll } from '../utils/scroll-lock';
7
+ import { cn } from '../utils/cn';
8
+
9
+ interface ModalContextType {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ closeOnOverlayClick?: boolean;
13
+ titleId: string;
14
+ descriptionId: string;
15
+ registerTitle: (id?: string) => () => void;
16
+ registerDescription: (id?: string) => () => void;
17
+ }
18
+
19
+ const ModalContext = createContext<ModalContextType | null>(null);
20
+
21
+ export function useModal() {
22
+ const context = useContext(ModalContext);
23
+ if (!context) {
24
+ throw new Error('Modal compound components must be used within a <Modal />');
25
+ }
26
+ return context;
27
+ }
28
+
29
+ export interface ModalProps {
30
+ isOpen: boolean;
31
+ onClose: () => void;
32
+ children: React.ReactNode;
33
+ closeOnEsc?: boolean;
34
+ closeOnOverlayClick?: boolean;
35
+ role?: 'dialog' | 'alertdialog';
36
+ 'aria-label'?: string;
37
+ 'aria-labelledby'?: string;
38
+ 'aria-describedby'?: string;
39
+ }
40
+
41
+ export function Modal({
42
+ isOpen,
43
+ onClose,
44
+ children,
45
+ closeOnEsc = true,
46
+ closeOnOverlayClick = true,
47
+ role = 'dialog',
48
+ 'aria-label': ariaLabel,
49
+ 'aria-labelledby': customLabelledBy,
50
+ 'aria-describedby': customDescribedBy,
51
+ }: ModalProps) {
52
+ const generatedId = useId();
53
+ const titleId = customLabelledBy || `modal-title-${generatedId}`;
54
+ const descriptionId = customDescribedBy || `modal-desc-${generatedId}`;
55
+
56
+ const [mountedTitleId, setMountedTitleId] = useState<string | null>(null);
57
+ const [mountedDescId, setMountedDescId] = useState<string | null>(null);
58
+
59
+ const registerTitle = useCallback(
60
+ (id?: string) => {
61
+ const activeId = id || titleId;
62
+ setMountedTitleId(activeId);
63
+ return () => setMountedTitleId(null);
64
+ },
65
+ [titleId]
66
+ );
67
+
68
+ const registerDescription = useCallback(
69
+ (id?: string) => {
70
+ const activeId = id || descriptionId;
71
+ setMountedDescId(activeId);
72
+ return () => setMountedDescId(null);
73
+ },
74
+ [descriptionId]
75
+ );
76
+
77
+ useEffect(() => {
78
+ if (!isOpen) return;
79
+
80
+ lockBodyScroll();
81
+
82
+ const handleKeyDown = (e: KeyboardEvent) => {
83
+ if (closeOnEsc && e.key === 'Escape') {
84
+ onClose();
85
+ }
86
+ };
87
+
88
+ window.addEventListener('keydown', handleKeyDown);
89
+ return () => {
90
+ window.removeEventListener('keydown', handleKeyDown);
91
+ unlockBodyScroll();
92
+ };
93
+ }, [isOpen, closeOnEsc, onClose]);
94
+
95
+ if (!isOpen) return null;
96
+
97
+ const resolvedLabelledBy = customLabelledBy || (ariaLabel ? undefined : (mountedTitleId || undefined));
98
+ const resolvedDescribedBy = customDescribedBy || (mountedDescId || undefined);
99
+
100
+ return (
101
+ <Portal>
102
+ <ModalContext.Provider
103
+ value={{
104
+ isOpen,
105
+ onClose,
106
+ closeOnOverlayClick,
107
+ titleId,
108
+ descriptionId,
109
+ registerTitle,
110
+ registerDescription,
111
+ }}
112
+ >
113
+ <FocusTrap active={isOpen} restoreFocus={true}>
114
+ <div
115
+ className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
116
+ role={role}
117
+ aria-modal="true"
118
+ aria-label={ariaLabel}
119
+ aria-labelledby={resolvedLabelledBy}
120
+ aria-describedby={resolvedDescribedBy}
121
+ >
122
+ {children}
123
+ </div>
124
+ </FocusTrap>
125
+ </ModalContext.Provider>
126
+ </Portal>
127
+ );
128
+ }
129
+
130
+ export interface ModalOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
131
+
132
+ export const ModalOverlay = forwardRef<HTMLDivElement, ModalOverlayProps>(
133
+ ({ className, onClick, ...props }, ref) => {
134
+ const { onClose, closeOnOverlayClick } = useModal();
135
+
136
+ return (
137
+ <div
138
+ ref={ref}
139
+ aria-hidden="true"
140
+ onClick={(e) => {
141
+ onClick?.(e);
142
+ if (closeOnOverlayClick) onClose();
143
+ }}
144
+ className={cn(
145
+ 'fixed inset-0 bg-[var(--ui-bg-overlay)] backdrop-blur-sm transition-opacity animate-in fade-in duration-200',
146
+ className
147
+ )}
148
+ {...props}
149
+ />
150
+ );
151
+ }
152
+ );
153
+ ModalOverlay.displayName = 'ModalOverlay';
154
+
155
+ export const modalContentVariants = cva(
156
+ 'relative w-full max-w-lg rounded-[var(--ui-radius-xl)] bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] p-6 text-[var(--ui-text-primary)] shadow-2xl transition-all animate-in zoom-in-95 duration-200 z-10',
157
+ {
158
+ variants: {
159
+ size: {
160
+ sm: 'max-w-sm',
161
+ md: 'max-w-md',
162
+ lg: 'max-w-lg',
163
+ xl: 'max-w-xl',
164
+ '2xl': 'max-w-2xl',
165
+ full: 'max-w-[calc(100vw-2rem)] h-[calc(100vh-2rem)]',
166
+ },
167
+ },
168
+ defaultVariants: {
169
+ size: 'md',
170
+ },
171
+ }
172
+ );
173
+
174
+ export interface ModalContentProps
175
+ extends React.HTMLAttributes<HTMLDivElement>,
176
+ VariantProps<typeof modalContentVariants> {}
177
+
178
+ export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
179
+ ({ className, size, children, ...props }, ref) => {
180
+ return (
181
+ <div
182
+ ref={ref}
183
+ className={cn(modalContentVariants({ size }), className)}
184
+ {...props}
185
+ >
186
+ {children}
187
+ </div>
188
+ );
189
+ }
190
+ );
191
+ ModalContent.displayName = 'ModalContent';
192
+
193
+ export const ModalHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
194
+ ({ className, children, ...props }, ref) => {
195
+ return (
196
+ <div ref={ref} className={cn('flex flex-col space-y-1.5 mb-4', className)} {...props}>
197
+ {children}
198
+ </div>
199
+ );
200
+ }
201
+ );
202
+ ModalHeader.displayName = 'ModalHeader';
203
+
204
+ export const ModalTitle = forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
205
+ ({ className, children, id, ...props }, ref) => {
206
+ const { titleId, registerTitle } = useModal();
207
+ const activeId = id || titleId;
208
+
209
+ useEffect(() => {
210
+ return registerTitle(activeId);
211
+ }, [registerTitle, activeId]);
212
+
213
+ return (
214
+ <h2
215
+ ref={ref}
216
+ id={activeId}
217
+ className={cn('text-lg sm:text-xl font-bold tracking-tight text-[var(--ui-text-primary)]', className)}
218
+ {...props}
219
+ >
220
+ {children}
221
+ </h2>
222
+ );
223
+ }
224
+ );
225
+ ModalTitle.displayName = 'ModalTitle';
226
+
227
+ export const ModalDescription = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
228
+ ({ className, children, id, ...props }, ref) => {
229
+ const { descriptionId, registerDescription } = useModal();
230
+ const activeId = id || descriptionId;
231
+
232
+ useEffect(() => {
233
+ return registerDescription(activeId);
234
+ }, [registerDescription, activeId]);
235
+
236
+ return (
237
+ <p
238
+ ref={ref}
239
+ id={activeId}
240
+ className={cn('text-sm text-[var(--ui-text-muted)]', className)}
241
+ {...props}
242
+ >
243
+ {children}
244
+ </p>
245
+ );
246
+ }
247
+ );
248
+ ModalDescription.displayName = 'ModalDescription';
249
+
250
+ export const ModalBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
251
+ ({ className, children, ...props }, ref) => {
252
+ return (
253
+ <div ref={ref} className={cn('py-2', className)} {...props}>
254
+ {children}
255
+ </div>
256
+ );
257
+ }
258
+ );
259
+ ModalBody.displayName = 'ModalBody';
260
+
261
+ export const ModalFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
262
+ ({ className, children, ...props }, ref) => {
263
+ return (
264
+ <div ref={ref} className={cn('flex items-center justify-end gap-3 mt-6 pt-4 border-t border-[var(--ui-border-subtle)]', className)} {...props}>
265
+ {children}
266
+ </div>
267
+ );
268
+ }
269
+ );
270
+ ModalFooter.displayName = 'ModalFooter';
271
+
272
+ export const ModalCloseButton = forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
273
+ ({ className, onClick, ...props }, ref) => {
274
+ const { onClose } = useModal();
275
+
276
+ return (
277
+ <button
278
+ ref={ref}
279
+ type="button"
280
+ onClick={(e) => {
281
+ onClick?.(e);
282
+ onClose();
283
+ }}
284
+ aria-label="Close dialog"
285
+ className={cn(
286
+ 'absolute top-4 right-4 p-1.5 rounded-full text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] hover:bg-[var(--ui-bg-tertiary)] transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
287
+ className
288
+ )}
289
+ {...props}
290
+ >
291
+ <X className="w-4 h-4" />
292
+ </button>
293
+ );
294
+ }
295
+ );
296
+ ModalCloseButton.displayName = 'ModalCloseButton';