@titan-design/react-ui 0.0.2 → 0.1.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 (206) hide show
  1. package/dist/{chunk-NA3EES23.mjs → chunk-MGW37MPO.mjs} +26 -4
  2. package/dist/chunk-MGW37MPO.mjs.map +1 -0
  3. package/dist/index.d.mts +115 -2
  4. package/dist/index.d.ts +115 -2
  5. package/dist/index.js +262 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +225 -3
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/theme/index.d.mts +124 -109
  10. package/dist/theme/index.d.ts +124 -109
  11. package/dist/theme/index.js +24 -1
  12. package/dist/theme/index.js.map +1 -1
  13. package/dist/theme/index.mjs +1 -1
  14. package/package.json +12 -5
  15. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  16. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  17. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  18. package/src/components/custom/DateTime/index.ts +2 -0
  19. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  20. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  21. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  22. package/src/components/custom/EmptyState/index.ts +2 -0
  23. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  24. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  25. package/src/components/custom/Metric/Metric.tsx +92 -0
  26. package/src/components/custom/Metric/index.ts +2 -0
  27. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  28. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  29. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  30. package/src/components/custom/Sidebar/index.ts +18 -0
  31. package/src/components/custom/Table/Table.stories.tsx +481 -0
  32. package/src/components/custom/Table/Table.test.tsx +531 -0
  33. package/src/components/custom/Table/Table.tsx +696 -0
  34. package/src/components/custom/Table/index.ts +22 -0
  35. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  36. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  37. package/src/components/custom/Typography/Typography.tsx +218 -0
  38. package/src/components/custom/Typography/index.ts +19 -0
  39. package/src/components/custom/index.ts +8 -0
  40. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  41. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  42. package/src/components/custom/stepper/Stepper.tsx +250 -0
  43. package/src/components/custom/stepper/index.ts +10 -0
  44. package/src/components/index.ts +5 -0
  45. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  46. package/src/components/ui/alert/Alert.test.tsx +202 -0
  47. package/src/components/ui/alert/Alert.tsx +170 -0
  48. package/src/components/ui/alert/index.ts +8 -0
  49. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  50. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  51. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  52. package/src/components/ui/autocomplete/index.ts +2 -0
  53. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  54. package/src/components/ui/avatar/Avatar.test.tsx +135 -0
  55. package/src/components/ui/avatar/Avatar.tsx +168 -0
  56. package/src/components/ui/avatar/index.ts +2 -0
  57. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  58. package/src/components/ui/badge/Badge.test.tsx +86 -0
  59. package/src/components/ui/badge/Badge.tsx +105 -0
  60. package/src/components/ui/badge/index.ts +2 -0
  61. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  62. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  63. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  64. package/src/components/ui/breadcrumbs/index.ts +2 -0
  65. package/src/components/ui/button/Button.stories.tsx +216 -0
  66. package/src/components/ui/button/Button.test.tsx +132 -0
  67. package/src/components/ui/button/Button.tsx +242 -0
  68. package/src/components/ui/button/index.ts +10 -0
  69. package/src/components/ui/card/Card.stories.tsx +443 -0
  70. package/src/components/ui/card/Card.test.tsx +265 -0
  71. package/src/components/ui/card/Card.tsx +380 -0
  72. package/src/components/ui/card/index.ts +20 -0
  73. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  74. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  75. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  76. package/src/components/ui/checkbox/index.ts +2 -0
  77. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  78. package/src/components/ui/chip/Chip.test.tsx +119 -0
  79. package/src/components/ui/chip/Chip.tsx +151 -0
  80. package/src/components/ui/chip/index.ts +2 -0
  81. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  82. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  83. package/src/components/ui/collapse/Collapse.tsx +275 -0
  84. package/src/components/ui/collapse/index.ts +18 -0
  85. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  86. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  87. package/src/components/ui/data-row/DataRow.tsx +29 -0
  88. package/src/components/ui/data-row/index.ts +2 -0
  89. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  90. package/src/components/ui/divider/Divider.test.tsx +55 -0
  91. package/src/components/ui/divider/Divider.tsx +37 -0
  92. package/src/components/ui/divider/index.ts +2 -0
  93. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  94. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  95. package/src/components/ui/drawer/Drawer.tsx +228 -0
  96. package/src/components/ui/drawer/index.ts +9 -0
  97. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  98. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  99. package/src/components/ui/form-field/FormField.tsx +308 -0
  100. package/src/components/ui/form-field/index.ts +2 -0
  101. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  102. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  103. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  104. package/src/components/ui/help-tip/index.ts +2 -0
  105. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  106. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  107. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  108. package/src/components/ui/icon-box/index.ts +1 -0
  109. package/src/components/ui/index.ts +36 -0
  110. package/src/components/ui/input/Input.stories.tsx +192 -0
  111. package/src/components/ui/input/Input.test.tsx +86 -0
  112. package/src/components/ui/input/Input.tsx +273 -0
  113. package/src/components/ui/input/index.ts +2 -0
  114. package/src/components/ui/link/Link.stories.tsx +78 -0
  115. package/src/components/ui/link/Link.test.tsx +94 -0
  116. package/src/components/ui/link/Link.tsx +86 -0
  117. package/src/components/ui/link/index.ts +2 -0
  118. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  119. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  120. package/src/components/ui/list-item/ListItem.tsx +89 -0
  121. package/src/components/ui/list-item/index.ts +14 -0
  122. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  123. package/src/components/ui/menu/Menu.test.tsx +353 -0
  124. package/src/components/ui/menu/Menu.tsx +222 -0
  125. package/src/components/ui/menu/index.ts +2 -0
  126. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  127. package/src/components/ui/modal/Modal.test.tsx +159 -0
  128. package/src/components/ui/modal/Modal.tsx +254 -0
  129. package/src/components/ui/modal/index.ts +19 -0
  130. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  131. package/src/components/ui/popover/Popover.test.tsx +277 -0
  132. package/src/components/ui/popover/Popover.tsx +162 -0
  133. package/src/components/ui/popover/index.ts +2 -0
  134. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  135. package/src/components/ui/progress/Progress.test.tsx +181 -0
  136. package/src/components/ui/progress/Progress.tsx +320 -0
  137. package/src/components/ui/progress/index.ts +9 -0
  138. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  139. package/src/components/ui/radio/Radio.test.tsx +166 -0
  140. package/src/components/ui/radio/Radio.tsx +200 -0
  141. package/src/components/ui/radio/index.ts +2 -0
  142. package/src/components/ui/section/Section.stories.tsx +111 -0
  143. package/src/components/ui/section/Section.test.tsx +121 -0
  144. package/src/components/ui/section/Section.tsx +52 -0
  145. package/src/components/ui/section/index.ts +6 -0
  146. package/src/components/ui/select/Select.stories.tsx +286 -0
  147. package/src/components/ui/select/Select.test.tsx +180 -0
  148. package/src/components/ui/select/Select.tsx +236 -0
  149. package/src/components/ui/select/index.ts +2 -0
  150. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  151. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  152. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  153. package/src/components/ui/skeleton/index.ts +16 -0
  154. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  155. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  156. package/src/components/ui/spinner/Spinner.tsx +72 -0
  157. package/src/components/ui/spinner/index.ts +2 -0
  158. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  159. package/src/components/ui/stack/Stack.test.tsx +184 -0
  160. package/src/components/ui/stack/Stack.tsx +76 -0
  161. package/src/components/ui/stack/index.ts +2 -0
  162. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  163. package/src/components/ui/surface/Surface.test.tsx +63 -0
  164. package/src/components/ui/surface/Surface.tsx +51 -0
  165. package/src/components/ui/surface/index.ts +1 -0
  166. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  167. package/src/components/ui/switch/Switch.test.tsx +112 -0
  168. package/src/components/ui/switch/Switch.tsx +103 -0
  169. package/src/components/ui/switch/index.ts +2 -0
  170. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  171. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  172. package/src/components/ui/tabs/Tabs.tsx +253 -0
  173. package/src/components/ui/tabs/index.ts +16 -0
  174. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  175. package/src/components/ui/toast/Toast.test.tsx +207 -0
  176. package/src/components/ui/toast/Toast.tsx +327 -0
  177. package/src/components/ui/toast/index.ts +8 -0
  178. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  179. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  180. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  181. package/src/components/ui/toolbar-button/index.ts +7 -0
  182. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  183. package/src/components/ui/tooltip/Tooltip.test.tsx +179 -0
  184. package/src/components/ui/tooltip/Tooltip.tsx +143 -0
  185. package/src/components/ui/tooltip/index.ts +2 -0
  186. package/src/index.ts +10 -0
  187. package/src/test/setup.ts +19 -0
  188. package/src/theme/Colors.stories.tsx +258 -0
  189. package/src/theme/config.ts +193 -0
  190. package/src/theme/elevation.stories.tsx +346 -0
  191. package/src/theme/elevation.test.ts +27 -0
  192. package/src/theme/elevation.ts +567 -0
  193. package/src/theme/index.ts +39 -0
  194. package/src/theme/shadows.stories.tsx +523 -0
  195. package/src/theme/shadows.ts +439 -0
  196. package/src/theme/tokens/index.ts +2 -0
  197. package/src/theme/tokens/primitives.ts +354 -0
  198. package/src/theme/tokens/semantic.ts +381 -0
  199. package/src/types/jest-axe.d.ts +16 -0
  200. package/src/types/nativewind.d.ts +54 -0
  201. package/src/utils/cn.ts +14 -0
  202. package/src/utils/colors.ts +140 -0
  203. package/src/utils/form.ts +188 -0
  204. package/src/utils/index.ts +29 -0
  205. package/src/utils/useTheme.ts +72 -0
  206. package/dist/chunk-NA3EES23.mjs.map +0 -1
