@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,265 @@
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('custom styling', () => {
220
+ it('applies custom borderColor', () => {
221
+ const { container } = render(
222
+ <Card variant="outline" borderColor="#ff0000">
223
+ <CardContent>Red border</CardContent>
224
+ </Card>
225
+ )
226
+ expect(container).toBeInTheDocument()
227
+ })
228
+
229
+ it('applies custom bgColor', () => {
230
+ const { container } = render(
231
+ <Card bgColor="#00ff00">
232
+ <CardContent>Green background</CardContent>
233
+ </Card>
234
+ )
235
+ expect(container).toBeInTheDocument()
236
+ })
237
+ })
238
+
239
+ describe('accessibility', () => {
240
+ it('has no accessibility violations', async () => {
241
+ const { container } = render(
242
+ <Card>
243
+ <CardHeader>
244
+ <CardTitle>Accessible Card</CardTitle>
245
+ <CardDescription>Card description</CardDescription>
246
+ </CardHeader>
247
+ <CardContent>Card content</CardContent>
248
+ <CardFooter>Footer</CardFooter>
249
+ </Card>
250
+ )
251
+ const results = await axe(container)
252
+ expect(results).toHaveNoViolations()
253
+ })
254
+
255
+ it('has no accessibility violations when interactive', async () => {
256
+ const { container } = render(
257
+ <Card isInteractive onPress={() => {}}>
258
+ <CardContent>Interactive card</CardContent>
259
+ </Card>
260
+ )
261
+ const results = await axe(container)
262
+ expect(results).toHaveNoViolations()
263
+ })
264
+ })
265
+ })
@@ -0,0 +1,380 @@
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'
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
+ /** Additional className */
32
+ className?: string
33
+ children?: React.ReactNode
34
+ /** Show header skeleton when loading (default: true) */
35
+ skeletonHasHeader?: boolean
36
+ /** Show footer skeleton when loading (default: false) */
37
+ skeletonHasFooter?: boolean
38
+ /** Number of content lines in skeleton when loading (default: 3) */
39
+ skeletonContentLines?: number
40
+ }
41
+
42
+ const variantStyles: Record<CardVariant, string> = {
43
+ elevated: '', // Will be set dynamically via elevation system
44
+ outline: 'border-2 border-border-strong', // Thicker border with stronger contrast
45
+ filled: '', // Will be set dynamically via elevation system
46
+ }
47
+
48
+ /**
49
+ * Card component for containing related content.
50
+ *
51
+ * @example
52
+ * <Card>
53
+ * <CardHeader>
54
+ * <CardTitle>Card Title</CardTitle>
55
+ * <CardDescription>Card description</CardDescription>
56
+ * </CardHeader>
57
+ * <CardContent>
58
+ * <Text>Card content goes here</Text>
59
+ * </CardContent>
60
+ * <CardFooter>
61
+ * <Button>Action</Button>
62
+ * </CardFooter>
63
+ * </Card>
64
+ *
65
+ * // Interactive card
66
+ * <Card isInteractive onPress={() => console.log('pressed')}>
67
+ * <CardContent>Click me</CardContent>
68
+ * </Card>
69
+ */
70
+ export function Card({
71
+ variant = 'elevated',
72
+ elevation = 2,
73
+ isInteractive = false,
74
+ isLoading = false,
75
+ onPress,
76
+ borderColor,
77
+ bgColor,
78
+ className,
79
+ children,
80
+ style,
81
+ skeletonHasHeader = true,
82
+ skeletonHasFooter = false,
83
+ skeletonContentLines = 3,
84
+ ...props
85
+ }: CardProps) {
86
+ const isClickable = isInteractive || !!onPress
87
+ const [isHovered, setIsHovered] = useState(false)
88
+ const theme = useTheme()
89
+
90
+ // Get elevation level (validate and clamp to card's allowed range)
91
+ const elevationLevel = useMemo(() => {
92
+ const validated = getValidatedElevation('card', elevation as ElevationLevel)
93
+ // Map variant to elevation if needed
94
+ if (variant === 'outline') {
95
+ return 1 as ElevationLevel // Outline uses subtle elevation
96
+ }
97
+ return validated
98
+ }, [variant, elevation])
99
+
100
+ // Get base surface color for theme
101
+ const baseColor = useMemo(() => getBaseSurfaceColor(theme), [theme])
102
+
103
+ // Calculate surface color and shadow from elevation
104
+ const surfaceColor = useMemo(() => {
105
+ // Outline variant in light mode uses a specific off-white for clear visibility
106
+ if (variant === 'outline' && theme === 'light') {
107
+ return '#FAFAFA' // Matches --color-surface-elevated in light mode
108
+ }
109
+ return getElevationSurface(baseColor, elevationLevel, theme)
110
+ }, [baseColor, elevationLevel, theme, variant])
111
+
112
+ const shadowStyle = useMemo(() => {
113
+ // Only apply hover shadow if card is actually clickable
114
+ const shouldHover = isHovered && isClickable
115
+ return getElevationShadow(baseColor, elevationLevel, theme, shouldHover)
116
+ }, [baseColor, elevationLevel, theme, isHovered, isClickable])
117
+
118
+ // Render skeleton content when loading, otherwise render children
119
+ const cardContent = isLoading ? (
120
+ <>
121
+ {skeletonHasHeader && (
122
+ <CardHeader>
123
+ <View className="h-5 w-1/3 bg-interactive-disabled rounded" />
124
+ <View className="mt-2 h-4 w-2/3 bg-interactive-disabled rounded" />
125
+ </CardHeader>
126
+ )}
127
+ <CardContent>
128
+ {Array.from({ length: skeletonContentLines }).map((_, i) => (
129
+ <View
130
+ key={i}
131
+ className={cn(
132
+ 'h-4 bg-interactive-disabled rounded',
133
+ i < skeletonContentLines - 1 ? 'mb-2 w-full' : 'w-4/5'
134
+ )}
135
+ />
136
+ ))}
137
+ </CardContent>
138
+ {skeletonHasFooter && (
139
+ <CardFooter>
140
+ <View className="h-9 w-24 bg-interactive-disabled rounded" />
141
+ </CardFooter>
142
+ )}
143
+ </>
144
+ ) : (
145
+ children
146
+ )
147
+
148
+ const baseClassName = cn(
149
+ 'rounded-lg overflow-hidden relative',
150
+ // Apply variant styles, but only use default border color if no custom borderColor
151
+ variant === 'outline'
152
+ ? borderColor
153
+ ? 'border-2' // Just the border width, color via style
154
+ : variantStyles[variant] // Full variant styles including color
155
+ : variantStyles[variant],
156
+ // Remove Tailwind shadow classes - we're using elevation shadows via style prop
157
+ isClickable && 'web:cursor-pointer web:transition-all web:duration-150',
158
+ isClickable && 'web:hover:-translate-y-0.5 active:scale-[0.99]',
159
+ isLoading && 'pointer-events-none animate-pulse',
160
+ className
161
+ )
162
+
163
+ // Merge styles: background color from elevation + shadow style + custom colors + custom style
164
+ const mergedStyle = useMemo(() => {
165
+ const elevationStyle: Record<string, any> = {
166
+ backgroundColor: bgColor || surfaceColor,
167
+ ...shadowStyle,
168
+ }
169
+
170
+ // Add custom border color if provided
171
+ if (borderColor) {
172
+ elevationStyle.borderColor = borderColor
173
+ }
174
+
175
+ if (!style) return elevationStyle
176
+ // Merge styles, with elevation style taking precedence for background/shadow
177
+ return [style, elevationStyle]
178
+ }, [style, surfaceColor, shadowStyle, borderColor, bgColor])
179
+
180
+ if (isClickable) {
181
+ return (
182
+ <Pressable
183
+ onPress={onPress}
184
+ onHoverIn={() => setIsHovered(true)}
185
+ onHoverOut={() => setIsHovered(false)}
186
+ accessibilityRole="button"
187
+ className={baseClassName}
188
+ style={mergedStyle}
189
+ {...props}
190
+ >
191
+ {cardContent}
192
+ </Pressable>
193
+ )
194
+ }
195
+
196
+ return (
197
+ <View className={baseClassName} style={mergedStyle} {...props}>
198
+ {cardContent}
199
+ </View>
200
+ )
201
+ }
202
+
203
+ export interface CardHeaderProps {
204
+ children?: React.ReactNode
205
+ className?: string
206
+ }
207
+
208
+ /**
209
+ * Header section of a Card.
210
+ */
211
+ export function CardHeader({ children, className }: CardHeaderProps) {
212
+ return (
213
+ <View className={cn('px-6 py-6', className)}>
214
+ {children}
215
+ </View>
216
+ )
217
+ }
218
+
219
+ export interface CardTitleProps {
220
+ children?: React.ReactNode
221
+ className?: string
222
+ }
223
+
224
+ /**
225
+ * Title for CardHeader.
226
+ */
227
+ export function CardTitle({ children, className }: CardTitleProps) {
228
+ return (
229
+ <Text
230
+ accessibilityRole="header"
231
+ className={cn('text-lg font-semibold text-text-primary font-heading', className)}
232
+ >
233
+ {children}
234
+ </Text>
235
+ )
236
+ }
237
+
238
+ export interface CardDescriptionProps {
239
+ children?: React.ReactNode
240
+ className?: string
241
+ }
242
+
243
+ /**
244
+ * Description for CardHeader.
245
+ */
246
+ export function CardDescription({ children, className }: CardDescriptionProps) {
247
+ return (
248
+ <Text className={cn('mt-1.5 text-sm text-text-secondary', className)}>
249
+ {children}
250
+ </Text>
251
+ )
252
+ }
253
+
254
+ export interface CardContentProps {
255
+ children?: React.ReactNode
256
+ className?: string
257
+ }
258
+
259
+ /**
260
+ * Content section of a Card.
261
+ */
262
+ export function CardContent({ children, className }: CardContentProps) {
263
+ return (
264
+ <View className={cn('px-6 py-4', className)}>
265
+ {children}
266
+ </View>
267
+ )
268
+ }
269
+
270
+ export interface CardFooterProps {
271
+ children?: React.ReactNode
272
+ className?: string
273
+ }
274
+
275
+ /**
276
+ * Footer section of a Card.
277
+ */
278
+ export function CardFooter({ children, className }: CardFooterProps) {
279
+ return (
280
+ <View className={cn('px-6 py-4 flex-row items-center gap-2', className)}>
281
+ {children}
282
+ </View>
283
+ )
284
+ }
285
+
286
+ export interface CardSkeletonProps {
287
+ /** Whether to show header skeleton */
288
+ hasHeader?: boolean
289
+ /** Whether to show footer skeleton */
290
+ hasFooter?: boolean
291
+ /** Number of content lines */
292
+ contentLines?: number
293
+ /** Additional className */
294
+ className?: string
295
+ }
296
+
297
+ /**
298
+ * Skeleton placeholder for Card loading state.
299
+ */
300
+ export function CardSkeleton({
301
+ hasHeader = true,
302
+ hasFooter = false,
303
+ contentLines = 3,
304
+ className,
305
+ }: CardSkeletonProps) {
306
+ return (
307
+ <Card variant="elevated" elevation={2} className={cn('animate-pulse', className)}>
308
+ {hasHeader && (
309
+ <CardHeader>
310
+ <View className="h-5 w-1/3 bg-interactive-disabled rounded" />
311
+ <View className="mt-2 h-4 w-2/3 bg-interactive-disabled rounded" />
312
+ </CardHeader>
313
+ )}
314
+ <CardContent>
315
+ {Array.from({ length: contentLines }).map((_, i) => (
316
+ <View
317
+ key={i}
318
+ className={cn(
319
+ 'h-4 bg-interactive-disabled rounded',
320
+ i < contentLines - 1 ? 'mb-2 w-full' : 'w-4/5'
321
+ )}
322
+ />
323
+ ))}
324
+ </CardContent>
325
+ {hasFooter && (
326
+ <CardFooter>
327
+ <View className="h-9 w-24 bg-interactive-disabled rounded" />
328
+ </CardFooter>
329
+ )}
330
+ </Card>
331
+ )
332
+ }
333
+
334
+ export interface CardInsetProps extends ViewProps {
335
+ /** Elevation level for inset (-2=deep, -1=shallow) */
336
+ elevation?: -2 | -1
337
+ /** Additional className */
338
+ className?: string
339
+ children?: React.ReactNode
340
+ }
341
+
342
+ /**
343
+ * Inset element within a Card. Applies pressed neumorphic shadow
344
+ * to create a sunken/recessed appearance.
345
+ *
346
+ * @example
347
+ * <Card elevation={2}>
348
+ * <CardContent>
349
+ * <CardInset elevation={-1}>
350
+ * <Text>This content appears inset/sunken</Text>
351
+ * </CardInset>
352
+ * </CardContent>
353
+ * </Card>
354
+ */
355
+ export function CardInset({
356
+ elevation = -1,
357
+ className,
358
+ children,
359
+ style,
360
+ ...props
361
+ }: CardInsetProps) {
362
+ const theme = useTheme()
363
+ const baseColor = useMemo(() => getBaseSurfaceColor(theme), [theme])
364
+ const surfaceColor = useMemo(() => {
365
+ return getElevationSurface(baseColor, elevation as ElevationLevel, theme)
366
+ }, [baseColor, elevation, theme])
367
+ const shadowStyle = useMemo(() => {
368
+ return getElevationShadow(baseColor, elevation as ElevationLevel, theme)
369
+ }, [baseColor, elevation, theme])
370
+
371
+ return (
372
+ <View
373
+ className={cn('rounded', className)}
374
+ style={[{ backgroundColor: surfaceColor }, shadowStyle, style]}
375
+ {...props}
376
+ >
377
+ {children}
378
+ </View>
379
+ )
380
+ }
@@ -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'