@titan-design/react-ui 0.1.0 → 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 (192) hide show
  1. package/package.json +10 -5
  2. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  3. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  4. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  5. package/src/components/custom/DateTime/index.ts +2 -0
  6. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  7. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  8. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  9. package/src/components/custom/EmptyState/index.ts +2 -0
  10. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  11. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  12. package/src/components/custom/Metric/Metric.tsx +92 -0
  13. package/src/components/custom/Metric/index.ts +2 -0
  14. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  15. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  16. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  17. package/src/components/custom/Sidebar/index.ts +18 -0
  18. package/src/components/custom/Table/Table.stories.tsx +481 -0
  19. package/src/components/custom/Table/Table.test.tsx +531 -0
  20. package/src/components/custom/Table/Table.tsx +696 -0
  21. package/src/components/custom/Table/index.ts +22 -0
  22. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  23. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  24. package/src/components/custom/Typography/Typography.tsx +218 -0
  25. package/src/components/custom/Typography/index.ts +19 -0
  26. package/src/components/custom/index.ts +8 -0
  27. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  28. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  29. package/src/components/custom/stepper/Stepper.tsx +250 -0
  30. package/src/components/custom/stepper/index.ts +10 -0
  31. package/src/components/index.ts +5 -0
  32. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  33. package/src/components/ui/alert/Alert.test.tsx +202 -0
  34. package/src/components/ui/alert/Alert.tsx +170 -0
  35. package/src/components/ui/alert/index.ts +8 -0
  36. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  37. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  38. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  39. package/src/components/ui/autocomplete/index.ts +2 -0
  40. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  41. package/src/components/ui/avatar/Avatar.test.tsx +135 -0
  42. package/src/components/ui/avatar/Avatar.tsx +168 -0
  43. package/src/components/ui/avatar/index.ts +2 -0
  44. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  45. package/src/components/ui/badge/Badge.test.tsx +86 -0
  46. package/src/components/ui/badge/Badge.tsx +105 -0
  47. package/src/components/ui/badge/index.ts +2 -0
  48. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  49. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  50. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  51. package/src/components/ui/breadcrumbs/index.ts +2 -0
  52. package/src/components/ui/button/Button.stories.tsx +216 -0
  53. package/src/components/ui/button/Button.test.tsx +132 -0
  54. package/src/components/ui/button/Button.tsx +242 -0
  55. package/src/components/ui/button/index.ts +10 -0
  56. package/src/components/ui/card/Card.stories.tsx +443 -0
  57. package/src/components/ui/card/Card.test.tsx +265 -0
  58. package/src/components/ui/card/Card.tsx +380 -0
  59. package/src/components/ui/card/index.ts +20 -0
  60. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  61. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  62. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  63. package/src/components/ui/checkbox/index.ts +2 -0
  64. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  65. package/src/components/ui/chip/Chip.test.tsx +119 -0
  66. package/src/components/ui/chip/Chip.tsx +151 -0
  67. package/src/components/ui/chip/index.ts +2 -0
  68. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  69. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  70. package/src/components/ui/collapse/Collapse.tsx +275 -0
  71. package/src/components/ui/collapse/index.ts +18 -0
  72. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  73. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  74. package/src/components/ui/data-row/DataRow.tsx +29 -0
  75. package/src/components/ui/data-row/index.ts +2 -0
  76. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  77. package/src/components/ui/divider/Divider.test.tsx +55 -0
  78. package/src/components/ui/divider/Divider.tsx +37 -0
  79. package/src/components/ui/divider/index.ts +2 -0
  80. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  81. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  82. package/src/components/ui/drawer/Drawer.tsx +228 -0
  83. package/src/components/ui/drawer/index.ts +9 -0
  84. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  85. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  86. package/src/components/ui/form-field/FormField.tsx +308 -0
  87. package/src/components/ui/form-field/index.ts +2 -0
  88. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  89. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  90. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  91. package/src/components/ui/help-tip/index.ts +2 -0
  92. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  93. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  94. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  95. package/src/components/ui/icon-box/index.ts +1 -0
  96. package/src/components/ui/index.ts +36 -0
  97. package/src/components/ui/input/Input.stories.tsx +192 -0
  98. package/src/components/ui/input/Input.test.tsx +86 -0
  99. package/src/components/ui/input/Input.tsx +273 -0
  100. package/src/components/ui/input/index.ts +2 -0
  101. package/src/components/ui/link/Link.stories.tsx +78 -0
  102. package/src/components/ui/link/Link.test.tsx +94 -0
  103. package/src/components/ui/link/Link.tsx +86 -0
  104. package/src/components/ui/link/index.ts +2 -0
  105. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  106. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  107. package/src/components/ui/list-item/ListItem.tsx +89 -0
  108. package/src/components/ui/list-item/index.ts +14 -0
  109. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  110. package/src/components/ui/menu/Menu.test.tsx +353 -0
  111. package/src/components/ui/menu/Menu.tsx +222 -0
  112. package/src/components/ui/menu/index.ts +2 -0
  113. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  114. package/src/components/ui/modal/Modal.test.tsx +159 -0
  115. package/src/components/ui/modal/Modal.tsx +254 -0
  116. package/src/components/ui/modal/index.ts +19 -0
  117. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  118. package/src/components/ui/popover/Popover.test.tsx +277 -0
  119. package/src/components/ui/popover/Popover.tsx +162 -0
  120. package/src/components/ui/popover/index.ts +2 -0
  121. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  122. package/src/components/ui/progress/Progress.test.tsx +181 -0
  123. package/src/components/ui/progress/Progress.tsx +320 -0
  124. package/src/components/ui/progress/index.ts +9 -0
  125. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  126. package/src/components/ui/radio/Radio.test.tsx +166 -0
  127. package/src/components/ui/radio/Radio.tsx +200 -0
  128. package/src/components/ui/radio/index.ts +2 -0
  129. package/src/components/ui/section/Section.stories.tsx +111 -0
  130. package/src/components/ui/section/Section.test.tsx +121 -0
  131. package/src/components/ui/section/Section.tsx +52 -0
  132. package/src/components/ui/section/index.ts +6 -0
  133. package/src/components/ui/select/Select.stories.tsx +286 -0
  134. package/src/components/ui/select/Select.test.tsx +180 -0
  135. package/src/components/ui/select/Select.tsx +236 -0
  136. package/src/components/ui/select/index.ts +2 -0
  137. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  138. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  139. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  140. package/src/components/ui/skeleton/index.ts +16 -0
  141. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  142. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  143. package/src/components/ui/spinner/Spinner.tsx +72 -0
  144. package/src/components/ui/spinner/index.ts +2 -0
  145. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  146. package/src/components/ui/stack/Stack.test.tsx +184 -0
  147. package/src/components/ui/stack/Stack.tsx +76 -0
  148. package/src/components/ui/stack/index.ts +2 -0
  149. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  150. package/src/components/ui/surface/Surface.test.tsx +63 -0
  151. package/src/components/ui/surface/Surface.tsx +51 -0
  152. package/src/components/ui/surface/index.ts +1 -0
  153. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  154. package/src/components/ui/switch/Switch.test.tsx +112 -0
  155. package/src/components/ui/switch/Switch.tsx +103 -0
  156. package/src/components/ui/switch/index.ts +2 -0
  157. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  158. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  159. package/src/components/ui/tabs/Tabs.tsx +253 -0
  160. package/src/components/ui/tabs/index.ts +16 -0
  161. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  162. package/src/components/ui/toast/Toast.test.tsx +207 -0
  163. package/src/components/ui/toast/Toast.tsx +327 -0
  164. package/src/components/ui/toast/index.ts +8 -0
  165. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  166. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  168. package/src/components/ui/toolbar-button/index.ts +7 -0
  169. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  170. package/src/components/ui/tooltip/Tooltip.test.tsx +179 -0
  171. package/src/components/ui/tooltip/Tooltip.tsx +143 -0
  172. package/src/components/ui/tooltip/index.ts +2 -0
  173. package/src/index.ts +10 -0
  174. package/src/test/setup.ts +19 -0
  175. package/src/theme/Colors.stories.tsx +258 -0
  176. package/src/theme/config.ts +193 -0
  177. package/src/theme/elevation.stories.tsx +346 -0
  178. package/src/theme/elevation.test.ts +27 -0
  179. package/src/theme/elevation.ts +567 -0
  180. package/src/theme/index.ts +39 -0
  181. package/src/theme/shadows.stories.tsx +523 -0
  182. package/src/theme/shadows.ts +439 -0
  183. package/src/theme/tokens/index.ts +2 -0
  184. package/src/theme/tokens/primitives.ts +354 -0
  185. package/src/theme/tokens/semantic.ts +381 -0
  186. package/src/types/jest-axe.d.ts +16 -0
  187. package/src/types/nativewind.d.ts +54 -0
  188. package/src/utils/cn.ts +14 -0
  189. package/src/utils/colors.ts +140 -0
  190. package/src/utils/form.ts +188 -0
  191. package/src/utils/index.ts +29 -0
  192. package/src/utils/useTheme.ts +72 -0
