fix: properly delay intro transitions (#12389)

* fix: properly delay intro transitions

WAAPI applies the styles of a delayed animation only when that animation starts. In the case of fade-in transitions that means the element is visible, then goes invisible and fades in. Fix that by never applying a delay on intro transitions, instead add keyframes of the initial state for the duration of the delay.

Fixes #10876

* fix bug, make test pass

* make test more selfcontained, test outro delay aswell and add functionality for that in animation-helpers

* lint

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/12395/head
Simon H 4 months ago committed by GitHub
parent f846cb4833
commit 277370a79d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: properly delay intro transitions

@ -268,6 +268,8 @@ export function transition(flags, element, get_fn, get_params) {
* @returns {import('#client').Animation}
*/
function animate(element, options, counterpart, t2, callback) {
var is_intro = t2 === 1;
if (is_function(options)) {
// In the case of a deferred transition (such as `crossfade`), `option` will be
// a function rather than an `AnimationConfig`. We need to call this function
@ -276,7 +278,7 @@ function animate(element, options, counterpart, t2, callback) {
var a;
queue_micro_task(() => {
var o = options({ direction: t2 === 1 ? 'in' : 'out' });
var o = options({ direction: is_intro ? 'in' : 'out' });
a = animate(element, o, counterpart, t2, callback);
});
@ -322,6 +324,16 @@ function animate(element, options, counterpart, t2, callback) {
var keyframes = [];
var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
// In case of a delayed intro, apply the initial style for the duration of the delay;
// else in case of a fade-in for example the element would be visible until the animation starts
if (is_intro && delay > 0) {
let m = Math.ceil(delay / (1000 / 60));
let keyframe = css_to_keyframe(css(0, 1));
for (let i = 0; i < m; i += 1) {
keyframes.push(keyframe);
}
}
for (var i = 0; i <= n; i += 1) {
var t = t1 + delta * easing(i / n);
var styles = css(t, 1 - t);
@ -329,8 +341,8 @@ function animate(element, options, counterpart, t2, callback) {
}
animation = element.animate(keyframes, {
delay,
duration,
delay: is_intro ? 0 : delay,
duration: duration + (is_intro ? delay : 0),
easing: 'linear',
fill: 'forwards'
});

@ -35,6 +35,7 @@ class Animation {
#target;
#keyframes;
#duration;
#delay;
#offset = raf.time;
@ -47,12 +48,13 @@ class Animation {
/**
* @param {HTMLElement} target
* @param {Keyframe[]} keyframes
* @param {{ duration: number }} options // TODO add delay
* @param {{ duration: number, delay: number }} options
*/
constructor(target, keyframes, { duration }) {
constructor(target, keyframes, { duration, delay }) {
this.#target = target;
this.#keyframes = keyframes;
this.#duration = duration;
this.#delay = delay ?? 0;
// Promise-like semantics, but call callbacks immediately on raf.tick
this.finished = {
@ -73,7 +75,9 @@ class Animation {
}
_update() {
this.currentTime = raf.time - this.#offset;
this.currentTime = raf.time - this.#offset - this.#delay;
if (this.currentTime < 0) return;
const target_frame = this.currentTime / this.#duration;
this.#apply_keyframe(target_frame);
@ -168,7 +172,7 @@ function interpolate(a, b, p) {
/**
* @param {Keyframe[]} keyframes
* @param {{duration: number}} options
* @param {{duration: number, delay: number}} options
* @returns {globalThis.Animation}
*/
HTMLElement.prototype.animate = function (keyframes, options) {

@ -0,0 +1,48 @@
import { flushSync } from '../../../../src/index-client.js';
import { test } from '../../test';
export default test({
test({ assert, raf, target }) {
const btn = target.querySelector('button');
// in
btn?.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
);
raf.tick(1);
assert.htmlEqual(
target.innerHTML,
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
);
raf.tick(99);
assert.htmlEqual(
target.innerHTML,
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
);
raf.tick(150);
assert.htmlEqual(
target.innerHTML,
'<button>toggle</button><p style="opacity: 0.5;">delayed fade</p>'
);
raf.tick(200);
assert.htmlEqual(target.innerHTML, '<button>toggle</button><p style="">delayed fade</p>');
// out
btn?.click();
flushSync();
raf.tick(275);
assert.htmlEqual(target.innerHTML, '<button>toggle</button><p style="">delayed fade</p>');
raf.tick(350);
assert.htmlEqual(
target.innerHTML,
'<button>toggle</button><p style="opacity: 0.5;">delayed fade</p>'
);
}
});

@ -0,0 +1,17 @@
<script>
function fade(_) {
return {
delay: 100,
duration: 100,
css: (t) => `opacity: ${t}`
};
}
let visible = $state(false);
</script>
<button onclick={() => (visible = !visible)}>toggle</button>
{#if visible}
<p transition:fade>delayed fade</p>
{/if}
Loading…
Cancel
Save