Skip to content

Commit

Permalink
Refactor transitions
Browse files Browse the repository at this point in the history
  • Loading branch information
jaywhy authored Oct 22, 2024
1 parent 08e5776 commit daaf703
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 50 deletions.
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()
})
})
})

0 comments on commit daaf703

Please sign in to comment.