Skip to content

Commit b69d79a

Browse files
authored
ensure JS.show/hide/toggle work as before, fix focus (#3692)
* ensure JS.show/hide/toggle work as before, fix focus Fixes #3691. Fixes #3675. Fixes #3563. Relates to: #3657 Relates to: f06877a * try to clarify comments
1 parent 579b6ff commit b69d79a

File tree

4 files changed

+128
-31
lines changed

4 files changed

+128
-31
lines changed

assets/js/phoenix_live_view/js.js

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,21 +95,35 @@ let JS = {
9595

9696
exec_focus(e, eventType, phxEvent, view, sourceEl, el){
9797
ARIA.attemptFocus(el)
98+
// in case the JS.focus command is in a JS.show/hide/toggle chain, for show we need
99+
// to wait for JS.show to have updated the element's display property (see exec_toggle)
100+
// but that run in nested animation frames, therefore we need to use them here as well
101+
window.requestAnimationFrame(() => {
102+
window.requestAnimationFrame(() => ARIA.attemptFocus(el))
103+
})
98104
},
99105

100106
exec_focus_first(e, eventType, phxEvent, view, sourceEl, el){
101107
ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el)
108+
// if you wonder about the nested animation frames, see exec_focus
109+
window.requestAnimationFrame(() => {
110+
window.requestAnimationFrame(() => ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el))
111+
})
102112
},
103113

104114
exec_push_focus(e, eventType, phxEvent, view, sourceEl, el){
105-
window.requestAnimationFrame(() => focusStack.push(el || sourceEl))
115+
focusStack.push(el || sourceEl)
106116
},
107117

108118
exec_pop_focus(_e, _eventType, _phxEvent, _view, _sourceEl, _el){
109-
window.requestAnimationFrame(() => {
110-
const el = focusStack.pop()
111-
if(el){ el.focus() }
112-
})
119+
const el = focusStack.pop()
120+
if(el){
121+
el.focus()
122+
// if you wonder about the nested animation frames, see exec_focus
123+
window.requestAnimationFrame(() => {
124+
window.requestAnimationFrame(() => el.focus())
125+
})
126+
}
113127
},
114128

115129
exec_add_class(e, eventType, phxEvent, view, sourceEl, el, {names, transition, time, blocking}){
@@ -195,11 +209,19 @@ let JS = {
195209
if(eventType === "remove"){ return }
196210
let onStart = () => {
197211
this.addOrRemoveClasses(el, inStartClasses, outClasses.concat(outStartClasses).concat(outEndClasses))
198-
let stickyDisplay = display || this.defaultDisplay(el)
199-
DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
212+
const stickyDisplay = display || this.defaultDisplay(el)
200213
window.requestAnimationFrame(() => {
214+
// first add the starting + active class, THEN make the element visible
215+
// otherwise if we toggled the visibility earlier css animations
216+
// would flicker, as the element becomes visible before the active animation
217+
// class is set (see https://github.com/phoenixframework/phoenix_live_view/issues/3456)
201218
this.addOrRemoveClasses(el, inClasses, [])
202-
window.requestAnimationFrame(() => this.addOrRemoveClasses(el, inEndClasses, inStartClasses))
219+
// addOrRemoveClasses uses a requestAnimationFrame itself, therefore we need to move the putSticky
220+
// into the next requestAnimationFrame...
221+
window.requestAnimationFrame(() => {
222+
DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
223+
this.addOrRemoveClasses(el, inEndClasses, inStartClasses)
224+
})
203225
})
204226
}
205227
let onEnd = () => {
@@ -216,14 +238,18 @@ let JS = {
216238
}
217239
} else {
218240
if(this.isVisible(el)){
219-
el.dispatchEvent(new Event("phx:hide-start"))
220-
DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = "none")
221-
el.dispatchEvent(new Event("phx:hide-end"))
241+
window.requestAnimationFrame(() => {
242+
el.dispatchEvent(new Event("phx:hide-start"))
243+
DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = "none")
244+
el.dispatchEvent(new Event("phx:hide-end"))
245+
})
222246
} else {
223-
el.dispatchEvent(new Event("phx:show-start"))
224-
let stickyDisplay = display || this.defaultDisplay(el)
225-
DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
226-
el.dispatchEvent(new Event("phx:show-end"))
247+
window.requestAnimationFrame(() => {
248+
el.dispatchEvent(new Event("phx:show-start"))
249+
let stickyDisplay = display || this.defaultDisplay(el)
250+
DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
251+
el.dispatchEvent(new Event("phx:show-end"))
252+
})
227253
}
228254
}
229255
},

