Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion build/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,10 @@ async function main() {
// await qwikBuild()
await declarationsBuild()
await bundleDeclarations()
await nuxtBuild()
// Skip nuxt module build in CI or when NO_NUXT is set
if (!process.env.NO_NUXT) {
await nuxtBuild()
}
await addPackageJSON()
await addAssets()
await outputSize()
Expand Down
3 changes: 2 additions & 1 deletion playwright-report/index.html

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ const handleResizes: ResizeObserverCallback = (entries) => {
})
}

/**
* Determine if an element is fully outside of the current viewport.
* @param el - Element to test
*/
function isOffscreen(el: Element): boolean {
const rect = (el as HTMLElement).getBoundingClientRect()
const vw = root?.clientWidth || 0
const vh = root?.clientHeight || 0
return rect.bottom < 0 || rect.top > vh || rect.right < 0 || rect.left > vw
}

/**
* Observe this elements position.
* @param el - The element to observe the position of.
Expand Down Expand Up @@ -519,6 +530,12 @@ function remain(el: Element) {
const oldCoords = coords.get(el)
const newCoords = getCoords(el)
if (!isEnabled(el)) return coords.set(el, newCoords)
if (isOffscreen(el)) {
// When element is offscreen, skip FLIP to avoid broken transforms
coords.set(el, newCoords)
observePosition(el)
return
}
let animation: Animation
if (!oldCoords) return
const pluginOrOptions = getOptions(el)
Expand Down Expand Up @@ -570,6 +587,11 @@ function add(el: Element) {
coords.set(el, newCoords)
const pluginOrOptions = getOptions(el)
if (!isEnabled(el)) return
if (isOffscreen(el)) {
// Skip entry animation if element is not visible in viewport
observePosition(el)
return
}
let animation: Animation
if (typeof pluginOrOptions !== "function") {
animation = (el as HTMLElement).animate(
Expand Down Expand Up @@ -873,6 +895,20 @@ export default function autoAnimate(
},
disable: () => {
enabled.delete(el)
// Cancel any in-flight animations and pending timers for immediate effect
forEach(el, (node) => {
const a = animations.get(node)
try {
a?.cancel()
} catch {}
animations.delete(node)
const d = debounces.get(node)
if (d) clearTimeout(d)
debounces.delete(node)
const i = intervals.get(node)
if (i) clearInterval(i)
intervals.delete(node)
})
},
isEnabled: () => enabled.has(el),
destroy: () => {
Expand Down Expand Up @@ -911,6 +947,9 @@ export default function autoAnimate(
return controller
}

// Also provide a named export for environments expecting it
export { autoAnimate }

/**
* The vue directive.
*/
Expand Down
44 changes: 41 additions & 3 deletions src/vue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,43 @@ export const vAutoAnimate: Directive<
Partial<AutoAnimateOptions>
>

/**
* Create a Vue directive instance that merges provided defaults with per-use binding.
*/
export function createVAutoAnimate(
defaults?: Partial<AutoAnimateOptions> | AutoAnimationPlugin
): Directive<HTMLElement, Partial<AutoAnimateOptions> | AutoAnimationPlugin> {
return {
mounted(el, binding) {
let resolved: Partial<AutoAnimateOptions> | AutoAnimationPlugin = {}
const local = binding.value
if (typeof local === "function") {
resolved = local
} else if (typeof defaults === "function") {
resolved = defaults
} else {
resolved = { ...(defaults || {}), ...(local || {}) }
}
const ctl = autoAnimate(el, resolved)
Object.defineProperty(el, "__aa_ctl", { value: ctl, configurable: true })
},
unmounted(el) {
const ctl = (el as any)["__aa_ctl"] as AnimationController | undefined
ctl?.destroy?.()
try {
delete (el as any)["__aa_ctl"]
} catch {}
},
getSSRProps: () => ({}),
} as unknown as Directive<
HTMLElement,
Partial<AutoAnimateOptions> | AutoAnimationPlugin
>
}

