@thangnqdev/ui 0.1.2 → 0.2.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 (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -206
  3. package/dist/bento/index.cjs.map +1 -1
  4. package/dist/bento/index.js +2 -2
  5. package/dist/bento/styles.css +2 -46
  6. package/dist/buttons/index.cjs +111 -18
  7. package/dist/buttons/index.cjs.map +1 -1
  8. package/dist/buttons/index.d.cts +1 -1
  9. package/dist/buttons/index.d.ts +1 -1
  10. package/dist/buttons/index.js +3 -3
  11. package/dist/buttons/styles.css +2 -6
  12. package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
  13. package/dist/chunk-2YJBW4E2.js.map +1 -0
  14. package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
  15. package/dist/chunk-4CRFNALF.js.map +1 -0
  16. package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
  17. package/dist/chunk-4MS6ZHFQ.js.map +1 -0
  18. package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
  19. package/dist/chunk-77KTYOMQ.js.map +1 -0
  20. package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
  21. package/dist/chunk-ADO3SVUA.js.map +1 -0
  22. package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
  23. package/dist/chunk-D7FB3TCA.js.map +1 -0
  24. package/dist/chunk-DSEL7ELZ.js +314 -0
  25. package/dist/chunk-DSEL7ELZ.js.map +1 -0
  26. package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
  27. package/dist/chunk-EXFR2BFJ.js.map +1 -0
  28. package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
  29. package/dist/chunk-GXWB6BSY.js.map +1 -0
  30. package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
  31. package/dist/chunk-I2FQBMVW.js.map +1 -0
  32. package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
  33. package/dist/chunk-I5O2WCGF.js.map +1 -0
  34. package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
  35. package/dist/chunk-ME4XILG5.js.map +1 -0
  36. package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
  37. package/dist/chunk-PC5SZFZC.js.map +1 -0
  38. package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
  39. package/dist/chunk-S6XJMYME.js.map +1 -0
  40. package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
  41. package/dist/chunk-V52JOSDF.js.map +1 -0
  42. package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
  43. package/dist/chunk-W5WNWTQC.js.map +1 -0
  44. package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
  45. package/dist/chunk-YJ64RI7J.js.map +1 -0
  46. package/dist/chunk-YOCGBORV.js +155 -0
  47. package/dist/chunk-YOCGBORV.js.map +1 -0
  48. package/dist/chunk-ZEDXARXW.js +96 -0
  49. package/dist/chunk-ZEDXARXW.js.map +1 -0
  50. package/dist/components.css +2 -0
  51. package/dist/core/index.cjs +33 -24
  52. package/dist/core/index.cjs.map +1 -1
  53. package/dist/core/index.js +2 -2
  54. package/dist/core/styles.css +2 -134
  55. package/dist/data-display/index.cjs +770 -247
  56. package/dist/data-display/index.cjs.map +1 -1
  57. package/dist/data-display/index.d.cts +1 -1
  58. package/dist/data-display/index.d.ts +1 -1
  59. package/dist/data-display/index.js +5 -4
  60. package/dist/data-display/styles.css +2 -4
  61. package/dist/feedback/index.cjs.map +1 -1
  62. package/dist/feedback/index.js +2 -2
  63. package/dist/feedback/styles.css +2 -4
  64. package/dist/forms/index.cjs +1211 -459
  65. package/dist/forms/index.cjs.map +1 -1
  66. package/dist/forms/index.d.cts +1 -1
  67. package/dist/forms/index.d.ts +1 -1
  68. package/dist/forms/index.js +8 -8
  69. package/dist/forms/styles.css +2 -6
  70. package/dist/glass/index.cjs.map +1 -1
  71. package/dist/glass/index.js +2 -2
  72. package/dist/glass/styles.css +2 -81
  73. package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
  74. package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
  75. package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
  76. package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
  77. package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
  78. package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
  79. package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
  80. package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
  81. package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
  82. package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
  83. package/dist/index.cjs +2481 -917
  84. package/dist/index.cjs.map +1 -1
  85. package/dist/index.d.cts +5 -5
  86. package/dist/index.d.ts +5 -5
  87. package/dist/index.js +19 -19
  88. package/dist/layout/index.cjs.map +1 -1
  89. package/dist/layout/index.js +2 -2
  90. package/dist/layout/styles.css +2 -8
  91. package/dist/navigation/index.cjs +288 -48
  92. package/dist/navigation/index.cjs.map +1 -1
  93. package/dist/navigation/index.d.cts +1 -1
  94. package/dist/navigation/index.d.ts +1 -1
  95. package/dist/navigation/index.js +6 -6
  96. package/dist/navigation/styles.css +2 -4
  97. package/dist/overlays/index.cjs +566 -229
  98. package/dist/overlays/index.cjs.map +1 -1
  99. package/dist/overlays/index.d.cts +1 -1
  100. package/dist/overlays/index.d.ts +1 -1
  101. package/dist/overlays/index.js +7 -7
  102. package/dist/overlays/styles.css +2 -8
  103. package/dist/reset.css +45 -0
  104. package/dist/styles.css +1 -1
  105. package/dist/tokens.css +11 -0
  106. package/dist/typography/index.cjs.map +1 -1
  107. package/dist/typography/index.js +4 -4
  108. package/dist/typography/styles.css +2 -5
  109. package/llms.txt +211 -113
  110. package/package.json +160 -116
  111. package/src/bento/BentoCard.tsx +140 -140
  112. package/src/bento/BentoGrid.tsx +50 -50
  113. package/src/bento/index.ts +2 -2
  114. package/src/bento/tokens.css +46 -46
  115. package/src/buttons/Button.tsx +104 -104
  116. package/src/buttons/ButtonGroup.tsx +43 -43
  117. package/src/buttons/IconButton.tsx +79 -79
  118. package/src/buttons/SplitButton.tsx +186 -86
  119. package/src/buttons/ToggleButton.tsx +157 -157
  120. package/src/buttons/index.ts +5 -5
  121. package/src/buttons/tokens.css +6 -6
  122. package/src/core/ThemeProvider.tsx +135 -123
  123. package/src/core/index.ts +2 -2
  124. package/src/core/tokens.css +134 -134
  125. package/src/data-display/Accordion.tsx +147 -147
  126. package/src/data-display/Avatar.tsx +134 -134
  127. package/src/data-display/Card.tsx +112 -112
  128. package/src/data-display/Chip.tsx +74 -74
  129. package/src/data-display/DataTablePro.tsx +299 -121
  130. package/src/data-display/StatCard.tsx +60 -60
  131. package/src/data-display/Table.tsx +74 -74
  132. package/src/data-display/Timeline.tsx +62 -62
  133. package/src/data-display/TreeView.tsx +243 -104
  134. package/src/data-display/tokens.css +4 -4
  135. package/src/feedback/Alert.tsx +80 -80
  136. package/src/feedback/EmptyState.tsx +45 -45
  137. package/src/feedback/Skeleton.tsx +66 -66
  138. package/src/feedback/Spinner.tsx +65 -65
  139. package/src/feedback/index.ts +5 -5
  140. package/src/feedback/tokens.css +4 -4
  141. package/src/forms/Checkbox.tsx +149 -69
  142. package/src/forms/CheckboxGroup.tsx +76 -29
  143. package/src/forms/Combobox.tsx +172 -28
  144. package/src/forms/DatePicker.tsx +385 -81
  145. package/src/forms/FileUpload.tsx +51 -7
  146. package/src/forms/FormField.tsx +115 -115
  147. package/src/forms/Input.tsx +151 -151
  148. package/src/forms/NumberInput.tsx +34 -20
  149. package/src/forms/PasswordInput.tsx +35 -35
  150. package/src/forms/PinInput.tsx +139 -125
  151. package/src/forms/Radio.tsx +142 -142
  152. package/src/forms/RangeSlider.tsx +237 -111
  153. package/src/forms/SearchInput.tsx +34 -34
  154. package/src/forms/Select.tsx +103 -103
  155. package/src/forms/Slider.tsx +85 -85
  156. package/src/forms/Textarea.tsx +114 -114
  157. package/src/forms/tokens.css +6 -6
  158. package/src/glass/GlassButton.tsx +59 -59
  159. package/src/glass/GlassCard.tsx +71 -71
  160. package/src/glass/index.ts +3 -3
  161. package/src/glass/tokens.css +81 -81
  162. package/src/index.ts +26 -26
  163. package/src/layout/AspectRatio.tsx +39 -39
  164. package/src/layout/Container.tsx +53 -53
  165. package/src/layout/Divider.tsx +61 -61
  166. package/src/layout/Flex.tsx +79 -79
  167. package/src/layout/Grid.tsx +124 -124
  168. package/src/layout/PageLayout.tsx +59 -59
  169. package/src/layout/Section.tsx +47 -47
  170. package/src/layout/Spacer.tsx +30 -30
  171. package/src/layout/Stack.tsx +88 -88
  172. package/src/layout/tokens.css +8 -8
  173. package/src/navigation/Breadcrumb.tsx +73 -73
  174. package/src/navigation/CommandPalette.tsx +142 -142
  175. package/src/navigation/DropdownMenu.tsx +244 -160
  176. package/src/navigation/Pagination.tsx +114 -114
  177. package/src/navigation/Stepper.tsx +93 -93
  178. package/src/navigation/Tabs.tsx +265 -133
  179. package/src/navigation/index.ts +6 -6
  180. package/src/navigation/tokens.css +4 -4
  181. package/src/overlays/AlertDialog.tsx +63 -54
  182. package/src/overlays/BottomSheet.tsx +30 -10
  183. package/src/overlays/ContextMenu.tsx +51 -9
  184. package/src/overlays/Drawer.tsx +160 -145
  185. package/src/overlays/FocusTrap.tsx +122 -49
  186. package/src/overlays/Modal.tsx +296 -219
  187. package/src/overlays/Popover.tsx +55 -25
  188. package/src/overlays/Portal.tsx +23 -23
  189. package/src/overlays/Toast.tsx +175 -137
  190. package/src/overlays/Tooltip.tsx +125 -100
  191. package/src/overlays/tokens.css +8 -8
  192. package/src/registry/components.ts +1368 -0
  193. package/src/styles/components.css +39 -0
  194. package/src/styles/index.css +2 -93
  195. package/src/styles/reset.css +45 -0
  196. package/src/styles/tokens.css +11 -2
  197. package/src/typography/Blockquote.tsx +32 -32
  198. package/src/typography/Code.tsx +89 -89
  199. package/src/typography/Heading.tsx +56 -56
  200. package/src/typography/Kbd.tsx +25 -25
  201. package/src/typography/Label.tsx +47 -47
  202. package/src/typography/Link.tsx +56 -56
  203. package/src/typography/Text.tsx +62 -62
  204. package/src/typography/index.ts +7 -7
  205. package/src/typography/tokens.css +5 -5
  206. package/src/utils/cn.ts +6 -6
  207. package/src/utils/scroll-lock.ts +19 -0
  208. package/src/utils/useControllableState.ts +54 -0
  209. package/ui-contract.json +192 -35
  210. package/dist/chunk-2OXZK6IP.js.map +0 -1
  211. package/dist/chunk-4S6L5V7X.js.map +0 -1
  212. package/dist/chunk-5FZ6ZFII.js.map +0 -1
  213. package/dist/chunk-66VWUJEI.js.map +0 -1
  214. package/dist/chunk-A7P3O5Z5.js.map +0 -1
  215. package/dist/chunk-ATYGDTMK.js.map +0 -1
  216. package/dist/chunk-C4SUXMI3.js.map +0 -1
  217. package/dist/chunk-EU2LPUYP.js.map +0 -1
  218. package/dist/chunk-F5Q7BTMH.js.map +0 -1
  219. package/dist/chunk-GXI2H5XA.js.map +0 -1
  220. package/dist/chunk-H3LCNPMC.js +0 -101
  221. package/dist/chunk-H3LCNPMC.js.map +0 -1
  222. package/dist/chunk-IABPUCAQ.js.map +0 -1
  223. package/dist/chunk-KJRBRTQE.js.map +0 -1
  224. package/dist/chunk-LBUBXUYQ.js +0 -40
  225. package/dist/chunk-LBUBXUYQ.js.map +0 -1
  226. package/dist/chunk-LWFRUJRU.js.map +0 -1
  227. package/dist/chunk-NFVONJUK.js.map +0 -1
  228. package/dist/chunk-PF4ZHGVW.js.map +0 -1
  229. package/dist/chunk-WAFHC4DQ.js.map +0 -1
  230. package/dist/chunk-X76KBH4Y.js +0 -71
  231. package/dist/chunk-X76KBH4Y.js.map +0 -1
@@ -1,219 +1,296 @@
1
- import React, { forwardRef, createContext, useContext, useEffect } 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 { cn } from '../utils/cn';
7
-
8
- interface ModalContextType {
9
- isOpen: boolean;
10
- onClose: () => void;
11
- closeOnOverlayClick?: boolean;
12
- }
13
-
14
- const ModalContext = createContext<ModalContextType | null>(null);
15
-
16
- export function useModal() {
17
- const context = useContext(ModalContext);
18
- if (!context) {
19
- throw new Error('Modal compound components must be used within a <Modal />');
20
- }
21
- return context;
22
- }
23
-
24
- export interface ModalProps {
25
- isOpen: boolean;
26
- onClose: () => void;
27
- children: React.ReactNode;
28
- closeOnEsc?: boolean;
29
- closeOnOverlayClick?: boolean;
30
- }
31
-
32
- export function Modal({
33
- isOpen,
34
- onClose,
35
- children,
36
- closeOnEsc = true,
37
- closeOnOverlayClick = true,
38
- }: ModalProps) {
39
- useEffect(() => {
40
- if (!isOpen) return;
41
-
42
- const handleKeyDown = (e: KeyboardEvent) => {
43
- if (closeOnEsc && e.key === 'Escape') {
44
- onClose();
45
- }
46
- };
47
-
48
- const originalOverflow = document.body.style.overflow;
49
- document.body.style.overflow = 'hidden';
50
-
51
- window.addEventListener('keydown', handleKeyDown);
52
- return () => {
53
- window.removeEventListener('keydown', handleKeyDown);
54
- document.body.style.overflow = originalOverflow;
55
- };
56
- }, [isOpen, closeOnEsc, onClose]);
57
-
58
- if (!isOpen) return null;
59
-
60
- return (
61
- <Portal>
62
- <ModalContext.Provider value={{ isOpen, onClose, closeOnOverlayClick }}>
63
- <FocusTrap active={isOpen}>
64
- <div
65
- className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
66
- role="dialog"
67
- aria-modal="true"
68
- >
69
- {children}
70
- </div>
71
- </FocusTrap>
72
- </ModalContext.Provider>
73
- </Portal>
74
- );
75
- }
76
-
77
- export interface ModalOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
78
-
79
- export const ModalOverlay = forwardRef<HTMLDivElement, ModalOverlayProps>(
80
- ({ className, onClick, ...props }, ref) => {
81
- const { onClose, closeOnOverlayClick } = useModal();
82
-
83
- return (
84
- <div
85
- ref={ref}
86
- aria-hidden="true"
87
- onClick={(e) => {
88
- onClick?.(e);
89
- if (closeOnOverlayClick) onClose();
90
- }}
91
- className={cn(
92
- 'fixed inset-0 bg-[var(--ui-bg-overlay)] backdrop-blur-sm transition-opacity animate-in fade-in duration-200',
93
- className
94
- )}
95
- {...props}
96
- />
97
- );
98
- }
99
- );
100
- ModalOverlay.displayName = 'ModalOverlay';
101
-
102
- export const modalContentVariants = cva(
103
- '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',
104
- {
105
- variants: {
106
- size: {
107
- sm: 'max-w-sm',
108
- md: 'max-w-md',
109
- lg: 'max-w-lg',
110
- xl: 'max-w-xl',
111
- '2xl': 'max-w-2xl',
112
- full: 'max-w-[calc(100vw-2rem)] h-[calc(100vh-2rem)]',
113
- },
114
- },
115
- defaultVariants: {
116
- size: 'md',
117
- },
118
- }
119
- );
120
-
121
- export interface ModalContentProps
122
- extends React.HTMLAttributes<HTMLDivElement>,
123
- VariantProps<typeof modalContentVariants> {}
124
-
125
- export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
126
- ({ className, size, children, ...props }, ref) => {
127
- return (
128
- <div
129
- ref={ref}
130
- className={cn(modalContentVariants({ size }), className)}
131
- {...props}
132
- >
133
- {children}
134
- </div>
135
- );
136
- }
137
- );
138
- ModalContent.displayName = 'ModalContent';
139
-
140
- export const ModalHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
141
- ({ className, children, ...props }, ref) => {
142
- return (
143
- <div ref={ref} className={cn('flex flex-col space-y-1.5 mb-4', className)} {...props}>
144
- {children}
145
- </div>
146
- );
147
- }
148
- );
149
- ModalHeader.displayName = 'ModalHeader';
150
-
151
- export const ModalTitle = forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
152
- ({ className, children, ...props }, ref) => {
153
- return (
154
- <h2 ref={ref} className={cn('text-lg sm:text-xl font-bold tracking-tight text-[var(--ui-text-primary)]', className)} {...props}>
155
- {children}
156
- </h2>
157
- );
158
- }
159
- );
160
- ModalTitle.displayName = 'ModalTitle';
161
-
162
- export const ModalDescription = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
163
- ({ className, children, ...props }, ref) => {
164
- return (
165
- <p ref={ref} className={cn('text-sm text-[var(--ui-text-muted)]', className)} {...props}>
166
- {children}
167
- </p>
168
- );
169
- }
170
- );
171
- ModalDescription.displayName = 'ModalDescription';
172
-
173
- export const ModalBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
174
- ({ className, children, ...props }, ref) => {
175
- return (
176
- <div ref={ref} className={cn('py-2', className)} {...props}>
177
- {children}
178
- </div>
179
- );
180
- }
181
- );
182
- ModalBody.displayName = 'ModalBody';
183
-
184
- export const ModalFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
185
- ({ className, children, ...props }, ref) => {
186
- return (
187
- <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}>
188
- {children}
189
- </div>
190
- );
191
- }
192
- );
193
- ModalFooter.displayName = 'ModalFooter';
194
-
195
- export const ModalCloseButton = forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
196
- ({ className, onClick, ...props }, ref) => {
197
- const { onClose } = useModal();
198
-
199
- return (
200
- <button
201
- ref={ref}
202
- type="button"
203
- onClick={(e) => {
204
- onClick?.(e);
205
- onClose();
206
- }}
207
- aria-label="Close dialog"
208
- className={cn(
209
- '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',
210
- className
211
- )}
212
- {...props}
213
- >
214
- <X className="w-4 h-4" />
215
- </button>
216
- );
217
- }
218
- );
219
- 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';
@@ -1,11 +1,15 @@
1
- import React, { useState, useRef, useEffect } from 'react';
1
+ import React, { useRef, useEffect, useState, useCallback } from 'react';
2
2
  import { Portal } from './Portal';
