@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,54 +1,63 @@
1
- import React, { forwardRef } from 'react';
2
- import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalFooter, ModalCloseButton } from './Modal';
3
- import { Button } from '../buttons/Button';
4
- import { cn } from '../utils/cn';
5
-
6
- export interface AlertDialogProps {
7
- isOpen: boolean;
8
- onClose: () => void;
9
- onConfirm: () => void;
10
- title: string;
11
- description: string;
12
- confirmText?: string;
13
- cancelText?: string;
14
- variant?: 'danger' | 'primary';
15
- isLoading?: boolean;
16
- }
17
-
18
- export function AlertDialog({
19
- isOpen,
20
- onClose,
21
- onConfirm,
22
- title,
23
- description,
24
- confirmText = 'Confirm',
25
- cancelText = 'Cancel',
26
- variant = 'danger',
27
- isLoading = false,
28
- }: AlertDialogProps) {
29
- return (
30
- <Modal isOpen={isOpen} onClose={onClose}>
31
- <ModalOverlay />
32
- <ModalContent size="sm">
33
- <ModalCloseButton />
34
- <ModalHeader>
35
- <ModalTitle>{title}</ModalTitle>
36
- <ModalDescription>{description}</ModalDescription>
37
- </ModalHeader>
38
- <ModalFooter>
39
- <Button variant="outline" size="sm" onClick={onClose} disabled={isLoading}>
40
- {cancelText}
41
- </Button>
42
- <Button
43
- variant={variant === 'danger' ? 'danger' : 'primary'}
44
- size="sm"
45
- loading={isLoading}
46
- onClick={onConfirm}
47
- >
48
- {confirmText}
49
- </Button>
50
- </ModalFooter>
51
- </ModalContent>
52
- </Modal>
53
- );
54
- }
1
+ import React, { forwardRef } from 'react';
2
+ import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalFooter, ModalCloseButton } from './Modal';
3
+ import { Button } from '../buttons/Button';
4
+ import { cn } from '../utils/cn';
5
+
6
+ export interface AlertDialogProps {
7
+ isOpen: boolean;
8
+ onClose: () => void;
9
+ onConfirm: () => void;
10
+ title: string;
11
+ description: string;
12
+ confirmText?: string;
13
+ cancelText?: string;
14
+ variant?: 'danger' | 'primary';
15
+ isLoading?: boolean;
16
+ closeOnOverlayClick?: boolean;
17
+ }
18
+
19
+ export function AlertDialog({
20
+ isOpen,
21
+ onClose,
22
+ onConfirm,
23
+ title,
24
+ description,
25
+ confirmText = 'Confirm',
26
+ cancelText = 'Cancel',
27
+ variant = 'danger',
28
+ isLoading = false,
29
+ closeOnOverlayClick = false,
30
+ }: AlertDialogProps) {
31
+ return (
32
+ <Modal
33
+ isOpen={isOpen}
34
+ onClose={onClose}
35
+ role="alertdialog"
36
+ closeOnOverlayClick={closeOnOverlayClick}
37
+ >
38
+ <ModalOverlay />
39
+ <ModalContent size="sm">
40
+ <ModalCloseButton />
41
+ <ModalHeader>
42
+ <ModalTitle>{title}</ModalTitle>
43
+ <ModalDescription>{description}</ModalDescription>
44
+ </ModalHeader>
45
+ <ModalFooter>
46
+ <Button variant="outline" size="sm" onClick={onClose} disabled={isLoading} autoFocus>
47
+ {cancelText}
48
+ </Button>
49
+ <Button
50
+ variant={variant === 'danger' ? 'danger' : 'primary'}
51
+ size="sm"
52
+ loading={isLoading}
53
+ onClick={onConfirm}
54
+ >
55
+ {confirmText}
56
+ </Button>
57
+ </ModalFooter>
58
+ </ModalContent>
59
+ </Modal>
60
+ );
61
+ }
62
+
63
+ AlertDialog.displayName = 'AlertDialog';
@@ -1,8 +1,9 @@
1
- import React, { forwardRef, useEffect, useRef } from 'react';
1
+ import React, { forwardRef, useEffect, useRef, useId } from 'react';
2
2
  import { X } from 'lucide-react';
