@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,226 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { View, Text, ScrollView, Pressable, type ViewProps } from 'react-native'
|
|
3
|
+
import { Card } from '../../ui/card'
|
|
4
|
+
import { ExerciseCard, type ExerciseCardProps } from './ExerciseCard'
|
|
5
|
+
import {
|
|
6
|
+
MuscleGroupChip,
|
|
7
|
+
type VolumeStatus,
|
|
8
|
+
} from './MuscleGroupChip'
|
|
9
|
+
|
|
10
|
+
export type WorkoutStatus = 'completed' | 'today' | 'upcoming'
|
|
11
|
+
|
|
12
|
+
/** Spec volume-status vocabulary; mapped to MuscleGroupChip's VolumeStatus internally. */
|
|
13
|
+
export type WorkoutMuscleVolumeStatus =
|
|
14
|
+
| 'under'
|
|
15
|
+
| 'maintenance'
|
|
16
|
+
| 'productive'
|
|
17
|
+
| 'over'
|
|
18
|
+
|
|
19
|
+
export interface WorkoutMuscleGroup {
|
|
20
|
+
/** Muscle group identifier (free-form to match plan data). */
|
|
21
|
+
group: string
|
|
22
|
+
/** Display label, may be abbreviated. */
|
|
23
|
+
label: string
|
|
24
|
+
/** Volume status relative to landmarks. */
|
|
25
|
+
volumeStatus?: WorkoutMuscleVolumeStatus
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkoutCardProps extends ViewProps {
|
|
29
|
+
name: string
|
|
30
|
+
date: string
|
|
31
|
+
/** e.g. "45 min" */
|
|
32
|
+
duration?: string
|
|
33
|
+
status: WorkoutStatus
|
|
34
|
+
muscleGroups: WorkoutMuscleGroup[]
|
|
35
|
+
totalSets: number
|
|
36
|
+
/** Total lbs/kg moved across the workout. */
|
|
37
|
+
totalVolume?: number
|
|
38
|
+
unit: 'lbs' | 'kg'
|
|
39
|
+
exercises?: ExerciseCardProps[]
|
|
40
|
+
expanded?: boolean
|
|
41
|
+
onToggle?: () => void
|
|
42
|
+
className?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const COLORS = {
|
|
46
|
+
textPrimary: '#F3F4F6',
|
|
47
|
+
textSecondary: '#9CA3AF',
|
|
48
|
+
textTertiary: '#6B7280',
|
|
49
|
+
statusSuccess: '#14B8A6',
|
|
50
|
+
brandPrimary: '#FF7900',
|
|
51
|
+
borderDefault: '#1F1F1F',
|
|
52
|
+
brandPrimarySubtle: 'rgba(255,121,0,0.06)',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const statusBorderColors: Record<WorkoutStatus, string> = {
|
|
56
|
+
completed: COLORS.statusSuccess,
|
|
57
|
+
today: COLORS.brandPrimary,
|
|
58
|
+
upcoming: COLORS.borderDefault,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Maps the spec's volume-status vocabulary onto MuscleGroupChip's enum. */
|
|
62
|
+
const volumeStatusMap: Record<WorkoutMuscleVolumeStatus, VolumeStatus> = {
|
|
63
|
+
under: 'behind',
|
|
64
|
+
maintenance: 'ontrack',
|
|
65
|
+
productive: 'target',
|
|
66
|
+
over: 'over',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatStats(
|
|
70
|
+
totalSets: number,
|
|
71
|
+
totalVolume: number | undefined,
|
|
72
|
+
unit: 'lbs' | 'kg',
|
|
73
|
+
duration: string | undefined,
|
|
74
|
+
): string {
|
|
75
|
+
const parts = [`${totalSets} sets`]
|
|
76
|
+
if (totalVolume != null) parts.push(`${totalVolume} ${unit}`)
|
|
77
|
+
if (duration) parts.push(duration)
|
|
78
|
+
return parts.join(' · ')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A workout within a week: name, date, duration, muscle-group pills, and a
|
|
83
|
+
* stats summary. When expandable it renders contained ExerciseCards on expand.
|
|
84
|
+
* Status drives a colored left border, today adds a brand tint, and upcoming
|
|
85
|
+
* is dimmed.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* <WorkoutCard
|
|
89
|
+
* name="Upper A"
|
|
90
|
+
* date="Mon, Jun 23"
|
|
91
|
+
* duration="45 min"
|
|
92
|
+
* status="today"
|
|
93
|
+
* muscleGroups={[{ group: 'chest', label: 'Chest', volumeStatus: 'productive' }]}
|
|
94
|
+
* totalSets={18}
|
|
95
|
+
* totalVolume={12450}
|
|
96
|
+
* unit="lbs"
|
|
97
|
+
* expanded
|
|
98
|
+
* onToggle={() => {}}
|
|
99
|
+
* exercises={exercises}
|
|
100
|
+
* />
|
|
101
|
+
*/
|
|
102
|
+
export function WorkoutCard({
|
|
103
|
+
name,
|
|
104
|
+
date,
|
|
105
|
+
duration,
|
|
106
|
+
status,
|
|
107
|
+
muscleGroups,
|
|
108
|
+
totalSets,
|
|
109
|
+
totalVolume,
|
|
110
|
+
unit,
|
|
111
|
+
exercises,
|
|
112
|
+
expanded = false,
|
|
113
|
+
onToggle,
|
|
114
|
+
className,
|
|
115
|
+
...props
|
|
116
|
+
}: WorkoutCardProps) {
|
|
117
|
+
const isExpandable = onToggle != null
|
|
118
|
+
const isUpcoming = status === 'upcoming'
|
|
119
|
+
const isToday = status === 'today'
|
|
120
|
+
|
|
121
|
+
const summary = (
|
|
122
|
+
<View style={{ padding: 14 }} testID="workout-card-body">
|
|
123
|
+
<View className="flex-row items-center" testID="workout-card-header">
|
|
124
|
+
<Text
|
|
125
|
+
style={{
|
|
126
|
+
fontSize: 15,
|
|
127
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
128
|
+
fontWeight: '700',
|
|
129
|
+
color: COLORS.textPrimary,
|
|
130
|
+
}}
|
|
131
|
+
testID="workout-card-name"
|
|
132
|
+
>
|
|
133
|
+
{name}
|
|
134
|
+
</Text>
|
|
135
|
+
<View className="flex-1" />
|
|
136
|
+
<Text
|
|
137
|
+
style={{
|
|
138
|
+
fontSize: 11,
|
|
139
|
+
fontFamily: 'Inter, sans-serif',
|
|
140
|
+
color: COLORS.textTertiary,
|
|
141
|
+
}}
|
|
142
|
+
testID="workout-card-date"
|
|
143
|
+
>
|
|
144
|
+
{date}
|
|
145
|
+
</Text>
|
|
146
|
+
</View>
|
|
147
|
+
|
|
148
|
+
<Text
|
|
149
|
+
style={{
|
|
150
|
+
marginTop: 4,
|
|
151
|
+
fontSize: 12,
|
|
152
|
+
fontFamily: 'Inter, sans-serif',
|
|
153
|
+
color: COLORS.textSecondary,
|
|
154
|
+
}}
|
|
155
|
+
testID="workout-card-stats"
|
|
156
|
+
>
|
|
157
|
+
{formatStats(totalSets, totalVolume, unit, duration)}
|
|
158
|
+
</Text>
|
|
159
|
+
|
|
160
|
+
{muscleGroups.length > 0 && (
|
|
161
|
+
<ScrollView
|
|
162
|
+
horizontal
|
|
163
|
+
showsHorizontalScrollIndicator={false}
|
|
164
|
+
contentContainerStyle={{ gap: 5 }}
|
|
165
|
+
style={{ marginTop: 8 }}
|
|
166
|
+
testID="workout-card-muscle-groups"
|
|
167
|
+
>
|
|
168
|
+
{muscleGroups.map((muscle) => (
|
|
169
|
+
<MuscleGroupChip
|
|
170
|
+
key={muscle.group}
|
|
171
|
+
name={muscle.label}
|
|
172
|
+
volumeStatus={
|
|
173
|
+
muscle.volumeStatus
|
|
174
|
+
? volumeStatusMap[muscle.volumeStatus]
|
|
175
|
+
: undefined
|
|
176
|
+
}
|
|
177
|
+
/>
|
|
178
|
+
))}
|
|
179
|
+
</ScrollView>
|
|
180
|
+
)}
|
|
181
|
+
</View>
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<Card
|
|
186
|
+
variant="accent"
|
|
187
|
+
elevation={1}
|
|
188
|
+
accentColor={statusBorderColors[status]}
|
|
189
|
+
bgColor={isToday ? COLORS.brandPrimarySubtle : undefined}
|
|
190
|
+
className={className}
|
|
191
|
+
style={isUpcoming ? { opacity: 0.6 } : undefined}
|
|
192
|
+
testID="workout-card"
|
|
193
|
+
{...props}
|
|
194
|
+
>
|
|
195
|
+
{isExpandable ? (
|
|
196
|
+
<Pressable
|
|
197
|
+
onPress={onToggle}
|
|
198
|
+
accessibilityRole="button"
|
|
199
|
+
accessibilityLabel={`${name} workout, ${date}, ${status}`}
|
|
200
|
+
aria-expanded={expanded}
|
|
201
|
+
testID="workout-card-toggle"
|
|
202
|
+
>
|
|
203
|
+
{summary}
|
|
204
|
+
</Pressable>
|
|
205
|
+
) : (
|
|
206
|
+
<View
|
|
207
|
+
accessibilityLabel={`${name} workout, ${date}, ${status}`}
|
|
208
|
+
testID="workout-card-static"
|
|
209
|
+
>
|
|
210
|
+
{summary}
|
|
211
|
+
</View>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{expanded && exercises && exercises.length > 0 && (
|
|
215
|
+
<View
|
|
216
|
+
style={{ paddingHorizontal: 8, paddingBottom: 8, gap: 6 }}
|
|
217
|
+
testID="workout-card-exercises"
|
|
218
|
+
>
|
|
219
|
+
{exercises.map((exercise, i) => (
|
|
220
|
+
<ExerciseCard key={i} {...exercise} />
|
|
221
|
+
))}
|
|
222
|
+
</View>
|
|
223
|
+
)}
|
|
224
|
+
</Card>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { WorkoutPill } from './WorkoutPill'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof WorkoutPill> = {
|
|
6
|
+
title: 'Custom/Workout/WorkoutPill',
|
|
7
|
+
component: WorkoutPill,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
name: { control: 'text', description: 'Workout name' },
|
|
11
|
+
status: {
|
|
12
|
+
control: 'select',
|
|
13
|
+
options: ['completed', 'current', 'next', 'upcoming', 'missed', 'deload'],
|
|
14
|
+
description: 'Pill status',
|
|
15
|
+
},
|
|
16
|
+
pulse: { control: 'boolean', description: 'Pulsing animation (independent of status)' },
|
|
17
|
+
highlighted: { control: 'boolean', description: 'Highlighted with thicker border and slight scale' },
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj<typeof WorkoutPill>
|
|
23
|
+
|
|
24
|
+
export const Completed: Story = {
|
|
25
|
+
args: { name: 'Upper A', status: 'completed' },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const Current: Story = {
|
|
29
|
+
args: { name: 'Lower B', status: 'current' },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const CurrentNoPulse: Story = {
|
|
33
|
+
args: { name: 'Lower B', status: 'current', pulse: false },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Next: Story = {
|
|
37
|
+
args: { name: 'Upper B', status: 'next' },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const Upcoming: Story = {
|
|
41
|
+
args: { name: 'Lower A', status: 'upcoming' },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Missed: Story = {
|
|
45
|
+
args: { name: 'Rest Day', status: 'missed' },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Deload: Story = {
|
|
49
|
+
args: { name: 'Deload Week', status: 'deload' },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const AllStatuses: Story = {
|
|
53
|
+
render: () => (
|
|
54
|
+
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
55
|
+
<WorkoutPill name="Upper A" status="completed" />
|
|
56
|
+
<WorkoutPill name="Lower B" status="current" />
|
|
57
|
+
<WorkoutPill name="Upper B" status="next" />
|
|
58
|
+
<WorkoutPill name="Lower A" status="upcoming" />
|
|
59
|
+
<WorkoutPill name="Rest Day" status="missed" />
|
|
60
|
+
<WorkoutPill name="Recovery" status="deload" />
|
|
61
|
+
</View>
|
|
62
|
+
),
|
|
63
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
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 { WorkoutPill } from './WorkoutPill'
|
|
5
|
+
|
|
6
|
+
describe('WorkoutPill', () => {
|
|
7
|
+
it('renders the workout name', () => {
|
|
8
|
+
render(<WorkoutPill name="Upper A" status="completed" />)
|
|
9
|
+
expect(screen.getByText('Upper A')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('shows checkmark for completed status', () => {
|
|
13
|
+
render(<WorkoutPill name="Upper A" status="completed" />)
|
|
14
|
+
expect(screen.getByTestId('workout-pill-check')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('shows dash for missed status', () => {
|
|
18
|
+
render(<WorkoutPill name="Rest Day" status="missed" />)
|
|
19
|
+
expect(screen.getByTestId('workout-pill-dash')).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('does not show checkmark for current status', () => {
|
|
23
|
+
render(<WorkoutPill name="Lower B" status="current" />)
|
|
24
|
+
expect(screen.queryByTestId('workout-pill-check')).not.toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('does not show checkmark for next status', () => {
|
|
28
|
+
render(<WorkoutPill name="Upper B" status="next" />)
|
|
29
|
+
expect(screen.queryByTestId('workout-pill-check')).not.toBeInTheDocument()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('does not show checkmark for upcoming status', () => {
|
|
33
|
+
render(<WorkoutPill name="Lower A" status="upcoming" />)
|
|
34
|
+
expect(screen.queryByTestId('workout-pill-check')).not.toBeInTheDocument()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('does not show checkmark for deload status', () => {
|
|
38
|
+
render(<WorkoutPill name="Deload Week" status="deload" />)
|
|
39
|
+
expect(screen.queryByTestId('workout-pill-check')).not.toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('wraps in Pressable when onPress is provided', () => {
|
|
43
|
+
render(<WorkoutPill name="Upper A" status="completed" onPress={() => {}} />)
|
|
44
|
+
expect(screen.getByTestId('workout-pill-pressable')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not wrap in Pressable when onPress is omitted', () => {
|
|
48
|
+
render(<WorkoutPill name="Upper A" status="completed" />)
|
|
49
|
+
expect(screen.queryByTestId('workout-pill-pressable')).not.toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('calls onPress when pressed', () => {
|
|
53
|
+
const onPress = vi.fn()
|
|
54
|
+
render(<WorkoutPill name="Upper A" status="completed" onPress={onPress} />)
|
|
55
|
+
fireEvent.click(screen.getByTestId('workout-pill-pressable'))
|
|
56
|
+
expect(onPress).toHaveBeenCalledOnce()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('has correct accessibility label', () => {
|
|
60
|
+
render(<WorkoutPill name="Upper A" status="current" />)
|
|
61
|
+
expect(screen.getByLabelText('Upper A workout, current')).toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('has correct accessibility label for missed', () => {
|
|
65
|
+
render(<WorkoutPill name="Rest Day" status="missed" />)
|
|
66
|
+
expect(screen.getByLabelText('Rest Day workout, missed')).toBeInTheDocument()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('has correct accessibility label for deload', () => {
|
|
70
|
+
render(<WorkoutPill name="Recovery" status="deload" />)
|
|
71
|
+
expect(screen.getByLabelText('Recovery workout, deload')).toBeInTheDocument()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('renders all status variants without error', () => {
|
|
75
|
+
const statuses = ['completed', 'current', 'next', 'upcoming', 'missed', 'deload'] as const
|
|
76
|
+
for (const status of statuses) {
|
|
77
|
+
const { unmount } = render(<WorkoutPill name="Test" status={status} />)
|
|
78
|
+
expect(screen.getByTestId('workout-pill')).toBeInTheDocument()
|
|
79
|
+
unmount()
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('pulse', () => {
|
|
84
|
+
it('pulses by default on current status', () => {
|
|
85
|
+
render(<WorkoutPill name="Upper A" status="current" />)
|
|
86
|
+
const pill = screen.getByTestId('workout-pill')
|
|
87
|
+
expect(pill).toBeInTheDocument()
|
|
88
|
+
expect(pill).toHaveStyle({ opacity: 1 })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does not pulse on non-current statuses by default', () => {
|
|
92
|
+
render(<WorkoutPill name="Upper A" status="upcoming" />)
|
|
93
|
+
const pill = screen.getByTestId('workout-pill')
|
|
94
|
+
expect(pill).toBeInTheDocument()
|
|
95
|
+
expect(pill.style.opacity).toBe('')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('can force pulse on a non-current status', () => {
|
|
99
|
+
render(<WorkoutPill name="Upper A" status="upcoming" pulse />)
|
|
100
|
+
expect(screen.getByTestId('workout-pill')).toHaveStyle({ opacity: 1 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('can disable pulse on current status', () => {
|
|
104
|
+
render(<WorkoutPill name="Upper A" status="current" pulse={false} />)
|
|
105
|
+
expect(screen.getByTestId('workout-pill').style.opacity).toBe('')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('highlighted prop', () => {
|
|
110
|
+
it('renders without highlighted by default', () => {
|
|
111
|
+
render(<WorkoutPill name="Upper A" status="current" />)
|
|
112
|
+
expect(screen.getByTestId('workout-pill')).toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('renders with highlighted prop', () => {
|
|
116
|
+
render(<WorkoutPill name="Upper A" status="current" highlighted />)
|
|
117
|
+
expect(screen.getByTestId('workout-pill')).toBeInTheDocument()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('accessibility', () => {
|
|
122
|
+
it('has no accessibility violations', async () => {
|
|
123
|
+
const { container } = render(
|
|
124
|
+
<WorkoutPill name="Upper A" status="completed" onPress={() => {}} />
|
|
125
|
+
)
|
|
126
|
+
const results = await axe(container)
|
|
127
|
+
expect(results).toHaveNoViolations()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('has no accessibility violations as static pill', async () => {
|
|
131
|
+
const { container } = render(
|
|
132
|
+
<WorkoutPill name="Lower B" status="upcoming" />,
|
|
133
|
+
)
|
|
134
|
+
const results = await axe(container)
|
|
135
|
+
expect(results).toHaveNoViolations()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('has no accessibility violations for missed status', async () => {
|
|
139
|
+
const { container } = render(
|
|
140
|
+
<WorkoutPill name="Rest Day" status="missed" onPress={() => {}} />,
|
|
141
|
+
)
|
|
142
|
+
const results = await axe(container)
|
|
143
|
+
expect(results).toHaveNoViolations()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('pressable has correct accessibility label', () => {
|
|
147
|
+
render(<WorkoutPill name="Upper A" status="completed" onPress={() => {}} />)
|
|
148
|
+
expect(
|
|
149
|
+
screen.getByLabelText('Upper A workout, completed'),
|
|
150
|
+
).toBeInTheDocument()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
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, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Spec statuses: completed | current | upcoming | deload.
|
|
7
|
+
* `next` and `missed` are additive extras supported by this implementation.
|
|
8
|
+
*/
|
|
9
|
+
export type WorkoutPillStatus = 'completed' | 'current' | 'upcoming' | 'deload' | 'next' | 'missed'
|
|
10
|
+
|
|
11
|
+
export interface WorkoutPillProps extends ViewProps {
|
|
12
|
+
name: string
|
|
13
|
+
status: WorkoutPillStatus
|
|
14
|
+
/** Force pulsing animation. Defaults to pulsing only on `current` status. */
|
|
15
|
+
pulse?: boolean
|
|
16
|
+
onPress?: () => void
|
|
17
|
+
highlighted?: boolean
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const statusBgColors: Record<WorkoutPillStatus, string> = {
|
|
22
|
+
completed: 'rgba(20,184,166,0.15)',
|
|
23
|
+
current: 'rgba(255,121,0,0.15)',
|
|
24
|
+
next: 'transparent',
|
|
25
|
+
upcoming: '#1C1C1C',
|
|
26
|
+
missed: 'rgba(209,67,67,0.1)',
|
|
27
|
+
deload: 'rgba(147,51,234,0.15)',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const statusBorderStyles: Record<WorkoutPillStatus, Record<string, unknown>> = {
|
|
31
|
+
completed: { borderWidth: 1, borderColor: 'rgba(20,184,166,0.3)' },
|
|
32
|
+
current: { borderWidth: 1, borderColor: 'rgba(255,121,0,0.3)' },
|
|
33
|
+
next: { borderWidth: 1, borderColor: 'rgba(255,121,0,0.4)' },
|
|
34
|
+
upcoming: { borderWidth: 1, borderColor: '#1F1F1F' },
|
|
35
|
+
missed: { borderWidth: 1, borderColor: 'rgba(209,67,67,0.25)' },
|
|
36
|
+
deload: { borderWidth: 1, borderColor: 'rgba(147,51,234,0.3)' },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const textColors: Record<WorkoutPillStatus, string> = {
|
|
40
|
+
completed: '#14B8A6',
|
|
41
|
+
current: '#FF7900',
|
|
42
|
+
next: '#FF7900',
|
|
43
|
+
upcoming: '#6B7280',
|
|
44
|
+
missed: 'rgba(209,67,67,0.7)',
|
|
45
|
+
deload: '#9333ea',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function usePulse(enabled: boolean) {
|
|
49
|
+
const [opacity] = useState(() => new Animated.Value(1))
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!enabled) {
|
|
53
|
+
opacity.setValue(1)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const animation = Animated.loop(
|
|
58
|
+
Animated.sequence([
|
|
59
|
+
Animated.timing(opacity, {
|
|
60
|
+
toValue: 0.7,
|
|
61
|
+
duration: 1000,
|
|
62
|
+
easing: Easing.inOut(Easing.ease),
|
|
63
|
+
useNativeDriver: true,
|
|
64
|
+
}),
|
|
65
|
+
Animated.timing(opacity, {
|
|
66
|
+
toValue: 1,
|
|
67
|
+
duration: 1000,
|
|
68
|
+
easing: Easing.inOut(Easing.ease),
|
|
69
|
+
useNativeDriver: true,
|
|
70
|
+
}),
|
|
71
|
+
]),
|
|
72
|
+
)
|
|
73
|
+
animation.start()
|
|
74
|
+
|
|
75
|
+
return () => animation.stop()
|
|
76
|
+
}, [enabled, opacity])
|
|
77
|
+
|
|
78
|
+
return opacity
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function WorkoutPill({
|
|
82
|
+
name,
|
|
83
|
+
status,
|
|
84
|
+
pulse,
|
|
85
|
+
onPress,
|
|
86
|
+
highlighted = false,
|
|
87
|
+
className,
|
|
88
|
+
...props
|
|
89
|
+
}: WorkoutPillProps) {
|
|
90
|
+
const shouldPulse = pulse ?? status === 'current'
|
|
91
|
+
const pulseOpacity = usePulse(shouldPulse)
|
|
92
|
+
const isCompleted = status === 'completed'
|
|
93
|
+
const isMissed = status === 'missed'
|
|
94
|
+
|
|
95
|
+
const pill = (
|
|
96
|
+
<Animated.View
|
|
97
|
+
style={shouldPulse ? { opacity: pulseOpacity } : undefined}
|
|
98
|
+
testID="workout-pill"
|
|
99
|
+
>
|
|
100
|
+
<View
|
|
101
|
+
className={className}
|
|
102
|
+
style={[
|
|
103
|
+
{
|
|
104
|
+
flexDirection: 'row',
|
|
105
|
+
alignItems: 'center',
|
|
106
|
+
paddingHorizontal: 10,
|
|
107
|
+
paddingVertical: 4,
|
|
108
|
+
borderRadius: 6,
|
|
109
|
+
backgroundColor: statusBgColors[status],
|
|
110
|
+
},
|
|
111
|
+
statusBorderStyles[status] as Record<string, unknown>,
|
|
112
|
+
highlighted ? { borderWidth: 2, transform: [{ scale: 1.02 }] } : undefined,
|
|
113
|
+
]}
|
|
114
|
+
accessibilityLabel={onPress ? undefined : `${name} workout, ${status}`}
|
|
115
|
+
{...props}
|
|
116
|
+
>
|
|
117
|
+
{isCompleted && (
|
|
118
|
+
<Text
|
|
119
|
+
style={{
|
|
120
|
+
fontWeight: '600',
|
|
121
|
+
fontFamily: '"Nunito Sans", sans-serif',
|
|
122
|
+
fontSize: 11,
|
|
123
|
+
color: textColors[status],
|
|
124
|
+
marginRight: 4,
|
|
125
|
+
}}
|
|
126
|
+
accessibilityElementsHidden
|
|
127
|
+
testID="workout-pill-check"
|
|
128
|
+
>
|
|
129
|
+
{'\u2713'}
|
|
130
|
+
</Text>
|
|
131
|
+
)}
|
|
132
|
+
{isMissed && (
|
|
133
|
+
<Text
|
|
134
|
+
style={{
|
|
135
|
+
fontWeight: '600',
|
|
136
|
+
fontFamily: '"Nunito Sans", sans-serif',
|
|
137
|
+
fontSize: 11,
|
|
138
|
+
color: textColors[status],
|
|
139
|
+
marginRight: 4,
|
|
140
|
+
}}
|
|
141
|
+
accessibilityElementsHidden
|
|
142
|
+
testID="workout-pill-dash"
|
|
143
|
+
>
|
|
144
|
+
{'\u2014'}
|
|
145
|
+
</Text>
|
|
146
|
+
)}
|
|
147
|
+
<Text
|
|
148
|
+
style={{
|
|
149
|
+
fontWeight: '600',
|
|
150
|
+
fontFamily: '"Nunito Sans", sans-serif',
|
|
151
|
+
fontSize: 11,
|
|
152
|
+
color: textColors[status],
|
|
153
|
+
}}
|
|
154
|
+
accessibilityElementsHidden={onPress != null}
|
|
155
|
+
testID="workout-pill-name"
|
|
156
|
+
>
|
|
157
|
+
{name}
|
|
158
|
+
</Text>
|
|
159
|
+
</View>
|
|
160
|
+
</Animated.View>
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if (onPress) {
|
|
164
|
+
return (
|
|
165
|
+
<Pressable
|
|
166
|
+
onPress={onPress}
|
|
167
|
+
accessibilityRole="button"
|
|
168
|
+
accessibilityLabel={`${name} workout, ${status}`}
|
|
169
|
+
testID="workout-pill-pressable"
|
|
170
|
+
>
|
|
171
|
+
{pill}
|
|
172
|
+
</Pressable>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return pill
|
|
177
|
+
}
|