@titan-design/react-ui 0.1.0 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.md +47 -1
  2. package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
  3. package/dist/chunk-UXVRPOME.mjs.map +1 -0
  4. package/dist/index.d.mts +101 -12
  5. package/dist/index.d.ts +101 -12
  6. package/dist/index.js +1267 -468
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +734 -112
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/theme/index.d.mts +86 -1
  11. package/dist/theme/index.d.ts +86 -1
  12. package/dist/theme/index.js +166 -0
  13. package/dist/theme/index.js.map +1 -1
  14. package/dist/theme/index.mjs +1 -1
  15. package/package.json +22 -6
  16. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  17. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  18. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  19. package/src/components/custom/DateTime/index.ts +2 -0
  20. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  21. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  22. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  23. package/src/components/custom/EmptyState/index.ts +2 -0
  24. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  25. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  26. package/src/components/custom/Metric/Metric.tsx +92 -0
  27. package/src/components/custom/Metric/index.ts +2 -0
  28. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  29. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  30. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  31. package/src/components/custom/Sidebar/index.ts +18 -0
  32. package/src/components/custom/Table/Table.stories.tsx +481 -0
  33. package/src/components/custom/Table/Table.test.tsx +531 -0
  34. package/src/components/custom/Table/Table.tsx +696 -0
  35. package/src/components/custom/Table/index.ts +22 -0
  36. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  37. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  38. package/src/components/custom/Typography/Typography.tsx +218 -0
  39. package/src/components/custom/Typography/index.ts +19 -0
  40. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  42. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  43. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  44. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  45. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  46. package/src/components/custom/Workout/index.ts +2 -0
  47. package/src/components/custom/index.ts +9 -0
  48. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  49. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  50. package/src/components/custom/stepper/Stepper.tsx +250 -0
  51. package/src/components/custom/stepper/index.ts +10 -0
  52. package/src/components/index.ts +5 -0
  53. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  54. package/src/components/ui/alert/Alert.test.tsx +202 -0
  55. package/src/components/ui/alert/Alert.tsx +170 -0
  56. package/src/components/ui/alert/index.ts +8 -0
  57. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  58. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  59. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  60. package/src/components/ui/autocomplete/index.ts +2 -0
  61. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  62. package/src/components/ui/avatar/Avatar.test.tsx +152 -0
  63. package/src/components/ui/avatar/Avatar.tsx +176 -0
  64. package/src/components/ui/avatar/index.ts +2 -0
  65. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  66. package/src/components/ui/badge/Badge.test.tsx +103 -0
  67. package/src/components/ui/badge/Badge.tsx +119 -0
  68. package/src/components/ui/badge/index.ts +3 -0
  69. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  70. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  71. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  72. package/src/components/ui/breadcrumbs/index.ts +2 -0
  73. package/src/components/ui/button/Button.stories.tsx +216 -0
  74. package/src/components/ui/button/Button.test.tsx +132 -0
  75. package/src/components/ui/button/Button.tsx +297 -0
  76. package/src/components/ui/button/index.ts +10 -0
  77. package/src/components/ui/card/Card.stories.tsx +443 -0
  78. package/src/components/ui/card/Card.test.tsx +294 -0
  79. package/src/components/ui/card/Card.tsx +401 -0
  80. package/src/components/ui/card/index.ts +20 -0
  81. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  82. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  83. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  84. package/src/components/ui/checkbox/index.ts +2 -0
  85. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  86. package/src/components/ui/chip/Chip.test.tsx +119 -0
  87. package/src/components/ui/chip/Chip.tsx +151 -0
  88. package/src/components/ui/chip/index.ts +2 -0
  89. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  90. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  91. package/src/components/ui/collapse/Collapse.tsx +275 -0
  92. package/src/components/ui/collapse/index.ts +18 -0
  93. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  94. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  95. package/src/components/ui/data-row/DataRow.tsx +29 -0
  96. package/src/components/ui/data-row/index.ts +2 -0
  97. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  98. package/src/components/ui/divider/Divider.test.tsx +55 -0
  99. package/src/components/ui/divider/Divider.tsx +37 -0
  100. package/src/components/ui/divider/index.ts +2 -0
  101. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  102. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  103. package/src/components/ui/drawer/Drawer.tsx +228 -0
  104. package/src/components/ui/drawer/index.ts +9 -0
  105. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  106. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  107. package/src/components/ui/form-field/FormField.tsx +308 -0
  108. package/src/components/ui/form-field/index.ts +2 -0
  109. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  110. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  111. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  112. package/src/components/ui/help-tip/index.ts +2 -0
  113. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  114. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  115. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  116. package/src/components/ui/icon-box/index.ts +1 -0
  117. package/src/components/ui/index.ts +38 -0
  118. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  119. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  120. package/src/components/ui/indicator/Indicator.tsx +73 -0
  121. package/src/components/ui/indicator/index.ts +2 -0
  122. package/src/components/ui/input/Input.stories.tsx +192 -0
  123. package/src/components/ui/input/Input.test.tsx +86 -0
  124. package/src/components/ui/input/Input.tsx +273 -0
  125. package/src/components/ui/input/index.ts +2 -0
  126. package/src/components/ui/link/Link.stories.tsx +78 -0
  127. package/src/components/ui/link/Link.test.tsx +94 -0
  128. package/src/components/ui/link/Link.tsx +86 -0
  129. package/src/components/ui/link/index.ts +2 -0
  130. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  131. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  132. package/src/components/ui/list-item/ListItem.tsx +89 -0
  133. package/src/components/ui/list-item/index.ts +14 -0
  134. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  135. package/src/components/ui/menu/Menu.test.tsx +353 -0
  136. package/src/components/ui/menu/Menu.tsx +222 -0
  137. package/src/components/ui/menu/index.ts +2 -0
  138. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  139. package/src/components/ui/modal/Modal.test.tsx +159 -0
  140. package/src/components/ui/modal/Modal.tsx +254 -0
  141. package/src/components/ui/modal/index.ts +19 -0
  142. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  143. package/src/components/ui/pill/Pill.test.tsx +69 -0
  144. package/src/components/ui/pill/Pill.tsx +104 -0
  145. package/src/components/ui/pill/index.ts +2 -0
  146. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  147. package/src/components/ui/popover/Popover.test.tsx +419 -0
  148. package/src/components/ui/popover/Popover.tsx +237 -0
  149. package/src/components/ui/popover/index.ts +2 -0
  150. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  151. package/src/components/ui/progress/Progress.test.tsx +210 -0
  152. package/src/components/ui/progress/Progress.tsx +339 -0
  153. package/src/components/ui/progress/index.ts +9 -0
  154. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  155. package/src/components/ui/radio/Radio.test.tsx +166 -0
  156. package/src/components/ui/radio/Radio.tsx +200 -0
  157. package/src/components/ui/radio/index.ts +2 -0
  158. package/src/components/ui/section/Section.stories.tsx +111 -0
  159. package/src/components/ui/section/Section.test.tsx +121 -0
  160. package/src/components/ui/section/Section.tsx +52 -0
  161. package/src/components/ui/section/index.ts +6 -0
  162. package/src/components/ui/select/Select.stories.tsx +286 -0
  163. package/src/components/ui/select/Select.test.tsx +200 -0
  164. package/src/components/ui/select/Select.tsx +243 -0
  165. package/src/components/ui/select/index.ts +2 -0
  166. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  167. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  168. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  169. package/src/components/ui/skeleton/index.ts +16 -0
  170. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  171. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  172. package/src/components/ui/spinner/Spinner.tsx +72 -0
  173. package/src/components/ui/spinner/index.ts +2 -0
  174. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  175. package/src/components/ui/stack/Stack.test.tsx +184 -0
  176. package/src/components/ui/stack/Stack.tsx +76 -0
  177. package/src/components/ui/stack/index.ts +2 -0
  178. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  179. package/src/components/ui/surface/Surface.test.tsx +63 -0
  180. package/src/components/ui/surface/Surface.tsx +51 -0
  181. package/src/components/ui/surface/index.ts +1 -0
  182. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  183. package/src/components/ui/switch/Switch.test.tsx +112 -0
  184. package/src/components/ui/switch/Switch.tsx +103 -0
  185. package/src/components/ui/switch/index.ts +2 -0
  186. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  187. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  188. package/src/components/ui/tabs/Tabs.tsx +253 -0
  189. package/src/components/ui/tabs/index.ts +16 -0
  190. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  191. package/src/components/ui/toast/Toast.test.tsx +207 -0
  192. package/src/components/ui/toast/Toast.tsx +327 -0
  193. package/src/components/ui/toast/index.ts +9 -0
  194. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  195. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  196. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  197. package/src/components/ui/toolbar-button/index.ts +7 -0
  198. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  199. package/src/components/ui/tooltip/Tooltip.test.tsx +262 -0
  200. package/src/components/ui/tooltip/Tooltip.tsx +234 -0
  201. package/src/components/ui/tooltip/index.ts +2 -0
  202. package/src/index.ts +10 -0
  203. package/src/stories/PresetShowcase.stories.tsx +462 -0
  204. package/src/stories/ThemePresets.stories.tsx +85 -0
  205. package/src/test/setup.ts +19 -0
  206. package/src/theme/Colors.stories.tsx +258 -0
  207. package/src/theme/config.ts +193 -0
  208. package/src/theme/elevation.stories.tsx +346 -0
  209. package/src/theme/elevation.test.ts +27 -0
  210. package/src/theme/elevation.ts +567 -0
  211. package/src/theme/global.css +129 -1
  212. package/src/theme/index.ts +40 -0
  213. package/src/theme/presets/apply.ts +97 -0
  214. package/src/theme/presets/audiobook.ts +93 -0
  215. package/src/theme/presets/default.ts +6 -0
  216. package/src/theme/presets/index.ts +4 -0
  217. package/src/theme/presets/presets.test.ts +51 -0
  218. package/src/theme/presets/types.ts +76 -0
  219. package/src/theme/shadows.stories.tsx +523 -0
  220. package/src/theme/shadows.ts +439 -0
  221. package/src/theme/tokens/index.ts +2 -0
  222. package/src/theme/tokens/primitives.ts +354 -0
  223. package/src/theme/tokens/semantic.ts +381 -0
  224. package/src/types/jest-axe.d.ts +16 -0
  225. package/src/types/nativewind.d.ts +54 -0
  226. package/src/utils/avatar-color.test.ts +30 -0
  227. package/src/utils/avatar-color.ts +20 -0
  228. package/src/utils/cn.ts +14 -0
  229. package/src/utils/colors.ts +140 -0
  230. package/src/utils/form.ts +188 -0
  231. package/src/utils/index.ts +29 -0
  232. package/src/utils/useTheme.ts +72 -0
  233. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  234. package/src/web-jsx/jsx-runtime.ts +41 -0
  235. package/tailwind.config.js +53 -3
  236. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -0,0 +1,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,2 @@
1
+ export { Radio, RadioGroup } from './Radio'
2
+ export type { RadioProps, RadioGroupProps, RadioSize, RadioColor } from './Radio'
@@ -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&apos;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
+ }
@@ -0,0 +1,6 @@
1
+ export { Section, SectionHeader, SectionContent } from './Section'
2
+ export type {
3
+ SectionProps,
4
+ SectionHeaderProps,
5
+ SectionContentProps,
6
+ } from './Section'