Skip to content

Commit bf45988

Browse files
fix: jumping when aligned at bottom (#69) (#211)
* fix: jumping when aligned at bottom (#69) * chore: use const instead of let * test(e2e): add Playwright visual tests (video + pixel diffs); stabilize animation waits; upload videos in CI --------- Co-authored-by: Justin Schroeder <justin@formkit.com>
1 parent 9bad0a6 commit bf45988

10 files changed

Lines changed: 420 additions & 39 deletions

‎.github/workflows/main.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,9 @@ jobs:
3636
with:
3737
name: playwright-report
3838
path: playwright-report
39+
- name: Upload Playwright videos (visual)
40+
if: always()
41+
uses: actions/upload-artifact@v4
42+
with:
43+
name: playwright-videos
44+
path: test-results/**/video.webm

‎package.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"test:e2e": "playwright test",
3131
"test:e2e:headed": "playwright test --headed --project=chromium",
3232
"test:e2e:ui": "playwright test --ui",
33-
"test:e2e:leak": "LEAK_STRICT=1 playwright test -g memory --project=chromium"
33+
"test:e2e:leak": "LEAK_STRICT=1 playwright test -g memory --project=chromium",
34+
"test:e2e:visual": "VISUAL=1 playwright test -g 'Visual:' --project=chromium"
3435
},
3536
"exports": {
3637
"./vue": {
@@ -108,7 +109,9 @@
108109
"vite-ssg": "^0.24.0",
109110
"vue": "^3.5.0",
110111
"vue-github-button": "^3.1.3",
111-
"vue-router": "^4.4.3"
112+
"vue-router": "^4.4.3",
113+
"pixelmatch": "^5.3.0",
114+
"pngjs": "^7.0.0"
112115
},
113116
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
114117
}

‎playwright.config.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,21 @@ export default defineConfig({
66
forbidOnly: !!process.env.CI,
77
retries: process.env.CI ? 2 : 0,
88
workers: process.env.CI ? 2 : undefined,
9+
expect: {
10+
toHaveScreenshot: {
11+
// Allow small rendering differences across environments
12+
maxDiffPixelRatio: 0.03,
13+
animations: 'allow',
14+
},
15+
},
916
reporter: [['list'], ['html', { open: 'never' }]],
1017
use: {
1118
baseURL: 'http://localhost:5173',
1219
trace: 'retain-on-failure',
1320
video: 'retain-on-failure',
1421
screenshot: 'only-on-failure',
22+
viewport: { width: 1200, height: 900 },
23+
deviceScaleFactor: 1,
1524
},
1625
webServer: {
1726
command: 'cd docs && vite --config=./vite.config.ts --port=5173',

‎pnpm-lock.yaml‎

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/index.ts‎

Lines changed: 59 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ function observePosition(el: Element) {
159159
root,
160160
threshold: 1,
161161
rootMargin,
162-
}
162+
},
163163
)
164164
observer.observe(el)
165165
intersections.set(el, observer)
@@ -168,11 +168,16 @@ function observePosition(el: Element) {
168168
/**
169169
* Update the exact position of a given element.
170170
* @param el - An element to update the position of.
171+
* @param debounce - Whether or not to debounce the update. After an animation is finished, it should update as soon as possible to prevent flickering on quick toggles.
171172
*/
172-
function updatePos(el: Element) {
173+
function updatePos(el: Element, debounce = true) {
173174
clearTimeout(debounces.get(el))
174175
const optionsOrPlugin = getOptions(el)
175-
const delay = isPlugin(optionsOrPlugin) ? 500 : optionsOrPlugin.duration
176+
const delay = debounce
177+
? isPlugin(optionsOrPlugin)
178+
? 500
179+
: optionsOrPlugin.duration
180+
: 0
176181
debounces.set(
177182
el,
178183
setTimeout(async () => {
@@ -186,7 +191,7 @@ function updatePos(el: Element) {
186191
} catch {
187192
// ignore errors as the `.finished` promise is rejected when animations were cancelled
188193
}
189-
}, delay)
194+
}, delay),
190195
)
191196
}
192197

@@ -199,9 +204,9 @@ function updateAllPos() {
199204
root,
200205
setTimeout(() => {
201206
parents.forEach((parent) =>
202-
forEach(parent, (el) => lowPriority(() => updatePos(el)))
207+
forEach(parent, (el) => lowPriority(() => updatePos(el))),
203208
)
204-
}, 100)
209+
}, 100),
205210
)
206211
}
207212