@@ -0,0 +1,298 @@
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
+ Collapse,
6
+ CollapseButton,
7
+ CollapseContent,
8
+ Accordion,
9
+ AccordionItem,
10
+ AccordionButton,
11
+ AccordionPanel,
12
+ } from './Collapse'
13
+
14
+ describe('Collapse', () => {
15
+ it('renders correctly', () => {
16
+ render(
17
+ <Collapse>
18
+ <CollapseButton>Toggle</CollapseButton>
19
+ <CollapseContent>Hidden content</CollapseContent>
20
+ </Collapse>
21
+ )
22
+ expect(screen.getByText('Toggle')).toBeInTheDocument()
23
+ })
24
+
25
+ it('hides content by default', () => {
26
+ render(
27
+ <Collapse>
28
+ <CollapseButton>Toggle</CollapseButton>
29
+ <CollapseContent>Hidden content</CollapseContent>
30
+ </Collapse>
31
+ )
32
+ expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
33
+ })
34
+
35
+ it('shows content when defaultIsOpen is true', () => {
36
+ render(
37
+ <Collapse defaultIsOpen>
38
+ <CollapseButton>Toggle</CollapseButton>
39
+ <CollapseContent>Visible content</CollapseContent>
40
+ </Collapse>
41
+ )
42
+ expect(screen.getByText('Visible content')).toBeInTheDocument()
43
+ })
44
+
45
+ it('toggles content on button click', () => {
46
+ render(
47
+ <Collapse>
48
+ <CollapseButton>Toggle</CollapseButton>
49
+ <CollapseContent>Content</CollapseContent>
50
+ </Collapse>
51
+ )
52
+ expect(screen.queryByText('Content')).not.toBeInTheDocument()
53
+
54
+ fireEvent.click(screen.getByRole('button'))
55
+ expect(screen.getByText('Content')).toBeInTheDocument()
56
+
57
+ fireEvent.click(screen.getByRole('button'))
58
+ expect(screen.queryByText('Content')).not.toBeInTheDocument()
59
+ })
60
+
61
+ it('calls onToggle callback', () => {
62
+ const onToggle = vi.fn()
63
+ render(
64
+ <Collapse onToggle={onToggle}>
65
+ <CollapseButton>Toggle</CollapseButton>
66
+ <CollapseContent>Content</CollapseContent>
67
+ </Collapse>
68
+ )
69
+ fireEvent.click(screen.getByRole('button'))
70
+ expect(onToggle).toHaveBeenCalledWith(true)
71
+
72
+ fireEvent.click(screen.getByRole('button'))
73
+ expect(onToggle).toHaveBeenCalledWith(false)
74
+ })
75
+
76
+ it('supports controlled isOpen prop', () => {
77
+ const onToggle = vi.fn()
78
+ const { rerender } = render(
79
+ <Collapse isOpen={false} onToggle={onToggle}>
80
+ <CollapseButton>Toggle</CollapseButton>
81
+ <CollapseContent>Content</CollapseContent>
82
+ </Collapse>
83
+ )
84
+ expect(screen.queryByText('Content')).not.toBeInTheDocument()
85
+
86
+ fireEvent.click(screen.getByRole('button'))
87
+ expect(onToggle).toHaveBeenCalledWith(true)
88
+ // Still hidden because controlled
89
+ expect(screen.queryByText('Content')).not.toBeInTheDocument()
90
+
91
+ rerender(
92
+ <Collapse isOpen={true} onToggle={onToggle}>
93
+ <CollapseButton>Toggle</CollapseButton>
94
+ <CollapseContent>Content</CollapseContent>
95
+ </Collapse>
96
+ )
97
+ expect(screen.getByText('Content')).toBeInTheDocument()
98
+ })
99
+
100
+ it('communicates expanded state via aria', () => {
101
+ render(
102
+ <Collapse>
103
+ <CollapseButton>Toggle</CollapseButton>
104
+ <CollapseContent>Content</CollapseContent>
105
+ </Collapse>
106
+ )
107
+ // react-native-web does not map accessibilityState.expanded to aria-expanded
108
+ const button = screen.getByRole('button')
109
+ expect(button).toBeInTheDocument()
110
+
111
+ fireEvent.click(button)
112
+ expect(screen.getByText('Content')).toBeInTheDocument()
113
+ })
114
+
115
+ it('applies custom className', () => {
116
+ const { container } = render(
117
+ <Collapse className="extra">
118
+ <CollapseButton>Toggle</CollapseButton>
119
+ <CollapseContent>Content</CollapseContent>
120
+ </Collapse>
121
+ )
122
+ expect(container.firstChild).toBeInTheDocument()
123
+ })
124
+
125
+ describe('Accordion', () => {
126
+ it('renders accordion items', () => {
127
+ render(
128
+ <Accordion>
129
+ <AccordionItem>
130
+ <AccordionButton>Section 1</AccordionButton>
131
+ <AccordionPanel>Panel 1</AccordionPanel>
132
+ </AccordionItem>
133
+ <AccordionItem>
134
+ <AccordionButton>Section 2</AccordionButton>
135
+ <AccordionPanel>Panel 2</AccordionPanel>
136
+ </AccordionItem>
137
+ </Accordion>
138
+ )
139
+ expect(screen.getByText('Section 1')).toBeInTheDocument()
140
+ expect(screen.getByText('Section 2')).toBeInTheDocument()
141
+ })
142
+
143
+ it('hides all panels by default', () => {
144
+ render(
145
+ <Accordion>
146
+ <AccordionItem>
147
+ <AccordionButton>Section 1</AccordionButton>
148
+ <AccordionPanel>Panel 1</AccordionPanel>
149
+ </AccordionItem>
150
+ </Accordion>
151
+ )
152
+ expect(screen.queryByText('Panel 1')).not.toBeInTheDocument()
153
+ })
154
+
155
+ it('opens panel on click', () => {
156
+ render(
157
+ <Accordion>
158
+ <AccordionItem>
159
+ <AccordionButton>Section 1</AccordionButton>
160
+ <AccordionPanel>Panel 1</AccordionPanel>
161
+ </AccordionItem>
162
+ </Accordion>
163
+ )
164
+ fireEvent.click(screen.getByText('Section 1'))
165
+ expect(screen.getByText('Panel 1')).toBeInTheDocument()
166
+ })
167
+
168
+ it('closes other panels in single mode', () => {
169
+ render(
170
+ <Accordion>
171
+ <AccordionItem>
172
+ <AccordionButton>Section 1</AccordionButton>
173
+ <AccordionPanel>Panel 1</AccordionPanel>
174
+ </AccordionItem>
175
+ <AccordionItem>
176
+ <AccordionButton>Section 2</AccordionButton>
177
+ <AccordionPanel>Panel 2</AccordionPanel>
178
+ </AccordionItem>
179
+ </Accordion>
180
+ )
181
+ fireEvent.click(screen.getByText('Section 1'))
182
+ expect(screen.getByText('Panel 1')).toBeInTheDocument()
183
+
184
+ fireEvent.click(screen.getByText('Section 2'))
185
+ expect(screen.queryByText('Panel 1')).not.toBeInTheDocument()
186
+ expect(screen.getByText('Panel 2')).toBeInTheDocument()
187
+ })
188
+
189
+ it('allows multiple open panels when allowMultiple', () => {
190
+ render(
191
+ <Accordion allowMultiple>
192
+ <AccordionItem>
193
+ <AccordionButton>Section 1</AccordionButton>
194
+ <AccordionPanel>Panel 1</AccordionPanel>
195
+ </AccordionItem>
196
+ <AccordionItem>
197
+ <AccordionButton>Section 2</AccordionButton>
198
+ <AccordionPanel>Panel 2</AccordionPanel>
199
+ </AccordionItem>
200
+ </Accordion>
201
+ )
202
+ fireEvent.click(screen.getByText('Section 1'))
203
+ fireEvent.click(screen.getByText('Section 2'))
204
+ expect(screen.getByText('Panel 1')).toBeInTheDocument()
205
+ expect(screen.getByText('Panel 2')).toBeInTheDocument()
206
+ })
207
+
208
+ it('supports defaultIndex for initially open items', () => {
209
+ render(
210
+ <Accordion defaultIndex={[0]}>
211
+ <AccordionItem>
212
+ <AccordionButton>Section 1</AccordionButton>
213
+ <AccordionPanel>Panel 1</AccordionPanel>
214
+ </AccordionItem>
215
+ <AccordionItem>
216
+ <AccordionButton>Section 2</AccordionButton>
217
+ <AccordionPanel>Panel 2</AccordionPanel>
218
+ </AccordionItem>
219
+ </Accordion>
220
+ )
221
+ expect(screen.getByText('Panel 1')).toBeInTheDocument()
222
+ expect(screen.queryByText('Panel 2')).not.toBeInTheDocument()
223
+ })
224
+
225
+ it('closes an open panel on toggle', () => {
226
+ render(
227
+ <Accordion>
228
+ <AccordionItem>
229
+ <AccordionButton>Section 1</AccordionButton>
230
+ <AccordionPanel>Panel 1</AccordionPanel>
231
+ </AccordionItem>
232
+ </Accordion>
233
+ )
234
+ fireEvent.click(screen.getByText('Section 1'))
235
+ expect(screen.getByText('Panel 1')).toBeInTheDocument()
236
+
237
+ fireEvent.click(screen.getByText('Section 1'))
238
+ expect(screen.queryByText('Panel 1')).not.toBeInTheDocument()
239
+ })
240
+
241
+ it('communicates expanded state via aria', () => {
242
+ render(
243
+ <Accordion>
244
+ <AccordionItem>
245
+ <AccordionButton>Section 1</AccordionButton>
246
+ <AccordionPanel>Panel 1</AccordionPanel>
247
+ </AccordionItem>
248
+ </Accordion>
249
+ )
250
+ const buttons = screen.getAllByRole('button')
251
+ // react-native-web does not map accessibilityState.expanded to aria-expanded
252
+ expect(buttons[0]).toBeInTheDocument()
253
+
254
+ fireEvent.click(buttons[0])
255
+ expect(screen.getByText('Panel 1')).toBeInTheDocument()
256
+ })
257
+ })
258
+
259
+ describe('accessibility', () => {
260
+ it('has no accessibility violations for Collapse', async () => {
261
+ const { container } = render(
262
+ <Collapse>
263
+ <CollapseButton>Toggle</CollapseButton>
264
+ <CollapseContent>Content</CollapseContent>
265
+ </Collapse>
266
+ )
267
+ const results = await axe(container)
268
+ expect(results).toHaveNoViolations()
269
+ })
270
+
271
+ it('has no accessibility violations for Accordion', async () => {
272
+ const { container } = render(
273
+ <Accordion>
274
+ <AccordionItem>
275
+ <AccordionButton>Section 1</AccordionButton>
276
+ <AccordionPanel>Panel 1</AccordionPanel>
277
+ </AccordionItem>
278
+ <AccordionItem>
279
+ <AccordionButton>Section 2</AccordionButton>
280
+ <AccordionPanel>Panel 2</AccordionPanel>
281
+ </AccordionItem>
282
+ </Accordion>
283
+ )
284
+ const results = await axe(container)
285
+ expect(results).toHaveNoViolations()
286
+ })
287
+
288
+ it('has button role on collapse trigger', () => {
289
+ render(
290
+ <Collapse>
291
+ <CollapseButton>Toggle</CollapseButton>
292
+ <CollapseContent>Content</CollapseContent>
293
+ </Collapse>
294
+ )
295
+ expect(screen.getByRole('button')).toBeInTheDocument()
296
+ })
297
+ })
298
+ })
@@ -0,0 +1,275 @@
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
+ export interface CollapseProps extends ViewProps {
6
+ /** Whether the collapse is initially open */
7
+ defaultIsOpen?: boolean
8
+ /** Controlled open state */
9
+ isOpen?: boolean
10
+ /** Callback when state changes */
11
+ onToggle?: (isOpen: boolean) => void
12
+ /** Additional className */
13
+ className?: string
14
+ children?: React.ReactNode
15
+ }
16
+
17
+ interface CollapseContextType {
18
+ isOpen: boolean
19
+ toggle: () => void
20
+ }
21
+
22
+ const CollapseContext = createContext<CollapseContextType>({
23
+ isOpen: false,
24
+ toggle: () => {},
25
+ })
26
+
27
+ /**
28
+ * Collapsible content container.
29
+ *
30
+ * @example
31
+ * <Collapse>
32
+ * <CollapseButton>
33
+ * <Text>Click to expand</Text>
34
+ * </CollapseButton>
35
+ * <CollapseContent>
36
+ * <Text>Hidden content</Text>
37
+ * </CollapseContent>
38
+ * </Collapse>
39
+ */
40
+ export function Collapse({
41
+ defaultIsOpen = false,
42
+ isOpen: controlledIsOpen,
43
+ onToggle,
44
+ className,
45
+ children,
46
+ ...props
47
+ }: CollapseProps) {
48
+ const [internalIsOpen, setInternalIsOpen] = useState(defaultIsOpen)
49
+ const isOpen = controlledIsOpen ?? internalIsOpen
50
+
51
+ const toggle = () => {
52
+ const newState = !isOpen
53
+ if (controlledIsOpen === undefined) {
54
+ setInternalIsOpen(newState)
55
+ }
56
+ onToggle?.(newState)
57
+ }
58
+
59
+ return (
60
+ <CollapseContext.Provider value={{ isOpen, toggle }}>
61
+ <View className={cn('w-full', className)} {...props}>
62
+ {children}
63
+ </View>
64
+ </CollapseContext.Provider>
65
+ )
66
+ }
67
+
68
+ export interface CollapseButtonProps {
69
+ children?: React.ReactNode
70
+ className?: string
71
+ }
72
+
73
+ /**
74
+ * Button to toggle collapse state.
75
+ */
76
+ export function CollapseButton({ children, className }: CollapseButtonProps) {
77
+ const { isOpen, toggle } = useContext(CollapseContext)
78
+
79
+ return (
80
+ <Pressable
81
+ onPress={toggle}
82
+ accessibilityRole="button"
83
+ accessibilityState={{ expanded: isOpen }}
84
+ className={cn(
85
+ 'flex-row items-center justify-between py-3 px-4',
86
+ 'web:hover:bg-interactive-hover active:bg-interactive-active rounded-lg',
87
+ className
88
+ )}
89
+ >
90
+ {children}
91
+ <Text className={cn('text-text-secondary transition-transform', isOpen && 'rotate-180')}>
92
+
93
+ </Text>
94
+ </Pressable>
95
+ )
96
+ }
97
+
98
+ export interface CollapseContentProps {
99
+ children?: React.ReactNode
100
+ className?: string
101
+ }
102
+
103
+ /**
104
+ * Collapsible content area.
105
+ */
106
+ export function CollapseContent({ children, className }: CollapseContentProps) {
107
+ const { isOpen } = useContext(CollapseContext)
108
+
109
+ if (!isOpen) return null
110
+
111
+ return (
112
+ <View className={cn('overflow-hidden', className)}>
113
+ {children}
114
+ </View>
115
+ )
116
+ }
117
+
118
+ // Accordion component
119
+
120
+ export interface AccordionProps extends ViewProps {
121
+ /** Allow multiple items to be open */
122
+ allowMultiple?: boolean
123
+ /** Default open item indices */
124
+ defaultIndex?: number[]
125
+ /** Additional className */
126
+ className?: string
127
+ children?: React.ReactNode
128
+ }
129
+
130
+ interface AccordionContextType {
131
+ openIndices: Set<number>
132
+ toggleIndex: (index: number) => void
133
+ allowMultiple: boolean
134
+ }
135
+
136
+ const AccordionContext = createContext<AccordionContextType>({
137
+ openIndices: new Set(),
138
+ toggleIndex: () => {},
139
+ allowMultiple: false,
140
+ })
141
+
142
+ /**
143
+ * Accordion component for multiple collapsible sections.
144
+ *
145
+ * @example
146
+ * <Accordion>
147
+ * <AccordionItem>
148
+ * <AccordionButton>Section 1</AccordionButton>
149
+ * <AccordionPanel>Content 1</AccordionPanel>
150
+ * </AccordionItem>
151
+ * <AccordionItem>
152
+ * <AccordionButton>Section 2</AccordionButton>
153
+ * <AccordionPanel>Content 2</AccordionPanel>
154
+ * </AccordionItem>
155
+ * </Accordion>
156
+ */
157
+ export function Accordion({
158
+ allowMultiple = false,
159
+ defaultIndex = [],
160
+ className,
161
+ children,
162
+ ...props
163
+ }: AccordionProps) {
164
+ const [openIndices, setOpenIndices] = useState<Set<number>>(new Set(defaultIndex))
165
+
166
+ const toggleIndex = (index: number) => {
167
+ setOpenIndices((prev) => {
168
+ const newSet = new Set(prev)
169
+ if (newSet.has(index)) {
170
+ newSet.delete(index)
171
+ } else {
172
+ if (!allowMultiple) {
173
+ newSet.clear()
174
+ }
175
+ newSet.add(index)
176
+ }
177
+ return newSet
178
+ })
179
+ }
180
+
181
+ return (
182
+ <AccordionContext.Provider value={{ openIndices, toggleIndex, allowMultiple }}>
183
+ <View className={cn('w-full divide-y divide-border-default', className)} {...props}>
184
+ {React.Children.map(children, (child, index) => {
185
+ if (React.isValidElement(child)) {
186
+ return React.cloneElement(child as React.ReactElement<any>, { index })
187
+ }
188
+ return child
189
+ })}
190
+ </View>
191
+ </AccordionContext.Provider>
192
+ )
193
+ }
194
+
195
+ export interface AccordionItemProps {
196
+ index?: number
197
+ children?: React.ReactNode
198
+ className?: string
199
+ }
200
+
201
+ /**
202
+ * Individual accordion item.
203
+ */
204
+ export function AccordionItem({ index = 0, children, className }: AccordionItemProps) {
205
+ const { openIndices, toggleIndex } = useContext(AccordionContext)
206
+ const isOpen = openIndices.has(index)
207
+
208
+ return (
209
+ <View className={className}>
210
+ {React.Children.map(children, (child) => {
211
+ if (React.isValidElement(child)) {
212
+ return React.cloneElement(child as React.ReactElement<any>, {
213
+ isOpen,
214
+ onToggle: () => toggleIndex(index),
215
+ })
216
+ }
217
+ return child
218
+ })}
219
+ </View>
220
+ )
221
+ }
222
+
223
+ export interface AccordionButtonProps {
224
+ isOpen?: boolean
225
+ onToggle?: () => void
226
+ children?: React.ReactNode
227
+ className?: string
228
+ }
229
+
230
+ /**
231
+ * Accordion item toggle button.
232
+ */
233
+ export function AccordionButton({
234
+ isOpen = false,
235
+ onToggle,
236
+ children,
237
+ className,
238
+ }: AccordionButtonProps) {
239
+ return (
240
+ <Pressable
241
+ onPress={onToggle}
242
+ accessibilityRole="button"
243
+ accessibilityState={{ expanded: isOpen }}
244
+ className={cn(
245
+ 'flex-row items-center justify-between py-4 px-1',
246
+ 'web:hover:bg-interactive-hover active:bg-interactive-active',
247
+ className
248
+ )}
249
+ >
250
+ <Text className="text-text-primary font-medium">{children}</Text>
251
+ <Text className={cn('text-text-secondary transition-transform', isOpen && 'rotate-180')}>
252
+
253
+ </Text>
254
+ </Pressable>
255
+ )
256
+ }
257
+
258
+ export interface AccordionPanelProps {
259
+ isOpen?: boolean
260
+ children?: React.ReactNode
261
+ className?: string
262
+ }
263
+
264
+ /**
265
+ * Accordion item content panel.
266
+ */
267
+ export function AccordionPanel({ isOpen = false, children, className }: AccordionPanelProps) {
268
+ if (!isOpen) return null
269
+
270
+ return (
271
+ <View className={cn('pb-4 px-1', className)}>
272
+ {children}
273
+ </View>
274
+ )
275
+ }
@@ -0,0 +1,18 @@
1
+ export {
2
+ Collapse,
3
+ CollapseButton,
4
+ CollapseContent,
5
+ Accordion,
6
+ AccordionItem,
7
+ AccordionButton,
8
+ AccordionPanel,
9
+ } from './Collapse'
10
+ export type {
11
+ CollapseProps,
12
+ CollapseButtonProps,
13
+ CollapseContentProps,
14
+ AccordionProps,
15
+ AccordionItemProps,
16
+ AccordionButtonProps,
17
+ AccordionPanelProps,
18
+ } from './Collapse'
@@ -0,0 +1,78 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { DataRow } from './DataRow'
4
+
5
+ const meta: Meta<typeof DataRow> = {
6
+ title: 'Components/DataRow',
7
+ component: DataRow,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ label: {
11
+ control: 'text',
12
+ description: 'Descriptive label shown on the left',
13
+ },
14
+ value: {
15
+ control: 'text',
16
+ description: 'Value shown on the right (string or ReactNode)',
17
+ },
18
+ },
19
+ }
20
+
21
+ export default meta
22
+ type Story = StoryObj<typeof DataRow>
23
+
24
+ export const Default: Story = {
25
+ args: {
26
+ label: 'Weight',
27
+ value: '185 lbs',
28
+ },
29
+ }
30
+
31
+ export const WithNumber: Story = {
32
+ args: {
33
+ label: 'Sets Completed',
34
+ value: '5',
35
+ },
36
+ }
37
+
38
+ export const WithCustomValue: Story = {
39
+ render: () => (
40
+ <DataRow
41
+ label="Status"
42
+ value={
43
+ <View style={{ backgroundColor: '#22c55e', borderRadius: 9999, paddingHorizontal: 8, paddingVertical: 2 }}>
44
+ <Text style={{ color: '#fff', fontSize: 12, fontWeight: '600' }}>Active</Text>
45
+ </View>
46
+ }
47
+ />
48
+ ),
49
+ }
50
+
51
+ export const WithValueClassName: Story = {
52
+ args: {
53
+ label: 'Peak Velocity',
54
+ value: '1.25 m/s',
55
+ valueClassName: 'text-brand-primary font-bold',
56
+ },
57
+ }
58
+
59
+ export const MultipleRows: Story = {
60
+ render: () => (
61
+ <View style={{ gap: 0 }}>
62
+ <DataRow label="Exercise" value="Back Squat" />
63
+ <DataRow label="Weight" value="185 lbs" />
64
+ <DataRow label="Sets" value="5" />
65
+ <DataRow label="Reps" value="5" />
66
+ <DataRow label="Mean Velocity" value="0.75 m/s" />
67
+ <DataRow label="Peak Velocity" value="1.25 m/s" valueClassName="font-bold" />
68
+ </View>
69
+ ),
70
+ }
71
+
72
+ export const CustomClassName: Story = {
73
+ args: {
74
+ label: 'Bordered Row',
75
+ value: 'With custom styles',
76
+ className: 'border-b border-border-primary px-4',
77
+ },
78
+ }