@@ -0,0 +1,353 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import {
5
+ Menu,
6
+ MenuTrigger,
7
+ MenuList,
8
+ MenuItem,
9
+ MenuDivider,
10
+ MenuGroup,
11
+ } from './Menu'
12
+
13
+ describe('Menu', () => {
14
+ it('renders trigger element', () => {
15
+ render(
16
+ <Menu>
17
+ <MenuTrigger>
18
+ <button>Options</button>
19
+ </MenuTrigger>
20
+ <MenuList>
21
+ <MenuItem>Edit</MenuItem>
22
+ </MenuList>
23
+ </Menu>
24
+ )
25
+ expect(screen.getByText('Options')).toBeInTheDocument()
26
+ })
27
+
28
+ it('does not show menu list by default', () => {
29
+ render(
30
+ <Menu>
31
+ <MenuTrigger>
32
+ <button>Options</button>
33
+ </MenuTrigger>
34
+ <MenuList>
35
+ <MenuItem>Edit</MenuItem>
36
+ </MenuList>
37
+ </Menu>
38
+ )
39
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument()
40
+ })
41
+
42
+ it('opens menu when trigger is clicked', () => {
43
+ render(
44
+ <Menu>
45
+ <MenuTrigger>
46
+ <button>Options</button>
47
+ </MenuTrigger>
48
+ <MenuList>
49
+ <MenuItem>Edit</MenuItem>
50
+ </MenuList>
51
+ </Menu>
52
+ )
53
+
54
+ fireEvent.click(screen.getByText('Options'))
55
+ expect(screen.getByText('Edit')).toBeInTheDocument()
56
+ })
57
+
58
+ it('closes menu when trigger is clicked again', () => {
59
+ render(
60
+ <Menu>
61
+ <MenuTrigger>
62
+ <button>Options</button>
63
+ </MenuTrigger>
64
+ <MenuList>
65
+ <MenuItem>Edit</MenuItem>
66
+ </MenuList>
67
+ </Menu>
68
+ )
69
+
70
+ fireEvent.click(screen.getByText('Options'))
71
+ expect(screen.getByText('Edit')).toBeInTheDocument()
72
+
73
+ fireEvent.click(screen.getByText('Options'))
74
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument()
75
+ })
76
+
77
+ describe('controlled state', () => {
78
+ it('shows menu when isOpen is true', () => {
79
+ render(
80
+ <Menu isOpen>
81
+ <MenuTrigger>
82
+ <button>Options</button>
83
+ </MenuTrigger>
84
+ <MenuList>
85
+ <MenuItem>Edit</MenuItem>
86
+ </MenuList>
87
+ </Menu>
88
+ )
89
+ expect(screen.getByText('Edit')).toBeInTheDocument()
90
+ })
91
+
92
+ it('hides menu when isOpen is false', () => {
93
+ render(
94
+ <Menu isOpen={false}>
95
+ <MenuTrigger>
96
+ <button>Options</button>
97
+ </MenuTrigger>
98
+ <MenuList>
99
+ <MenuItem>Edit</MenuItem>
100
+ </MenuList>
101
+ </Menu>
102
+ )
103
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument()
104
+ })
105
+
106
+ it('calls onOpenChange when trigger is clicked', () => {
107
+ const onOpenChange = vi.fn()
108
+ render(
109
+ <Menu onOpenChange={onOpenChange}>
110
+ <MenuTrigger>
111
+ <button>Options</button>
112
+ </MenuTrigger>
113
+ <MenuList>
114
+ <MenuItem>Edit</MenuItem>
115
+ </MenuList>
116
+ </Menu>
117
+ )
118
+
119
+ fireEvent.click(screen.getByText('Options'))
120
+ expect(onOpenChange).toHaveBeenCalledWith(true)
121
+ })
122
+ })
123
+
124
+ describe('MenuItem', () => {
125
+ it('calls onPress when clicked', () => {
126
+ const onPress = vi.fn()
127
+ render(
128
+ <Menu isOpen>
129
+ <MenuTrigger>
130
+ <button>Options</button>
131
+ </MenuTrigger>
132
+ <MenuList>
133
+ <MenuItem onPress={onPress}>Edit</MenuItem>
134
+ </MenuList>
135
+ </Menu>
136
+ )
137
+
138
+ fireEvent.click(screen.getByText('Edit'))
139
+ expect(onPress).toHaveBeenCalledTimes(1)
140
+ })
141
+
142
+ it('closes menu after item is clicked', () => {
143
+ render(
144
+ <Menu>
145
+ <MenuTrigger>
146
+ <button>Options</button>
147
+ </MenuTrigger>
148
+ <MenuList>
149
+ <MenuItem onPress={() => {}}>Edit</MenuItem>
150
+ </MenuList>
151
+ </Menu>
152
+ )
153
+
154
+ fireEvent.click(screen.getByText('Options'))
155
+ expect(screen.getByText('Edit')).toBeInTheDocument()
156
+
157
+ fireEvent.click(screen.getByText('Edit'))
158
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument()
159
+ })
160
+
161
+ it('does not call onPress when disabled', () => {
162
+ const onPress = vi.fn()
163
+ render(
164
+ <Menu isOpen>
165
+ <MenuTrigger>
166
+ <button>Options</button>
167
+ </MenuTrigger>
168
+ <MenuList>
169
+ <MenuItem onPress={onPress} isDisabled>
170
+ Disabled Item
171
+ </MenuItem>
172
+ </MenuList>
173
+ </Menu>
174
+ )
175
+
176
+ fireEvent.click(screen.getByText('Disabled Item'))
177
+ expect(onPress).not.toHaveBeenCalled()
178
+ })
179
+
180
+ it('renders destructive item', () => {
181
+ render(
182
+ <Menu isOpen>
183
+ <MenuTrigger>
184
+ <button>Options</button>
185
+ </MenuTrigger>
186
+ <MenuList>
187
+ <MenuItem isDestructive>Delete</MenuItem>
188
+ </MenuList>
189
+ </Menu>
190
+ )
191
+ expect(screen.getByText('Delete')).toBeInTheDocument()
192
+ })
193
+
194
+ it('renders item with icon', () => {
195
+ render(
196
+ <Menu isOpen>
197
+ <MenuTrigger>
198
+ <button>Options</button>
199
+ </MenuTrigger>
200
+ <MenuList>
201
+ <MenuItem icon={<span data-testid="edit-icon">pencil</span>}>
202
+ Edit
203
+ </MenuItem>
204
+ </MenuList>
205
+ </Menu>
206
+ )
207
+ expect(screen.getByTestId('edit-icon')).toBeInTheDocument()
208
+ })
209
+
210
+ it('has menuitem accessibility role', () => {
211
+ render(
212
+ <Menu isOpen>
213
+ <MenuTrigger>
214
+ <button>Options</button>
215
+ </MenuTrigger>
216
+ <MenuList>
217
+ <MenuItem>Edit</MenuItem>
218
+ </MenuList>
219
+ </Menu>
220
+ )
221
+ expect(screen.getByRole('menuitem')).toBeInTheDocument()
222
+ })
223
+ })
224
+
225
+ describe('MenuDivider', () => {
226
+ it('renders a divider', () => {
227
+ const { container } = render(
228
+ <Menu isOpen>
229
+ <MenuTrigger>
230
+ <button>Options</button>
231
+ </MenuTrigger>
232
+ <MenuList>
233
+ <MenuItem>Edit</MenuItem>
234
+ <MenuDivider />
235
+ <MenuItem>Delete</MenuItem>
236
+ </MenuList>
237
+ </Menu>
238
+ )
239
+ expect(container).toBeInTheDocument()
240
+ })
241
+ })
242
+
243
+ describe('MenuGroup', () => {
244
+ it('renders group with label', () => {
245
+ render(
246
+ <Menu isOpen>
247
+ <MenuTrigger>
248
+ <button>Options</button>
249
+ </MenuTrigger>
250
+ <MenuList>
251
+ <MenuGroup label="Actions">
252
+ <MenuItem>Edit</MenuItem>
253
+ <MenuItem>Duplicate</MenuItem>
254
+ </MenuGroup>
255
+ </MenuList>
256
+ </Menu>
257
+ )
258
+ expect(screen.getByText('Actions')).toBeInTheDocument()
259
+ expect(screen.getByText('Edit')).toBeInTheDocument()
260
+ expect(screen.getByText('Duplicate')).toBeInTheDocument()
261
+ })
262
+
263
+ it('renders group without label', () => {
264
+ render(
265
+ <Menu isOpen>
266
+ <MenuTrigger>
267
+ <button>Options</button>
268
+ </MenuTrigger>
269
+ <MenuList>
270
+ <MenuGroup>
271
+ <MenuItem>Edit</MenuItem>
272
+ </MenuGroup>
273
+ </MenuList>
274
+ </Menu>
275
+ )
276
+ expect(screen.getByText('Edit')).toBeInTheDocument()
277
+ })
278
+ })
279
+
280
+ describe('MenuTrigger', () => {
281
+ it('communicates expanded state', () => {
282
+ render(
283
+ <Menu>
284
+ <MenuTrigger>
285
+ <span>Options</span>
286
+ </MenuTrigger>
287
+ <MenuList>
288
+ <MenuItem>Edit</MenuItem>
289
+ </MenuList>
290
+ </Menu>
291
+ )
292
+
293
+ const trigger = screen.getByRole('button', { name: 'Options' })
294
+ // react-native-web does not render aria-expanded for accessibilityState.expanded
295
+ expect(trigger).toBeInTheDocument()
296
+
297
+ fireEvent.click(trigger)
298
+ // Verify menu opens by checking for menu content
299
+ expect(screen.getByRole('menu')).toBeInTheDocument()
300
+ })
301
+ })
302
+
303
+ describe('MenuList', () => {
304
+ it('has menu accessibility role', () => {
305
+ render(
306
+ <Menu isOpen>
307
+ <MenuTrigger>
308
+ <button>Options</button>
309
+ </MenuTrigger>
310
+ <MenuList>
311
+ <MenuItem>Edit</MenuItem>
312
+ </MenuList>
313
+ </Menu>
314
+ )
315
+ expect(screen.getByRole('menu')).toBeInTheDocument()
316
+ })
317
+ })
318
+
319
+ describe('accessibility', () => {
320
+ it('has no accessibility violations when closed', async () => {
321
+ const { container } = render(
322
+ <Menu>
323
+ <MenuTrigger>
324
+ <span>Options</span>
325
+ </MenuTrigger>
326
+ <MenuList>
327
+ <MenuItem>Edit</MenuItem>
328
+ <MenuItem>Delete</MenuItem>
329
+ </MenuList>
330
+ </Menu>
331
+ )
332
+ const results = await axe(container)
333
+ expect(results).toHaveNoViolations()
334
+ })
335
+
336
+ it('has no accessibility violations when open', async () => {
337
+ const { container } = render(
338
+ <Menu isOpen>
339
+ <MenuTrigger>
340
+ <span>Options</span>
341
+ </MenuTrigger>
342
+ <MenuList>
343
+ <MenuItem>Edit</MenuItem>
344
+ <MenuDivider />
345
+ <MenuItem isDestructive>Delete</MenuItem>
346
+ </MenuList>
347
+ </Menu>
348
+ )
349
+ const results = await axe(container)
350
+ expect(results).toHaveNoViolations()
351
+ })
352
+ })
353
+ })
@@ -0,0 +1,222 @@
1
+ import React, { useState, createContext, useContext } from 'react'
2
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+
5
+ interface MenuContextType {
6
+ isOpen: boolean
7
+ setIsOpen: (open: boolean) => void
8
+ closeMenu: () => void
9
+ }
10
+
11
+ const MenuContext = createContext<MenuContextType>({
12
+ isOpen: false,
13
+ setIsOpen: () => {},
14
+ closeMenu: () => {},
15
+ })
16
+
17
+ export interface MenuProps extends ViewProps {
18
+ /** Controlled open state */
19
+ isOpen?: boolean
20
+ /** Callback when open state changes */
21
+ onOpenChange?: (isOpen: boolean) => void
22
+ /** Additional className */
23
+ className?: string
24
+ children?: React.ReactNode
25
+ }
26
+
27
+ /**
28
+ * Menu component for dropdown menus.
29
+ *
30
+ * @example
31
+ * <Menu>
32
+ * <MenuTrigger>
33
+ * <Button><ButtonText>Options</ButtonText></Button>
34
+ * </MenuTrigger>
35
+ * <MenuList>
36
+ * <MenuItem onPress={() => {}}>Edit</MenuItem>
37
+ * <MenuItem onPress={() => {}}>Delete</MenuItem>
38
+ * <MenuDivider />
39
+ * <MenuItem onPress={() => {}} isDestructive>Remove</MenuItem>
40
+ * </MenuList>
41
+ * </Menu>
42
+ */
43
+ export function Menu({
44
+ isOpen: controlledIsOpen,
45
+ onOpenChange,
46
+ className,
47
+ children,
48
+ ...props
49
+ }: MenuProps) {
50
+ const [internalIsOpen, setInternalIsOpen] = useState(false)
51
+ const isOpen = controlledIsOpen ?? internalIsOpen
52
+
53
+ const setIsOpen = (open: boolean) => {
54
+ if (controlledIsOpen === undefined) {
55
+ setInternalIsOpen(open)
56
+ }
57
+ onOpenChange?.(open)
58
+ }
59
+
60
+ const closeMenu = () => setIsOpen(false)
61
+
62
+ return (
63
+ <MenuContext.Provider value={{ isOpen, setIsOpen, closeMenu }}>
64
+ <View className={cn('relative', className)} {...props}>
65
+ {children}
66
+ </View>
67
+ </MenuContext.Provider>
68
+ )
69
+ }
70
+
71
+ export interface MenuTriggerProps {
72
+ children: React.ReactNode
73
+ className?: string
74
+ }
75
+
76
+ /**
77
+ * Trigger element for the menu.
78
+ */
79
+ export function MenuTrigger({ children, className }: MenuTriggerProps) {
80
+ const { isOpen, setIsOpen } = useContext(MenuContext)
81
+
82
+ return (
83
+ <Pressable
84
+ onPress={() => setIsOpen(!isOpen)}
85
+ accessibilityRole="button"
86
+ accessibilityState={{ expanded: isOpen }}
87
+ className={className}
88
+ >
89
+ {children}
90
+ </Pressable>
91
+ )
92
+ }
93
+
94
+ export interface MenuListProps {
95
+ children?: React.ReactNode
96
+ className?: string
97
+ }
98
+
99
+ /**
100
+ * Container for menu items.
101
+ */
102
+ export function MenuList({ children, className }: MenuListProps) {
103
+ const { isOpen, setIsOpen } = useContext(MenuContext)
104
+
105
+ if (!isOpen) return null
106
+
107
+ return (
108
+ <>
109
+ {/* Backdrop */}
110
+ <Pressable
111
+ onPress={() => setIsOpen(false)}
112
+ className="fixed inset-0 z-40"
113
+ style={{ position: 'absolute' }}
114
+ />
115
+ {/* Menu */}
116
+ <View
117
+ className={cn(
118
+ 'absolute z-50 top-full left-0 mt-1 min-w-[160px]',
119
+ 'bg-surface-elevated rounded-lg shadow-lg border border-border-default',
120
+ 'py-1 overflow-hidden',
121
+ className
122
+ )}
123
+ accessibilityRole="menu"
124
+ >
125
+ {children}
126
+ </View>
127
+ </>
128
+ )
129
+ }
130
+
131
+ export interface MenuItemProps {
132
+ /** Press handler */
133
+ onPress?: () => void
134
+ /** Whether the item is disabled */
135
+ isDisabled?: boolean
136
+ /** Whether the item is destructive (shows in red) */
137
+ isDestructive?: boolean
138
+ /** Icon element */
139
+ icon?: React.ReactNode
140
+ /** Additional className */
141
+ className?: string
142
+ children?: React.ReactNode
143
+ }
144
+
145
+ /**
146
+ * Individual menu item.
147
+ */
148
+ export function MenuItem({
149
+ onPress,
150
+ isDisabled = false,
151
+ isDestructive = false,
152
+ icon,
153
+ className,
154
+ children,
155
+ }: MenuItemProps) {
156
+ const { closeMenu } = useContext(MenuContext)
157
+
158
+ const handlePress = () => {
159
+ if (isDisabled) return
160
+ onPress?.()
161
+ closeMenu()
162
+ }
163
+
164
+ return (
165
+ <Pressable
166
+ onPress={handlePress}
167
+ disabled={isDisabled}
168
+ accessibilityRole="menuitem"
169
+ accessibilityState={{ disabled: isDisabled }}
170
+ className={cn(
171
+ 'flex-row items-center px-4 py-2',
172
+ 'web:hover:bg-interactive-hover active:bg-interactive-active',
173
+ isDisabled && 'opacity-50 cursor-not-allowed',
174
+ className
175
+ )}
176
+ >
177
+ {icon && <View className="mr-3">{icon}</View>}
178
+ <Text
179
+ className={cn(
180
+ 'text-sm',
181
+ isDestructive ? 'text-status-error' : 'text-text-primary'
182
+ )}
183
+ >
184
+ {children}
185
+ </Text>
186
+ </Pressable>
187
+ )
188
+ }
189
+
190
+ export interface MenuDividerProps {
191
+ className?: string
192
+ }
193
+
194
+ /**
195
+ * Divider between menu items.
196
+ */
197
+ export function MenuDivider({ className }: MenuDividerProps) {
198
+ return <View className={cn('h-px bg-border-default my-1', className)} />
199
+ }
200
+
201
+ export interface MenuGroupProps {
202
+ /** Group label */
203
+ label?: string
204
+ children?: React.ReactNode
205
+ className?: string
206
+ }
207
+
208
+ /**
209
+ * Group of menu items with optional label.
210
+ */
211
+ export function MenuGroup({ label, children, className }: MenuGroupProps) {
212
+ return (
213
+ <View className={className}>
214
+ {label && (
215
+ <Text className="px-4 py-2 text-xs font-semibold text-text-secondary uppercase">
216
+ {label}
217
+ </Text>
218
+ )}
219
+ {children}
220
+ </View>
221
+ )
222
+ }
@@ -0,0 +1,2 @@
1
+ export { Menu, MenuTrigger, MenuList, MenuItem, MenuDivider, MenuGroup } from './Menu'
2
+ export type { MenuProps, MenuTriggerProps, MenuListProps, MenuItemProps, MenuDividerProps, MenuGroupProps } from './Menu'