nandi/gleanpublic⑂ Fork 0Commits
⑂ 5bd3cd5
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.gitgit clone ssh://git@rickub.com/nandi/glean.gitHost key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.
refactor(web): improve navbarUnverified
5bd3cd5 parent: ff72525 modified
web/src/lib/components/NavigationProgress.svelte +58 -29 | @@ -1,28 +1,68 @@ | ||
| 1 | 1 | <script lang="ts"> |
| 2 | 2 | import { navigating } from "$app/state"; |
| 3 | 3 | |
| 4 | - // Avoid flashing on instant navigations: only show the bar after a short | |
| 5 | - // delay if the navigation is still in progress. | |
| 4 | + // Monotonic progress bar: the fill only ever moves forward while a | |
| 5 | + // navigation is active, then completes to 100% and fades out. This avoids | |
| 6 | + // the back-and-forth jitter caused by restarting a looping keyframe | |
| 7 | + // animation on every navigation. | |
| 8 | + let progress = $state(0); | |
| 6 | 9 | let visible = $state(false); |
| 7 | - let timer: ReturnType<typeof setTimeout> | undefined; | |
| 10 | + let fading = $state(false); | |
| 8 | 11 | |
| 9 | - $effect(() => { | |
| 10 | - const active = navigating.to !== null; | |
| 11 | - if (active) { | |
| 12 | - timer = setTimeout(() => (visible = true), 100); | |
| 13 | - } else { | |
| 14 | - if (timer) clearTimeout(timer); | |
| 12 | + let showTimer: ReturnType<typeof setTimeout> | undefined; | |
| 13 | + let trickleTimer: ReturnType<typeof setInterval> | undefined; | |
| 14 | + let completeTimer: ReturnType<typeof setTimeout> | undefined; | |
| 15 | + | |
| 16 | + function clearTimers() { | |
| 17 | + if (showTimer) clearTimeout(showTimer); | |
| 18 | + if (trickleTimer) clearInterval(trickleTimer); | |
| 19 | + if (completeTimer) clearTimeout(completeTimer); | |
| 20 | + showTimer = trickleTimer = completeTimer = undefined; | |
| 21 | + } | |
| 22 | + | |
| 23 | + function start() { | |
| 24 | + clearTimers(); | |
| 25 | + progress = 0; | |
| 26 | + fading = false; | |
| 27 | + // Avoid flashing on instant navigations: only show after a delay. | |
| 28 | + showTimer = setTimeout(() => { | |
| 29 | + visible = true; | |
| 30 | + progress = 0.2; | |
| 31 | + // Asymptotic trickle toward 0.9 so the bar never stalls at 100% | |
| 32 | + // before the navigation actually completes. | |
| 33 | + trickleTimer = setInterval(() => { | |
| 34 | + progress = Math.min(0.9, progress + (0.9 - progress) * 0.1); | |
| 35 | + }, 200); | |
| 36 | + }, 100); | |
| 37 | + } | |
| 38 | + | |
| 39 | + function done() { | |
| 40 | + clearTimers(); | |
| 41 | + if (!visible) return; | |
| 42 | + progress = 1; | |
| 43 | + fading = true; | |
| 44 | + completeTimer = setTimeout(() => { | |
| 15 | 45 | visible = false; |
| 16 | - } | |
| 46 | + fading = false; | |
| 47 | + }, 300); | |
| 48 | + } | |
| 49 | + | |
| 50 | + $effect(() => { | |
| 51 | + if (navigating.to !== null) start(); | |
| 52 | + else done(); | |
| 17 | 53 | }); |
| 54 | + | |
| 55 | + $effect(() => () => clearTimers()); | |
| 18 | 56 | </script> |
| 19 | 57 | |
| 20 | 58 | {#if visible} |
| 21 | 59 | <div |
| 22 | 60 | class="nav-progress" |
| 61 | + class:fading | |
| 23 | 62 | role="progressbar" |
| 24 | 63 | aria-label="Loading" |
| 25 | 64 | aria-busy="true" |
| 65 | + style="transform: scaleX({progress})" | |
| 26 | 66 | ></div> |
| 27 | 67 | {/if} |
| 28 | 68 | |
| @@ -34,32 +74,21 @@ | ||
| 34 | 74 | height: 3px; |
| 35 | 75 | width: 100%; |
| 36 | 76 | background: var(--accent); |
| 37 | - z-index: 60; | |
| 38 | - /* Indeterminate sweep: shrink-grow across the viewport. */ | |
| 39 | 77 | transform-origin: left center; |
| 40 | - animation: nav-progress-indeterminate 1s ease-in-out infinite; | |
| 78 | + opacity: 1; | |
| 79 | + z-index: 60; | |
| 80 | + transition: | |
| 81 | + transform 0.2s ease, | |
| 82 | + opacity 0.3s ease; | |
| 41 | 83 | } |
| 42 | 84 | |
| 43 | - @keyframes nav-progress-indeterminate { | |
| 44 | - 0% { | |
| 45 | - transform: scaleX(0); | |
| 46 | - opacity: 0.85; | |
| 47 | - } | |
| 48 | - 50% { | |
| 49 | - transform: scaleX(0.7); | |
| 50 | - opacity: 1; | |
| 51 | - } | |
| 52 | - 100% { | |
| 53 | - transform: scaleX(0); | |
| 54 | - opacity: 0.85; | |
| 55 | - } | |
| 85 | + .nav-progress.fading { | |
| 86 | + opacity: 0; | |
| 56 | 87 | } |
| 57 | 88 | |
| 58 | 89 | @media (prefers-reduced-motion: reduce) { |
| 59 | 90 | .nav-progress { |
| 60 | - animation: none; | |
| 61 | - transform: scaleX(1); | |
| 62 | - opacity: 1; | |
| 91 | + transition: none; | |
| 63 | 92 | } |
| 64 | 93 | } |
| 65 | 94 | </style> |
| @@ -1,28 +1,68 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import { navigating } from "$app/state"; | 2 | import { navigating } from "$app/state"; |
| 3 | 3 | ||
| 4 | - // Avoid flashing on instant navigations: only show the bar after a short | 4 | + // Monotonic progress bar: the fill only ever moves forward while a |
| 5 | - // delay if the navigation is still in progress. | 5 | + // navigation is active, then completes to 100% and fades out. This avoids |
| 6 | + // the back-and-forth jitter caused by restarting a looping keyframe | ||
| 7 | + // animation on every navigation. | ||
| 8 | + let progress = $state(0); | ||
| 6 | let visible = $state(false); | 9 | let visible = $state(false); |
| 7 | - let timer: ReturnType<typeof setTimeout> | undefined; | 10 | + let fading = $state(false); |
| 8 | 11 | ||
| 9 | - $effect(() => { | 12 | + let showTimer: ReturnType<typeof setTimeout> | undefined; |
| 10 | - const active = navigating.to !== null; | 13 | + let trickleTimer: ReturnType<typeof setInterval> | undefined; |
| 11 | - if (active) { | 14 | + let completeTimer: ReturnType<typeof setTimeout> | undefined; |
| 12 | - timer = setTimeout(() => (visible = true), 100); | 15 | + |
| 13 | - } else { | 16 | + function clearTimers() { |
| 14 | - if (timer) clearTimeout(timer); | 17 | + if (showTimer) clearTimeout(showTimer); |
| 18 | + if (trickleTimer) clearInterval(trickleTimer); | ||
| 19 | + if (completeTimer) clearTimeout(completeTimer); | ||
| 20 | + showTimer = trickleTimer = completeTimer = undefined; | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + function start() { | ||
| 24 | + clearTimers(); | ||
| 25 | + progress = 0; | ||
| 26 | + fading = false; | ||
| 27 | + // Avoid flashing on instant navigations: only show after a delay. | ||
| 28 | + showTimer = setTimeout(() => { | ||
| 29 | + visible = true; | ||
| 30 | + progress = 0.2; | ||
| 31 | + // Asymptotic trickle toward 0.9 so the bar never stalls at 100% | ||
| 32 | + // before the navigation actually completes. | ||
| 33 | + trickleTimer = setInterval(() => { | ||
| 34 | + progress = Math.min(0.9, progress + (0.9 - progress) * 0.1); | ||
| 35 | + }, 200); | ||
| 36 | + }, 100); | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + function done() { | ||
| 40 | + clearTimers(); | ||
| 41 | + if (!visible) return; | ||
| 42 | + progress = 1; | ||
| 43 | + fading = true; | ||
| 44 | + completeTimer = setTimeout(() => { | ||
| 15 | visible = false; | 45 | visible = false; |
| 16 | - } | 46 | + fading = false; |
| 47 | + }, 300); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + $effect(() => { | ||
| 51 | + if (navigating.to !== null) start(); | ||
| 52 | + else done(); | ||
| 17 | }); | 53 | }); |
| 54 | + | ||
| 55 | + $effect(() => () => clearTimers()); | ||
| 18 | </script> | 56 | </script> |
| 19 | 57 | ||
| 20 | {#if visible} | 58 | {#if visible} |
| 21 | <div | 59 | <div |
| 22 | class="nav-progress" | 60 | class="nav-progress" |
| 61 | + class:fading | ||
| 23 | role="progressbar" | 62 | role="progressbar" |
| 24 | aria-label="Loading" | 63 | aria-label="Loading" |
| 25 | aria-busy="true" | 64 | aria-busy="true" |
| 65 | + style="transform: scaleX({progress})" | ||
| 26 | ></div> | 66 | ></div> |
| 27 | {/if} | 67 | {/if} |
| 28 | 68 | ||
| @@ -34,32 +74,21 @@ | |||
| 34 | height: 3px; | 74 | height: 3px; |
| 35 | width: 100%; | 75 | width: 100%; |
| 36 | background: var(--accent); | 76 | background: var(--accent); |
| 37 | - z-index: 60; | ||
| 38 | - /* Indeterminate sweep: shrink-grow across the viewport. */ | ||
| 39 | transform-origin: left center; | 77 | transform-origin: left center; |
| 40 | - animation: nav-progress-indeterminate 1s ease-in-out infinite; | 78 | + opacity: 1; |
| 79 | + z-index: 60; | ||
| 80 | + transition: | ||
| 81 | + transform 0.2s ease, | ||
| 82 | + opacity 0.3s ease; | ||
| 41 | } | 83 | } |
| 42 | 84 | ||
| 43 | - @keyframes nav-progress-indeterminate { | 85 | + .nav-progress.fading { |
| 44 | - 0% { | 86 | + opacity: 0; |
| 45 | - transform: scaleX(0); | ||
| 46 | - opacity: 0.85; | ||
| 47 | - } | ||
| 48 | - 50% { | ||
| 49 | - transform: scaleX(0.7); | ||
| 50 | - opacity: 1; | ||
| 51 | - } | ||
| 52 | - 100% { | ||
| 53 | - transform: scaleX(0); | ||
| 54 | - opacity: 0.85; | ||
| 55 | - } | ||
| 56 | } | 87 | } |
| 57 | 88 | ||
| 58 | @media (prefers-reduced-motion: reduce) { | 89 | @media (prefers-reduced-motion: reduce) { |
| 59 | .nav-progress { | 90 | .nav-progress { |
| 60 | - animation: none; | 91 | + transition: none; |
| 61 | - transform: scaleX(1); | ||
| 62 | - opacity: 1; | ||
| 63 | } | 92 | } |
| 64 | } | 93 | } |
| 65 | </style> | 94 | </style> |