@titan-design/react-ui 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +112 -55
- package/dist/index.d.ts +112 -55
- package/dist/index.js +795 -501
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +795 -498
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-8u_4WbL-.d.mts → pages-D7hOD4Vj.d.mts} +17 -8
- package/dist/{pages-DaWiBOGa.d.ts → pages-DyEx-lBf.d.ts} +17 -8
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +691 -250
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +692 -251
- package/dist/pages.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
- package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
- package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
- package/src/components/custom/CircularTimer/index.ts +1 -0
- package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
- package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
- package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
- package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
- package/src/components/custom/Workout/README.md +70 -3
- package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
- package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
- package/src/components/custom/Workout/RestTimer.tsx +141 -50
- package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
- package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
- package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
- package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
- package/src/components/custom/Workout/VelocityStrip.test.tsx +67 -3
- package/src/components/custom/Workout/VelocityStrip.tsx +323 -42
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
- package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
- package/src/components/custom/Workout/ZoneTrack.tsx +91 -19
- package/src/components/custom/Workout/index.ts +1 -9
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/alert/Alert.stories.tsx +85 -2
- package/src/components/ui/alert/Alert.test.tsx +52 -0
- package/src/components/ui/alert/Alert.tsx +54 -20
- package/src/components/ui/progress/Progress.tsx +47 -22
- package/src/lab/north-star/LivePage.tsx +101 -0
- package/src/lab/north-star/LiveView.tsx +409 -0
- package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
- package/src/lab/north-star/RestView.tsx +140 -0
- package/src/lab/north-star/fixtures.ts +224 -0
- package/src/components/custom/Workout/TempoBar.stories.tsx +0 -95
- package/src/components/custom/Workout/TempoBar.test.tsx +0 -103
- package/src/components/custom/Workout/TempoBar.tsx +0 -215
|
@@ -184,3 +184,76 @@ describe('RestTimer zero-duration timer (NaN guard)', () => {
|
|
|
184
184
|
expect(container.innerHTML).not.toContain('NaN')
|
|
185
185
|
})
|
|
186
186
|
})
|
|
187
|
+
|
|
188
|
+
describe('RestTimer ring variant', () => {
|
|
189
|
+
const ringProps = { ...defaultProps, variant: 'ring' as const }
|
|
190
|
+
|
|
191
|
+
it('renders the ring (composed CircularProgress) instead of the linear bar', () => {
|
|
192
|
+
render(<RestTimer {...ringProps} />)
|
|
193
|
+
expect(screen.getByTestId('rest-timer-ring')).toBeInTheDocument()
|
|
194
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
|
195
|
+
expect(screen.queryByTestId('rest-timer-progress-track')).not.toBeInTheDocument()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('shows the mm:ss countdown in the ring center while resting', () => {
|
|
199
|
+
render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={60000} />)
|
|
200
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('1:00')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('shows GO (not a time) when rest is complete', () => {
|
|
204
|
+
render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={120000} />)
|
|
205
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('GO')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('labels the completed ring as rest complete', () => {
|
|
209
|
+
render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={125000} />)
|
|
210
|
+
expect(screen.getByLabelText('Rest complete, next set ready')).toBeInTheDocument()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('labels the resting ring with seconds remaining', () => {
|
|
214
|
+
render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={60000} />)
|
|
215
|
+
expect(screen.getByLabelText('Rest timer, 60 seconds remaining')).toBeInTheDocument()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('renders the next-set line and controls by default', () => {
|
|
219
|
+
render(<RestTimer {...ringProps} nextSetInfo="Next · Bench · set 3 of 4" />)
|
|
220
|
+
expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent('Next · Bench · set 3 of 4')
|
|
221
|
+
expect(screen.getByTestId('rest-timer-add-time')).toBeInTheDocument()
|
|
222
|
+
expect(screen.getByTestId('rest-timer-skip')).toBeInTheDocument()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('hides the controls in displayOnly mode (keeps ring + next-set)', () => {
|
|
226
|
+
render(<RestTimer {...ringProps} nextSetInfo="Next" displayOnly />)
|
|
227
|
+
expect(screen.getByTestId('rest-timer-ring')).toBeInTheDocument()
|
|
228
|
+
expect(screen.getByTestId('rest-timer-next-set')).toBeInTheDocument()
|
|
229
|
+
expect(screen.queryByTestId('rest-timer-skip')).not.toBeInTheDocument()
|
|
230
|
+
expect(screen.queryByTestId('rest-timer-add-time')).not.toBeInTheDocument()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('fires onSkip / onAddTime from the ring controls', () => {
|
|
234
|
+
const onSkip = vi.fn()
|
|
235
|
+
const onAddTime = vi.fn()
|
|
236
|
+
render(<RestTimer {...ringProps} onSkip={onSkip} onAddTime={onAddTime} />)
|
|
237
|
+
fireEvent.click(screen.getByTestId('rest-timer-skip'))
|
|
238
|
+
fireEvent.click(screen.getByTestId('rest-timer-add-time'))
|
|
239
|
+
expect(onSkip).toHaveBeenCalledOnce()
|
|
240
|
+
expect(onAddTime).toHaveBeenCalledOnce()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('respects visible=false', () => {
|
|
244
|
+
render(<RestTimer {...ringProps} visible={false} />)
|
|
245
|
+
expect(screen.queryByTestId('rest-timer-ring')).not.toBeInTheDocument()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('emits no NaN when totalSeconds is 0', () => {
|
|
249
|
+
const { container } = render(<RestTimer {...ringProps} totalSeconds={0} elapsedMs={0} />)
|
|
250
|
+
expect(container.innerHTML).not.toContain('NaN')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('has no accessibility violations', async () => {
|
|
254
|
+
const { container } = render(
|
|
255
|
+
<RestTimer {...ringProps} nextSetInfo="Next · Bench · set 3 of 4" />
|
|
256
|
+
)
|
|
257
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
@@ -3,9 +3,13 @@ import { View, Text, Pressable } from 'react-native'
|
|
|
3
3
|
import { resolveColor } from '../../../theme/resolve-color'
|
|
4
4
|
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
5
5
|
import { useTimer } from '../../../hooks/useTimer'
|
|
6
|
+
import { CircularTimer } from '../CircularTimer/CircularTimer'
|
|
6
7
|
|
|
7
8
|
const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
|
|
8
9
|
|
|
10
|
+
/** Default `ring` diameter (px) — the across-the-room wall rest treatment. */
|
|
11
|
+
const RING_DEFAULT_SIZE = 180
|
|
12
|
+
|
|
9
13
|
export interface RestTimerProps {
|
|
10
14
|
totalSeconds: number
|
|
11
15
|
elapsedMs: number
|
|
@@ -15,6 +19,123 @@ export interface RestTimerProps {
|
|
|
15
19
|
visible: boolean
|
|
16
20
|
/** Passive/poll-only mode: hides the Skip and +30s controls, keeps the countdown, progress bar, and next-set line. */
|
|
17
21
|
displayOnly?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* `bar` (default) — the compact linear card (REST label + mm:ss + progress bar +
|
|
24
|
+
* controls), the mobile bottom-bar treatment. `ring` — the across-the-room wall
|
|
25
|
+
* treatment: a draining countdown ring (composes {@link CircularProgress}) with a
|
|
26
|
+
* big remaining-seconds numeral, the next-set line, and the same controls.
|
|
27
|
+
*/
|
|
28
|
+
variant?: 'bar' | 'ring'
|
|
29
|
+
/** `ring` diameter in px. Default 180. */
|
|
30
|
+
size?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The +30s / Skip control row — shared by both variants (hidden in `displayOnly`). */
|
|
34
|
+
function RestActions({ onAddTime, onSkip }: { onAddTime: () => void; onSkip: () => void }) {
|
|
35
|
+
return (
|
|
36
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
37
|
+
<Pressable
|
|
38
|
+
onPress={onAddTime}
|
|
39
|
+
style={{
|
|
40
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
41
|
+
paddingVertical: 8,
|
|
42
|
+
paddingHorizontal: 20,
|
|
43
|
+
borderRadius: 8,
|
|
44
|
+
}}
|
|
45
|
+
accessibilityRole="button"
|
|
46
|
+
accessibilityLabel="Add 30 seconds"
|
|
47
|
+
testID="rest-timer-add-time"
|
|
48
|
+
>
|
|
49
|
+
<Text
|
|
50
|
+
className="text-text-secondary"
|
|
51
|
+
style={{ fontSize: 11, fontFamily: 'Inter, sans-serif', fontWeight: '600' }}
|
|
52
|
+
>
|
|
53
|
+
+30s
|
|
54
|
+
</Text>
|
|
55
|
+
</Pressable>
|
|
56
|
+
<Pressable
|
|
57
|
+
onPress={onSkip}
|
|
58
|
+
style={{
|
|
59
|
+
backgroundColor: 'rgba(255,121,0,0.12)',
|
|
60
|
+
paddingVertical: 8,
|
|
61
|
+
paddingHorizontal: 20,
|
|
62
|
+
borderRadius: 8,
|
|
63
|
+
}}
|
|
64
|
+
accessibilityRole="button"
|
|
65
|
+
accessibilityLabel="Skip rest"
|
|
66
|
+
testID="rest-timer-skip"
|
|
67
|
+
>
|
|
68
|
+
<Text
|
|
69
|
+
style={{
|
|
70
|
+
fontSize: 11,
|
|
71
|
+
fontFamily: 'Inter, sans-serif',
|
|
72
|
+
fontWeight: '600',
|
|
73
|
+
color: BRAND_PRIMARY,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
Skip
|
|
77
|
+
</Text>
|
|
78
|
+
</Pressable>
|
|
79
|
+
</View>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface RestTimerRingProps {
|
|
84
|
+
totalSeconds: number
|
|
85
|
+
elapsedMs: number
|
|
86
|
+
remainingSec: number
|
|
87
|
+
done: boolean
|
|
88
|
+
size: number
|
|
89
|
+
nextSetInfo?: string
|
|
90
|
+
displayOnly: boolean
|
|
91
|
+
onSkip: () => void
|
|
92
|
+
onAddTime: () => void
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The `ring` render: the wall rest treatment. Composes the generic {@link CircularTimer}
|
|
97
|
+
* (which owns the arc + mm:ss readout + the fill-green "GO" done state), passing the
|
|
98
|
+
* shared {@link RestActions} as its controls, and adds the rest-specific next-set footer
|
|
99
|
+
* caption below. Rest-domain chrome only — the timer mechanics live in `CircularTimer`.
|
|
100
|
+
*/
|
|
101
|
+
function RestTimerRing({
|
|
102
|
+
totalSeconds,
|
|
103
|
+
elapsedMs,
|
|
104
|
+
remainingSec,
|
|
105
|
+
done,
|
|
106
|
+
size,
|
|
107
|
+
nextSetInfo,
|
|
108
|
+
displayOnly,
|
|
109
|
+
onSkip,
|
|
110
|
+
onAddTime,
|
|
111
|
+
}: RestTimerRingProps) {
|
|
112
|
+
const a11yLabel = done
|
|
113
|
+
? 'Rest complete, next set ready'
|
|
114
|
+
: `Rest timer, ${Math.max(0, remainingSec)} seconds remaining`
|
|
115
|
+
return (
|
|
116
|
+
<View style={{ alignItems: 'center', gap: 16 }} testID="rest-timer">
|
|
117
|
+
<CircularTimer
|
|
118
|
+
durationMs={totalSeconds * 1000}
|
|
119
|
+
elapsedMs={elapsedMs}
|
|
120
|
+
mode="down"
|
|
121
|
+
size={size}
|
|
122
|
+
doneLabel="GO"
|
|
123
|
+
doneColor="success"
|
|
124
|
+
accessibilityLabel={a11yLabel}
|
|
125
|
+
controls={!displayOnly ? <RestActions onAddTime={onAddTime} onSkip={onSkip} /> : undefined}
|
|
126
|
+
testID="rest-timer-ring"
|
|
127
|
+
/>
|
|
128
|
+
{nextSetInfo != null && (
|
|
129
|
+
<Text
|
|
130
|
+
className="text-text-tertiary"
|
|
131
|
+
style={{ fontSize: 13, fontFamily: 'Inter, sans-serif' }}
|
|
132
|
+
testID="rest-timer-next-set"
|
|
133
|
+
>
|
|
134
|
+
{nextSetInfo}
|
|
135
|
+
</Text>
|
|
136
|
+
)}
|
|
137
|
+
</View>
|
|
138
|
+
)
|
|
18
139
|
}
|
|
19
140
|
|
|
20
141
|
export function RestTimer({
|
|
@@ -25,6 +146,8 @@ export function RestTimer({
|
|
|
25
146
|
nextSetInfo,
|
|
26
147
|
visible,
|
|
27
148
|
displayOnly = false,
|
|
149
|
+
variant = 'bar',
|
|
150
|
+
size = RING_DEFAULT_SIZE,
|
|
28
151
|
}: RestTimerProps) {
|
|
29
152
|
// useTimer owns the countdown math (remaining/progress/mm:ss); a zero-duration
|
|
30
153
|
// timer is complete, which keeps the width out of the 0/0 === NaN case.
|
|
@@ -32,6 +155,7 @@ export function RestTimer({
|
|
|
32
155
|
remainingMs,
|
|
33
156
|
progress,
|
|
34
157
|
label: timeDisplay,
|
|
158
|
+
done,
|
|
35
159
|
} = useTimer({
|
|
36
160
|
mode: 'down',
|
|
37
161
|
durationMs: totalSeconds * 1000,
|
|
@@ -42,6 +166,22 @@ export function RestTimer({
|
|
|
42
166
|
|
|
43
167
|
if (!visible) return null
|
|
44
168
|
|
|
169
|
+
if (variant === 'ring') {
|
|
170
|
+
return (
|
|
171
|
+
<RestTimerRing
|
|
172
|
+
totalSeconds={totalSeconds}
|
|
173
|
+
elapsedMs={elapsedMs}
|
|
174
|
+
remainingSec={remainingSec}
|
|
175
|
+
done={done}
|
|
176
|
+
size={size}
|
|
177
|
+
nextSetInfo={nextSetInfo}
|
|
178
|
+
displayOnly={displayOnly}
|
|
179
|
+
onSkip={onSkip}
|
|
180
|
+
onAddTime={onAddTime}
|
|
181
|
+
/>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
45
185
|
return (
|
|
46
186
|
<View
|
|
47
187
|
className="bg-surface-raised"
|
|
@@ -133,56 +273,7 @@ export function RestTimer({
|
|
|
133
273
|
</View>
|
|
134
274
|
|
|
135
275
|
{/* Actions row — hidden in display-only (poll-only) mode */}
|
|
136
|
-
{!displayOnly &&
|
|
137
|
-
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
138
|
-
<Pressable
|
|
139
|
-
onPress={onAddTime}
|
|
140
|
-
style={{
|
|
141
|
-
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
142
|
-
paddingVertical: 8,
|
|
143
|
-
paddingHorizontal: 20,
|
|
144
|
-
borderRadius: 8,
|
|
145
|
-
}}
|
|
146
|
-
accessibilityRole="button"
|
|
147
|
-
accessibilityLabel="Add 30 seconds"
|
|
148
|
-
testID="rest-timer-add-time"
|
|
149
|
-
>
|
|
150
|
-
<Text
|
|
151
|
-
className="text-text-secondary"
|
|
152
|
-
style={{
|
|
153
|
-
fontSize: 11,
|
|
154
|
-
fontFamily: 'Inter, sans-serif',
|
|
155
|
-
fontWeight: '600',
|
|
156
|
-
}}
|
|
157
|
-
>
|
|
158
|
-
+30s
|
|
159
|
-
</Text>
|
|
160
|
-
</Pressable>
|
|
161
|
-
<Pressable
|
|
162
|
-
onPress={onSkip}
|
|
163
|
-
style={{
|
|
164
|
-
backgroundColor: 'rgba(255,121,0,0.12)',
|
|
165
|
-
paddingVertical: 8,
|
|
166
|
-
paddingHorizontal: 20,
|
|
167
|
-
borderRadius: 8,
|
|
168
|
-
}}
|
|
169
|
-
accessibilityRole="button"
|
|
170
|
-
accessibilityLabel="Skip rest"
|
|
171
|
-
testID="rest-timer-skip"
|
|
172
|
-
>
|
|
173
|
-
<Text
|
|
174
|
-
style={{
|
|
175
|
-
fontSize: 11,
|
|
176
|
-
fontFamily: 'Inter, sans-serif',
|
|
177
|
-
fontWeight: '600',
|
|
178
|
-
color: BRAND_PRIMARY,
|
|
179
|
-
}}
|
|
180
|
-
>
|
|
181
|
-
Skip
|
|
182
|
-
</Text>
|
|
183
|
-
</Pressable>
|
|
184
|
-
</View>
|
|
185
|
-
)}
|
|
276
|
+
{!displayOnly && <RestActions onAddTime={onAddTime} onSkip={onSkip} />}
|
|
186
277
|
</View>
|
|
187
278
|
)
|
|
188
279
|
}
|
|
@@ -34,6 +34,12 @@ describe('SetsRepsLoad', () => {
|
|
|
34
34
|
expect(screen.getByTestId('sets-reps-load')).toHaveTextContent('15-20')
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
+
it('scales the cell font size via fontSize', () => {
|
|
38
|
+
render(<SetsRepsLoad sets={4} reps={8} load={140} fontSize={32} />)
|
|
39
|
+
// The value cells (and separators) render at the supplied size.
|
|
40
|
+
expect(screen.getByText('4')).toHaveStyle({ fontSize: 32 })
|
|
41
|
+
})
|
|
42
|
+
|
|
37
43
|
describe('accessibility', () => {
|
|
38
44
|
it('has no accessibility violations', async () => {
|
|
39
45
|
const { container } = render(<SetsRepsLoad sets={5} reps={8} load={145} />)
|
|
@@ -14,6 +14,8 @@ export interface SetsRepsLoadProps extends ViewProps {
|
|
|
14
14
|
load: number
|
|
15
15
|
/** Displayed load unit label (e.g. "lb", "kg"). */
|
|
16
16
|
unit?: string
|
|
17
|
+
/** Cell font size (px). Default 11 — the compact rail/card scale. Raise for a wall read-out. */
|
|
18
|
+
fontSize?: number
|
|
17
19
|
className?: string
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -27,6 +29,7 @@ export function SetsRepsLoad({
|
|
|
27
29
|
reps,
|
|
28
30
|
load,
|
|
29
31
|
unit = 'lb',
|
|
32
|
+
fontSize,
|
|
30
33
|
className,
|
|
31
34
|
...props
|
|
32
35
|
}: SetsRepsLoadProps) {
|
|
@@ -41,12 +44,18 @@ export function SetsRepsLoad({
|
|
|
41
44
|
testID="sets-reps-load"
|
|
42
45
|
{...props}
|
|
43
46
|
>
|
|
44
|
-
<MetricCell color={value}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
<MetricCell color={sep}>{` ${
|
|
48
|
-
<MetricCell color={value}
|
|
49
|
-
|
|
47
|
+
<MetricCell color={value} fontSize={fontSize}>
|
|
48
|
+
{sets}
|
|
49
|
+
</MetricCell>
|
|
50
|
+
<MetricCell color={sep} fontSize={fontSize}>{` ${TIMES} `}</MetricCell>
|
|
51
|
+
<MetricCell color={value} fontSize={fontSize}>
|
|
52
|
+
{reps}
|
|
53
|
+
</MetricCell>
|
|
54
|
+
<MetricCell color={sep} fontSize={fontSize}>{` ${AT} `}</MetricCell>
|
|
55
|
+
<MetricCell color={value} fontSize={fontSize}>
|
|
56
|
+
{load}
|
|
57
|
+
</MetricCell>
|
|
58
|
+
<MetricCell color={sep} fontSize={fontSize}>{` ${unit}`}</MetricCell>
|
|
50
59
|
</View>
|
|
51
60
|
)
|
|
52
61
|
}
|