@titan-design/react-ui 0.1.0 → 0.2.5

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 (236) hide show
  1. package/README.md +47 -1
  2. package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
  3. package/dist/chunk-UXVRPOME.mjs.map +1 -0
  4. package/dist/index.d.mts +101 -12
  5. package/dist/index.d.ts +101 -12
  6. package/dist/index.js +1267 -468
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +734 -112
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/theme/index.d.mts +86 -1
  11. package/dist/theme/index.d.ts +86 -1
  12. package/dist/theme/index.js +166 -0
  13. package/dist/theme/index.js.map +1 -1
  14. package/dist/theme/index.mjs +1 -1
  15. package/package.json +22 -6
  16. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  17. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  18. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  19. package/src/components/custom/DateTime/index.ts +2 -0
  20. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  21. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  22. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  23. package/src/components/custom/EmptyState/index.ts +2 -0
  24. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  25. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  26. package/src/components/custom/Metric/Metric.tsx +92 -0
  27. package/src/components/custom/Metric/index.ts +2 -0
  28. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  29. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  30. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  31. package/src/components/custom/Sidebar/index.ts +18 -0
  32. package/src/components/custom/Table/Table.stories.tsx +481 -0
  33. package/src/components/custom/Table/Table.test.tsx +531 -0
  34. package/src/components/custom/Table/Table.tsx +696 -0
  35. package/src/components/custom/Table/index.ts +22 -0
  36. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  37. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  38. package/src/components/custom/Typography/Typography.tsx +218 -0
  39. package/src/components/custom/Typography/index.ts +19 -0
  40. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  42. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  43. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  44. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  45. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  46. package/src/components/custom/Workout/index.ts +2 -0
  47. package/src/components/custom/index.ts +9 -0
  48. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  49. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  50. package/src/components/custom/stepper/Stepper.tsx +250 -0
  51. package/src/components/custom/stepper/index.ts +10 -0
  52. package/src/components/index.ts +5 -0
  53. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  54. package/src/components/ui/alert/Alert.test.tsx +202 -0
  55. package/src/components/ui/alert/Alert.tsx +170 -0
  56. package/src/components/ui/alert/index.ts +8 -0
  57. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  58. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  59. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  60. package/src/components/ui/autocomplete/index.ts +2 -0
  61. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  62. package/src/components/ui/avatar/Avatar.test.tsx +152 -0
  63. package/src/components/ui/avatar/Avatar.tsx +176 -0
  64. package/src/components/ui/avatar/index.ts +2 -0
  65. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  66. package/src/components/ui/badge/Badge.test.tsx +103 -0
  67. package/src/components/ui/badge/Badge.tsx +119 -0
  68. package/src/components/ui/badge/index.ts +3 -0
  69. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  70. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  71. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  72. package/src/components/ui/breadcrumbs/index.ts +2 -0
  73. package/src/components/ui/button/Button.stories.tsx +216 -0
  74. package/src/components/ui/button/Button.test.tsx +132 -0
  75. package/src/components/ui/button/Button.tsx +297 -0
  76. package/src/components/ui/button/index.ts +10 -0
  77. package/src/components/ui/card/Card.stories.tsx +443 -0
  78. package/src/components/ui/card/Card.test.tsx +294 -0
  79. package/src/components/ui/card/Card.tsx +401 -0
  80. package/src/components/ui/card/index.ts +20 -0
  81. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  82. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  83. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  84. package/src/components/ui/checkbox/index.ts +2 -0
  85. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  86. package/src/components/ui/chip/Chip.test.tsx +119 -0
  87. package/src/components/ui/chip/Chip.tsx +151 -0
  88. package/src/components/ui/chip/index.ts +2 -0
  89. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  90. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  91. package/src/components/ui/collapse/Collapse.tsx +275 -0
  92. package/src/components/ui/collapse/index.ts +18 -0
  93. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  94. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  95. package/src/components/ui/data-row/DataRow.tsx +29 -0
  96. package/src/components/ui/data-row/index.ts +2 -0
  97. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  98. package/src/components/ui/divider/Divider.test.tsx +55 -0
  99. package/src/components/ui/divider/Divider.tsx +37 -0
  100. package/src/components/ui/divider/index.ts +2 -0
  101. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  102. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  103. package/src/components/ui/drawer/Drawer.tsx +228 -0
  104. package/src/components/ui/drawer/index.ts +9 -0
  105. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  106. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  107. package/src/components/ui/form-field/FormField.tsx +308 -0
  108. package/src/components/ui/form-field/index.ts +2 -0
  109. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  110. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  111. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  112. package/src/components/ui/help-tip/index.ts +2 -0
  113. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  114. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  115. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  116. package/src/components/ui/icon-box/index.ts +1 -0
  117. package/src/components/ui/index.ts +38 -0
  118. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  119. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  120. package/src/components/ui/indicator/Indicator.tsx +73 -0
  121. package/src/components/ui/indicator/index.ts +2 -0
  122. package/src/components/ui/input/Input.stories.tsx +192 -0
  123. package/src/components/ui/input/Input.test.tsx +86 -0
  124. package/src/components/ui/input/Input.tsx +273 -0
  125. package/src/components/ui/input/index.ts +2 -0
  126. package/src/components/ui/link/Link.stories.tsx +78 -0
  127. package/src/components/ui/link/Link.test.tsx +94 -0
  128. package/src/components/ui/link/Link.tsx +86 -0
  129. package/src/components/ui/link/index.ts +2 -0
  130. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  131. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  132. package/src/components/ui/list-item/ListItem.tsx +89 -0
  133. package/src/components/ui/list-item/index.ts +14 -0
  134. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  135. package/src/components/ui/menu/Menu.test.tsx +353 -0
  136. package/src/components/ui/menu/Menu.tsx +222 -0
  137. package/src/components/ui/menu/index.ts +2 -0
  138. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  139. package/src/components/ui/modal/Modal.test.tsx +159 -0
  140. package/src/components/ui/modal/Modal.tsx +254 -0
  141. package/src/components/ui/modal/index.ts +19 -0
  142. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  143. package/src/components/ui/pill/Pill.test.tsx +69 -0
  144. package/src/components/ui/pill/Pill.tsx +104 -0
  145. package/src/components/ui/pill/index.ts +2 -0
  146. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  147. package/src/components/ui/popover/Popover.test.tsx +419 -0
  148. package/src/components/ui/popover/Popover.tsx +237 -0
  149. package/src/components/ui/popover/index.ts +2 -0
  150. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  151. package/src/components/ui/progress/Progress.test.tsx +210 -0
  152. package/src/components/ui/progress/Progress.tsx +339 -0
  153. package/src/components/ui/progress/index.ts +9 -0
  154. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  155. package/src/components/ui/radio/Radio.test.tsx +166 -0
  156. package/src/components/ui/radio/Radio.tsx +200 -0
  157. package/src/components/ui/radio/index.ts +2 -0
  158. package/src/components/ui/section/Section.stories.tsx +111 -0
  159. package/src/components/ui/section/Section.test.tsx +121 -0
  160. package/src/components/ui/section/Section.tsx +52 -0
  161. package/src/components/ui/section/index.ts +6 -0
  162. package/src/components/ui/select/Select.stories.tsx +286 -0
  163. package/src/components/ui/select/Select.test.tsx +200 -0
  164. package/src/components/ui/select/Select.tsx +243 -0
  165. package/src/components/ui/select/index.ts +2 -0
  166. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  167. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  168. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  169. package/src/components/ui/skeleton/index.ts +16 -0
  170. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  171. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  172. package/src/components/ui/spinner/Spinner.tsx +72 -0
  173. package/src/components/ui/spinner/index.ts +2 -0
  174. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  175. package/src/components/ui/stack/Stack.test.tsx +184 -0
  176. package/src/components/ui/stack/Stack.tsx +76 -0
  177. package/src/components/ui/stack/index.ts +2 -0
  178. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  179. package/src/components/ui/surface/Surface.test.tsx +63 -0
  180. package/src/components/ui/surface/Surface.tsx +51 -0
  181. package/src/components/ui/surface/index.ts +1 -0
  182. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  183. package/src/components/ui/switch/Switch.test.tsx +112 -0
  184. package/src/components/ui/switch/Switch.tsx +103 -0
  185. package/src/components/ui/switch/index.ts +2 -0
  186. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  187. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  188. package/src/components/ui/tabs/Tabs.tsx +253 -0
  189. package/src/components/ui/tabs/index.ts +16 -0
  190. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  191. package/src/components/ui/toast/Toast.test.tsx +207 -0
  192. package/src/components/ui/toast/Toast.tsx +327 -0
  193. package/src/components/ui/toast/index.ts +9 -0
  194. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  195. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  196. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  197. package/src/components/ui/toolbar-button/index.ts +7 -0
  198. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  199. package/src/components/ui/tooltip/Tooltip.test.tsx +262 -0
  200. package/src/components/ui/tooltip/Tooltip.tsx +234 -0
  201. package/src/components/ui/tooltip/index.ts +2 -0
  202. package/src/index.ts +10 -0
  203. package/src/stories/PresetShowcase.stories.tsx +462 -0
  204. package/src/stories/ThemePresets.stories.tsx +85 -0
  205. package/src/test/setup.ts +19 -0
  206. package/src/theme/Colors.stories.tsx +258 -0
  207. package/src/theme/config.ts +193 -0
  208. package/src/theme/elevation.stories.tsx +346 -0
  209. package/src/theme/elevation.test.ts +27 -0
  210. package/src/theme/elevation.ts +567 -0
  211. package/src/theme/global.css +129 -1
  212. package/src/theme/index.ts +40 -0
  213. package/src/theme/presets/apply.ts +97 -0
  214. package/src/theme/presets/audiobook.ts +93 -0
  215. package/src/theme/presets/default.ts +6 -0
  216. package/src/theme/presets/index.ts +4 -0
  217. package/src/theme/presets/presets.test.ts +51 -0
  218. package/src/theme/presets/types.ts +76 -0
  219. package/src/theme/shadows.stories.tsx +523 -0
  220. package/src/theme/shadows.ts +439 -0
  221. package/src/theme/tokens/index.ts +2 -0
  222. package/src/theme/tokens/primitives.ts +354 -0
  223. package/src/theme/tokens/semantic.ts +381 -0
  224. package/src/types/jest-axe.d.ts +16 -0
  225. package/src/types/nativewind.d.ts +54 -0
  226. package/src/utils/avatar-color.test.ts +30 -0
  227. package/src/utils/avatar-color.ts +20 -0
  228. package/src/utils/cn.ts +14 -0
  229. package/src/utils/colors.ts +140 -0
  230. package/src/utils/form.ts +188 -0
  231. package/src/utils/index.ts +29 -0
  232. package/src/utils/useTheme.ts +72 -0
  233. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  234. package/src/web-jsx/jsx-runtime.ts +41 -0
  235. package/tailwind.config.js +53 -3
  236. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -0,0 +1,294 @@
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
+ Card,
6
+ CardHeader,
7
+ CardTitle,
8
+ CardDescription,
9
+ CardContent,
10
+ CardFooter,
11
+ CardSkeleton,
12
+ CardInset,
13
+ } from './Card'
14
+
15
+ describe('Card', () => {
16
+ it('renders children correctly', () => {
17
+ render(
18
+ <Card>
19
+ <CardContent>Card body</CardContent>
20
+ </Card>
21
+ )
22
+ expect(screen.getByText('Card body')).toBeInTheDocument()
23
+ })
24
+
25
+ it('renders with default variant (elevated)', () => {
26
+ render(
27
+ <Card testID="card">
28
+ <CardContent>Content</CardContent>
29
+ </Card>
30
+ )
31
+ expect(screen.getByText('Content')).toBeInTheDocument()
32
+ })
33
+
34
+ it('renders with outline variant', () => {
35
+ render(
36
+ <Card variant="outline">
37
+ <CardContent>Outline card</CardContent>
38
+ </Card>
39
+ )
40
+ expect(screen.getByText('Outline card')).toBeInTheDocument()
41
+ })
42
+
43
+ it('renders with filled variant', () => {
44
+ render(
45
+ <Card variant="filled">
46
+ <CardContent>Filled card</CardContent>
47
+ </Card>
48
+ )
49
+ expect(screen.getByText('Filled card')).toBeInTheDocument()
50
+ })
51
+
52
+ it('renders with different elevation levels', () => {
53
+ const { rerender } = render(
54
+ <Card elevation={1}>
55
+ <CardContent>Subtle</CardContent>
56
+ </Card>
57
+ )
58
+ expect(screen.getByText('Subtle')).toBeInTheDocument()
59
+
60
+ rerender(
61
+ <Card elevation={3}>
62
+ <CardContent>Prominent</CardContent>
63
+ </Card>
64
+ )
65
+ expect(screen.getByText('Prominent')).toBeInTheDocument()
66
+ })
67
+
68
+ describe('interactive card', () => {
69
+ it('renders as a button when isInteractive is true', () => {
70
+ render(
71
+ <Card isInteractive>
72
+ <CardContent>Interactive card</CardContent>
73
+ </Card>
74
+ )
75
+ expect(screen.getByRole('button')).toBeInTheDocument()
76
+ })
77
+
78
+ it('renders as a button when onPress is provided', () => {
79
+ render(
80
+ <Card onPress={() => {}}>
81
+ <CardContent>Pressable card</CardContent>
82
+ </Card>
83
+ )
84
+ expect(screen.getByRole('button')).toBeInTheDocument()
85
+ })
86
+
87
+ it('calls onPress handler when clicked', () => {
88
+ const onPress = vi.fn()
89
+ render(
90
+ <Card onPress={onPress}>
91
+ <CardContent>Click me</CardContent>
92
+ </Card>
93
+ )
94
+
95
+ fireEvent.click(screen.getByRole('button'))
96
+ expect(onPress).toHaveBeenCalledTimes(1)
97
+ })
98
+
99
+ it('does not render as a button when not interactive', () => {
100
+ render(
101
+ <Card>
102
+ <CardContent>Static card</CardContent>
103
+ </Card>
104
+ )
105
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
106
+ })
107
+ })
108
+
109
+ describe('loading state', () => {
110
+ it('renders skeleton content when isLoading is true', () => {
111
+ render(
112
+ <Card isLoading>
113
+ <CardContent>This should not appear</CardContent>
114
+ </Card>
115
+ )
116
+ expect(screen.queryByText('This should not appear')).not.toBeInTheDocument()
117
+ })
118
+
119
+ it('renders skeleton without header when skeletonHasHeader is false', () => {
120
+ const { container } = render(
121
+ <Card isLoading skeletonHasHeader={false}>
122
+ <CardContent>Hidden</CardContent>
123
+ </Card>
124
+ )
125
+ expect(container).toBeInTheDocument()
126
+ })
127
+
128
+ it('renders skeleton with footer when skeletonHasFooter is true', () => {
129
+ const { container } = render(
130
+ <Card isLoading skeletonHasFooter>
131
+ <CardContent>Hidden</CardContent>
132
+ </Card>
133
+ )
134
+ expect(container).toBeInTheDocument()
135
+ })
136
+ })
137
+
138
+ describe('compound sub-components', () => {
139
+ it('renders full compound card structure', () => {
140
+ render(
141
+ <Card>
142
+ <CardHeader>
143
+ <CardTitle>Title</CardTitle>
144
+ <CardDescription>Description</CardDescription>
145
+ </CardHeader>
146
+ <CardContent>Body content</CardContent>
147
+ <CardFooter>Footer content</CardFooter>
148
+ </Card>
149
+ )
150
+
151
+ expect(screen.getByText('Title')).toBeInTheDocument()
152
+ expect(screen.getByText('Description')).toBeInTheDocument()
153
+ expect(screen.getByText('Body content')).toBeInTheDocument()
154
+ expect(screen.getByText('Footer content')).toBeInTheDocument()
155
+ })
156
+
157
+ it('CardTitle has header accessibility role', () => {
158
+ render(<CardTitle>My Title</CardTitle>)
159
+ expect(screen.getByRole('heading')).toBeInTheDocument()
160
+ })
161
+
162
+ it('accepts custom className on sub-components', () => {
163
+ render(
164
+ <Card className="custom-card">
165
+ <CardHeader className="custom-header">
166
+ <CardTitle className="custom-title">Title</CardTitle>
167
+ <CardDescription className="custom-desc">Desc</CardDescription>
168
+ </CardHeader>
169
+ <CardContent className="custom-content">Content</CardContent>
170
+ <CardFooter className="custom-footer">Footer</CardFooter>
171
+ </Card>
172
+ )
173
+ expect(screen.getByText('Title')).toBeInTheDocument()
174
+ })
175
+ })
176
+
177
+ describe('CardSkeleton', () => {
178
+ it('renders skeleton placeholder', () => {
179
+ const { container } = render(<CardSkeleton />)
180
+ expect(container).toBeInTheDocument()
181
+ })
182
+
183
+ it('renders skeleton with custom content lines', () => {
184
+ const { container } = render(<CardSkeleton contentLines={5} />)
185
+ expect(container).toBeInTheDocument()
186
+ })
187
+
188
+ it('renders skeleton without header', () => {
189
+ const { container } = render(<CardSkeleton hasHeader={false} />)
190
+ expect(container).toBeInTheDocument()
191
+ })
192
+
193
+ it('renders skeleton with footer', () => {
194
+ const { container } = render(<CardSkeleton hasFooter />)
195
+ expect(container).toBeInTheDocument()
196
+ })
197
+ })
198
+
199
+ describe('CardInset', () => {
200
+ it('renders inset content', () => {
201
+ render(
202
+ <CardInset>
203
+ <span>Inset content</span>
204
+ </CardInset>
205
+ )
206
+ expect(screen.getByText('Inset content')).toBeInTheDocument()
207
+ })
208
+
209
+ it('accepts elevation prop', () => {
210
+ const { container } = render(
211
+ <CardInset elevation={-2}>
212
+ <span>Deep inset</span>
213
+ </CardInset>
214
+ )
215
+ expect(container).toBeInTheDocument()
216
+ })
217
+ })
218
+
219
+ describe('accent and subtle variants', () => {
220
+ it('renders accent variant', () => {
221
+ const { container } = render(
222
+ <Card variant="accent">
223
+ <CardContent>Accent card</CardContent>
224
+ </Card>
225
+ )
226
+ expect(container.firstChild).toBeInTheDocument()
227
+ })
228
+
229
+ it('renders subtle variant', () => {
230
+ const { container } = render(
231
+ <Card variant="subtle">
232
+ <CardContent>Subtle card</CardContent>
233
+ </Card>
234
+ )
235
+ expect(container.firstChild).toBeInTheDocument()
236
+ })
237
+
238
+ it('renders accent variant with custom accentColor and accentWidth', () => {
239
+ const { container } = render(
240
+ <Card variant="accent" accentColor="#FF0000" accentWidth={5}>
241
+ <CardContent>Custom accent</CardContent>
242
+ </Card>
243
+ )
244
+ expect(container.firstChild).toBeInTheDocument()
245
+ })
246
+ })
247
+
248
+ describe('custom styling', () => {
249
+ it('applies custom borderColor', () => {
250
+ const { container } = render(
251
+ <Card variant="outline" borderColor="#ff0000">
252
+ <CardContent>Red border</CardContent>
253
+ </Card>
254
+ )
255
+ expect(container).toBeInTheDocument()
256
+ })
257
+
258
+ it('applies custom bgColor', () => {
259
+ const { container } = render(
260
+ <Card bgColor="#00ff00">
261
+ <CardContent>Green background</CardContent>
262
+ </Card>
263
+ )
264
+ expect(container).toBeInTheDocument()
265
+ })
266
+ })
267
+
268
+ describe('accessibility', () => {
269
+ it('has no accessibility violations', async () => {
270
+ const { container } = render(
271
+ <Card>
272
+ <CardHeader>
273
+ <CardTitle>Accessible Card</CardTitle>
274
+ <CardDescription>Card description</CardDescription>
275
+ </CardHeader>
276
+ <CardContent>Card content</CardContent>
277
+ <CardFooter>Footer</CardFooter>
278
+ </Card>
279
+ )
280
+ const results = await axe(container)
281
+ expect(results).toHaveNoViolations()
282
+ })
283
+
284
+ it('has no accessibility violations when interactive', async () => {
285
+ const { container } = render(
286
+ <Card isInteractive onPress={() => {}}>
287
+ <CardContent>Interactive card</CardContent>
288
+ </Card>
289
+ )
290
+ const results = await axe(container)
291
+ expect(results).toHaveNoViolations()
292
+ })
293
+ })
294
+ })
@@ -0,0 +1,401 @@
1
+ import React, { useMemo, useState } from 'react'
2
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+ import {
5
+ getElevationSurface,
6
+ getElevationShadow,
7
+ getBaseSurfaceColor,
8
+ getValidatedElevation,
9
+ type ElevationLevel
10
+ } from '../../../theme'
11
+ import { useTheme } from '../../../utils/useTheme'
12
+
13
+ export type CardVariant = 'elevated' | 'outline' | 'filled' | 'accent' | 'subtle'
14
+ export type CardElevation = 1 | 2 | 3 // subtle, standard, prominent
15
+
16
+ export interface CardProps extends ViewProps {
17
+ /** Visual variant */
18
+ variant?: CardVariant
19
+ /** Elevation level (1=subtle, 2=standard, 3=prominent) */
20
+ elevation?: CardElevation
21
+ /** Whether the card is interactive (hoverable/pressable) */
22
+ isInteractive?: boolean
23
+ /** Whether the card is in a loading state (renders skeleton) */
24
+ isLoading?: boolean
25
+ /** Callback when card is pressed (makes card interactive) */
26
+ onPress?: () => void
27
+ /** Custom border color (hex, rgb, or CSS color). Useful for status cards. */
28
+ borderColor?: string
29
+ /** Custom background color (hex, rgb, or CSS color). Useful for colored cards. */
30
+ bgColor?: string
31
+ /** Accent stripe color for accent variant (CSS color or hex) */
32
+ accentColor?: string
33
+ /** Accent stripe width for accent variant in pixels (default: 3) */
34
+ accentWidth?: number
35
+ /** Additional className */
36
+ className?: string
37
+ children?: React.ReactNode
38
+ /** Show header skeleton when loading (default: true) */
39
+ skeletonHasHeader?: boolean
40
+ /** Show footer skeleton when loading (default: false) */
41
+ skeletonHasFooter?: boolean
42
+ /** Number of content lines in skeleton when loading (default: 3) */
43
+ skeletonContentLines?: number
44
+ }
45
+
46
+ const variantStyles: Record<CardVariant, string> = {
47
+ elevated: '', // Will be set dynamically via elevation system
48
+ outline: 'border-2 border-border-strong', // Thicker border with stronger contrast
49
+ filled: '', // Will be set dynamically via elevation system
50
+ accent: 'border border-border-default',
51
+ subtle: 'border border-border-subtle',
52
+ }
53
+
54
+ /**
55
+ * Card component for containing related content.
56
+ *
57
+ * @example
58
+ * <Card>
59
+ * <CardHeader>
60
+ * <CardTitle>Card Title</CardTitle>
61
+ * <CardDescription>Card description</CardDescription>
62
+ * </CardHeader>
63
+ * <CardContent>
64
+ * <Text>Card content goes here</Text>
65
+ * </CardContent>
66
+ * <CardFooter>
67
+ * <Button>Action</Button>
68
+ * </CardFooter>
69
+ * </Card>
70
+ *
71
+ * // Interactive card
72
+ * <Card isInteractive onPress={() => console.log('pressed')}>
73
+ * <CardContent>Click me</CardContent>
74
+ * </Card>
75
+ */
76
+ export function Card({
77
+ variant = 'elevated',
78
+ elevation = 2,
79
+ isInteractive = false,
80
+ isLoading = false,
81
+ onPress,
82
+ borderColor,
83
+ bgColor,
84
+ accentColor,
85
+ accentWidth,
86
+ className,
87
+ children,
88
+ style,
89
+ skeletonHasHeader = true,
90
+ skeletonHasFooter = false,
91
+ skeletonContentLines = 3,
92
+ ...props
93
+ }: CardProps) {
94
+ const isClickable = isInteractive || !!onPress
95
+ const [isHovered, setIsHovered] = useState(false)
96
+ const theme = useTheme()
97
+
98
+ // Get elevation level (validate and clamp to card's allowed range)
99
+ const elevationLevel = useMemo(() => {
100
+ const validated = getValidatedElevation('card', elevation as ElevationLevel)
101
+ // Map variant to elevation if needed
102
+ if (variant === 'outline' || variant === 'accent' || variant === 'subtle') {
103
+ return 1 as ElevationLevel // These border variants use subtle elevation
104
+ }
105
+ return validated
106
+ }, [variant, elevation])
107
+
108
+ // Get base surface color for theme
109
+ const baseColor = useMemo(() => getBaseSurfaceColor(theme), [theme])
110
+
111
+ // Calculate surface color and shadow from elevation
112
+ const surfaceColor = useMemo(() => {
113
+ // Outline variant in light mode uses a specific off-white for clear visibility
114
+ if ((variant === 'outline' || variant === 'accent') && theme === 'light') {
115
+ return '#FAFAFA' // Matches --color-surface-elevated in light mode
116
+ }
117
+ // Subtle variant uses the base surface color without elevation lift
118
+ if (variant === 'subtle') {
119
+ return getElevationSurface(baseColor, 0 as ElevationLevel, theme)
120
+ }
121
+ return getElevationSurface(baseColor, elevationLevel, theme)
122
+ }, [baseColor, elevationLevel, theme, variant])
123
+
124
+ const shadowStyle = useMemo(() => {
125
+ // Only apply hover shadow if card is actually clickable
126
+ const shouldHover = isHovered && isClickable
127
+ return getElevationShadow(baseColor, elevationLevel, theme, shouldHover)
128
+ }, [baseColor, elevationLevel, theme, isHovered, isClickable])
129
+
130
+ // Render skeleton content when loading, otherwise render children
131
+ const cardContent = isLoading ? (
132
+ <>
133
+ {skeletonHasHeader && (
134
+ <CardHeader>
135
+ <View className="h-5 w-1/3 bg-interactive-disabled rounded" />
136
+ <View className="mt-2 h-4 w-2/3 bg-interactive-disabled rounded" />
137
+ </CardHeader>
138
+ )}
139
+ <CardContent>
140
+ {Array.from({ length: skeletonContentLines }).map((_, i) => (
141
+ <View
142
+ key={i}
143
+ className={cn(
144
+ 'h-4 bg-interactive-disabled rounded',
145
+ i < skeletonContentLines - 1 ? 'mb-2 w-full' : 'w-4/5'
146
+ )}
147
+ />
148
+ ))}
149
+ </CardContent>
150
+ {skeletonHasFooter && (
151
+ <CardFooter>
152
+ <View className="h-9 w-24 bg-interactive-disabled rounded" />
153
+ </CardFooter>
154
+ )}
155
+ </>
156
+ ) : (
157
+ children
158
+ )
159
+
160
+ const baseClassName = cn(
161
+ 'rounded-lg overflow-hidden relative',
162
+ // Apply variant styles, but only use default border color if no custom borderColor
163
+ variant === 'outline'
164
+ ? borderColor
165
+ ? 'border-2' // Just the border width, color via style
166
+ : variantStyles[variant] // Full variant styles including color
167
+ : variantStyles[variant],
168
+ // Remove Tailwind shadow classes - we're using elevation shadows via style prop
169
+ isClickable && 'web:cursor-pointer web:transition-all web:duration-150',
170
+ isClickable && 'web:hover:-translate-y-0.5 active:scale-[0.99]',
171
+ isLoading && 'pointer-events-none animate-pulse',
172
+ className
173
+ )
174
+
175
+ // Accent variant: left stripe via borderLeft override
176
+ const accentStyle = useMemo(() => variant === 'accent' ? {
177
+ borderLeftWidth: accentWidth ?? 3,
178
+ borderLeftColor: accentColor ?? 'var(--color-brand-primary)',
179
+ } : {}, [variant, accentWidth, accentColor])
180
+
181
+ // Merge styles: background color from elevation + shadow style + custom colors + custom style
182
+ const mergedStyle = useMemo(() => {
183
+ const elevationStyle: Record<string, any> = {
184
+ backgroundColor: bgColor || surfaceColor,
185
+ borderRadius: 8,
186
+ overflow: 'hidden' as const,
187
+ ...shadowStyle,
188
+ ...accentStyle,
189
+ }
190
+
191
+ // Add custom border color if provided
192
+ if (borderColor) {
193
+ elevationStyle.borderColor = borderColor
194
+ }
195
+
196
+ if (!style) return elevationStyle
197
+ // User style takes precedence over elevation defaults (e.g. maxWidth, custom bg)
198
+ return [elevationStyle, style]
199
+ }, [style, surfaceColor, shadowStyle, borderColor, bgColor, accentStyle])
200
+
201
+ if (isClickable) {
202
+ return (
203
+ <Pressable
204
+ onPress={onPress}
205
+ onHoverIn={() => setIsHovered(true)}
206
+ onHoverOut={() => setIsHovered(false)}
207
+ accessibilityRole="button"
208
+ className={baseClassName}
209
+ style={mergedStyle}
210
+ {...props}
211
+ >
212
+ {cardContent}
213
+ </Pressable>
214
+ )
215
+ }
216
+
217
+ return (
218
+ <View className={baseClassName} style={mergedStyle} {...props}>
219
+ {cardContent}
220
+ </View>
221
+ )
222
+ }
223
+
224
+ export interface CardHeaderProps {
225
+ children?: React.ReactNode
226
+ className?: string
227
+ }
228
+
229
+ /**
230
+ * Header section of a Card.
231
+ */
232
+ export function CardHeader({ children, className }: CardHeaderProps) {
233
+ return (
234
+ <View className={cn('px-6 py-6', className)}>
235
+ {children}
236
+ </View>
237
+ )
238
+ }
239
+
240
+ export interface CardTitleProps {
241
+ children?: React.ReactNode
242
+ className?: string
243
+ }
244
+
245
+ /**
246
+ * Title for CardHeader.
247
+ */
248
+ export function CardTitle({ children, className }: CardTitleProps) {
249
+ return (
250
+ <Text
251
+ accessibilityRole="header"
252
+ className={cn('text-lg font-semibold text-text-primary font-heading', className)}
253
+ >
254
+ {children}
255
+ </Text>
256
+ )
257
+ }
258
+
259
+ export interface CardDescriptionProps {
260
+ children?: React.ReactNode
261
+ className?: string
262
+ }
263
+
264
+ /**
265
+ * Description for CardHeader.
266
+ */
267
+ export function CardDescription({ children, className }: CardDescriptionProps) {
268
+ return (
269
+ <Text className={cn('mt-1.5 text-sm text-text-secondary', className)}>
270
+ {children}
271
+ </Text>
272
+ )
273
+ }
274
+
275
+ export interface CardContentProps {
276
+ children?: React.ReactNode
277
+ className?: string
278
+ }
279
+
280
+ /**
281
+ * Content section of a Card.
282
+ */
283
+ export function CardContent({ children, className }: CardContentProps) {
284
+ return (
285
+ <View className={cn('px-6 py-4', className)}>
286
+ {children}
287
+ </View>
288
+ )
289
+ }
290
+
291
+ export interface CardFooterProps {
292
+ children?: React.ReactNode
293
+ className?: string
294
+ }
295
+
296
+ /**
297
+ * Footer section of a Card.
298
+ */
299
+ export function CardFooter({ children, className }: CardFooterProps) {
300
+ return (
301
+ <View className={cn('px-6 py-4 flex-row items-center gap-2', className)}>
302
+ {children}
303
+ </View>
304
+ )
305
+ }
306
+
307
+ export interface CardSkeletonProps {
308
+ /** Whether to show header skeleton */
309
+ hasHeader?: boolean
310
+ /** Whether to show footer skeleton */
311
+ hasFooter?: boolean
312
+ /** Number of content lines */
313
+ contentLines?: number
314
+ /** Additional className */
315
+ className?: string
316
+ }
317
+
318
+ /**
319
+ * Skeleton placeholder for Card loading state.
320
+ */
321
+ export function CardSkeleton({
322
+ hasHeader = true,
323
+ hasFooter = false,
324
+ contentLines = 3,
325
+ className,
326
+ }: CardSkeletonProps) {
327
+ return (
328
+ <Card variant="elevated" elevation={2} className={cn('animate-pulse', className)}>
329
+ {hasHeader && (
330
+ <CardHeader>
331
+ <View className="h-5 w-1/3 bg-interactive-disabled rounded" />
332
+ <View className="mt-2 h-4 w-2/3 bg-interactive-disabled rounded" />
333
+ </CardHeader>
334
+ )}
335
+ <CardContent>
336
+ {Array.from({ length: contentLines }).map((_, i) => (
337
+ <View
338
+ key={i}
339
+ className={cn(
340
+ 'h-4 bg-interactive-disabled rounded',
341
+ i < contentLines - 1 ? 'mb-2 w-full' : 'w-4/5'
342
+ )}
343
+ />
344
+ ))}
345
+ </CardContent>
346
+ {hasFooter && (
347
+ <CardFooter>
348
+ <View className="h-9 w-24 bg-interactive-disabled rounded" />
349
+ </CardFooter>
350
+ )}
351
+ </Card>
352
+ )
353
+ }
354
+
355
+ export interface CardInsetProps extends ViewProps {
356
+ /** Elevation level for inset (-2=deep, -1=shallow) */
357
+ elevation?: -2 | -1
358
+ /** Additional className */
359
+ className?: string
360
+ children?: React.ReactNode
361
+ }
362
+
363
+ /**
364
+ * Inset element within a Card. Applies pressed neumorphic shadow
365
+ * to create a sunken/recessed appearance.
366
+ *
367
+ * @example
368
+ * <Card elevation={2}>
369
+ * <CardContent>
370
+ * <CardInset elevation={-1}>
371
+ * <Text>This content appears inset/sunken</Text>
372
+ * </CardInset>
373
+ * </CardContent>
374
+ * </Card>
375
+ */
376
+ export function CardInset({
377
+ elevation = -1,
378
+ className,
379
+ children,
380
+ style,
381
+ ...props
382
+ }: CardInsetProps) {
383
+ const theme = useTheme()
384
+ const baseColor = useMemo(() => getBaseSurfaceColor(theme), [theme])
385
+ const surfaceColor = useMemo(() => {
386
+ return getElevationSurface(baseColor, elevation as ElevationLevel, theme)
387
+ }, [baseColor, elevation, theme])
388
+ const shadowStyle = useMemo(() => {
389
+ return getElevationShadow(baseColor, elevation as ElevationLevel, theme)
390
+ }, [baseColor, elevation, theme])
391
+
392
+ return (
393
+ <View
394
+ className={cn('rounded', className)}
395
+ style={[{ backgroundColor: surfaceColor }, shadowStyle, style]}
396
+ {...props}
397
+ >
398
+ {children}
399
+ </View>
400
+ )
401
+ }
@@ -0,0 +1,20 @@
1
+ export {
2
+ Card,
3
+ CardHeader,
4
+ CardTitle,
5
+ CardDescription,
6
+ CardContent,
7
+ CardFooter,
8
+ CardSkeleton,
9
+ } from './Card'
10
+ export type {
11
+ CardProps,
12
+ CardHeaderProps,
13
+ CardTitleProps,
14
+ CardDescriptionProps,
15
+ CardContentProps,
16
+ CardFooterProps,
17
+ CardSkeletonProps,
18
+ CardVariant,
19
+ CardElevation,
20
+ } from './Card'