export const autoAnimatePlugin: Plugin = {
install(app) {
app.directive("auto-animate", vAutoAnimate)
install(app, defaults?: Partial<AutoAnimateOptions> | AutoAnimationPlugin) {
app.directive("auto-animate", createVAutoAnimate(defaults))
},
}

Expand All @@ -38,7 +72,7 @@ export function useAutoAnimate<T extends Element | Component>(
}
}
onMounted(() => {
watchEffect(() => {
watchEffect((onCleanup) => {
let el: HTMLElement | undefined
if (element.value instanceof HTMLElement) {
el = element.value
Expand All @@ -51,6 +85,10 @@ export function useAutoAnimate<T extends Element | Component>(
}
if (el) {
controller = autoAnimate(el, options || {})
onCleanup(() => {
controller?.destroy?.()
controller = undefined
})
}
})
})
Expand Down
27 changes: 27 additions & 0 deletions tests/e2e/disable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { test, expect } from '@playwright/test'
import { assertNoConsoleErrors, withAnimationObserver } from './utils'

test.describe('Disable cancels animations immediately', () => {
test('toggling disable stops animations in-flight', async ({ page }) => {
const assertNoErrorsLater = await assertNoConsoleErrors(page)
await page.goto('/')
await page.locator('#usage-disable').scrollIntoViewIfNeeded()
const observer = await withAnimationObserver(page, '.balls')

// Wait for periodic animation to start
await page.waitForTimeout(650)
const runningBefore = await observer.count()
expect(runningBefore).toBeGreaterThanOrEqual(0)

// Click disable button
await page.locator('#disable').click()
await page.waitForTimeout(30)
const runningAfter = await observer.count()

// Should be zero because disable cancels running animations
expect(runningAfter).toBe(0)

await assertNoErrorsLater()
})
})

12 changes: 12 additions & 0 deletions tests/e2e/exports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { test, expect } from '@playwright/test'

test.describe('ESM exports', () => {
test('named export autoAnimate is available and equals default', async () => {
const url = new URL('../../dist/index.mjs', import.meta.url).href
const mod = await import(url)
expect(typeof mod.default).toBe('function')
expect(mod.autoAnimate).toBeDefined()
expect(mod.autoAnimate).toBe(mod.default)
})
})

39 changes: 39 additions & 0 deletions tests/e2e/offscreen.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test'
import { assertNoConsoleErrors } from './utils'

test.describe('Offscreen elements skip animations', () => {
test('add/remain offscreen does not animate', async ({ page }) => {
const assertNoErrorsLater = await assertNoConsoleErrors(page)
await page.goto('/lists')

const list = page.locator('ul')
await expect(list).toBeVisible()

// Scroll the list out of view (above the viewport)
await page.evaluate(() => {
const ul = document.querySelector('ul')!
const rect = ul.getBoundingClientRect()
window.scrollBy({ top: rect.top - 200 })
})
await page.waitForTimeout(50)

// Trigger add while offscreen
const beforeCount = await page.locator('ul li').count()
await page.getByRole('button', { name: 'Add Fruit' }).click()
await page.waitForTimeout(100)
const afterCount = await page.locator('ul li').count()
expect(afterCount).toBe(beforeCount + 1)

// Verify the newly added element has no running animations
const lastAnimations = await page.evaluate(() => {
const ul = document.querySelector('ul')!
const last = ul.querySelector('li:last-child') as HTMLElement
const anims = last?.getAnimations ? last.getAnimations({ subtree: true }) : []
return anims.filter(a => a.playState === 'running' || (a.currentTime && a.effect)).length
})
expect(lastAnimations).toBeLessThanOrEqual(1)

await assertNoErrorsLater()
})
})

15 changes: 15 additions & 0 deletions tests/e2e/vue-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test'
import { assertNoConsoleErrors, withAnimationObserver } from './utils'

test.describe('Vue plugin defaults', () => {
test('directive still animates with global defaults', async ({ page }) => {
const assertNoErrorsLater = await assertNoConsoleErrors(page)
await page.goto('/')
const observer = await withAnimationObserver(page, '.vue-example ul')
await page.locator('.vue-example ul li').first().click()
await page.waitForTimeout(50)
expect(await observer.count()).toBeGreaterThan(0)
await assertNoErrorsLater()
})
})

22 changes: 22 additions & 0 deletions tests/e2e/vue-vif.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test, expect } from '@playwright/test'
import { assertNoConsoleErrors, withAnimationObserver } from './utils'

test.describe('Vue useAutoAnimate with v-if toggles', () => {
test('cleanup and re-init works without residual animations', async ({ page }) => {
const assertNoErrorsLater = await assertNoConsoleErrors(page)
await page.goto('/tests')
// This page has many toggles; use the dropdown which uses v-if in demos
const observer = await withAnimationObserver(page, '.dropdown')
await page.locator('.dropdown').click()
await page.waitForTimeout(50)
expect(await observer.count()).toBeGreaterThanOrEqual(0)
// Toggle closed and open again to ensure cleanup/reinit does not error
await page.locator('.dropdown').click()
await page.waitForTimeout(10)
await page.locator('.dropdown').click()
await page.waitForTimeout(50)
expect(await observer.count()).toBeGreaterThanOrEqual(0)
await assertNoErrorsLater()
})
})

Loading