@titan-design/react-ui 0.2.6 → 0.3.0

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 (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,116 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View, Text, Pressable } from 'react-native'
4
+ import { PrHistoryModal, type PrRecord } from './PrHistoryModal'
5
+
6
+ const records: PrRecord[] = [
7
+ { type: 'e1rm', value: 245, unit: 'lbs', date: 'Mar 14', isRecent: true },
8
+ { type: 'weight', value: 225, unit: 'lbs', date: 'Mar 14', isRecent: true },
9
+ { type: 'reps', value: '8 @ 185 lbs', date: 'Feb 28' },
10
+ { type: 'volume', value: '12,400 lbs', date: 'Feb 14' },
11
+ { type: 'velocity', value: '0.82 m/s', date: 'Jan 30' },
12
+ ]
13
+
14
+ const meta: Meta<typeof PrHistoryModal> = {
15
+ title: 'Custom/Workout/PrHistoryModal',
16
+ component: PrHistoryModal,
17
+ tags: ['autodocs'],
18
+ argTypes: {
19
+ exerciseId: {
20
+ control: 'text',
21
+ description: 'Identifier of the exercise whose PR history is shown',
22
+ },
23
+ exerciseName: {
24
+ control: 'text',
25
+ description: 'Human-readable name used in the title and a11y label',
26
+ },
27
+ isOpen: {
28
+ control: 'boolean',
29
+ description: 'Whether the bottom sheet is visible',
30
+ },
31
+ records: {
32
+ control: 'object',
33
+ description: 'PR records to display, newest-first',
34
+ },
35
+ },
36
+ }
37
+
38
+ export default meta
39
+ type Story = StoryObj<typeof PrHistoryModal>
40
+
41
+ export const Default: Story = {
42
+ args: {
43
+ exerciseId: 'bench-press',
44
+ exerciseName: 'Bench Press',
45
+ isOpen: true,
46
+ records,
47
+ onClose: () => {},
48
+ },
49
+ }
50
+
51
+ export const RecentPrsHighlighted: Story = {
52
+ args: {
53
+ ...Default.args,
54
+ records: records.map((r) => ({ ...r, isRecent: true })),
55
+ },
56
+ }
57
+
58
+ export const SingleRecord: Story = {
59
+ args: {
60
+ ...Default.args,
61
+ exerciseName: 'Barbell Row',
62
+ records: [{ type: 'e1rm', value: 185, unit: 'lbs', date: 'Today', isRecent: true }],
63
+ },
64
+ }
65
+
66
+ export const Empty: Story = {
67
+ args: {
68
+ ...Default.args,
69
+ exerciseName: 'Overhead Press',
70
+ records: [],
71
+ },
72
+ }
73
+
74
+ export const FallsBackToExerciseId: Story = {
75
+ args: {
76
+ exerciseId: 'deadlift',
77
+ exerciseName: undefined,
78
+ isOpen: true,
79
+ records,
80
+ onClose: () => {},
81
+ },
82
+ }
83
+
84
+ function InteractivePrHistoryModal() {
85
+ const [open, setOpen] = useState(false)
86
+ return (
87
+ <View style={{ padding: 16 }}>
88
+ <Pressable
89
+ onPress={() => setOpen(true)}
90
+ accessibilityRole="button"
91
+ style={{
92
+ alignSelf: 'flex-start',
93
+ backgroundColor: '#FF7900',
94
+ paddingVertical: 10,
95
+ paddingHorizontal: 20,
96
+ borderRadius: 8,
97
+ }}
98
+ >
99
+ <Text style={{ color: '#FFFFFF', fontWeight: '700', fontFamily: 'Inter, sans-serif' }}>
100
+ Open PR history
101
+ </Text>
102
+ </Pressable>
103
+ <PrHistoryModal
104
+ exerciseId="bench-press"
105
+ exerciseName="Bench Press"
106
+ isOpen={open}
107
+ onClose={() => setOpen(false)}
108
+ records={records}
109
+ />
110
+ </View>
111
+ )
112
+ }
113
+
114
+ export const Interactive: Story = {
115
+ render: () => <InteractivePrHistoryModal />,
116
+ }
@@ -0,0 +1,161 @@
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 { PrHistoryModal, type PrRecord } from './PrHistoryModal'
5
+
6
+ const records: PrRecord[] = [
7
+ { type: 'e1rm', value: 245, unit: 'lbs', date: 'Mar 14', isRecent: true },
8
+ { type: 'reps', value: '8 @ 185 lbs', date: 'Feb 28' },
9
+ { type: 'velocity', value: '0.82 m/s', date: 'Jan 30' },
10
+ ]
11
+
12
+ const baseProps = {
13
+ exerciseId: 'bench-press',
14
+ exerciseName: 'Bench Press',
15
+ isOpen: true,
16
+ records,
17
+ onClose: vi.fn(),
18
+ }
19
+
20
+ describe('PrHistoryModal', () => {
21
+ describe('rendering', () => {
22
+ it('renders the sheet with title when open', () => {
23
+ render(<PrHistoryModal {...baseProps} />)
24
+ expect(screen.getByTestId('pr-history-modal')).toBeInTheDocument()
25
+ expect(screen.getByTestId('pr-history-modal-title')).toHaveTextContent(
26
+ 'Bench Press PR history',
27
+ )
28
+ })
29
+
30
+ it('does not render when closed', () => {
31
+ render(<PrHistoryModal {...baseProps} isOpen={false} />)
32
+ expect(screen.queryByTestId('pr-history-modal')).not.toBeInTheDocument()
33
+ })
34
+
35
+ it('supports the `visible` alias for visibility', () => {
36
+ render(
37
+ <PrHistoryModal
38
+ exerciseId="bench-press"
39
+ exerciseName="Bench Press"
40
+ visible
41
+ records={records}
42
+ onClose={vi.fn()}
43
+ />,
44
+ )
45
+ expect(screen.getByTestId('pr-history-modal')).toBeInTheDocument()
46
+ })
47
+
48
+ it('falls back to exerciseId when no name is given', () => {
49
+ render(
50
+ <PrHistoryModal
51
+ exerciseId="deadlift"
52
+ isOpen
53
+ records={records}
54
+ onClose={vi.fn()}
55
+ />,
56
+ )
57
+ expect(screen.getByTestId('pr-history-modal-title')).toHaveTextContent(
58
+ 'deadlift PR history',
59
+ )
60
+ })
61
+
62
+ it('renders a drag handle', () => {
63
+ render(<PrHistoryModal {...baseProps} />)
64
+ expect(screen.getByTestId('pr-history-modal-handle')).toBeInTheDocument()
65
+ })
66
+ })
67
+
68
+ describe('records', () => {
69
+ it('renders a row per record with type, value, and date', () => {
70
+ render(<PrHistoryModal {...baseProps} />)
71
+ expect(screen.getByTestId('pr-history-modal-record-0-type')).toHaveTextContent(
72
+ 'e1RM',
73
+ )
74
+ expect(screen.getByTestId('pr-history-modal-record-0-value')).toHaveTextContent(
75
+ '245 lbs',
76
+ )
77
+ expect(screen.getByTestId('pr-history-modal-record-0-date')).toHaveTextContent(
78
+ 'Mar 14',
79
+ )
80
+ })
81
+
82
+ it('renders pre-formatted string values verbatim', () => {
83
+ render(<PrHistoryModal {...baseProps} />)
84
+ expect(screen.getByTestId('pr-history-modal-record-1-value')).toHaveTextContent(
85
+ '8 @ 185 lbs',
86
+ )
87
+ })
88
+
89
+ it('renders one row per supplied record', () => {
90
+ render(<PrHistoryModal {...baseProps} />)
91
+ expect(screen.getByTestId('pr-history-modal-record-0')).toBeInTheDocument()
92
+ expect(screen.getByTestId('pr-history-modal-record-1')).toBeInTheDocument()
93
+ expect(screen.getByTestId('pr-history-modal-record-2')).toBeInTheDocument()
94
+ expect(
95
+ screen.queryByTestId('pr-history-modal-record-3'),
96
+ ).not.toBeInTheDocument()
97
+ })
98
+
99
+ it('marks recent records in their accessible label', () => {
100
+ render(<PrHistoryModal {...baseProps} />)
101
+ expect(
102
+ screen.getByLabelText('e1RM personal record: 245 lbs, Mar 14, recent'),
103
+ ).toBeInTheDocument()
104
+ expect(
105
+ screen.getByLabelText('Reps personal record: 8 @ 185 lbs, Feb 28'),
106
+ ).toBeInTheDocument()
107
+ })
108
+
109
+ it('shows an empty state when there are no records', () => {
110
+ render(<PrHistoryModal {...baseProps} records={[]} />)
111
+ expect(screen.getByTestId('pr-history-modal-empty')).toBeInTheDocument()
112
+ expect(
113
+ screen.queryByTestId('pr-history-modal-record-0'),
114
+ ).not.toBeInTheDocument()
115
+ })
116
+ })
117
+
118
+ describe('interaction', () => {
119
+ it('calls onClose when the close button is pressed', () => {
120
+ const onClose = vi.fn()
121
+ render(<PrHistoryModal {...baseProps} onClose={onClose} />)
122
+ fireEvent.click(screen.getByTestId('pr-history-modal-close'))
123
+ expect(onClose).toHaveBeenCalledOnce()
124
+ })
125
+ })
126
+
127
+ describe('accessibility', () => {
128
+ it('exposes a dialog role labelled with the exercise', () => {
129
+ render(<PrHistoryModal {...baseProps} />)
130
+ const dialog = screen.getByRole('dialog')
131
+ expect(dialog).toHaveAttribute('aria-label', 'Bench Press PR history')
132
+ })
133
+
134
+ it('labels the close affordance', () => {
135
+ render(<PrHistoryModal {...baseProps} />)
136
+ expect(screen.getByLabelText('Close PR history')).toBeInTheDocument()
137
+ })
138
+
139
+ it('has no accessibility violations', async () => {
140
+ const { container } = render(<PrHistoryModal {...baseProps} />)
141
+ expect(await axe(container)).toHaveNoViolations()
142
+ })
143
+
144
+ it('has no accessibility violations across all states (recent, plain, empty-safe)', async () => {
145
+ const { container } = render(
146
+ <PrHistoryModal
147
+ exerciseId="squat"
148
+ exerciseName="Back Squat"
149
+ isOpen
150
+ onClose={vi.fn()}
151
+ records={[
152
+ { type: 'e1rm', value: 405, unit: 'lbs', date: 'Today', isRecent: true },
153
+ { type: 'weight', value: 365, unit: 'lbs', date: 'Last week' },
154
+ { type: 'volume', value: '18,200 lbs', date: 'Mar 1' },
155
+ ]}
156
+ />,
157
+ )
158
+ expect(await axe(container)).toHaveNoViolations()
159
+ })
160
+ })
161
+ })
@@ -0,0 +1,244 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
3
+ import { Star } from 'lucide-react'
4
+ import { Drawer, DrawerBody } from '../../ui/drawer'
5
+
6
+ const BRAND_PRIMARY = '#FF7900'
7
+ const RECENT_BORDER = 'rgba(255,176,32,0.3)'
8
+ const RECENT_GLOW = '0 0 12px rgba(255,176,32,0.06)'
9
+ const SURFACE_RAISED = 'var(--color-surface-raised)'
10
+ const BORDER_DEFAULT = 'var(--color-border-default)'
11
+ const TEXT_PRIMARY = 'var(--color-text-primary)'
12
+ const TEXT_SECONDARY = 'var(--color-text-secondary)'
13
+ const TEXT_TERTIARY = 'var(--color-text-tertiary)'
14
+
15
+ export type PrRecordType = 'e1rm' | 'weight' | 'reps' | 'volume' | 'velocity'
16
+
17
+ export interface PrRecord {
18
+ /** PR category, drives the row label and ordering grouping. */
19
+ type: PrRecordType
20
+ /** Numeric magnitude (paired with `unit`) or a pre-formatted display string. */
21
+ value: string | number
22
+ /** Unit suffix appended to numeric values, e.g. "lbs" or "kg". */
23
+ unit?: 'lbs' | 'kg'
24
+ /** Display date for when the record was set, e.g. "Mar 14" or "2 weeks ago". */
25
+ date: string
26
+ /** Marks the record as recently set; adds a subtle amber glow highlight. */
27
+ isRecent?: boolean
28
+ }
29
+
30
+ export interface PrHistoryModalProps extends ViewProps {
31
+ /** Identifier of the exercise whose PR history is shown. */
32
+ exerciseId: string
33
+ /** Human-readable exercise name for the title and a11y label; falls back to `exerciseId`. */
34
+ exerciseName?: string
35
+ /** PR records to display, expected newest-first. */
36
+ records: PrRecord[]
37
+ /** Whether the bottom sheet is visible. */
38
+ isOpen?: boolean
39
+ /** Alias for `isOpen` (spec wording: "isOpen/visible"). `isOpen` wins when both are set. */
40
+ visible?: boolean
41
+ /** Invoked when the sheet requests to close (backdrop, close button, or back gesture). */
42
+ onClose: () => void
43
+ }
44
+
45
+ const TYPE_LABELS: Record<PrRecordType, string> = {
46
+ e1rm: 'e1RM',
47
+ weight: 'Weight',
48
+ reps: 'Reps',
49
+ volume: 'Volume',
50
+ velocity: 'Velocity',
51
+ }
52
+
53
+ function recordLabel(type: PrRecordType): string {
54
+ return TYPE_LABELS[type] ?? type
55
+ }
56
+
57
+ function recordValueText({ value, unit }: PrRecord): string {
58
+ if (typeof value === 'number') {
59
+ return unit ? `${value} ${unit}` : String(value)
60
+ }
61
+ return value
62
+ }
63
+
64
+ function PrRecordRow({ record, index }: { record: PrRecord; index: number }) {
65
+ const label = recordLabel(record.type)
66
+ const valueText = recordValueText(record)
67
+ const a11yLabel = record.isRecent
68
+ ? `${label} personal record: ${valueText}, ${record.date}, recent`
69
+ : `${label} personal record: ${valueText}, ${record.date}`
70
+
71
+ return (
72
+ <View
73
+ accessibilityRole="text"
74
+ accessibilityLabel={a11yLabel}
75
+ className="flex-row items-center"
76
+ style={{
77
+ gap: 10,
78
+ paddingVertical: 10,
79
+ paddingHorizontal: 12,
80
+ marginBottom: 8,
81
+ borderRadius: 8,
82
+ backgroundColor: SURFACE_RAISED,
83
+ borderWidth: 1,
84
+ borderColor: record.isRecent ? RECENT_BORDER : BORDER_DEFAULT,
85
+ ...(record.isRecent ? { boxShadow: RECENT_GLOW } : {}),
86
+ }}
87
+ testID={`pr-history-modal-record-${index}`}
88
+ >
89
+ <Star size={16} color={BRAND_PRIMARY} fill={BRAND_PRIMARY} strokeWidth={2} />
90
+ <Text
91
+ style={{
92
+ flex: 1,
93
+ fontSize: 13,
94
+ fontFamily: 'Inter, sans-serif',
95
+ fontWeight: '600',
96
+ color: TEXT_SECONDARY,
97
+ }}
98
+ testID={`pr-history-modal-record-${index}-type`}
99
+ >
100
+ {label}
101
+ </Text>
102
+ <Text
103
+ style={{
104
+ fontSize: 14,
105
+ fontFamily: '"Space Grotesk", sans-serif',
106
+ fontWeight: '700',
107
+ color: TEXT_PRIMARY,
108
+ }}
109
+ testID={`pr-history-modal-record-${index}-value`}
110
+ >
111
+ {valueText}
112
+ </Text>
113
+ <Text
114
+ style={{
115
+ minWidth: 64,
116
+ textAlign: 'right',
117
+ fontSize: 11,
118
+ fontFamily: 'Inter, sans-serif',
119
+ color: TEXT_TERTIARY,
120
+ }}
121
+ testID={`pr-history-modal-record-${index}-date`}
122
+ >
123
+ {record.date}
124
+ </Text>
125
+ </View>
126
+ )
127
+ }
128
+
129
+ /**
130
+ * Bottom-sheet modal listing an exercise's personal-record history. Each row
131
+ * shows the PR type, value, and date; recent records get a subtle amber glow.
132
+ * Built on the titan `Drawer` primitive (bottom placement) so it inherits
133
+ * backdrop dismissal, the back gesture, and `role="dialog"` semantics.
134
+ *
135
+ * Typically triggered by tapping a `WeightBadge`, but the modal itself is
136
+ * controlled — the trigger lives in the consumer.
137
+ *
138
+ * @example
139
+ * <PrHistoryModal
140
+ * exerciseId="bench-press"
141
+ * exerciseName="Bench Press"
142
+ * isOpen={open}
143
+ * onClose={() => setOpen(false)}
144
+ * records={[
145
+ * { type: 'e1rm', value: 245, unit: 'lbs', date: 'Mar 14', isRecent: true },
146
+ * { type: 'reps', value: '8 @ 185 lbs', date: 'Feb 28' },
147
+ * ]}
148
+ * />
149
+ */
150
+ export function PrHistoryModal({
151
+ exerciseId,
152
+ exerciseName,
153
+ records,
154
+ isOpen,
155
+ visible,
156
+ onClose,
157
+ ...props
158
+ }: PrHistoryModalProps) {
159
+ const open = isOpen ?? visible ?? false
160
+ const exercise = exerciseName ?? exerciseId
161
+ const dialogLabel = `${exercise} PR history`
162
+
163
+ return (
164
+ <Drawer
165
+ isOpen={open}
166
+ onClose={onClose}
167
+ placement="bottom"
168
+ showCloseButton={false}
169
+ className="rounded-t-2xl h-auto max-h-[85%]"
170
+ accessibilityLabel={dialogLabel}
171
+ testID="pr-history-modal"
172
+ {...props}
173
+ >
174
+ <View
175
+ accessibilityElementsHidden
176
+ style={{
177
+ alignSelf: 'center',
178
+ width: 40,
179
+ height: 4,
180
+ borderRadius: 2,
181
+ marginTop: 8,
182
+ backgroundColor: BORDER_DEFAULT,
183
+ }}
184
+ testID="pr-history-modal-handle"
185
+ />
186
+
187
+ <View
188
+ className="flex-row items-center justify-between"
189
+ style={{ paddingHorizontal: 16, paddingTop: 12, paddingBottom: 8 }}
190
+ testID="pr-history-modal-header"
191
+ >
192
+ <Text
193
+ accessibilityRole="header"
194
+ style={{
195
+ fontSize: 16,
196
+ fontFamily: '"Space Grotesk", sans-serif',
197
+ fontWeight: '700',
198
+ color: TEXT_PRIMARY,
199
+ }}
200
+ testID="pr-history-modal-title"
201
+ >
202
+ {dialogLabel}
203
+ </Text>
204
+ <Pressable
205
+ onPress={onClose}
206
+ accessibilityRole="button"
207
+ accessibilityLabel="Close PR history"
208
+ hitSlop={8}
209
+ style={{ padding: 4, margin: -4 }}
210
+ testID="pr-history-modal-close"
211
+ >
212
+ <Text style={{ fontSize: 22, lineHeight: 22, color: TEXT_SECONDARY }}>
213
+ {'×'}
214
+ </Text>
215
+ </Pressable>
216
+ </View>
217
+
218
+ <DrawerBody className="px-4 pt-0 pb-4">
219
+ {records.length === 0 ? (
220
+ <View style={{ paddingVertical: 24 }} testID="pr-history-modal-empty">
221
+ <Text
222
+ style={{
223
+ textAlign: 'center',
224
+ fontSize: 13,
225
+ fontFamily: 'Inter, sans-serif',
226
+ color: TEXT_TERTIARY,
227
+ }}
228
+ >
229
+ No personal records yet
230
+ </Text>
231
+ </View>
232
+ ) : (
233
+ records.map((record, index) => (
234
+ <PrRecordRow
235
+ key={`${record.type}-${record.date}-${index}`}
236
+ record={record}
237
+ index={index}
238
+ />
239
+ ))
240
+ )}
241
+ </DrawerBody>
242
+ </Drawer>
243
+ )
244
+ }