@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,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';