Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Catch the finished promise AbortError and ignores it #259

Merged
merged 13 commits into from
Oct 22, 2024
2 changes: 1 addition & 1 deletion src/alert.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default class extends Controller {
enter(this.element)
}, this.showDelayValue)

// Auto dimiss if defined
// Auto dismiss if defined
if (this.hasDismissAfterValue) {
setTimeout(() => {
this.close()
Expand Down
139 changes: 99 additions & 40 deletions src/transition.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
// transition(this.element, false)
export async function transition(element, state, transitionOptions = {}) {
if (!!state) {
enter(element, transitionOptions)
await enter(element, transitionOptions)
} else {
leave(element, transitionOptions)
await leave(element, transitionOptions)
}
}

Expand All @@ -22,62 +22,121 @@ export async function transition(element, state, transitionOptions = {}) {
// data-transition-leave-to="bg-opacity-0"
export async function enter(element, transitionOptions = {}) {
const transitionClasses = element.dataset.transitionEnter || transitionOptions.enter || 'enter'
const fromClasses =
element.dataset.transitionEnterFrom || transitionOptions.enterFrom || 'enter-from'
const fromClasses = element.dataset.transitionEnterFrom || transitionOptions.enterFrom || 'enter-from'
const toClasses = element.dataset.transitionEnterTo || transitionOptions.enterTo || 'enter-to'
const toggleClass = element.dataset.toggleClass || transitionOptions.toggleClass || 'hidden'

// Prepare transition
element.classList.add(...transitionClasses.split(' '))
element.classList.add(...fromClasses.split(' '))
element.classList.remove(...toClasses.split(' '))
element.classList.remove(...toggleClass.split(' '))

await nextFrame()

element.classList.remove(...fromClasses.split(' '))
element.classList.add(...toClasses.split(' '))

try {
await afterTransition(element)
} finally {
element.classList.remove(...transitionClasses.split(' '))
}
return performTransitions(element, {
firstFrame() {
element.classList.add(...transitionClasses.split(' '))
element.classList.add(...fromClasses.split(' '))
element.classList.remove(...toClasses.split(' '))
element.classList.remove(...toggleClass.split(' '))
},
secondFrame() {
element.classList.remove(...fromClasses.split(' '))
element.classList.add(...toClasses.split(' '))
},
ending() {
element.classList.remove(...transitionClasses.split(' '))
}
})
}

export async function leave(element, transitionOptions = {}) {
const transitionClasses = element.dataset.transitionLeave || transitionOptions.leave || 'leave'
const fromClasses =
element.dataset.transitionLeaveFrom || transitionOptions.leaveFrom || 'leave-from'
const fromClasses = element.dataset.transitionLeaveFrom || transitionOptions.leaveFrom || 'leave-from'
const toClasses = element.dataset.transitionLeaveTo || transitionOptions.leaveTo || 'leave-to'
const toggleClass = element.dataset.toggleClass || transitionOptions.toggle || 'hidden'

// Prepare transition
element.classList.add(...transitionClasses.split(' '))
element.classList.add(...fromClasses.split(' '))
element.classList.remove(...toClasses.split(' '))

await nextFrame()
return performTransitions(element, {
firstFrame() {
element.classList.add(...fromClasses.split(' '))
element.classList.remove(...toClasses.split(' '))
element.classList.add(...transitionClasses.split(' '))
},
secondFrame() {
element.classList.remove(...fromClasses.split(' '))
element.classList.add(...toClasses.split(' '))
},
ending() {
element.classList.remove(...transitionClasses.split(' '))
element.classList.add(...toggleClass.split(' '))
}
})
}

element.classList.remove(...fromClasses.split(' '))
element.classList.add(...toClasses.split(' '))
function setupTransition(element) {
element._stimulus_transition = {
timeout: null,
interrupted: false
}
}

try {
await afterTransition(element)
} finally {
element.classList.remove(...transitionClasses.split(' '))
element.classList.add(...toggleClass.split(' '))
export function cancelTransition(element) {
if(element._stimulus_transition && element._stimulus_transition.interrupt) {
element._stimulus_transition.interrupt()
}
}

function nextFrame() {
return new Promise(resolve => {
function performTransitions(element, transitionStages) {
if (element._stimulus_transition) cancelTransition(element)

let interrupted, firstStageComplete, secondStageComplete
setupTransition(element)

element._stimulus_transition.cleanup = () => {
if(! firstStageComplete) transitionStages.firstFrame()
if(! secondStageComplete) transitionStages.secondFrame()

transitionStages.ending()
element._stimulus_transition = null
}

element._stimulus_transition.interrupt = () => {
interrupted = true
if(element._stimulus_transition.timeout) {
clearTimeout(element._stimulus_transition.timeout)
}
element._stimulus_transition.cleanup()
}

return new Promise((resolve) => {
if(interrupted) return

requestAnimationFrame(() => {
requestAnimationFrame(resolve)
if(interrupted) return

transitionStages.firstFrame()
firstStageComplete = true

requestAnimationFrame(() => {
if(interrupted) return

transitionStages.secondFrame()
secondStageComplete = true

if(element._stimulus_transition) {
element._stimulus_transition.timeout = setTimeout(() => {
if(interrupted) {
resolve()
return
}

element._stimulus_transition.cleanup()
resolve()
}, getAnimationDuration(element))
}
})
})
})
}

function afterTransition(element) {
return Promise.all(element.getAnimations().map(animation => animation.finished))
function getAnimationDuration(element) {
let duration = Number(getComputedStyle(element).transitionDuration.replace(/,.*/, '').replace('s', '')) * 1000
let delay = Number(getComputedStyle(element).transitionDelay.replace(/,.*/, '').replace('s', '')) * 1000

if (duration === 0) duration = Number(getComputedStyle(element).animationDuration.replace('s', '')) * 1000

return duration + delay
}
2 changes: 2 additions & 0 deletions test/alert_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ describe('AlertController', () => {
await loadFixture('alerts/alert_default.html')
expect(fetchElement().className.includes("hidden")).to.equal(false)

// Timeout so click() doesn't happen before setTimeout runs in controller.
await aTimeout(0)
const closeButton = document.querySelector("[data-action='alert#close']")
closeButton.click()

Expand Down
3 changes: 2 additions & 1 deletion test/dropdown_test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { html, fixture, expect, nextFrame } from '@open-wc/testing'
import { fixture, expect, nextFrame } from '@open-wc/testing'
import { fetchFixture } from './test_helpers'

import { Application } from '@hotwired/stimulus'
Expand All @@ -19,6 +19,7 @@ describe('DropdownController', () => {
const button = document.querySelector('[data-action="dropdown#toggle:stop"]')
button.click()
await nextFrame()
await nextFrame()
expect(menu.className.includes('hidden')).to.equal(false)
})
})
Expand Down
19 changes: 11 additions & 8 deletions test/popover_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,28 @@ describe('PopoverController', () => {
})
target.dispatchEvent(mouseover)
await nextFrame()
await nextFrame()
expect(target.className.includes('hidden')).to.equal(false)
})

it('mouseOut adds hidden class', (done) => {
it('mouseOut adds hidden class', async () => {
const target = document.querySelector('[data-popover-target="content"]')
target.className.replace('hidden', '')
const event = new MouseEvent('mouseleave', {
view: window,
bubbles: true,
cancelable: true,
})

target.dispatchEvent(event)
setTimeout(() => {
expect(target.className.includes('transition-opacity')).to.equal(true)
}, 10)
setTimeout(() => {
expect(target.className.includes('hidden')).to.equal(true)
done()
}, 101)

await nextFrame()
await nextFrame()
expect(target.className.includes('transition-opacity')).to.equal(true)

await nextFrame()
expect(target.className.includes('hidden')).to.equal(true)
expect(target.className.includes('transition-opacity')).to.not.equal(true)
})
})
})
2 changes: 2 additions & 0 deletions test/toggle_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ describe('ToggleController', () => {
await nextFrame()
action.click()
await nextFrame()
await nextFrame()
await nextFrame()

expect(target.className.includes('class1')).to.equal(true)
expect(target.className.includes('class2')).to.equal(true)
Expand Down
146 changes: 146 additions & 0 deletions test/transition_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { html, fixture, expect, nextFrame, aTimeout } from '@open-wc/testing'
import { enter, leave, cancelTransition } from '../src/transition'
import { Application } from '@hotwired/stimulus'
import Popover from '../src/popover'

describe('Transition', () => {
beforeEach(async () => {
await fixture(html`
<div class="inline-block relative cursor-pointer" data-controller="popover" data-action="mouseenter->popover#show mouseleave->popover#hide">
<span class="underline">Hover me</span>
<div class="foo"
data-popover-target="content"
data-transition-enter="transition-opacity ease-in-out duration-100"
data-transition-enter-from="opacity-0"
data-transition-enter-to="opacity-100"
data-transition-leave="transition-opacity ease-in-out duration-100"
data-transition-leave-from="opacity-100"
data-transition-leave-to="opacity-0"
>
This popover shows on hover
</div>
</div>
`)

const application = Application.start()
application.register('popover', Popover)
})

it('should clean up after a completed transition', async () => {
const target = document.querySelector('[data-popover-target="content"]')

await enter(target, {})

expect(target._stimulus_transition).to.be.null
expect(target.className.includes('hidden')).to.be.false

await leave(target, {})

expect(target.className.split(' ')).to.have.members(['foo', 'hidden', 'opacity-0'])
expect(target._stimulus_transition).to.be.null
})

it('cancels a transition that is already running', async () => {
const target = document.querySelector('[data-popover-target="content"]')

enter(target)
await nextFrame()
expect(target.className.includes('hidden')).to.be.false

await leave(target, {})
expect(target.className.includes('hidden')).to.be.true
})

describe('has different stages', () => {
it('should cancel and clean up when canceled before the first stage', async () => {
const target = document.querySelector('[data-popover-target="content"]')

await leave(target)
enter(target, {})

expect(target.className.split(' ')).to.have.members(['foo', 'opacity-0', 'hidden'])

cancelTransition(target)

expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])
expect(target._stimulus_transition).to.be.null
})

it('should cancel and clean up when canceled before second stage', async () => {
const target = document.querySelector('[data-popover-target="content"]')

await leave(target)
enter(target, {})
await nextFrame()

expect(target.className.split(' ')).to.have.members(['foo', 'opacity-0', 'transition-opacity', 'ease-in-out', 'duration-100'])

cancelTransition(target)

expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])
expect(target._stimulus_transition).to.be.null
})

it('should cancel and clean up when canceled after second stage', async () => {
const target = document.querySelector('[data-popover-target="content"]')

await leave(target)
enter(target, {})
await nextFrame()
await nextFrame()

expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100', 'transition-opacity', 'ease-in-out', 'duration-100'])

cancelTransition(target)

expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])
expect(target._stimulus_transition).to.be.null
})
})

describe('leave()', () => {
it('parses, adds, and removes the transition classes correctly', async () => {
const target = document.querySelector('[data-popover-target="content"]')

await enter(target, {})
leave(target, {})
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])

await nextFrame()
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100', 'transition-opacity', 'ease-in-out', 'duration-100'])

await nextFrame()
expect(target.className.split(' ')).to.have.members(['foo', 'transition-opacity', 'ease-in-out', 'duration-100', 'opacity-0'])

await aTimeout(100)
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-0', 'hidden'])
})
})

describe('enter()', () => {
it('parses, adds, and removes the transition classes correctly', async () => {
const target = document.querySelector('[data-popover-target="content"]')

await leave(target, {})
enter(target, {})
expect(target.className.split(' ')).to.have.members(['foo', 'hidden', 'opacity-0'])

await nextFrame()
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-0', 'transition-opacity', 'ease-in-out', 'duration-100'])

await nextFrame()
expect(target.className.split(' ')).to.have.members(['foo', 'transition-opacity', 'ease-in-out', 'duration-100', 'opacity-100'])

await aTimeout(100)
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])
})
})

describe('cancelTransition()', () => {
it("doesn't error when a canceling a transition that is already finished", async () => {
const target = document.querySelector('[data-popover-target="content"]')
await enter(target, {})
expect(() => cancelTransition(target)).to.not.throw()
})
})
})