@titan-design/react-ui 0.0.2 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-NA3EES23.mjs → chunk-MGW37MPO.mjs} +26 -4
- package/dist/chunk-MGW37MPO.mjs.map +1 -0
- package/dist/index.d.mts +115 -2
- package/dist/index.d.ts +115 -2
- package/dist/index.js +262 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +225 -3
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +124 -109
- package/dist/theme/index.d.ts +124 -109
- package/dist/theme/index.js +24 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1 -1
- package/package.json +12 -5
- package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
- package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
- package/src/components/custom/DateTime/DateTime.tsx +235 -0
- package/src/components/custom/DateTime/index.ts +2 -0
- package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
- package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
- package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
- package/src/components/custom/EmptyState/index.ts +2 -0
- package/src/components/custom/Metric/Metric.stories.tsx +133 -0
- package/src/components/custom/Metric/Metric.test.tsx +120 -0
- package/src/components/custom/Metric/Metric.tsx +92 -0
- package/src/components/custom/Metric/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
- package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
- package/src/components/custom/Sidebar/index.ts +18 -0
- package/src/components/custom/Table/Table.stories.tsx +481 -0
- package/src/components/custom/Table/Table.test.tsx +531 -0
- package/src/components/custom/Table/Table.tsx +696 -0
- package/src/components/custom/Table/index.ts +22 -0
- package/src/components/custom/Typography/Typography.stories.tsx +153 -0
- package/src/components/custom/Typography/Typography.test.tsx +107 -0
- package/src/components/custom/Typography/Typography.tsx +218 -0
- package/src/components/custom/Typography/index.ts +19 -0
- package/src/components/custom/index.ts +8 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
- package/src/components/custom/stepper/Stepper.test.tsx +230 -0
- package/src/components/custom/stepper/Stepper.tsx +250 -0
- package/src/components/custom/stepper/index.ts +10 -0
- package/src/components/index.ts +5 -0
- package/src/components/ui/alert/Alert.stories.tsx +154 -0
- package/src/components/ui/alert/Alert.test.tsx +202 -0
- package/src/components/ui/alert/Alert.tsx +170 -0
- package/src/components/ui/alert/index.ts +8 -0
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
- package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
- package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
- package/src/components/ui/autocomplete/index.ts +2 -0
- package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
- package/src/components/ui/avatar/Avatar.test.tsx +135 -0
- package/src/components/ui/avatar/Avatar.tsx +168 -0
- package/src/components/ui/avatar/index.ts +2 -0
- package/src/components/ui/badge/Badge.stories.tsx +100 -0
- package/src/components/ui/badge/Badge.test.tsx +86 -0
- package/src/components/ui/badge/Badge.tsx +105 -0
- package/src/components/ui/badge/index.ts +2 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
- package/src/components/ui/breadcrumbs/index.ts +2 -0
- package/src/components/ui/button/Button.stories.tsx +216 -0
- package/src/components/ui/button/Button.test.tsx +132 -0
- package/src/components/ui/button/Button.tsx +242 -0
- package/src/components/ui/button/index.ts +10 -0
- package/src/components/ui/card/Card.stories.tsx +443 -0
- package/src/components/ui/card/Card.test.tsx +265 -0
- package/src/components/ui/card/Card.tsx +380 -0
- package/src/components/ui/card/index.ts +20 -0
- package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
- package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
- package/src/components/ui/checkbox/Checkbox.tsx +163 -0
- package/src/components/ui/checkbox/index.ts +2 -0
- package/src/components/ui/chip/Chip.stories.tsx +112 -0
- package/src/components/ui/chip/Chip.test.tsx +119 -0
- package/src/components/ui/chip/Chip.tsx +151 -0
- package/src/components/ui/chip/index.ts +2 -0
- package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
- package/src/components/ui/collapse/Collapse.test.tsx +298 -0
- package/src/components/ui/collapse/Collapse.tsx +275 -0
- package/src/components/ui/collapse/index.ts +18 -0
- package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
- package/src/components/ui/data-row/DataRow.test.tsx +78 -0
- package/src/components/ui/data-row/DataRow.tsx +29 -0
- package/src/components/ui/data-row/index.ts +2 -0
- package/src/components/ui/divider/Divider.stories.tsx +71 -0
- package/src/components/ui/divider/Divider.test.tsx +55 -0
- package/src/components/ui/divider/Divider.tsx +37 -0
- package/src/components/ui/divider/index.ts +2 -0
- package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
- package/src/components/ui/drawer/Drawer.test.tsx +142 -0
- package/src/components/ui/drawer/Drawer.tsx +228 -0
- package/src/components/ui/drawer/index.ts +9 -0
- package/src/components/ui/form-field/FormField.stories.tsx +260 -0
- package/src/components/ui/form-field/FormField.test.tsx +280 -0
- package/src/components/ui/form-field/FormField.tsx +308 -0
- package/src/components/ui/form-field/index.ts +2 -0
- package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
- package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
- package/src/components/ui/help-tip/HelpTip.tsx +229 -0
- package/src/components/ui/help-tip/index.ts +2 -0
- package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
- package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
- package/src/components/ui/icon-box/IconBox.tsx +57 -0
- package/src/components/ui/icon-box/index.ts +1 -0
- package/src/components/ui/index.ts +36 -0
- package/src/components/ui/input/Input.stories.tsx +192 -0
- package/src/components/ui/input/Input.test.tsx +86 -0
- package/src/components/ui/input/Input.tsx +273 -0
- package/src/components/ui/input/index.ts +2 -0
- package/src/components/ui/link/Link.stories.tsx +78 -0
- package/src/components/ui/link/Link.test.tsx +94 -0
- package/src/components/ui/link/Link.tsx +86 -0
- package/src/components/ui/link/index.ts +2 -0
- package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
- package/src/components/ui/list-item/ListItem.test.tsx +128 -0
- package/src/components/ui/list-item/ListItem.tsx +89 -0
- package/src/components/ui/list-item/index.ts +14 -0
- package/src/components/ui/menu/Menu.stories.tsx +180 -0
- package/src/components/ui/menu/Menu.test.tsx +353 -0
- package/src/components/ui/menu/Menu.tsx +222 -0
- package/src/components/ui/menu/index.ts +2 -0
- package/src/components/ui/modal/Modal.stories.tsx +350 -0
- package/src/components/ui/modal/Modal.test.tsx +159 -0
- package/src/components/ui/modal/Modal.tsx +254 -0
- package/src/components/ui/modal/index.ts +19 -0
- package/src/components/ui/popover/Popover.stories.tsx +190 -0
- package/src/components/ui/popover/Popover.test.tsx +277 -0
- package/src/components/ui/popover/Popover.tsx +162 -0
- package/src/components/ui/popover/index.ts +2 -0
- package/src/components/ui/progress/Progress.stories.tsx +190 -0
- package/src/components/ui/progress/Progress.test.tsx +181 -0
- package/src/components/ui/progress/Progress.tsx +320 -0
- package/src/components/ui/progress/index.ts +9 -0
- package/src/components/ui/radio/Radio.stories.tsx +199 -0
- package/src/components/ui/radio/Radio.test.tsx +166 -0
- package/src/components/ui/radio/Radio.tsx +200 -0
- package/src/components/ui/radio/index.ts +2 -0
- package/src/components/ui/section/Section.stories.tsx +111 -0
- package/src/components/ui/section/Section.test.tsx +121 -0
- package/src/components/ui/section/Section.tsx +52 -0
- package/src/components/ui/section/index.ts +6 -0
- package/src/components/ui/select/Select.stories.tsx +286 -0
- package/src/components/ui/select/Select.test.tsx +180 -0
- package/src/components/ui/select/Select.tsx +236 -0
- package/src/components/ui/select/index.ts +2 -0
- package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
- package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
- package/src/components/ui/skeleton/Skeleton.tsx +229 -0
- package/src/components/ui/skeleton/index.ts +16 -0
- package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
- package/src/components/ui/spinner/Spinner.test.tsx +67 -0
- package/src/components/ui/spinner/Spinner.tsx +72 -0
- package/src/components/ui/spinner/index.ts +2 -0
- package/src/components/ui/stack/Stack.stories.tsx +127 -0
- package/src/components/ui/stack/Stack.test.tsx +184 -0
- package/src/components/ui/stack/Stack.tsx +76 -0
- package/src/components/ui/stack/index.ts +2 -0
- package/src/components/ui/surface/Surface.stories.tsx +91 -0
- package/src/components/ui/surface/Surface.test.tsx +63 -0
- package/src/components/ui/surface/Surface.tsx +51 -0
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/switch/Switch.stories.tsx +111 -0
- package/src/components/ui/switch/Switch.test.tsx +112 -0
- package/src/components/ui/switch/Switch.tsx +103 -0
- package/src/components/ui/switch/index.ts +2 -0
- package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
- package/src/components/ui/tabs/Tabs.test.tsx +192 -0
- package/src/components/ui/tabs/Tabs.tsx +253 -0
- package/src/components/ui/tabs/index.ts +16 -0
- package/src/components/ui/toast/Toast.stories.tsx +255 -0
- package/src/components/ui/toast/Toast.test.tsx +207 -0
- package/src/components/ui/toast/Toast.tsx +327 -0
- package/src/components/ui/toast/index.ts +8 -0
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
- package/src/components/ui/toolbar-button/index.ts +7 -0
- package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
- package/src/components/ui/tooltip/Tooltip.test.tsx +179 -0
- package/src/components/ui/tooltip/Tooltip.tsx +143 -0
- package/src/components/ui/tooltip/index.ts +2 -0
- package/src/index.ts +10 -0
- package/src/test/setup.ts +19 -0
- package/src/theme/Colors.stories.tsx +258 -0
- package/src/theme/config.ts +193 -0
- package/src/theme/elevation.stories.tsx +346 -0
- package/src/theme/elevation.test.ts +27 -0
- package/src/theme/elevation.ts +567 -0
- package/src/theme/index.ts +39 -0
- package/src/theme/shadows.stories.tsx +523 -0
- package/src/theme/shadows.ts +439 -0
- package/src/theme/tokens/index.ts +2 -0
- package/src/theme/tokens/primitives.ts +354 -0
- package/src/theme/tokens/semantic.ts +381 -0
- package/src/types/jest-axe.d.ts +16 -0
- package/src/types/nativewind.d.ts +54 -0
- package/src/utils/cn.ts +14 -0
- package/src/utils/colors.ts +140 -0
- package/src/utils/form.ts +188 -0
- package/src/utils/index.ts +29 -0
- package/src/utils/useTheme.ts +72 -0
- package/dist/chunk-NA3EES23.mjs.map +0 -1
|
@@ -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'
|