Skip to content

Commit 840cb55

Browse files
committed
Add interactive seeking to timer progress bar
1 parent 674d970 commit 840cb55

1 file changed

Lines changed: 120 additions & 4 deletions

File tree

‎src/routes/+page.svelte‎

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
let settingsSeconds = 0;
1616
let settingsError = '';
1717
let isFullscreen = false;
18+
let isSeeking = false;
1819
/** @type {HTMLDialogElement} */
1920
let settingsDialog;
2021
@@ -130,6 +131,75 @@
130131
if (autostart) startTimer();
131132
}
132133
134+
/** @param {number} fraction */
135+
function seekTo(fraction) {
136+
const clampedFraction = Math.max(0, Math.min(1, fraction));
137+
const wasRunning = status === 'running';
138+
remainingMs = durationSeconds * 1000 * (1 - clampedFraction);
139+
displaySeconds = Math.ceil(remainingMs / 1000);
140+
141+
if (remainingMs <= 0) {
142+
remainingMs = 0;
143+
displaySeconds = 0;
144+
status = 'finished';
145+
cancelAnimationFrame(frameId);
146+
frameId = 0;
147+
} else if (wasRunning) {
148+
endTime = performance.now() + remainingMs;
149+
status = 'running';
150+
if (!frameId) frameId = requestAnimationFrame(tick);
151+
} else {
152+
status = clampedFraction === 0 ? 'idle' : 'paused';
153+
}
154+
}
155+
156+
/** @param {PointerEvent} event */
157+
function seekFromPointer(event) {
158+
const bar = /** @type {HTMLButtonElement} */ (event.currentTarget);
159+
const bounds = bar.getBoundingClientRect();
160+
seekTo((event.clientX - bounds.left) / bounds.width);
161+
}
162+
163+
/** @param {PointerEvent} event */
164+
function startSeeking(event) {
165+
event.preventDefault();
166+
isSeeking = true;
167+
const bar = /** @type {HTMLButtonElement} */ (event.currentTarget);
168+
bar.setPointerCapture(event.pointerId);
169+
seekFromPointer(event);
170+
}
171+
172+
/** @param {PointerEvent} event */
173+
function continueSeeking(event) {
174+
if (isSeeking) seekFromPointer(event);
175+
}
176+
177+
/** @param {PointerEvent} event */
178+
function stopSeeking(event) {
179+
if (!isSeeking) return;
180+
if (event.type !== 'pointercancel') seekFromPointer(event);
181+
isSeeking = false;
182+
const bar = /** @type {HTMLButtonElement} */ (event.currentTarget);
183+
if (bar.hasPointerCapture(event.pointerId)) bar.releasePointerCapture(event.pointerId);
184+
}
185+
186+
/** @param {KeyboardEvent} event */
187+
function handleProgressKeydown(event) {
188+
const elapsedSeconds = durationSeconds - remainingMs / 1000;
189+
const step = event.shiftKey ? 30 : 5;
190+
let nextElapsed = elapsedSeconds;
191+
192+
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') nextElapsed -= step;
193+
else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') nextElapsed += step;
194+
else if (event.key === 'Home') nextElapsed = 0;
195+
else if (event.key === 'End') nextElapsed = durationSeconds;
196+
else return;
197+
198+
event.preventDefault();
199+
event.stopPropagation();
200+
seekTo(nextElapsed / durationSeconds);
201+
}
202+
133203
function openSettings() {
134204
if (status === 'running') pauseTimer();
135205
settingsMinutes = Math.floor(durationSeconds / 60);
@@ -252,9 +322,27 @@
252322
253323
<main class:warning={visualState === 'warning'} class:critical={visualState === 'critical'} class:finished={visualState === 'finished'}>
254324
<div class="ambient" aria-hidden="true"></div>
255-
<div class="progress" aria-hidden="true">
256-
<div class="progress__fill" style:width={`${progress * 100}%`}></div>
257-
</div>
325+
<button
326+
class:seeking={isSeeking}
327+
class="progress"
328+
type="button"
329+
role="slider"
330+
aria-label="Timer progress"
331+
aria-valuemin="0"
332+
aria-valuemax={durationSeconds}
333+
aria-valuenow={Math.round(progress * durationSeconds)}
334+
aria-valuetext={`${spokenTime(displaySeconds)} remaining`}
335+
title="Click or drag to seek"
336+
onpointerdown={startSeeking}
337+
onpointermove={continueSeeking}
338+
onpointerup={stopSeeking}
339+
onpointercancel={stopSeeking}
340+
onkeydown={handleProgressKeydown}
341+
>
342+
<span class="progress__track" aria-hidden="true">
343+
<span class="progress__fill" style:width={`${progress * 100}%`}></span>
344+
</span>
345+
</button>
258346
259347
<section class="timer-stage" aria-label="Timer">
260348
<button
@@ -358,6 +446,7 @@
358446
--muted: #92969d;
359447
--line: rgba(255, 255, 255, 0.13);
360448
--panel: rgba(255, 255, 255, 0.075);
449+
--primary-label: #090a0c;
361450
position: relative;
362451
isolation: isolate;
363452
display: grid;
@@ -383,6 +472,7 @@
383472
--muted: rgba(0, 0, 0, 0.63);
384473
--line: rgba(0, 0, 0, 0.22);
385474
--panel: rgba(0, 0, 0, 0.1);
475+
--primary-label: #ed292d;
386476
background: #ed292d;
387477
}
388478
@@ -404,17 +494,43 @@
404494
top: 0;
405495
right: 0;
406496
left: 0;
497+
width: 100%;
498+
height: 16px;
499+
padding: 0;
500+
touch-action: none;
501+
border: 0;
502+
background: transparent;
503+
cursor: ew-resize;
504+
}
505+
506+
.progress__track {
507+
position: absolute;
508+
top: 0;
509+
right: 0;
510+
left: 0;
407511
height: 3px;
512+
overflow: hidden;
408513
background: var(--line);
514+
transition: height 120ms ease;
515+
}
516+
517+
.progress:hover .progress__track,
518+
.progress.seeking .progress__track {
519+
height: 6px;
409520
}
410521
411522
.progress__fill {
523+
display: block;
412524
height: 100%;
413525
background: var(--foreground);
414526
opacity: 0.9;
415527
transition: width 100ms linear, background-color 220ms ease;
416528
}
417529
530+
.progress.seeking .progress__fill {
531+
transition: background-color 220ms ease;
532+
}
533+
418534
footer {
419535
position: relative;
420536
z-index: 2;
@@ -558,7 +674,7 @@
558674
min-width: 150px;
559675
gap: 9px;
560676
padding: 0 24px;
561-
color: #090a0c;
677+
color: var(--primary-label);
562678
border-color: var(--foreground);
563679
background: var(--foreground);
564680
}

0 commit comments

Comments
 (0)