@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.
- package/dist/chunk-3VLOBS6O.mjs +846 -0
- package/dist/chunk-3VLOBS6O.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
- package/dist/chunk-5SSKGS6E.mjs.map +1 -0
- package/dist/index.d.mts +1005 -4
- package/dist/index.d.ts +1005 -4
- package/dist/index.js +6043 -213
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5901 -177
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +57 -1
- package/dist/theme/index.d.ts +57 -1
- package/dist/theme/index.js +63 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +18 -0
- package/dist/theme/tokens-css.d.ts +18 -0
- package/dist/theme/tokens-css.js +289 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/docs/WEB_SETUP.md +217 -0
- package/package.json +17 -5
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +348 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +163 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +235 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
- package/src/components/custom/Workout/RestTimer.tsx +8 -6
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +253 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +159 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +113 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +34 -0
- package/src/theme/workout-tokens.ts +61 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
5
|
+
|
|
6
|
+
export interface InputBarProps {
|
|
7
|
+
exerciseName: string
|
|
8
|
+
setNumber: number
|
|
9
|
+
totalSets: number | null
|
|
10
|
+
reps: string
|
|
11
|
+
weight: string
|
|
12
|
+
unit: 'lbs' | 'kg'
|
|
13
|
+
onRepsChange: (value: string) => void
|
|
14
|
+
onWeightChange: (value: string) => void
|
|
15
|
+
onRecord: () => void
|
|
16
|
+
canRecord: boolean
|
|
17
|
+
visible: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BRAND_PRIMARY = '#FF7900'
|
|
21
|
+
const TEXT_PRIMARY = '#F3F4F6'
|
|
22
|
+
const TEXT_TERTIARY = '#6B7280'
|
|
23
|
+
|
|
24
|
+
const inputStyle = {
|
|
25
|
+
fontSize: 14,
|
|
26
|
+
fontWeight: '600' as const,
|
|
27
|
+
fontFamily: 'Inter, sans-serif',
|
|
28
|
+
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
29
|
+
borderWidth: 1,
|
|
30
|
+
borderColor: WORKOUT_TOKENS.border.strong,
|
|
31
|
+
borderRadius: 6,
|
|
32
|
+
textAlign: 'center' as const,
|
|
33
|
+
color: TEXT_PRIMARY,
|
|
34
|
+
paddingVertical: 5,
|
|
35
|
+
paddingHorizontal: 2,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function InputBar({
|
|
39
|
+
exerciseName,
|
|
40
|
+
setNumber,
|
|
41
|
+
totalSets,
|
|
42
|
+
reps,
|
|
43
|
+
weight,
|
|
44
|
+
unit,
|
|
45
|
+
onRepsChange,
|
|
46
|
+
onWeightChange,
|
|
47
|
+
onRecord,
|
|
48
|
+
canRecord,
|
|
49
|
+
visible,
|
|
50
|
+
}: InputBarProps) {
|
|
51
|
+
if (!visible) return null
|
|
52
|
+
|
|
53
|
+
const setLabel = totalSets != null ? `Set ${setNumber}/${totalSets}` : `Set ${setNumber}`
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<View
|
|
57
|
+
style={{
|
|
58
|
+
width: '100%',
|
|
59
|
+
backgroundColor: WORKOUT_TOKENS.surface.elevated,
|
|
60
|
+
borderTopWidth: 1,
|
|
61
|
+
borderTopColor: WORKOUT_TOKENS.border.default,
|
|
62
|
+
paddingTop: 10,
|
|
63
|
+
paddingHorizontal: 16,
|
|
64
|
+
paddingBottom: 12,
|
|
65
|
+
flexDirection: 'row',
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
gap: 10,
|
|
68
|
+
}}
|
|
69
|
+
accessibilityRole="toolbar"
|
|
70
|
+
testID="input-bar"
|
|
71
|
+
>
|
|
72
|
+
<View style={{ minWidth: 80, flexDirection: 'column' }}>
|
|
73
|
+
<Text
|
|
74
|
+
style={{
|
|
75
|
+
fontSize: 12,
|
|
76
|
+
fontWeight: '700',
|
|
77
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
78
|
+
color: TEXT_PRIMARY,
|
|
79
|
+
}}
|
|
80
|
+
testID="input-bar-exercise-name"
|
|
81
|
+
numberOfLines={1}
|
|
82
|
+
>
|
|
83
|
+
{exerciseName}
|
|
84
|
+
</Text>
|
|
85
|
+
<Text
|
|
86
|
+
style={{
|
|
87
|
+
fontSize: 10,
|
|
88
|
+
fontFamily: 'Inter, sans-serif',
|
|
89
|
+
color: TEXT_TERTIARY,
|
|
90
|
+
}}
|
|
91
|
+
testID="input-bar-set-info"
|
|
92
|
+
>
|
|
93
|
+
{setLabel}
|
|
94
|
+
</Text>
|
|
95
|
+
</View>
|
|
96
|
+
|
|
97
|
+
<View className="flex-1 flex-row items-center" style={{ gap: 4 }}>
|
|
98
|
+
<TextInput
|
|
99
|
+
value={reps}
|
|
100
|
+
onChangeText={onRepsChange}
|
|
101
|
+
style={{ ...inputStyle, width: 36 }}
|
|
102
|
+
accessibilityLabel="Reps"
|
|
103
|
+
testID="input-bar-reps"
|
|
104
|
+
keyboardType="numeric"
|
|
105
|
+
/>
|
|
106
|
+
<Text
|
|
107
|
+
style={{
|
|
108
|
+
fontSize: 12,
|
|
109
|
+
fontFamily: 'Inter, sans-serif',
|
|
110
|
+
color: TEXT_TERTIARY,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{'\u00D7'}
|
|
114
|
+
</Text>
|
|
115
|
+
<TextInput
|
|
116
|
+
value={weight}
|
|
117
|
+
onChangeText={onWeightChange}
|
|
118
|
+
style={{ ...inputStyle, width: 52 }}
|
|
119
|
+
accessibilityLabel={`Weight in ${unit}`}
|
|
120
|
+
testID="input-bar-weight"
|
|
121
|
+
keyboardType="numeric"
|
|
122
|
+
/>
|
|
123
|
+
<Text
|
|
124
|
+
style={{
|
|
125
|
+
fontSize: 12,
|
|
126
|
+
fontWeight: '500',
|
|
127
|
+
fontFamily: 'Inter, sans-serif',
|
|
128
|
+
color: TEXT_TERTIARY,
|
|
129
|
+
}}
|
|
130
|
+
testID="input-bar-unit"
|
|
131
|
+
>
|
|
132
|
+
{unit}
|
|
133
|
+
</Text>
|
|
134
|
+
</View>
|
|
135
|
+
|
|
136
|
+
<Pressable
|
|
137
|
+
onPress={onRecord}
|
|
138
|
+
disabled={!canRecord}
|
|
139
|
+
style={({ pressed }) => ({
|
|
140
|
+
backgroundColor: BRAND_PRIMARY,
|
|
141
|
+
borderRadius: 8,
|
|
142
|
+
paddingVertical: 10,
|
|
143
|
+
paddingHorizontal: 16,
|
|
144
|
+
opacity: !canRecord ? 0.4 : pressed ? 0.8 : 1,
|
|
145
|
+
})}
|
|
146
|
+
accessibilityRole="button"
|
|
147
|
+
accessibilityLabel="Record set"
|
|
148
|
+
testID="input-bar-record"
|
|
149
|
+
>
|
|
150
|
+
<Text
|
|
151
|
+
style={{
|
|
152
|
+
fontSize: 13,
|
|
153
|
+
fontWeight: '700',
|
|
154
|
+
fontFamily: 'Inter, sans-serif',
|
|
155
|
+
color: '#FFFFFF',
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
Record
|
|
159
|
+
</Text>
|
|
160
|
+
</Pressable>
|
|
161
|
+
</View>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { View } from 'react-native'
|
|
4
|
+
import { MesoCard } from './MesoCard'
|
|
5
|
+
import type { WeekRowProps } from './WeekRow'
|
|
6
|
+
|
|
7
|
+
const weeks: WeekRowProps[] = [
|
|
8
|
+
{
|
|
9
|
+
weekNumber: 1,
|
|
10
|
+
totalWeeks: 4,
|
|
11
|
+
workouts: [
|
|
12
|
+
{ name: 'Upper', status: 'completed' },
|
|
13
|
+
{ name: 'Lower', status: 'completed' },
|
|
14
|
+
],
|
|
15
|
+
intensityLevel: 0.4,
|
|
16
|
+
intensityThreshold: 0.85,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
weekNumber: 2,
|
|
20
|
+
totalWeeks: 4,
|
|
21
|
+
workouts: [
|
|
22
|
+
{ name: 'Upper', status: 'current' },
|
|
23
|
+
{ name: 'Lower', status: 'upcoming' },
|
|
24
|
+
],
|
|
25
|
+
intensityLevel: 0.6,
|
|
26
|
+
intensityThreshold: 0.85,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
weekNumber: 3,
|
|
30
|
+
totalWeeks: 4,
|
|
31
|
+
workouts: [
|
|
32
|
+
{ name: 'Upper', status: 'upcoming' },
|
|
33
|
+
{ name: 'Lower', status: 'upcoming' },
|
|
34
|
+
],
|
|
35
|
+
intensityLevel: 0.8,
|
|
36
|
+
intensityThreshold: 0.85,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
weekNumber: 4,
|
|
40
|
+
totalWeeks: 4,
|
|
41
|
+
workouts: [
|
|
42
|
+
{ name: 'Upper', status: 'upcoming' },
|
|
43
|
+
{ name: 'Lower', status: 'upcoming' },
|
|
44
|
+
],
|
|
45
|
+
intensityLevel: 0.35,
|
|
46
|
+
isDeload: true,
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const meta: Meta<typeof MesoCard> = {
|
|
51
|
+
title: 'Custom/Workout/MesoCard',
|
|
52
|
+
component: MesoCard,
|
|
53
|
+
tags: ['autodocs'],
|
|
54
|
+
argTypes: {
|
|
55
|
+
goal: {
|
|
56
|
+
control: 'select',
|
|
57
|
+
options: ['Hypertrophy', 'Strength', 'Peaking'],
|
|
58
|
+
description: 'Training goal shown in the badge',
|
|
59
|
+
},
|
|
60
|
+
expanded: {
|
|
61
|
+
control: 'boolean',
|
|
62
|
+
description: 'Whether the week list is revealed',
|
|
63
|
+
},
|
|
64
|
+
highlighted: {
|
|
65
|
+
control: 'boolean',
|
|
66
|
+
description: 'Synced highlight with the MesoProgressBar',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default meta
|
|
72
|
+
type Story = StoryObj<typeof MesoCard>
|
|
73
|
+
|
|
74
|
+
export const Collapsed: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
name: 'Accumulation',
|
|
77
|
+
goal: 'Hypertrophy',
|
|
78
|
+
split: 'Upper/Lower',
|
|
79
|
+
weekRange: 'Weeks 1-4',
|
|
80
|
+
currentWeek: 2,
|
|
81
|
+
totalWeeks: 4,
|
|
82
|
+
weeks,
|
|
83
|
+
volumeHeatmap: [
|
|
84
|
+
{ group: 'chest', percentage: 80 },
|
|
85
|
+
{ group: 'back', percentage: 70 },
|
|
86
|
+
{ group: 'legs', percentage: 90 },
|
|
87
|
+
{ group: 'shoulders', percentage: 45 },
|
|
88
|
+
{ group: 'arms', percentage: 35 },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const Expanded: Story = {
|
|
94
|
+
args: {
|
|
95
|
+
...Collapsed.args,
|
|
96
|
+
expanded: true,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const Highlighted: Story = {
|
|
101
|
+
args: {
|
|
102
|
+
...Collapsed.args,
|
|
103
|
+
expanded: true,
|
|
104
|
+
highlighted: true,
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const StrengthBlock: Story = {
|
|
109
|
+
args: {
|
|
110
|
+
name: 'Intensification',
|
|
111
|
+
goal: 'Strength',
|
|
112
|
+
split: 'Push/Pull/Legs',
|
|
113
|
+
weekRange: 'Weeks 5-8',
|
|
114
|
+
currentWeek: 6,
|
|
115
|
+
totalWeeks: 4,
|
|
116
|
+
weeks: weeks.map((w) => ({ ...w, weekNumber: w.weekNumber + 4 })),
|
|
117
|
+
volumeHeatmap: [
|
|
118
|
+
{ group: 'chest', percentage: 60 },
|
|
119
|
+
{ group: 'back', percentage: 65 },
|
|
120
|
+
{ group: 'legs', percentage: 75 },
|
|
121
|
+
],
|
|
122
|
+
expanded: true,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function InteractiveMesoCard() {
|
|
127
|
+
const [expanded, setExpanded] = useState(false)
|
|
128
|
+
return (
|
|
129
|
+
<View style={{ maxWidth: 420, padding: 16 }}>
|
|
130
|
+
<MesoCard
|
|
131
|
+
name="Accumulation"
|
|
132
|
+
goal="Hypertrophy"
|
|
133
|
+
split="Upper/Lower"
|
|
134
|
+
weekRange="Weeks 1-4"
|
|
135
|
+
currentWeek={2}
|
|
136
|
+
totalWeeks={4}
|
|
137
|
+
weeks={weeks}
|
|
138
|
+
volumeHeatmap={[
|
|
139
|
+
{ group: 'chest', percentage: 80 },
|
|
140
|
+
{ group: 'back', percentage: 70 },
|
|
141
|
+
{ group: 'legs', percentage: 90 },
|
|
142
|
+
]}
|
|
143
|
+
expanded={expanded}
|
|
144
|
+
onToggle={() => setExpanded((v) => !v)}
|
|
145
|
+
/>
|
|
146
|
+
</View>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const Interactive: Story = {
|
|
151
|
+
render: () => <InteractiveMesoCard />,
|
|
152
|
+
}
|