@wandelbots/wandelbots-js-react-components 2.34.1-pr.feature-robot-precondition-list.372.c1de8ff → 2.34.1-pr.feature-robot-precondition-list.372.90c151f
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/components/CycleTimer.d.ts +33 -16
- package/dist/components/CycleTimer.d.ts.map +1 -1
- package/dist/index.cjs +36 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +7561 -7490
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/CycleTimer.tsx +238 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wandelbots/wandelbots-js-react-components",
|
|
3
|
-
"version": "2.34.1-pr.feature-robot-precondition-list.372.
|
|
3
|
+
"version": "2.34.1-pr.feature-robot-precondition-list.372.90c151f",
|
|
4
4
|
"description": "React UI toolkit for building applications on top of the Wandelbots platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Typography, useTheme } from "@mui/material"
|
|
1
|
+
import { Box, Fade, Typography, useTheme } from "@mui/material"
|
|
2
2
|
import { Gauge } from "@mui/x-charts/Gauge"
|
|
3
3
|
import { observer } from "mobx-react-lite"
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
@@ -9,13 +9,13 @@ import { useInterpolation } from "./utils/interpolation"
|
|
|
9
9
|
export interface CycleTimerProps {
|
|
10
10
|
/**
|
|
11
11
|
* Callback that receives the timer control functions:
|
|
12
|
-
* - `startNewCycle(maxTimeSeconds
|
|
12
|
+
* - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (if maxTimeSeconds is omitted, runs as count-up timer)
|
|
13
13
|
* - `pause()` - Pause the countdown while preserving remaining time
|
|
14
14
|
* - `resume()` - Resume countdown from where it was paused
|
|
15
15
|
* - `isPaused()` - Check current pause state
|
|
16
16
|
*/
|
|
17
17
|
onCycleComplete: (controls: {
|
|
18
|
-
startNewCycle: (maxTimeSeconds
|
|
18
|
+
startNewCycle: (maxTimeSeconds?: number, elapsedSeconds?: number) => void
|
|
19
19
|
pause: () => void
|
|
20
20
|
resume: () => void
|
|
21
21
|
isPaused: () => boolean
|
|
@@ -30,18 +30,23 @@ export interface CycleTimerProps {
|
|
|
30
30
|
compact?: boolean
|
|
31
31
|
/** Additional CSS classes */
|
|
32
32
|
className?: string
|
|
33
|
+
/** Whether the timer is in an error state (pauses timer and shows error styling) */
|
|
34
|
+
hasError?: boolean
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
|
-
* A circular gauge timer component that shows the remaining time of a cycle
|
|
38
|
+
* A circular gauge timer component that shows the remaining time of a cycle or counts up
|
|
37
39
|
*
|
|
38
40
|
* Features:
|
|
39
41
|
* - Circular gauge with 264px diameter and 40px thickness
|
|
40
|
-
* -
|
|
41
|
-
* -
|
|
42
|
-
* -
|
|
42
|
+
* - Two modes: count-down (with max time) or count-up (without max time)
|
|
43
|
+
* - Count-down mode: shows remaining time prominently, counts down to zero
|
|
44
|
+
* - Count-up mode: shows elapsed time, gauge progresses in minute steps
|
|
45
|
+
* - Displays appropriate labels based on mode
|
|
46
|
+
* - Automatically counts down/up and triggers callback when reaching zero (count-down only)
|
|
43
47
|
* - Full timer control: start, pause, resume functionality
|
|
44
48
|
* - Support for starting with elapsed time (resume mid-cycle)
|
|
49
|
+
* - Error state support: pauses timer and shows error styling (red color)
|
|
45
50
|
* - Smooth spring-based progress animations for all state transitions
|
|
46
51
|
* - Fully localized with i18next
|
|
47
52
|
* - Material-UI theming integration
|
|
@@ -53,32 +58,44 @@ export interface CycleTimerProps {
|
|
|
53
58
|
* @param variant - Visual variant: "default" (large gauge) or "small" (animated icon with text)
|
|
54
59
|
* @param compact - For small variant: whether to hide remaining time details
|
|
55
60
|
* @param className - Additional CSS classes
|
|
61
|
+
* @param hasError - Whether the timer is in an error state (pauses timer and shows error styling)
|
|
56
62
|
*
|
|
57
63
|
* Usage:
|
|
58
64
|
* ```tsx
|
|
65
|
+
* // Count-down timer (with max time)
|
|
59
66
|
* <CycleTimer
|
|
60
67
|
* onCycleComplete={(controls) => {
|
|
61
|
-
* // Start a 5-minute cycle
|
|
68
|
+
* // Start a 5-minute countdown cycle
|
|
62
69
|
* controls.startNewCycle(300)
|
|
63
70
|
*
|
|
64
71
|
* // Or start a 5-minute cycle with 2 minutes already elapsed
|
|
65
72
|
* controls.startNewCycle(300, 120)
|
|
73
|
+
* }}
|
|
74
|
+
* onCycleEnd={() => console.log('Cycle completed!')}
|
|
75
|
+
* />
|
|
66
76
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
77
|
+
* // Count-up timer (no max time)
|
|
78
|
+
* <CycleTimer
|
|
79
|
+
* onCycleComplete={(controls) => {
|
|
80
|
+
* // Start count-up timer
|
|
81
|
+
* controls.startNewCycle()
|
|
69
82
|
*
|
|
70
|
-
* //
|
|
71
|
-
* controls.
|
|
83
|
+
* // Or start count-up timer with some elapsed time
|
|
84
|
+
* controls.startNewCycle(undefined, 120)
|
|
85
|
+
* }}
|
|
86
|
+
* />
|
|
72
87
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
88
|
+
* // Timer with error state
|
|
89
|
+
* <CycleTimer
|
|
90
|
+
* hasError={errorCondition}
|
|
91
|
+
* onCycleComplete={(controls) => {
|
|
92
|
+
* controls.startNewCycle(300)
|
|
75
93
|
* }}
|
|
76
|
-
* onCycleEnd={() => console.log('Cycle completed!')}
|
|
77
94
|
* />
|
|
78
95
|
* ```
|
|
79
96
|
*
|
|
80
97
|
* Control Functions:
|
|
81
|
-
* - `startNewCycle(maxTimeSeconds
|
|
98
|
+
* - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (omit maxTimeSeconds for count-up mode)
|
|
82
99
|
* - `pause()` - Pause the countdown while preserving remaining time
|
|
83
100
|
* - `resume()` - Resume countdown from where it was paused
|
|
84
101
|
* - `isPaused()` - Check current pause state
|
|
@@ -92,17 +109,23 @@ export const CycleTimer = externalizeComponent(
|
|
|
92
109
|
variant = "default",
|
|
93
110
|
compact = false,
|
|
94
111
|
className,
|
|
112
|
+
hasError = false,
|
|
95
113
|
}: CycleTimerProps) => {
|
|
96
114
|
const theme = useTheme()
|
|
97
115
|
const { t } = useTranslation()
|
|
98
116
|
const [remainingTime, setRemainingTime] = useState(0)
|
|
99
|
-
const [maxTime, setMaxTime] = useState(
|
|
117
|
+
const [maxTime, setMaxTime] = useState<number | null>(null)
|
|
100
118
|
const [isRunning, setIsRunning] = useState(false)
|
|
101
119
|
const [isPausedState, setIsPausedState] = useState(false)
|
|
102
120
|
const [currentProgress, setCurrentProgress] = useState(0)
|
|
103
121
|
const animationRef = useRef<number | null>(null)
|
|
104
122
|
const startTimeRef = useRef<number | null>(null)
|
|
105
123
|
const pausedTimeRef = useRef<number>(0)
|
|
124
|
+
const [wasRunningBeforeError, setWasRunningBeforeError] = useState(false)
|
|
125
|
+
|
|
126
|
+
// Track mode changes for fade transitions
|
|
127
|
+
const [showLabels, setShowLabels] = useState(true)
|
|
128
|
+
const prevMaxTimeRef = useRef<number | null | undefined>(undefined)
|
|
106
129
|
|
|
107
130
|
// Spring-based interpolator for smooth gauge progress animations
|
|
108
131
|
// Uses physics simulation to create natural, smooth transitions between progress values
|
|
@@ -114,37 +137,78 @@ export const CycleTimer = externalizeComponent(
|
|
|
114
137
|
},
|
|
115
138
|
})
|
|
116
139
|
|
|
140
|
+
// Handle mode changes with fade transitions for labels only
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const currentIsCountUp = maxTime === null
|
|
143
|
+
const prevMaxTime = prevMaxTimeRef.current
|
|
144
|
+
const prevIsCountUp = prevMaxTime === null
|
|
145
|
+
|
|
146
|
+
// Check if mode actually changed (not just first render)
|
|
147
|
+
if (
|
|
148
|
+
prevMaxTimeRef.current !== undefined &&
|
|
149
|
+
prevIsCountUp !== currentIsCountUp
|
|
150
|
+
) {
|
|
151
|
+
// Mode changed - labels will fade based on the Fade component conditions
|
|
152
|
+
// We just need to ensure showLabels is true so Fade can control visibility
|
|
153
|
+
setShowLabels(true)
|
|
154
|
+
} else {
|
|
155
|
+
// No mode change or first time - set initial state
|
|
156
|
+
setShowLabels(true)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
prevMaxTimeRef.current = maxTime
|
|
160
|
+
}, [maxTime])
|
|
161
|
+
|
|
117
162
|
const startNewCycle = useCallback(
|
|
118
|
-
(maxTimeSeconds
|
|
119
|
-
setMaxTime(maxTimeSeconds)
|
|
120
|
-
const remainingSeconds = Math.max(0, maxTimeSeconds - elapsedSeconds)
|
|
121
|
-
setRemainingTime(remainingSeconds)
|
|
163
|
+
(maxTimeSeconds?: number, elapsedSeconds: number = 0) => {
|
|
164
|
+
setMaxTime(maxTimeSeconds ?? null)
|
|
122
165
|
setIsPausedState(false)
|
|
123
166
|
pausedTimeRef.current = 0
|
|
124
167
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
168
|
+
if (maxTimeSeconds !== undefined) {
|
|
169
|
+
// Count-down mode: set remaining time
|
|
170
|
+
const remainingSeconds = Math.max(
|
|
171
|
+
0,
|
|
172
|
+
maxTimeSeconds - elapsedSeconds,
|
|
173
|
+
)
|
|
174
|
+
setRemainingTime(remainingSeconds)
|
|
175
|
+
|
|
176
|
+
// Animate progress smoothly to starting position
|
|
177
|
+
const initialProgress =
|
|
178
|
+
elapsedSeconds > 0 ? (elapsedSeconds / maxTimeSeconds) * 100 : 0
|
|
179
|
+
if (elapsedSeconds === 0) {
|
|
180
|
+
progressInterpolator.setTarget([0])
|
|
181
|
+
} else {
|
|
182
|
+
progressInterpolator.setTarget([initialProgress])
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (remainingSeconds === 0) {
|
|
186
|
+
setIsRunning(false)
|
|
187
|
+
startTimeRef.current = null
|
|
188
|
+
// Trigger completion callback immediately if time is already up
|
|
189
|
+
if (onCycleEnd) {
|
|
190
|
+
setTimeout(() => onCycleEnd(), 0)
|
|
191
|
+
}
|
|
192
|
+
} else if (autoStart) {
|
|
193
|
+
startTimeRef.current = Date.now() - elapsedSeconds * 1000
|
|
194
|
+
setIsRunning(true)
|
|
195
|
+
} else {
|
|
196
|
+
startTimeRef.current = null
|
|
197
|
+
}
|
|
132
198
|
} else {
|
|
199
|
+
// Count-up mode: start from elapsed time
|
|
200
|
+
setRemainingTime(elapsedSeconds)
|
|
201
|
+
|
|
202
|
+
// For count-up mode, progress is based on minute steps
|
|
203
|
+
const initialProgress = ((elapsedSeconds / 60) % 1) * 100
|
|
133
204
|
progressInterpolator.setTarget([initialProgress])
|
|
134
|
-
}
|
|
135
205
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
setTimeout(() => onCycleEnd(), 0)
|
|
206
|
+
if (autoStart) {
|
|
207
|
+
startTimeRef.current = Date.now() - elapsedSeconds * 1000
|
|
208
|
+
setIsRunning(true)
|
|
209
|
+
} else {
|
|
210
|
+
startTimeRef.current = null
|
|
142
211
|
}
|
|
143
|
-
} else if (autoStart) {
|
|
144
|
-
startTimeRef.current = Date.now() - elapsedSeconds * 1000
|
|
145
|
-
setIsRunning(true)
|
|
146
|
-
} else {
|
|
147
|
-
startTimeRef.current = null
|
|
148
212
|
}
|
|
149
213
|
},
|
|
150
214
|
[autoStart, onCycleEnd, progressInterpolator],
|
|
@@ -159,8 +223,16 @@ export const CycleTimer = externalizeComponent(
|
|
|
159
223
|
// Calculate exact progress position and smoothly animate to it when pausing
|
|
160
224
|
// This ensures the visual progress matches the actual elapsed time
|
|
161
225
|
const totalElapsed = pausedTimeRef.current / 1000
|
|
162
|
-
|
|
163
|
-
|
|
226
|
+
|
|
227
|
+
if (maxTime !== null) {
|
|
228
|
+
// Count-down mode
|
|
229
|
+
const exactProgress = Math.min(100, (totalElapsed / maxTime) * 100)
|
|
230
|
+
progressInterpolator.setTarget([exactProgress])
|
|
231
|
+
} else {
|
|
232
|
+
// Count-up mode: progress based on minute steps
|
|
233
|
+
const exactProgress = ((totalElapsed / 60) % 1) * 100
|
|
234
|
+
progressInterpolator.setTarget([exactProgress])
|
|
235
|
+
}
|
|
164
236
|
}
|
|
165
237
|
setIsRunning(false)
|
|
166
238
|
setIsPausedState(true)
|
|
@@ -178,6 +250,30 @@ export const CycleTimer = externalizeComponent(
|
|
|
178
250
|
return isPausedState
|
|
179
251
|
}, [isPausedState])
|
|
180
252
|
|
|
253
|
+
// Handle error state changes
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (hasError) {
|
|
256
|
+
// Error occurred - pause timer if running and remember state
|
|
257
|
+
if (isRunning && !isPausedState) {
|
|
258
|
+
setWasRunningBeforeError(true)
|
|
259
|
+
pause()
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
// Error resolved - resume if was running before error
|
|
263
|
+
if (wasRunningBeforeError && isPausedState) {
|
|
264
|
+
setWasRunningBeforeError(false)
|
|
265
|
+
resume()
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}, [
|
|
269
|
+
hasError,
|
|
270
|
+
isRunning,
|
|
271
|
+
isPausedState,
|
|
272
|
+
wasRunningBeforeError,
|
|
273
|
+
pause,
|
|
274
|
+
resume,
|
|
275
|
+
])
|
|
276
|
+
|
|
181
277
|
// Call onCycleComplete immediately to provide the timer control functions
|
|
182
278
|
useEffect(() => {
|
|
183
279
|
let isMounted = true
|
|
@@ -202,30 +298,39 @@ export const CycleTimer = externalizeComponent(
|
|
|
202
298
|
if (isRunning) {
|
|
203
299
|
// Single animation frame loop that handles both time updates and progress
|
|
204
300
|
const updateTimer = () => {
|
|
205
|
-
if (startTimeRef.current
|
|
301
|
+
if (startTimeRef.current) {
|
|
206
302
|
const now = Date.now()
|
|
207
303
|
const elapsed =
|
|
208
304
|
(now - startTimeRef.current + pausedTimeRef.current) / 1000
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
305
|
+
|
|
306
|
+
if (maxTime !== null) {
|
|
307
|
+
// Count-down mode
|
|
308
|
+
const remaining = Math.max(0, maxTime - elapsed)
|
|
309
|
+
setRemainingTime(Math.ceil(remaining))
|
|
310
|
+
|
|
311
|
+
// Smoothly animate progress based on elapsed time for fluid visual feedback
|
|
312
|
+
const progress = Math.min(100, (elapsed / maxTime) * 100)
|
|
313
|
+
progressInterpolator.setTarget([progress])
|
|
314
|
+
|
|
315
|
+
if (remaining <= 0) {
|
|
316
|
+
setIsRunning(false)
|
|
317
|
+
startTimeRef.current = null
|
|
318
|
+
setRemainingTime(0)
|
|
319
|
+
// Animate to 100% completion with smooth spring transition
|
|
320
|
+
progressInterpolator.setTarget([100])
|
|
321
|
+
// Call onCycleEnd when timer reaches zero to notify about completion
|
|
322
|
+
if (onCycleEnd) {
|
|
323
|
+
setTimeout(() => onCycleEnd(), 0)
|
|
324
|
+
}
|
|
325
|
+
return
|
|
227
326
|
}
|
|
228
|
-
|
|
327
|
+
} else {
|
|
328
|
+
// Count-up mode
|
|
329
|
+
setRemainingTime(Math.floor(elapsed))
|
|
330
|
+
|
|
331
|
+
// For count-up mode, progress completes every minute (0-100% per minute)
|
|
332
|
+
const progress = ((elapsed / 60) % 1) * 100
|
|
333
|
+
progressInterpolator.setTarget([progress])
|
|
229
334
|
}
|
|
230
335
|
|
|
231
336
|
// Continue animation loop while running
|
|
@@ -272,9 +377,16 @@ export const CycleTimer = externalizeComponent(
|
|
|
272
377
|
// Keep interpolator synchronized with static progress when timer is stopped
|
|
273
378
|
// Ensures correct visual state when component initializes or timer stops
|
|
274
379
|
useEffect(() => {
|
|
275
|
-
if (!isRunning && !isPausedState
|
|
276
|
-
|
|
277
|
-
|
|
380
|
+
if (!isRunning && !isPausedState) {
|
|
381
|
+
if (maxTime !== null && maxTime > 0) {
|
|
382
|
+
// Count-down mode
|
|
383
|
+
const staticProgress = ((maxTime - remainingTime) / maxTime) * 100
|
|
384
|
+
progressInterpolator.setTarget([staticProgress])
|
|
385
|
+
} else if (maxTime === null) {
|
|
386
|
+
// Count-up mode
|
|
387
|
+
const staticProgress = ((remainingTime / 60) % 1) * 100
|
|
388
|
+
progressInterpolator.setTarget([staticProgress])
|
|
389
|
+
}
|
|
278
390
|
}
|
|
279
391
|
}, [
|
|
280
392
|
isRunning,
|
|
@@ -331,7 +443,11 @@ export const CycleTimer = externalizeComponent(
|
|
|
331
443
|
cy="10"
|
|
332
444
|
r="8"
|
|
333
445
|
fill="none"
|
|
334
|
-
stroke={
|
|
446
|
+
stroke={
|
|
447
|
+
hasError
|
|
448
|
+
? theme.palette.error.light
|
|
449
|
+
: theme.palette.success.main
|
|
450
|
+
}
|
|
335
451
|
strokeWidth="2"
|
|
336
452
|
opacity={0.3}
|
|
337
453
|
/>
|
|
@@ -341,7 +457,11 @@ export const CycleTimer = externalizeComponent(
|
|
|
341
457
|
cy="10"
|
|
342
458
|
r="8"
|
|
343
459
|
fill="none"
|
|
344
|
-
stroke={
|
|
460
|
+
stroke={
|
|
461
|
+
hasError
|
|
462
|
+
? theme.palette.error.light
|
|
463
|
+
: theme.palette.success.main
|
|
464
|
+
}
|
|
345
465
|
strokeWidth="2"
|
|
346
466
|
strokeLinecap="round"
|
|
347
467
|
strokeDasharray={`${2 * Math.PI * 8}`}
|
|
@@ -361,11 +481,15 @@ export const CycleTimer = externalizeComponent(
|
|
|
361
481
|
fontSize: "14px",
|
|
362
482
|
}}
|
|
363
483
|
>
|
|
364
|
-
{
|
|
365
|
-
? //
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
484
|
+
{maxTime !== null
|
|
485
|
+
? // Count-down mode: show remaining time
|
|
486
|
+
compact
|
|
487
|
+
? // Compact mode: show remaining time with "min." suffix
|
|
488
|
+
`${formatTime(remainingTime)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
|
|
489
|
+
: // Full mode: show "remaining / of total min." format
|
|
490
|
+
`${formatTime(remainingTime)} / ${t("CycleTimer.Time.lb", { time: formatTime(maxTime) })}`
|
|
491
|
+
: // Count-up mode: show elapsed time only
|
|
492
|
+
formatTime(remainingTime)}
|
|
369
493
|
</Typography>
|
|
370
494
|
</Box>
|
|
371
495
|
)
|
|
@@ -397,7 +521,9 @@ export const CycleTimer = externalizeComponent(
|
|
|
397
521
|
opacity: isPausedState ? 0.6 : 1,
|
|
398
522
|
transition: "opacity 0.2s ease",
|
|
399
523
|
[`& .MuiGauge-valueArc`]: {
|
|
400
|
-
fill:
|
|
524
|
+
fill: hasError
|
|
525
|
+
? theme.palette.error.light
|
|
526
|
+
: theme.palette.success.main,
|
|
401
527
|
},
|
|
402
528
|
[`& .MuiGauge-referenceArc`]: {
|
|
403
529
|
fill: "white",
|
|
@@ -425,19 +551,30 @@ export const CycleTimer = externalizeComponent(
|
|
|
425
551
|
gap: 1,
|
|
426
552
|
}}
|
|
427
553
|
>
|
|
428
|
-
{/* "remaining time" label */}
|
|
429
|
-
<
|
|
430
|
-
variant="body2"
|
|
554
|
+
{/* "remaining time" label - always reserves space to prevent layout shift */}
|
|
555
|
+
<Box
|
|
431
556
|
sx={{
|
|
432
|
-
|
|
433
|
-
|
|
557
|
+
height: "16px", // Fixed height to prevent layout shift
|
|
558
|
+
display: "flex",
|
|
559
|
+
alignItems: "center",
|
|
560
|
+
justifyContent: "center",
|
|
434
561
|
marginBottom: 0.5,
|
|
435
562
|
}}
|
|
436
563
|
>
|
|
437
|
-
{
|
|
438
|
-
|
|
564
|
+
<Fade in={showLabels && maxTime !== null} timeout={300}>
|
|
565
|
+
<Typography
|
|
566
|
+
variant="body2"
|
|
567
|
+
sx={{
|
|
568
|
+
fontSize: "12px",
|
|
569
|
+
color: theme.palette.text.secondary,
|
|
570
|
+
}}
|
|
571
|
+
>
|
|
572
|
+
{t("CycleTimer.RemainingTime.lb")}
|
|
573
|
+
</Typography>
|
|
574
|
+
</Fade>
|
|
575
|
+
</Box>
|
|
439
576
|
|
|
440
|
-
{/* Main timer display */}
|
|
577
|
+
{/* Main timer display - never fades, always visible */}
|
|
441
578
|
<Typography
|
|
442
579
|
variant="h1"
|
|
443
580
|
sx={{
|
|
@@ -451,16 +588,29 @@ export const CycleTimer = externalizeComponent(
|
|
|
451
588
|
{formatTime(remainingTime)}
|
|
452
589
|
</Typography>
|
|
453
590
|
|
|
454
|
-
{/* Total time display */}
|
|
455
|
-
<
|
|
456
|
-
variant="body2"
|
|
591
|
+
{/* Total time display - always reserves space to prevent layout shift */}
|
|
592
|
+
<Box
|
|
457
593
|
sx={{
|
|
458
|
-
|
|
459
|
-
|
|
594
|
+
height: "16px", // Fixed height to prevent layout shift
|
|
595
|
+
display: "flex",
|
|
596
|
+
alignItems: "center",
|
|
597
|
+
justifyContent: "center",
|
|
460
598
|
}}
|
|
461
599
|
>
|
|
462
|
-
|
|
463
|
-
|
|
600
|
+
<Fade in={showLabels && maxTime !== null} timeout={300}>
|
|
601
|
+
<Typography
|
|
602
|
+
variant="body2"
|
|
603
|
+
sx={{
|
|
604
|
+
fontSize: "12px",
|
|
605
|
+
color: theme.palette.text.secondary,
|
|
606
|
+
}}
|
|
607
|
+
>
|
|
608
|
+
{maxTime !== null
|
|
609
|
+
? t("CycleTimer.OfTime.lb", { time: formatTime(maxTime) })
|
|
610
|
+
: ""}
|
|
611
|
+
</Typography>
|
|
612
|
+
</Fade>
|
|
613
|
+
</Box>
|
|
464
614
|
</Box>
|
|
465
615
|
</Box>
|
|
466
616
|
)
|