0
0
mirror of https://github.com/twbs/bootstrap.git synced 2025-01-18 10:52:19 +01:00

Fix handling of transitionend events dispatched by nested elements(#33845)

Fix handling of transitionend events dispatched by nested elements
Properly handle events from nested elements

Change `emulateTransitionEnd` to `executeAfterTransition` &&
This commit is contained in:
alpadev 2021-06-03 13:44:16 +02:00 committed by GitHub
parent 071a288d39
commit 4a5029ea29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 201 additions and 88 deletions

View File

@ -7,10 +7,8 @@
import Data from './dom/data' import Data from './dom/data'
import { import {
emulateTransitionEnd, executeAfterTransition,
execute, getElement
getElement,
getTransitionDurationFromElement
} from './util/index' } from './util/index'
import EventHandler from './dom/event-handler' import EventHandler from './dom/event-handler'
@ -44,15 +42,7 @@ class BaseComponent {
} }
_queueCallback(callback, element, isAnimated = true) { _queueCallback(callback, element, isAnimated = true) {
if (!isAnimated) { executeAfterTransition(callback, element, isAnimated)
execute(callback)
return
}
const transitionDuration = getTransitionDurationFromElement(element)
EventHandler.one(element, 'transitionend', () => execute(callback))
emulateTransitionEnd(element, transitionDuration)
} }
/** Static */ /** Static */

View File

@ -7,9 +7,7 @@
import { import {
defineJQueryPlugin, defineJQueryPlugin,
emulateTransitionEnd,
getElementFromSelector, getElementFromSelector,
getTransitionDurationFromElement,
isRTL, isRTL,
isVisible, isVisible,
reflow, reflow,
@ -339,25 +337,28 @@ class Modal extends BaseComponent {
return return
} }
const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight const { classList, scrollHeight, style } = this._element
const isModalOverflowing = scrollHeight > document.documentElement.clientHeight
if (!isModalOverflowing) { // return if the following background transition hasn't yet completed
this._element.style.overflowY = 'hidden' if ((!isModalOverflowing && style.overflowY === 'hidden') || classList.contains(CLASS_NAME_STATIC)) {
return
} }
this._element.classList.add(CLASS_NAME_STATIC) if (!isModalOverflowing) {
const modalTransitionDuration = getTransitionDurationFromElement(this._dialog) style.overflowY = 'hidden'
EventHandler.off(this._element, 'transitionend') }
EventHandler.one(this._element, 'transitionend', () => {
this._element.classList.remove(CLASS_NAME_STATIC) classList.add(CLASS_NAME_STATIC)
this._queueCallback(() => {
classList.remove(CLASS_NAME_STATIC)
if (!isModalOverflowing) { if (!isModalOverflowing) {
EventHandler.one(this._element, 'transitionend', () => { this._queueCallback(() => {
this._element.style.overflowY = '' style.overflowY = ''
}) }, this._dialog)
emulateTransitionEnd(this._element, modalTransitionDuration)
} }
}) }, this._dialog)
emulateTransitionEnd(this._element, modalTransitionDuration)
this._element.focus() this._element.focus()
} }

View File

@ -6,7 +6,7 @@
*/ */
import EventHandler from '../dom/event-handler' import EventHandler from '../dom/event-handler'
import { emulateTransitionEnd, execute, getElement, getTransitionDurationFromElement, reflow, typeCheckConfig } from './index' import { execute, executeAfterTransition, getElement, reflow, typeCheckConfig } from './index'
const Default = { const Default = {
isVisible: true, // if false, we use the backdrop helper without adding any element to the dom isVisible: true, // if false, we use the backdrop helper without adding any element to the dom
@ -122,14 +122,7 @@ class Backdrop {
} }
_emulateAnimation(callback) { _emulateAnimation(callback) {
if (!this._config.isAnimated) { executeAfterTransition(callback, this._getElement(), this._config.isAnimated)
execute(callback)
return
}
const backdropTransitionDuration = getTransitionDurationFromElement(this._getElement())
EventHandler.one(this._getElement(), 'transitionend', () => execute(callback))
emulateTransitionEnd(this._getElement(), backdropTransitionDuration)
} }
} }

View File