assets/test/js_test.js

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ describe("JS", () => {
1717
beforeEach(() => {
1818
global.document.body.innerHTML = ""
1919
jest.useFakeTimers()
20+
setStartSystemTime()
2021
})
2122

2223
afterEach(() => {
@@ -196,11 +197,10 @@ describe("JS", () => {
196197
done()
197198
})
198199

199-
test("with in and out classes", (done) => {
200-
jest.useFakeTimers()
200+
test("with in and out classes", async () => {
201201
let view = setupView(`
202202
<div id="modal">modal</div>
203-
<div id="click" phx-click='[["toggle", {"to": "#modal", "ins": [["fade-in"],[],[]], "outs": [["fade-out"],[],[]]}]]'></div>
203+
<div id="click" phx-click='[["toggle", {"to": "#modal", "ins": [["fade-in"],["fade-in-start"],["fade-in-end"]], "outs": [["fade-out"],["fade-out-start"],["fade-out-end"]]}]]'></div>
204204
`)
205205
let modal = simulateVisibility(document.querySelector("#modal"))
206206
let click = document.querySelector("#click")
@@ -217,26 +217,62 @@ describe("JS", () => {
217217
expect(modal.classList.contains("fade-out")).toBe(false)
218218
expect(modal.classList.contains("fade-in")).toBe(false)
219219

220-
// toggle in
220+
// toggle out
221221
JS.exec(event, "click", click.getAttribute("phx-click"), view, click)
222-
jest.advanceTimersByTime(100)
222+
expect(hideStartCalled).toBe(true)
223+
// first tick: waiting for start classes to be set
224+
advanceTimersToNextFrame()
225+
expect(modal.classList.contains("fade-out-start")).toBe(true)
226+
expect(modal.classList.contains("fade-out")).toBe(false)
227+
// second tick: waiting for out classes to be set
228+
advanceTimersToNextFrame()
229+
expect(modal.classList.contains("fade-out-start")).toBe(true)
223230
expect(modal.classList.contains("fade-out")).toBe(true)
224-
expect(modal.classList.contains("fade-in")).toBe(false)
231+
// third tick: waiting for outEndClasses
232+
advanceTimersToNextFrame()
233+
expect(modal.classList.contains("fade-out-start")).toBe(false)
234+
expect(modal.classList.contains("fade-out")).toBe(true)
235+
expect(modal.classList.contains("fade-out-end")).toBe(true)
236+
// wait for onEnd
225237
jest.runAllTimers()
238+
advanceTimersToNextFrame()
239+
// fifth tick: display: none
240+
advanceTimersToNextFrame()
241+
expect(hideEndCalled).toBe(true)
242+
expect(modal.style.display).toEqual("none")
243+
// sixth tick, removed end classes
244+
advanceTimersToNextFrame()
245+
expect(modal.classList.contains("fade-out-start")).toBe(false)
246+
expect(modal.classList.contains("fade-out")).toBe(false)
247+
expect(modal.classList.contains("fade-out-end")).toBe(false)
226248

227-
// toggle out
249+
// toggle in
228250
JS.exec(event, "click", click.getAttribute("phx-click"), view, click)
229-
jest.advanceTimersByTime(100)
230-
expect(modal.classList.contains("fade-out")).toBe(false)
251+
expect(showStartCalled).toBe(true)
252+
// first tick: waiting for start classes to be set
253+
advanceTimersToNextFrame()
254+
expect(modal.classList.contains("fade-in-start")).toBe(true)
255+
expect(modal.classList.contains("fade-in")).toBe(false)
256+
expect(modal.style.display).toEqual("none")
257+
// second tick: waiting for in classes to be set
258+
advanceTimersToNextFrame()
259+
expect(modal.classList.contains("fade-in-start")).toBe(true)
231260
expect(modal.classList.contains("fade-in")).toBe(true)
261+
expect(modal.classList.contains("fade-in-end")).toBe(false)
262+
expect(modal.style.display).toEqual("block")
263+
// third tick: waiting for inEndClasses
264+
advanceTimersToNextFrame()
265+
expect(modal.classList.contains("fade-in-start")).toBe(false)
266+
expect(modal.classList.contains("fade-in")).toBe(true)
267+
expect(modal.classList.contains("fade-in-end")).toBe(true)
268+
// wait for onEnd
232269
jest.runAllTimers()
233-
270+
advanceTimersToNextFrame()
234271
expect(showEndCalled).toBe(true)
235-
expect(hideEndCalled).toBe(true)
236-
expect(showStartCalled).toBe(true)
237-
expect(hideStartCalled).toBe(true)
238-
239-
done()
272+
// sixth tick, removed end classes
273+
expect(modal.classList.contains("fade-in-start")).toBe(false)
274+
expect(modal.classList.contains("fade-in")).toBe(false)
275+
expect(modal.classList.contains("fade-in-end")).toBe(false)
240276
})
241277
})
242278

jest.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ module.exports = {
144144
],
145145

146146
// A list of paths to modules that run some code to configure or set up the testing framework before each test
147-
// setupFilesAfterEnv: [],
147+
setupFilesAfterEnv: [
148+
"<rootDir>/setupTestsAfterEnv.js"
149+
],
148150

149151
// The number of seconds after which a test is considered as slow and reported as such in the results.
150152
// slowTestThreshold: 5,

setupTestsAfterEnv.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// https://github.com/jestjs/jest/pull/14598#issuecomment-1748047560
2+
// TODO: remove this once jest.advanceTimersToNextFrame() is available
3+
// ensure you are using "modern" fake timers
4+
// 1. before doing anything, grab the start time `setStartSystemTime()`
5+
// 2. step through frames by using `advanceTimersToNextFrame()`
6+
7+
let startTime = null
8+
9+
/** Record the initial (mocked) system start time
10+
*
11+
* This is no longer needed once `jest.advanceTimersToNextFrame()` is available
12+
* https://github.com/jestjs/jest/pull/14598
13+
*/
14+
global.setStartSystemTime = () => {
15+
startTime = Date.now()
16+
}
17+
18+
/** Step forward a single animation frame
19+
*
20+
* This is no longer needed once `jest.advanceTimersToNextFrame()` is available
21+
* https://github.com/jestjs/jest/pull/14598
22+
*/
23+
global.advanceTimersToNextFrame = () => {
24+
if(startTime == null){
25+
throw new Error("Must call `setStartSystemTime` before using `advanceTimersToNextFrame()`")
26+
}
27+
28+
// Stealing logic from sinon fake timers
29+
// https://github.com/sinonjs/fake-timers/blob/fc312b9ce96a4ea2c7e60bb0cccd2c604b75cdbd/src/fake-timers-src.js#L1102-L1105
30+
const timePassedInFrame = (Date.now() - startTime) % 16
31+
const timeToNextFrame = 16 - timePassedInFrame
32+
jest.advanceTimersByTime(timeToNextFrame)
33+
}

0 commit comments

Comments
 (0)