Skip to content

Commit daaf703

Browse files
authored
Refactor transitions
1 parent 08e5776 commit daaf703

File tree

7 files changed

+263
-50
lines changed

7 files changed

+263
-50
lines changed

src/alert.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default class extends Controller {
1212
enter(this.element)
1313
}, this.showDelayValue)
1414

15-
// Auto dimiss if defined
15+
// Auto dismiss if defined
1616
if (this.hasDismissAfterValue) {
1717
setTimeout(() => {
1818
this.close()

src/transition.js

Lines changed: 99 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
// transition(this.element, false)
88
export async function transition(element, state, transitionOptions = {}) {
99
if (!!state) {
10-
enter(element, transitionOptions)
10+
await enter(element, transitionOptions)
1111
} else {
12-
leave(element, transitionOptions)
12+
await leave(element, transitionOptions)
1313
}
1414
}
1515

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

30-
// Prepare transition
31-
element.classList.add(...transitionClasses.split(' '))
32-
element.classList.add(...fromClasses.split(' '))
33-
element.classList.remove(...toClasses.split(' '))
34-
element.classList.remove(...toggleClass.split(' '))
35-
36-
await nextFrame()
37-
38-
element.classList.remove(...fromClasses.split(' '))
39-
element.classList.add(...toClasses.split(' '))
40-
41-
try {
42-
await afterTransition(element)
43-
} finally {
44-
element.classList.remove(...transitionClasses.split(' '))
45-
}
29+
return performTransitions(element, {
30+
firstFrame() {
31+
element.classList.add(...transitionClasses.split(' '))
32+
element.classList.add(...fromClasses.split(' '))
33+
element.classList.remove(...toClasses.split(' '))
34+
element.classList.remove(...toggleClass.split(' '))
35+
},
36+
secondFrame() {
37+
element.classList.remove(...fromClasses.split(' '))
38+
element.classList.add(...toClasses.split(' '))
39+
},
40+
ending() {
41+
element.classList.remove(...transitionClasses.split(' '))
42+
}
43+
})
4644
}
4745

4846
export async function leave(element, transitionOptions = {}) {
4947
const transitionClasses = element.dataset.transitionLeave || transitionOptions.leave || 'leave'
50-
const fromClasses =
51-
element.dataset.transitionLeaveFrom || transitionOptions.leaveFrom || 'leave-from'
48+
const fromClasses = element.dataset.transitionLeaveFrom || transitionOptions.leaveFrom || 'leave-from'
5249
const toClasses = element.dataset.transitionLeaveTo || transitionOptions.leaveTo || 'leave-to'
5350
const toggleClass = element.dataset.toggleClass || transitionOptions.toggle || 'hidden'
5451

55-
// Prepare transition
56-
element.classList.add(...transitionClasses.split(' '))
57-
element.classList.add(...fromClasses.split(' '))
58-
element.classList.remove(...toClasses.split(' '))
59-
60-
await nextFrame()
52+
return performTransitions(element, {
53+
firstFrame() {
54+
element.classList.add(...fromClasses.split(' '))
55+
element.classList.remove(...toClasses.split(' '))
56+
element.classList.add(...transitionClasses.split(' '))
57+
},
58+
secondFrame() {
59+
element.classList.remove(...fromClasses.split(' '))
60+
element.classList.add(...toClasses.split(' '))
61+
},
62+
ending() {
63+
element.classList.remove(...transitionClasses.split(' '))
64+
element.classList.add(...toggleClass.split(' '))
65+
}
66+
})
67+
}
6168

62-
element.classList.remove(...fromClasses.split(' '))
63-
element.classList.add(...toClasses.split(' '))
69+
function setupTransition(element) {
70+
element._stimulus_transition = {
71+
timeout: null,
72+
interrupted: false
73+
}
74+
}
6475

65-
try {
66-
await afterTransition(element)
67-
} finally {
68-
element.classList.remove(...transitionClasses.split(' '))
69-
element.classList.add(...toggleClass.split(' '))
76+
export function cancelTransition(element) {
77+
if(element._stimulus_transition && element._stimulus_transition.interrupt) {
78+
element._stimulus_transition.interrupt()
7079
}
7180
}
7281

73-
function nextFrame() {
74-
return new Promise(resolve => {
82+
function performTransitions(element, transitionStages) {
83+
if (element._stimulus_transition) cancelTransition(element)
84+
85+
let interrupted, firstStageComplete, secondStageComplete
86+
setupTransition(element)
87+
88+
element._stimulus_transition.cleanup = () => {
89+
if(! firstStageComplete) transitionStages.firstFrame()
90+
if(! secondStageComplete) transitionStages.secondFrame()
91+
92+
transitionStages.ending()
93+
element._stimulus_transition = null
94+
}
95+
96+
element._stimulus_transition.interrupt = () => {
97+
interrupted = true
98+
if(element._stimulus_transition.timeout) {
99+
clearTimeout(element._stimulus_transition.timeout)
100+
}
101+
element._stimulus_transition.cleanup()
102+
}
103+
104+
return new Promise((resolve) => {
105+
if(interrupted) return
106+
75107
requestAnimationFrame(() => {
76-
requestAnimationFrame(resolve)
108+
if(interrupted) return
109+
110+
transitionStages.firstFrame()
111+
firstStageComplete = true
112+
113+
requestAnimationFrame(() => {
114+
if(interrupted) return
115+
116+
transitionStages.secondFrame()
117+
secondStageComplete = true
118+
119+
if(element._stimulus_transition) {
120+
element._stimulus_transition.timeout = setTimeout(() => {
121+
if(interrupted) {
122+
resolve()
123+
return
124+
}
125+
126+
element._stimulus_transition.cleanup()
127+
resolve()
128+
}, getAnimationDuration(element))
129+
}
130+
})
77131
})
78132
})
79133
}
80134

81-
function afterTransition(element) {
82-
return Promise.all(element.getAnimations().map(animation => animation.finished))
135+
function getAnimationDuration(element) {
136+
let duration = Number(getComputedStyle(element).transitionDuration.replace(/,.*/, '').replace('s', '')) * 1000
137+
let delay = Number(getComputedStyle(element).transitionDelay.replace(/,.*/, '').replace('s', '')) * 1000
138+
139+
if (duration === 0) duration = Number(getComputedStyle(element).animationDuration.replace('s', '')) * 1000
140+
141+
return duration + delay
83142
}

test/alert_test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ describe('AlertController', () => {
1919
await loadFixture('alerts/alert_default.html')
2020
expect(fetchElement().className.includes("hidden")).to.equal(false)
2121

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

test/dropdown_test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { html, fixture, expect, nextFrame } from '@open-wc/testing'
1+
import { fixture, expect, nextFrame } from '@open-wc/testing'
22
import { fetchFixture } from './test_helpers'
33

44
import { Application } from '@hotwired/stimulus'
@@ -19,6 +19,7 @@ describe('DropdownController', () => {
1919
const button = document.querySelector('[data-action="dropdown#toggle:stop"]')
2020
button.click()
2121
await nextFrame()
22+
await nextFrame()
2223
expect(menu.className.includes('hidden')).to.equal(false)
2324
})
2425
})

test/popover_test.js

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,25 +36,28 @@ describe('PopoverController', () => {
3636
})
3737
target.dispatchEvent(mouseover)
3838
await nextFrame()
39+
await nextFrame()
3940
expect(target.className.includes('hidden')).to.equal(false)
4041
})
4142

42-
it('mouseOut adds hidden class', (done) => {
43+
it('mouseOut adds hidden class', async () => {
4344
const target = document.querySelector('[data-popover-target="content"]')
4445
target.className.replace('hidden', '')
4546
const event = new MouseEvent('mouseleave', {
4647
view: window,
4748
bubbles: true,
4849
cancelable: true,
4950
})
51+
5052
target.dispatchEvent(event)
51-
setTimeout(() => {
52-
expect(target.className.includes('transition-opacity')).to.equal(true)
53-
}, 10)
54-
setTimeout(() => {
55-
expect(target.className.includes('hidden')).to.equal(true)
56-
done()
57-
}, 101)
53+
54+
await nextFrame()
55+
await nextFrame()
56+
expect(target.className.includes('transition-opacity')).to.equal(true)
57+
58+
await nextFrame()
59+
expect(target.className.includes('hidden')).to.equal(true)
60+
expect(target.className.includes('transition-opacity')).to.not.equal(true)
5861
})
5962
})
6063
})

test/toggle_test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ describe('ToggleController', () => {
8686
await nextFrame()
8787
action.click()
8888
await nextFrame()
89+
await nextFrame()
90+
await nextFrame()
8991

9092
expect(target.className.includes('class1')).to.equal(true)
9193
expect(target.className.includes('class2')).to.equal(true)

test/transition_test.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { html, fixture, expect, nextFrame, aTimeout } from '@open-wc/testing'
2+
import { enter, leave, cancelTransition } from '../src/transition'
3+
import { Application } from '@hotwired/stimulus'
4+
import Popover from '../src/popover'
5+
6+
describe('Transition', () => {
7+
beforeEach(async () => {
8+
await fixture(html`
9+
<div class="inline-block relative cursor-pointer" data-controller="popover" data-action="mouseenter->popover#show mouseleave->popover#hide">
10+
<span class="underline">Hover me</span>
11+
<div class="foo"
12+
data-popover-target="content"
13+
data-transition-enter="transition-opacity ease-in-out duration-100"
14+
data-transition-enter-from="opacity-0"
15+
data-transition-enter-to="opacity-100"
16+
data-transition-leave="transition-opacity ease-in-out duration-100"
17+
data-transition-leave-from="opacity-100"
18+
data-transition-leave-to="opacity-0"
19+
>
20+
This popover shows on hover
21+
</div>
22+
</div>
23+
`)
24+
25+
const application = Application.start()
26+
application.register('popover', Popover)
27+
})
28+
29+
it('should clean up after a completed transition', async () => {
30+
const target = document.querySelector('[data-popover-target="content"]')
31+
32+
await enter(target, {})
33+
34+
expect(target._stimulus_transition).to.be.null
35+
expect(target.className.includes('hidden')).to.be.false
36+
37+
await leave(target, {})
38+
39+
expect(target.className.split(' ')).to.have.members(['foo', 'hidden', 'opacity-0'])
40+
expect(target._stimulus_transition).to.be.null
41+
})
42+
43+
it('cancels a transition that is already running', async () => {
44+
const target = document.querySelector('[data-popover-target="content"]')
45+
46+
enter(target)
47+
await nextFrame()
48+
expect(target.className.includes('hidden')).to.be.false
49+
50+
await leave(target, {})
51+
expect(target.className.includes('hidden')).to.be.true
52+
})
53+
54+
describe('has different stages', () => {
55+
it('should cancel and clean up when canceled before the first stage', async () => {
56+
const target = document.querySelector('[data-popover-target="content"]')
57+
58+
await leave(target)
59+
enter(target, {})
60+
61+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-0', 'hidden'])
62+
63+
cancelTransition(target)
64+
65+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])
66+
expect(target._stimulus_transition).to.be.null
67+
})
68+
69+
it('should cancel and clean up when canceled before second stage', async () => {
70+
const target = document.querySelector('[data-popover-target="content"]')
71+
72+
await leave(target)
73+
enter(target, {})
74+
await nextFrame()
75+
76+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-0', 'transition-opacity', 'ease-in-out', 'duration-100'])
77+
78+
cancelTransition(target)
79+
80+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])
81+
expect(target._stimulus_transition).to.be.null
82+
})
83+
84+
it('should cancel and clean up when canceled after second stage', async () => {
85+
const target = document.querySelector('[data-popover-target="content"]')
86+
87+
await leave(target)
88+
enter(target, {})
89+
await nextFrame()
90+
await nextFrame()
91+
92+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100', 'transition-opacity', 'ease-in-out', 'duration-100'])
93+
94+
cancelTransition(target)
95+
96+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])
97+
expect(target._stimulus_transition).to.be.null
98+
})
99+
})
100+
101+
describe('leave()', () => {
102+
it('parses, adds, and removes the transition classes correctly', async () => {
103+
const target = document.querySelector('[data-popover-target="content"]')
104+
105+
await enter(target, {})
106+
leave(target, {})
107+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])
108+
109+
await nextFrame()
110+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100', 'transition-opacity', 'ease-in-out', 'duration-100'])
111+
112+
await nextFrame()
113+
expect(target.className.split(' ')).to.have.members(['foo', 'transition-opacity', 'ease-in-out', 'duration-100', 'opacity-0'])
114+
115+
await aTimeout(100)
116+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-0', 'hidden'])
117+
})
118+
})
119+
120+
describe('enter()', () => {
121+
it('parses, adds, and removes the transition classes correctly', async () => {
122+
const target = document.querySelector('[data-popover-target="content"]')
123+
124+
await leave(target, {})
125+
enter(target, {})
126+
expect(target.className.split(' ')).to.have.members(['foo', 'hidden', 'opacity-0'])
127+
128+
await nextFrame()
129+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-0', 'transition-opacity', 'ease-in-out', 'duration-100'])
130+
131+
await nextFrame()
132+
expect(target.className.split(' ')).to.have.members(['foo', 'transition-opacity', 'ease-in-out', 'duration-100', 'opacity-100'])
133+
134+
await aTimeout(100)
135+
expect(target.className.split(' ')).to.have.members(['foo', 'opacity-100'])
136+
})
137+
})
138+
139+
describe('cancelTransition()', () => {
140+
it("doesn't error when a canceling a transition that is already finished", async () => {
141+
const target = document.querySelector('[data-popover-target="content"]')
142+
await enter(target, {})
143+
expect(() => cancelTransition(target)).to.not.throw()
144+
})
145+
})
146+
})

0 commit comments

Comments
 (0)