@titan-design/react-ui 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/dist/chunk-3VLOBS6O.mjs +846 -0
  2. package/dist/chunk-3VLOBS6O.mjs.map +1 -0
  3. package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
  4. package/dist/chunk-5SSKGS6E.mjs.map +1 -0
  5. package/dist/index.d.mts +1005 -4
  6. package/dist/index.d.ts +1005 -4
  7. package/dist/index.js +6043 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5901 -177
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/theme/index.d.mts +57 -1
  12. package/dist/theme/index.d.ts +57 -1
  13. package/dist/theme/index.js +63 -0
  14. package/dist/theme/index.js.map +1 -1
  15. package/dist/theme/index.mjs +2 -1
  16. package/dist/theme/tokens-css.d.mts +18 -0
  17. package/dist/theme/tokens-css.d.ts +18 -0
  18. package/dist/theme/tokens-css.js +289 -0
  19. package/dist/theme/tokens-css.js.map +1 -0
  20. package/dist/theme/tokens-css.mjs +24 -0
  21. package/dist/theme/tokens-css.mjs.map +1 -0
  22. package/docs/WEB_SETUP.md +217 -0
  23. package/package.json +17 -5
  24. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  25. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  26. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  27. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  28. package/src/components/custom/Gauge/index.ts +2 -0
  29. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  30. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  31. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  32. package/src/components/custom/Scatter/index.ts +2 -0
  33. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  34. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  35. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  36. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  37. package/src/components/custom/Treemap/index.ts +2 -0
  38. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  39. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  40. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  41. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  42. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  43. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  46. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  47. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  48. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  49. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  50. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  51. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  52. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  53. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  54. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  55. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  56. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  57. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  58. package/src/components/custom/Workout/InputBar.tsx +163 -0
  59. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  60. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  61. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  62. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  63. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  64. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  65. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  66. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  67. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  68. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  69. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  70. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  73. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  76. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  77. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  78. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  79. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  80. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  81. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  82. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  83. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  84. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  85. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  86. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  87. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  88. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  89. package/src/components/custom/Workout/SetRow.tsx +253 -0
  90. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  91. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  92. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  93. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  94. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  95. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  98. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  99. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  100. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  101. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  102. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  103. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  104. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  105. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  106. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  107. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  108. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  109. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  110. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  111. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  112. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  113. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  114. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  115. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  116. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  117. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  118. package/src/components/custom/Workout/index.ts +113 -0
  119. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  120. package/src/components/custom/index.ts +3 -0
  121. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  122. package/src/components/custom/stepper/Stepper.tsx +2 -2
  123. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  124. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  125. package/src/components/ui/card/Card.tsx +1 -1
  126. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  127. package/src/components/ui/collapse/Collapse.tsx +1 -1
  128. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  129. package/src/components/ui/drawer/Drawer.tsx +3 -3
  130. package/src/components/ui/form-field/FormField.tsx +1 -1
  131. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  132. package/src/components/ui/menu/Menu.tsx +2 -2
  133. package/src/components/ui/pill/Pill.tsx +1 -1
  134. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  135. package/src/components/ui/popover/Popover.tsx +1 -1
  136. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  137. package/src/components/ui/section/Section.stories.tsx +4 -4
  138. package/src/components/ui/select/Select.tsx +1 -1
  139. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  140. package/src/components/ui/tabs/Tabs.tsx +2 -2
  141. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  142. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  143. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  144. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  145. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  146. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  147. package/src/stories/PresetShowcase.stories.tsx +15 -15
  148. package/src/theme/index.ts +2 -1
  149. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  150. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  151. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  152. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  153. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  154. package/src/theme/manifest/index.ts +11 -0
  155. package/src/theme/tokens-css.test.ts +28 -0
  156. package/src/theme/tokens-css.ts +34 -0
  157. package/src/theme/workout-tokens.ts +61 -0
  158. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,75 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { Dumbbell } from 'lucide-react'