3
3
  import { cn } from '../utils/cn';
4
+ import { useControllableState } from '../utils/useControllableState';
4
5
 
5
6
  export interface PopoverProps {
6
7
  trigger: React.ReactNode;
7
8
  children: React.ReactNode;
8
- isOpen?: boolean;
9
+ open?: boolean;
10
+ isOpen?: boolean; // Legacy alias
11
+ defaultOpen?: boolean;
12
+ onOpenChange?: (open: boolean) => void;
9
13
  onClose?: () => void;
10
14
  placement?: 'top' | 'bottom' | 'left' | 'right';
11
15
  className?: string;
@@ -14,19 +18,39 @@ export interface PopoverProps {
14
18
  export const Popover = ({
15
19
  trigger,
16
20
  children,
17
- isOpen: controlledIsOpen,
21
+ open: controlledOpen,
22
+ isOpen: legacyIsOpen,
23
+ defaultOpen = false,
24
+ onOpenChange,
18
25
  onClose,
19
26
  placement = 'bottom',
20
27
  className,
21
28
  }: PopoverProps) => {
22
- const [internalIsOpen, setInternalIsOpen] = useState(false);
23
- const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
29
+ // Resolve controlled open state with legacy support
30
+ const activeControlled = controlledOpen !== undefined ? controlledOpen : legacyIsOpen;
31
+
32
+ const handleStateChange = useCallback(
33
+ (nextOpen: boolean) => {
34
+ onOpenChange?.(nextOpen);
35
+ if (!nextOpen) {
36
+ onClose?.();
37
+ }
38
+ },
39
+ [onOpenChange, onClose]
40
+ );
41
+
42
+ const [isOpen, setIsOpen] = useControllableState<boolean>({
43
+ value: activeControlled,
44
+ defaultValue: defaultOpen,
45
+ onChange: handleStateChange,
46
+ name: 'Popover',
47
+ });
24
48
 
25
49
  const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
26
50
  const triggerRef = useRef<HTMLDivElement | null>(null);
27
51
  const popoverRef = useRef<HTMLDivElement | null>(null);
28
52
 
29
- const calculatePosition = () => {
53
+ const calculatePosition = useCallback(() => {
30
54
  if (triggerRef.current) {
31
55
  const rect = triggerRef.current.getBoundingClientRect();
32
56
  let top = 0;
@@ -48,26 +72,24 @@ export const Popover = ({
48
72
 
49
73
  setCoords({ top, left });
50
74
  }
51
- };
75
+ }, [placement]);
52
76
 
53
77
  const handleToggle = () => {
54
78
  if (!isOpen) {
55
79
  calculatePosition();
56
80
  }
57
- if (controlledIsOpen !== undefined) {
58
- if (isOpen) onClose?.();
59
- } else {
60
- setInternalIsOpen(!internalIsOpen);
61
- }
81
+ setIsOpen(!isOpen);
62
82
  };
63
83
 
64
84
  useEffect(() => {
65
85
  if (isOpen) {
66
86
  calculatePosition();
67
87
  }
68
- }, [isOpen]);
88
+ }, [isOpen, calculatePosition]);
69
89
 
70
90
  useEffect(() => {
91
+ if (!isOpen) return;
92
+
71
93
  const handleOutside = (e: MouseEvent) => {
72
94
  if (
73
95
  popoverRef.current &&
@@ -75,29 +97,37 @@ export const Popover = ({
75
97
  triggerRef.current &&
76
98
  !triggerRef.current.contains(e.target as Node)
77
99
  ) {
78
- if (controlledIsOpen !== undefined) {
79
- onClose?.();
80
- } else {
81
- setInternalIsOpen(false);
82
- }
100
+ setIsOpen(false);
83
101
  }
84
102
  };
85
103
 
86
- if (isOpen) {
87
- document.addEventListener('mousedown', handleOutside);
88
- window.addEventListener('resize', calculatePosition);
89
- window.addEventListener('scroll', calculatePosition);
90
- }
104
+ const handleKeyDown = (e: KeyboardEvent) => {
105
+ if (e.key === 'Escape') {
106
+ setIsOpen(false);
107
+ }
108
+ };
109
+
110
+ document.addEventListener('mousedown', handleOutside);
111
+ document.addEventListener('keydown', handleKeyDown);
112
+ window.addEventListener('resize', calculatePosition);
113
+ window.addEventListener('scroll', calculatePosition);
114
+
91
115
  return () => {
92
116
  document.removeEventListener('mousedown', handleOutside);
117
+ document.removeEventListener('keydown', handleKeyDown);
93
118
  window.removeEventListener('resize', calculatePosition);
94
119
  window.removeEventListener('scroll', calculatePosition);
95
120
  };
96
- }, [isOpen, controlledIsOpen, onClose]);
121
+ }, [isOpen, setIsOpen, calculatePosition]);
97
122
 
98
123
  return (
99
124
  <>
100
- <div ref={triggerRef} onClick={handleToggle} className="w-full inline-block cursor-pointer">
125
+ <div
126
+ ref={triggerRef}
127
+ onClick={handleToggle}
128
+ className="w-full inline-block cursor-pointer"
129
+ aria-expanded={isOpen}
130
+ >
101
131
  {trigger}
102
132
  </div>
103
133