@alikhalilll/a-smart-skeleton 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +356 -2
  3. package/dist/_chunks/core.js +2314 -0
  4. package/dist/_chunks/core.js.map +1 -0
  5. package/dist/_chunks/index.d.ts +431 -0
  6. package/dist/core/index.cjs +67 -0
  7. package/dist/core/index.d.cts +2 -0
  8. package/dist/core/index.d.ts +2 -0
  9. package/dist/core/index.js +2 -0
  10. package/dist/element/index.cjs +47 -0
  11. package/dist/element/index.cjs.map +1 -0
  12. package/dist/element/index.d.cts +24 -0
  13. package/dist/element/index.d.ts +24 -0
  14. package/dist/element/index.js +46 -0
  15. package/dist/element/index.js.map +1 -0
  16. package/dist/index.cjs +512 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +204 -0
  19. package/dist/index.d.ts +204 -0
  20. package/dist/index.js +441 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/nuxt/index.cjs +36 -0
  23. package/dist/nuxt/index.cjs.map +1 -0
  24. package/dist/nuxt/index.d.cts +30 -0
  25. package/dist/nuxt/index.d.ts +30 -0
  26. package/dist/nuxt/index.js +36 -0
  27. package/dist/nuxt/index.js.map +1 -0
  28. package/dist/resolver/index.cjs +26 -0
  29. package/dist/resolver/index.cjs.map +1 -0
  30. package/dist/resolver/index.d.cts +19 -0
  31. package/dist/resolver/index.d.ts +19 -0
  32. package/dist/resolver/index.js +26 -0
  33. package/dist/resolver/index.js.map +1 -0
  34. package/dist/styles.css +136 -0
  35. package/package.json +144 -3
  36. package/src/components/ASmartSkeleton.vue +228 -0
  37. package/src/components/ASmartSkeletonBox.vue +23 -0
  38. package/src/components/ASmartSkeletonProvider.vue +34 -0
  39. package/src/components/ASmartSkeletonSuspense.vue +142 -0
  40. package/src/composables/useSmartSkeleton.ts +20 -0
  41. package/src/composables/useStyles.ts +8 -0
  42. package/src/core/boot.ts +63 -0
  43. package/src/core/canvas.ts +7 -0
  44. package/src/core/constants.ts +76 -0
  45. package/src/core/debug.ts +154 -0
  46. package/src/core/dev.ts +53 -0
  47. package/src/core/fade.ts +490 -0
  48. package/src/core/ghost.ts +236 -0
  49. package/src/core/images.ts +28 -0
  50. package/src/core/index.ts +71 -0
  51. package/src/core/inline.generated.ts +9 -0
  52. package/src/core/measure.ts +571 -0
  53. package/src/core/memory.ts +146 -0
  54. package/src/core/motion.ts +12 -0
  55. package/src/core/plan.ts +368 -0
  56. package/src/core/reveal.ts +89 -0
  57. package/src/core/reveals.ts +39 -0
  58. package/src/core/shapes.ts +84 -0
  59. package/src/core/shimmer.ts +229 -0
  60. package/src/core/skeleton.ts +43 -0
  61. package/src/core/snapshot.ts +159 -0
  62. package/src/core/styles.ts +187 -0
  63. package/src/core/sweep.ts +203 -0
  64. package/src/core/theme.ts +162 -0
  65. package/src/element/index.ts +59 -0
  66. package/src/index.ts +27 -0
  67. package/src/nuxt/index.ts +59 -0
  68. package/src/resolver/index.ts +35 -0
  69. package/src/types.ts +100 -0
  70. package/src/vue/Skeleton.ts +275 -0
  71. package/src/vue/Suspense.ts +157 -0
  72. package/src/vue/config.ts +78 -0
  73. package/src/vue/index.ts +7 -0
  74. package/web-types.json +256 -0
