@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.
- package/README.md +47 -1
- package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
- package/dist/chunk-UXVRPOME.mjs.map +1 -0
- package/dist/index.d.mts +101 -12
- package/dist/index.d.ts +101 -12
- package/dist/index.js +1267 -468
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +734 -112
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +86 -1
- package/dist/theme/index.d.ts +86 -1
- package/dist/theme/index.js +166 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1 -1
- package/package.json +22 -6
- 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/Workout/RestTimer.stories.tsx +71 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
- package/src/components/custom/Workout/RestTimer.tsx +172 -0
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
- package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
- package/src/components/custom/Workout/index.ts +2 -0
- package/src/components/custom/index.ts +9 -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 +152 -0
- package/src/components/ui/avatar/Avatar.tsx +176 -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 +103 -0
- package/src/components/ui/badge/Badge.tsx +119 -0
- package/src/components/ui/badge/index.ts +3 -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 +297 -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 +294 -0
- package/src/components/ui/card/Card.tsx +401 -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 +38 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
- package/src/components/ui/indicator/Indicator.test.tsx +55 -0
- package/src/components/ui/indicator/Indicator.tsx +73 -0
- package/src/components/ui/indicator/index.ts +2 -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/pill/Pill.stories.tsx +71 -0
- package/src/components/ui/pill/Pill.test.tsx +69 -0
- package/src/components/ui/pill/Pill.tsx +104 -0
- package/src/components/ui/pill/index.ts +2 -0
- package/src/components/ui/popover/Popover.stories.tsx +190 -0
- package/src/components/ui/popover/Popover.test.tsx +419 -0
- package/src/components/ui/popover/Popover.tsx +237 -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 +210 -0
- package/src/components/ui/progress/Progress.tsx +339 -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 +200 -0
- package/src/components/ui/select/Select.tsx +243 -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 +9 -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 +262 -0
- package/src/components/ui/tooltip/Tooltip.tsx +234 -0
- package/src/components/ui/tooltip/index.ts +2 -0
- package/src/index.ts +10 -0
- package/src/stories/PresetShowcase.stories.tsx +462 -0
- package/src/stories/ThemePresets.stories.tsx +85 -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/global.css +129 -1
- package/src/theme/index.ts +40 -0
- package/src/theme/presets/apply.ts +97 -0
- package/src/theme/presets/audiobook.ts +93 -0
- package/src/theme/presets/default.ts +6 -0
- package/src/theme/presets/index.ts +4 -0
- package/src/theme/presets/presets.test.ts +51 -0
- package/src/theme/presets/types.ts +76 -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/avatar-color.test.ts +30 -0
- package/src/utils/avatar-color.ts +20 -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/src/web-jsx/jsx-dev-runtime.ts +28 -0
- package/src/web-jsx/jsx-runtime.ts +41 -0
- package/tailwind.config.js +53 -3
- package/dist/chunk-MGW37MPO.mjs.map +0 -1
|
@@ -0,0 +1,166 @@
|
|
|
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 { Radio, RadioGroup } from './Radio'
|
|
5
|
+
|
|
6
|
+
describe('Radio', () => {
|
|
7
|
+
it('renders RadioGroup with Radio children', () => {
|
|
8
|
+
render(
|
|
9
|
+
<RadioGroup value="a" onChange={() => {}}>
|
|
10
|
+
<Radio value="a">Option A</Radio>
|
|
11
|
+
<Radio value="b">Option B</Radio>
|
|
12
|
+
</RadioGroup>
|
|
13
|
+
)
|
|
14
|
+
expect(screen.getByText('Option A')).toBeInTheDocument()
|
|
15
|
+
expect(screen.getByText('Option B')).toBeInTheDocument()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('throws when Radio is used outside RadioGroup', () => {
|
|
19
|
+
expect(() => render(<Radio value="a">Orphan</Radio>)).toThrow(
|
|
20
|
+
'Radio must be used within a RadioGroup'
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('handles selection change', () => {
|
|
25
|
+
const onChange = vi.fn()
|
|
26
|
+
render(
|
|
27
|
+
<RadioGroup value="a" onChange={onChange}>
|
|
28
|
+
<Radio value="a">A</Radio>
|
|
29
|
+
<Radio value="b">B</Radio>
|
|
30
|
+
</RadioGroup>
|
|
31
|
+
)
|
|
32
|
+
fireEvent.click(screen.getAllByRole('radio')[1])
|
|
33
|
+
expect(onChange).toHaveBeenCalledWith('b')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('marks selected radio as checked', () => {
|
|
37
|
+
render(
|
|
38
|
+
<RadioGroup value="a" onChange={() => {}}>
|
|
39
|
+
<Radio value="a">A</Radio>
|
|
40
|
+
<Radio value="b">B</Radio>
|
|
41
|
+
</RadioGroup>
|
|
42
|
+
)
|
|
43
|
+
const radios = screen.getAllByRole('radio')
|
|
44
|
+
// react-native-web does not map accessibilityState.checked to aria-checked
|
|
45
|
+
expect(radios[0]).toBeInTheDocument()
|
|
46
|
+
expect(radios[1]).toBeInTheDocument()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('disables all radios when group isDisabled', () => {
|
|
50
|
+
const onChange = vi.fn()
|
|
51
|
+
render(
|
|
52
|
+
<RadioGroup value="a" onChange={onChange} isDisabled>
|
|
53
|
+
<Radio value="a">A</Radio>
|
|
54
|
+
<Radio value="b">B</Radio>
|
|
55
|
+
</RadioGroup>
|
|
56
|
+
)
|
|
57
|
+
const radios = screen.getAllByRole('radio')
|
|
58
|
+
radios.forEach((radio) => {
|
|
59
|
+
// react-native-web renders aria-disabled on a div, toBeDisabled() only works on form elements
|
|
60
|
+
expect(radio).toHaveAttribute('aria-disabled', 'true')
|
|
61
|
+
})
|
|
62
|
+
fireEvent.click(radios[1])
|
|
63
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('disables individual radio', () => {
|
|
67
|
+
const onChange = vi.fn()
|
|
68
|
+
render(
|
|
69
|
+
<RadioGroup value="a" onChange={onChange}>
|
|
70
|
+
<Radio value="a">A</Radio>
|
|
71
|
+
<Radio value="b" isDisabled>B</Radio>
|
|
72
|
+
</RadioGroup>
|
|
73
|
+
)
|
|
74
|
+
const radios = screen.getAllByRole('radio')
|
|
75
|
+
expect(radios[1]).toHaveAttribute('aria-disabled', 'true')
|
|
76
|
+
fireEvent.click(radios[1])
|
|
77
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('renders with all size options', () => {
|
|
81
|
+
const sizes = ['sm', 'md', 'lg'] as const
|
|
82
|
+
sizes.forEach((size) => {
|
|
83
|
+
const { unmount } = render(
|
|
84
|
+
<RadioGroup value="a" onChange={() => {}} size={size}>
|
|
85
|
+
<Radio value="a">A</Radio>
|
|
86
|
+
</RadioGroup>
|
|
87
|
+
)
|
|
88
|
+
expect(screen.getByRole('radio')).toBeInTheDocument()
|
|
89
|
+
unmount()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('renders with all color options', () => {
|
|
94
|
+
const colors = ['primary', 'secondary', 'success', 'error'] as const
|
|
95
|
+
colors.forEach((color) => {
|
|
96
|
+
const { unmount } = render(
|
|
97
|
+
<RadioGroup value="a" onChange={() => {}} color={color}>
|
|
98
|
+
<Radio value="a">A</Radio>
|
|
99
|
+
</RadioGroup>
|
|
100
|
+
)
|
|
101
|
+
expect(screen.getByRole('radio')).toBeInTheDocument()
|
|
102
|
+
unmount()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('renders with group label', () => {
|
|
107
|
+
render(
|
|
108
|
+
<RadioGroup value="a" onChange={() => {}} label="Pick one">
|
|
109
|
+
<Radio value="a">A</Radio>
|
|
110
|
+
</RadioGroup>
|
|
111
|
+
)
|
|
112
|
+
expect(screen.getByText('Pick one')).toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('renders horizontal orientation', () => {
|
|
116
|
+
const { container } = render(
|
|
117
|
+
<RadioGroup value="a" onChange={() => {}} orientation="horizontal">
|
|
118
|
+
<Radio value="a">A</Radio>
|
|
119
|
+
<Radio value="b">B</Radio>
|
|
120
|
+
</RadioGroup>
|
|
121
|
+
)
|
|
122
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('has radiogroup role on group', () => {
|
|
126
|
+
render(
|
|
127
|
+
<RadioGroup value="a" onChange={() => {}} label="Group">
|
|
128
|
+
<Radio value="a">A</Radio>
|
|
129
|
+
</RadioGroup>
|
|
130
|
+
)
|
|
131
|
+
expect(screen.getByRole('radiogroup')).toBeInTheDocument()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('allows Radio to override group size and color', () => {
|
|
135
|
+
render(
|
|
136
|
+
<RadioGroup value="a" onChange={() => {}} size="sm" color="primary">
|
|
137
|
+
<Radio value="a" size="lg" color="error">A</Radio>
|
|
138
|
+
</RadioGroup>
|
|
139
|
+
)
|
|
140
|
+
expect(screen.getByRole('radio')).toBeInTheDocument()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('accessibility', () => {
|
|
144
|
+
it('has no accessibility violations', async () => {
|
|
145
|
+
const { container } = render(
|
|
146
|
+
<RadioGroup value="a" onChange={() => {}} label="Options">
|
|
147
|
+
<Radio value="a">Option A</Radio>
|
|
148
|
+
<Radio value="b">Option B</Radio>
|
|
149
|
+
</RadioGroup>
|
|
150
|
+
)
|
|
151
|
+
const results = await axe(container, {
|
|
152
|
+
rules: { 'aria-required-attr': { enabled: false } },
|
|
153
|
+
})
|
|
154
|
+
expect(results).toHaveNoViolations()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('has correct radio role on items', () => {
|
|
158
|
+
render(
|
|
159
|
+
<RadioGroup value="a" onChange={() => {}}>
|
|
160
|
+
<Radio value="a">A</Radio>
|
|
161
|
+
</RadioGroup>
|
|
162
|
+
)
|
|
163
|
+
expect(screen.getByRole('radio')).toBeInTheDocument()
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react'
|
|
2
|
+
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export type RadioSize = 'sm' | 'md' | 'lg'
|
|
6
|
+
export type RadioColor = 'primary' | 'secondary' | 'success' | 'error'
|
|
7
|
+
|
|
8
|
+
interface RadioGroupContextType {
|
|
9
|
+
value: string | null
|
|
10
|
+
onChange: (value: string) => void
|
|
11
|
+
name?: string
|
|
12
|
+
isDisabled?: boolean
|
|
13
|
+
size?: RadioSize
|
|
14
|
+
color?: RadioColor
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const RadioGroupContext = createContext<RadioGroupContextType | null>(null)
|
|
18
|
+
|
|
19
|
+
export interface RadioGroupProps extends ViewProps {
|
|
20
|
+
/** Current selected value */
|
|
21
|
+
value: string | null
|
|
22
|
+
/** Callback when value changes */
|
|
23
|
+
onChange: (value: string) => void
|
|
24
|
+
/** Group name for form submission */
|
|
25
|
+
name?: string
|
|
26
|
+
/** Whether all radios are disabled */
|
|
27
|
+
isDisabled?: boolean
|
|
28
|
+
/** Size for all radios */
|
|
29
|
+
size?: RadioSize
|
|
30
|
+
/** Color for all radios */
|
|
31
|
+
color?: RadioColor
|
|
32
|
+
/** Orientation of the group */
|
|
33
|
+
orientation?: 'horizontal' | 'vertical'
|
|
34
|
+
/** Gap between items */
|
|
35
|
+
gap?: 'sm' | 'md' | 'lg'
|
|
36
|
+
/** Label for the group */
|
|
37
|
+
label?: string
|
|
38
|
+
/** Additional className */
|
|
39
|
+
className?: string
|
|
40
|
+
children?: React.ReactNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const gapStyles = {
|
|
44
|
+
sm: 'gap-2',
|
|
45
|
+
md: 'gap-3',
|
|
46
|
+
lg: 'gap-4',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* RadioGroup component for grouping radio buttons.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* <RadioGroup value={selected} onChange={setSelected}>
|
|
54
|
+
* <Radio value="option1">Option 1</Radio>
|
|
55
|
+
* <Radio value="option2">Option 2</Radio>
|
|
56
|
+
* <Radio value="option3">Option 3</Radio>
|
|
57
|
+
* </RadioGroup>
|
|
58
|
+
*/
|
|
59
|
+
export function RadioGroup({
|
|
60
|
+
value,
|
|
61
|
+
onChange,
|
|
62
|
+
name,
|
|
63
|
+
isDisabled = false,
|
|
64
|
+
size = 'md',
|
|
65
|
+
color = 'primary',
|
|
66
|
+
orientation = 'vertical',
|
|
67
|
+
gap = 'md',
|
|
68
|
+
label,
|
|
69
|
+
className,
|
|
70
|
+
children,
|
|
71
|
+
...props
|
|
72
|
+
}: RadioGroupProps) {
|
|
73
|
+
return (
|
|
74
|
+
<RadioGroupContext.Provider value={{ value, onChange, name, isDisabled, size, color }}>
|
|
75
|
+
<View
|
|
76
|
+
className={cn(className)}
|
|
77
|
+
accessibilityRole="radiogroup"
|
|
78
|
+
accessibilityLabel={label}
|
|
79
|
+
{...props}
|
|
80
|
+
>
|
|
81
|
+
{label && (
|
|
82
|
+
<Text className="text-sm font-medium text-text-primary mb-2">
|
|
83
|
+
{label}
|
|
84
|
+
</Text>
|
|
85
|
+
)}
|
|
86
|
+
<View
|
|
87
|
+
className={cn(
|
|
88
|
+
orientation === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col',
|
|
89
|
+
gapStyles[gap]
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
</View>
|
|
94
|
+
</View>
|
|
95
|
+
</RadioGroupContext.Provider>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface RadioProps extends ViewProps {
|
|
100
|
+
/** Value of this radio option */
|
|
101
|
+
value: string
|
|
102
|
+
/** Whether this radio is disabled */
|
|
103
|
+
isDisabled?: boolean
|
|
104
|
+
/** Size override */
|
|
105
|
+
size?: RadioSize
|
|
106
|
+
/** Color override */
|
|
107
|
+
color?: RadioColor
|
|
108
|
+
/** Additional className */
|
|
109
|
+
className?: string
|
|
110
|
+
children?: React.ReactNode
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sizeConfig: Record<RadioSize, { outer: string; inner: string; text: string }> = {
|
|
114
|
+
sm: { outer: 'w-4 h-4', inner: 'w-2 h-2', text: 'text-sm' },
|
|
115
|
+
md: { outer: 'w-5 h-5', inner: 'w-2.5 h-2.5', text: 'text-base' },
|
|
116
|
+
lg: { outer: 'w-6 h-6', inner: 'w-3 h-3', text: 'text-lg' },
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const colorStyles: Record<RadioColor, { checked: string; unchecked: string }> = {
|
|
120
|
+
primary: { checked: 'border-brand-primary bg-brand-primary', unchecked: 'border-border-strong' },
|
|
121
|
+
secondary: { checked: 'border-brand-secondary bg-brand-secondary', unchecked: 'border-border-strong' },
|
|
122
|
+
success: { checked: 'border-status-success bg-status-success', unchecked: 'border-border-strong' },
|
|
123
|
+
error: { checked: 'border-status-error bg-status-error', unchecked: 'border-border-strong' },
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Radio button component.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* <Radio value="option1">Option 1</Radio>
|
|
131
|
+
*/
|
|
132
|
+
export function Radio({
|
|
133
|
+
value,
|
|
134
|
+
isDisabled: radioDisabled,
|
|
135
|
+
size: radioSize,
|
|
136
|
+
color: radioColor,
|
|
137
|
+
className,
|
|
138
|
+
children,
|
|
139
|
+
...props
|
|
140
|
+
}: RadioProps) {
|
|
141
|
+
const context = useContext(RadioGroupContext)
|
|
142
|
+
|
|
143
|
+
if (!context) {
|
|
144
|
+
throw new Error('Radio must be used within a RadioGroup')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { value: selectedValue, onChange, isDisabled: groupDisabled, size: groupSize, color: groupColor } = context
|
|
148
|
+
|
|
149
|
+
const isDisabled = radioDisabled ?? groupDisabled ?? false
|
|
150
|
+
const size = radioSize ?? groupSize ?? 'md'
|
|
151
|
+
const color = radioColor ?? groupColor ?? 'primary'
|
|
152
|
+
const isChecked = selectedValue === value
|
|
153
|
+
|
|
154
|
+
const { outer, inner, text } = sizeConfig[size]
|
|
155
|
+
const { checked, unchecked } = colorStyles[color]
|
|
156
|
+
|
|
157
|
+
const handlePress = () => {
|
|
158
|
+
if (!isDisabled) {
|
|
159
|
+
onChange(value)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<Pressable
|
|
165
|
+
onPress={handlePress}
|
|
166
|
+
disabled={isDisabled}
|
|
167
|
+
accessibilityRole="radio"
|
|
168
|
+
accessibilityState={{ checked: isChecked, disabled: isDisabled }}
|
|
169
|
+
className={cn(
|
|
170
|
+
'flex-row items-center',
|
|
171
|
+
isDisabled && 'opacity-50',
|
|
172
|
+
className
|
|
173
|
+
)}
|
|
174
|
+
{...props}
|
|
175
|
+
>
|
|
176
|
+
{/* Radio circle */}
|
|
177
|
+
<View
|
|
178
|
+
className={cn(
|
|
179
|
+
'rounded-full border-2 items-center justify-center',
|
|
180
|
+
'transition-colors duration-150',
|
|
181
|
+
outer,
|
|
182
|
+
isChecked ? checked : unchecked,
|
|
183
|
+
!isDisabled && !isChecked && 'web:hover:border-brand-primary'
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{/* Inner dot */}
|
|
187
|
+
{isChecked && (
|
|
188
|
+
<View className={cn('rounded-full bg-white', inner)} />
|
|
189
|
+
)}
|
|
190
|
+
</View>
|
|
191
|
+
|
|
192
|
+
{/* Label */}
|
|
193
|
+
{children && (
|
|
194
|
+
<Text className={cn('ml-2 text-text-primary', text)}>
|
|
195
|
+
{children}
|
|
196
|
+
</Text>
|
|
197
|
+
)}
|
|
198
|
+
</Pressable>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import { Section, SectionHeader, SectionContent } from './Section'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Section> = {
|
|
6
|
+
title: 'Components/Section',
|
|
7
|
+
component: Section,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof Section>
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
render: () => (
|
|
16
|
+
<Section>
|
|
17
|
+
<SectionHeader title="Recent Workouts" />
|
|
18
|
+
<SectionContent>
|
|
19
|
+
<Text style={{ color: '#a0a0a0' }}>Workout items would go here</Text>
|
|
20
|
+
</SectionContent>
|
|
21
|
+
</Section>
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const WithSubtitle: Story = {
|
|
26
|
+
render: () => (
|
|
27
|
+
<Section>
|
|
28
|
+
<SectionHeader
|
|
29
|
+
title="Workout Stats"
|
|
30
|
+
subtitle="Last 7 days"
|
|
31
|
+
/>
|
|
32
|
+
<SectionContent>
|
|
33
|
+
<Text style={{ color: '#a0a0a0' }}>Stats content</Text>
|
|
34
|
+
</SectionContent>
|
|
35
|
+
</Section>
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const WithTrailingAction: Story = {
|
|
40
|
+
render: () => (
|
|
41
|
+
<Section>
|
|
42
|
+
<SectionHeader
|
|
43
|
+
title="Exercises"
|
|
44
|
+
trailing={
|
|
45
|
+
<Text style={{ color: '#6366f1', fontSize: 13 }}>View All</Text>
|
|
46
|
+
}
|
|
47
|
+
/>
|
|
48
|
+
<SectionContent>
|
|
49
|
+
<Text style={{ color: '#a0a0a0' }}>Exercise list</Text>
|
|
50
|
+
</SectionContent>
|
|
51
|
+
</Section>
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const FullExample: Story = {
|
|
56
|
+
render: () => (
|
|
57
|
+
<Section>
|
|
58
|
+
<SectionHeader
|
|
59
|
+
title="Performance"
|
|
60
|
+
subtitle="Average velocity by set"
|
|
61
|
+
trailing={
|
|
62
|
+
<Text style={{ color: '#6366f1', fontSize: 13 }}>Details</Text>
|
|
63
|
+
}
|
|
64
|
+
/>
|
|
65
|
+
<SectionContent className="rounded-lg bg-surface-secondary p-4">
|
|
66
|
+
<Text style={{ color: '#a0a0a0' }}>Chart or data visualization</Text>
|
|
67
|
+
</SectionContent>
|
|
68
|
+
</Section>
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const MultipleSections: Story = {
|
|
73
|
+
render: () => (
|
|
74
|
+
<View>
|
|
75
|
+
<Section>
|
|
76
|
+
<SectionHeader title="Today" subtitle="3 exercises" />
|
|
77
|
+
<SectionContent className="rounded-lg bg-surface-secondary p-4">
|
|
78
|
+
<Text style={{ color: '#a0a0a0' }}>Today's workout data</Text>
|
|
79
|
+
</SectionContent>
|
|
80
|
+
</Section>
|
|
81
|
+
|
|
82
|
+
<Section>
|
|
83
|
+
<SectionHeader
|
|
84
|
+
title="This Week"
|
|
85
|
+
subtitle="12 sets completed"
|
|
86
|
+
trailing={
|
|
87
|
+
<Text style={{ color: '#6366f1', fontSize: 13 }}>See All</Text>
|
|
88
|
+
}
|
|
89
|
+
/>
|
|
90
|
+
<SectionContent className="rounded-lg bg-surface-secondary p-4">
|
|
91
|
+
<Text style={{ color: '#a0a0a0' }}>Weekly summary</Text>
|
|
92
|
+
</SectionContent>
|
|
93
|
+
</Section>
|
|
94
|
+
|
|
95
|
+
<Section>
|
|
96
|
+
<SectionHeader title="Personal Records" />
|
|
97
|
+
<SectionContent className="rounded-lg bg-surface-secondary p-4">
|
|
98
|
+
<Text style={{ color: '#a0a0a0' }}>PR list</Text>
|
|
99
|
+
</SectionContent>
|
|
100
|
+
</Section>
|
|
101
|
+
</View>
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const HeaderOnly: Story = {
|
|
106
|
+
render: () => (
|
|
107
|
+
<Section>
|
|
108
|
+
<SectionHeader title="Section Title Only" />
|
|
109
|
+
</Section>
|
|
110
|
+
),
|
|
111
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Text } from 'react-native'
|
|
5
|
+
import { Section, SectionHeader, SectionContent } from './Section'
|
|
6
|
+
|
|
7
|
+
describe('Section', () => {
|
|
8
|
+
it('renders children correctly', () => {
|
|
9
|
+
render(
|
|
10
|
+
<Section>
|
|
11
|
+
<Text>Section content</Text>
|
|
12
|
+
</Section>
|
|
13
|
+
)
|
|
14
|
+
expect(screen.getByText('Section content')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('applies custom className', () => {
|
|
18
|
+
const { container } = render(
|
|
19
|
+
<Section className="mt-4" testID="section">
|
|
20
|
+
<Text>Content</Text>
|
|
21
|
+
</Section>
|
|
22
|
+
)
|
|
23
|
+
expect(screen.getByText('Content')).toBeInTheDocument()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('SectionHeader', () => {
|
|
28
|
+
it('renders title', () => {
|
|
29
|
+
render(<SectionHeader title="My Section" />)
|
|
30
|
+
expect(screen.getByText('My Section')).toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders subtitle when provided', () => {
|
|
34
|
+
render(<SectionHeader title="Title" subtitle="Subtitle text" />)
|
|
35
|
+
expect(screen.getByText('Title')).toBeInTheDocument()
|
|
36
|
+
expect(screen.getByText('Subtitle text')).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not render subtitle when not provided', () => {
|
|
40
|
+
render(<SectionHeader title="Title" />)
|
|
41
|
+
expect(screen.queryByText('Subtitle text')).not.toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('renders trailing action when provided', () => {
|
|
45
|
+
render(
|
|
46
|
+
<SectionHeader
|
|
47
|
+
title="Title"
|
|
48
|
+
trailing={<Text>View All</Text>}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
expect(screen.getByText('View All')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('does not render trailing when not provided', () => {
|
|
55
|
+
const { container } = render(<SectionHeader title="Title" />)
|
|
56
|
+
expect(screen.getByText('Title')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('applies custom className', () => {
|
|
60
|
+
render(<SectionHeader title="Title" className="px-4" />)
|
|
61
|
+
expect(screen.getByText('Title')).toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('SectionContent', () => {
|
|
66
|
+
it('renders children correctly', () => {
|
|
67
|
+
render(
|
|
68
|
+
<SectionContent>
|
|
69
|
+
<Text>Inner content</Text>
|
|
70
|
+
</SectionContent>
|
|
71
|
+
)
|
|
72
|
+
expect(screen.getByText('Inner content')).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('applies custom className', () => {
|
|
76
|
+
render(
|
|
77
|
+
<SectionContent className="p-4">
|
|
78
|
+
<Text>Styled content</Text>
|
|
79
|
+
</SectionContent>
|
|
80
|
+
)
|
|
81
|
+
expect(screen.getByText('Styled content')).toBeInTheDocument()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('Section compound component', () => {
|
|
86
|
+
it('composes all subcomponents together', () => {
|
|
87
|
+
render(
|
|
88
|
+
<Section>
|
|
89
|
+
<SectionHeader
|
|
90
|
+
title="Workout Stats"
|
|
91
|
+
subtitle="Last 7 days"
|
|
92
|
+
trailing={<Text>See All</Text>}
|
|
93
|
+
/>
|
|
94
|
+
<SectionContent>
|
|
95
|
+
<Text>Stats go here</Text>
|
|
96
|
+
</SectionContent>
|
|
97
|
+
</Section>
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
expect(screen.getByText('Workout Stats')).toBeInTheDocument()
|
|
101
|
+
expect(screen.getByText('Last 7 days')).toBeInTheDocument()
|
|
102
|
+
expect(screen.getByText('See All')).toBeInTheDocument()
|
|
103
|
+
expect(screen.getByText('Stats go here')).toBeInTheDocument()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('accessibility', () => {
|
|
107
|
+
it('has no accessibility violations', async () => {
|
|
108
|
+
const { container } = render(
|
|
109
|
+
<Section>
|
|
110
|
+
<SectionHeader title="Accessible Section" subtitle="With subtitle" />
|
|
111
|
+
<SectionContent>
|
|
112
|
+
<Text>Content</Text>
|
|
113
|
+
</SectionContent>
|
|
114
|
+
</Section>
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const results = await axe(container)
|
|
118
|
+
expect(results).toHaveNoViolations()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export interface SectionProps extends ViewProps {
|
|
6
|
+
className?: string
|
|
7
|
+
children: React.ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Section({ className, children, ...props }: SectionProps) {
|
|
11
|
+
return (
|
|
12
|
+
<View className={cn('mb-6', className)} {...props}>
|
|
13
|
+
{children}
|
|
14
|
+
</View>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SectionHeaderProps {
|
|
19
|
+
title: string
|
|
20
|
+
subtitle?: string
|
|
21
|
+
trailing?: React.ReactNode
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SectionHeader({ title, subtitle, trailing, className }: SectionHeaderProps) {
|
|
26
|
+
return (
|
|
27
|
+
<View className={cn('flex-row items-center justify-between mb-3 px-1', className)}>
|
|
28
|
+
<View className="flex-1">
|
|
29
|
+
<Text className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
|
30
|
+
{title}
|
|
31
|
+
</Text>
|
|
32
|
+
{subtitle && (
|
|
33
|
+
<Text className="text-xs text-text-tertiary mt-0.5">{subtitle}</Text>
|
|
34
|
+
)}
|
|
35
|
+
</View>
|
|
36
|
+
{trailing && <View>{trailing}</View>}
|
|
37
|
+
</View>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SectionContentProps extends ViewProps {
|
|
42
|
+
className?: string
|
|
43
|
+
children: React.ReactNode
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function SectionContent({ className, children, ...props }: SectionContentProps) {
|
|
47
|
+
return (
|
|
48
|
+
<View className={cn(className)} {...props}>
|
|
49
|
+
{children}
|
|
50
|
+
</View>
|
|
51
|
+
)
|
|
52
|
+
}
|