@titan-design/react-ui 0.2.5 → 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 (158) 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/docs/WEB_SETUP.md +217 -0
  23. package/package.json +17 -5
  24. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  25. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  26. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  27. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  28. package/src/components/custom/Gauge/index.ts +2 -0
  29. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  30. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  31. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  32. package/src/components/custom/Scatter/index.ts +2 -0
  33. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  34. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  35. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  36. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  37. package/src/components/custom/Treemap/index.ts +2 -0
  38. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  39. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  40. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  41. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  42. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  43. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  46. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  47. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  48. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  49. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  50. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  51. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  52. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  53. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  54. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  55. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  56. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  57. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  58. package/src/components/custom/Workout/InputBar.tsx +163 -0
  59. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  60. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  61. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  62. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  63. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  64. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  65. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  66. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  67. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  68. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  69. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  70. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  73. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  76. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  77. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  78. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  79. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  80. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  81. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  82. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  83. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  84. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  85. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  86. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  87. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  88. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  89. package/src/components/custom/Workout/SetRow.tsx +253 -0
  90. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  91. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  92. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  93. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  94. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  95. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  98. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  99. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  100. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  101. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  102. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  103. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  104. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  105. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  106. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  107. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  108. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  109. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  110. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  111. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  112. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  113. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  114. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  115. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  116. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  117. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  118. package/src/components/custom/Workout/index.ts +113 -0
  119. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  120. package/src/components/custom/index.ts +3 -0
  121. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  122. package/src/components/custom/stepper/Stepper.tsx +2 -2
  123. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  124. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  125. package/src/components/ui/card/Card.tsx +1 -1
  126. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  127. package/src/components/ui/collapse/Collapse.tsx +1 -1
  128. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  129. package/src/components/ui/drawer/Drawer.tsx +3 -3
  130. package/src/components/ui/form-field/FormField.tsx +1 -1
  131. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  132. package/src/components/ui/menu/Menu.tsx +2 -2
  133. package/src/components/ui/pill/Pill.tsx +1 -1
  134. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  135. package/src/components/ui/popover/Popover.tsx +1 -1
  136. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  137. package/src/components/ui/section/Section.stories.tsx +4 -4
  138. package/src/components/ui/select/Select.tsx +1 -1
  139. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  140. package/src/components/ui/tabs/Tabs.tsx +2 -2
  141. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  142. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  143. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  144. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  145. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  146. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  147. package/src/stories/PresetShowcase.stories.tsx +15 -15
  148. package/src/theme/index.ts +2 -1
  149. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  150. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  151. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  152. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  153. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  154. package/src/theme/manifest/index.ts +11 -0
  155. package/src/theme/tokens-css.test.ts +28 -0
  156. package/src/theme/tokens-css.ts +34 -0
  157. package/src/theme/workout-tokens.ts +61 -0
  158. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,90 @@
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 { TempoDisplay } from './TempoDisplay'
5
+
6
+ // tempo = [eccentric, pauseBottom, concentric, pauseTop]
7
+ describe('TempoDisplay', () => {
8
+ it('renders tempo values', () => {
9
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
10
+ expect(screen.getByTestId('tempo-value')).toBeInTheDocument()
11
+ expect(screen.getByText('3')).toBeInTheDocument()
12
+ })
13
+
14
+ it('renders the TEMPO label prefix', () => {
15
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
16
+ expect(screen.getByText('TEMPO')).toBeInTheDocument()
17
+ })
18
+
19
+ it('renders colored phases by default', () => {
20
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
21
+ const tempoValue = screen.getByTestId('tempo-value')
22
+ expect(tempoValue.children.length).toBeGreaterThan(1)
23
+ })
24
+
25
+ it('renders mono variant', () => {
26
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />)
27
+ expect(screen.getByText('3-1-1-0')).toBeInTheDocument()
28
+ })
29
+
30
+ it('renders order: eccentric-pauseBottom-concentric-pauseTop', () => {
31
+ render(<TempoDisplay tempo={[4, 3, 2, 1]} colored={false} />)
32
+ expect(screen.getByText('4-3-2-1')).toBeInTheDocument()
33
+ })
34
+
35
+ it('has correct accessibility label', () => {
36
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
37
+ expect(
38
+ screen.getByLabelText(
39
+ 'Tempo: 3 second eccentric, 1 second pause, 1 second concentric, 0 second pause'
40
+ )
41
+ ).toBeInTheDocument()
42
+ })
43
+
44
+ it('is always a Pressable for tooltip', () => {
45
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
46
+ const display = screen.getByTestId('tempo-display')
47
+ expect(display).toBeInTheDocument()
48
+ })
49
+
50
+ it('calls onPress when pressed', () => {
51
+ const onPress = vi.fn()
52
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} onPress={onPress} />)
53
+ const display = screen.getByTestId('tempo-display')
54
+ fireEvent.click(display)
55
+ expect(onPress).toHaveBeenCalledOnce()
56
+ })
57
+
58
+ it('toggles tooltip on press', () => {
59
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
60
+ expect(screen.queryByTestId('tempo-tooltip')).not.toBeInTheDocument()
61
+ fireEvent.click(screen.getByTestId('tempo-display'))
62
+ expect(screen.getByTestId('tempo-tooltip')).toBeInTheDocument()
63
+ })
64
+
65
+ it('does not show tooltip when showInfo is false', () => {
66
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} showInfo={false} />)
67
+ fireEvent.click(screen.getByTestId('tempo-display'))
68
+ expect(screen.queryByTestId('tempo-tooltip')).not.toBeInTheDocument()
69
+ })
70
+
71
+ describe('sizes', () => {
72
+ it('renders at sm size', () => {
73
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} size="sm" />)
74
+ expect(screen.getByTestId('tempo-value')).toBeInTheDocument()
75
+ })
76
+
77
+ it('renders at md size', () => {
78
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} size="md" />)
79
+ expect(screen.getByTestId('tempo-value')).toBeInTheDocument()
80
+ })
81
+ })
82
+
83
+ describe('accessibility', () => {
84
+ it('has no accessibility violations', async () => {
85
+ const { container } = render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
86
+ const results = await axe(container)
87
+ expect(results).toHaveNoViolations()
88
+ })
89
+ })
90
+ })
@@ -0,0 +1,207 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useState, useCallback } from 'react'
3
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
4
+
5
+ export interface TempoDisplayProps extends ViewProps {
6
+ /** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
7
+ tempo: [number, number, number, number]
8
+ size?: 'sm' | 'md'
9
+ /** Colored phases or mono (all gray) */
10
+ colored?: boolean
11
+ /** Show info tooltip on press */
12
+ showInfo?: boolean
13
+ onPress?: () => void
14
+ className?: string
15
+ }
16
+
17
+ const INTER = 'Inter, sans-serif'
18
+ const TEXT_TERTIARY = '#6B7280'
19
+
20
+ // Phase colors: [eccentric, pauseBottom, concentric, pauseTop]
21
+ const phaseColors = {
22
+ eccentric: '#FFB020', // status-warning (amber)
23
+ pauseBottom: TEXT_TERTIARY,
24
+ concentric: '#14B8A6', // status-success (teal)
25
+ pauseTop: TEXT_TERTIARY,
26
+ dash: TEXT_TERTIARY,
27
+ }
28
+
29
+ function TempoValue({
30
+ value,
31
+ color,
32
+ fontSize,
33
+ }: {
34
+ value: number
35
+ color: string
36
+ fontSize: number
37
+ }) {
38
+ return (
39
+ <Text
40
+ style={{
41
+ fontFamily: INTER,
42
+ fontSize,
43
+ color,
44
+ fontWeight: '600',
45
+ letterSpacing: 1,
46
+ }}
47
+ >
48
+ {value}
49
+ </Text>
50
+ )
51
+ }
52
+
53
+ function TempoSeparator({ color, fontSize }: { color: string; fontSize: number }) {
54
+ return (
55
+ <Text
56
+ style={{
57
+ fontFamily: INTER,
58
+ fontSize,
59
+ color,
60
+ fontWeight: '600',
61
+ letterSpacing: 1,
62
+ }}
63
+ >
64
+ -
65
+ </Text>
66
+ )
67
+ }
68
+
69
+ export function TempoDisplay({
70
+ tempo,
71
+ size = 'md',
72
+ colored = true,
73
+ showInfo = true,
74
+ onPress,
75
+ className,
76
+ ...props
77
+ }: TempoDisplayProps) {
78
+ const [showTooltip, setShowTooltip] = useState(false)
79
+ const [eccentric, pauseBottom, concentric, pauseTop] = tempo
80
+ const isSm = size === 'sm'
81
+ const fontSize = isSm ? 9 : 11
82
+ const monoColor = TEXT_TERTIARY
83
+
84
+ const handlePress = useCallback(() => {
85
+ if (onPress) {
86
+ onPress()
87
+ }
88
+ if (showInfo) {
89
+ setShowTooltip((prev) => !prev)
90
+ }
91
+ }, [onPress, showInfo])
92
+
93
+ const content = (
94
+ <View
95
+ className={className}
96
+ style={{
97
+ flexDirection: 'row',
98
+ alignItems: 'center',
99
+ backgroundColor: '#1C1C1C',
100
+ paddingHorizontal: isSm ? 6 : 8,
101
+ paddingVertical: 3,
102
+ borderRadius: 4,
103
+ }}
104
+ {...props}
105
+ >
106
+ <Text
107
+ style={{
108
+ fontFamily: INTER,
109
+ fontSize: 9,
110
+ fontWeight: '500',
111
+ color: TEXT_TERTIARY,
112
+ letterSpacing: 0.5,
113
+ textTransform: 'uppercase',
114
+ marginRight: 6,
115
+ }}
116
+ >
117
+ TEMPO
118
+ </Text>
119
+ <View style={{ flexDirection: 'row' }} testID="tempo-value">
120
+ {colored ? (
121
+ <>
122
+ <TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
123
+ <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
124
+ <TempoValue value={pauseBottom} color={phaseColors.pauseBottom} fontSize={fontSize} />
125
+ <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
126
+ <TempoValue value={concentric} color={phaseColors.concentric} fontSize={fontSize} />
127
+ <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
128
+ <TempoValue value={pauseTop} color={phaseColors.pauseTop} fontSize={fontSize} />
129
+ </>
130
+ ) : (
131
+ <Text
132
+ style={{
133
+ fontFamily: INTER,
134
+ fontSize,
135
+ color: monoColor,
136
+ fontWeight: '600',
137
+ letterSpacing: 1,
138
+ }}
139
+ >
140
+ {eccentric}-{pauseBottom}-{concentric}-{pauseTop}
141
+ </Text>
142
+ )}
143
+ </View>
144
+ </View>
145
+ )
146
+
147
+ return (
148
+ <Pressable
149
+ accessibilityLabel={`Tempo: ${eccentric} second eccentric, ${pauseBottom} second pause, ${concentric} second concentric, ${pauseTop} second pause`}
150
+ accessibilityRole="button"
151
+ onPress={handlePress}
152
+ testID="tempo-display"
153
+ >
154
+ {content}
155
+ {showTooltip && (
156
+ <View
157
+ style={{
158
+ position: 'absolute',
159
+ bottom: '100%',
160
+ left: '50%',
161
+ transform: [{ translateX: '-50%' as unknown as number }],
162
+ marginBottom: 8,
163
+ zIndex: 20,
164
+ alignItems: 'center',
165
+ }}
166
+ testID="tempo-tooltip"
167
+ >
168
+ <View
169
+ style={{
170
+ backgroundColor: '#191919',
171
+ borderRadius: 6,
172
+ paddingVertical: 8,
173
+ paddingHorizontal: 12,
174
+ borderWidth: 1,
175
+ borderColor: '#2C2C2C',
176
+ }}
177
+ >
178
+ <Text style={{ fontSize: 10, lineHeight: 16, color: '#9CA3AF', fontFamily: INTER }}>
179
+ Eccentric: {eccentric}s
180
+ </Text>
181
+ <Text style={{ fontSize: 10, lineHeight: 16, color: '#9CA3AF', fontFamily: INTER }}>
182
+ Pause (bottom): {pauseBottom}s
183
+ </Text>
184
+ <Text style={{ fontSize: 10, lineHeight: 16, color: '#9CA3AF', fontFamily: INTER }}>
185
+ Concentric: {concentric}s
186
+ </Text>
187
+ <Text style={{ fontSize: 10, lineHeight: 16, color: '#9CA3AF', fontFamily: INTER }}>
188
+ Pause (top): {pauseTop}s
189
+ </Text>
190
+ </View>
191
+ <View
192
+ style={{
193
+ width: 0,
194
+ height: 0,
195
+ borderLeftWidth: 5,
196
+ borderRightWidth: 5,
197
+ borderTopWidth: 5,
198
+ borderLeftColor: 'transparent',
199
+ borderRightColor: 'transparent',
200
+ borderTopColor: '#2C2C2C',
201
+ }}
202
+ />
203
+ </View>
204
+ )}
205
+ </Pressable>
206
+ )
207
+ }
@@ -0,0 +1,125 @@
1
+ import { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View } from 'react-native'
4
+ import { VelocityStrip } from './VelocityStrip'
5
+
6
+ const meta: Meta<typeof VelocityStrip> = {
7
+ title: 'Custom/Workout/VelocityStrip',
8
+ component: VelocityStrip,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ expanded: {
12
+ control: 'boolean',
13
+ description: 'Whether the strip is expanded into a bar chart',
14
+ },
15
+ variant: {
16
+ control: 'select',
17
+ options: ['full', 'mini'],
18
+ description: 'Display variant',
19
+ },
20
+ showInfo: {
21
+ control: 'boolean',
22
+ description: 'Whether to show the info row (zone name + loss %) in expanded mode',
23
+ },
24
+ },
25
+ }
26
+
27
+ export default meta
28
+ type Story = StoryObj<typeof VelocityStrip>
29
+
30
+ const fastSet = [1.15, 1.12, 1.08, 1.05, 1.02]
31
+ const slowSet = [0.65, 0.58, 0.52, 0.48, 0.42]
32
+ const mixedSet = [1.1, 0.95, 0.82, 0.68, 0.55, 0.45]
33
+ const moderateSet = [0.88, 0.85, 0.82, 0.78, 0.76]
34
+
35
+ export const DefaultCollapsed: Story = {
36
+ args: {
37
+ velocities: moderateSet,
38
+ onToggle: () => {},
39
+ },
40
+ }
41
+
42
+ export const Expanded: Story = {
43
+ args: {
44
+ velocities: moderateSet,
45
+ expanded: true,
46
+ onToggle: () => {},
47
+ },
48
+ }
49
+
50
+ export const ExpandedNoInfo: Story = {
51
+ args: {
52
+ velocities: moderateSet,
53
+ expanded: true,
54
+ showInfo: false,
55
+ onToggle: () => {},
56
+ },
57
+ }
58
+
59
+ export const FastEasySet: Story = {
60
+ args: {
61
+ velocities: fastSet,
62
+ expanded: true,
63
+ onToggle: () => {},
64
+ },
65
+ }
66
+
67
+ export const SlowGrindingSet: Story = {
68
+ args: {
69
+ velocities: slowSet,
70
+ expanded: true,
71
+ onToggle: () => {},
72
+ },
73
+ }
74
+
75
+ export const MixedVelocities: Story = {
76
+ args: {
77
+ velocities: mixedSet,
78
+ expanded: true,
79
+ onToggle: () => {},
80
+ },
81
+ }
82
+
83
+ export const Mini: Story = {
84
+ args: {
85
+ velocities: mixedSet,
86
+ variant: 'mini',
87
+ },
88
+ }
89
+
90
+ function InteractiveVelocityStrip() {
91
+ const [expanded, setExpanded] = useState(false)
92
+ return (
93
+ <View style={{ width: 300, padding: 16 }}>
94
+ <VelocityStrip
95
+ velocities={moderateSet}
96
+ expanded={expanded}
97
+ onToggle={() => setExpanded(!expanded)}
98
+ onRepPress={(index, velocity) =>
99
+ console.log(`Rep ${index + 1}: ${velocity}`)
100
+ }
101
+ />
102
+ </View>
103
+ )
104
+ }
105
+
106
+ export const Interactive: Story = {
107
+ render: () => <InteractiveVelocityStrip />,
108
+ }
109
+
110
+ export const AllProfiles: Story = {
111
+ render: () => (
112
+ <View style={{ gap: 24, padding: 16 }}>
113
+ <View style={{ gap: 8 }}>
114
+ <VelocityStrip velocities={fastSet} expanded onToggle={() => {}} />
115
+ <VelocityStrip
116
+ velocities={moderateSet}
117
+ expanded
118
+ onToggle={() => {}}
119
+ />
120
+ <VelocityStrip velocities={slowSet} expanded onToggle={() => {}} />
121
+ <VelocityStrip velocities={mixedSet} expanded onToggle={() => {}} />
122
+ </View>
123
+ </View>
124
+ ),
125
+ }
@@ -0,0 +1,234 @@
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 {
5
+ VelocityStrip,
6
+ getVelocityZoneColor,
7
+ getVelocityZoneName,
8
+ calculateVelocityLoss,
9
+ calculateMeanVelocity,
10
+ } from './VelocityStrip'
11
+
12
+ const sampleVelocities = [1.1, 0.95, 0.82, 0.68, 0.45]
13
+
14
+ describe('VelocityStrip', () => {
15
+ it('renders correct number of segments', () => {
16
+ render(<VelocityStrip velocities={sampleVelocities} />)
17
+ for (let i = 0; i < sampleVelocities.length; i++) {
18
+ expect(screen.getByTestId(`velocity-bar-${i}`)).toBeInTheDocument()
19
+ }
20
+ })
21
+
22
+ it('renders with expanded state', () => {
23
+ render(<VelocityStrip velocities={sampleVelocities} expanded />)
24
+ expect(screen.getByTestId('velocity-strip')).toBeInTheDocument()
25
+ expect(screen.getByTestId('velocity-info-row')).toBeInTheDocument()
26
+ })
27
+
28
+ it('shows velocity labels when expanded', () => {
29
+ render(<VelocityStrip velocities={sampleVelocities} expanded />)
30
+ for (let i = 0; i < sampleVelocities.length; i++) {
31
+ expect(screen.getByTestId(`velocity-label-${i}`)).toBeInTheDocument()
32
+ }
33
+ })
34
+
35
+ it('fires onToggle on press', () => {
36
+ const onToggle = vi.fn()
37
+ render(
38
+ <VelocityStrip velocities={sampleVelocities} onToggle={onToggle} />,
39
+ )
40
+ const pressable = screen.getByTestId('velocity-strip-pressable')
41
+ fireEvent.click(pressable)
42
+ expect(onToggle).toHaveBeenCalledOnce()
43
+ })
44
+
45
+ it('fires onRepPress with correct index when bar is tapped', () => {
46
+ const onRepPress = vi.fn()
47
+ render(
48
+ <VelocityStrip
49
+ velocities={sampleVelocities}
50
+ expanded
51
+ onRepPress={onRepPress}
52
+ />,
53
+ )
54
+ const bar = screen.getByTestId('velocity-bar-pressable-2')
55
+ fireEvent.click(bar)
56
+ expect(onRepPress).toHaveBeenCalledWith(2, 0.82)
57
+ })
58
+
59
+ it('sets accessibility role and label', () => {
60
+ render(<VelocityStrip velocities={sampleVelocities} />)
61
+ expect(
62
+ screen.getByLabelText(
63
+ 'Velocity chart for set, 5 reps, tap to expand',
64
+ ),
65
+ ).toBeInTheDocument()
66
+ })
67
+
68
+ it('sets expanded accessibility label when expanded', () => {
69
+ render(<VelocityStrip velocities={sampleVelocities} expanded />)
70
+ expect(
71
+ screen.getByLabelText(
72
+ 'Velocity chart for set, 5 reps, tap to collapse',
73
+ ),
74
+ ).toBeInTheDocument()
75
+ })
76
+
77
+ describe('showInfo prop', () => {
78
+ it('shows info row when expanded and showInfo is true (default)', () => {
79
+ render(<VelocityStrip velocities={sampleVelocities} expanded />)
80
+ expect(screen.getByTestId('velocity-info-row')).toBeInTheDocument()
81
+ })
82
+
83
+ it('hides info row when expanded and showInfo is false', () => {
84
+ render(<VelocityStrip velocities={sampleVelocities} expanded showInfo={false} />)
85
+ expect(screen.queryByTestId('velocity-info-row')).not.toBeInTheDocument()
86
+ })
87
+
88
+ it('does not show info row when collapsed regardless of showInfo', () => {
89
+ render(<VelocityStrip velocities={sampleVelocities} showInfo />)
90
+ expect(screen.queryByTestId('velocity-info-row')).not.toBeInTheDocument()
91
+ })
92
+ })
93
+
94
+ describe('mini variant', () => {
95
+ it('renders mini strip', () => {
96
+ render(<VelocityStrip velocities={sampleVelocities} variant="mini" />)
97
+ expect(screen.getByTestId('velocity-strip-mini')).toBeInTheDocument()
98
+ })
99
+
100
+ it('does not respond to press', () => {
101
+ const onToggle = vi.fn()
102
+ render(
103
+ <VelocityStrip
104
+ velocities={sampleVelocities}
105
+ variant="mini"
106
+ onToggle={onToggle}
107
+ />,
108
+ )
109
+ expect(
110
+ screen.queryByTestId('velocity-strip-pressable'),
111
+ ).not.toBeInTheDocument()
112
+ })
113
+
114
+ it('renders correct number of segments', () => {
115
+ render(<VelocityStrip velocities={sampleVelocities} variant="mini" />)
116
+ for (let i = 0; i < sampleVelocities.length; i++) {
117
+ expect(screen.getByTestId(`velocity-bar-${i}`)).toBeInTheDocument()
118
+ }
119
+ })
120
+ })
121
+
122
+ describe('accessibility', () => {
123
+ it('has no accessibility violations', async () => {
124
+ const { container } = render(
125
+ <VelocityStrip velocities={sampleVelocities} onToggle={() => {}} />,
126
+ )
127
+ const results = await axe(container)
128
+ expect(results).toHaveNoViolations()
129
+ })
130
+
131
+ it('has no accessibility violations when expanded', async () => {
132
+ const { container } = render(
133
+ <VelocityStrip velocities={sampleVelocities} expanded onToggle={() => {}} />,
134
+ )
135
+ const results = await axe(container)
136
+ expect(results).toHaveNoViolations()
137
+ })
138
+
139
+ it('has no accessibility violations when expanded with rep press', async () => {
140
+ const { container } = render(
141
+ <VelocityStrip
142
+ velocities={sampleVelocities}
143
+ expanded
144
+ onRepPress={() => {}}
145
+ />,
146
+ )
147
+ const results = await axe(container)
148
+ expect(results).toHaveNoViolations()
149
+ })
150
+
151
+ it('has no accessibility violations in mini variant', async () => {
152
+ const { container } = render(
153
+ <VelocityStrip velocities={sampleVelocities} variant="mini" />,
154
+ )
155
+ const results = await axe(container)
156
+ expect(results).toHaveNoViolations()
157
+ })
158
+
159
+ it('outer pressable has accessibility label when onToggle provided', () => {
160
+ render(<VelocityStrip velocities={sampleVelocities} onToggle={() => {}} />)
161
+ expect(
162
+ screen.getByLabelText('Velocity chart for set, 5 reps, tap to expand'),
163
+ ).toBeInTheDocument()
164
+ })
165
+
166
+ it('rep pressables have accessibility labels when expanded with onRepPress', () => {
167
+ render(
168
+ <VelocityStrip
169
+ velocities={sampleVelocities}
170
+ expanded
171
+ onRepPress={() => {}}
172
+ />,
173
+ )
174
+ expect(
175
+ screen.getByLabelText('Rep 1: 1.10 meters per second, tap for details'),
176
+ ).toBeInTheDocument()
177
+ })
178
+ })
179
+ })
180
+
181
+ describe('getVelocityZoneColor', () => {
182
+ it('returns vel-green for velocities >= 1.0', () => {
183
+ expect(getVelocityZoneColor(1.0)).toBe('vel-green')
184
+ expect(getVelocityZoneColor(1.5)).toBe('vel-green')
185
+ })
186
+
187
+ it('returns vel-yellow for velocities >= 0.75 and < 1.0', () => {
188
+ expect(getVelocityZoneColor(0.75)).toBe('vel-yellow')
189
+ expect(getVelocityZoneColor(0.99)).toBe('vel-yellow')
190
+ })
191
+
192
+ it('returns vel-orange for velocities >= 0.50 and < 0.75', () => {
193
+ expect(getVelocityZoneColor(0.5)).toBe('vel-orange')
194
+ expect(getVelocityZoneColor(0.74)).toBe('vel-orange')
195
+ })
196
+
197
+ it('returns vel-red for velocities < 0.50', () => {
198
+ expect(getVelocityZoneColor(0.49)).toBe('vel-red')
199
+ expect(getVelocityZoneColor(0.1)).toBe('vel-red')
200
+ })
201
+ })
202
+
203
+ describe('getVelocityZoneName', () => {
204
+ it('returns correct zone names', () => {
205
+ expect(getVelocityZoneName(1.2)).toBe('Speed')
206
+ expect(getVelocityZoneName(0.85)).toBe('Power')
207
+ expect(getVelocityZoneName(0.6)).toBe('Strength-Speed')
208
+ expect(getVelocityZoneName(0.3)).toBe('Strength')
209
+ })
210
+ })
211
+
212
+ describe('calculateVelocityLoss', () => {
213
+ it('calculates percentage loss from first to last rep', () => {
214
+ expect(calculateVelocityLoss([1.0, 0.8])).toBe(20)
215
+ })
216
+
217
+ it('returns 0 for single rep', () => {
218
+ expect(calculateVelocityLoss([1.0])).toBe(0)
219
+ })
220
+
221
+ it('returns 0 for empty array', () => {
222
+ expect(calculateVelocityLoss([])).toBe(0)
223
+ })
224
+ })
225
+
226
+ describe('calculateMeanVelocity', () => {
227
+ it('calculates mean of velocities', () => {
228
+ expect(calculateMeanVelocity([1.0, 0.8, 0.6])).toBeCloseTo(0.8)
229
+ })
230
+
231
+ it('returns 0 for empty array', () => {
232
+ expect(calculateMeanVelocity([])).toBe(0)
233
+ })
234
+ })