@titan-design/react-ui 0.2.6 → 0.3.0

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 (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -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
+ })
@@ -0,0 +1,162 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import React from 'react'
3
+ import { View, Text, Pressable, TextInput } from 'react-native'
4
+
5
+ export interface InputBarProps {
6
+ exerciseName: string
7
+ setNumber: number
8
+ totalSets: number | null
9
+ reps: string
10
+ weight: string
11
+ unit: 'lbs' | 'kg'
12
+ onRepsChange: (value: string) => void
13
+ onWeightChange: (value: string) => void
14
+ onRecord: () => void
15
+ canRecord: boolean
16
+ visible: boolean
17
+ }
18
+
19
+ const BRAND_PRIMARY = '#FF7900'
20
+ const TEXT_PRIMARY = 'var(--color-text-primary)'
21
+ const TEXT_TERTIARY = 'var(--color-text-tertiary)'
22
+
23
+ const inputStyle = {
24
+ fontSize: 14,
25
+ fontWeight: '600' as const,
26
+ fontFamily: 'Inter, sans-serif',
27
+ backgroundColor: 'var(--color-surface-raised)',
28
+ borderWidth: 1,
29
+ borderColor: 'var(--color-border-strong)',
30
+ borderRadius: 6,
31
+ textAlign: 'center' as const,
32
+ color: TEXT_PRIMARY,
33
+ paddingVertical: 5,
34
+ paddingHorizontal: 2,
35
+ }
36
+
37
+ export function InputBar({
38
+ exerciseName,
39
+ setNumber,
40
+ totalSets,
41
+ reps,
42
+ weight,
43
+ unit,
44
+ onRepsChange,
45
+ onWeightChange,
46
+ onRecord,
47
+ canRecord,
48
+ visible,
49
+ }: InputBarProps) {
50
+ if (!visible) return null
51
+
52
+ const setLabel = totalSets != null ? `Set ${setNumber}/${totalSets}` : `Set ${setNumber}`
53
+
54
+ return (
55
+ <View
56
+ style={{
57
+ width: '100%',
58
+ backgroundColor: 'var(--color-surface-elevated)',
59
+ borderTopWidth: 1,
60
+ borderTopColor: 'var(--color-border-default)',
61
+ paddingTop: 10,
62
+ paddingHorizontal: 16,
63
+ paddingBottom: 12,
64
+ flexDirection: 'row',
65
+ alignItems: 'center',
66
+ gap: 10,
67
+ }}
68
+ accessibilityRole="toolbar"
69
+ testID="input-bar"
70
+ >
71
+ <View style={{ minWidth: 80, flexDirection: 'column' }}>
72
+ <Text
73
+ style={{
74
+ fontSize: 12,
75
+ fontWeight: '700',
76
+ fontFamily: '"Space Grotesk", sans-serif',
77
+ color: TEXT_PRIMARY,
78
+ }}
79
+ testID="input-bar-exercise-name"
80
+ numberOfLines={1}
81
+ >
82
+ {exerciseName}
83
+ </Text>
84
+ <Text
85
+ style={{
86
+ fontSize: 10,
87
+ fontFamily: 'Inter, sans-serif',
88
+ color: TEXT_TERTIARY,
89
+ }}
90
+ testID="input-bar-set-info"
91
+ >
92
+ {setLabel}
93
+ </Text>
94
+ </View>
95
+
96
+ <View className="flex-1 flex-row items-center" style={{ gap: 4 }}>
97
+ <TextInput
98
+ value={reps}
99
+ onChangeText={onRepsChange}
100
+ style={{ ...inputStyle, width: 36 }}
101
+ accessibilityLabel="Reps"
102
+ testID="input-bar-reps"
103
+ keyboardType="numeric"
104
+ />
105
+ <Text
106
+ style={{
107
+ fontSize: 12,
108
+ fontFamily: 'Inter, sans-serif',
109
+ color: TEXT_TERTIARY,
110
+ }}
111
+ >
112
+ {'\u00D7'}
113
+ </Text>
114
+ <TextInput
115
+ value={weight}
116
+ onChangeText={onWeightChange}
117
+ style={{ ...inputStyle, width: 52 }}
118
+ accessibilityLabel={`Weight in ${unit}`}
119
+ testID="input-bar-weight"
120
+ keyboardType="numeric"
121
+ />
122
+ <Text
123
+ style={{
124
+ fontSize: 12,
125
+ fontWeight: '500',
126
+ fontFamily: 'Inter, sans-serif',
127
+ color: TEXT_TERTIARY,
128
+ }}
129
+ testID="input-bar-unit"
130
+ >
131
+ {unit}
132
+ </Text>
133
+ </View>
134
+
135
+ <Pressable
136
+ onPress={onRecord}
137
+ disabled={!canRecord}
138
+ style={({ pressed }) => ({
139
+ backgroundColor: BRAND_PRIMARY,
140
+ borderRadius: 8,
141
+ paddingVertical: 10,
142
+ paddingHorizontal: 16,
143
+ opacity: !canRecord ? 0.4 : pressed ? 0.8 : 1,
144
+ })}
145
+ accessibilityRole="button"
146
+ accessibilityLabel="Record set"
147
+ testID="input-bar-record"
148
+ >
149
+ <Text
150
+ style={{
151
+ fontSize: 13,
152
+ fontWeight: '700',
153
+ fontFamily: 'Inter, sans-serif',
154
+ color: '#FFFFFF',
155
+ }}
156
+ >
157
+ Record
158
+ </Text>
159
+ </Pressable>
160
+ </View>
161
+ )
162
+ }
@@ -0,0 +1,60 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { IntensityBar } from './IntensityBar'
4
+
5
+ const meta: Meta<typeof IntensityBar> = {
6
+ title: 'Custom/Workout/IntensityBar',
7
+ component: IntensityBar,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ level: {
11
+ control: { type: 'range', min: 0, max: 1, step: 0.05 },
12
+ description: 'Intensity level (0-1)',
13
+ },
14
+ threshold: {
15
+ control: { type: 'range', min: 0, max: 1, step: 0.05 },
16
+ description: 'MRV/overexertion threshold position (0-1)',
17
+ },
18
+ orientation: {
19
+ control: { type: 'radio' },
20
+ options: ['vertical', 'horizontal'],
21
+ description: 'Bar orientation',
22
+ },
23
+ size: { control: 'number', description: 'Length along the fill axis (px)' },
24
+ thickness: { control: 'number', description: 'Bar thickness (px)' },
25
+ showThresholdLabel: { control: 'boolean', description: 'Show MRV label' },
26
+ },
27
+ }
28
+
29
+ export default meta
30
+ type Story = StoryObj<typeof IntensityBar>
31
+
32
+ export const Low: Story = {
33
+ args: { level: 0.3, threshold: 0.85, showThresholdLabel: true },
34
+ }
35
+
36
+ export const Moderate: Story = {
37
+ args: { level: 0.55, threshold: 0.85, showThresholdLabel: true },
38
+ }
39
+
40
+ export const High: Story = {
41
+ args: { level: 0.85, threshold: 0.85, showThresholdLabel: true },
42
+ }
43
+
44
+ export const Horizontal: Story = {
45
+ args: { level: 0.6, threshold: 0.85, orientation: 'horizontal', size: 120 },
46
+ }
47
+
48
+ export const AllZones: Story = {
49
+ render: () => (
50
+ <View style={{ flexDirection: 'row', gap: 16, alignItems: 'flex-end', padding: 16 }}>
51
+ <IntensityBar level={0.2} threshold={0.85} showThresholdLabel />
52
+ <IntensityBar level={0.35} threshold={0.85} showThresholdLabel />
53
+ <IntensityBar level={0.5} threshold={0.85} showThresholdLabel />
54
+ <IntensityBar level={0.65} threshold={0.85} showThresholdLabel />
55
+ <IntensityBar level={0.75} threshold={0.85} showThresholdLabel />
56
+ <IntensityBar level={0.9} threshold={0.85} showThresholdLabel />
57
+ <IntensityBar level={1.0} threshold={0.85} showThresholdLabel />
58
+ </View>
59
+ ),
60
+ }
@@ -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 { IntensityBar } from './IntensityBar'
5
+
6
+ describe('IntensityBar', () => {
7
+ it('renders the bar', () => {
8
+ render(<IntensityBar level={0.5} />)
9
+ expect(screen.getByTestId('intensity-bar')).toBeInTheDocument()
10
+ })
11
+
12
+ it('renders fill element', () => {
13
+ render(<IntensityBar level={0.5} />)
14
+ expect(screen.getByTestId('intensity-fill')).toBeInTheDocument()
15
+ })
16
+
17
+ it('renders threshold line when threshold is provided', () => {
18
+ render(<IntensityBar level={0.5} threshold={0.85} />)
19
+ expect(screen.getByTestId('intensity-threshold')).toBeInTheDocument()
20
+ })
21
+
22
+ it('does not render threshold line when threshold is omitted', () => {
23
+ render(<IntensityBar level={0.5} />)
24
+ expect(screen.queryByTestId('intensity-threshold')).not.toBeInTheDocument()
25
+ })
26
+
27
+ it('shows MRV label when showThresholdLabel is true and threshold set', () => {
28
+ render(<IntensityBar level={0.6} threshold={0.85} showThresholdLabel />)
29
+ expect(screen.getByText('MRV')).toBeInTheDocument()
30
+ })
31
+
32
+ it('hides MRV label when showThresholdLabel is false', () => {
33
+ render(<IntensityBar level={0.6} threshold={0.85} />)
34
+ expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
35
+ })
36
+
37
+ it('hides MRV label when threshold is omitted even if showThresholdLabel is true', () => {
38
+ render(<IntensityBar level={0.6} showThresholdLabel />)
39
+ expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
40
+ })
41
+
42
+ it('has correct accessibility role', () => {
43
+ render(<IntensityBar level={0.5} />)
44
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
45
+ })
46
+
47
+ it('maps level 0-1 to percentage in accessibility label', () => {
48
+ render(<IntensityBar level={0.75} />)
49
+ expect(screen.getByLabelText('Intensity level: 75%')).toBeInTheDocument()
50
+ })
51
+
52
+ it('clamps level above 1 to 100%', () => {
53
+ render(<IntensityBar level={1.5} />)
54
+ expect(screen.getByLabelText('Intensity level: 100%')).toBeInTheDocument()
55
+ })
56
+
57
+ it('clamps level below 0 to 0%', () => {
58
+ render(<IntensityBar level={-0.5} />)
59
+ expect(screen.getByLabelText('Intensity level: 0%')).toBeInTheDocument()
60
+ })
61
+
62
+ it('renders horizontal orientation', () => {
63
+ render(<IntensityBar level={0.5} orientation="horizontal" />)
64
+ expect(screen.getByTestId('intensity-fill')).toBeInTheDocument()
65
+ })
66
+
67
+ it('accepts custom size and thickness', () => {
68
+ render(<IntensityBar level={0.5} size={32} thickness={8} />)
69
+ expect(screen.getByTestId('intensity-bar')).toBeInTheDocument()
70
+ })
71
+
72
+ describe('fill zones', () => {
73
+ it('renders teal fill for low levels (0-0.4)', () => {
74
+ render(<IntensityBar level={0.3} />)
75
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
76
+ })
77
+
78
+ it('renders amber fill for moderate levels (0.4-0.7)', () => {
79
+ render(<IntensityBar level={0.5} />)
80
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
81
+ })
82
+
83
+ it('renders red fill for high levels (0.7-1.0)', () => {
84
+ render(<IntensityBar level={0.85} />)
85
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#D14343' })
86
+ })
87
+
88
+ it('uses teal at the lower zone boundary', () => {
89
+ render(<IntensityBar level={0.39} />)
90
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
91
+ })
92
+
93
+ it('uses amber at the 0.4 boundary', () => {
94
+ render(<IntensityBar level={0.4} />)
95
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
96
+ })
97
+
98
+ it('uses red at the 0.7 boundary', () => {
99
+ render(<IntensityBar level={0.7} />)
100
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#D14343' })
101
+ })
102
+ })
103
+
104
+ describe('accessibility', () => {
105
+ it('has no accessibility violations', async () => {
106
+ const { container } = render(
107
+ <IntensityBar level={0.5} threshold={0.85} showThresholdLabel />,
108
+ )
109
+ const results = await axe(container)
110
+ expect(results).toHaveNoViolations()
111
+ })
112
+ })
113
+ })
@@ -0,0 +1,166 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useState } from 'react'
3
+ import { View, Text, Animated, type ViewProps, type ViewStyle } from 'react-native'
4
+
5
+ export type IntensityBarOrientation = 'vertical' | 'horizontal'
6
+
7
+ export interface IntensityBarProps extends ViewProps {
8
+ /** Current intensity level 0-1 */
9
+ level: number
10
+ /** MRV/overexertion threshold position 0-1 */
11
+ threshold?: number
12
+ /** Orientation */
13
+ orientation?: IntensityBarOrientation
14
+ /** Height for vertical (default 24px) or width for horizontal */
15
+ size?: number
16
+ /** Width for vertical (default 6px) or height for horizontal */
17
+ thickness?: number
18
+ /** Show threshold label ("MRV") */
19
+ showThresholdLabel?: boolean
20
+ className?: string
21
+ }
22
+
23
+ const TRACK_BG = 'rgba(255,255,255,0.06)'
24
+ const THRESHOLD_COLOR = '#FFFFFF'
25
+ const LABEL_COLOR = '#6B7280'
26
+
27
+ /** Fill color by intensity zone: teal 0-0.4, amber 0.4-0.7, red 0.7-1.0. */
28
+ function getZoneColor(level: number): string {
29
+ if (level >= 0.7) return '#D14343'
30
+ if (level >= 0.4) return '#FFB020'
31
+ return '#14B8A6'
32
+ }
33
+
34
+ function clamp01(value: number): number {
35
+ if (value < 0) return 0
36
+ if (value > 1) return 1
37
+ return value
38
+ }
39
+
40
+ export function IntensityBar({
41
+ level,
42
+ threshold,
43
+ orientation = 'vertical',
44
+ size = 24,
45
+ thickness = 6,
46
+ showThresholdLabel,
47
+ className,
48
+ ...props
49
+ }: IntensityBarProps) {
50
+ const isVertical = orientation === 'vertical'
51
+ const clampedLevel = clamp01(level)
52
+ const percentage = Math.round(clampedLevel * 100)
53
+ const zoneColor = getZoneColor(clampedLevel)
54
+
55
+ const [fillAnim] = useState(() => new Animated.Value(clampedLevel))
56
+
57
+ useEffect(() => {
58
+ const animation = Animated.timing(fillAnim, {
59
+ toValue: clampedLevel,
60
+ duration: 250,
61
+ useNativeDriver: false,
62
+ })
63
+ animation.start()
64
+ return () => animation.stop()
65
+ }, [clampedLevel, fillAnim])
66
+
67
+ const fillSizePercent = fillAnim.interpolate({
68
+ inputRange: [0, 1],
69
+ outputRange: ['0%', '100%'],
70
+ })
71
+
72
+ const trackStyle = isVertical
73
+ ? { width: thickness, height: size }
74
+ : { width: size, height: thickness }
75
+
76
+ const fillStyle = isVertical
77
+ ? {
78
+ position: 'absolute' as const,
79
+ bottom: 0,
80
+ left: 0,
81
+ right: 0,
82
+ height: fillSizePercent,
83
+ }
84
+ : {
85
+ position: 'absolute' as const,
86
+ top: 0,
87
+ bottom: 0,
88
+ left: 0,
89
+ width: fillSizePercent,
90
+ }
91
+
92
+ const thresholdLineStyle: ViewStyle | undefined =
93
+ threshold != null
94
+ ? isVertical
95
+ ? {
96
+ position: 'absolute' as const,
97
+ bottom: `${clamp01(threshold) * 100}%`,
98
+ left: -2,
99
+ right: -2,
100
+ height: 1,
101
+ backgroundColor: THRESHOLD_COLOR,
102
+ zIndex: 4,
103
+ }
104
+ : {
105
+ position: 'absolute' as const,
106
+ left: `${clamp01(threshold) * 100}%`,
107
+ top: -2,
108
+ bottom: -2,
109
+ width: 1,
110
+ backgroundColor: THRESHOLD_COLOR,
111
+ zIndex: 4,
112
+ }
113
+ : undefined
114
+
115
+ return (
116
+ <View
117
+ className={className}
118
+ style={{ alignItems: 'center' }}
119
+ accessibilityRole="progressbar"
120
+ accessibilityValue={{ min: 0, max: 100, now: percentage }}
121
+ accessibilityLabel={`Intensity level: ${percentage}%`}
122
+ testID="intensity-bar"
123
+ {...props}
124
+ >
125
+ <View
126
+ style={{
127
+ ...trackStyle,
128
+ borderRadius: 3,
129
+ backgroundColor: TRACK_BG,
130
+ position: 'relative',
131
+ overflow: 'visible',
132
+ }}
133
+ accessibilityElementsHidden
134
+ >
135
+ <Animated.View
136
+ style={{
137
+ ...fillStyle,
138
+ borderRadius: 3,
139
+ backgroundColor: zoneColor,
140
+ zIndex: 2,
141
+ }}
142
+ testID="intensity-fill"
143
+ />
144
+
145
+ {threshold != null && (
146
+ <View style={thresholdLineStyle} testID="intensity-threshold" />
147
+ )}
148
+ </View>
149
+ {showThresholdLabel && threshold != null && (
150
+ <Text
151
+ style={{
152
+ fontSize: 8,
153
+ fontWeight: '500',
154
+ color: LABEL_COLOR,
155
+ fontFamily: 'Inter, sans-serif',
156
+ marginTop: 6,
157
+ }}
158
+ accessibilityElementsHidden
159
+ testID="intensity-threshold-label"
160
+ >
161
+ MRV
162
+ </Text>
163
+ )}
164
+ </View>
165
+ )
166
+ }