@ -126,24 +126,6 @@ const getElement = obj => {
return null return null
} }
const emulateTransitionEnd = (element, duration) => {
let called = false
const durationPadding = 5
const emulatedDuration = duration + durationPadding
function listener() {
called = true
element.removeEventListener(TRANSITION_END, listener)
}
element.addEventListener(TRANSITION_END, listener)
setTimeout(() => {
if (!called) {
triggerTransitionEnd(element)
}
}, emulatedDuration)
}
const typeCheckConfig = (componentName, config, configTypes) => { const typeCheckConfig = (componentName, config, configTypes) => {
Object.keys(configTypes).forEach(property => { Object.keys(configTypes).forEach(property => {
const expectedTypes = configTypes[property] const expectedTypes = configTypes[property]
@ -252,6 +234,35 @@ const execute = callback => {
} }
} }
const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => {
if (!waitForTransition) {
execute(callback)
return
}
const durationPadding = 5
const emulatedDuration = getTransitionDurationFromElement(transitionElement) + durationPadding
let called = false
const handler = ({ target }) => {
if (target !== transitionElement) {
return
}
called = true
transitionElement.removeEventListener(TRANSITION_END, handler)
execute(callback)
}
transitionElement.addEventListener(TRANSITION_END, handler)
setTimeout(() => {
if (!called) {
triggerTransitionEnd(transitionElement)
}
}, emulatedDuration)
}
/** /**
* Return the previous/next element of a list. * Return the previous/next element of a list.
* *
@ -288,7 +299,6 @@ export {
getTransitionDurationFromElement, getTransitionDurationFromElement,
triggerTransitionEnd, triggerTransitionEnd,
isElement, isElement,
emulateTransitionEnd,
typeCheckConfig, typeCheckConfig,
isVisible, isVisible,
isDisabled, isDisabled,
@ -300,5 +310,6 @@ export {
onDOMContentLoaded, onDOMContentLoaded,
isRTL, isRTL,
defineJQueryPlugin, defineJQueryPlugin,
execute execute,
executeAfterTransition
} }

View File

@ -539,6 +539,29 @@ describe('Modal', () => {
modal.show() modal.show()
}) })
it('should not queue multiple callbacks when clicking outside of modal-content and backdrop = static', done => {
fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" style="transition-duration: 50ms;"></div></div>'
const modalEl = fixtureEl.querySelector('.modal')
const modal = new Modal(modalEl, {
backdrop: 'static'
})
modalEl.addEventListener('shown.bs.modal', () => {
const spy = spyOn(modal, '_queueCallback').and.callThrough()
modalEl.click()
modalEl.click()
setTimeout(() => {
expect(spy).toHaveBeenCalledTimes(1)
done()
}, 20)
})
modal.show()
})
it('should enforce focus', done => { it('should enforce focus', done => {
fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'

View File

@ -446,7 +446,7 @@ describe('Tooltip', () => {
const tooltip = new Tooltip(tooltipEl) const tooltip = new Tooltip(tooltipEl)
document.documentElement.ontouchstart = noop document.documentElement.ontouchstart = noop
spyOn(EventHandler, 'on') spyOn(EventHandler, 'on').and.callThrough()
tooltipEl.addEventListener('shown.bs.tooltip', () => { tooltipEl.addEventListener('shown.bs.tooltip', () => {
expect(document.querySelector('.tooltip')).not.toBeNull() expect(document.querySelector('.tooltip')).not.toBeNull()

View File

@ -157,12 +157,13 @@ describe('Util', () => {
describe('triggerTransitionEnd', () => { describe('triggerTransitionEnd', () => {
it('should trigger transitionend event', done => { it('should trigger transitionend event', done => {
fixtureEl.innerHTML = '<div style="transition: all 300ms ease-out;"></div>' fixtureEl.innerHTML = '<div></div>'
const el = fixtureEl.querySelector('div') const el = fixtureEl.querySelector('div')
const spy = spyOn(el, 'dispatchEvent').and.callThrough()
el.addEventListener('transitionend', () => { el.addEventListener('transitionend', () => {
expect().nothing() expect(spy).toHaveBeenCalled()
done() done()
}) })
@ -226,33 +227,6 @@ describe('Util', () => {
}) })
}) })
describe('emulateTransitionEnd', () => {
it('should emulate transition end', () => {
fixtureEl.innerHTML = '<div></div>'
const el = document.querySelector('div')
const spy = spyOn(window, 'setTimeout')
Util.emulateTransitionEnd(el, 10)
expect(spy).toHaveBeenCalled()
})
it('should not emulate transition end if already triggered', done => {
fixtureEl.innerHTML = '<div></div>'
const el = fixtureEl.querySelector('div')
const spy = spyOn(el, 'removeEventListener')
Util.emulateTransitionEnd(el, 10)
Util.triggerTransitionEnd(el)
setTimeout(() => {
expect(spy).toHaveBeenCalled()
done()
}, 20)
})
})
describe('typeCheckConfig', () => { describe('typeCheckConfig', () => {
const namePlugin = 'collapse' const namePlugin = 'collapse'
@ -660,6 +634,127 @@ describe('Util', () => {
}) })
}) })
describe('executeAfterTransition', () => {
it('should immediately execute a function when waitForTransition parameter is false', () => {
const el = document.createElement('div')
const callbackSpy = jasmine.createSpy('callback spy')
const eventListenerSpy = spyOn(el, 'addEventListener')
Util.executeAfterTransition(callbackSpy, el, false)
expect(callbackSpy).toHaveBeenCalled()
expect(eventListenerSpy).not.toHaveBeenCalled()
})
it('should execute a function when a transitionend event is dispatched', () => {
const el = document.createElement('div')
const callbackSpy = jasmine.createSpy('callback spy')
spyOn(window, 'getComputedStyle').and.returnValue({
transitionDuration: '0.05s',
transitionDelay: '0s'
})
Util.executeAfterTransition(callbackSpy, el)
el.dispatchEvent(new TransitionEvent('transitionend'))
expect(callbackSpy).toHaveBeenCalled()
})
it('should execute a function after a computed CSS transition duration and there was no transitionend event dispatched', done => {
const el = document.createElement('div')
const callbackSpy = jasmine.createSpy('callback spy')
spyOn(window, 'getComputedStyle').and.returnValue({
transitionDuration: '0.05s',
transitionDelay: '0s'
})
Util.executeAfterTransition(callbackSpy, el)
setTimeout(() => {
expect(callbackSpy).toHaveBeenCalled()
done()
}, 70)
})
it('should not execute a function a second time after a computed CSS transition duration and if a transitionend event has already been dispatched', done => {
const el = document.createElement('div')
const callbackSpy = jasmine.createSpy('callback spy')
spyOn(window, 'getComputedStyle').and.returnValue({
transitionDuration: '0.05s',
transitionDelay: '0s'
})
Util.executeAfterTransition(callbackSpy, el)
setTimeout(() => {
el.dispatchEvent(new TransitionEvent('transitionend'))
}, 50)
setTimeout(() => {
expect(callbackSpy).toHaveBeenCalledTimes(1)
done()
}, 70)
})
it('should not trigger a transitionend event if another transitionend event had already happened', done => {
const el = document.createElement('div')
spyOn(window, 'getComputedStyle').and.returnValue({
transitionDuration: '0.05s',
transitionDelay: '0s'
})
Util.executeAfterTransition(() => {}, el)
// simulate a event dispatched by the browser
el.dispatchEvent(new TransitionEvent('transitionend'))
const dispatchSpy = spyOn(el, 'dispatchEvent').and.callThrough()
setTimeout(() => {
// setTimeout should not have triggered another transitionend event.
expect(dispatchSpy).not.toHaveBeenCalled()
done()
}, 70)
})
it('should ignore transitionend events from nested elements', done => {
fixtureEl.innerHTML = [
'<div class="outer">',
' <div class="nested"></div>',
'</div>'
].join('')
const outer = fixtureEl.querySelector('.outer')
const nested = fixtureEl.querySelector('.nested')
const callbackSpy = jasmine.createSpy('callback spy')
spyOn(window, 'getComputedStyle').and.returnValue({
transitionDuration: '0.05s',
transitionDelay: '0s'
})
Util.executeAfterTransition(callbackSpy, outer)
nested.dispatchEvent(new TransitionEvent('transitionend', {
bubbles: true
}))
setTimeout(() => {
expect(callbackSpy).not.toHaveBeenCalled()
}, 20)
setTimeout(() => {
expect(callbackSpy).toHaveBeenCalled()
done()
}, 70)
})
})
describe('getNextActiveElement', () => { describe('getNextActiveElement', () => {
it('should return first element if active not exists or not given and shouldGetNext is either true, or false with cycling being disabled', () => { it('should return first element if active not exists or not given and shouldGetNext is either true, or false with cycling being disabled', () => {
const array = ['a', 'b', 'c', 'd'] const array = ['a', 'b', 'c', 'd']