|
15 | 15 | let settingsSeconds = 0; |
16 | 16 | let settingsError = ''; |
17 | 17 | let isFullscreen = false; |
| 18 | + let isSeeking = false; |
18 | 19 | /** @type {HTMLDialogElement} */ |
19 | 20 | let settingsDialog; |
20 | 21 |
|
|
130 | 131 | if (autostart) startTimer(); |
131 | 132 | } |
132 | 133 |
|
| 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 | +
|
133 | 203 | function openSettings() { |
134 | 204 | if (status === 'running') pauseTimer(); |
135 | 205 | settingsMinutes = Math.floor(durationSeconds / 60); |
|
252 | 322 |
|
253 | 323 | <main class:warning={visualState === 'warning'} class:critical={visualState === 'critical'} class:finished={visualState === 'finished'}> |
254 | 324 | <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> |
258 | 346 |
|
259 | 347 | <section class="timer-stage" aria-label="Timer"> |
260 | 348 | <button |
|
358 | 446 | --muted: #92969d; |
359 | 447 | --line: rgba(255, 255, 255, 0.13); |
360 | 448 | --panel: rgba(255, 255, 255, 0.075); |
| 449 | + --primary-label: #090a0c; |
361 | 450 | position: relative; |
362 | 451 | isolation: isolate; |
363 | 452 | display: grid; |
|
383 | 472 | --muted: rgba(0, 0, 0, 0.63); |
384 | 473 | --line: rgba(0, 0, 0, 0.22); |
385 | 474 | --panel: rgba(0, 0, 0, 0.1); |
| 475 | + --primary-label: #ed292d; |
386 | 476 | background: #ed292d; |
387 | 477 | } |
388 | 478 |
|
|
404 | 494 | top: 0; |
405 | 495 | right: 0; |
406 | 496 | 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; |
407 | 511 | height: 3px; |
| 512 | + overflow: hidden; |
408 | 513 | background: var(--line); |
| 514 | + transition: height 120ms ease; |
| 515 | + } |
| 516 | +
|
| 517 | + .progress:hover .progress__track, |
| 518 | + .progress.seeking .progress__track { |
| 519 | + height: 6px; |
409 | 520 | } |
410 | 521 |
|
411 | 522 | .progress__fill { |
| 523 | + display: block; |
412 | 524 | height: 100%; |
413 | 525 | background: var(--foreground); |
414 | 526 | opacity: 0.9; |
415 | 527 | transition: width 100ms linear, background-color 220ms ease; |
416 | 528 | } |
417 | 529 |
|
| 530 | + .progress.seeking .progress__fill { |
| 531 | + transition: background-color 220ms ease; |
| 532 | + } |
| 533 | +
|
418 | 534 | footer { |
419 | 535 | position: relative; |
420 | 536 | z-index: 2; |
|
558 | 674 | min-width: 150px; |
559 | 675 | gap: 9px; |
560 | 676 | padding: 0 24px; |
561 | | - color: #090a0c; |
| 677 | + color: var(--primary-label); |
562 | 678 | border-color: var(--foreground); |
563 | 679 | background: var(--foreground); |
564 | 680 | } |
|
0 commit comments