@thangnqdev/ui 0.1.2 → 0.2.1

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 (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -206
  3. package/bin/cli.js +125 -0
  4. package/dist/bento/index.cjs.map +1 -1
  5. package/dist/bento/index.js +2 -2
  6. package/dist/bento/styles.css +2 -46
  7. package/dist/buttons/index.cjs +111 -18
  8. package/dist/buttons/index.cjs.map +1 -1
  9. package/dist/buttons/index.d.cts +1 -1
  10. package/dist/buttons/index.d.ts +1 -1
  11. package/dist/buttons/index.js +3 -3
  12. package/dist/buttons/styles.css +2 -6
  13. package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
  14. package/dist/chunk-2YJBW4E2.js.map +1 -0
  15. package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
  16. package/dist/chunk-4CRFNALF.js.map +1 -0
  17. package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
  18. package/dist/chunk-4MS6ZHFQ.js.map +1 -0
  19. package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
  20. package/dist/chunk-77KTYOMQ.js.map +1 -0
  21. package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
  22. package/dist/chunk-ADO3SVUA.js.map +1 -0
  23. package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
  24. package/dist/chunk-D7FB3TCA.js.map +1 -0
  25. package/dist/chunk-DSEL7ELZ.js +314 -0
  26. package/dist/chunk-DSEL7ELZ.js.map +1 -0
  27. package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
  28. package/dist/chunk-EXFR2BFJ.js.map +1 -0
  29. package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
  30. package/dist/chunk-GXWB6BSY.js.map +1 -0
  31. package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
  32. package/dist/chunk-I2FQBMVW.js.map +1 -0
  33. package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
  34. package/dist/chunk-I5O2WCGF.js.map +1 -0
  35. package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
  36. package/dist/chunk-ME4XILG5.js.map +1 -0
  37. package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
  38. package/dist/chunk-PC5SZFZC.js.map +1 -0
  39. package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
  40. package/dist/chunk-S6XJMYME.js.map +1 -0
  41. package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
  42. package/dist/chunk-V52JOSDF.js.map +1 -0
  43. package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
  44. package/dist/chunk-W5WNWTQC.js.map +1 -0
  45. package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
  46. package/dist/chunk-YJ64RI7J.js.map +1 -0
  47. package/dist/chunk-YOCGBORV.js +155 -0
  48. package/dist/chunk-YOCGBORV.js.map +1 -0
  49. package/dist/chunk-ZEDXARXW.js +96 -0
  50. package/dist/chunk-ZEDXARXW.js.map +1 -0
  51. package/dist/components.css +2 -0
  52. package/dist/core/index.cjs +33 -24
  53. package/dist/core/index.cjs.map +1 -1
  54. package/dist/core/index.js +2 -2
  55. package/dist/core/styles.css +2 -134
  56. package/dist/data-display/index.cjs +770 -247
  57. package/dist/data-display/index.cjs.map +1 -1
  58. package/dist/data-display/index.d.cts +1 -1
  59. package/dist/data-display/index.d.ts +1 -1
  60. package/dist/data-display/index.js +5 -4
  61. package/dist/data-display/styles.css +2 -4
  62. package/dist/feedback/index.cjs.map +1 -1
  63. package/dist/feedback/index.js +2 -2
  64. package/dist/feedback/styles.css +2 -4
  65. package/dist/forms/index.cjs +1211 -459
  66. package/dist/forms/index.cjs.map +1 -1
  67. package/dist/forms/index.d.cts +1 -1
  68. package/dist/forms/index.d.ts +1 -1
  69. package/dist/forms/index.js +8 -8
  70. package/dist/forms/styles.css +2 -6
  71. package/dist/glass/index.cjs.map +1 -1
  72. package/dist/glass/index.js +2 -2
  73. package/dist/glass/styles.css +2 -81
  74. package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
  75. package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
  76. package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
  77. package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
  78. package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
  79. package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
  80. package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
  81. package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
  82. package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
  83. package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
  84. package/dist/index.cjs +2481 -917
  85. package/dist/index.cjs.map +1 -1
  86. package/dist/index.d.cts +5 -5
  87. package/dist/index.d.ts +5 -5
  88. package/dist/index.js +19 -19
  89. package/dist/layout/index.cjs.map +1 -1
  90. package/dist/layout/index.js +2 -2
  91. package/dist/layout/styles.css +2 -8
  92. package/dist/navigation/index.cjs +288 -48
  93. package/dist/navigation/index.cjs.map +1 -1
  94. package/dist/navigation/index.d.cts +1 -1
  95. package/dist/navigation/index.d.ts +1 -1
  96. package/dist/navigation/index.js +6 -6
  97. package/dist/navigation/styles.css +2 -4
  98. package/dist/overlays/index.cjs +566 -229
  99. package/dist/overlays/index.cjs.map +1 -1
  100. package/dist/overlays/index.d.cts +1 -1
  101. package/dist/overlays/index.d.ts +1 -1
  102. package/dist/overlays/index.js +7 -7
  103. package/dist/overlays/styles.css +2 -8
  104. package/dist/reset.css +45 -0
  105. package/dist/styles.css +1 -1
  106. package/dist/tokens.css +11 -0
  107. package/dist/typography/index.cjs.map +1 -1
  108. package/dist/typography/index.js +4 -4
  109. package/dist/typography/styles.css +2 -5
  110. package/llms.txt +211 -113
  111. package/package.json +167 -116
  112. package/scripts/postinstall.js +125 -0
  113. package/src/bento/BentoCard.tsx +140 -140
  114. package/src/bento/BentoGrid.tsx +50 -50
  115. package/src/bento/index.ts +2 -2
  116. package/src/bento/tokens.css +46 -46
  117. package/src/buttons/Button.tsx +104 -104
  118. package/src/buttons/ButtonGroup.tsx +43 -43
  119. package/src/buttons/IconButton.tsx +79 -79
  120. package/src/buttons/SplitButton.tsx +186 -86
  121. package/src/buttons/ToggleButton.tsx +157 -157
  122. package/src/buttons/index.ts +5 -5
  123. package/src/buttons/tokens.css +6 -6
  124. package/src/core/ThemeProvider.tsx +135 -123
  125. package/src/core/index.ts +2 -2
  126. package/src/core/tokens.css +134 -134
  127. package/src/data-display/Accordion.tsx +147 -147
  128. package/src/data-display/Avatar.tsx +134 -134
  129. package/src/data-display/Card.tsx +112 -112
  130. package/src/data-display/Chip.tsx +74 -74
  131. package/src/data-display/DataTablePro.tsx +299 -121
  132. package/src/data-display/StatCard.tsx +60 -60
  133. package/src/data-display/Table.tsx +74 -74
  134. package/src/data-display/Timeline.tsx +62 -62
  135. package/src/data-display/TreeView.tsx +243 -104
  136. package/src/data-display/tokens.css +4 -4
  137. package/src/feedback/Alert.tsx +80 -80
  138. package/src/feedback/EmptyState.tsx +45 -45
  139. package/src/feedback/Skeleton.tsx +66 -66
  140. package/src/feedback/Spinner.tsx +65 -65
  141. package/src/feedback/index.ts +5 -5
  142. package/src/feedback/tokens.css +4 -4
  143. package/src/forms/Checkbox.tsx +149 -69
  144. package/src/forms/CheckboxGroup.tsx +76 -29
  145. package/src/forms/Combobox.tsx +172 -28
  146. package/src/forms/DatePicker.tsx +385 -81
  147. package/src/forms/FileUpload.tsx +51 -7
  148. package/src/forms/FormField.tsx +115 -115
  149. package/src/forms/Input.tsx +151 -151
  150. package/src/forms/NumberInput.tsx +34 -20
  151. package/src/forms/PasswordInput.tsx +35 -35
  152. package/src/forms/PinInput.tsx +139 -125
  153. package/src/forms/Radio.tsx +142 -142
  154. package/src/forms/RangeSlider.tsx +237 -111
  155. package/src/forms/SearchInput.tsx +34 -34
  156. package/src/forms/Select.tsx +103 -103
  157. package/src/forms/Slider.tsx +85 -85
  158. package/src/forms/Textarea.tsx +114 -114
  159. package/src/forms/tokens.css +6 -6
  160. package/src/glass/GlassButton.tsx +59 -59
  161. package/src/glass/GlassCard.tsx +71 -71
  162. package/src/glass/index.ts +3 -3
  163. package/src/glass/tokens.css +81 -81
  164. package/src/index.ts +26 -26
  165. package/src/layout/AspectRatio.tsx +39 -39
  166. package/src/layout/Container.tsx +53 -53
  167. package/src/layout/Divider.tsx +61 -61
  168. package/src/layout/Flex.tsx +79 -79
  169. package/src/layout/Grid.tsx +124 -124
  170. package/src/layout/PageLayout.tsx +59 -59
  171. package/src/layout/Section.tsx +47 -47
  172. package/src/layout/Spacer.tsx +30 -30
  173. package/src/layout/Stack.tsx +88 -88
  174. package/src/layout/tokens.css +8 -8
  175. package/src/navigation/Breadcrumb.tsx +73 -73
  176. package/src/navigation/CommandPalette.tsx +142 -142
  177. package/src/navigation/DropdownMenu.tsx +244 -160
  178. package/src/navigation/Pagination.tsx +114 -114
  179. package/src/navigation/Stepper.tsx +93 -93
  180. package/src/navigation/Tabs.tsx +265 -133
  181. package/src/navigation/index.ts +6 -6
  182. package/src/navigation/tokens.css +4 -4
  183. package/src/overlays/AlertDialog.tsx +63 -54
  184. package/src/overlays/BottomSheet.tsx +30 -10
  185. package/src/overlays/ContextMenu.tsx +51 -9
  186. package/src/overlays/Drawer.tsx +160 -145
  187. package/src/overlays/FocusTrap.tsx +122 -49
  188. package/src/overlays/Modal.tsx +296 -219
  189. package/src/overlays/Popover.tsx +55 -25
  190. package/src/overlays/Portal.tsx +23 -23
  191. package/src/overlays/Toast.tsx +175 -137
  192. package/src/overlays/Tooltip.tsx +125 -100
  193. package/src/overlays/tokens.css +8 -8
  194. package/src/registry/components.ts +1368 -0
  195. package/src/styles/components.css +39 -0
  196. package/src/styles/index.css +2 -93
  197. package/src/styles/reset.css +45 -0
  198. package/src/styles/tokens.css +11 -2
  199. package/src/typography/Blockquote.tsx +32 -32
  200. package/src/typography/Code.tsx +89 -89
  201. package/src/typography/Heading.tsx +56 -56
  202. package/src/typography/Kbd.tsx +25 -25
  203. package/src/typography/Label.tsx +47 -47
  204. package/src/typography/Link.tsx +56 -56
  205. package/src/typography/Text.tsx +62 -62
  206. package/src/typography/index.ts +7 -7
  207. package/src/typography/tokens.css +5 -5
  208. package/src/utils/cn.ts +6 -6
  209. package/src/utils/scroll-lock.ts +19 -0
  210. package/src/utils/useControllableState.ts +54 -0
  211. package/ui-contract.json +192 -35
  212. package/dist/chunk-2OXZK6IP.js.map +0 -1
  213. package/dist/chunk-4S6L5V7X.js.map +0 -1
  214. package/dist/chunk-5FZ6ZFII.js.map +0 -1
  215. package/dist/chunk-66VWUJEI.js.map +0 -1
  216. package/dist/chunk-A7P3O5Z5.js.map +0 -1
  217. package/dist/chunk-ATYGDTMK.js.map +0 -1
  218. package/dist/chunk-C4SUXMI3.js.map +0 -1
  219. package/dist/chunk-EU2LPUYP.js.map +0 -1
  220. package/dist/chunk-F5Q7BTMH.js.map +0 -1
  221. package/dist/chunk-GXI2H5XA.js.map +0 -1
  222. package/dist/chunk-H3LCNPMC.js +0 -101
  223. package/dist/chunk-H3LCNPMC.js.map +0 -1
  224. package/dist/chunk-IABPUCAQ.js.map +0 -1
  225. package/dist/chunk-KJRBRTQE.js.map +0 -1
  226. package/dist/chunk-LBUBXUYQ.js +0 -40
  227. package/dist/chunk-LBUBXUYQ.js.map +0 -1
  228. package/dist/chunk-LWFRUJRU.js.map +0 -1
  229. package/dist/chunk-NFVONJUK.js.map +0 -1
  230. package/dist/chunk-PF4ZHGVW.js.map +0 -1
  231. package/dist/chunk-WAFHC4DQ.js.map +0 -1
  232. package/dist/chunk-X76KBH4Y.js +0 -71
  233. 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