@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,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 = '#1C1C1C'
|
|
10
|
+
const BORDER_DEFAULT = '#1F1F1F'
|
|
11
|
+
const TEXT_PRIMARY = '#F3F4F6'
|
|
12
|
+
const TEXT_SECONDARY = '#9CA3AF'
|
|
13
|
+
const TEXT_TERTIARY = '#6B7280'
|
|
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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useMemo, useState } from 'react'
|
|
3
|
+
import { View } from 'react-native'
|
|
4
|
+
import { ReadinessCheck, type ReadinessFactor } from './ReadinessCheck'
|
|
5
|
+
|
|
6
|
+
const FACTOR_SEED: Array<{ id: string; label: string; value: number }> = [
|
|
7
|
+
{ id: 'sleep', label: 'Sleep', value: 4 },
|
|
8
|
+
{ id: 'soreness', label: 'Soreness', value: 3 },
|
|
9
|
+
{ id: 'motivation', label: 'Motivation', value: 5 },
|
|
10
|
+
{ id: 'stress', label: 'Stress', value: 3 },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const staticFactors: ReadinessFactor[] = FACTOR_SEED.map((f) => ({
|
|
14
|
+
...f,
|
|
15
|
+
onChange: () => {},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
const meta: Meta<typeof ReadinessCheck> = {
|
|
19
|
+
title: 'Custom/Workout/ReadinessCheck',
|
|
20
|
+
component: ReadinessCheck,
|
|
21
|
+
tags: ['autodocs'],
|
|
22
|
+
argTypes: {
|
|
23
|
+
score: {
|
|
24
|
+
control: { type: 'range', min: 0, max: 100, step: 1 },
|
|
25
|
+
description: 'Computed overall readiness score (0-100), colors the gauge',
|
|
26
|
+
},
|
|
27
|
+
warmUpCompleted: {
|
|
28
|
+
control: 'boolean',
|
|
29
|
+
description: 'Whether the VBT warm-up set has been performed',
|
|
30
|
+
},
|
|
31
|
+
onConfirm: {
|
|
32
|
+
description: 'Called when the assessment is confirmed and the workout begins',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default meta
|
|
38
|
+
type Story = StoryObj<typeof ReadinessCheck>
|
|
39
|
+
|
|
40
|
+
export const Default: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
factors: staticFactors,
|
|
43
|
+
score: 78,
|
|
44
|
+
warmUpCompleted: false,
|
|
45
|
+
onConfirm: () => {},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const HighReadiness: Story = {
|
|
50
|
+
args: {
|
|
51
|
+
...Default.args,
|
|
52
|
+
score: 92,
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const LowReadiness: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
...Default.args,
|
|
59
|
+
factors: FACTOR_SEED.map((f) => ({ ...f, value: 2, onChange: () => {} })),
|
|
60
|
+
score: 34,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const WithWarmUpGood: Story = {
|
|
65
|
+
args: {
|
|
66
|
+
...Default.args,
|
|
67
|
+
score: 81,
|
|
68
|
+
warmUpCompleted: true,
|
|
69
|
+
warmUpValidation: {
|
|
70
|
+
velocityDeficit: 3,
|
|
71
|
+
recommendation: 'Velocity on target — proceed with the planned loads.',
|
|
72
|
+
status: 'good',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const WithWarmUpCaution: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
...Default.args,
|
|
80
|
+
score: 52,
|
|
81
|
+
warmUpCompleted: true,
|
|
82
|
+
warmUpValidation: {
|
|
83
|
+
velocityDeficit: 18,
|
|
84
|
+
recommendation: 'Significant velocity loss — consider reducing load 5-10% today.',
|
|
85
|
+
status: 'caution',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function InteractiveReadinessCheck() {
|
|
91
|
+
const [values, setValues] = useState<Record<string, number>>(
|
|
92
|
+
Object.fromEntries(FACTOR_SEED.map((f) => [f.id, f.value])),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const factors: ReadinessFactor[] = FACTOR_SEED.map((f) => ({
|
|
96
|
+
id: f.id,
|
|
97
|
+
label: f.label,
|
|
98
|
+
value: values[f.id],
|
|
99
|
+
onChange: (value) => setValues((prev) => ({ ...prev, [f.id]: value })),
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
const score = useMemo(() => {
|
|
103
|
+
const total = FACTOR_SEED.reduce((sum, f) => sum + values[f.id], 0)
|
|
104
|
+
return Math.round((total / (FACTOR_SEED.length * 5)) * 100)
|
|
105
|
+
}, [values])
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<View style={{ maxWidth: 420, padding: 16 }}>
|
|
109
|
+
<ReadinessCheck
|
|
110
|
+
factors={factors}
|
|
111
|
+
score={score}
|
|
112
|
+
warmUpCompleted
|
|
113
|
+
warmUpValidation={{
|
|
114
|
+
velocityDeficit: 6,
|
|
115
|
+
recommendation: 'Velocity slightly down — warm up thoroughly before top sets.',
|
|
116
|
+
status: 'moderate',
|
|
117
|
+
}}
|
|
118
|
+
onConfirm={() => {}}
|
|
119
|
+
/>
|
|
120
|
+
</View>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const Interactive: Story = {
|
|
125
|
+
render: () => <InteractiveReadinessCheck />,
|
|
126
|
+
}
|