@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.
Files changed (158) hide show
  1. package/dist/chunk-3VLOBS6O.mjs +846 -0
  2. package/dist/chunk-3VLOBS6O.mjs.map +1 -0
  3. package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
  4. package/dist/chunk-5SSKGS6E.mjs.map +1 -0
  5. package/dist/index.d.mts +1005 -4
  6. package/dist/index.d.ts +1005 -4
  7. package/dist/index.js +6043 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5901 -177
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/theme/index.d.mts +57 -1
  12. package/dist/theme/index.d.ts +57 -1
  13. package/dist/theme/index.js +63 -0
  14. package/dist/theme/index.js.map +1 -1
  15. package/dist/theme/index.mjs +2 -1
  16. package/dist/theme/tokens-css.d.mts +18 -0
  17. package/dist/theme/tokens-css.d.ts +18 -0
  18. package/dist/theme/tokens-css.js +289 -0
  19. package/dist/theme/tokens-css.js.map +1 -0
  20. package/dist/theme/tokens-css.mjs +24 -0
  21. package/dist/theme/tokens-css.mjs.map +1 -0
  22. package/docs/WEB_SETUP.md +217 -0
  23. package/package.json +17 -5
  24. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  25. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  26. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  27. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  28. package/src/components/custom/Gauge/index.ts +2 -0
  29. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  30. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  31. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  32. package/src/components/custom/Scatter/index.ts +2 -0
  33. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  34. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  35. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  36. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  37. package/src/components/custom/Treemap/index.ts +2 -0
  38. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  39. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  40. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  41. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  42. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  43. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  46. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  47. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  48. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  49. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  50. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  51. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  52. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  53. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  54. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  55. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  56. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  57. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  58. package/src/components/custom/Workout/InputBar.tsx +163 -0
  59. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  60. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  61. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  62. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  63. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  64. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  65. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  66. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  67. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  68. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  69. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  70. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  73. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  76. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  77. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  78. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  79. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  80. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  81. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  82. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  83. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  84. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  85. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  86. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  87. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  88. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  89. package/src/components/custom/Workout/SetRow.tsx +253 -0
  90. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  91. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  92. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  93. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  94. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  95. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  98. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  99. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  100. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  101. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  102. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  103. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  104. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  105. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  106. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  107. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  108. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  109. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  110. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  111. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  112. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  113. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  114. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  115. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  116. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  117. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  118. package/src/components/custom/Workout/index.ts +113 -0
  119. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  120. package/src/components/custom/index.ts +3 -0
  121. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  122. package/src/components/custom/stepper/Stepper.tsx +2 -2
  123. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  124. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  125. package/src/components/ui/card/Card.tsx +1 -1
  126. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  127. package/src/components/ui/collapse/Collapse.tsx +1 -1
  128. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  129. package/src/components/ui/drawer/Drawer.tsx +3 -3
  130. package/src/components/ui/form-field/FormField.tsx +1 -1
  131. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  132. package/src/components/ui/menu/Menu.tsx +2 -2
  133. package/src/components/ui/pill/Pill.tsx +1 -1
  134. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  135. package/src/components/ui/popover/Popover.tsx +1 -1
  136. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  137. package/src/components/ui/section/Section.stories.tsx +4 -4
  138. package/src/components/ui/select/Select.tsx +1 -1
  139. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  140. package/src/components/ui/tabs/Tabs.tsx +2 -2
  141. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  142. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  143. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  144. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  145. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  146. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  147. package/src/stories/PresetShowcase.stories.tsx +15 -15
  148. package/src/theme/index.ts +2 -1
  149. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  150. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  151. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  152. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  153. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  154. package/src/theme/manifest/index.ts +11 -0
  155. package/src/theme/tokens-css.test.ts +28 -0
  156. package/src/theme/tokens-css.ts +34 -0
  157. package/src/theme/workout-tokens.ts +61 -0
  158. 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
+ }