@titan-design/react-ui 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/docs/WEB_SETUP.md +217 -0
- package/package.json +17 -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,92 @@
|
|
|
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
|
+
|
|
4
|
+
// Aliases for spec compatibility: under=behind, maintenance=ontrack, productive=target
|
|
5
|
+
export type VolumeStatus = 'untrained' | 'behind' | 'ontrack' | 'target' | 'over'
|
|
6
|
+
|
|
7
|
+
export interface MuscleGroupChipProps extends ViewProps {
|
|
8
|
+
name: string
|
|
9
|
+
volumeStatus?: VolumeStatus
|
|
10
|
+
onPress?: () => void
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const dotColorMap: Record<VolumeStatus, string> = {
|
|
15
|
+
untrained: '#2C2C2C',
|
|
16
|
+
behind: '#406D87',
|
|
17
|
+
ontrack: '#14B8A6',
|
|
18
|
+
target: '#FF7900',
|
|
19
|
+
over: '#D14343',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function MuscleGroupChip({
|
|
23
|
+
name,
|
|
24
|
+
volumeStatus,
|
|
25
|
+
onPress,
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
}: MuscleGroupChipProps) {
|
|
29
|
+
const dotColor = volumeStatus
|
|
30
|
+
? dotColorMap[volumeStatus]
|
|
31
|
+
: '#2C2C2C'
|
|
32
|
+
|
|
33
|
+
const statusLabel = volumeStatus ?? 'no status'
|
|
34
|
+
|
|
35
|
+
const content = (
|
|
36
|
+
<View
|
|
37
|
+
className={className}
|
|
38
|
+
style={{
|
|
39
|
+
flexDirection: 'row',
|
|
40
|
+
alignItems: 'center',
|
|
41
|
+
borderRadius: 9999,
|
|
42
|
+
paddingHorizontal: 9,
|
|
43
|
+
paddingVertical: 3,
|
|
44
|
+
backgroundColor: 'var(--color-surface-raised)',
|
|
45
|
+
borderWidth: 1,
|
|
46
|
+
borderColor: 'var(--color-border-default)',
|
|
47
|
+
}}
|
|
48
|
+
accessibilityLabel={onPress ? undefined : `${name}, volume status: ${statusLabel}`}
|
|
49
|
+
testID="muscle-group-chip"
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
<View
|
|
53
|
+
style={{
|
|
54
|
+
width: 6,
|
|
55
|
+
height: 6,
|
|
56
|
+
borderRadius: 9999,
|
|
57
|
+
marginRight: 5,
|
|
58
|
+
flexShrink: 0,
|
|
59
|
+
backgroundColor: dotColor,
|
|
60
|
+
}}
|
|
61
|
+
accessibilityElementsHidden
|
|
62
|
+
testID="muscle-group-chip-dot"
|
|
63
|
+
/>
|
|
64
|
+
<Text
|
|
65
|
+
style={{
|
|
66
|
+
fontFamily: 'Inter, sans-serif',
|
|
67
|
+
fontWeight: '500',
|
|
68
|
+
fontSize: 11,
|
|
69
|
+
color: 'var(--color-text-secondary)',
|
|
70
|
+
}}
|
|
71
|
+
accessibilityElementsHidden
|
|
72
|
+
>
|
|
73
|
+
{name}
|
|
74
|
+
</Text>
|
|
75
|
+
</View>
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if (onPress) {
|
|
79
|
+
return (
|
|
80
|
+
<Pressable
|
|
81
|
+
onPress={onPress}
|
|
82
|
+
accessibilityRole="button"
|
|
83
|
+
accessibilityLabel={`${name}, volume status: ${statusLabel}`}
|
|
84
|
+
testID="muscle-group-chip-pressable"
|
|
85
|
+
>
|
|
86
|
+
{content}
|
|
87
|
+
</Pressable>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return content
|
|
92
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { PlaceholderStrip } from './PlaceholderStrip'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof PlaceholderStrip> = {
|
|
6
|
+
title: 'Custom/Workout/PlaceholderStrip',
|
|
7
|
+
component: PlaceholderStrip,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
width: {
|
|
11
|
+
control: 'text',
|
|
12
|
+
description: 'Fixed width or flex:1 (default)',
|
|
13
|
+
},
|
|
14
|
+
mode: {
|
|
15
|
+
control: 'select',
|
|
16
|
+
options: ['single', 'segmented'],
|
|
17
|
+
description: 'Render as single dashed strip or segmented mini-strip',
|
|
18
|
+
},
|
|
19
|
+
segments: {
|
|
20
|
+
control: 'number',
|
|
21
|
+
description: 'Number of segments in segmented mode',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default meta
|
|
27
|
+
type Story = StoryObj<typeof PlaceholderStrip>
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: {},
|
|
31
|
+
decorators: [
|
|
32
|
+
(Story) => (
|
|
33
|
+
<View style={{ width: 300 }}>
|
|
34
|
+
<Story />
|
|
35
|
+
</View>
|
|
36
|
+
),
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const FixedWidth: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
width: 200,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const Segmented: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
mode: 'segmented',
|
|
49
|
+
segments: 5,
|
|
50
|
+
},
|
|
51
|
+
decorators: [
|
|
52
|
+
(Story) => (
|
|
53
|
+
<View style={{ width: 300 }}>
|
|
54
|
+
<Story />
|
|
55
|
+
</View>
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const SegmentedThree: Story = {
|
|
61
|
+
args: {
|
|
62
|
+
mode: 'segmented',
|
|
63
|
+
segments: 3,
|
|
64
|
+
},
|
|
65
|
+
decorators: [
|
|
66
|
+
(Story) => (
|
|
67
|
+
<View style={{ width: 200 }}>
|
|
68
|
+
<Story />
|
|
69
|
+
</View>
|
|
70
|
+
),
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const MultipleStrips: Story = {
|
|
75
|
+
render: () => (
|
|
76
|
+
<View style={{ flexDirection: 'row', gap: 4, width: 300 }}>
|
|
77
|
+
<View style={{ flex: 1, height: 3, backgroundColor: '#2ed573', borderRadius: 1 }} />
|
|
78
|
+
<View style={{ flex: 1, height: 3, backgroundColor: '#ffd43b', borderRadius: 1 }} />
|
|
79
|
+
<View style={{ flex: 1, height: 3, backgroundColor: '#ffa502', borderRadius: 1 }} />
|
|
80
|
+
<PlaceholderStrip />
|
|
81
|
+
<PlaceholderStrip />
|
|
82
|
+
</View>
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const MixedModes: Story = {
|
|
87
|
+
render: () => (
|
|
88
|
+
<View style={{ gap: 12, width: 300 }}>
|
|
89
|
+
<PlaceholderStrip mode="single" />
|
|
90
|
+
<PlaceholderStrip mode="segmented" segments={5} />
|
|
91
|
+
<PlaceholderStrip mode="segmented" segments={8} />
|
|
92
|
+
</View>
|
|
93
|
+
),
|
|
94
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { PlaceholderStrip } from './PlaceholderStrip'
|
|
5
|
+
|
|
6
|
+
describe('PlaceholderStrip', () => {
|
|
7
|
+
it('renders a strip element', () => {
|
|
8
|
+
render(<PlaceholderStrip />)
|
|
9
|
+
expect(screen.getByTestId('placeholder-strip')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('sets accessibility label', () => {
|
|
13
|
+
render(<PlaceholderStrip />)
|
|
14
|
+
expect(
|
|
15
|
+
screen.getByLabelText('Planned set, not yet completed'),
|
|
16
|
+
).toBeInTheDocument()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('applies fixed width when provided', () => {
|
|
20
|
+
render(<PlaceholderStrip width={200} />)
|
|
21
|
+
const strip = screen.getByTestId('placeholder-strip')
|
|
22
|
+
expect(strip).toBeInTheDocument()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders multiple strips', () => {
|
|
26
|
+
render(
|
|
27
|
+
<>
|
|
28
|
+
<PlaceholderStrip />
|
|
29
|
+
<PlaceholderStrip />
|
|
30
|
+
<PlaceholderStrip />
|
|
31
|
+
</>,
|
|
32
|
+
)
|
|
33
|
+
const strips = screen.getAllByTestId('placeholder-strip')
|
|
34
|
+
expect(strips).toHaveLength(3)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('defaults to single mode', () => {
|
|
38
|
+
render(<PlaceholderStrip />)
|
|
39
|
+
expect(screen.queryAllByTestId('placeholder-segment')).toHaveLength(0)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('segmented mode', () => {
|
|
43
|
+
it('renders segments when mode is segmented', () => {
|
|
44
|
+
render(<PlaceholderStrip mode="segmented" segments={5} />)
|
|
45
|
+
const segments = screen.getAllByTestId('placeholder-segment')
|
|
46
|
+
expect(segments).toHaveLength(5)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('defaults to 3 segments when segments prop is omitted', () => {
|
|
50
|
+
render(<PlaceholderStrip mode="segmented" />)
|
|
51
|
+
const segments = screen.getAllByTestId('placeholder-segment')
|
|
52
|
+
expect(segments).toHaveLength(3)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('still has the strip container testID', () => {
|
|
56
|
+
render(<PlaceholderStrip mode="segmented" segments={4} />)
|
|
57
|
+
expect(screen.getByTestId('placeholder-strip')).toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('sets accessibility label in segmented mode', () => {
|
|
61
|
+
render(<PlaceholderStrip mode="segmented" segments={3} />)
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByLabelText('Planned set, not yet completed'),
|
|
64
|
+
).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('accessibility', () => {
|
|
69
|
+
it('has no accessibility violations in single mode', async () => {
|
|
70
|
+
const { container } = render(<PlaceholderStrip />)
|
|
71
|
+
const results = await axe(container)
|
|
72
|
+
expect(results).toHaveNoViolations()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('has no accessibility violations in segmented mode', async () => {
|
|
76
|
+
const { container } = render(
|
|
77
|
+
<PlaceholderStrip mode="segmented" segments={4} />,
|
|
78
|
+
)
|
|
79
|
+
const results = await axe(container)
|
|
80
|
+
expect(results).toHaveNoViolations()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { View, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
5
|
+
|
|
6
|
+
export interface PlaceholderStripProps extends ViewProps {
|
|
7
|
+
width?: number | string
|
|
8
|
+
/** Render as single solid strip or segmented mini-strip */
|
|
9
|
+
mode?: 'single' | 'segmented'
|
|
10
|
+
/** Number of segments when mode is 'segmented' */
|
|
11
|
+
segments?: number
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function SingleStrip({
|
|
16
|
+
width,
|
|
17
|
+
className,
|
|
18
|
+
...props
|
|
19
|
+
}: Omit<PlaceholderStripProps, 'mode' | 'segments'>) {
|
|
20
|
+
return (
|
|
21
|
+
<View
|
|
22
|
+
className={cn(className)}
|
|
23
|
+
style={[
|
|
24
|
+
{
|
|
25
|
+
height: 3,
|
|
26
|
+
backgroundColor: 'transparent',
|
|
27
|
+
borderWidth: 1,
|
|
28
|
+
borderStyle: 'dashed',
|
|
29
|
+
borderColor: WORKOUT_TOKENS.border.strong,
|
|
30
|
+
borderRadius: 1,
|
|
31
|
+
opacity: 0.5,
|
|
32
|
+
},
|
|
33
|
+
width != null ? { width: width as number } : { flex: 1 },
|
|
34
|
+
]}
|
|
35
|
+
accessibilityRole="image"
|
|
36
|
+
accessibilityLabel="Planned set, not yet completed"
|
|
37
|
+
testID="placeholder-strip"
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function SegmentedStrip({
|
|
44
|
+
segments = 3,
|
|
45
|
+
width,
|
|
46
|
+
className,
|
|
47
|
+
...props
|
|
48
|
+
}: Omit<PlaceholderStripProps, 'mode'>) {
|
|
49
|
+
return (
|
|
50
|
+
<View
|
|
51
|
+
className={cn('flex-row', className)}
|
|
52
|
+
style={[
|
|
53
|
+
{ height: 3, gap: 2, opacity: 0.5 },
|
|
54
|
+
width != null ? { width: width as number } : { flex: 1 },
|
|
55
|
+
]}
|
|
56
|
+
accessibilityRole="image"
|
|
57
|
+
accessibilityLabel="Planned set, not yet completed"
|
|
58
|
+
testID="placeholder-strip"
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
{Array.from({ length: segments }, (_, i) => (
|
|
62
|
+
<View
|
|
63
|
+
key={i}
|
|
64
|
+
style={{
|
|
65
|
+
flex: 1,
|
|
66
|
+
height: 3,
|
|
67
|
+
backgroundColor: 'transparent',
|
|
68
|
+
borderWidth: 1,
|
|
69
|
+
borderStyle: 'dashed',
|
|
70
|
+
borderColor: WORKOUT_TOKENS.border.strong,
|
|
71
|
+
borderRadius: 1,
|
|
72
|
+
minWidth: 4,
|
|
73
|
+
}}
|
|
74
|
+
accessibilityElementsHidden
|
|
75
|
+
testID="placeholder-segment"
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
</View>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function PlaceholderStrip({
|
|
83
|
+
mode = 'single',
|
|
84
|
+
segments,
|
|
85
|
+
...props
|
|
86
|
+
}: PlaceholderStripProps) {
|
|
87
|
+
if (mode === 'segmented') {
|
|
88
|
+
return <SegmentedStrip segments={segments} {...props} />
|
|
89
|
+
}
|
|
90
|
+
return <SingleStrip {...props} />
|
|
91
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { PrBadge } from './PrBadge'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof PrBadge> = {
|
|
6
|
+
title: 'Custom/Workout/PrBadge',
|
|
7
|
+
component: PrBadge,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
type: {
|
|
11
|
+
control: 'select',
|
|
12
|
+
options: ['e1rm', 'weight', 'reps', 'volume', 'velocity'],
|
|
13
|
+
description: 'PR type determines auto-generated label',
|
|
14
|
+
},
|
|
15
|
+
label: {
|
|
16
|
+
control: 'text',
|
|
17
|
+
description: 'Custom label (overrides auto-generated)',
|
|
18
|
+
},
|
|
19
|
+
compact: {
|
|
20
|
+
control: 'boolean',
|
|
21
|
+
description: 'Show only star icon, no text',
|
|
22
|
+
},
|
|
23
|
+
animate: {
|
|
24
|
+
control: 'boolean',
|
|
25
|
+
description: 'Trigger pop animation on mount',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default meta
|
|
31
|
+
type Story = StoryObj<typeof PrBadge>
|
|
32
|
+
|
|
33
|
+
export const Animated: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
animate: true,
|
|
36
|
+
type: 'e1rm',
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const Static: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
animate: false,
|
|
43
|
+
type: 'e1rm',
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const WeightPr: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
type: 'weight',
|
|
50
|
+
animate: false,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const RepsPr: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
type: 'reps',
|
|
57
|
+
animate: false,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const VelocityPr: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
type: 'velocity',
|
|
64
|
+
animate: false,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const CustomLabel: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
label: 'PR 5RM',
|
|
71
|
+
animate: false,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const Compact: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
compact: true,
|
|
78
|
+
animate: false,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const CompactAnimated: Story = {
|
|
83
|
+
args: {
|
|
84
|
+
compact: true,
|
|
85
|
+
animate: true,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const AllTypes: Story = {
|
|
90
|
+
render: () => (
|
|
91
|
+
<View style={{ gap: 8 }}>
|
|
92
|
+
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
|
93
|
+
<PrBadge type="e1rm" animate={false} />
|
|
94
|
+
<PrBadge type="weight" animate={false} />
|
|
95
|
+
<PrBadge type="reps" animate={false} />
|
|
96
|
+
<PrBadge type="volume" animate={false} />
|
|
97
|
+
<PrBadge type="velocity" animate={false} />
|
|
98
|
+
</View>
|
|
99
|
+
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
|
100
|
+
<PrBadge compact animate={false} />
|
|
101
|
+
<PrBadge label="PR 5RM" animate={false} />
|
|
102
|
+
</View>
|
|
103
|
+
</View>
|
|
104
|
+
),
|
|
105
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { PrBadge } from './PrBadge'
|
|
5
|
+
|
|
6
|
+
describe('PrBadge', () => {
|
|
7
|
+
it('renders star and default PR label', () => {
|
|
8
|
+
render(<PrBadge animate={false} />)
|
|
9
|
+
expect(screen.getByText(/\u2605 PR e1RM/)).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('renders with animation wrapper when animate is true', () => {
|
|
13
|
+
render(<PrBadge animate />)
|
|
14
|
+
expect(screen.getByTestId('pr-badge-animated')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders without animation wrapper when animate is false', () => {
|
|
18
|
+
render(<PrBadge animate={false} />)
|
|
19
|
+
expect(screen.queryByTestId('pr-badge-animated')).not.toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('defaults to animated', () => {
|
|
23
|
+
render(<PrBadge />)
|
|
24
|
+
expect(screen.getByTestId('pr-badge-animated')).toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('sets accessibility label with resolved label', () => {
|
|
28
|
+
render(<PrBadge animate={false} />)
|
|
29
|
+
expect(
|
|
30
|
+
screen.getByLabelText('Personal record: PR e1RM'),
|
|
31
|
+
).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('type prop', () => {
|
|
35
|
+
it('generates label from e1rm type', () => {
|
|
36
|
+
render(<PrBadge type="e1rm" animate={false} />)
|
|
37
|
+
expect(screen.getByText(/PR e1RM/)).toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('generates label from weight type', () => {
|
|
41
|
+
render(<PrBadge type="weight" animate={false} />)
|
|
42
|
+
expect(screen.getByText(/PR Weight/)).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('generates label from reps type', () => {
|
|
46
|
+
render(<PrBadge type="reps" animate={false} />)
|
|
47
|
+
expect(screen.getByText(/PR Reps/)).toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('generates label from volume type', () => {
|
|
51
|
+
render(<PrBadge type="volume" animate={false} />)
|
|
52
|
+
expect(screen.getByText(/PR Volume/)).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('generates label from velocity type', () => {
|
|
56
|
+
render(<PrBadge type="velocity" animate={false} />)
|
|
57
|
+
expect(screen.getByText(/PR Velocity/)).toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('custom label', () => {
|
|
62
|
+
it('uses custom label over auto-generated', () => {
|
|
63
|
+
render(<PrBadge label="PR 5RM" animate={false} />)
|
|
64
|
+
expect(screen.getByText(/PR 5RM/)).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('sets accessibility label with custom label', () => {
|
|
68
|
+
render(<PrBadge label="PR 5RM" animate={false} />)
|
|
69
|
+
expect(
|
|
70
|
+
screen.getByLabelText('Personal record: PR 5RM'),
|
|
71
|
+
).toBeInTheDocument()
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('compact mode', () => {
|
|
76
|
+
it('renders only the star icon when compact', () => {
|
|
77
|
+
render(<PrBadge compact animate={false} />)
|
|
78
|
+
const star = screen.getByTestId('pr-badge-star')
|
|
79
|
+
expect(star).toBeInTheDocument()
|
|
80
|
+
// The compact star is now a Lucide SVG icon, not text
|
|
81
|
+
expect(star.querySelector('svg')).toBeInTheDocument()
|
|
82
|
+
expect(star).not.toHaveTextContent('\u2605')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('does not show label text in compact mode', () => {
|
|
86
|
+
render(<PrBadge compact animate={false} />)
|
|
87
|
+
expect(screen.queryByText(/PR e1RM/)).not.toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('has correct accessibility label in compact mode', () => {
|
|
91
|
+
render(<PrBadge compact animate={false} />)
|
|
92
|
+
expect(
|
|
93
|
+
screen.getByLabelText('Personal record: PR e1RM'),
|
|
94
|
+
).toBeInTheDocument()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('accessibility', () => {
|
|
99
|
+
it('has no accessibility violations', async () => {
|
|
100
|
+
const { container } = render(<PrBadge animate={false} />)
|
|
101
|
+
const results = await axe(container)
|
|
102
|
+
expect(results).toHaveNoViolations()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('has no accessibility violations in compact mode', async () => {
|
|
106
|
+
const { container } = render(<PrBadge compact animate={false} />)
|
|
107
|
+
const results = await axe(container)
|
|
108
|
+
expect(results).toHaveNoViolations()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
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, Easing, type ViewProps } from 'react-native'
|
|
4
|
+
import { Star } from 'lucide-react'
|
|
5
|
+
import { BaseBadge } from './BaseBadge'
|
|
6
|
+
|
|
7
|
+
export type PRType = 'e1rm' | 'weight' | 'reps' | 'volume' | 'velocity'
|
|
8
|
+
|
|
9
|
+
export interface PrBadgeProps extends ViewProps {
|
|
10
|
+
/** PR type determines auto-generated label */
|
|
11
|
+
type?: PRType
|
|
12
|
+
/** Display label (e.g., "PR e1RM", "PR 5RM"). Auto-generated from type if omitted. */
|
|
13
|
+
label?: string
|
|
14
|
+
/** Show only the star icon, no text label */
|
|
15
|
+
compact?: boolean
|
|
16
|
+
/** Trigger pop animation on mount */
|
|
17
|
+
animate?: boolean
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const typeLabels: Record<PRType, string> = {
|
|
22
|
+
e1rm: 'PR e1RM',
|
|
23
|
+
weight: 'PR Weight',
|
|
24
|
+
reps: 'PR Reps',
|
|
25
|
+
volume: 'PR Volume',
|
|
26
|
+
velocity: 'PR Velocity',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function PrBadge({
|
|
30
|
+
type = 'e1rm',
|
|
31
|
+
label: labelProp,
|
|
32
|
+
compact = false,
|
|
33
|
+
animate = true,
|
|
34
|
+
className,
|
|
35
|
+
...props
|
|
36
|
+
}: PrBadgeProps) {
|
|
37
|
+
const resolvedLabel = labelProp ?? typeLabels[type]
|
|
38
|
+
const [scale] = useState(() => new Animated.Value(animate ? 0.8 : 1))
|
|
39
|
+
const [opacity] = useState(() => new Animated.Value(animate ? 0 : 1))
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!animate) return
|
|
43
|
+
|
|
44
|
+
// HTML demo uses a 3-keyframe sequence; bezier approximation is close enough for native
|
|
45
|
+
Animated.parallel([
|
|
46
|
+
Animated.timing(scale, {
|
|
47
|
+
toValue: 1,
|
|
48
|
+
duration: 400,
|
|
49
|
+
easing: Easing.bezier(0.34, 1.56, 0.64, 1),
|
|
50
|
+
useNativeDriver: true,
|
|
51
|
+
}),
|
|
52
|
+
Animated.timing(opacity, {
|
|
53
|
+
toValue: 1,
|
|
54
|
+
duration: 400,
|
|
55
|
+
easing: Easing.bezier(0.34, 1.56, 0.64, 1),
|
|
56
|
+
useNativeDriver: true,
|
|
57
|
+
}),
|
|
58
|
+
]).start()
|
|
59
|
+
}, [animate, scale, opacity])
|
|
60
|
+
|
|
61
|
+
const badge = compact ? (
|
|
62
|
+
<View
|
|
63
|
+
className={className}
|
|
64
|
+
accessibilityRole="image"
|
|
65
|
+
accessibilityLabel={`Personal record: ${resolvedLabel}`}
|
|
66
|
+
testID="pr-badge-star"
|
|
67
|
+
{...props}
|
|
68
|
+
>
|
|
69
|
+
<Star size={14} color="#FF7900" fill="#FF7900" strokeWidth={2} />
|
|
70
|
+
</View>
|
|
71
|
+
) : (
|
|
72
|
+
<BaseBadge
|
|
73
|
+
variant="pr"
|
|
74
|
+
className={className}
|
|
75
|
+
accessibilityLabel={`Personal record: ${resolvedLabel}`}
|
|
76
|
+
{...props}
|
|
77
|
+
>
|
|
78
|
+
<Text
|
|
79
|
+
style={{
|
|
80
|
+
fontSize: 10,
|
|
81
|
+
color: '#FF7900',
|
|
82
|
+
fontWeight: '700',
|
|
83
|
+
fontFamily: 'Inter, sans-serif',
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{'\u2605'} {resolvedLabel}
|
|
87
|
+
</Text>
|
|
88
|
+
</BaseBadge>
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if (animate) {
|
|
92
|
+
return (
|
|
93
|
+
<Animated.View
|
|
94
|
+
style={{ transform: [{ scale }], opacity }}
|
|
95
|
+
testID="pr-badge-animated"
|
|
96
|
+
>
|
|
97
|
+
{badge}
|
|
98
|
+
</Animated.View>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return badge
|
|
103
|
+
}
|