@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.
- package/dist/bodymap-BhG55xHM.d.mts +318 -0
- package/dist/bodymap-BhG55xHM.d.ts +318 -0
- package/dist/bodymap.d.mts +3 -0
- package/dist/bodymap.d.ts +3 -0
- package/dist/bodymap.js +2670 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1101 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/chunk-P7TSQUN6.mjs +846 -0
- package/dist/chunk-P7TSQUN6.mjs.map +1 -0
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +1131 -4
- package/dist/index.d.ts +1131 -4
- package/dist/index.js +6396 -225
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5572 -641
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +317 -1
- package/dist/theme/index.d.ts +317 -1
- package/dist/theme/index.js +185 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +20 -0
- package/dist/theme/tokens-css.d.ts +20 -0
- package/dist/theme/tokens-css.js +598 -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 +25 -5
- package/src/bodymap.ts +37 -0
- 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/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- 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/ActiveWorkoutPage.stories.tsx +118 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -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 +347 -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 +477 -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 +343 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -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 +162 -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 +237 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +165 -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 +90 -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/ProgramPlanningPage.stories.tsx +257 -0
- package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -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 +265 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +15 -11
- 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 +247 -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 +160 -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 +209 -0
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
- package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +288 -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 +228 -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 +152 -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/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- 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/design-freeze.test.ts +155 -0
- package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
- package/src/theme/manifest/extract-css-properties.test.ts +112 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +36 -0
- package/src/theme/workout-tokens.ts +59 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/workout-format.test.ts +81 -0
- package/src/utils/workout-format.ts +83 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,116 @@
|
|
|
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 { InputBar, type InputBarProps } from './InputBar'
|
|
5
|
+
|
|
6
|
+
const defaultProps: InputBarProps = {
|
|
7
|
+
exerciseName: 'Bench Press',
|
|
8
|
+
setNumber: 2,
|
|
9
|
+
totalSets: 5,
|
|
10
|
+
reps: '5',
|
|
11
|
+
weight: '135',
|
|
12
|
+
unit: 'lbs',
|
|
13
|
+
onRepsChange: vi.fn(),
|
|
14
|
+
onWeightChange: vi.fn(),
|
|
15
|
+
onRecord: vi.fn(),
|
|
16
|
+
canRecord: true,
|
|
17
|
+
visible: true,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderInputBar(overrides: Partial<InputBarProps> = {}) {
|
|
21
|
+
return render(<InputBar {...defaultProps} {...overrides} />)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('InputBar', () => {
|
|
25
|
+
it('renders with all props', () => {
|
|
26
|
+
renderInputBar()
|
|
27
|
+
expect(screen.getByTestId('input-bar')).toBeInTheDocument()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('displays exercise name', () => {
|
|
31
|
+
renderInputBar({ exerciseName: 'Squat' })
|
|
32
|
+
expect(screen.getByTestId('input-bar-exercise-name')).toHaveTextContent('Squat')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('displays set info with total', () => {
|
|
36
|
+
renderInputBar({ setNumber: 3, totalSets: 4 })
|
|
37
|
+
expect(screen.getByTestId('input-bar-set-info')).toHaveTextContent('Set 3/4')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('displays set info without total', () => {
|
|
41
|
+
renderInputBar({ setNumber: 1, totalSets: null })
|
|
42
|
+
expect(screen.getByTestId('input-bar-set-info')).toHaveTextContent('Set 1')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('fires onRepsChange when reps input changes', () => {
|
|
46
|
+
const onRepsChange = vi.fn()
|
|
47
|
+
renderInputBar({ onRepsChange })
|
|
48
|
+
const input = screen.getByTestId('input-bar-reps')
|
|
49
|
+
fireEvent.change(input, { target: { value: '8' } })
|
|
50
|
+
expect(onRepsChange).toHaveBeenCalled()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('fires onWeightChange when weight input changes', () => {
|
|
54
|
+
const onWeightChange = vi.fn()
|
|
55
|
+
renderInputBar({ onWeightChange })
|
|
56
|
+
const input = screen.getByTestId('input-bar-weight')
|
|
57
|
+
fireEvent.change(input, { target: { value: '225' } })
|
|
58
|
+
expect(onWeightChange).toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('fires onRecord when record button is clicked', () => {
|
|
62
|
+
const onRecord = vi.fn()
|
|
63
|
+
renderInputBar({ onRecord })
|
|
64
|
+
fireEvent.click(screen.getByTestId('input-bar-record'))
|
|
65
|
+
expect(onRecord).toHaveBeenCalledOnce()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('applies reduced opacity when canRecord is false', () => {
|
|
69
|
+
renderInputBar({ canRecord: false })
|
|
70
|
+
const button = screen.getByTestId('input-bar-record')
|
|
71
|
+
expect(button).toHaveStyle({ opacity: '0.4' })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns null when visible is false', () => {
|
|
75
|
+
renderInputBar({ visible: false })
|
|
76
|
+
expect(screen.queryByTestId('input-bar')).not.toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('shows correct unit label', () => {
|
|
80
|
+
renderInputBar({ unit: 'kg' })
|
|
81
|
+
expect(screen.getByTestId('input-bar-unit')).toHaveTextContent('kg')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('shows lbs unit label', () => {
|
|
85
|
+
renderInputBar({ unit: 'lbs' })
|
|
86
|
+
expect(screen.getByTestId('input-bar-unit')).toHaveTextContent('lbs')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('accessibility', () => {
|
|
90
|
+
it('has no accessibility violations', async () => {
|
|
91
|
+
const { container } = renderInputBar()
|
|
92
|
+
const results = await axe(container)
|
|
93
|
+
expect(results).toHaveNoViolations()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('has toolbar role on container', () => {
|
|
97
|
+
renderInputBar()
|
|
98
|
+
expect(screen.getByRole('toolbar')).toBeInTheDocument()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('has correct accessibility label on record button', () => {
|
|
102
|
+
renderInputBar()
|
|
103
|
+
expect(screen.getByLabelText('Record set')).toBeInTheDocument()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('has correct accessibility label on reps input', () => {
|
|
107
|
+
renderInputBar()
|
|
108
|
+
expect(screen.getByLabelText('Reps')).toBeInTheDocument()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('has correct accessibility label on weight input', () => {
|
|
112
|
+
renderInputBar({ unit: 'kg' })
|
|
113
|
+
expect(screen.getByLabelText('Weight in kg')).toBeInTheDocument()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { View, Text, Pressable, TextInput } from 'react-native'
|
|
4
|
+
|
|
5
|
+
export interface InputBarProps {
|
|
6
|
+
exerciseName: string
|
|
7
|
+
setNumber: number
|
|
8
|
+
totalSets: number | null
|
|
9
|
+
reps: string
|
|
10
|
+
weight: string
|
|
11
|
+
unit: 'lbs' | 'kg'
|
|
12
|
+
onRepsChange: (value: string) => void
|
|
13
|
+
onWeightChange: (value: string) => void
|
|
14
|
+
onRecord: () => void
|
|
15
|
+
canRecord: boolean
|
|
16
|
+
visible: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const BRAND_PRIMARY = '#FF7900'
|
|
20
|
+
const TEXT_PRIMARY = 'var(--color-text-primary)'
|
|
21
|
+
const TEXT_TERTIARY = 'var(--color-text-tertiary)'
|
|
22
|
+
|
|
23
|
+
const inputStyle = {
|
|
24
|
+
fontSize: 14,
|
|
25
|
+
fontWeight: '600' as const,
|
|
26
|
+
fontFamily: 'Inter, sans-serif',
|
|
27
|
+
backgroundColor: 'var(--color-surface-raised)',
|
|
28
|
+
borderWidth: 1,
|
|
29
|
+
borderColor: 'var(--color-border-strong)',
|
|
30
|
+
borderRadius: 6,
|
|
31
|
+
textAlign: 'center' as const,
|
|
32
|
+
color: TEXT_PRIMARY,
|
|
33
|
+
paddingVertical: 5,
|
|
34
|
+
paddingHorizontal: 2,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function InputBar({
|
|
38
|
+
exerciseName,
|
|
39
|
+
setNumber,
|
|
40
|
+
totalSets,
|
|
41
|
+
reps,
|
|
42
|
+
weight,
|
|
43
|
+
unit,
|
|
44
|
+
onRepsChange,
|
|
45
|
+
onWeightChange,
|
|
46
|
+
onRecord,
|
|
47
|
+
canRecord,
|
|
48
|
+
visible,
|
|
49
|
+
}: InputBarProps) {
|
|
50
|
+
if (!visible) return null
|
|
51
|
+
|
|
52
|
+
const setLabel = totalSets != null ? `Set ${setNumber}/${totalSets}` : `Set ${setNumber}`
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View
|
|
56
|
+
style={{
|
|
57
|
+
width: '100%',
|
|
58
|
+
backgroundColor: 'var(--color-surface-elevated)',
|
|
59
|
+
borderTopWidth: 1,
|
|
60
|
+
borderTopColor: 'var(--color-border-default)',
|
|
61
|
+
paddingTop: 10,
|
|
62
|
+
paddingHorizontal: 16,
|
|
63
|
+
paddingBottom: 12,
|
|
64
|
+
flexDirection: 'row',
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
gap: 10,
|
|
67
|
+
}}
|
|
68
|
+
accessibilityRole="toolbar"
|
|
69
|
+
testID="input-bar"
|
|
70
|
+
>
|
|
71
|
+
<View style={{ minWidth: 80, flexDirection: 'column' }}>
|
|
72
|
+
<Text
|
|
73
|
+
style={{
|
|
74
|
+
fontSize: 12,
|
|
75
|
+
fontWeight: '700',
|
|
76
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
77
|
+
color: TEXT_PRIMARY,
|
|
78
|
+
}}
|
|
79
|
+
testID="input-bar-exercise-name"
|
|
80
|
+
numberOfLines={1}
|
|
81
|
+
>
|
|
82
|
+
{exerciseName}
|
|
83
|
+
</Text>
|
|
84
|
+
<Text
|
|
85
|
+
style={{
|
|
86
|
+
fontSize: 10,
|
|
87
|
+
fontFamily: 'Inter, sans-serif',
|
|
88
|
+
color: TEXT_TERTIARY,
|
|
89
|
+
}}
|
|
90
|
+
testID="input-bar-set-info"
|
|
91
|
+
>
|
|
92
|
+
{setLabel}
|
|
93
|
+
</Text>
|
|
94
|
+
</View>
|
|
95
|
+
|
|
96
|
+
<View className="flex-1 flex-row items-center" style={{ gap: 4 }}>
|
|
97
|
+
<TextInput
|
|
98
|
+
value={reps}
|
|
99
|
+
onChangeText={onRepsChange}
|
|
100
|
+
style={{ ...inputStyle, width: 36 }}
|
|
101
|
+
accessibilityLabel="Reps"
|
|
102
|
+
testID="input-bar-reps"
|
|
103
|
+
keyboardType="numeric"
|
|
104
|
+
/>
|
|
105
|
+
<Text
|
|
106
|
+
style={{
|
|
107
|
+
fontSize: 12,
|
|
108
|
+
fontFamily: 'Inter, sans-serif',
|
|
109
|
+
color: TEXT_TERTIARY,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{'\u00D7'}
|
|
113
|
+
</Text>
|
|
114
|
+
<TextInput
|
|
115
|
+
value={weight}
|
|
116
|
+
onChangeText={onWeightChange}
|
|
117
|
+
style={{ ...inputStyle, width: 52 }}
|
|
118
|
+
accessibilityLabel={`Weight in ${unit}`}
|
|
119
|
+
testID="input-bar-weight"
|
|
120
|
+
keyboardType="numeric"
|
|
121
|
+
/>
|
|
122
|
+
<Text
|
|
123
|
+
style={{
|
|
124
|
+
fontSize: 12,
|
|
125
|
+
fontWeight: '500',
|
|
126
|
+
fontFamily: 'Inter, sans-serif',
|
|
127
|
+
color: TEXT_TERTIARY,
|
|
128
|
+
}}
|
|
129
|
+
testID="input-bar-unit"
|
|
130
|
+
>
|
|
131
|
+
{unit}
|
|
132
|
+
</Text>
|
|
133
|
+
</View>
|
|
134
|
+
|
|
135
|
+
<Pressable
|
|
136
|
+
onPress={onRecord}
|
|
137
|
+
disabled={!canRecord}
|
|
138
|
+
style={({ pressed }) => ({
|
|
139
|
+
backgroundColor: BRAND_PRIMARY,
|
|
140
|
+
borderRadius: 8,
|
|
141
|
+
paddingVertical: 10,
|
|
142
|
+
paddingHorizontal: 16,
|
|
143
|
+
opacity: !canRecord ? 0.4 : pressed ? 0.8 : 1,
|
|
144
|
+
})}
|
|
145
|
+
accessibilityRole="button"
|
|
146
|
+
accessibilityLabel="Record set"
|
|
147
|
+
testID="input-bar-record"
|
|
148
|
+
>
|
|
149
|
+
<Text
|
|
150
|
+
style={{
|
|
151
|
+
fontSize: 13,
|
|
152
|
+
fontWeight: '700',
|
|
153
|
+
fontFamily: 'Inter, sans-serif',
|
|
154
|
+
color: '#FFFFFF',
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
Record
|
|
158
|
+
</Text>
|
|
159
|
+
</Pressable>
|
|
160
|
+
</View>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { IntensityBar } from './IntensityBar'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof IntensityBar> = {
|
|
6
|
+
title: 'Custom/Workout/IntensityBar',
|
|
7
|
+
component: IntensityBar,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
level: {
|
|
11
|
+
control: { type: 'range', min: 0, max: 1, step: 0.05 },
|
|
12
|
+
description: 'Intensity level (0-1)',
|
|
13
|
+
},
|
|
14
|
+
threshold: {
|
|
15
|
+
control: { type: 'range', min: 0, max: 1, step: 0.05 },
|
|
16
|
+
description: 'MRV/overexertion threshold position (0-1)',
|
|
17
|
+
},
|
|
18
|
+
orientation: {
|
|
19
|
+
control: { type: 'radio' },
|
|
20
|
+
options: ['vertical', 'horizontal'],
|
|
21
|
+
description: 'Bar orientation',
|
|
22
|
+
},
|
|
23
|
+
size: { control: 'number', description: 'Length along the fill axis (px)' },
|
|
24
|
+
thickness: { control: 'number', description: 'Bar thickness (px)' },
|
|
25
|
+
showThresholdLabel: { control: 'boolean', description: 'Show MRV label' },
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default meta
|
|
30
|
+
type Story = StoryObj<typeof IntensityBar>
|
|
31
|
+
|
|
32
|
+
export const Low: Story = {
|
|
33
|
+
args: { level: 0.3, threshold: 0.85, showThresholdLabel: true },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Moderate: Story = {
|
|
37
|
+
args: { level: 0.55, threshold: 0.85, showThresholdLabel: true },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const High: Story = {
|
|
41
|
+
args: { level: 0.85, threshold: 0.85, showThresholdLabel: true },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Horizontal: Story = {
|
|
45
|
+
args: { level: 0.6, threshold: 0.85, orientation: 'horizontal', size: 120 },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const AllZones: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<View style={{ flexDirection: 'row', gap: 16, alignItems: 'flex-end', padding: 16 }}>
|
|
51
|
+
<IntensityBar level={0.2} threshold={0.85} showThresholdLabel />
|
|
52
|
+
<IntensityBar level={0.35} threshold={0.85} showThresholdLabel />
|
|
53
|
+
<IntensityBar level={0.5} threshold={0.85} showThresholdLabel />
|
|
54
|
+
<IntensityBar level={0.65} threshold={0.85} showThresholdLabel />
|
|
55
|
+
<IntensityBar level={0.75} threshold={0.85} showThresholdLabel />
|
|
56
|
+
<IntensityBar level={0.9} threshold={0.85} showThresholdLabel />
|
|
57
|
+
<IntensityBar level={1.0} threshold={0.85} showThresholdLabel />
|
|
58
|
+
</View>
|
|
59
|
+
),
|
|
60
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { IntensityBar } from './IntensityBar'
|
|
5
|
+
|
|
6
|
+
describe('IntensityBar', () => {
|
|
7
|
+
it('renders the bar', () => {
|
|
8
|
+
render(<IntensityBar level={0.5} />)
|
|
9
|
+
expect(screen.getByTestId('intensity-bar')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('renders fill element', () => {
|
|
13
|
+
render(<IntensityBar level={0.5} />)
|
|
14
|
+
expect(screen.getByTestId('intensity-fill')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders threshold line when threshold is provided', () => {
|
|
18
|
+
render(<IntensityBar level={0.5} threshold={0.85} />)
|
|
19
|
+
expect(screen.getByTestId('intensity-threshold')).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('does not render threshold line when threshold is omitted', () => {
|
|
23
|
+
render(<IntensityBar level={0.5} />)
|
|
24
|
+
expect(screen.queryByTestId('intensity-threshold')).not.toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('shows MRV label when showThresholdLabel is true and threshold set', () => {
|
|
28
|
+
render(<IntensityBar level={0.6} threshold={0.85} showThresholdLabel />)
|
|
29
|
+
expect(screen.getByText('MRV')).toBeInTheDocument()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('hides MRV label when showThresholdLabel is false', () => {
|
|
33
|
+
render(<IntensityBar level={0.6} threshold={0.85} />)
|
|
34
|
+
expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('hides MRV label when threshold is omitted even if showThresholdLabel is true', () => {
|
|
38
|
+
render(<IntensityBar level={0.6} showThresholdLabel />)
|
|
39
|
+
expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('has correct accessibility role', () => {
|
|
43
|
+
render(<IntensityBar level={0.5} />)
|
|
44
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('maps level 0-1 to percentage in accessibility label', () => {
|
|
48
|
+
render(<IntensityBar level={0.75} />)
|
|
49
|
+
expect(screen.getByLabelText('Intensity level: 75%')).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('clamps level above 1 to 100%', () => {
|
|
53
|
+
render(<IntensityBar level={1.5} />)
|
|
54
|
+
expect(screen.getByLabelText('Intensity level: 100%')).toBeInTheDocument()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('clamps level below 0 to 0%', () => {
|
|
58
|
+
render(<IntensityBar level={-0.5} />)
|
|
59
|
+
expect(screen.getByLabelText('Intensity level: 0%')).toBeInTheDocument()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('renders horizontal orientation', () => {
|
|
63
|
+
render(<IntensityBar level={0.5} orientation="horizontal" />)
|
|
64
|
+
expect(screen.getByTestId('intensity-fill')).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('accepts custom size and thickness', () => {
|
|
68
|
+
render(<IntensityBar level={0.5} size={32} thickness={8} />)
|
|
69
|
+
expect(screen.getByTestId('intensity-bar')).toBeInTheDocument()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('fill zones', () => {
|
|
73
|
+
it('renders teal fill for low levels (0-0.4)', () => {
|
|
74
|
+
render(<IntensityBar level={0.3} />)
|
|
75
|
+
expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('renders amber fill for moderate levels (0.4-0.7)', () => {
|
|
79
|
+
render(<IntensityBar level={0.5} />)
|
|
80
|
+
expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('renders red fill for high levels (0.7-1.0)', () => {
|
|
84
|
+
render(<IntensityBar level={0.85} />)
|
|
85
|
+
expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#D14343' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('uses teal at the lower zone boundary', () => {
|
|
89
|
+
render(<IntensityBar level={0.39} />)
|
|
90
|
+
expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('uses amber at the 0.4 boundary', () => {
|
|
94
|
+
render(<IntensityBar level={0.4} />)
|
|
95
|
+
expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('uses red at the 0.7 boundary', () => {
|
|
99
|
+
render(<IntensityBar level={0.7} />)
|
|
100
|
+
expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#D14343' })
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('accessibility', () => {
|
|
105
|
+
it('has no accessibility violations', async () => {
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<IntensityBar level={0.5} threshold={0.85} showThresholdLabel />,
|
|
108
|
+
)
|
|
109
|
+
const results = await axe(container)
|
|
110
|
+
expect(results).toHaveNoViolations()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { View, Text, Animated, type ViewProps, type ViewStyle } from 'react-native'
|
|
4
|
+
|
|
5
|
+
export type IntensityBarOrientation = 'vertical' | 'horizontal'
|
|
6
|
+
|
|
7
|
+
export interface IntensityBarProps extends ViewProps {
|
|
8
|
+
/** Current intensity level 0-1 */
|
|
9
|
+
level: number
|
|
10
|
+
/** MRV/overexertion threshold position 0-1 */
|
|
11
|
+
threshold?: number
|
|
12
|
+
/** Orientation */
|
|
13
|
+
orientation?: IntensityBarOrientation
|
|
14
|
+
/** Height for vertical (default 24px) or width for horizontal */
|
|
15
|
+
size?: number
|
|
16
|
+
/** Width for vertical (default 6px) or height for horizontal */
|
|
17
|
+
thickness?: number
|
|
18
|
+
/** Show threshold label ("MRV") */
|
|
19
|
+
showThresholdLabel?: boolean
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TRACK_BG = 'rgba(255,255,255,0.06)'
|
|
24
|
+
const THRESHOLD_COLOR = '#FFFFFF'
|
|
25
|
+
const LABEL_COLOR = '#6B7280'
|
|
26
|
+
|
|
27
|
+
/** Fill color by intensity zone: teal 0-0.4, amber 0.4-0.7, red 0.7-1.0. */
|
|
28
|
+
function getZoneColor(level: number): string {
|
|
29
|
+
if (level >= 0.7) return '#D14343'
|
|
30
|
+
if (level >= 0.4) return '#FFB020'
|
|
31
|
+
return '#14B8A6'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clamp01(value: number): number {
|
|
35
|
+
if (value < 0) return 0
|
|
36
|
+
if (value > 1) return 1
|
|
37
|
+
return value
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function IntensityBar({
|
|
41
|
+
level,
|
|
42
|
+
threshold,
|
|
43
|
+
orientation = 'vertical',
|
|
44
|
+
size = 24,
|
|
45
|
+
thickness = 6,
|
|
46
|
+
showThresholdLabel,
|
|
47
|
+
className,
|
|
48
|
+
...props
|
|
49
|
+
}: IntensityBarProps) {
|
|
50
|
+
const isVertical = orientation === 'vertical'
|
|
51
|
+
const clampedLevel = clamp01(level)
|
|
52
|
+
const percentage = Math.round(clampedLevel * 100)
|
|
53
|
+
const zoneColor = getZoneColor(clampedLevel)
|
|
54
|
+
|
|
55
|
+
const [fillAnim] = useState(() => new Animated.Value(clampedLevel))
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const animation = Animated.timing(fillAnim, {
|
|
59
|
+
toValue: clampedLevel,
|
|
60
|
+
duration: 250,
|
|
61
|
+
useNativeDriver: false,
|
|
62
|
+
})
|
|
63
|
+
animation.start()
|
|
64
|
+
return () => animation.stop()
|
|
65
|
+
}, [clampedLevel, fillAnim])
|
|
66
|
+
|
|
67
|
+
const fillSizePercent = fillAnim.interpolate({
|
|
68
|
+
inputRange: [0, 1],
|
|
69
|
+
outputRange: ['0%', '100%'],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const trackStyle = isVertical
|
|
73
|
+
? { width: thickness, height: size }
|
|
74
|
+
: { width: size, height: thickness }
|
|
75
|
+
|
|
76
|
+
const fillStyle = isVertical
|
|
77
|
+
? {
|
|
78
|
+
position: 'absolute' as const,
|
|
79
|
+
bottom: 0,
|
|
80
|
+
left: 0,
|
|
81
|
+
right: 0,
|
|
82
|
+
height: fillSizePercent,
|
|
83
|
+
}
|
|
84
|
+
: {
|
|
85
|
+
position: 'absolute' as const,
|
|
86
|
+
top: 0,
|
|
87
|
+
bottom: 0,
|
|
88
|
+
left: 0,
|
|
89
|
+
width: fillSizePercent,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const thresholdLineStyle: ViewStyle | undefined =
|
|
93
|
+
threshold != null
|
|
94
|
+
? isVertical
|
|
95
|
+
? {
|
|
96
|
+
position: 'absolute' as const,
|
|
97
|
+
bottom: `${clamp01(threshold) * 100}%`,
|
|
98
|
+
left: -2,
|
|
99
|
+
right: -2,
|
|
100
|
+
height: 1,
|
|
101
|
+
backgroundColor: THRESHOLD_COLOR,
|
|
102
|
+
zIndex: 4,
|
|
103
|
+
}
|
|
104
|
+
: {
|
|
105
|
+
position: 'absolute' as const,
|
|
106
|
+
left: `${clamp01(threshold) * 100}%`,
|
|
107
|
+
top: -2,
|
|
108
|
+
bottom: -2,
|
|
109
|
+
width: 1,
|
|
110
|
+
backgroundColor: THRESHOLD_COLOR,
|
|
111
|
+
zIndex: 4,
|
|
112
|
+
}
|
|
113
|
+
: undefined
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<View
|
|
117
|
+
className={className}
|
|
118
|
+
style={{ alignItems: 'center' }}
|
|
119
|
+
accessibilityRole="progressbar"
|
|
120
|
+
accessibilityValue={{ min: 0, max: 100, now: percentage }}
|
|
121
|
+
accessibilityLabel={`Intensity level: ${percentage}%`}
|
|
122
|
+
testID="intensity-bar"
|
|
123
|
+
{...props}
|
|
124
|
+
>
|
|
125
|
+
<View
|
|
126
|
+
style={{
|
|
127
|
+
...trackStyle,
|
|
128
|
+
borderRadius: 3,
|
|
129
|
+
backgroundColor: TRACK_BG,
|
|
130
|
+
position: 'relative',
|
|
131
|
+
overflow: 'visible',
|
|
132
|
+
}}
|
|
133
|
+
accessibilityElementsHidden
|
|
134
|
+
>
|
|
135
|
+
<Animated.View
|
|
136
|
+
style={{
|
|
137
|
+
...fillStyle,
|
|
138
|
+
borderRadius: 3,
|
|
139
|
+
backgroundColor: zoneColor,
|
|
140
|
+
zIndex: 2,
|
|
141
|
+
}}
|
|
142
|
+
testID="intensity-fill"
|
|
143
|
+
/>
|
|
144
|
+
|
|
145
|
+
{threshold != null && (
|
|
146
|
+
<View style={thresholdLineStyle} testID="intensity-threshold" />
|
|
147
|
+
)}
|
|
148
|
+
</View>
|
|
149
|
+
{showThresholdLabel && threshold != null && (
|
|
150
|
+
<Text
|
|
151
|
+
style={{
|
|
152
|
+
fontSize: 8,
|
|
153
|
+
fontWeight: '500',
|
|
154
|
+
color: LABEL_COLOR,
|
|
155
|
+
fontFamily: 'Inter, sans-serif',
|
|
156
|
+
marginTop: 6,
|
|
157
|
+
}}
|
|
158
|
+
accessibilityElementsHidden
|
|
159
|
+
testID="intensity-threshold-label"
|
|
160
|
+
>
|
|
161
|
+
MRV
|
|
162
|
+
</Text>
|
|
163
|
+
)}
|
|
164
|
+
</View>
|
|
165
|
+
)
|
|
166
|
+
}
|