4
+ import { BaseBadge } from './BaseBadge'
5
+
6
+ const meta: Meta<typeof BaseBadge> = {
7
+ title: 'Custom/Workout/BaseBadge',
8
+ component: BaseBadge,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ variant: { control: 'select', options: ['plain', 'pr'], description: 'Visual variant' },
12
+ size: { control: 'select', options: ['sm', 'md', 'lg'], description: 'Badge size' },
13
+ onPress: { action: 'pressed', description: 'Callback when tapped' },
14
+ },
15
+ }
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof BaseBadge>
19
+
20
+ const Label = ({ children, color = '#9CA3AF' }: { children: string; color?: string }) => (
21
+ <Text
22
+ style={{
23
+ fontFamily: '"Space Grotesk", sans-serif',
24
+ fontWeight: '600',
25
+ fontSize: 10,
26
+ color,
27
+ }}
28
+ >
29
+ {children}
30
+ </Text>
31
+ )
32
+
33
+ export const Plain: Story = {
34
+ args: {
35
+ variant: 'plain',
36
+ children: <Label>225 lbs</Label>,
37
+ },
38
+ }
39
+
40
+ export const Pr: Story = {
41
+ args: {
42
+ variant: 'pr',
43
+ children: <Label color="#FF7900">PR</Label>,
44
+ },
45
+ }
46
+
47
+ export const WithIcon: Story = {
48
+ args: {
49
+ variant: 'plain',
50
+ icon: <Dumbbell size={12} color="#9CA3AF" strokeWidth={2} />,
51
+ children: <Label>225 lbs</Label>,
52
+ },
53
+ }
54
+
55
+ export const AllVariants: Story = {
56
+ render: () => (
57
+ <View style={{ gap: 12 }}>
58
+ <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
59
+ <BaseBadge size="sm"><Label>225 lbs</Label></BaseBadge>
60
+ <BaseBadge size="md"><Label>225 lbs</Label></BaseBadge>
61
+ <BaseBadge size="lg"><Label>225 lbs</Label></BaseBadge>
62
+ </View>
63
+ <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
64
+ <BaseBadge variant="pr" size="sm"><Label color="#FF7900">PR</Label></BaseBadge>
65
+ <BaseBadge variant="pr" size="md"><Label color="#FF7900">PR</Label></BaseBadge>
66
+ <BaseBadge variant="pr" size="lg"><Label color="#FF7900">PR</Label></BaseBadge>
67
+ </View>
68
+ <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
69
+ <BaseBadge icon={<Dumbbell size={12} color="#9CA3AF" strokeWidth={2} />}>
70
+ <Label>225 lbs</Label>
71
+ </BaseBadge>
72
+ </View>
73
+ </View>
74
+ ),
75
+ }
@@ -0,0 +1,102 @@
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 { Text, View } from 'react-native'
5
+ import { BaseBadge } from './BaseBadge'
6
+
7
+ describe('BaseBadge', () => {
8
+ it('renders children', () => {
9
+ render(<BaseBadge testID="bb"><Text>Hello</Text></BaseBadge>)
10
+ expect(screen.getByText('Hello')).toBeInTheDocument()
11
+ })
12
+
13
+ it('defaults to the plain variant border color', () => {
14
+ render(<BaseBadge testID="bb"><Text>x</Text></BaseBadge>)
15
+ expect(screen.getByTestId('bb')).toHaveStyle({ borderTopColor: '#1F1F1F' })
16
+ })
17
+
18
+ it('applies the pr variant border color', () => {
19
+ render(
20
+ <BaseBadge variant="pr" testID="bb">
21
+ <Text>x</Text>
22
+ </BaseBadge>,
23
+ )
24
+ expect(screen.getByTestId('bb')).toHaveStyle({
25
+ borderTopColor: 'rgba(255, 121, 0, 0.12)',
26
+ })
27
+ })
28
+
29
+ describe('icon', () => {
30
+ it('renders the icon when provided and hides it from the a11y tree', () => {
31
+ render(
32
+ <BaseBadge icon={<View testID="my-icon" />}>
33
+ <Text>x</Text>
34
+ </BaseBadge>,
35
+ )
36
+ expect(screen.getByTestId('base-badge-icon')).toBeInTheDocument()
37
+ expect(screen.getByTestId('my-icon')).toBeInTheDocument()
38
+ })
39
+
40
+ it('does not render an icon wrapper when no icon is provided', () => {
41
+ render(<BaseBadge><Text>x</Text></BaseBadge>)
42
+ expect(screen.queryByTestId('base-badge-icon')).not.toBeInTheDocument()
43
+ })
44
+ })
45
+
46
+ describe('onPress', () => {
47
+ it('wraps in a Pressable when onPress is provided', () => {
48
+ const onPress = vi.fn()
49
+ render(
50
+ <BaseBadge onPress={onPress} accessibilityLabel="Press me">
51
+ <Text>x</Text>
52
+ </BaseBadge>,
53
+ )
54
+ const pressable = screen.getByTestId('base-badge-pressable')
55
+ fireEvent.click(pressable)
56
+ expect(onPress).toHaveBeenCalledOnce()
57
+ })
58
+
59
+ it('does not render a Pressable when onPress is omitted', () => {
60
+ render(<BaseBadge><Text>x</Text></BaseBadge>)
61
+ expect(screen.queryByTestId('base-badge-pressable')).not.toBeInTheDocument()
62
+ })
63
+
64
+ it('exposes a button role only when pressable', () => {
65
+ render(
66
+ <BaseBadge onPress={() => {}} accessibilityLabel="Press me">
67
+ <Text>x</Text>
68
+ </BaseBadge>,
69
+ )
70
+ expect(screen.getByRole('button')).toBeInTheDocument()
71
+ })
72
+ })
73
+
74
+ describe('size', () => {
75
+ it.each(['sm', 'md', 'lg'] as const)('renders %s size', (size) => {
76
+ render(<BaseBadge size={size} testID="bb"><Text>x</Text></BaseBadge>)
77
+ expect(screen.getByTestId('bb')).toBeInTheDocument()
78
+ })
79
+ })
80
+
81
+ describe('accessibility', () => {
82
+ it('has no accessibility violations as a static badge', async () => {
83
+ const { container } = render(
84
+ <BaseBadge accessibilityLabel="Static badge">
85
+ <Text>x</Text>
86
+ </BaseBadge>,
87
+ )
88
+ const results = await axe(container)
89
+ expect(results).toHaveNoViolations()
90
+ })
91
+
92
+ it('has no accessibility violations as a pressable badge', async () => {
93
+ const { container } = render(
94
+ <BaseBadge onPress={() => {}} accessibilityLabel="Pressable badge">
95
+ <Text>x</Text>
96
+ </BaseBadge>,
97
+ )
98
+ const results = await axe(container)
99
+ expect(results).toHaveNoViolations()
100
+ })
101
+ })
102
+ })
@@ -0,0 +1,113 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useState, type ReactNode } from 'react'
3
+ import { View, Pressable, Animated, type ViewProps } from 'react-native'
4
+
5
+ export type BaseBadgeVariant = 'plain' | 'pr'
6
+ export type BaseBadgeSize = 'sm' | 'md' | 'lg'
7
+
8
+ export interface BaseBadgeProps extends ViewProps {
9
+ /** Visual variant — plain (neutral) or pr (brand-primary-subtle) */
10
+ variant?: BaseBadgeVariant
11
+ /** Size variant */
12
+ size?: BaseBadgeSize
13
+ /** Optional icon rendered left of children; hidden from a11y tree */
14
+ icon?: ReactNode
15
+ onPress?: () => void
16
+ className?: string
17
+ children?: ReactNode
18
+ }
19
+
20
+ export const baseBadgeSizeConfig: Record<
21
+ BaseBadgeSize,
22
+ { fontSize: number; paddingH: number; paddingV: number; iconSize: number }
23
+ > = {
24
+ sm: { fontSize: 9, paddingH: 6, paddingV: 2, iconSize: 10 },
25
+ md: { fontSize: 10, paddingH: 8, paddingV: 2, iconSize: 12 },
26
+ lg: { fontSize: 12, paddingH: 10, paddingV: 4, iconSize: 14 },
27
+ }
28
+
29
+ const variantColors: Record<
30
+ BaseBadgeVariant,
31
+ { backgroundColor: string; borderColor: string }
32
+ > = {
33
+ plain: { backgroundColor: '#1C1C1C', borderColor: '#1F1F1F' },
34
+ pr: {
35
+ backgroundColor: 'rgba(255, 121, 0, 0.12)',
36
+ borderColor: 'rgba(255, 121, 0, 0.12)',
37
+ },
38
+ }
39
+
40
+ export function BaseBadge({
41
+ variant = 'plain',
42
+ size = 'md',
43
+ icon,
44
+ onPress,
45
+ className,
46
+ children,
47
+ ...props
48
+ }: BaseBadgeProps) {
49
+ const [scaleAnim] = useState(() => new Animated.Value(1))
50
+ const config = baseBadgeSizeConfig[size]
51
+ const colors = variantColors[variant]
52
+
53
+ const handlePressIn = () => {
54
+ Animated.timing(scaleAnim, {
55
+ toValue: 0.97,
56
+ duration: 150,
57
+ useNativeDriver: true,
58
+ }).start()
59
+ }
60
+
61
+ const handlePressOut = () => {
62
+ Animated.timing(scaleAnim, {
63
+ toValue: 1,
64
+ duration: 150,
65
+ useNativeDriver: true,
66
+ }).start()
67
+ }
68
+
69
+ const badge = (
70
+ <View
71
+ className={className}
72
+ style={{
73
+ flexDirection: 'row',
74
+ alignItems: 'center',
75
+ gap: 3,
76
+ // borderRadius 2 is intentional per workout-tokens.ts (squared-off pill)
77
+ borderRadius: 2,
78
+ borderWidth: 1,
79
+ borderColor: colors.borderColor,
80
+ backgroundColor: colors.backgroundColor,
81
+ paddingHorizontal: config.paddingH,
82
+ paddingVertical: config.paddingV,
83
+ }}
84
+ {...props}
85
+ >
86
+ {icon != null && (
87
+ <View accessibilityElementsHidden testID="base-badge-icon">
88
+ {icon}
89
+ </View>
90
+ )}
91
+ {children}
92
+ </View>
93
+ )
94
+
95
+ if (onPress) {
96
+ return (
97
+ <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
98
+ <Pressable
99
+ onPress={onPress}
100
+ onPressIn={handlePressIn}
101
+ onPressOut={handlePressOut}
102
+ accessibilityRole="button"
103
+ accessibilityLabel={props.accessibilityLabel}
104
+ testID="base-badge-pressable"
105
+ >
106
+ {badge}
107
+ </Pressable>
108
+ </Animated.View>
109
+ )
110
+ }
111
+
112
+ return badge
113
+ }
@@ -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
+ })