@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,87 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { WeightBadge } from './WeightBadge'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof WeightBadge> = {
|
|
6
|
+
title: 'Custom/Workout/WeightBadge',
|
|
7
|
+
component: WeightBadge,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
value: { control: 'number', description: 'Weight value' },
|
|
11
|
+
unit: { control: 'select', options: ['lbs', 'kg'], description: 'Unit system' },
|
|
12
|
+
reps: {
|
|
13
|
+
control: 'number',
|
|
14
|
+
description: 'N-rep max context (omit for estimated 1RM)',
|
|
15
|
+
},
|
|
16
|
+
showIcon: { control: 'boolean', description: 'Show the weight icon' },
|
|
17
|
+
isPr: { control: 'boolean', description: 'Apply PR styling (orange bg, star)' },
|
|
18
|
+
size: { control: 'select', options: ['sm', 'md', 'lg'], description: 'Badge size' },
|
|
19
|
+
delta: { control: 'number', description: 'Percentage change since last mesocycle' },
|
|
20
|
+
onPress: { action: 'pressed', description: 'Callback when tapped' },
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default meta
|
|
25
|
+
type Story = StoryObj<typeof WeightBadge>
|
|
26
|
+
|
|
27
|
+
export const Default: Story = {
|
|
28
|
+
args: { value: 217, unit: 'lbs' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const WithoutIcon: Story = {
|
|
32
|
+
args: { value: 217, unit: 'lbs', showIcon: false },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const RepMax: Story = {
|
|
36
|
+
args: { value: 275, unit: 'lbs', reps: 5 },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const WithPr: Story = {
|
|
40
|
+
args: { value: 217, unit: 'lbs', isPr: true },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const WithPositiveDelta: Story = {
|
|
44
|
+
args: { value: 217, unit: 'lbs', delta: 3 },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const WithNegativeDelta: Story = {
|
|
48
|
+
args: { value: 217, unit: 'lbs', delta: -2 },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const PrWithDelta: Story = {
|
|
52
|
+
args: { value: 217, unit: 'lbs', isPr: true, delta: 3, size: 'lg' },
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const AllVariants: Story = {
|
|
56
|
+
render: () => (
|
|
57
|
+
<View style={{ gap: 12 }}>
|
|
58
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
59
|
+
<WeightBadge value={217} unit="lbs" size="sm" />
|
|
60
|
+
<WeightBadge value={217} unit="lbs" size="md" />
|
|
61
|
+
<WeightBadge value={217} unit="lbs" size="lg" />
|
|
62
|
+
</View>
|
|
63
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
64
|
+
<WeightBadge value={217} unit="lbs" size="sm" showIcon={false} />
|
|
65
|
+
<WeightBadge value={217} unit="lbs" size="md" showIcon={false} />
|
|
66
|
+
<WeightBadge value={217} unit="lbs" size="lg" showIcon={false} />
|
|
67
|
+
</View>
|
|
68
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
69
|
+
<WeightBadge value={275} unit="lbs" reps={5} />
|
|
70
|
+
<WeightBadge value={315} unit="lbs" reps={3} />
|
|
71
|
+
<WeightBadge value={185} unit="lbs" reps={8} />
|
|
72
|
+
</View>
|
|
73
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
74
|
+
<WeightBadge value={217} unit="lbs" size="sm" isPr />
|
|
75
|
+
<WeightBadge value={217} unit="lbs" size="md" isPr />
|
|
76
|
+
<WeightBadge value={217} unit="lbs" size="lg" isPr />
|
|
77
|
+
</View>
|
|
78
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
79
|
+
<WeightBadge value={217} unit="lbs" delta={3} />
|
|
80
|
+
<WeightBadge value={217} unit="lbs" delta={-2} />
|
|
81
|
+
</View>
|
|
82
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
83
|
+
<WeightBadge value={217} unit="lbs" isPr delta={3} size="lg" />
|
|
84
|
+
</View>
|
|
85
|
+
</View>
|
|
86
|
+
),
|
|
87
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
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 { WeightBadge } from './WeightBadge'
|
|
5
|
+
|
|
6
|
+
describe('WeightBadge', () => {
|
|
7
|
+
it('renders value and default unit', () => {
|
|
8
|
+
render(<WeightBadge value={225} />)
|
|
9
|
+
expect(screen.getByText('225 lbs')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('renders with kg unit', () => {
|
|
13
|
+
render(<WeightBadge value={100} unit="kg" />)
|
|
14
|
+
expect(screen.getByText('100 kg')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('wraps in Pressable when onPress is provided', () => {
|
|
18
|
+
const onPress = vi.fn()
|
|
19
|
+
render(<WeightBadge value={225} onPress={onPress} />)
|
|
20
|
+
const pressable = screen.getByTestId('base-badge-pressable')
|
|
21
|
+
fireEvent.click(pressable)
|
|
22
|
+
expect(onPress).toHaveBeenCalledOnce()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does not render Pressable when onPress is omitted', () => {
|
|
26
|
+
render(<WeightBadge value={225} />)
|
|
27
|
+
expect(screen.queryByTestId('base-badge-pressable')).not.toBeInTheDocument()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('showIcon', () => {
|
|
31
|
+
it('shows the weight icon by default', () => {
|
|
32
|
+
render(<WeightBadge value={225} />)
|
|
33
|
+
expect(screen.getByTestId('base-badge-icon')).toBeInTheDocument()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('hides the weight icon when showIcon is false', () => {
|
|
37
|
+
render(<WeightBadge value={225} showIcon={false} />)
|
|
38
|
+
expect(screen.queryByTestId('base-badge-icon')).not.toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('reps (rep-max context)', () => {
|
|
43
|
+
it('shows the estimated 1RM label when reps is omitted', () => {
|
|
44
|
+
render(<WeightBadge value={315} unit="lbs" />)
|
|
45
|
+
expect(
|
|
46
|
+
screen.getByLabelText('Estimated one rep max: 315 lbs'),
|
|
47
|
+
).toBeInTheDocument()
|
|
48
|
+
expect(screen.queryByTestId('weight-badge-repmax')).not.toBeInTheDocument()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('shows the rep-max qualifier and label when reps is provided', () => {
|
|
52
|
+
render(<WeightBadge value={275} unit="lbs" reps={5} />)
|
|
53
|
+
expect(screen.getByTestId('weight-badge-repmax')).toHaveTextContent('5RM')
|
|
54
|
+
expect(
|
|
55
|
+
screen.getByLabelText('5 rep max: 275 lbs'),
|
|
56
|
+
).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('isPr', () => {
|
|
61
|
+
it('renders without error when isPr is false', () => {
|
|
62
|
+
render(<WeightBadge value={225} />)
|
|
63
|
+
expect(screen.getByTestId('weight-badge')).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('renders without error when isPr is true', () => {
|
|
67
|
+
render(<WeightBadge value={315} isPr />)
|
|
68
|
+
expect(screen.getByTestId('weight-badge')).toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('uses default border color when not a PR', () => {
|
|
72
|
+
render(<WeightBadge value={225} />)
|
|
73
|
+
expect(screen.getByTestId('weight-badge')).toHaveStyle({
|
|
74
|
+
borderTopColor: '#1F1F1F',
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('uses brand-primary-subtle border color on PR state', () => {
|
|
79
|
+
render(<WeightBadge value={315} isPr />)
|
|
80
|
+
expect(screen.getByTestId('weight-badge')).toHaveStyle({
|
|
81
|
+
borderTopColor: 'rgba(255, 121, 0, 0.12)',
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('size', () => {
|
|
87
|
+
it('defaults to md size', () => {
|
|
88
|
+
render(<WeightBadge value={225} />)
|
|
89
|
+
expect(screen.getByTestId('weight-badge')).toBeInTheDocument()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('renders sm size', () => {
|
|
93
|
+
render(<WeightBadge value={225} size="sm" />)
|
|
94
|
+
expect(screen.getByTestId('weight-badge')).toBeInTheDocument()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('renders lg size', () => {
|
|
98
|
+
render(<WeightBadge value={225} size="lg" />)
|
|
99
|
+
expect(screen.getByTestId('weight-badge')).toBeInTheDocument()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('delta', () => {
|
|
104
|
+
it('shows positive delta', () => {
|
|
105
|
+
render(<WeightBadge value={225} delta={3} />)
|
|
106
|
+
expect(screen.getByTestId('weight-badge-delta')).toHaveTextContent('+3%')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('shows negative delta', () => {
|
|
110
|
+
render(<WeightBadge value={225} delta={-2} />)
|
|
111
|
+
expect(screen.getByTestId('weight-badge-delta')).toHaveTextContent('-2%')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('shows zero delta as positive', () => {
|
|
115
|
+
render(<WeightBadge value={225} delta={0} />)
|
|
116
|
+
expect(screen.getByTestId('weight-badge-delta')).toHaveTextContent('+0%')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('does not show delta when not provided', () => {
|
|
120
|
+
render(<WeightBadge value={225} />)
|
|
121
|
+
expect(screen.queryByTestId('weight-badge-delta')).not.toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('accessibility', () => {
|
|
126
|
+
it('has no accessibility violations', async () => {
|
|
127
|
+
const { container } = render(
|
|
128
|
+
<WeightBadge value={225} isPr onPress={() => {}} />,
|
|
129
|
+
)
|
|
130
|
+
const results = await axe(container)
|
|
131
|
+
expect(results).toHaveNoViolations()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('has no accessibility violations with delta', async () => {
|
|
135
|
+
const { container } = render(
|
|
136
|
+
<WeightBadge value={225} delta={3} onPress={() => {}} />,
|
|
137
|
+
)
|
|
138
|
+
const results = await axe(container)
|
|
139
|
+
expect(results).toHaveNoViolations()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('has no accessibility violations as static badge', async () => {
|
|
143
|
+
const { container } = render(
|
|
144
|
+
<WeightBadge value={315} unit="kg" isPr delta={-2} />,
|
|
145
|
+
)
|
|
146
|
+
const results = await axe(container)
|
|
147
|
+
expect(results).toHaveNoViolations()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('includes delta in the e1RM accessibility label', () => {
|
|
151
|
+
render(<WeightBadge value={225} delta={5} />)
|
|
152
|
+
expect(
|
|
153
|
+
screen.getByLabelText('Estimated one rep max: 225 lbs, +5% change'),
|
|
154
|
+
).toBeInTheDocument()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('includes delta in the rep-max accessibility label', () => {
|
|
158
|
+
render(<WeightBadge value={275} reps={5} delta={-3} />)
|
|
159
|
+
expect(
|
|
160
|
+
screen.getByLabelText('5 rep max: 275 lbs, -3% change'),
|
|
161
|
+
).toBeInTheDocument()
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { Text } from 'react-native'
|
|
3
|
+
import { Dumbbell } from 'lucide-react'
|
|
4
|
+
import {
|
|
5
|
+
BaseBadge,
|
|
6
|
+
baseBadgeSizeConfig,
|
|
7
|
+
type BaseBadgeSize,
|
|
8
|
+
type BaseBadgeProps,
|
|
9
|
+
} from './BaseBadge'
|
|
10
|
+
|
|
11
|
+
export type WeightBadgeSize = BaseBadgeSize
|
|
12
|
+
|
|
13
|
+
export interface WeightBadgeProps
|
|
14
|
+
extends Omit<BaseBadgeProps, 'variant' | 'icon' | 'children'> {
|
|
15
|
+
value: number
|
|
16
|
+
unit?: 'lbs' | 'kg'
|
|
17
|
+
/**
|
|
18
|
+
* When given, this is an N-rep max (e.g. "5RM"). When omitted, it is an
|
|
19
|
+
* estimated 1RM / e1RM.
|
|
20
|
+
*/
|
|
21
|
+
reps?: number
|
|
22
|
+
/** Percentage delta from previous mesocycle */
|
|
23
|
+
delta?: number
|
|
24
|
+
isPr?: boolean
|
|
25
|
+
/** Show the weight icon */
|
|
26
|
+
showIcon?: boolean
|
|
27
|
+
size?: WeightBadgeSize
|
|
28
|
+
onPress?: () => void
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function WeightBadge({
|
|
33
|
+
value,
|
|
34
|
+
unit = 'lbs',
|
|
35
|
+
reps,
|
|
36
|
+
delta,
|
|
37
|
+
isPr,
|
|
38
|
+
showIcon = true,
|
|
39
|
+
size = 'md',
|
|
40
|
+
onPress,
|
|
41
|
+
className,
|
|
42
|
+
...props
|
|
43
|
+
}: WeightBadgeProps) {
|
|
44
|
+
const config = baseBadgeSizeConfig[size]
|
|
45
|
+
const textColor = isPr ? '#FF7900' : '#9CA3AF'
|
|
46
|
+
// Dumbbell reads clearly as a weight/strength metric and is unit-agnostic
|
|
47
|
+
// across estimated-1RM and N-rep-max contexts (vs. TrendingUp which implies
|
|
48
|
+
// change/trend rather than a load value).
|
|
49
|
+
const iconColor = textColor
|
|
50
|
+
|
|
51
|
+
const repMaxClause = reps != null ? `${reps} rep max` : 'Estimated one rep max'
|
|
52
|
+
const deltaLabel =
|
|
53
|
+
delta != null ? `, ${delta >= 0 ? '+' : ''}${delta}% change` : ''
|
|
54
|
+
const fullLabel = `${repMaxClause}: ${value} ${unit}${deltaLabel}`
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<BaseBadge
|
|
58
|
+
variant={isPr ? 'pr' : 'plain'}
|
|
59
|
+
size={size}
|
|
60
|
+
onPress={onPress}
|
|
61
|
+
className={className}
|
|
62
|
+
icon={
|
|
63
|
+
showIcon ? (
|
|
64
|
+
<Dumbbell size={config.iconSize} color={iconColor} strokeWidth={2} />
|
|
65
|
+
) : undefined
|
|
66
|
+
}
|
|
67
|
+
accessibilityLabel={fullLabel}
|
|
68
|
+
testID="weight-badge"
|
|
69
|
+
{...props}
|
|
70
|
+
>
|
|
71
|
+
<Text
|
|
72
|
+
style={{
|
|
73
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
74
|
+
fontWeight: '600',
|
|
75
|
+
fontSize: config.fontSize,
|
|
76
|
+
color: textColor,
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{isPr ? '✳ ' : ''}
|
|
80
|
+
{value} {unit}
|
|
81
|
+
</Text>
|
|
82
|
+
{reps != null && (
|
|
83
|
+
<Text
|
|
84
|
+
style={{
|
|
85
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
86
|
+
fontWeight: '600',
|
|
87
|
+
fontSize: config.fontSize,
|
|
88
|
+
marginLeft: 2,
|
|
89
|
+
color: textColor,
|
|
90
|
+
opacity: 0.7,
|
|
91
|
+
}}
|
|
92
|
+
testID="weight-badge-repmax"
|
|
93
|
+
>
|
|
94
|
+
{reps}RM
|
|
95
|
+
</Text>
|
|
96
|
+
)}
|
|
97
|
+
{delta != null && (
|
|
98
|
+
<Text
|
|
99
|
+
style={{
|
|
100
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
101
|
+
fontSize: config.fontSize,
|
|
102
|
+
marginLeft: 4,
|
|
103
|
+
color: delta >= 0 ? '#4caf50' : '#ef5350',
|
|
104
|
+
}}
|
|
105
|
+
testID="weight-badge-delta"
|
|
106
|
+
>
|
|
107
|
+
{delta >= 0 ? '+' : ''}
|
|
108
|
+
{delta}%
|
|
109
|
+
</Text>
|
|
110
|
+
)}
|
|
111
|
+
</BaseBadge>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { WorkoutCard } from './WorkoutCard'
|
|
4
|
+
import type { ExerciseCardProps } from './ExerciseCard'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof WorkoutCard> = {
|
|
7
|
+
title: 'Custom/Workout/WorkoutCard',
|
|
8
|
+
component: WorkoutCard,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
argTypes: {
|
|
11
|
+
status: {
|
|
12
|
+
control: 'select',
|
|
13
|
+
options: ['completed', 'today', 'upcoming'],
|
|
14
|
+
description: 'Drives left-border color, tint, and opacity',
|
|
15
|
+
},
|
|
16
|
+
unit: {
|
|
17
|
+
control: 'select',
|
|
18
|
+
options: ['lbs', 'kg'],
|
|
19
|
+
description: 'Weight unit',
|
|
20
|
+
},
|
|
21
|
+
expanded: {
|
|
22
|
+
control: 'boolean',
|
|
23
|
+
description: 'Whether contained ExerciseCards are shown',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
decorators: [
|
|
27
|
+
(Story) => (
|
|
28
|
+
<View style={{ padding: 16, maxWidth: 420 }}>
|
|
29
|
+
<Story />
|
|
30
|
+
</View>
|
|
31
|
+
),
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default meta
|
|
36
|
+
type Story = StoryObj<typeof WorkoutCard>
|
|
37
|
+
|
|
38
|
+
const sampleExercises: ExerciseCardProps[] = [
|
|
39
|
+
{
|
|
40
|
+
name: 'Bench Press',
|
|
41
|
+
state: 'collapsed',
|
|
42
|
+
onToggle: () => {},
|
|
43
|
+
summary: { sets: 3, reps: 8, weight: 175, unit: 'lbs' },
|
|
44
|
+
e1rm: { value: 225, unit: 'lbs' },
|
|
45
|
+
setVelocities: [
|
|
46
|
+
[1.1, 0.95, 0.82],
|
|
47
|
+
[0.95, 0.88, 0.78],
|
|
48
|
+
],
|
|
49
|
+
totalPlannedSets: 3,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'Incline DB Press',
|
|
53
|
+
state: 'upcoming',
|
|
54
|
+
onToggle: () => {},
|
|
55
|
+
prescription: '3×10-12 @ RPE 8',
|
|
56
|
+
previousBest: '70 lbs × 11',
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const muscleGroups = [
|
|
61
|
+
{ group: 'chest', label: 'Chest', volumeStatus: 'productive' as const },
|
|
62
|
+
{ group: 'front-delts', label: 'Front Delts', volumeStatus: 'maintenance' as const },
|
|
63
|
+
{ group: 'triceps', label: 'Triceps', volumeStatus: 'under' as const },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
export const Today: Story = {
|
|
67
|
+
args: {
|
|
68
|
+
name: 'Upper A',
|
|
69
|
+
date: 'Mon, Jun 23',
|
|
70
|
+
duration: '45 min',
|
|
71
|
+
status: 'today',
|
|
72
|
+
muscleGroups,
|
|
73
|
+
totalSets: 18,
|
|
74
|
+
totalVolume: 12450,
|
|
75
|
+
unit: 'lbs',
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const Completed: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
name: 'Lower B',
|
|
82
|
+
date: 'Sat, Jun 21',
|
|
83
|
+
duration: '52 min',
|
|
84
|
+
status: 'completed',
|
|
85
|
+
muscleGroups: [
|
|
86
|
+
{ group: 'quads', label: 'Quads', volumeStatus: 'over' },
|
|
87
|
+
{ group: 'hamstrings', label: 'Hamstrings', volumeStatus: 'productive' },
|
|
88
|
+
{ group: 'glutes', label: 'Glutes', volumeStatus: 'maintenance' },
|
|
89
|
+
],
|
|
90
|
+
totalSets: 20,
|
|
91
|
+
totalVolume: 18200,
|
|
92
|
+
unit: 'lbs',
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const Upcoming: Story = {
|
|
97
|
+
args: {
|
|
98
|
+
name: 'Push B',
|
|
99
|
+
date: 'Wed, Jun 25',
|
|
100
|
+
status: 'upcoming',
|
|
101
|
+
muscleGroups,
|
|
102
|
+
totalSets: 16,
|
|
103
|
+
unit: 'lbs',
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const Expanded: Story = {
|
|
108
|
+
args: {
|
|
109
|
+
name: 'Upper A',
|
|
110
|
+
date: 'Mon, Jun 23',
|
|
111
|
+
duration: '45 min',
|
|
112
|
+
status: 'today',
|
|
113
|
+
muscleGroups,
|
|
114
|
+
totalSets: 18,
|
|
115
|
+
totalVolume: 12450,
|
|
116
|
+
unit: 'lbs',
|
|
117
|
+
expanded: true,
|
|
118
|
+
onToggle: () => {},
|
|
119
|
+
exercises: sampleExercises,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const NoToggle: Story = {
|
|
124
|
+
args: {
|
|
125
|
+
name: 'Pull A',
|
|
126
|
+
date: 'Thu, Jun 26',
|
|
127
|
+
status: 'upcoming',
|
|
128
|
+
muscleGroups: [
|
|
129
|
+
{ group: 'back', label: 'Back', volumeStatus: 'productive' },
|
|
130
|
+
{ group: 'biceps', label: 'Biceps', volumeStatus: 'maintenance' },
|
|
131
|
+
],
|
|
132
|
+
totalSets: 17,
|
|
133
|
+
unit: 'kg',
|
|
134
|
+
},
|
|
135
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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 { WorkoutCard, type WorkoutCardProps } from './WorkoutCard'
|
|
5
|
+
import type { ExerciseCardProps } from './ExerciseCard'
|
|
6
|
+
|
|
7
|
+
const baseProps: WorkoutCardProps = {
|
|
8
|
+
name: 'Upper A',
|
|
9
|
+
date: 'Mon, Jun 23',
|
|
10
|
+
status: 'today',
|
|
11
|
+
muscleGroups: [
|
|
12
|
+
{ group: 'chest', label: 'Chest', volumeStatus: 'productive' },
|
|
13
|
+
{ group: 'back', label: 'Back', volumeStatus: 'maintenance' },
|
|
14
|
+
],
|
|
15
|
+
totalSets: 18,
|
|
16
|
+
unit: 'lbs',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const exercises: ExerciseCardProps[] = [
|
|
20
|
+
{
|
|
21
|
+
name: 'Bench Press',
|
|
22
|
+
state: 'collapsed',
|
|
23
|
+
onToggle: () => {},
|
|
24
|
+
summary: { sets: 3, reps: 8, weight: 175, unit: 'lbs' },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'Row',
|
|
28
|
+
state: 'upcoming',
|
|
29
|
+
onToggle: () => {},
|
|
30
|
+
prescription: '3×10 @ RPE 8',
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
describe('WorkoutCard', () => {
|
|
35
|
+
describe('rendering', () => {
|
|
36
|
+
it('renders name and date', () => {
|
|
37
|
+
render(<WorkoutCard {...baseProps} />)
|
|
38
|
+
expect(screen.getByTestId('workout-card-name')).toHaveTextContent(
|
|
39
|
+
'Upper A',
|
|
40
|
+
)
|
|
41
|
+
expect(screen.getByTestId('workout-card-date')).toHaveTextContent(
|
|
42
|
+
'Mon, Jun 23',
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders the set count in the stats row', () => {
|
|
47
|
+
render(<WorkoutCard {...baseProps} />)
|
|
48
|
+
expect(screen.getByTestId('workout-card-stats')).toHaveTextContent(
|
|
49
|
+
'18 sets',
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('includes duration in stats when provided', () => {
|
|
54
|
+
render(<WorkoutCard {...baseProps} duration="45 min" />)
|
|
55
|
+
expect(screen.getByTestId('workout-card-stats')).toHaveTextContent(
|
|
56
|
+
'45 min',
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('includes total volume with unit when provided', () => {
|
|
61
|
+
render(<WorkoutCard {...baseProps} totalVolume={12450} unit="lbs" />)
|
|
62
|
+
expect(screen.getByTestId('workout-card-stats')).toHaveTextContent(
|
|
63
|
+
'12450 lbs',
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('omits volume from stats when not provided', () => {
|
|
68
|
+
render(<WorkoutCard {...baseProps} />)
|
|
69
|
+
expect(screen.getByTestId('workout-card-stats')).not.toHaveTextContent(
|
|
70
|
+
'lbs',
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('muscle groups', () => {
|
|
76
|
+
it('renders a chip per muscle group', () => {
|
|
77
|
+
render(<WorkoutCard {...baseProps} />)
|
|
78
|
+
expect(screen.getAllByTestId('muscle-group-chip')).toHaveLength(2)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('renders chip labels', () => {
|
|
82
|
+
render(<WorkoutCard {...baseProps} />)
|
|
83
|
+
const row = screen.getByTestId('workout-card-muscle-groups')
|
|
84
|
+
expect(row).toHaveTextContent('Chest')
|
|
85
|
+
expect(row).toHaveTextContent('Back')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not render the muscle group row when empty', () => {
|
|
89
|
+
render(<WorkoutCard {...baseProps} muscleGroups={[]} />)
|
|
90
|
+
expect(
|
|
91
|
+
screen.queryByTestId('workout-card-muscle-groups'),
|
|
92
|
+
).not.toBeInTheDocument()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('expansion', () => {
|
|
97
|
+
it('renders exercises when expanded', () => {
|
|
98
|
+
render(
|
|
99
|
+
<WorkoutCard
|
|
100
|
+
{...baseProps}
|
|
101
|
+
expanded
|
|
102
|
+
onToggle={() => {}}
|
|
103
|
+
exercises={exercises}
|
|
104
|
+
/>,
|
|
105
|
+
)
|
|
106
|
+
expect(screen.getByTestId('workout-card-exercises')).toBeInTheDocument()
|
|
107
|
+
expect(screen.getAllByTestId('exercise-card')).toHaveLength(2)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not render exercises when collapsed', () => {
|
|
111
|
+
render(
|
|
112
|
+
<WorkoutCard
|
|
113
|
+
{...baseProps}
|
|
114
|
+
expanded={false}
|
|
115
|
+
onToggle={() => {}}
|
|
116
|
+
exercises={exercises}
|
|
117
|
+
/>,
|
|
118
|
+
)
|
|
119
|
+
expect(
|
|
120
|
+
screen.queryByTestId('workout-card-exercises'),
|
|
121
|
+
).not.toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('calls onToggle when pressed', () => {
|
|
125
|
+
const onToggle = vi.fn()
|
|
126
|
+
render(<WorkoutCard {...baseProps} onToggle={onToggle} />)
|
|
127
|
+
fireEvent.click(screen.getByTestId('workout-card-toggle'))
|
|
128
|
+
expect(onToggle).toHaveBeenCalledOnce()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('status styling', () => {
|
|
133
|
+
it('dims the card when upcoming', () => {
|
|
134
|
+
render(<WorkoutCard {...baseProps} status="upcoming" />)
|
|
135
|
+
expect(screen.getByTestId('workout-card')).toHaveStyle({ opacity: 0.6 })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('does not dim a completed card', () => {
|
|
139
|
+
render(<WorkoutCard {...baseProps} status="completed" />)
|
|
140
|
+
expect(screen.getByTestId('workout-card')).not.toHaveStyle({
|
|
141
|
+
opacity: 0.6,
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('accessibility', () => {
|
|
147
|
+
it('exposes button role and expanded state when expandable', () => {
|
|
148
|
+
render(
|
|
149
|
+
<WorkoutCard {...baseProps} expanded onToggle={() => {}} />,
|
|
150
|
+
)
|
|
151
|
+
const card = screen.getByRole('button', {
|
|
152
|
+
name: 'Upper A workout, Mon, Jun 23, today',
|
|
153
|
+
})
|
|
154
|
+
expect(card).toHaveAttribute('aria-expanded', 'true')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('has no accessibility violations', async () => {
|
|
158
|
+
const { container } = render(
|
|
159
|
+
<WorkoutCard
|
|
160
|
+
{...baseProps}
|
|
161
|
+
duration="45 min"
|
|
162
|
+
totalVolume={12450}
|
|
163
|
+
onToggle={() => {}}
|
|
164
|
+
expanded
|
|
165
|
+
exercises={exercises}
|
|
166
|
+
/>,
|
|
167
|
+
)
|
|
168
|
+
const results = await axe(container)
|
|
169
|
+
expect(results).toHaveNoViolations()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
})
|