@titan-design/react-ui 0.2.6 → 0.2.7

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 (157) hide show
  1. package/dist/chunk-3VLOBS6O.mjs +846 -0
  2. package/dist/chunk-3VLOBS6O.mjs.map +1 -0
  3. package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
  4. package/dist/chunk-5SSKGS6E.mjs.map +1 -0
  5. package/dist/index.d.mts +1005 -4
  6. package/dist/index.d.ts +1005 -4
  7. package/dist/index.js +6043 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5901 -177
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/theme/index.d.mts +57 -1
  12. package/dist/theme/index.d.ts +57 -1
  13. package/dist/theme/index.js +63 -0
  14. package/dist/theme/index.js.map +1 -1
  15. package/dist/theme/index.mjs +2 -1
  16. package/dist/theme/tokens-css.d.mts +18 -0
  17. package/dist/theme/tokens-css.d.ts +18 -0
  18. package/dist/theme/tokens-css.js +289 -0
  19. package/dist/theme/tokens-css.js.map +1 -0
  20. package/dist/theme/tokens-css.mjs +24 -0
  21. package/dist/theme/tokens-css.mjs.map +1 -0
  22. package/package.json +16 -5
  23. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  24. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  25. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  26. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  27. package/src/components/custom/Gauge/index.ts +2 -0
  28. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  29. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  30. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  31. package/src/components/custom/Scatter/index.ts +2 -0
  32. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  33. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  34. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  35. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  36. package/src/components/custom/Treemap/index.ts +2 -0
  37. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  38. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  39. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  40. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  41. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  42. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  43. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  46. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  47. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  48. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  49. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  50. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  51. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  52. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  54. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  55. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  56. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  57. package/src/components/custom/Workout/InputBar.tsx +163 -0
  58. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  59. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  60. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  61. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  62. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  63. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  64. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  65. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  66. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  67. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  68. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  69. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  70. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  73. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  76. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  77. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  78. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  79. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  80. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  81. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  82. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  83. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  84. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  85. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  86. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  87. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  88. package/src/components/custom/Workout/SetRow.tsx +253 -0
  89. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  90. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  91. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  92. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  93. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  94. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  95. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  98. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  99. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  100. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  101. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  102. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  103. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  104. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  105. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  106. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  107. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  108. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  109. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  110. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  111. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  112. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  113. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  114. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  115. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  116. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  117. package/src/components/custom/Workout/index.ts +113 -0
  118. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  119. package/src/components/custom/index.ts +3 -0
  120. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  121. package/src/components/custom/stepper/Stepper.tsx +2 -2
  122. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  123. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  124. package/src/components/ui/card/Card.tsx +1 -1
  125. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  126. package/src/components/ui/collapse/Collapse.tsx +1 -1
  127. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  128. package/src/components/ui/drawer/Drawer.tsx +3 -3
  129. package/src/components/ui/form-field/FormField.tsx +1 -1
  130. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  131. package/src/components/ui/menu/Menu.tsx +2 -2
  132. package/src/components/ui/pill/Pill.tsx +1 -1
  133. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  134. package/src/components/ui/popover/Popover.tsx +1 -1
  135. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  136. package/src/components/ui/section/Section.stories.tsx +4 -4
  137. package/src/components/ui/select/Select.tsx +1 -1
  138. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  139. package/src/components/ui/tabs/Tabs.tsx +2 -2
  140. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  141. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  142. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  143. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  144. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  145. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  146. package/src/stories/PresetShowcase.stories.tsx +15 -15
  147. package/src/theme/index.ts +2 -1
  148. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  149. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  150. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  151. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  152. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  153. package/src/theme/manifest/index.ts +11 -0
  154. package/src/theme/tokens-css.test.ts +28 -0
  155. package/src/theme/tokens-css.ts +34 -0
  156. package/src/theme/workout-tokens.ts +61 -0
  157. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,222 @@
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 { SetRow } from './SetRow'
5
+
6
+ const baseProps = {
7
+ mode: 'completed' as const,
8
+ setNumber: 1,
9
+ reps: 8,
10
+ weight: 135,
11
+ unit: 'lbs' as const,
12
+ }
13
+
14
+ describe('SetRow', () => {
15
+ describe('completed mode', () => {
16
+ it('renders set number, reps, weight', () => {
17
+ render(<SetRow {...baseProps} />)
18
+ expect(screen.getByTestId('set-row')).toBeInTheDocument()
19
+ expect(screen.getByTestId('set-row-set-number')).toHaveTextContent('1')
20
+ expect(screen.getByTestId('set-row-reps')).toHaveTextContent('8')
21
+ expect(screen.getByTestId('set-row-weight')).toHaveTextContent('135')
22
+ })
23
+
24
+ it('displays previous data when provided', () => {
25
+ render(<SetRow {...baseProps} previous={{ reps: 6, weight: 130 }} />)
26
+ expect(screen.getByTestId('set-row-previous')).toHaveTextContent(
27
+ '6 x 130',
28
+ )
29
+ })
30
+
31
+ it('displays dash when no previous data', () => {
32
+ render(<SetRow {...baseProps} />)
33
+ expect(screen.getByTestId('set-row-previous')).toHaveTextContent('\u2014')
34
+ })
35
+
36
+ it('applies reduced opacity for non-next completed sets', () => {
37
+ render(<SetRow {...baseProps} />)
38
+ const row = screen.getByTestId('set-row')
39
+ expect(row).toHaveStyle({ opacity: 0.55 })
40
+ })
41
+ })
42
+
43
+ describe('active mode', () => {
44
+ it('renders target values when reps/weight are null', () => {
45
+ render(
46
+ <SetRow
47
+ mode="active"
48
+ setNumber={2}
49
+ reps={null}
50
+ weight={null}
51
+ unit="lbs"
52
+ targets={{ reps: 10, weight: 150 }}
53
+ />,
54
+ )
55
+ expect(screen.getByTestId('set-row-target-reps')).toHaveTextContent('10')
56
+ expect(screen.getByTestId('set-row-target-weight')).toHaveTextContent(
57
+ '150',
58
+ )
59
+ })
60
+
61
+ it('renders dashes when no targets and values are null', () => {
62
+ render(
63
+ <SetRow mode="active" setNumber={2} reps={null} weight={null} unit="lbs" />,
64
+ )
65
+ expect(screen.getByTestId('set-row-reps')).toHaveTextContent('\u2014')
66
+ expect(screen.getByTestId('set-row-weight')).toHaveTextContent('\u2014')
67
+ })
68
+ })
69
+
70
+ describe('isNextSet highlighting', () => {
71
+ it('applies highlight styles for active isNextSet', () => {
72
+ render(
73
+ <SetRow
74
+ mode="active"
75
+ setNumber={1}
76
+ reps={null}
77
+ weight={null}
78
+ unit="lbs"
79
+ isNextSet
80
+ />,
81
+ )
82
+ const row = screen.getByTestId('set-row')
83
+ const style = row.getAttribute('style') ?? ''
84
+ expect(style).toContain('background-color')
85
+ expect(style).toContain('border')
86
+ })
87
+
88
+ it('does not apply highlight for non-next active sets', () => {
89
+ render(
90
+ <SetRow mode="active" setNumber={1} reps={null} weight={null} unit="lbs" />,
91
+ )
92
+ const row = screen.getByTestId('set-row')
93
+ expect(row).not.toHaveStyle({
94
+ backgroundColor: 'rgba(255,121,0,0.06)',
95
+ })
96
+ })
97
+ })
98
+
99
+ describe('history mode', () => {
100
+ it('renders like completed mode', () => {
101
+ render(
102
+ <SetRow mode="history" setNumber={3} reps={5} weight={200} unit="kg" />,
103
+ )
104
+ expect(screen.getByTestId('set-row-reps')).toHaveTextContent('5')
105
+ expect(screen.getByTestId('set-row-weight')).toHaveTextContent('200')
106
+ })
107
+ })
108
+
109
+ describe('velocity strip', () => {
110
+ it('renders velocity strip when velocities provided', () => {
111
+ render(
112
+ <SetRow {...baseProps} velocities={[1.1, 0.95, 0.82]} />,
113
+ )
114
+ expect(screen.getByTestId('set-row-velocity-strip')).toBeInTheDocument()
115
+ })
116
+
117
+ it('does not render velocity strip when no velocities', () => {
118
+ render(<SetRow {...baseProps} />)
119
+ expect(
120
+ screen.queryByTestId('set-row-velocity-strip'),
121
+ ).not.toBeInTheDocument()
122
+ })
123
+
124
+ it('passes onVelocityToggle to velocity strip', () => {
125
+ const onToggle = vi.fn()
126
+ render(
127
+ <SetRow
128
+ {...baseProps}
129
+ velocities={[1.1, 0.95]}
130
+ onVelocityToggle={onToggle}
131
+ />,
132
+ )
133
+ const pressable = screen.getByTestId('velocity-strip-pressable')
134
+ fireEvent.click(pressable)
135
+ expect(onToggle).toHaveBeenCalledOnce()
136
+ })
137
+ })
138
+
139
+ describe('set type badge', () => {
140
+ it('renders set type badge instead of set number', () => {
141
+ render(<SetRow {...baseProps} setType="W" />)
142
+ expect(screen.getByTestId('set-row-type-badge')).toHaveTextContent('W')
143
+ })
144
+
145
+ it('does not render type badge when setType is not provided', () => {
146
+ render(<SetRow {...baseProps} />)
147
+ expect(
148
+ screen.queryByTestId('set-row-type-badge'),
149
+ ).not.toBeInTheDocument()
150
+ })
151
+ })
152
+
153
+ describe('PR badges', () => {
154
+ it('renders PR badges when provided', () => {
155
+ render(
156
+ <SetRow
157
+ {...baseProps}
158
+ prBadges={[{ type: 'e1rm', label: 'PR e1RM' }]}
159
+ />,
160
+ )
161
+ expect(screen.getByTestId('set-row-pr-badges')).toBeInTheDocument()
162
+ })
163
+
164
+ it('does not render PR badges container when no badges', () => {
165
+ render(<SetRow {...baseProps} />)
166
+ expect(
167
+ screen.queryByTestId('set-row-pr-badges'),
168
+ ).not.toBeInTheDocument()
169
+ })
170
+ })
171
+
172
+ describe('RPE', () => {
173
+ it('displays RPE value with color coding', () => {
174
+ render(<SetRow {...baseProps} rpe={9.5} />)
175
+ expect(screen.getByTestId('set-row-rpe')).toHaveTextContent('9.5')
176
+ })
177
+
178
+ it('displays dash when RPE is null', () => {
179
+ render(<SetRow {...baseProps} rpe={null} />)
180
+ expect(screen.getByTestId('set-row-rpe')).toHaveTextContent('\u2014')
181
+ })
182
+ })
183
+
184
+ describe('accessibility', () => {
185
+ it('has correct accessibility label', () => {
186
+ render(<SetRow {...baseProps} />)
187
+ expect(
188
+ screen.getByLabelText('Set 1: 8 reps at 135 lbs'),
189
+ ).toBeInTheDocument()
190
+ })
191
+
192
+ it('has correct accessibility label with null values', () => {
193
+ render(
194
+ <SetRow mode="active" setNumber={2} reps={null} weight={null} unit="kg" />,
195
+ )
196
+ expect(
197
+ screen.getByLabelText('Set 2: no reps'),
198
+ ).toBeInTheDocument()
199
+ })
200
+
201
+ it('has no accessibility violations', async () => {
202
+ const { container } = render(<SetRow {...baseProps} />)
203
+ const results = await axe(container)
204
+ expect(results).toHaveNoViolations()
205
+ })
206
+
207
+ it('has no accessibility violations with all features', async () => {
208
+ const { container } = render(
209
+ <SetRow
210
+ {...baseProps}
211
+ rpe={8}
212
+ velocities={[1.1, 0.95]}
213
+ setType="W"
214
+ prBadges={[{ type: 'e1rm', label: 'PR e1RM' }]}
215
+ previous={{ reps: 6, weight: 130 }}
216
+ />,
217
+ )
218
+ const results = await axe(container)
219
+ expect(results).toHaveNoViolations()
220
+ })
221
+ })
222
+ })
@@ -0,0 +1,253 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text } from 'react-native'
3
+ import { VelocityStrip } from './VelocityStrip'
4
+ import { PrBadge, type PRType } from './PrBadge'
5
+
6
+ export type SetRowMode = 'active' | 'completed' | 'history'
7
+
8
+ export interface SetRowProps {
9
+ mode: SetRowMode
10
+ setNumber: number
11
+ previous?: { reps: number; weight: number } | null
12
+ reps: number | null
13
+ weight: number | null
14
+ rpe?: number | null
15
+ unit: 'lbs' | 'kg'
16
+ velocities?: number[]
17
+ velocityExpanded?: boolean
18
+ onVelocityToggle?: () => void
19
+ setType?: string
20
+ prBadges?: Array<{ type: PRType; label: string }>
21
+ isNextSet?: boolean
22
+ targets?: { reps: number; weight: number }
23
+ }
24
+
25
+ function getRPEColor(rpe: number): string {
26
+ if (rpe >= 9.5) return '#ff4757'
27
+ if (rpe >= 8.5) return '#ffa502'
28
+ if (rpe >= 7) return '#ffd43b'
29
+ return '#2ed573'
30
+ }
31
+
32
+ function formatAccessibilityLabel(
33
+ setNumber: number,
34
+ reps: number | null,
35
+ weight: number | null,
36
+ unit: string,
37
+ ): string {
38
+ const repsText = reps != null ? `${reps} reps` : 'no reps'
39
+ const weightText = weight != null ? `at ${weight} ${unit}` : ''
40
+ return `Set ${setNumber}: ${repsText}${weightText ? ` ${weightText}` : ''}`
41
+ }
42
+
43
+ export function SetRow({
44
+ mode,
45
+ setNumber,
46
+ previous,
47
+ reps,
48
+ weight,
49
+ rpe,
50
+ unit,
51
+ velocities,
52
+ velocityExpanded,
53
+ onVelocityToggle,
54
+ setType,
55
+ prBadges,
56
+ isNextSet,
57
+ targets,
58
+ }: SetRowProps) {
59
+ const isActive = mode === 'active'
60
+ const isCompleted = mode === 'completed'
61
+
62
+ const rowStyle: Record<string, unknown> = {
63
+ flexDirection: 'row' as const,
64
+ alignItems: 'center' as const,
65
+ padding: 7,
66
+ paddingHorizontal: 8,
67
+ gap: 8,
68
+ borderRadius: 8,
69
+ }
70
+
71
+ if (isActive && isNextSet) {
72
+ rowStyle.backgroundColor = 'rgba(255,121,0,0.06)'
73
+ rowStyle.borderWidth = 1
74
+ rowStyle.borderColor = 'rgba(255,121,0,0.15)'
75
+ }
76
+
77
+ if (isCompleted && !isNextSet) {
78
+ rowStyle.opacity = 0.55
79
+ }
80
+
81
+ return (
82
+ <View
83
+ style={rowStyle}
84
+ accessibilityLabel={formatAccessibilityLabel(
85
+ setNumber,
86
+ reps,
87
+ weight,
88
+ unit,
89
+ )}
90
+ testID="set-row"
91
+ >
92
+ <View
93
+ style={{ width: 36, alignItems: 'center', justifyContent: 'center' }}
94
+ testID="set-row-set-number"
95
+ >
96
+ {setType ? (
97
+ <Text
98
+ style={{
99
+ fontSize: 10,
100
+ fontWeight: '700',
101
+ fontFamily: 'Inter, sans-serif',
102
+ color: '#ffa502',
103
+ backgroundColor: 'rgba(255,165,2,0.12)',
104
+ paddingVertical: 1,
105
+ paddingHorizontal: 5,
106
+ borderRadius: 3,
107
+ overflow: 'hidden',
108
+ }}
109
+ testID="set-row-type-badge"
110
+ >
111
+ {setType}
112
+ </Text>
113
+ ) : (
114
+ <Text
115
+ style={{
116
+ fontSize: 13,
117
+ fontWeight: '600',
118
+ fontFamily: 'Inter, sans-serif',
119
+ color: '#9CA3AF',
120
+ }}
121
+ >
122
+ {setNumber}
123
+ </Text>
124
+ )}
125
+ </View>
126
+
127
+ <View
128
+ className="flex-1"
129
+ testID="set-row-previous"
130
+ >
131
+ <Text
132
+ style={{
133
+ fontSize: 12,
134
+ fontFamily: 'Inter, sans-serif',
135
+ color: '#9CA3AF',
136
+ }}
137
+ >
138
+ {previous
139
+ ? `${previous.reps} x ${previous.weight}`
140
+ : '\u2014'}
141
+ </Text>
142
+ </View>
143
+
144
+ <View
145
+ style={{ width: 44, alignItems: 'center', justifyContent: 'center' }}
146
+ testID="set-row-reps"
147
+ >
148
+ {isActive && reps == null && targets ? (
149
+ <Text
150
+ style={{
151
+ fontSize: 13,
152
+ fontStyle: 'italic',
153
+ fontFamily: 'Inter, sans-serif',
154
+ color: '#6B7280',
155
+ }}
156
+ testID="set-row-target-reps"
157
+ >
158
+ {targets.reps}
159
+ </Text>
160
+ ) : (
161
+ <Text
162
+ style={{
163
+ fontSize: 14,
164
+ fontWeight: '600',
165
+ fontFamily: 'Inter, sans-serif',
166
+ color: reps != null ? '#F3F4F6' : '#6B7280',
167
+ }}
168
+ >
169
+ {reps != null ? reps : '\u2014'}
170
+ </Text>
171
+ )}
172
+ </View>
173
+
174
+ <View
175
+ style={{ width: 56, alignItems: 'center', justifyContent: 'center' }}
176
+ testID="set-row-weight"
177
+ >
178
+ {isActive && weight == null && targets ? (
179
+ <Text
180
+ style={{
181
+ fontSize: 13,
182
+ fontStyle: 'italic',
183
+ fontFamily: 'Inter, sans-serif',
184
+ color: '#6B7280',
185
+ }}
186
+ testID="set-row-target-weight"
187
+ >
188
+ {targets.weight}
189
+ </Text>
190
+ ) : (
191
+ <Text
192
+ style={{
193
+ fontSize: 14,
194
+ fontWeight: '600',
195
+ fontFamily: 'Inter, sans-serif',
196
+ color: weight != null ? '#F3F4F6' : '#6B7280',
197
+ }}
198
+ >
199
+ {weight != null ? weight : '\u2014'}
200
+ </Text>
201
+ )}
202
+ </View>
203
+
204
+ <View
205
+ style={{ width: 36, alignItems: 'center', justifyContent: 'center' }}
206
+ testID="set-row-rpe"
207
+ >
208
+ <Text
209
+ style={{
210
+ fontSize: 13,
211
+ fontWeight: '600',
212
+ fontFamily: 'Inter, sans-serif',
213
+ color: rpe != null ? getRPEColor(rpe) : '#6B7280',
214
+ }}
215
+ >
216
+ {rpe != null ? rpe : '\u2014'}
217
+ </Text>
218
+ </View>
219
+
220
+ {prBadges && prBadges.length > 0 && (
221
+ <View
222
+ className="flex-row items-center"
223
+ style={{ gap: 4 }}
224
+ testID="set-row-pr-badges"
225
+ >
226
+ {prBadges.map((badge, i) => (
227
+ <PrBadge
228
+ key={i}
229
+ type={badge.type}
230
+ label={badge.label}
231
+ animate={false}
232
+ compact
233
+ />
234
+ ))}
235
+ </View>
236
+ )}
237
+
238
+ {velocities && velocities.length > 0 && (
239
+ <View
240
+ style={{ position: 'absolute', bottom: 0, left: 8, right: 8 }}
241
+ testID="set-row-velocity-strip"
242
+ >
243
+ <VelocityStrip
244
+ velocities={velocities}
245
+ expanded={velocityExpanded}
246
+ onToggle={onVelocityToggle}
247
+ variant={onVelocityToggle ? 'full' : 'mini'}
248
+ />
249
+ </View>
250
+ )}
251
+ </View>
252
+ )
253
+ }
@@ -0,0 +1,125 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { Sparkline } from './Sparkline'
4
+
5
+ const meta: Meta<typeof Sparkline> = {
6
+ title: 'Custom/Workout/Sparkline',
7
+ component: Sparkline,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ width: {
11
+ control: 'number',
12
+ description: 'Chart width in pixels',
13
+ },
14
+ height: {
15
+ control: 'number',
16
+ description: 'Chart height in pixels',
17
+ },
18
+ color: {
19
+ control: 'color',
20
+ description: 'Line and dot color',
21
+ },
22
+ showDots: {
23
+ control: 'boolean',
24
+ description: 'Show dots at data points',
25
+ },
26
+ highlightLast: {
27
+ control: 'boolean',
28
+ description: 'Highlight the last data point with a larger dot',
29
+ },
30
+ },
31
+ }
32
+
33
+ export default meta
34
+ type Story = StoryObj<typeof Sparkline>
35
+
36
+ const ascending = [10, 15, 22, 28, 35, 42, 50]
37
+ const descending = [50, 45, 38, 30, 22, 15, 10]
38
+ const uCurve = [40, 25, 15, 10, 12, 20, 35]
39
+ const flat = [20, 21, 19, 20, 21, 20, 19]
40
+ const volatile = [10, 35, 15, 45, 20, 40, 25]
41
+
42
+ export const Default: Story = {
43
+ args: {
44
+ data: ascending,
45
+ },
46
+ }
47
+
48
+ export const Ascending: Story = {
49
+ args: {
50
+ data: ascending,
51
+ showDots: true,
52
+ },
53
+ }
54
+
55
+ export const Descending: Story = {
56
+ args: {
57
+ data: descending,
58
+ showDots: true,
59
+ color: '#ff4757',
60
+ },
61
+ }
62
+
63
+ export const UCurve: Story = {
64
+ args: {
65
+ data: uCurve,
66
+ showDots: true,
67
+ color: '#2ed573',
68
+ },
69
+ }
70
+
71
+ export const Flat: Story = {
72
+ args: {
73
+ data: flat,
74
+ showDots: true,
75
+ color: '#ffd43b',
76
+ },
77
+ }
78
+
79
+ export const WithReferenceLines: Story = {
80
+ args: {
81
+ data: ascending,
82
+ showDots: true,
83
+ referenceLines: [
84
+ { value: 30, color: '#ff4757', dashed: true },
85
+ { value: 45, color: '#2ed573', dashed: false },
86
+ ],
87
+ },
88
+ }
89
+
90
+ export const HighlightLast: Story = {
91
+ args: {
92
+ data: ascending,
93
+ highlightLast: true,
94
+ },
95
+ }
96
+
97
+ export const DotsAndHighlightLast: Story = {
98
+ args: {
99
+ data: volatile,
100
+ showDots: true,
101
+ highlightLast: true,
102
+ color: '#ffa502',
103
+ },
104
+ }
105
+
106
+ export const CustomSize: Story = {
107
+ args: {
108
+ data: ascending,
109
+ width: 120,
110
+ height: 50,
111
+ showDots: true,
112
+ },
113
+ }
114
+
115
+ export const AllShapes: Story = {
116
+ render: () => (
117
+ <View style={{ gap: 16, padding: 16 }}>
118
+ <Sparkline data={ascending} showDots highlightLast />
119
+ <Sparkline data={descending} showDots color="#ff4757" />
120
+ <Sparkline data={uCurve} showDots color="#2ed573" />
121
+ <Sparkline data={flat} showDots color="#ffd43b" />
122
+ <Sparkline data={volatile} showDots color="#ffa502" highlightLast />
123
+ </View>
124
+ ),
125
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Sparkline } from './Sparkline'
5
+
6
+ const sampleData = [10, 25, 15, 30, 20]
7
+
8
+ describe('Sparkline', () => {
9
+ it('renders with data points', () => {
10
+ render(<Sparkline data={sampleData} />)
11
+ expect(screen.getByTestId('sparkline')).toBeInTheDocument()
12
+ })
13
+
14
+ it('renders empty state for no data', () => {
15
+ render(<Sparkline data={[]} />)
16
+ expect(screen.getByTestId('sparkline-empty')).toBeInTheDocument()
17
+ })
18
+
19
+ it('renders line segments between points', () => {
20
+ render(<Sparkline data={sampleData} />)
21
+ for (let i = 1; i < sampleData.length; i++) {
22
+ expect(
23
+ screen.getByTestId(`sparkline-segment-${i}`),
24
+ ).toBeInTheDocument()
25
+ }
26
+ })
27
+
28
+ it('renders dots when showDots is true', () => {
29
+ render(<Sparkline data={sampleData} showDots />)
30
+ for (let i = 0; i < sampleData.length; i++) {
31
+ expect(screen.getByTestId(`sparkline-dot-${i}`)).toBeInTheDocument()
32
+ }
33
+ })
34
+
35
+ it('does not render dots when showDots is false', () => {
36
+ render(<Sparkline data={sampleData} />)
37
+ expect(screen.queryByTestId('sparkline-dot-0')).not.toBeInTheDocument()
38
+ })
39
+
40
+ it('highlights last dot when highlightLast is true', () => {
41
+ render(<Sparkline data={sampleData} highlightLast />)
42
+ const lastIndex = sampleData.length - 1
43
+ expect(
44
+ screen.getByTestId(`sparkline-dot-${lastIndex}`),
45
+ ).toBeInTheDocument()
46
+ // Other dots should not be present (showDots is false)
47
+ expect(screen.queryByTestId('sparkline-dot-0')).not.toBeInTheDocument()
48
+ })
49
+
50
+ it('renders both regular dots and highlighted last', () => {
51
+ render(<Sparkline data={sampleData} showDots highlightLast />)
52
+ for (let i = 0; i < sampleData.length; i++) {
53
+ expect(screen.getByTestId(`sparkline-dot-${i}`)).toBeInTheDocument()
54
+ }
55
+ })
56
+
57
+ it('renders reference lines', () => {
58
+ render(
59
+ <Sparkline
60
+ data={sampleData}
61
+ referenceLines={[
62
+ { value: 20, color: '#ff0000' },
63
+ { value: 25, color: '#00ff00', dashed: true },
64
+ ]}
65
+ />,
66
+ )
67
+ expect(screen.getByTestId('sparkline-reference-0')).toBeInTheDocument()
68
+ expect(screen.getByTestId('sparkline-reference-1')).toBeInTheDocument()
69
+ })
70
+
71
+ it('does not render reference lines when not provided', () => {
72
+ render(<Sparkline data={sampleData} />)
73
+ expect(
74
+ screen.queryByTestId('sparkline-reference-0'),
75
+ ).not.toBeInTheDocument()
76
+ })
77
+
78
+ it('uses default dimensions', () => {
79
+ render(<Sparkline data={sampleData} />)
80
+ const sparkline = screen.getByTestId('sparkline')
81
+ expect(sparkline).toHaveStyle({ width: '80px', height: '30px' })
82
+ })
83
+
84
+ it('accepts custom dimensions', () => {
85
+ render(<Sparkline data={sampleData} width={120} height={50} />)
86
+ const sparkline = screen.getByTestId('sparkline')
87
+ expect(sparkline).toHaveStyle({ width: '120px', height: '50px' })
88
+ })
89
+
90
+ it('sets accessibility label', () => {
91
+ render(<Sparkline data={sampleData} />)
92
+ expect(
93
+ screen.getByLabelText('Sparkline chart with 5 data points'),
94
+ ).toBeInTheDocument()
95
+ })
96
+
97
+ it('sets empty accessibility label for no data', () => {
98
+ render(<Sparkline data={[]} />)
99
+ expect(
100
+ screen.getByLabelText('Sparkline chart, no data'),
101
+ ).toBeInTheDocument()
102
+ })
103
+
104
+ describe('accessibility', () => {
105
+ it('has no accessibility violations', async () => {
106
+ const { container } = render(
107
+ <Sparkline data={sampleData} showDots highlightLast />,
108
+ )
109
+ const results = await axe(container)
110
+ expect(results).toHaveNoViolations()
111
+ })
112
+ })
113
+ })