@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,429 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useState } from 'react'
3
+ import { View, Text, Pressable } from 'react-native'
4
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
+ import { VelocityStrip } from './VelocityStrip'
6
+ import { PlaceholderStrip } from './PlaceholderStrip'
7
+ import { WeightBadge } from './WeightBadge'
8
+ import { PrBadge } from './PrBadge'
9
+ import { TempoDisplay } from './TempoDisplay'
10
+ import { SetRow, type SetRowProps } from './SetRow'
11
+
12
+ export type ExerciseCardState = 'collapsed' | 'expanded' | 'upcoming'
13
+
14
+ export interface ExerciseCardProps {
15
+ name: string
16
+ state: ExerciseCardState
17
+ onToggle: () => void
18
+ onNavigateDetail?: () => void
19
+ summary?: {
20
+ sets: number
21
+ reps: number | string
22
+ weight: number
23
+ unit: 'lbs' | 'kg'
24
+ }
25
+ e1rm?: { value: number; unit: 'lbs' | 'kg' }
26
+ isPR?: boolean
27
+ setVelocities?: number[][]
28
+ totalPlannedSets?: number
29
+ sets?: SetRowProps[]
30
+ tempo?: [number, number, number, number]
31
+ prescription?: string
32
+ previousBest?: string
33
+ supersetPosition?: 'first' | 'last' | 'middle' | null
34
+ supersetColor?: string
35
+ }
36
+
37
+ const COLORS = {
38
+ textPrimary: '#F3F4F6',
39
+ textSecondary: '#9CA3AF',
40
+ textTertiary: '#6B7280',
41
+ brandPrimary: '#FF7900',
42
+ }
43
+
44
+ const COLUMN_HEADERS = ['SET', 'PREV', 'REPS', 'LBS', 'RPE'] as const
45
+
46
+ function getSupersetBorderRadius(
47
+ position: ExerciseCardProps['supersetPosition'],
48
+ ): Record<string, number> {
49
+ switch (position) {
50
+ case 'first':
51
+ return {
52
+ borderTopLeftRadius: 12,
53
+ borderTopRightRadius: 12,
54
+ borderBottomLeftRadius: 8,
55
+ borderBottomRightRadius: 8,
56
+ }
57
+ case 'last':
58
+ return {
59
+ borderTopLeftRadius: 8,
60
+ borderTopRightRadius: 8,
61
+ borderBottomLeftRadius: 12,
62
+ borderBottomRightRadius: 12,
63
+ }
64
+ case 'middle':
65
+ return {
66
+ borderTopLeftRadius: 8,
67
+ borderTopRightRadius: 8,
68
+ borderBottomLeftRadius: 8,
69
+ borderBottomRightRadius: 8,
70
+ }
71
+ default:
72
+ return {
73
+ borderTopLeftRadius: 12,
74
+ borderTopRightRadius: 12,
75
+ borderBottomLeftRadius: 12,
76
+ borderBottomRightRadius: 12,
77
+ }
78
+ }
79
+ }
80
+
81
+ function getSupersetMargin(
82
+ position: ExerciseCardProps['supersetPosition'],
83
+ ): Record<string, number> {
84
+ if (position === 'first' || position === 'middle') {
85
+ return { marginBottom: 2 }
86
+ }
87
+ return {}
88
+ }
89
+
90
+ function formatSummary(summary: ExerciseCardProps['summary']): string {
91
+ if (!summary) return ''
92
+ return `${summary.sets}\u00D7${summary.reps} @ ${summary.weight} ${summary.unit}`
93
+ }
94
+
95
+ function formatAccessibilityLabel(
96
+ name: string,
97
+ summary: ExerciseCardProps['summary'],
98
+ prescription: ExerciseCardProps['prescription'],
99
+ ): string {
100
+ if (summary) return `${name}, ${formatSummary(summary)}`
101
+ if (prescription) return `${name}, ${prescription}`
102
+ return name
103
+ }
104
+
105
+ function CollapsedCard({
106
+ name,
107
+ onToggle,
108
+ summary,
109
+ e1rm,
110
+ isPR,
111
+ setVelocities,
112
+ totalPlannedSets,
113
+ supersetPosition,
114
+ }: ExerciseCardProps) {
115
+ const [pressed, setPressed] = useState(false)
116
+ const completedSets = setVelocities?.length ?? 0
117
+ const remaining = Math.max(0, (totalPlannedSets ?? 0) - completedSets)
118
+ const borderRadius = getSupersetBorderRadius(supersetPosition)
119
+ const supersetMargin = getSupersetMargin(supersetPosition)
120
+
121
+ return (
122
+ <Pressable
123
+ onPress={onToggle}
124
+ onPressIn={() => setPressed(true)}
125
+ onPressOut={() => setPressed(false)}
126
+ accessibilityRole="button"
127
+ accessibilityLabel={formatAccessibilityLabel(name, summary, undefined)}
128
+ testID="exercise-card"
129
+ >
130
+ <View
131
+ style={{
132
+ padding: 12,
133
+ paddingHorizontal: 14,
134
+ cursor: 'pointer',
135
+ backgroundColor: pressed
136
+ ? WORKOUT_TOKENS.surface.raised
137
+ : 'transparent',
138
+ ...borderRadius,
139
+ ...supersetMargin,
140
+ }}
141
+ >
142
+ <View
143
+ className="flex-row items-center"
144
+ testID="exercise-card-header"
145
+ >
146
+ <Text
147
+ style={{
148
+ fontSize: 14,
149
+ fontFamily: '"Space Grotesk", sans-serif',
150
+ fontWeight: '700',
151
+ color: COLORS.textPrimary,
152
+ }}
153
+ testID="exercise-card-name"
154
+ >
155
+ {name}
156
+ </Text>
157
+ <View className="flex-1" />
158
+ {summary && (
159
+ <Text
160
+ style={{
161
+ fontSize: 12,
162
+ fontFamily: 'Inter, sans-serif',
163
+ color: COLORS.textSecondary,
164
+ marginRight: 8,
165
+ }}
166
+ testID="exercise-card-summary"
167
+ >
168
+ {formatSummary(summary)}
169
+ </Text>
170
+ )}
171
+ {e1rm && (
172
+ <WeightBadge
173
+ value={e1rm.value}
174
+ unit={e1rm.unit}
175
+ size="sm"
176
+ showIcon={false}
177
+ testID="exercise-card-e1rm"
178
+ />
179
+ )}
180
+ {isPR && (
181
+ <View style={{ marginLeft: 4 }}>
182
+ <PrBadge type="e1rm" compact animate={false} />
183
+ </View>
184
+ )}
185
+ </View>
186
+
187
+ {(completedSets > 0 || remaining > 0) && (
188
+ <View
189
+ className="flex-row"
190
+ style={{ marginTop: 6, gap: 4 }}
191
+ testID="exercise-card-strips"
192
+ >
193
+ {setVelocities?.map((velocities, i) => (
194
+ <VelocityStrip
195
+ key={i}
196
+ velocities={velocities}
197
+ variant="mini"
198
+ testID={`exercise-card-velocity-strip-${i}`}
199
+ />
200
+ ))}
201
+ {Array.from({ length: remaining }, (_, i) => (
202
+ <PlaceholderStrip
203
+ key={`placeholder-${i}`}
204
+ testID={`exercise-card-placeholder-${i}`}
205
+ />
206
+ ))}
207
+ </View>
208
+ )}
209
+ </View>
210
+ </Pressable>
211
+ )
212
+ }
213
+
214
+ function ExpandedCard({
215
+ name,
216
+ onToggle,
217
+ summary,
218
+ e1rm,
219
+ isPR,
220
+ sets,
221
+ tempo,
222
+ supersetPosition,
223
+ }: ExerciseCardProps) {
224
+ const borderRadius = getSupersetBorderRadius(supersetPosition)
225
+ const supersetMargin = getSupersetMargin(supersetPosition)
226
+
227
+ return (
228
+ <View
229
+ style={{
230
+ backgroundColor: WORKOUT_TOKENS.surface.elevated,
231
+ borderWidth: 1,
232
+ borderColor: WORKOUT_TOKENS.border.default,
233
+ ...borderRadius,
234
+ ...supersetMargin,
235
+ }}
236
+ testID="exercise-card"
237
+ >
238
+ <Pressable
239
+ onPress={onToggle}
240
+ accessibilityRole="button"
241
+ accessibilityLabel={formatAccessibilityLabel(name, summary, undefined)}
242
+ aria-expanded={true}
243
+ testID="exercise-card-header"
244
+ >
245
+ <View
246
+ className="flex-row items-center"
247
+ style={{
248
+ padding: 12,
249
+ paddingHorizontal: 14,
250
+ paddingBottom: 10,
251
+ borderBottomWidth: 1,
252
+ borderBottomColor: WORKOUT_TOKENS.border.default,
253
+ }}
254
+ >
255
+ <Text
256
+ style={{
257
+ fontSize: 14,
258
+ fontFamily: '"Space Grotesk", sans-serif',
259
+ fontWeight: '700',
260
+ color: COLORS.textPrimary,
261
+ }}
262
+ testID="exercise-card-name"
263
+ >
264
+ {name}
265
+ </Text>
266
+ <View className="flex-1" />
267
+ {summary && (
268
+ <Text
269
+ style={{
270
+ fontSize: 12,
271
+ fontFamily: 'Inter, sans-serif',
272
+ color: COLORS.textSecondary,
273
+ marginRight: 8,
274
+ }}
275
+ testID="exercise-card-summary"
276
+ >
277
+ {formatSummary(summary)}
278
+ </Text>
279
+ )}
280
+ {e1rm && (
281
+ <WeightBadge
282
+ value={e1rm.value}
283
+ unit={e1rm.unit}
284
+ size="sm"
285
+ showIcon={false}
286
+ testID="exercise-card-e1rm"
287
+ />
288
+ )}
289
+ {isPR && (
290
+ <View style={{ marginLeft: 4 }}>
291
+ <PrBadge type="e1rm" compact animate={false} />
292
+ </View>
293
+ )}
294
+ </View>
295
+ </Pressable>
296
+
297
+ {tempo && (
298
+ <View style={{ marginTop: 8, paddingHorizontal: 14 }} testID="exercise-card-tempo">
299
+ <TempoDisplay tempo={tempo} size="sm" />
300
+ </View>
301
+ )}
302
+
303
+ <View
304
+ className="flex-row"
305
+ style={{
306
+ paddingVertical: 8,
307
+ paddingHorizontal: 8,
308
+ paddingBottom: 4,
309
+ }}
310
+ testID="exercise-card-column-headers"
311
+ >
312
+ {COLUMN_HEADERS.map((header, i) => {
313
+ const widths = [36, undefined, 44, 56, 36]
314
+ const width = widths[i]
315
+ const isFlex = width === undefined
316
+
317
+ return (
318
+ <View
319
+ key={header}
320
+ style={{
321
+ ...(isFlex ? {} : { width }),
322
+ alignItems: 'center',
323
+ justifyContent: 'center',
324
+ gap: 8,
325
+ }}
326
+ {...(isFlex ? { className: 'flex-1' } : {})}
327
+ >
328
+ <Text
329
+ style={{
330
+ fontSize: 10,
331
+ fontWeight: '600',
332
+ fontFamily: 'Inter, sans-serif',
333
+ textTransform: 'uppercase',
334
+ letterSpacing: 0.8,
335
+ color: COLORS.textTertiary,
336
+ }}
337
+ >
338
+ {header}
339
+ </Text>
340
+ </View>
341
+ )
342
+ })}
343
+ </View>
344
+
345
+ {sets && (
346
+ <View testID="exercise-card-sets">
347
+ {sets.map((setProps, i) => (
348
+ <SetRow key={i} {...setProps} />
349
+ ))}
350
+ </View>
351
+ )}
352
+ </View>
353
+ )
354
+ }
355
+
356
+ function UpcomingCard({
357
+ name,
358
+ prescription,
359
+ previousBest,
360
+ supersetPosition,
361
+ }: ExerciseCardProps) {
362
+ const borderRadius = getSupersetBorderRadius(supersetPosition)
363
+ const supersetMargin = getSupersetMargin(supersetPosition)
364
+
365
+ return (
366
+ <View
367
+ style={{
368
+ opacity: 0.6,
369
+ padding: 12,
370
+ paddingHorizontal: 14,
371
+ ...borderRadius,
372
+ ...supersetMargin,
373
+ }}
374
+ accessibilityLabel={formatAccessibilityLabel(name, undefined, prescription)}
375
+ testID="exercise-card"
376
+ >
377
+ <View className="flex-row items-center">
378
+ <Text
379
+ style={{
380
+ fontSize: 14,
381
+ fontFamily: '"Space Grotesk", sans-serif',
382
+ fontWeight: '700',
383
+ color: COLORS.textPrimary,
384
+ }}
385
+ testID="exercise-card-name"
386
+ >
387
+ {name}
388
+ </Text>
389
+ {prescription && (
390
+ <Text
391
+ style={{
392
+ fontSize: 12,
393
+ fontFamily: 'Inter, sans-serif',
394
+ color: COLORS.textSecondary,
395
+ marginLeft: 8,
396
+ }}
397
+ testID="exercise-card-prescription"
398
+ >
399
+ {prescription}
400
+ </Text>
401
+ )}
402
+ <View className="flex-1" />
403
+ {previousBest && (
404
+ <Text
405
+ style={{
406
+ fontSize: 11,
407
+ fontFamily: 'Inter, sans-serif',
408
+ color: COLORS.textTertiary,
409
+ }}
410
+ testID="exercise-card-previous-best"
411
+ >
412
+ {previousBest}
413
+ </Text>
414
+ )}
415
+ </View>
416
+ </View>
417
+ )
418
+ }
419
+
420
+ export function ExerciseCard(props: ExerciseCardProps) {
421
+ switch (props.state) {
422
+ case 'collapsed':
423
+ return <CollapsedCard {...props} />
424
+ case 'expanded':
425
+ return <ExpandedCard {...props} />
426
+ case 'upcoming':
427
+ return <UpcomingCard {...props} />
428
+ }
429
+ }
@@ -0,0 +1,75 @@
1
+ import React from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View } from 'react-native'
4
+ import { InputBar } from './InputBar'
5
+
6
+ const meta: Meta<typeof InputBar> = {
7
+ title: 'Custom/Workout/InputBar',
8
+ component: InputBar,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ canRecord: {
12
+ control: 'boolean',
13
+ description: 'Whether the record button is enabled',
14
+ },
15
+ unit: {
16
+ control: 'select',
17
+ options: ['lbs', 'kg'],
18
+ description: 'Weight unit',
19
+ },
20
+ visible: {
21
+ control: 'boolean',
22
+ description: 'Whether the input bar is visible',
23
+ },
24
+ },
25
+ decorators: [
26
+ (Story) => (
27
+ <View style={{ width: 400, backgroundColor: '#111111' }}>
28
+ <Story />
29
+ </View>
30
+ ),
31
+ ],
32
+ }
33
+
34
+ export default meta
35
+ type Story = StoryObj<typeof InputBar>
36
+
37
+ export const Default: Story = {
38
+ args: {
39
+ exerciseName: 'Bench Press',
40
+ setNumber: 2,
41
+ totalSets: 5,
42
+ reps: '',
43
+ weight: '',
44
+ unit: 'lbs',
45
+ onRepsChange: () => {},
46
+ onWeightChange: () => {},
47
+ onRecord: () => {},
48
+ canRecord: true,
49
+ visible: true,
50
+ },
51
+ }
52
+
53
+ export const Disabled: Story = {
54
+ args: {
55
+ ...Default.args,
56
+ canRecord: false,
57
+ },
58
+ }
59
+
60
+ export const Kilograms: Story = {
61
+ args: {
62
+ ...Default.args,
63
+ unit: 'kg',
64
+ weight: '60',
65
+ reps: '8',
66
+ },
67
+ }
68
+
69
+ export const WithFilledValues: Story = {
70
+ args: {
71
+ ...Default.args,
72
+ reps: '5',
73
+ weight: '225',
74
+ },
75
+ }
@@ -0,0 +1,116 @@
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 { InputBar, type InputBarProps } from './InputBar'
5
+
6
+ const defaultProps: InputBarProps = {
7
+ exerciseName: 'Bench Press',
8
+ setNumber: 2,
9
+ totalSets: 5,
10
+ reps: '5',
11
+ weight: '135',
12
+ unit: 'lbs',
13
+ onRepsChange: vi.fn(),
14
+ onWeightChange: vi.fn(),
15
+ onRecord: vi.fn(),
16
+ canRecord: true,
17
+ visible: true,
18
+ }
19
+
20
+ function renderInputBar(overrides: Partial<InputBarProps> = {}) {
21
+ return render(<InputBar {...defaultProps} {...overrides} />)
22
+ }
23
+
24
+ describe('InputBar', () => {
25
+ it('renders with all props', () => {
26
+ renderInputBar()
27
+ expect(screen.getByTestId('input-bar')).toBeInTheDocument()
28
+ })
29
+
30
+ it('displays exercise name', () => {
31
+ renderInputBar({ exerciseName: 'Squat' })
32
+ expect(screen.getByTestId('input-bar-exercise-name')).toHaveTextContent('Squat')
33
+ })
34
+
35
+ it('displays set info with total', () => {
36
+ renderInputBar({ setNumber: 3, totalSets: 4 })
37
+ expect(screen.getByTestId('input-bar-set-info')).toHaveTextContent('Set 3/4')
38
+ })
39
+
40
+ it('displays set info without total', () => {
41
+ renderInputBar({ setNumber: 1, totalSets: null })
42
+ expect(screen.getByTestId('input-bar-set-info')).toHaveTextContent('Set 1')
43
+ })
44
+
45
+ it('fires onRepsChange when reps input changes', () => {
46
+ const onRepsChange = vi.fn()
47
+ renderInputBar({ onRepsChange })
48
+ const input = screen.getByTestId('input-bar-reps')
49
+ fireEvent.change(input, { target: { value: '8' } })
50
+ expect(onRepsChange).toHaveBeenCalled()
51
+ })
52
+
53
+ it('fires onWeightChange when weight input changes', () => {
54
+ const onWeightChange = vi.fn()
55
+ renderInputBar({ onWeightChange })
56
+ const input = screen.getByTestId('input-bar-weight')
57
+ fireEvent.change(input, { target: { value: '225' } })
58
+ expect(onWeightChange).toHaveBeenCalled()
59
+ })
60
+
61
+ it('fires onRecord when record button is clicked', () => {
62
+ const onRecord = vi.fn()
63
+ renderInputBar({ onRecord })
64
+ fireEvent.click(screen.getByTestId('input-bar-record'))
65
+ expect(onRecord).toHaveBeenCalledOnce()
66
+ })
67
+
68
+ it('applies reduced opacity when canRecord is false', () => {
69
+ renderInputBar({ canRecord: false })
70
+ const button = screen.getByTestId('input-bar-record')
71
+ expect(button).toHaveStyle({ opacity: '0.4' })
72
+ })
73
+
74
+ it('returns null when visible is false', () => {
75
+ renderInputBar({ visible: false })
76
+ expect(screen.queryByTestId('input-bar')).not.toBeInTheDocument()
77
+ })
78
+
79
+ it('shows correct unit label', () => {
80
+ renderInputBar({ unit: 'kg' })
81
+ expect(screen.getByTestId('input-bar-unit')).toHaveTextContent('kg')
82
+ })
83
+
84
+ it('shows lbs unit label', () => {
85
+ renderInputBar({ unit: 'lbs' })
86
+ expect(screen.getByTestId('input-bar-unit')).toHaveTextContent('lbs')
87
+ })
88
+
89
+ describe('accessibility', () => {
90
+ it('has no accessibility violations', async () => {
91
+ const { container } = renderInputBar()
92
+ const results = await axe(container)
93
+ expect(results).toHaveNoViolations()
94
+ })
95
+
96
+ it('has toolbar role on container', () => {
97
+ renderInputBar()
98
+ expect(screen.getByRole('toolbar')).toBeInTheDocument()
99
+ })
100
+
101
+ it('has correct accessibility label on record button', () => {
102
+ renderInputBar()
103
+ expect(screen.getByLabelText('Record set')).toBeInTheDocument()
104
+ })
105
+
106
+ it('has correct accessibility label on reps input', () => {
107
+ renderInputBar()
108
+ expect(screen.getByLabelText('Reps')).toBeInTheDocument()
109
+ })
110
+
111
+ it('has correct accessibility label on weight input', () => {
112
+ renderInputBar({ unit: 'kg' })
113
+ expect(screen.getByLabelText('Weight in kg')).toBeInTheDocument()
114
+ })
115
+ })
116
+ })