3
3
  import { cn } from '../utils/cn';
4
4
  import { Portal } from './Portal';
5
5
  import { FocusTrap } from './FocusTrap';
6
+ import { lockBodyScroll, unlockBodyScroll } from '../utils/scroll-lock';
6
7
 
7
8
  export interface BottomSheetProps {
8
9
  isOpen: boolean;
@@ -13,6 +14,7 @@ export interface BottomSheetProps {
13
14
  className?: string;
14
15
  showHandle?: boolean;
15
16
  maxHeight?: string;
17
+ 'aria-label'?: string;
16
18
  }
17
19
 
18
20
  export const BottomSheet = forwardRef<HTMLDivElement, BottomSheetProps>(
@@ -26,28 +28,45 @@ export const BottomSheet = forwardRef<HTMLDivElement, BottomSheetProps>(
26
28
  className,
27
29
  showHandle = true,
28
30
  maxHeight = 'max-h-[85vh]',
31
+ 'aria-label': ariaLabel,
29
32
  },
30
33
  ref
31
34
  ) => {
32
- const sheetRef = useRef<HTMLDivElement | null>(null);
35
+ const generatedId = useId();
36
+ const titleId = `bottomsheet-title-${generatedId}`;
37
+ const descId = `bottomsheet-desc-${generatedId}`;
33
38
 
34
- // Escape key listener
35
39
  useEffect(() => {
40
+ if (!isOpen) return;
41
+
42
+ lockBodyScroll();
43
+
36
44
  const handleKeyDown = (e: KeyboardEvent) => {
37
- if (e.key === 'Escape' && isOpen) {
45
+ if (e.key === 'Escape') {
38
46
  onClose();
39
47
  }
40
48
  };
49
+
41
50
  window.addEventListener('keydown', handleKeyDown);
42
- return () => window.removeEventListener('keydown', handleKeyDown);
51
+ return () => {
52
+ window.removeEventListener('keydown', handleKeyDown);
53
+ unlockBodyScroll();
54
+ };
43
55
  }, [isOpen, onClose]);
44
56
 
45
57
  if (!isOpen) return null;
46
58
 
47
59
  return (
48
60
  <Portal>
49
- <FocusTrap active={isOpen}>
50
- <div className="fixed inset-0 z-50 flex flex-col justify-end">
61
+ <FocusTrap active={isOpen} restoreFocus={true}>
62
+ <div
63
+ className="fixed inset-0 z-50 flex flex-col justify-end"
64
+ role="dialog"
65
+ aria-modal="true"
66
+ aria-label={ariaLabel}
67
+ aria-labelledby={title ? titleId : undefined}
68
+ aria-describedby={description ? descId : undefined}
69
+ >
51
70
  {/* Backdrop */}
52
71
  <div
53
72
  onClick={onClose}
@@ -78,13 +97,14 @@ export const BottomSheet = forwardRef<HTMLDivElement, BottomSheetProps>(
78
97
  {(title || description) && (
79
98
  <div className="px-6 pb-3 flex items-center justify-between border-b border-[var(--ui-border-subtle)]">
80
99
  <div>
81
- {title && <h3 className="text-base font-bold tracking-tight text-[var(--ui-text-primary)]">{title}</h3>}
82
- {description && <p className="text-xs text-[var(--ui-text-muted)]">{description}</p>}
100
+ {title && <h3 id={titleId} className="text-base font-bold tracking-tight text-[var(--ui-text-primary)]">{title}</h3>}
101
+ {description && <p id={descId} className="text-xs text-[var(--ui-text-muted)]">{description}</p>}
83
102
  </div>
84
103
  <button
85
104
  type="button"
86
105
  onClick={onClose}
87
- className="p-1.5 rounded-full hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors"
106
+ aria-label="Close sheet"
107
+ className="p-1.5 rounded-full hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]"
88
108
  >
89
109
  <X className="w-4 h-4" />
90
110
  </button>
@@ -24,15 +24,22 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = ({
24
24
  return (
25
25
  <div
26
26
  role="menuitem"
27
+ tabIndex={disabled ? -1 : 0}
27
28
  aria-disabled={disabled}
28
29
  onClick={() => {
29
30
  if (!disabled) onSelect?.();
30
31
  }}
32
+ onKeyDown={(e) => {
33
+ if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
34
+ e.preventDefault();
35
+ onSelect?.();
36
+ }
37
+ }}
31
38
  className={cn(
32
- 'flex items-center justify-between px-3 py-1.5 rounded-xl text-xs font-medium cursor-pointer transition-colors select-none',
39
+ 'flex items-center justify-between px-3 py-1.5 rounded-xl text-xs font-medium cursor-pointer transition-colors select-none focus:outline-none focus:bg-[var(--ui-bg-tertiary)]',
33
40
  disabled && 'opacity-40 cursor-not-allowed pointer-events-none',
34
41
  destructive
35
- ? 'text-rose-500 hover:bg-rose-500/10'
42
+ ? 'text-rose-500 hover:bg-rose-500/10 focus:bg-rose-500/10'
36
43
  : 'text-[var(--ui-text-primary)] hover:bg-[var(--ui-bg-tertiary)]',
37
44
  className
38
45
  )}
@@ -73,21 +80,54 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
73
80
  };
74
81
 
75
82
  useEffect(() => {
83
+ if (!isOpen) return;
84
+
85
+ const timer = setTimeout(() => {
86
+ const firstItem = menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])');
87
+ firstItem?.focus();
88
+ }, 10);
89
+
76
90
  const handleClickOutside = (e: MouseEvent) => {
77
91
  if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
78
92
  setIsOpen(false);
79
93
  }
80
94
  };
95
+
81
96
  const handleKeyDown = (e: KeyboardEvent) => {
82
- if (e.key === 'Escape') setIsOpen(false);
97
+ if (!menuRef.current) return;
98
+ const items = Array.from(
99
+ menuRef.current.querySelectorAll<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])')
100
+ );
101
+ if (items.length === 0) return;
102
+
103
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement);
104
+
105
+ if (e.key === 'ArrowDown') {
106
+ e.preventDefault();
107
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % items.length;
108
+ items[nextIndex]?.focus();
109
+ } else if (e.key === 'ArrowUp') {
110
+ e.preventDefault();
111
+ const prevIndex = currentIndex === -1 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;
112
+ items[prevIndex]?.focus();
113
+ } else if (e.key === 'Home') {
114
+ e.preventDefault();
115
+ items[0]?.focus();
116
+ } else if (e.key === 'End') {
117
+ e.preventDefault();
118
+ items[items.length - 1]?.focus();
119
+ } else if (e.key === 'Escape') {
120
+ e.preventDefault();
121
+ setIsOpen(false);
122
+ }
83
123
  };
84
124
 
85
- if (isOpen) {
86
- window.addEventListener('click', handleClickOutside);
87
- window.addEventListener('contextmenu', handleClickOutside);
88
- window.addEventListener('keydown', handleKeyDown);
89
- }
125
+ window.addEventListener('click', handleClickOutside);
126
+ window.addEventListener('contextmenu', handleClickOutside);
127
+ window.addEventListener('keydown', handleKeyDown);
128
+
90
129
  return () => {
130
+ clearTimeout(timer);
91
131
  window.removeEventListener('click', handleClickOutside);
92
132
  window.removeEventListener('contextmenu', handleClickOutside);
93
133
  window.removeEventListener('keydown', handleKeyDown);
@@ -104,9 +144,11 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
104
144
  <Portal>
105
145
  <div
106
146
  ref={menuRef}
147
+ role="menu"
148
+ tabIndex={-1}
107
149
  style={{ top: `${position.y}px`, left: `${position.x}px` }}
108
150
  onClick={() => setIsOpen(false)}
109
- className="fixed z-50 min-w-48 p-1.5 rounded-2xl bg-[var(--ui-bg-surface)]/90 backdrop-blur-3xl border border-[var(--ui-border-default)] shadow-2xl space-y-0.5 animate-in fade-in zoom-in-95 duration-150"
151
+ className="fixed z-50 min-w-48 p-1.5 rounded-2xl bg-[var(--ui-bg-surface)]/90 backdrop-blur-3xl border border-[var(--ui-border-default)] shadow-2xl space-y-0.5 animate-in fade-in zoom-in-95 duration-150 focus:outline-none"
110
152
  >
111
153
  {menuContent}
112
154
  </div>
@@ -1,145 +1,160 @@
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 DrawerContextType {
9
- isOpen: boolean;
10
- onClose: () => void;
11
- }
12
-
13
- const DrawerContext = createContext<DrawerContextType | null>(null);
14
-
15
- export function useDrawer() {
16
- const context = useContext(DrawerContext);
17
- if (!context) {
18
- throw new Error('Drawer compound components must be used within a <Drawer />');
19
- }
20
- return context;
21
- }
22
-
23
- export const drawerVariants = cva(
24
- 'fixed z-50 bg-[var(--ui-bg-surface)] text-[var(--ui-text-primary)] border-[var(--ui-border-default)] shadow-2xl transition-transform duration-300 ease-in-out flex flex-col',
25
- {
26
- variants: {
27
- placement: {
28
- right: 'inset-y-0 right-0 h-full w-full sm:max-w-md border-l animate-in slide-in-from-right',
29
- left: 'inset-y-0 left-0 h-full w-full sm:max-w-md border-r animate-in slide-in-from-left',
30
- top: 'inset-x-0 top-0 w-full max-h-[80vh] border-b animate-in slide-in-from-top',
31
- bottom: 'inset-x-0 bottom-0 w-full max-h-[80vh] border-t rounded-t-[var(--ui-radius-xl)] animate-in slide-in-from-bottom',
32
- },
33
- },
34
- defaultVariants: {
35
- placement: 'right',
36
- },
37
- }
38
- );
39
-
40
- export interface DrawerProps extends VariantProps<typeof drawerVariants> {
41
- isOpen: boolean;
42
- onClose: () => void;
43
- children: React.ReactNode;
44
- closeOnEsc?: boolean;
45
- }
46
-
47
- export function Drawer({
48
- isOpen,
49
- onClose,
50
- placement = 'right',
51
- children,
52
- closeOnEsc = true,
53
- }: DrawerProps) {
54
- useEffect(() => {
55
- if (!isOpen) return;
56
-
57
- const handleKeyDown = (e: KeyboardEvent) => {
58
- if (closeOnEsc && e.key === 'Escape') {
59
- onClose();
60
- }
61
- };
62
-
63
- const originalOverflow = document.body.style.overflow;
64
- document.body.style.overflow = 'hidden';
65
-
66
- window.addEventListener('keydown', handleKeyDown);
67
- return () => {
68
- window.removeEventListener('keydown', handleKeyDown);
69
- document.body.style.overflow = originalOverflow;
70
- };
71
- }, [isOpen, closeOnEsc, onClose]);
72
-
73
- if (!isOpen) return null;
74
-
75
- return (
76
- <Portal>
77
- <DrawerContext.Provider value={{ isOpen, onClose }}>
78
- <FocusTrap active={isOpen}>
79
- <div className="fixed inset-0 z-50 overflow-hidden">
80
- {/* Backdrop */}
81
- <div
82
- aria-hidden="true"
83
- onClick={onClose}
84
- className="fixed inset-0 bg-[var(--ui-bg-overlay)] backdrop-blur-sm transition-opacity animate-in fade-in duration-200"
85
- />
86
- {/* Drawer Panel */}
87
- <div className={cn(drawerVariants({ placement }))}>
88
- {children}
89
- </div>
90
- </div>
91
- </FocusTrap>
92
- </DrawerContext.Provider>
93
- </Portal>
94
- );
95
- }
96
-
97
- export const DrawerHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
98
- ({ className, children, ...props }, ref) => {
99
- const { onClose } = useDrawer();
100
- return (
101
- <div
102
- ref={ref}
103
- className={cn('flex items-center justify-between p-5 border-b border-[var(--ui-border-subtle)]', className)}
104
- {...props}
105
- >
106
- <div className="flex-1">{children}</div>
107
- <button
108
- type="button"
109
- onClick={onClose}
110
- aria-label="Close drawer"
111
- className="p-1.5 rounded-full text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] hover:bg-[var(--ui-bg-tertiary)] transition-colors ml-4"
112
- >
113
- <X className="w-4 h-4" />
114
- </button>
115
- </div>
116
- );
117
- }
118
- );
119
- DrawerHeader.displayName = 'DrawerHeader';
120
-
121
- export const DrawerBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
122
- ({ className, children, ...props }, ref) => {
123
- return (
124
- <div ref={ref} className={cn('flex-1 p-5 overflow-y-auto', className)} {...props}>
125
- {children}
126
- </div>
127
- );
128
- }
129
- );
130
- DrawerBody.displayName = 'DrawerBody';
131
-
132
- export const DrawerFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
133
- ({ className, children, ...props }, ref) => {
134
- return (
135
- <div
136
- ref={ref}
137
- className={cn('p-4 border-t border-[var(--ui-border-subtle)] bg-[var(--ui-bg-secondary)] flex items-center justify-end gap-3', className)}
138
- {...props}
139
- >
140
- {children}
141
- </div>
142
- );
143
- }
144
- );
145
- DrawerFooter.displayName = 'DrawerFooter';
1
+ import React, { forwardRef, createContext, useContext, useEffect, useId } 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 DrawerContextType {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ titleId: string;
13
+ }
14
+
15
+ const DrawerContext = createContext<DrawerContextType | null>(null);
16
+
17
+ export function useDrawer() {
18
+ const context = useContext(DrawerContext);
19
+ if (!context) {
20
+ throw new Error('Drawer compound components must be used within a <Drawer />');
21
+ }
22
+ return context;
23
+ }
24
+
25
+ export const drawerVariants = cva(
26
+ 'fixed z-50 bg-[var(--ui-bg-surface)] text-[var(--ui-text-primary)] border-[var(--ui-border-default)] shadow-2xl transition-transform duration-300 ease-in-out flex flex-col',
27
+ {
28
+ variants: {
29
+ placement: {
30
+ right: 'inset-y-0 right-0 h-full w-full sm:max-w-md border-l animate-in slide-in-from-right',
31
+ left: 'inset-y-0 left-0 h-full w-full sm:max-w-md border-r animate-in slide-in-from-left',
32
+ top: 'inset-x-0 top-0 w-full max-h-[80vh] border-b animate-in slide-in-from-top',
33
+ bottom: 'inset-x-0 bottom-0 w-full max-h-[80vh] border-t rounded-t-[var(--ui-radius-xl)] animate-in slide-in-from-bottom',
34
+ },
35
+ },
36
+ defaultVariants: {
37
+ placement: 'right',
38
+ },
39
+ }
40
+ );
41
+
42
+ export interface DrawerProps extends VariantProps<typeof drawerVariants> {
43
+ isOpen: boolean;
44
+ onClose: () => void;
45
+ children: React.ReactNode;
46
+ closeOnEsc?: boolean;
47
+ 'aria-label'?: string;
48
+ 'aria-labelledby'?: string;
49
+ }
50
+
51
+ export function Drawer({
52
+ isOpen,
53
+ onClose,
54
+ placement = 'right',
55
+ children,
56
+ closeOnEsc = true,
57
+ 'aria-label': ariaLabel,
58
+ 'aria-labelledby': customLabelledBy,
59
+ }: DrawerProps) {
60
+ const generatedId = useId();
61
+ const titleId = customLabelledBy || `drawer-title-${generatedId}`;
62
+
63
+ useEffect(() => {
64
+ if (!isOpen) return;
65
+
66
+ lockBodyScroll();
67
+
68
+ const handleKeyDown = (e: KeyboardEvent) => {
69
+ if (closeOnEsc && e.key === 'Escape') {
70
+ onClose();
71
+ }
72
+ };
73
+
74
+ window.addEventListener('keydown', handleKeyDown);
75
+ return () => {
76
+ window.removeEventListener('keydown', handleKeyDown);
77
+ unlockBodyScroll();
78
+ };
79
+ }, [isOpen, closeOnEsc, onClose]);
80
+
81
+ if (!isOpen) return null;
82
+
83
+ return (
84
+ <Portal>
85
+ <DrawerContext.Provider value={{ isOpen, onClose, titleId }}>
86
+ <FocusTrap active={isOpen} restoreFocus={true}>
87
+ <div
88
+ className="fixed inset-0 z-50 overflow-hidden"
89
+ role="dialog"
90
+ aria-modal="true"
91
+ aria-label={ariaLabel}
92
+ aria-labelledby={ariaLabel ? undefined : titleId}
93
+ >
94
+ {/* Backdrop */}
95
+ <div
96
+ aria-hidden="true"
97
+ onClick={onClose}
98
+ className="fixed inset-0 bg-[var(--ui-bg-overlay)] backdrop-blur-sm transition-opacity animate-in fade-in duration-200"
99
+ />
100
+ {/* Drawer Panel */}
101
+ <div className={cn(drawerVariants({ placement }))}>
102
+ {children}
103
+ </div>
104
+ </div>
105
+ </FocusTrap>
106
+ </DrawerContext.Provider>
107
+ </Portal>
108
+ );
109
+ }
110
+
111
+ export const DrawerHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
112
+ ({ className, children, id, ...props }, ref) => {
113
+ const { onClose, titleId } = useDrawer();
114
+ return (
115
+ <div
116
+ ref={ref}
117
+ id={id || titleId}
118
+ className={cn('flex items-center justify-between p-5 border-b border-[var(--ui-border-subtle)]', className)}
119
+ {...props}
120
+ >
121
+ <div className="flex-1 font-semibold text-base text-[var(--ui-text-primary)]">{children}</div>
122
+ <button
123
+ type="button"
124
+ onClick={onClose}
125
+ aria-label="Close drawer"
126
+ className="p-1.5 rounded-full text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] hover:bg-[var(--ui-bg-tertiary)] transition-colors ml-4 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]"
127
+ >
128
+ <X className="w-4 h-4" />
129
+ </button>
130
+ </div>
131
+ );
132
+ }
133
+ );
134
+ DrawerHeader.displayName = 'DrawerHeader';
135
+
136
+ export const DrawerBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
137
+ ({ className, children, ...props }, ref) => {
138
+ return (
139
+ <div ref={ref} className={cn('flex-1 p-5 overflow-y-auto', className)} {...props}>
140
+ {children}
141
+ </div>
142
+ );
143
+ }
144
+ );
145
+ DrawerBody.displayName = 'DrawerBody';
146
+
147
+ export const DrawerFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
148
+ ({ className, children, ...props }, ref) => {
149
+ return (
150
+ <div
151
+ ref={ref}
152
+ className={cn('p-4 border-t border-[var(--ui-border-subtle)] bg-[var(--ui-bg-secondary)] flex items-center justify-end gap-3', className)}
153
+ {...props}
154
+ >
155
+ {children}
156
+ </div>
157
+ );
158
+ }
159
+ );
160
+ DrawerFooter.displayName = 'DrawerFooter';