@@ -0,0 +1,490 @@
1
+ import {
2
+ ATTR_RECT,
3
+ ATTR_LAYER,
4
+ ATTR_EXITING,
5
+ ATTR_GHOST,
6
+ ATTR_OWN,
7
+ ATTR_PANE,
8
+ ATTR_SNAPSHOT,
9
+ FADE_DURATION,
10
+ RESIZE_DURATION,
11
+ } from './constants';
12
+ import { imageReady, pendingImages } from './images';
13
+ import { animationsOf, canAnimate, prefersReducedMotion } from './motion';
14
+ import { warnOnce } from './dev';
15
+ import {
16
+ planReveal,
17
+ pieces,
18
+ type Plan,
19
+ type RevealMode,
20
+ type SequencePlan,
21
+ type WipePlan,
22
+ } from './plan';
23
+ import { revealSpec } from './reveals';
24
+ import { rectsOf, shimmer } from './shimmer';
25
+
26
+ export { prefersReducedMotion };
27
+
28
+ const noop = () => {};
29
+
30
+ const settle = (animation: Animation) => animation.finished.then(noop, noop);
31
+
32
+ // How a reveal's incoming pane decided to arrive, for its outgoing pane to match
33
+ const plans = new WeakMap<Element, Plan | { kind: 'skeleton'; hold: number }>();
34
+
35
+ function appear(element: Element, delay = 0, duration = FADE_DURATION, from = 0, enter?: Keyframe) {
36
+ if (prefersReducedMotion()) return Promise.resolve();
37
+ return settle(
38
+ element.animate([{ ...enter, opacity: from }, { opacity: 1 }], {
39
+ duration,
40
+ delay,
41
+ easing: 'ease',
42
+ fill: 'backwards',
43
+ })
44
+ );
45
+ }
46
+
47
+ // The bones layer, or the ghost a skeleton is drawn from — vanilla adds its layer a moment later
48
+ const isSkeleton = (element: Element) =>
49
+ !!element.querySelector(`[${ATTR_LAYER}], [${ATTR_GHOST}], :scope > [${ATTR_OWN}]`);
50
+
51
+ const sameBox = (a: DOMRect, b: DOMRect) =>
52
+ Math.abs(a.left - b.left) < 2 &&
53
+ Math.abs(a.top - b.top) < 2 &&
54
+ Math.abs(a.width - b.width) < 2 &&
55
+ Math.abs(a.height - b.height) < 2;
56
+
57
+ // Bones standing in for images still loading, by reveal
58
+ const standIns = new WeakMap<Element, Set<HTMLElement>>();
59
+
60
+ function clearStandIns(root: Element) {
61
+ for (const stand of standIns.get(root) ?? []) stand.remove();
62
+ standIns.delete(root);
63
+ }
64
+
65
+ // Each image still loading stays hidden behind a bone of its own until it's ready, so nothing else waits on it
66
+ function holdImages(element: HTMLElement, root: HTMLElement) {
67
+ const images = pendingImages(element);
68
+ const overSkeleton = [...root.children].some(
69
+ (pane) => pane !== element && pane.hasAttribute(ATTR_PANE) && isSkeleton(pane)
70
+ );
71
+ if (!images.length || !overSkeleton) return;
72
+ for (const image of images) image.style.opacity = '0';
73
+
74
+ // A microtask on, once the skeleton has popped out — layout read any sooner moves the page
75
+ queueMicrotask(() => {
76
+ if (!element.isConnected) return;
77
+ const origin = root.getBoundingClientRect();
78
+ const bones = [...root.querySelectorAll(`:scope > [${ATTR_EXITING}] [${ATTR_LAYER}]`)].flatMap(
79
+ rectsOf
80
+ );
81
+ const stands = standIns.get(root) ?? new Set<HTMLElement>();
82
+ standIns.set(root, stands);
83
+
84
+ for (const image of images) {
85
+ const rect = image.getBoundingClientRect();
86
+ // Its bone in the skeleton, which the stand-in takes over exactly
87
+ const bone = bones.find((b) => sameBox(b.getBoundingClientRect(), rect));
88
+ const box = bone?.getBoundingClientRect() ?? rect;
89
+ const stand = document.createElement('div');
90
+ // Same "media"-type bone the main pass produces — fill colour comes from the
91
+ // stylesheet (`[ask-rect]` → `var(--ask-base)`) so a consumer theme reaches it.
92
+ stand.setAttribute(ATTR_RECT, 'media');
93
+ Object.assign(stand.style, {
94
+ position: 'absolute',
95
+ left: `${box.left - origin.left - root.clientLeft}px`,
96
+ top: `${box.top - origin.top - root.clientTop}px`,
97
+ width: `${box.width}px`,
98
+ height: `${box.height}px`,
99
+ borderRadius: bone?.style.borderRadius || getComputedStyle(image).borderRadius,
100
+ backgroundRepeat: 'no-repeat',
101
+ pointerEvents: 'none',
102
+ });
103
+ root.append(stand);
104
+ stands.add(stand);
105
+ if (bone) bone.style.visibility = 'hidden';
106
+ shimmer(stand);
107
+
108
+ imageReady(image).then(() => {
109
+ if (!stands.has(stand)) return;
110
+ stands.delete(stand);
111
+ image.style.opacity = '';
112
+ appear(image);
113
+ if (prefersReducedMotion()) return stand.remove();
114
+ settle(
115
+ stand.animate([{ opacity: 1 }, { opacity: 0 }], {
116
+ duration: FADE_DURATION,
117
+ easing: 'ease',
118
+ fill: 'forwards',
119
+ })
120
+ ).then(() => stand.remove());
121
+ });
122
+ }
123
+ });
124
+ }
125
+
126
+ function sequenceIn(element: HTMLElement, plan: SequencePlan) {
127
+ if (prefersReducedMotion()) return Promise.resolve();
128
+ return Promise.all(
129
+ pieces(element).map((piece) => {
130
+ const delay = plan.pieceDelay(piece);
131
+ if (delay === null) return;
132
+ return appear(piece, delay, plan.duration, 0, plan.enter);
133
+ })
134
+ ).then(noop);
135
+ }
136
+
137
+ // Content arriving at once: anything still on its way out goes now too
138
+ function clearExiting(root: Element) {
139
+ for (const pane of root.querySelectorAll<HTMLElement>(`:scope > [${ATTR_EXITING}]`)) {
140
+ for (const animation of animationsOf(pane)) animation.finish();
141
+ pane.style.opacity = '0';
142
+ }
143
+ }
144
+
145
+ // Content still fading out as content lands again — what the new pane takes over from, so the two never dip together
146
+ function takeOver(root: Element) {
147
+ let from = 0;
148
+ for (const pane of root.querySelectorAll<HTMLElement>(`:scope > [${ATTR_EXITING}]`)) {
149
+ if (isSkeleton(pane)) continue;
150
+ from = Math.max(from, parseFloat(getComputedStyle(pane).opacity) || 0);
151
+ for (const animation of animationsOf(pane)) animation.finish();
152
+ pane.style.opacity = '0';
153
+ }
154
+ return from;
155
+ }
156
+
157
+ /** Brings a pane in: a skeleton after the reveal's `hold`, content as `reveal` plans; `initial` has nothing to replace. */
158
+ export function fadeIn(
159
+ element: HTMLElement,
160
+ reveal: RevealMode = 'smart',
161
+ { initial = false } = {}
162
+ ): Promise<void> {
163
+ let arriving = arrivals.get(element);
164
+ if (!arriving) {
165
+ arriving = arrive(element, reveal, initial);
166
+ arrivals.set(element, arriving);
167
+ }
168
+ return arriving;
169
+ }
170
+
171
+ // One arrival per pane: StrictMode mounts twice, and a second plan would part from the first, which the outgoing pane already matched
172
+ const arrivals = new WeakMap<HTMLElement, Promise<void>>();
173
+
174
+ function arrive(element: HTMLElement, reveal: RevealMode, initial: boolean): Promise<void> {
175
+ const root = element.parentElement;
176
+ const spec = revealSpec(reveal);
177
+
178
+ if (isSkeleton(element)) {
179
+ if (root) clearStandIns(root);
180
+ const hold = Math.max(spec.hold ?? 0, 0);
181
+ if (!hold) {
182
+ if (root) plans.delete(root);
183
+ return initial ? Promise.resolve() : appear(element);
184
+ }
185
+ if (root) plans.set(root, { kind: 'skeleton', hold });
186
+ // A reveal's first skeleton has nothing on screen to hold back for — held, it would only be a blank
187
+ if (initial) return Promise.resolve();
188
+ return appear(element, hold);
189
+ }
190
+ if (initial || !root) return Promise.resolve();
191
+
192
+ const from = takeOver(root);
193
+ if (from > 0.01) {
194
+ const duration = FADE_DURATION * (1 - from);
195
+ plans.set(root, { kind: 'together', delay: 0, duration });
196
+ return appear(element, 0, duration, from);
197
+ }
198
+
199
+ holdImages(element, root);
200
+
201
+ const plan = planReveal(element, root, spec, (key) =>
202
+ warnOnce(
203
+ `enter:${key}`,
204
+ `\`enter\` can't set \`${key}\`: only properties that change nothing's layout animate, so the reveal never moves the page.`
205
+ )
206
+ );
207
+ plans.set(root, plan);
208
+ if (plan.kind === 'instant') {
209
+ clearExiting(root);
210
+ return Promise.resolve();
211
+ }
212
+ if (plan.kind === 'sequence') return sequenceIn(element, plan);
213
+ if (plan.kind === 'wipe') return wipe(element, plan, true);
214
+ return appear(element, plan.delay, plan.duration, 0, plan.enter);
215
+ }
216
+
217
+ /** Where a pane stands within its reveal — what it's pinned to when it pops out. */
218
+ export type PaneBox = {
219
+ top: number;
220
+ left: number;
221
+ width: number;
222
+ height: number;
223
+ };
224
+
225
+ /** Measures `element` for `fadeOut`: before its replacement is inserted, or scroll anchoring moves the page. */
226
+ export function boxOf(element: HTMLElement): PaneBox {
227
+ // Fractional, not offset*: a width rounded down rewraps a shrink-to-fit pane's text as it leaves
228
+ const rect = element.getBoundingClientRect();
229
+ const parent = element.offsetParent ?? document.documentElement;
230
+ const origin = parent.getBoundingClientRect();
231
+ return {
232
+ top: rect.top - origin.top - parent.clientTop,
233
+ left: rect.left - origin.left - parent.clientLeft,
234
+ width: rect.width,
235
+ height: rect.height,
236
+ };
237
+ }
238
+
239
+ // Takes the element out of flow where it stands, so what replaces it lays out at once
240
+ function popOut(element: HTMLElement, box = boxOf(element)) {
241
+ Object.assign(element.style, {
242
+ position: 'absolute',
243
+ top: `${box.top}px`,
244
+ left: `${box.left}px`,
245
+ width: `${box.width}px`,
246
+ height: `${box.height}px`,
247
+ margin: '0',
248
+ boxSizing: 'border-box',
249
+ pointerEvents: 'none',
250
+ });
251
+ element.inert = true;
252
+ element.setAttribute(ATTR_EXITING, '');
253
+ return box;
254
+ }
255
+
256
+ type Resize = { animation: Animation; overflow: string; boxSizing: string };
257
+
258
+ // A reveal's running resize, so the next starts from where it got to and restores the styles it found
259
+ const resizes = new WeakMap<HTMLElement, Resize>();
260
+
261
+ // Eases a reveal from the height the outgoing pane gave it to the incoming one's; the outgoing pane is already out of flow
262
+ function resize(root: HTMLElement, box: PaneBox) {
263
+ const incoming = root.querySelector<HTMLElement>(
264
+ `:scope > [${ATTR_PANE}]:not([${ATTR_EXITING}])`
265
+ );
266
+ if (!incoming) return;
267
+ const running = resizes.get(root);
268
+ const shown = root.getBoundingClientRect().height;
269
+ running?.animation.cancel();
270
+ const to = root.getBoundingClientRect().height;
271
+ const from = running ? shown : to - incoming.getBoundingClientRect().height + box.height;
272
+ if (Math.abs(from - to) < 0.5) {
273
+ if (running) restore(root, running);
274
+ return;
275
+ }
276
+
277
+ const { overflow, boxSizing } = running ?? root.style;
278
+ Object.assign(root.style, { overflow: 'clip', boxSizing: 'border-box' });
279
+ const animation = root.animate([{ height: `${from}px` }, { height: `${to}px` }], {
280
+ duration: RESIZE_DURATION,
281
+ easing: 'cubic-bezier(0.2, 0, 0, 1)',
282
+ });
283
+ const current = { animation, overflow, boxSizing };
284
+ resizes.set(root, current);
285
+ settle(animation).then(() => {
286
+ if (resizes.get(root) === current) restore(root, current);
287
+ });
288
+ }
289
+
290
+ function restore(root: HTMLElement, { overflow, boxSizing }: Resize) {
291
+ resizes.delete(root);
292
+ Object.assign(root.style, { overflow, boxSizing });
293
+ }
294
+
295
+ /** Resolves once `element` can be removed, leaving as the pane replacing it decides; its parent must be positioned. */
296
+ export function fadeOut(element: HTMLElement, box?: PaneBox): Promise<void> {
297
+ // Read before cancelling, so an interrupted fade-in leaves from where it got to
298
+ const opacity = parseFloat(getComputedStyle(element).opacity);
299
+ const from = Number.isNaN(opacity) ? 1 : opacity;
300
+ for (const animation of animationsOf(element)) animation.cancel();
301
+ clearMask(element);
302
+ element.style.filter = '';
303
+
304
+ const parent = element.parentElement;
305
+ // Content moving on: the bones standing in for its images go with it
306
+ if (parent && !isSkeleton(element)) clearStandIns(parent);
307
+ const stood = popOut(element, box);
308
+
309
+ const fade = (delay: number, duration: number) => {
310
+ if (!canAnimate()) {
311
+ element.style.opacity = '0';
312
+ return Promise.resolve();
313
+ }
314
+ return settle(
315
+ element.animate([{ opacity: from }, { opacity: 0 }], {
316
+ duration: duration * from,
317
+ delay,
318
+ easing: 'ease',
319
+ fill: 'forwards',
320
+ })
321
+ );
322
+ };
323
+
324
+ // A microtask on, so the pane replacing it has registered how it arrives
325
+ return Promise.resolve().then(() => {
326
+ const plan = parent && plans.get(parent);
327
+ const instant = prefersReducedMotion() || plan?.kind === 'instant';
328
+ if (parent && !instant) resize(parent, stood);
329
+
330
+ // Content stays until the skeleton replacing it shows — which may be never
331
+ if (plan?.kind === 'skeleton') return fade(plan.hold, FADE_DURATION);
332
+ if (instant) {
333
+ element.style.opacity = '0';
334
+ return;
335
+ }
336
+ if (plan?.kind === 'sequence' && isSkeleton(element)) {
337
+ return sequenceOut(element, plan);
338
+ }
339
+ if (plan?.kind === 'wipe' && isSkeleton(element)) {
340
+ return wipe(element, plan, false);
341
+ }
342
+ if (plan?.kind === 'together') return fade(plan.delay, plan.duration);
343
+ return fade(0, FADE_DURATION);
344
+ });
345
+ }
346
+
347
+ function sequenceOut(element: HTMLElement, plan: SequencePlan) {
348
+ // The content's own surfaces arrive whole; only the bones go piece by piece
349
+ for (const ghost of element.querySelectorAll<HTMLElement>(
350
+ `[${ATTR_GHOST}], [${ATTR_SNAPSHOT}]`
351
+ )) {
352
+ ghost.style.visibility = 'hidden';
353
+ }
354
+
355
+ // A skeleton the content draws itself sits in the ghost, but leaves like a bone
356
+ const own = [...element.querySelectorAll<HTMLElement>(`[${ATTR_GHOST}] [${ATTR_OWN}]`)].filter(
357
+ (part) => !part.parentElement?.closest(`[${ATTR_OWN}]`)
358
+ );
359
+ for (const part of own) part.style.visibility = 'visible';
360
+
361
+ const bones = [...[...element.querySelectorAll(`[${ATTR_LAYER}]`)].flatMap(rectsOf), ...own];
362
+ return Promise.all(
363
+ bones.map((bone) => {
364
+ const rect = bone.getBoundingClientRect();
365
+ const go = (delay: number) =>
366
+ settle(
367
+ bone.animate([{ opacity: 1 }, { opacity: 0 }], {
368
+ duration: plan.duration,
369
+ delay,
370
+ easing: 'ease',
371
+ fill: 'forwards',
372
+ })
373
+ );
374
+ const delay = plan.boneDelay(rect);
375
+ if (delay === null) {
376
+ bone.style.opacity = '0';
377
+ return;
378
+ }
379
+ return go(delay);
380
+ })
381
+ ).then(noop);
382
+ }
383
+
384
+ const STALL = 100; // ms between frames that counts as frames dropping — the wipe gives way to a crossfade
385
+
386
+ // Running fronts, by pane — stopped when the pane's mask is cleared
387
+ const fronts = new WeakMap<HTMLElement, () => void>();
388
+
389
+ // Prefixed too: Safari before 15.4 masks with -webkit- only
390
+ function setMask(element: HTMLElement, image: string) {
391
+ element.style.setProperty('mask-image', image);
392
+ element.style.setProperty('-webkit-mask-image', image);
393
+ }
394
+
395
+ // The inverse of `image`: a full layer with `image` cut out of it
396
+ function setInverseMask(element: HTMLElement, image: string) {
397
+ setMask(element, `linear-gradient(#000 0 0), ${image}`);
398
+ // Prefixed first: WebKit aliases the two, and the later wins
399
+ element.style.setProperty('-webkit-mask-composite', 'xor');
400
+ element.style.setProperty('mask-composite', 'exclude');
401
+ }
402
+
403
+ function clearMask(element: HTMLElement) {
404
+ fronts.get(element)?.();
405
+ for (const property of [
406
+ 'mask-image',
407
+ '-webkit-mask-image',
408
+ 'mask-composite',
409
+ '-webkit-mask-composite',
410
+ ]) {
411
+ element.style.removeProperty(property);
412
+ }
413
+ }
414
+
415
+ // A wipe across one pane on the plan's shared clock; the plainest mask, rewritten each frame, for Safari 18
416
+ function wipe(element: HTMLElement, plan: WipePlan, incoming: boolean) {
417
+ const pane = { rect: element.getBoundingClientRect(), reveal: plan.reveal };
418
+ const { mask, out } = plan.wipe;
419
+ const draw = (at: number) => {
420
+ if (incoming) setMask(element, mask(at, pane));
421
+ else if (out) setMask(element, out(at, pane));
422
+ else setInverseMask(element, mask(at, pane));
423
+ };
424
+ const progress = () => (performance.now() - plan.start) / plan.duration;
425
+ if (progress() >= 1) {
426
+ if (!incoming) element.style.opacity = '0';
427
+ return Promise.resolve();
428
+ }
429
+ if (incoming) {
430
+ plan.wipe.effect?.(element, { ...pane, progress });
431
+ if (plan.enter) {
432
+ element.animate([plan.enter, {}], {
433
+ duration: plan.duration,
434
+ delay: Math.max(plan.start - performance.now(), 0),
435
+ easing: 'ease',
436
+ fill: 'backwards',
437
+ });
438
+ }
439
+ }
440
+
441
+ // Resolves null once crossed, or with how far it got when frames dropped and the wipe gave way
442
+ const run = new Promise<number | null>((resolve) => {
443
+ let frame = 0;
444
+ let last = performance.now();
445
+ let at = 0;
446
+ const tick = () => {
447
+ const now = performance.now();
448
+ if (now - last > STALL) plan.stalled = true;
449
+ last = now;
450
+ if (plan.stalled) return done(at);
451
+ at = Math.min(Math.max(progress(), 0), 1);
452
+ draw(at);
453
+ if (at < 1) frame = requestAnimationFrame(tick);
454
+ else done(null);
455
+ };
456
+ const done = (stalledAt: number | null = null) => {
457
+ cancelAnimationFrame(frame);
458
+ clearTimeout(deadline);
459
+ fronts.delete(element);
460
+ resolve(stalledAt);
461
+ };
462
+ // Frames can stop altogether, as in a hidden tab: the wipe still ends when its time is up
463
+ const deadline = setTimeout(
464
+ () => done(null),
465
+ Math.max(plan.start + plan.duration - performance.now(), 0) + STALL
466
+ );
467
+ fronts.set(element, done);
468
+ tick();
469
+ });
470
+
471
+ return run.then((stalledAt) => {
472
+ clearMask(element);
473
+ if (stalledAt === null) {
474
+ if (!incoming) element.style.opacity = '0';
475
+ return;
476
+ }
477
+ // Frames dropped: the rest as a crossfade, from as far as the front got
478
+ const rest = FADE_DURATION * (1 - stalledAt);
479
+ if (incoming) return appear(element, 0, rest, stalledAt);
480
+ return settle(
481
+ element.animate([{ opacity: 1 - stalledAt }, { opacity: 0 }], {
482
+ duration: rest,
483
+ easing: 'ease',
484
+ fill: 'forwards',
485
+ })
486
+ ).then(() => {
487
+ element.style.opacity = '0';
488
+ });
489
+ });
490
+ }