@titan-design/react-ui 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/package.json +16 -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,121 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { View } from 'react-native'
|
|
4
|
+
import { BodyMap, type BodyMapData } from './BodyMap'
|
|
5
|
+
import { MuscleGroup } from './muscleTaxonomy'
|
|
6
|
+
|
|
7
|
+
const sampleData: BodyMapData[] = [
|
|
8
|
+
{ muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 },
|
|
9
|
+
{
|
|
10
|
+
muscleGroup: MuscleGroup.FRONT_DELTS,
|
|
11
|
+
intensity: 0.5,
|
|
12
|
+
volumeStatus: 'maintenance',
|
|
13
|
+
weeklySets: 5,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
muscleGroup: MuscleGroup.SIDE_DELTS,
|
|
17
|
+
intensity: 0.6,
|
|
18
|
+
volumeStatus: 'productive',
|
|
19
|
+
weeklySets: 10,
|
|
20
|
+
},
|
|
21
|
+
{ muscleGroup: MuscleGroup.BICEPS, intensity: 0.3, volumeStatus: 'under', weeklySets: 3 },
|
|
22
|
+
{ muscleGroup: MuscleGroup.TRICEPS, intensity: 0.65, volumeStatus: 'productive', weeklySets: 8 },
|
|
23
|
+
{ muscleGroup: MuscleGroup.ABS, intensity: 0.4, volumeStatus: 'maintenance', weeklySets: 6 },
|
|
24
|
+
{
|
|
25
|
+
muscleGroup: MuscleGroup.OBLIQUES,
|
|
26
|
+
intensity: 0.35,
|
|
27
|
+
volumeStatus: 'maintenance',
|
|
28
|
+
weeklySets: 4,
|
|
29
|
+
},
|
|
30
|
+
{ muscleGroup: MuscleGroup.QUADS, intensity: 0.95, volumeStatus: 'over', weeklySets: 20 },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const backData: BodyMapData[] = [
|
|
34
|
+
{ muscleGroup: MuscleGroup.LATS, intensity: 0.6, volumeStatus: 'productive', weeklySets: 11 },
|
|
35
|
+
{
|
|
36
|
+
muscleGroup: MuscleGroup.UPPER_BACK,
|
|
37
|
+
intensity: 0.5,
|
|
38
|
+
volumeStatus: 'maintenance',
|
|
39
|
+
weeklySets: 8,
|
|
40
|
+
},
|
|
41
|
+
{ muscleGroup: MuscleGroup.REAR_DELTS, intensity: 0.3, volumeStatus: 'under', weeklySets: 4 },
|
|
42
|
+
{ muscleGroup: MuscleGroup.TRICEPS, intensity: 0.65, volumeStatus: 'productive', weeklySets: 8 },
|
|
43
|
+
{ muscleGroup: MuscleGroup.GLUTES, intensity: 0.7, volumeStatus: 'productive', weeklySets: 10 },
|
|
44
|
+
{
|
|
45
|
+
muscleGroup: MuscleGroup.HAMSTRINGS,
|
|
46
|
+
intensity: 0.55,
|
|
47
|
+
volumeStatus: 'productive',
|
|
48
|
+
weeklySets: 9,
|
|
49
|
+
},
|
|
50
|
+
{ muscleGroup: MuscleGroup.CALVES, intensity: 0.2, volumeStatus: 'under', weeklySets: 3 },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const meta: Meta<typeof BodyMap> = {
|
|
54
|
+
title: 'Custom/Workout/BodyMap',
|
|
55
|
+
component: BodyMap,
|
|
56
|
+
tags: ['autodocs'],
|
|
57
|
+
argTypes: {
|
|
58
|
+
view: { control: 'inline-radio', options: ['front', 'back'] },
|
|
59
|
+
mode: { control: 'inline-radio', options: ['simple', 'detailed'] },
|
|
60
|
+
},
|
|
61
|
+
decorators: [
|
|
62
|
+
(Story) => (
|
|
63
|
+
<View style={{ padding: 16, maxWidth: 320 }}>
|
|
64
|
+
<Story />
|
|
65
|
+
</View>
|
|
66
|
+
),
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default meta
|
|
71
|
+
type Story = StoryObj<typeof BodyMap>
|
|
72
|
+
|
|
73
|
+
export const Front: Story = {
|
|
74
|
+
args: { data: sampleData, view: 'front', mode: 'detailed' },
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const Back: Story = {
|
|
78
|
+
args: { data: backData, view: 'back', mode: 'detailed' },
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const Simple: Story = {
|
|
82
|
+
args: { data: sampleData, view: 'front', mode: 'simple' },
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const Detailed: Story = {
|
|
86
|
+
args: { data: sampleData, view: 'front', mode: 'detailed' },
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const Highlighted: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
data: sampleData,
|
|
92
|
+
view: 'front',
|
|
93
|
+
mode: 'detailed',
|
|
94
|
+
highlightedMuscle: MuscleGroup.QUADS,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const Empty: Story = {
|
|
99
|
+
args: { data: [], view: 'front' },
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function InteractiveBodyMap() {
|
|
103
|
+
const [view, setView] = useState<'front' | 'back'>('front')
|
|
104
|
+
const [selected, setSelected] = useState<MuscleGroup | null>(null)
|
|
105
|
+
return (
|
|
106
|
+
<View style={{ maxWidth: 320 }}>
|
|
107
|
+
<BodyMap
|
|
108
|
+
data={view === 'front' ? sampleData : backData}
|
|
109
|
+
view={view}
|
|
110
|
+
onViewChange={setView}
|
|
111
|
+
onMusclePress={(m) => setSelected((cur) => (cur === m ? null : m))}
|
|
112
|
+
highlightedMuscle={selected}
|
|
113
|
+
mode="detailed"
|
|
114
|
+
/>
|
|
115
|
+
</View>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const Interactive: Story = {
|
|
120
|
+
render: () => <InteractiveBodyMap />,
|
|
121
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
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 { BodyMap, type BodyMapData } from './BodyMap'
|
|
5
|
+
import { MuscleGroup } from './muscleTaxonomy'
|
|
6
|
+
|
|
7
|
+
const data: BodyMapData[] = [
|
|
8
|
+
{ muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 },
|
|
9
|
+
{ muscleGroup: MuscleGroup.LATS, intensity: 0.4, volumeStatus: 'under', weeklySets: 6 },
|
|
10
|
+
{ muscleGroup: MuscleGroup.QUADS, intensity: 0.9, volumeStatus: 'over', weeklySets: 20 },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('BodyMap', () => {
|
|
14
|
+
describe('rendering', () => {
|
|
15
|
+
it('renders the container, SVG, and view toggle', () => {
|
|
16
|
+
render(<BodyMap data={data} view="front" />)
|
|
17
|
+
expect(screen.getByTestId('body-map')).toBeInTheDocument()
|
|
18
|
+
expect(screen.getByTestId('body-map-svg')).toBeInTheDocument()
|
|
19
|
+
expect(screen.getByTestId('body-map-view-toggle')).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders the SVG body for both front and back views', () => {
|
|
23
|
+
const { rerender, container } = render(<BodyMap data={data} view="front" />)
|
|
24
|
+
expect(container.querySelector('svg')).toBeInTheDocument()
|
|
25
|
+
rerender(<BodyMap data={data} view="back" />)
|
|
26
|
+
expect(container.querySelector('svg')).toBeInTheDocument()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders one legend button per muscle in detailed mode', () => {
|
|
30
|
+
render(<BodyMap data={data} view="front" mode="detailed" />)
|
|
31
|
+
expect(screen.getByTestId('body-map-muscle-chest')).toBeInTheDocument()
|
|
32
|
+
expect(screen.getByTestId('body-map-muscle-lats')).toBeInTheDocument()
|
|
33
|
+
expect(screen.getByTestId('body-map-muscle-quads')).toBeInTheDocument()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('aggregates muscles into simple groups in simple mode', () => {
|
|
37
|
+
render(<BodyMap data={data} view="front" mode="simple" />)
|
|
38
|
+
// chest -> chest, lats -> back, quads -> legs
|
|
39
|
+
expect(screen.getByTestId('body-map-muscle-chest')).toBeInTheDocument()
|
|
40
|
+
expect(screen.getByTestId('body-map-muscle-back')).toBeInTheDocument()
|
|
41
|
+
expect(screen.getByTestId('body-map-muscle-legs')).toBeInTheDocument()
|
|
42
|
+
expect(screen.queryByTestId('body-map-muscle-lats')).not.toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders an empty hint when there is no data', () => {
|
|
46
|
+
render(<BodyMap data={[]} view="front" />)
|
|
47
|
+
expect(screen.getByTestId('body-map-empty')).toBeInTheDocument()
|
|
48
|
+
expect(screen.queryByTestId('body-map-legend')).not.toBeInTheDocument()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('interaction', () => {
|
|
53
|
+
it('calls onMusclePress with the tapped muscle group', () => {
|
|
54
|
+
const onMusclePress = vi.fn()
|
|
55
|
+
render(<BodyMap data={data} view="front" onMusclePress={onMusclePress} />)
|
|
56
|
+
fireEvent.click(screen.getByTestId('body-map-muscle-chest'))
|
|
57
|
+
expect(onMusclePress).toHaveBeenCalledWith(MuscleGroup.CHEST)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('reports a representative detailed muscle from a simple group tap', () => {
|
|
61
|
+
const onMusclePress = vi.fn()
|
|
62
|
+
render(<BodyMap data={data} view="front" mode="simple" onMusclePress={onMusclePress} />)
|
|
63
|
+
fireEvent.click(screen.getByTestId('body-map-muscle-back'))
|
|
64
|
+
expect(onMusclePress).toHaveBeenCalledWith(MuscleGroup.LATS)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('calls onViewChange from the front/back toggle', () => {
|
|
68
|
+
const onViewChange = vi.fn()
|
|
69
|
+
render(<BodyMap data={data} view="front" onViewChange={onViewChange} />)
|
|
70
|
+
fireEvent.click(screen.getByTestId('body-map-toggle-back'))
|
|
71
|
+
expect(onViewChange).toHaveBeenCalledWith('back')
|
|
72
|
+
fireEvent.click(screen.getByTestId('body-map-toggle-front'))
|
|
73
|
+
expect(onViewChange).toHaveBeenCalledWith('front')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('highlighted muscle', () => {
|
|
78
|
+
it('marks the highlighted muscle button as pressed', () => {
|
|
79
|
+
render(<BodyMap data={data} view="front" highlightedMuscle={MuscleGroup.CHEST} />)
|
|
80
|
+
expect(screen.getByTestId('body-map-muscle-chest')).toHaveAttribute('aria-pressed', 'true')
|
|
81
|
+
expect(screen.getByTestId('body-map-muscle-lats')).toHaveAttribute('aria-pressed', 'false')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('applies an edge glow when a muscle is highlighted', () => {
|
|
85
|
+
render(<BodyMap data={data} view="front" highlightedMuscle={MuscleGroup.QUADS} />)
|
|
86
|
+
const svg = screen.getByTestId('body-map-svg')
|
|
87
|
+
expect(svg.getAttribute('style') ?? '').toContain('box-shadow')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('accessibility', () => {
|
|
92
|
+
it('labels each muscle button with name, status, and weekly sets', () => {
|
|
93
|
+
render(<BodyMap data={data} view="front" />)
|
|
94
|
+
expect(screen.getByLabelText('Chest, productive, 12 sets this week')).toBeInTheDocument()
|
|
95
|
+
expect(screen.getByLabelText('Lats, under-trained, 6 sets this week')).toBeInTheDocument()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('labels the view toggle buttons', () => {
|
|
99
|
+
render(<BodyMap data={data} view="front" onViewChange={vi.fn()} />)
|
|
100
|
+
expect(screen.getByLabelText('Show front view')).toBeInTheDocument()
|
|
101
|
+
expect(screen.getByLabelText('Show back view')).toBeInTheDocument()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('has no accessibility violations with data', async () => {
|
|
105
|
+
const { container } = render(
|
|
106
|
+
<BodyMap
|
|
107
|
+
data={data}
|
|
108
|
+
view="front"
|
|
109
|
+
onViewChange={vi.fn()}
|
|
110
|
+
onMusclePress={vi.fn()}
|
|
111
|
+
highlightedMuscle={MuscleGroup.CHEST}
|
|
112
|
+
/>
|
|
113
|
+
)
|
|
114
|
+
const results = await axe(container)
|
|
115
|
+
expect(results).toHaveNoViolations()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('has no accessibility violations when empty', async () => {
|
|
119
|
+
const { container } = render(<BodyMap data={[]} view="back" />)
|
|
120
|
+
const results = await axe(container)
|
|
121
|
+
expect(results).toHaveNoViolations()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
3
|
+
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
|
+
import BodyHighlighter, { type ExtendedBodyPart, type Slug } from 'react-native-body-highlighter'
|
|
5
|
+
import { cn } from '../../../utils/cn'
|
|
6
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
7
|
+
import {
|
|
8
|
+
MuscleGroup,
|
|
9
|
+
SimpleMuscleGroup,
|
|
10
|
+
MUSCLE_TO_SVG_SLUGS,
|
|
11
|
+
MUSCLE_DISPLAY_NAMES,
|
|
12
|
+
SIMPLE_DISPLAY_NAMES,
|
|
13
|
+
DETAILED_TO_SIMPLE,
|
|
14
|
+
SLUG_TO_PRIMARY_MUSCLE,
|
|
15
|
+
VOLUME_STATUS_LABELS,
|
|
16
|
+
getHeatmapColor,
|
|
17
|
+
isMoreSevere,
|
|
18
|
+
type VolumeStatus,
|
|
19
|
+
} from './muscleTaxonomy'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* react-native-body-highlighter is published as a CommonJS default export; the
|
|
23
|
+
* interop keeps it working whether the bundler unwraps the default or not.
|
|
24
|
+
*/
|
|
25
|
+
const Body = ((BodyHighlighter as unknown as { default?: typeof BodyHighlighter }).default ??
|
|
26
|
+
BodyHighlighter) as typeof BodyHighlighter
|
|
27
|
+
|
|
28
|
+
const OUTLINE_FILL = 'rgba(255,255,255,0.08)'
|
|
29
|
+
const OUTLINE_BORDER = 'rgba(255,255,255,0.12)'
|
|
30
|
+
const BODY_SCALE = 0.8 // 200x400 intrinsic -> ~160x320 px
|
|
31
|
+
const TEXT_PRIMARY = '#F3F4F6'
|
|
32
|
+
const TEXT_SECONDARY = '#9CA3AF'
|
|
33
|
+
|
|
34
|
+
export interface BodyMapData {
|
|
35
|
+
muscleGroup: MuscleGroup
|
|
36
|
+
/** Intensity 0-1 for heatmap color. */
|
|
37
|
+
intensity: number
|
|
38
|
+
/** Volume status for tooltip / heatmap token. */
|
|
39
|
+
volumeStatus: VolumeStatus
|
|
40
|
+
/** Weekly effective sets. */
|
|
41
|
+
weeklySets: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BodyMapProps extends ViewProps {
|
|
45
|
+
/** Muscle group intensity data. */
|
|
46
|
+
data: BodyMapData[]
|
|
47
|
+
/** Which view to show. */
|
|
48
|
+
view: 'front' | 'back'
|
|
49
|
+
/** Toggle between front/back. */
|
|
50
|
+
onViewChange?: (view: 'front' | 'back') => void
|
|
51
|
+
/** Called when a muscle group is tapped. */
|
|
52
|
+
onMusclePress?: (muscleGroup: MuscleGroup) => void
|
|
53
|
+
/** Currently highlighted muscle (from external selection). */
|
|
54
|
+
highlightedMuscle?: MuscleGroup | null
|
|
55
|
+
/** Show simplified (8 groups) or detailed (15 groups). */
|
|
56
|
+
mode?: 'simple' | 'detailed'
|
|
57
|
+
className?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface LegendEntry {
|
|
61
|
+
key: string
|
|
62
|
+
name: string
|
|
63
|
+
status: VolumeStatus
|
|
64
|
+
intensity: number
|
|
65
|
+
sets: number
|
|
66
|
+
/** Detailed group reported to onMusclePress. */
|
|
67
|
+
muscle: MuscleGroup
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build the per-slug heatmap fill list, combining groups that share a slug. */
|
|
71
|
+
function buildSlugParts(data: BodyMapData[]): ExtendedBodyPart[] {
|
|
72
|
+
const bySlug = new Map<string, { status: VolumeStatus; intensity: number }>()
|
|
73
|
+
for (const d of data) {
|
|
74
|
+
for (const slug of MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []) {
|
|
75
|
+
const existing = bySlug.get(slug)
|
|
76
|
+
if (!existing || isMoreSevere(d.volumeStatus, existing.status)) {
|
|
77
|
+
bySlug.set(slug, { status: d.volumeStatus, intensity: d.intensity })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return Array.from(bySlug.entries()).map(([slug, v]) => ({
|
|
82
|
+
slug: slug as Slug,
|
|
83
|
+
color: getHeatmapColor(v.status, v.intensity),
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Collapse data into per-simple-group legend entries (summed sets). */
|
|
88
|
+
function buildSimpleLegend(data: BodyMapData[]): LegendEntry[] {
|
|
89
|
+
const bySimple = new Map<SimpleMuscleGroup, LegendEntry>()
|
|
90
|
+
for (const d of data) {
|
|
91
|
+
const simple = DETAILED_TO_SIMPLE[d.muscleGroup]
|
|
92
|
+
const existing = bySimple.get(simple)
|
|
93
|
+
if (!existing) {
|
|
94
|
+
bySimple.set(simple, {
|
|
95
|
+
key: simple,
|
|
96
|
+
name: SIMPLE_DISPLAY_NAMES[simple],
|
|
97
|
+
status: d.volumeStatus,
|
|
98
|
+
intensity: d.intensity,
|
|
99
|
+
sets: d.weeklySets,
|
|
100
|
+
muscle: d.muscleGroup,
|
|
101
|
+
})
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
existing.sets += d.weeklySets
|
|
105
|
+
existing.intensity = Math.max(existing.intensity, d.intensity)
|
|
106
|
+
if (isMoreSevere(d.volumeStatus, existing.status)) {
|
|
107
|
+
existing.status = d.volumeStatus
|
|
108
|
+
existing.muscle = d.muscleGroup
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return Array.from(bySimple.values())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One legend entry per data row, named by detailed group. */
|
|
115
|
+
function buildDetailedLegend(data: BodyMapData[]): LegendEntry[] {
|
|
116
|
+
return data.map((d) => ({
|
|
117
|
+
key: d.muscleGroup,
|
|
118
|
+
name: MUSCLE_DISPLAY_NAMES[d.muscleGroup],
|
|
119
|
+
status: d.volumeStatus,
|
|
120
|
+
intensity: d.intensity,
|
|
121
|
+
sets: d.weeklySets,
|
|
122
|
+
muscle: d.muscleGroup,
|
|
123
|
+
}))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Interactive SVG body map with tappable muscle groups and a volume heatmap.
|
|
128
|
+
* Renders the front or back view (toggle via onViewChange), colors each muscle
|
|
129
|
+
* by its weekly-volume status, and exposes an accessible muscle legend wired to
|
|
130
|
+
* onMusclePress. Phase 1 uses react-native-body-highlighter's combined slugs
|
|
131
|
+
* (deltoids as one path, lats/upper-back shared); swipe between views and the
|
|
132
|
+
* delt/lat SVG split are follow-ups.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* <BodyMap
|
|
136
|
+
* data={[{ muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 }]}
|
|
137
|
+
* view="front"
|
|
138
|
+
* onViewChange={setView}
|
|
139
|
+
* onMusclePress={setSelected}
|
|
140
|
+
* highlightedMuscle={selected}
|
|
141
|
+
* />
|
|
142
|
+
*/
|
|
143
|
+
export function BodyMap({
|
|
144
|
+
data,
|
|
145
|
+
view,
|
|
146
|
+
onViewChange,
|
|
147
|
+
onMusclePress,
|
|
148
|
+
highlightedMuscle,
|
|
149
|
+
mode = 'detailed',
|
|
150
|
+
className,
|
|
151
|
+
...props
|
|
152
|
+
}: BodyMapProps) {
|
|
153
|
+
const slugParts = useMemo(() => buildSlugParts(data), [data])
|
|
154
|
+
const legend = useMemo(
|
|
155
|
+
() => (mode === 'simple' ? buildSimpleLegend(data) : buildDetailedLegend(data)),
|
|
156
|
+
[data, mode]
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const [highlight] = useState(() => new Animated.Value(highlightedMuscle ? 1 : 0))
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
const animation = Animated.timing(highlight, {
|
|
162
|
+
toValue: highlightedMuscle ? 1 : 0,
|
|
163
|
+
duration: 250,
|
|
164
|
+
easing: Easing.out(Easing.ease),
|
|
165
|
+
useNativeDriver: false,
|
|
166
|
+
})
|
|
167
|
+
animation.start()
|
|
168
|
+
return () => animation.stop()
|
|
169
|
+
}, [highlightedMuscle, highlight])
|
|
170
|
+
|
|
171
|
+
const scale = highlight.interpolate({ inputRange: [0, 1], outputRange: [1, 1.02] })
|
|
172
|
+
const glowColor = highlightedMuscle
|
|
173
|
+
? getHeatmapColor(
|
|
174
|
+
data.find((d) => d.muscleGroup === highlightedMuscle)?.volumeStatus,
|
|
175
|
+
data.find((d) => d.muscleGroup === highlightedMuscle)?.intensity ?? 0
|
|
176
|
+
)
|
|
177
|
+
: null
|
|
178
|
+
|
|
179
|
+
const handleBodyPress = (part: ExtendedBodyPart) => {
|
|
180
|
+
if (!onMusclePress || !part.slug) return
|
|
181
|
+
const fromData = data.find((d) =>
|
|
182
|
+
(MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []).includes(part.slug as string)
|
|
183
|
+
)
|
|
184
|
+
const muscle = fromData?.muscleGroup ?? SLUG_TO_PRIMARY_MUSCLE[part.slug]
|
|
185
|
+
if (muscle) onMusclePress(muscle)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<View className={cn(className)} testID="body-map" {...props}>
|
|
190
|
+
<ViewToggle view={view} onViewChange={onViewChange} />
|
|
191
|
+
|
|
192
|
+
<Animated.View
|
|
193
|
+
style={[
|
|
194
|
+
{
|
|
195
|
+
width: 200 * BODY_SCALE,
|
|
196
|
+
alignSelf: 'center',
|
|
197
|
+
transform: [{ scale }],
|
|
198
|
+
},
|
|
199
|
+
glowColor ? { boxShadow: `0 0 14px 2px ${glowColor}` } : undefined,
|
|
200
|
+
]}
|
|
201
|
+
// The SVG is decorative; the accessible muscle legend below is the
|
|
202
|
+
// screen-reader / keyboard interface.
|
|
203
|
+
aria-hidden
|
|
204
|
+
accessibilityElementsHidden
|
|
205
|
+
importantForAccessibility="no-hide-descendants"
|
|
206
|
+
testID="body-map-svg"
|
|
207
|
+
>
|
|
208
|
+
<Body
|
|
209
|
+
side={view}
|
|
210
|
+
data={slugParts}
|
|
211
|
+
scale={BODY_SCALE}
|
|
212
|
+
gender="male"
|
|
213
|
+
defaultFill={OUTLINE_FILL}
|
|
214
|
+
border={OUTLINE_BORDER}
|
|
215
|
+
onBodyPartPress={handleBodyPress}
|
|
216
|
+
/>
|
|
217
|
+
</Animated.View>
|
|
218
|
+
|
|
219
|
+
{legend.length === 0 ? (
|
|
220
|
+
<Text
|
|
221
|
+
style={{
|
|
222
|
+
marginTop: 8,
|
|
223
|
+
fontSize: 12,
|
|
224
|
+
color: TEXT_SECONDARY,
|
|
225
|
+
textAlign: 'center',
|
|
226
|
+
fontFamily: 'Inter, sans-serif',
|
|
227
|
+
}}
|
|
228
|
+
testID="body-map-empty"
|
|
229
|
+
>
|
|
230
|
+
No training data
|
|
231
|
+
</Text>
|
|
232
|
+
) : (
|
|
233
|
+
<View
|
|
234
|
+
className="flex-row flex-wrap"
|
|
235
|
+
style={{ marginTop: 10, gap: 6, justifyContent: 'center' }}
|
|
236
|
+
testID="body-map-legend"
|
|
237
|
+
>
|
|
238
|
+
{legend.map((entry) => (
|
|
239
|
+
<MuscleButton
|
|
240
|
+
key={entry.key}
|
|
241
|
+
entry={entry}
|
|
242
|
+
highlighted={highlightedMuscle === entry.muscle}
|
|
243
|
+
onMusclePress={onMusclePress}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
</View>
|
|
247
|
+
)}
|
|
248
|
+
</View>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface ViewToggleProps {
|
|
253
|
+
view: 'front' | 'back'
|
|
254
|
+
onViewChange?: (view: 'front' | 'back') => void
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ViewToggle({ view, onViewChange }: ViewToggleProps) {
|
|
258
|
+
return (
|
|
259
|
+
<View
|
|
260
|
+
className="flex-row self-center"
|
|
261
|
+
style={{ gap: 4, marginBottom: 8 }}
|
|
262
|
+
testID="body-map-view-toggle"
|
|
263
|
+
>
|
|
264
|
+
{(['front', 'back'] as const).map((side) => {
|
|
265
|
+
const active = view === side
|
|
266
|
+
return (
|
|
267
|
+
<Pressable
|
|
268
|
+
key={side}
|
|
269
|
+
onPress={() => onViewChange?.(side)}
|
|
270
|
+
accessibilityRole="button"
|
|
271
|
+
accessibilityLabel={`Show ${side} view`}
|
|
272
|
+
aria-pressed={active}
|
|
273
|
+
disabled={!onViewChange}
|
|
274
|
+
style={{
|
|
275
|
+
paddingHorizontal: 12,
|
|
276
|
+
paddingVertical: 4,
|
|
277
|
+
borderRadius: 9999,
|
|
278
|
+
backgroundColor: active ? 'rgba(255,121,0,0.16)' : 'transparent',
|
|
279
|
+
borderWidth: 1,
|
|
280
|
+
borderColor: active ? '#FF7900' : WORKOUT_TOKENS.border.strong,
|
|
281
|
+
}}
|
|
282
|
+
testID={`body-map-toggle-${side}`}
|
|
283
|
+
>
|
|
284
|
+
<Text
|
|
285
|
+
style={{
|
|
286
|
+
fontSize: 12,
|
|
287
|
+
fontFamily: 'Inter, sans-serif',
|
|
288
|
+
fontWeight: active ? '700' : '500',
|
|
289
|
+
color: active ? '#FF7900' : TEXT_SECONDARY,
|
|
290
|
+
textTransform: 'capitalize',
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
{side}
|
|
294
|
+
</Text>
|
|
295
|
+
</Pressable>
|
|
296
|
+
)
|
|
297
|
+
})}
|
|
298
|
+
</View>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
interface MuscleButtonProps {
|
|
303
|
+
entry: LegendEntry
|
|
304
|
+
highlighted: boolean
|
|
305
|
+
onMusclePress?: (muscleGroup: MuscleGroup) => void
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps) {
|
|
309
|
+
const dotColor = getHeatmapColor(entry.status, entry.intensity)
|
|
310
|
+
const label = `${entry.name}, ${VOLUME_STATUS_LABELS[entry.status]}, ${entry.sets} sets this week`
|
|
311
|
+
return (
|
|
312
|
+
<Pressable
|
|
313
|
+
onPress={() => onMusclePress?.(entry.muscle)}
|
|
314
|
+
accessibilityRole="button"
|
|
315
|
+
accessibilityLabel={label}
|
|
316
|
+
aria-pressed={highlighted}
|
|
317
|
+
style={{
|
|
318
|
+
flexDirection: 'row',
|
|
319
|
+
alignItems: 'center',
|
|
320
|
+
gap: 5,
|
|
321
|
+
paddingHorizontal: 8,
|
|
322
|
+
paddingVertical: 3,
|
|
323
|
+
borderRadius: 9999,
|
|
324
|
+
borderWidth: 1,
|
|
325
|
+
borderColor: highlighted ? '#FF7900' : WORKOUT_TOKENS.border.default,
|
|
326
|
+
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
327
|
+
}}
|
|
328
|
+
testID={`body-map-muscle-${entry.key}`}
|
|
329
|
+
>
|
|
330
|
+
<View
|
|
331
|
+
style={{ width: 7, height: 7, borderRadius: 9999, backgroundColor: dotColor }}
|
|
332
|
+
accessibilityElementsHidden
|
|
333
|
+
testID={`body-map-muscle-dot-${entry.key}`}
|
|
334
|
+
/>
|
|
335
|
+
<Text
|
|
336
|
+
style={{
|
|
337
|
+
fontSize: 11,
|
|
338
|
+
fontFamily: 'Inter, sans-serif',
|
|
339
|
+
fontWeight: '500',
|
|
340
|
+
color: TEXT_PRIMARY,
|
|
341
|
+
}}
|
|
342
|
+
accessibilityElementsHidden
|
|
343
|
+
>
|
|
344
|
+
{entry.name}
|
|
345
|
+
</Text>
|
|
346
|
+
</Pressable>
|
|
347
|
+
)
|
|
348
|
+
}
|