400b6ac2a5
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The info toast began drifting up and fading the instant it finished appearing (the CSS keyframes went straight from the 12% appeared-stop to the 100% risen-and-faded stop), so a glanced message was already leaving. Insert a ~1s hold at full opacity/rest before the rise-and-fade: extend the animation 2s → 3s with keyframe stops at 8% (appeared, ~240ms) and 41% (end of the ~1s hold), keeping the original rise-and-fade pace for the tail. The reduced-motion variant gets the same appear/hold/fade timing (fade only, no travel). The showToast dismissal timer is bumped 2000 → 3000ms to stay in lockstep with the animation (the error toast's 4s dwell is unchanged).
92 lines
2.6 KiB
Svelte
92 lines
2.6 KiB
Svelte
<script lang="ts">
|
|
import { fade, fly } from 'svelte/transition';
|
|
import { app, dismissToast } from '../lib/app.svelte';
|
|
|
|
const dur = $derived(app.reduceMotion ? 0 : 260);
|
|
// An info bubble owns its whole 3s life through a CSS animation — appear, a ~1s hold at rest,
|
|
// then rise-and-fade — so it takes no Svelte enter/leave transition; an error keeps the fly-in /
|
|
// fade-out dwell behaviour.
|
|
const isInfo = $derived(app.toast?.kind !== 'error');
|
|
</script>
|
|
|
|
{#if app.toast}
|
|
<!-- Re-key on the toast's seq so a fresh message tears the old node down and replays the
|
|
appear cycle: the newest toast cancels the previous one and starts its own. -->
|
|
{#key app.toast.seq}
|
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
class="toast {app.toast.kind}"
|
|
class:rise={isInfo && !app.reduceMotion}
|
|
class:rise-reduced={isInfo && app.reduceMotion}
|
|
role="status"
|
|
aria-live="polite"
|
|
onclick={dismissToast}
|
|
in:fly={{ y: isInfo ? 0 : 32, duration: isInfo ? 0 : dur }}
|
|
out:fade={{ duration: isInfo ? 0 : dur }}
|
|
>
|
|
{app.toast.text}
|
|
</div>
|
|
{/key}
|
|
{/if}
|
|
|
|
<style>
|
|
.toast {
|
|
position: fixed;
|
|
left: 50%;
|
|
bottom: 24px;
|
|
transform: translateX(-50%);
|
|
max-width: min(92vw, 420px);
|
|
padding: 10px 16px;
|
|
border-radius: var(--radius);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow);
|
|
z-index: 50;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
}
|
|
.error {
|
|
border-color: var(--danger);
|
|
color: var(--danger);
|
|
}
|
|
/* An info bubble fades in, holds for ~1s at rest, then drifts up by roughly the tab-bar height
|
|
while fading out, all within 3s; the X centring is preserved across the rise. The 8%/41%
|
|
stops are the appear (~240ms) and the end of the ~1s hold. */
|
|
.toast.rise {
|
|
animation: toast-rise 3s ease-out forwards;
|
|
}
|
|
@keyframes toast-rise {
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(8px);
|
|
}
|
|
8%,
|
|
41% {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(-56px);
|
|
}
|
|
}
|
|
/* Reduced motion: the same 3s life (appear, ~1s hold, fade), fade only — no upward travel. */
|
|
.toast.rise-reduced {
|
|
animation: toast-rise-reduced 3s ease-out forwards;
|
|
}
|
|
@keyframes toast-rise-reduced {
|
|
0% {
|
|
opacity: 0;
|
|
}
|
|
8%,
|
|
41% {
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
</style>
|