@@ -214,12 +219,15 @@ function updateAllPos() {
214219
* @param el - Element
215220
*/
216221
function poll(el: Element) {
217-
setTimeout(() => {
218-
intervals.set(
219-
el,
220-
setInterval(() => lowPriority(updatePos.bind(null, el)), 2000)
221-
)
222-
}, Math.round(2000 * Math.random()))
222+
setTimeout(
223+
() => {
224+
intervals.set(
225+
el,
226+
setInterval(() => lowPriority(updatePos.bind(null, el)), 2000),
227+
)
228+
},
229+
Math.round(2000 * Math.random()),
230+
)
223231
}
224232

225233
/**
@@ -279,7 +287,7 @@ function getElements(mutations: MutationRecord[]): Set<Element> | false {
279287
}, [])
280288
// Short circuit if _only_ comment nodes are observed
281289
const onlyCommentNodesObserved = observedNodes.every(
282-
(node) => node.nodeName === "#comment"
290+
(node) => node.nodeName === "#comment",
283291
)
284292

285293
if (onlyCommentNodesObserved) return false
@@ -343,7 +351,8 @@ function animate(el: Element) {
343351
const isMounted = el.isConnected
344352
const preExisting = coords.has(el)
345353
if (isMounted && siblings.has(el)) siblings.delete(el)
346-
if (animations.has(el)) {
354+
355+
if (animations.get(el)?.playState !== "finished") {
347356
animations.get(el)?.cancel()
348357
}
349358
if (NEW in el) {
@@ -409,7 +418,7 @@ function getCoords(el: Element): Coordinates {
409418
export function getTransitionSizes(
410419
el: Element,
411420
oldCoords: Coordinates,
412-
newCoords: Coordinates
421+
newCoords: Coordinates,
413422
) {
414423
let widthFrom = oldCoords.width
415424
let heightFrom = oldCoords.height
@@ -491,7 +500,7 @@ function forEach(
491500
* Always return tuple to provide consistent interface
492501
*/
493502
function getPluginTuple(
494-
pluginReturn: ReturnType<AutoAnimationPlugin>
503+
pluginReturn: ReturnType<AutoAnimationPlugin>,
495504
): [KeyframeEffect, AutoAnimationPluginOptions] | [KeyframeEffect] {
496505
if (Array.isArray(pluginReturn)) return pluginReturn
497506

@@ -502,7 +511,7 @@ function getPluginTuple(
502511
* Determine if config is plugin
503512
*/
504513
function isPlugin(
505-
config: Partial<AutoAnimateOptions> | AutoAnimationPlugin
514+
config: Partial<AutoAnimateOptions> | AutoAnimationPlugin,
506515
): config is AutoAnimationPlugin {
507516
return typeof config === "function"
508517
}
@@ -520,15 +529,24 @@ function remain(el: Element) {
520529
if (!oldCoords) return
521530
const pluginOrOptions = getOptions(el)
522531
if (typeof pluginOrOptions !== "function") {
523-
const deltaX = oldCoords.left - newCoords.left
524-
const deltaY = oldCoords.top - newCoords.top
532+
let deltaLeft = oldCoords.left - newCoords.left
533+
let deltaTop = oldCoords.top - newCoords.top
534+
const deltaRight =
535+
oldCoords.left + oldCoords.width - (newCoords.left + newCoords.width)
536+
const deltaBottom =
537+
oldCoords.top + oldCoords.height - (newCoords.top + newCoords.height)
538+
539+
// element is probably anchored and doesn't need to be offset
540+
if (deltaBottom == 0) deltaTop = 0
541+
if (deltaRight == 0) deltaLeft = 0
542+
525543
const [widthFrom, widthTo, heightFrom, heightTo] = getTransitionSizes(
526544
el,
527545
oldCoords,
528-
newCoords
546+
newCoords,
529547
)
530548
const start: Record<string, any> = {
531-
transform: `translate(${deltaX}px, ${deltaY}px)`,
549+
transform: `translate(${deltaLeft}px, ${deltaTop}px)`,
532550
}
533551
const end: Record<string, any> = {
534552
transform: `translate(0, 0)`,
@@ -547,14 +565,16 @@ function remain(el: Element) {
547565
})
548566
} else {
549567
const [keyframes] = getPluginTuple(
550-
pluginOrOptions(el, "remain", oldCoords, newCoords)
568+
pluginOrOptions(el, "remain", oldCoords, newCoords),
551569
)
552570
animation = new Animation(keyframes)
553571
animation.play()
554572
}
555573
animations.set(el, animation)
556574
coords.set(el, newCoords)
557-
animation.addEventListener("finish", () => updatePos(el), { once: true })
575+
animation.addEventListener("finish", updatePos.bind(null, el, false), {
576+
once: true,
577+
})
558578
}
559579

560580
/**
@@ -578,15 +598,17 @@ function add(el: Element) {
578598
{
579599
duration: (pluginOrOptions as AutoAnimateOptions).duration * 1.5,
580600
easing: "ease-in",
581-
}
601+
},
582602
)
583603
} else {
584604
const [keyframes] = getPluginTuple(pluginOrOptions(el, "add", newCoords))
585605
animation = new Animation(keyframes)
586606
animation.play()
587607
}
588608
animations.set(el, animation)
589-
animation.addEventListener("finish", () => updatePos(el), { once: true })
609+
animation.addEventListener("finish", updatePos.bind(null, el, false), {
610+
once: true,
611+
})
590612
}
591613

592614
/**
@@ -672,11 +694,11 @@ function remove(el: Element) {
672694
{
673695
duration: (optionsOrPlugin as AutoAnimateOptions).duration,
674696
easing: "ease-out",
675-
}
697+
},
676698
)
677699
} else {
678700
const [keyframes, options] = getPluginTuple(
679-
(optionsOrPlugin as AutoAnimationPlugin)(el, "remove", oldCoords)
701+
(optionsOrPlugin as AutoAnimationPlugin)(el, "remove", oldCoords),
680702
)
681703
if (
682704
(options as AutoAnimationPluginOptions | undefined)?.styleReset !== false
@@ -713,7 +735,7 @@ function adjustScroll(
713735
el: Element,
714736
finalX: number,
715737
finalY: number,
716-
optionsOrPlugin: AutoAnimateOptions | AutoAnimationPlugin
738+
optionsOrPlugin: AutoAnimateOptions | AutoAnimationPlugin,
717739
) {
718740
const scrollDeltaX = scrollX - finalX
719741
const scrollDeltaY = scrollY - finalY
@@ -761,7 +783,7 @@ function adjustScroll(
761783
* @returns
762784
*/
763785
function deletePosition(
764-
el: Element
786+
el: Element,
765787
): [top: number, left: number, width: number, height: number] {
766788
const oldCoords = coords.get(el)!
767789
const [width, , height] = getTransitionSizes(el, oldCoords, getCoords(el))
@@ -776,7 +798,11 @@ function deletePosition(
776798
}
777799
if (!offsetParent) offsetParent = document.body
778800
const parentStyles = getComputedStyle(offsetParent)
779-
const parentCoords = coords.get(offsetParent) || getCoords(offsetParent)
801+
const parentCoords =
802+
!animations.has(el) || animations.get(el)?.playState === "finished"
803+
? getCoords(offsetParent)
804+
: coords.get(offsetParent)!
805+
780806
const top =
781807
Math.round(oldCoords.top - parentCoords.top) -
782808
raw(parentStyles.borderTopWidth)
@@ -821,7 +847,7 @@ export interface AutoAnimationPlugin {
821847
newCoordinates?: T extends "add" | "remain" | "remove"
822848
? Coordinates
823849
: undefined,
824-
oldCoordinates?: T extends "remain" ? Coordinates : undefined
850+
oldCoordinates?: T extends "remain" ? Coordinates : undefined,
825851
): KeyframeEffect | [KeyframeEffect, AutoAnimationPluginOptions]
826852
}
827853

@@ -834,7 +860,7 @@ export interface AutoAnimationPlugin {
834860
*/
835861
export default function autoAnimate(
836862
el: HTMLElement,
837-
config: Partial<AutoAnimateOptions> | AutoAnimationPlugin = {}
863+
config: Partial<AutoAnimateOptions> | AutoAnimationPlugin = {},
838864
): AnimationController {
839865
if (supportedBrowser && resize) {
840866
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
@@ -916,7 +942,7 @@ export const vAutoAnimate = {
916942
el: HTMLElement,
917943
binding: {
918944
value: Partial<AutoAnimateOptions> | AutoAnimationPlugin | undefined
919-
}
945+
},
920946
) => {
921947
const ctl = autoAnimate(el, binding.value || {})
922948
Object.defineProperty(el, "__aa_ctl", { value: ctl, configurable: true })

0 commit comments

Comments
 (0)