From 33211eefdfb27eff7ba21886e16f2efdc0efa3e6 Mon Sep 17 00:00:00 2001 From: Alessandro Chitolina Date: Fri, 15 Sep 2017 16:07:24 +0200 Subject: [PATCH] Rewritten modal without jquery (#23955) * Trigger jquery events if available in event handler * Rewritten modal without jquery --- js/src/carousel.js | 2 +- js/src/dom/data.js | 2 +- js/src/dom/eventHandler.js | 105 ++++++++++--- js/src/dom/manipulator.js | 20 +++ js/src/modal.js | 239 +++++++++++++++--------------- js/src/util.js | 114 +++++++++----- js/tests/index.html | 1 + js/tests/unit/button.js | 22 +-- js/tests/unit/carousel.js | 36 ++--- js/tests/unit/dom/eventHandler.js | 239 ++++++++++++++++++++++++++++++ js/tests/unit/modal.js | 49 +++--- js/tests/visual/modal.html | 3 + 12 files changed, 596 insertions(+), 236 deletions(-) create mode 100644 js/tests/unit/dom/eventHandler.js diff --git a/js/src/carousel.js b/js/src/carousel.js index 5bc83243b7..f73edf9109 100644 --- a/js/src/carousel.js +++ b/js/src/carousel.js @@ -212,7 +212,7 @@ class Carousel { } dispose() { - EventHandler.off(this._element, DATA_KEY) + EventHandler.off(this._element, EVENT_KEY) Data.removeData(this._element, DATA_KEY) this._items = null diff --git a/js/src/dom/data.js b/js/src/dom/data.js index 655706fbc3..68908d8f22 100644 --- a/js/src/dom/data.js +++ b/js/src/dom/data.js @@ -21,7 +21,7 @@ const mapData = (() => { id++ }, get(element, key) { - if (typeof element.key === 'undefined') { + if (typeof element === 'undefined' || typeof element.key === 'undefined') { return null } diff --git a/js/src/dom/eventHandler.js b/js/src/dom/eventHandler.js index 746f84bcb5..a69ab61368 100644 --- a/js/src/dom/eventHandler.js +++ b/js/src/dom/eventHandler.js @@ -63,6 +63,7 @@ if (!window.Event || typeof window.Event !== 'function') { const namespaceRegex = /[^.]*(?=\..*)\.|.*/ const stripNameRegex = /\..*/ const keyEventRegex = /^key/ +const stripUidRegex = /::\d+$/ // Events storage const eventRegistry = {} @@ -110,10 +111,10 @@ function bootstrapHandler(element, fn) { } } -function bootstrapDelegationHandler(selector, fn) { +function bootstrapDelegationHandler(element, selector, fn) { return function (event) { event = fixEvent(event) - const domElements = document.querySelectorAll(selector) + const domElements = element.querySelectorAll(selector) for (let target = event.target; target && target !== this; target = target.parentNode) { for (let i = domElements.length; i--;) { if (domElements[i] === target) { @@ -126,6 +127,26 @@ function bootstrapDelegationHandler(selector, fn) { } } +function removeHandler(element, events, typeEvent, handler) { + const uidEvent = handler.uidEvent + const fn = events[typeEvent][uidEvent] + element.removeEventListener(typeEvent, fn, fn.delegation) + delete events[typeEvent][uidEvent] +} + +function removeNamespacedHandlers(element, events, typeEvent, namespace) { + const storeElementEvent = events[typeEvent] || {} + for (const handlerKey in storeElementEvent) { + if (!Object.prototype.hasOwnProperty.call(storeElementEvent, handlerKey)) { + continue + } + + if (handlerKey.indexOf(namespace) > -1) { + removeHandler(element, events, typeEvent, storeElementEvent[handlerKey].originalHandler) + } + } +} + const EventHandler = { on(element, originalTypeEvent, handler, delegationFn) { if (typeof originalTypeEvent !== 'string' || @@ -155,7 +176,7 @@ const EventHandler = { return } - const fn = !delegation ? bootstrapHandler(element, handler) : bootstrapDelegationHandler(handler, delegationFn) + const fn = !delegation ? bootstrapHandler(element, handler) : bootstrapDelegationHandler(element, handler, delegationFn) fn.isDelegation = delegation handlers[uid] = fn originalHandler.uidEvent = uid @@ -179,43 +200,49 @@ const EventHandler = { const events = getEvent(element) let typeEvent = originalTypeEvent.replace(stripNameRegex, '') + const inNamespace = typeEvent !== originalTypeEvent const custom = customEvents[typeEvent] if (custom) { typeEvent = custom } + const isNative = nativeEvents.indexOf(typeEvent) > -1 if (!isNative) { typeEvent = originalTypeEvent } - if (typeof handler === 'undefined') { + if (typeof handler !== 'undefined') { + // Simplest case: handler is passed, remove that listener ONLY. + if (!events || !events[typeEvent]) { + return + } + + removeHandler(element, events, typeEvent, handler) + return + } + + const isNamespace = originalTypeEvent.charAt(0) === '.' + if (isNamespace) { for (const elementEvent in events) { if (!Object.prototype.hasOwnProperty.call(events, elementEvent)) { continue } - const storeElementEvent = events[elementEvent] - for (const keyHandlers in storeElementEvent) { - if (!Object.prototype.hasOwnProperty.call(storeElementEvent, keyHandlers)) { - continue - } - // delete all the namespaced listeners - if (inNamespace && keyHandlers.indexOf(originalTypeEvent) > -1) { - const handlerFn = events[elementEvent][keyHandlers] - EventHandler.off(element, elementEvent, handlerFn.originalHandler) - } - } + removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.substr(1)) } - } else { - if (!events || !events[typeEvent]) { - return + } + + const storeElementEvent = events[typeEvent] || {} + for (const keyHandlers in storeElementEvent) { + if (!Object.prototype.hasOwnProperty.call(storeElementEvent, keyHandlers)) { + continue } - const uidEvent = handler.uidEvent - const fn = events[typeEvent][uidEvent] - element.removeEventListener(typeEvent, fn, fn.delegation) - delete events[typeEvent][uidEvent] + const handlerKey = keyHandlers.replace(stripUidRegex, '') + if (!inNamespace || originalTypeEvent.indexOf(handlerKey) > -1) { + removeHandler(element, events, typeEvent, storeElementEvent[keyHandlers].originalHandler) + } } }, @@ -226,7 +253,25 @@ const EventHandler = { } const typeEvent = event.replace(stripNameRegex, '') + const inNamespace = event !== typeEvent const isNative = nativeEvents.indexOf(typeEvent) > -1 + + const $ = Util.jQuery + let jQueryEvent + + let bubbles = true + let nativeDispatch = true + let defaultPrevented = false + + if (inNamespace && typeof $ !== 'undefined') { + jQueryEvent = new $.Event(event, args) + + $(element).trigger(jQueryEvent) + bubbles = !jQueryEvent.isPropagationStopped() + nativeDispatch = !jQueryEvent.isImmediatePropagationStopped() + defaultPrevented = jQueryEvent.isDefaultPrevented() + } + let evt = null if (isNative) { @@ -234,7 +279,7 @@ const EventHandler = { evt.initEvent(typeEvent, true, true) } else { evt = new CustomEvent(event, { - bubbles: true, + bubbles, cancelable: true }) } @@ -243,7 +288,19 @@ const EventHandler = { if (typeof args !== 'undefined') { evt = Util.extend(evt, args) } - element.dispatchEvent(evt) + + if (defaultPrevented) { + evt.preventDefault() + } + + if (nativeDispatch) { + element.dispatchEvent(evt) + } + + if (evt.defaultPrevented && typeof jQueryEvent !== 'undefined') { + jQueryEvent.preventDefault() + } + return evt } } diff --git a/js/src/dom/manipulator.js b/js/src/dom/manipulator.js index c124802349..b8136dda13 100644 --- a/js/src/dom/manipulator.js +++ b/js/src/dom/manipulator.js @@ -1,3 +1,5 @@ +import Util from '../util' + /** * -------------------------------------------------------------------------- * Bootstrap (v4.0.0-beta): dom/manipulator.js @@ -18,6 +20,24 @@ const Manipulator = { return input.bsChecked || input.checked } throw new Error('INPUT parameter is not an HTMLInputElement') + }, + + setDataAttribute(element, key, value) { + const $ = Util.jQuery + if (typeof $ !== 'undefined') { + $(element).data(key, value) + } + + element.setAttribute(`data-${key.replace(/[A-Z]/g, (chr) => `-${chr.toLowerCase()}`)}`, value) + }, + + removeDataAttribute(element, key) { + const $ = Util.jQuery + if (typeof $ !== 'undefined') { + $(element).removeData(key) + } + + element.removeAttribute(`data-${key.replace(/[A-Z]/g, (chr) => `-${chr.toLowerCase()}`)}`) } } diff --git a/js/src/modal.js b/js/src/modal.js index 0668907ecb..898d1c73d7 100644 --- a/js/src/modal.js +++ b/js/src/modal.js @@ -5,7 +5,10 @@ * -------------------------------------------------------------------------- */ -import $ from 'jquery' +import Data from './dom/data' +import EventHandler from './dom/eventHandler' +import Manipulator from './dom/manipulator' +import SelectorEngine from './dom/selectorEngine' import Util from './util' /** @@ -19,7 +22,6 @@ const VERSION = '4.3.1' const DATA_KEY = 'bs.modal' const EVENT_KEY = `.${DATA_KEY}` const DATA_API_KEY = '.data-api' -const JQUERY_NO_CONFLICT = $.fn[NAME] const ESCAPE_KEYCODE = 27 // KeyboardEvent.which value for Escape (Esc) key const Default = { @@ -78,7 +80,7 @@ class Modal { constructor(element, config) { this._config = this._getConfig(config) this._element = element - this._dialog = element.querySelector(Selector.DIALOG) + this._dialog = SelectorEngine.findOne(Selector.DIALOG, element) this._backdrop = null this._isShown = false this._isBodyOverflowing = false @@ -108,16 +110,14 @@ class Modal { return } - if ($(this._element).hasClass(ClassName.FADE)) { + if (this._element.classList.contains(ClassName.FADE)) { this._isTransitioning = true } - const showEvent = $.Event(Event.SHOW, { + const showEvent = EventHandler.trigger(this._element, Event.SHOW, { relatedTarget }) - $(this._element).trigger(showEvent) - if (this._isShown || showEvent.isDefaultPrevented()) { return } @@ -132,15 +132,15 @@ class Modal { this._setEscapeEvent() this._setResizeEvent() - $(this._element).on( + EventHandler.on(this._element, Event.CLICK_DISMISS, Selector.DATA_DISMISS, (event) => this.hide(event) ) - $(this._dialog).on(Event.MOUSEDOWN_DISMISS, () => { - $(this._element).one(Event.MOUSEUP_DISMISS, (event) => { - if ($(event.target).is(this._element)) { + EventHandler.on(this._dialog, Event.MOUSEDOWN_DISMISS, () => { + EventHandler.one(this._element, Event.MOUSEUP_DISMISS, (event) => { + if (event.target === this._element) { this._ignoreBackdropClick = true } }) @@ -158,16 +158,14 @@ class Modal { return } - const hideEvent = $.Event(Event.HIDE) - - $(this._element).trigger(hideEvent) + const hideEvent = EventHandler.trigger(this._element, Event.HIDE) if (!this._isShown || hideEvent.isDefaultPrevented()) { return } this._isShown = false - const transition = $(this._element).hasClass(ClassName.FADE) + const transition = this._element.classList.contains(ClassName.FADE) if (transition) { this._isTransitioning = true @@ -176,20 +174,18 @@ class Modal { this._setEscapeEvent() this._setResizeEvent() - $(document).off(Event.FOCUSIN) + EventHandler.off(document, Event.FOCUSIN) - $(this._element).removeClass(ClassName.SHOW) + this._element.classList.remove(ClassName.SHOW) - $(this._element).off(Event.CLICK_DISMISS) - $(this._dialog).off(Event.MOUSEDOWN_DISMISS) + EventHandler.off(this._element, Event.CLICK_DISMISS) + EventHandler.off(this._dialog, Event.MOUSEDOWN_DISMISS) if (transition) { const transitionDuration = Util.getTransitionDurationFromElement(this._element) - $(this._element) - .one(Util.TRANSITION_END, (event) => this._hideModal(event)) - + EventHandler.one(this._element, Util.TRANSITION_END, (event) => this._hideModal(event)) Util.emulateTransitionEnd(this._element, transitionDuration) } else { this._hideModal() @@ -198,16 +194,16 @@ class Modal { dispose() { [window, this._element, this._dialog] - .forEach((htmlElement) => $(htmlElement).off(EVENT_KEY)) + .forEach((htmlElement) => EventHandler.off(htmlElement, EVENT_KEY)) /** * `document` has 2 events `Event.FOCUSIN` and `Event.CLICK_DATA_API` * Do not move `document` in `htmlElements` array * It will remove `Event.CLICK_DATA_API` event that should remain */ - $(document).off(Event.FOCUSIN) + EventHandler.off(document, Event.FOCUSIN) - $.removeData(this._element, DATA_KEY) + Data.removeData(this._element, DATA_KEY) this._config = null this._element = null @@ -258,46 +254,43 @@ class Modal { Util.reflow(this._element) } - $(this._element).addClass(ClassName.SHOW) + this._element.classList.add(ClassName.SHOW) if (this._config.focus) { this._enforceFocus() } - const shownEvent = $.Event(Event.SHOWN, { - relatedTarget - }) - const transitionComplete = () => { if (this._config.focus) { this._element.focus() } this._isTransitioning = false - $(this._element).trigger(shownEvent) + EventHandler.trigger(this._element, Event.SHOWN, { + relatedTarget + }) } if (transition) { const transitionDuration = Util.getTransitionDurationFromElement(this._dialog) - $(this._dialog) - .one(Util.TRANSITION_END, transitionComplete) - - Util.emulateTransitionEnd(transitionDuration) + EventHandler.one(this._dialog, Util.TRANSITION_END, transitionComplete) + Util.emulateTransitionEnd(this._dialog, transitionDuration) } else { transitionComplete() } } _enforceFocus() { - $(document) - .off(Event.FOCUSIN) // Guard against infinite focus loop - .on(Event.FOCUSIN, (event) => { - if (document !== event.target && - this._element !== event.target && - $(this._element).has(event.target).length === 0) { - this._element.focus() + if (this._isShown && this._config.keyboard) { + EventHandler.on(this._element, Event.KEYDOWN_DISMISS, (event) => { + if (event.which === ESCAPE_KEYCODE) { + event.preventDefault() + this.hide() } }) + } else if (!this._isShown) { + EventHandler.off(this._element, Event.KEYDOWN_DISMISS) + } } _setEscapeEvent() { @@ -315,9 +308,9 @@ class Modal { _setResizeEvent() { if (this._isShown) { - $(window).on(Event.RESIZE, (event) => this.handleUpdate(event)) + EventHandler.on(window, Event.RESIZE, (event) => this.handleUpdate(event)) } else { - $(window).off(Event.RESIZE) + EventHandler.off(window, Event.RESIZE) } } @@ -327,23 +320,24 @@ class Modal { this._element.removeAttribute('aria-modal') this._isTransitioning = false this._showBackdrop(() => { - $(document.body).removeClass(ClassName.OPEN) + document.body.classList.remove(ClassName.OPEN) this._resetAdjustments() this._resetScrollbar() - $(this._element).trigger(Event.HIDDEN) + EventHandler.trigger(this._element, Event.HIDDEN) }) } _removeBackdrop() { if (this._backdrop) { - $(this._backdrop).remove() + this._backdrop.parentNode.removeChild(this._backdrop) this._backdrop = null } } _showBackdrop(callback) { - const animate = $(this._element).hasClass(ClassName.FADE) - ? ClassName.FADE : '' + const animate = this._element.classList.contains(ClassName.FADE) + ? ClassName.FADE + : '' if (this._isShown && this._config.backdrop) { this._backdrop = document.createElement('div') @@ -353,9 +347,9 @@ class Modal { this._backdrop.classList.add(animate) } - $(this._backdrop).appendTo(document.body) + document.body.appendChild(this._backdrop) - $(this._element).on(Event.CLICK_DISMISS, (event) => { + EventHandler.on(this._element, Event.CLICK_DISMISS, (event) => { if (this._ignoreBackdropClick) { this._ignoreBackdropClick = false return @@ -374,7 +368,7 @@ class Modal { Util.reflow(this._backdrop) } - $(this._backdrop).addClass(ClassName.SHOW) + this._backdrop.classList.add(ClassName.SHOW) if (!callback) { return @@ -387,12 +381,10 @@ class Modal { const backdropTransitionDuration = Util.getTransitionDurationFromElement(this._backdrop) - $(this._backdrop) - .one(Util.TRANSITION_END, callback) - + EventHandler.one(this._backdrop, Util.TRANSITION_END, callback) Util.emulateTransitionEnd(backdropTransitionDuration) } else if (!this._isShown && this._backdrop) { - $(this._backdrop).removeClass(ClassName.SHOW) + this._backdrop.classList.remove(ClassName.SHOW) const callbackRemove = () => { this._removeBackdrop() @@ -401,12 +393,10 @@ class Modal { } } - if ($(this._element).hasClass(ClassName.FADE)) { + if (this._element.classList.contains(ClassName.FADE)) { const backdropTransitionDuration = Util.getTransitionDurationFromElement(this._backdrop) - $(this._backdrop) - .one(Util.TRANSITION_END, callbackRemove) - + EventHandler.one(this._backdrop, Util.TRANSITION_END, callbackRemove) Util.emulateTransitionEnd(backdropTransitionDuration) } else { callbackRemove() @@ -449,60 +439,65 @@ class Modal { if (this._isBodyOverflowing) { // Note: DOMNode.style.paddingRight returns the actual value or '' if not set // while $(DOMNode).css('padding-right') returns the calculated value or 0 if not set - const fixedContent = [].slice.call(document.querySelectorAll(Selector.FIXED_CONTENT)) - const stickyContent = [].slice.call(document.querySelectorAll(Selector.STICKY_CONTENT)) // Adjust fixed content padding - $(fixedContent).each((index, element) => { - const actualPadding = element.style.paddingRight - const calculatedPadding = $(element).css('padding-right') - $(element) - .data('padding-right', actualPadding) - .css('padding-right', `${parseFloat(calculatedPadding) + this._scrollbarWidth}px`) - }) + Util.makeArray(SelectorEngine.find(Selector.FIXED_CONTENT)) + .forEach((element) => { + const actualPadding = element.style.paddingRight + const calculatedPadding = window.getComputedStyle(element)['padding-right'] + Manipulator.setDataAttribute(element, 'padding-right', actualPadding) + element.style.paddingRight = `${parseFloat(calculatedPadding) + this._scrollbarWidth}px` + }) // Adjust sticky content margin - $(stickyContent).each((index, element) => { - const actualMargin = element.style.marginRight - const calculatedMargin = $(element).css('margin-right') - $(element) - .data('margin-right', actualMargin) - .css('margin-right', `${parseFloat(calculatedMargin) - this._scrollbarWidth}px`) - }) + Util.makeArray(SelectorEngine.find(Selector.STICKY_CONTENT)) + .forEach((element) => { + const actualMargin = element.style.marginRight + const calculatedMargin = window.getComputedStyle(element)['margin-right'] + Manipulator.setDataAttribute(element, 'margin-right', actualMargin) + element.style.marginRight = `${parseFloat(calculatedMargin) - this._scrollbarWidth}px` + }) // Adjust body padding const actualPadding = document.body.style.paddingRight - const calculatedPadding = $(document.body).css('padding-right') - $(document.body) - .data('padding-right', actualPadding) - .css('padding-right', `${parseFloat(calculatedPadding) + this._scrollbarWidth}px`) + const calculatedPadding = window.getComputedStyle(document.body)['padding-right'] + + Manipulator.setDataAttribute(document.body, 'padding-right', actualPadding) + document.body.style.paddingRight = `${parseFloat(calculatedPadding) + this._scrollbarWidth}px` } - $(document.body).addClass(ClassName.OPEN) + document.body.classList.add(ClassName.OPEN) } _resetScrollbar() { // Restore fixed content padding - const fixedContent = [].slice.call(document.querySelectorAll(Selector.FIXED_CONTENT)) - $(fixedContent).each((index, element) => { - const padding = $(element).data('padding-right') - $(element).removeData('padding-right') - element.style.paddingRight = padding ? padding : '' - }) + Util.makeArray(SelectorEngine.find(Selector.FIXED_CONTENT)) + .forEach((element) => { + const padding = Util.getDataAttribute(element, 'padding-right') + if (typeof padding !== 'undefined') { + Manipulator.removeDataAttribute(element, 'padding-right') + element.style.paddingRight = padding + } + }) - // Restore sticky content - const elements = [].slice.call(document.querySelectorAll(`${Selector.STICKY_CONTENT}`)) - $(elements).each((index, element) => { - const margin = $(element).data('margin-right') - if (typeof margin !== 'undefined') { - $(element).css('margin-right', margin).removeData('margin-right') - } - }) + // Restore sticky content and navbar-toggler margin + Util.makeArray(SelectorEngine.find(`${Selector.STICKY_CONTENT}`)) + .forEach((element) => { + const margin = Util.getDataAttribute(element, 'margin-right') + if (typeof margin !== 'undefined') { + Manipulator.removeDataAttribute(element, 'margin-right') + element.style.marginRight = margin + } + }) // Restore body padding - const padding = $(document.body).data('padding-right') - $(document.body).removeData('padding-right') - document.body.style.paddingRight = padding ? padding : '' + const padding = Util.getDataAttribute(document.body, 'padding-right') + if (typeof padding !== 'undefined') { + Manipulator.removeDataAttribute(document.body, 'padding-right') + document.body.style.paddingRight = padding + } else { + document.body.style.paddingRight = '' + } } _getScrollbarWidth() { // thx d.walsh @@ -518,16 +513,16 @@ class Modal { static _jQueryInterface(config, relatedTarget) { return this.each(function () { - let data = $(this).data(DATA_KEY) + let data = Data.getData(this, DATA_KEY) const _config = { ...Default, - ...$(this).data(), + ...Util.getDataAttributes(this), ...typeof config === 'object' && config ? config : {} } if (!data) { data = new Modal(this, _config) - $(this).data(DATA_KEY, data) + Data.setData(this, DATA_KEY, data) } if (typeof config === 'string') { @@ -548,38 +543,44 @@ class Modal { * ------------------------------------------------------------------------ */ -$(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { +EventHandler.on(document, Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { let target const selector = Util.getSelectorFromElement(this) if (selector) { - target = document.querySelector(selector) + target = SelectorEngine.findOne(selector) } - const config = $(target).data(DATA_KEY) + const config = Data.getData(target, DATA_KEY) ? 'toggle' : { - ...$(target).data(), - ...$(this).data() + ...Util.getDataAttributes(target), + ...Util.getDataAttributes(this) } if (this.tagName === 'A' || this.tagName === 'AREA') { event.preventDefault() } - const $target = $(target).one(Event.SHOW, (showEvent) => { - if (showEvent.isDefaultPrevented()) { - // Only register focus restorer if modal will actually get shown + EventHandler.one(target, Event.SHOW, (showEvent) => { + if (showEvent.defaultPrevented) { + // only register focus restorer if modal will actually get shown return } - $target.one(Event.HIDDEN, () => { - if ($(this).is(':visible')) { + EventHandler.one(target, Event.HIDDEN, () => { + if (Util.isVisible(this)) { this.focus() } }) }) - Modal._jQueryInterface.call($(target), config, this) + let data = Data.getData(target, DATA_KEY) + if (!data) { + data = new Modal(target, config) + Data.setData(target, DATA_KEY, data) + } + + data.show(this) }) /** @@ -588,11 +589,15 @@ $(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { * ------------------------------------------------------------------------ */ -$.fn[NAME] = Modal._jQueryInterface -$.fn[NAME].Constructor = Modal -$.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Modal._jQueryInterface +const $ = Util.jQuery +if (typeof $ !== 'undefined') { + const JQUERY_NO_CONFLICT = $.fn[NAME] + $.fn[NAME] = Modal._jQueryInterface + $.fn[NAME].Constructor = Modal + $.fn[NAME].noConflict = () => { + $.fn[NAME] = JQUERY_NO_CONFLICT + return Modal._jQueryInterface + } } export default Modal diff --git a/js/src/util.js b/js/src/util.js index ad147a1be7..607d50fd43 100644 --- a/js/src/util.js +++ b/js/src/util.js @@ -22,6 +22,20 @@ function toType(obj) { return {}.toString.call(obj).match(/\s([a-z]+)/i)[1].toLowerCase() } +function normalizeData(val) { + if (val === 'true') { + return true + } else if (val === 'false') { + return false + } else if (val === 'null') { + return null + } else if (val === Number(val).toString()) { + return Number(val) + } + + return val +} + const Util = { TRANSITION_END: 'bsTransitionEnd', @@ -111,60 +125,84 @@ const Util = { `but expected type "${expectedTypes}".`) } } - }, + } + }, - extend(obj1, obj2) { - for (const secondProp in obj2) { - if (Object.prototype.hasOwnProperty.call(obj2, secondProp)) { - const secondVal = obj2[secondProp] - // Is this value an object? If so, iterate over its properties, copying them over - if (secondVal && Object.prototype.toString.call(secondVal) === '[object Object]') { - obj1[secondProp] = obj1[secondProp] || {} - Util.extend(obj1[secondProp], secondVal) - } else { - obj1[secondProp] = secondVal - } + extend(obj1, ...others) { + const obj2 = others.shift() + for (const secondProp in obj2) { + if (Object.prototype.hasOwnProperty.call(obj2, secondProp)) { + const secondVal = obj2[secondProp] + // Is this value an object? If so, iterate over its properties, copying them over + if (secondVal && Object.prototype.toString.call(secondVal) === '[object Object]') { + obj1[secondProp] = obj1[secondProp] || {} + Util.extend(obj1[secondProp], secondVal) + } else { + obj1[secondProp] = secondVal } } - return obj1 - }, + } - makeArray(nodeList) { - if (typeof nodeList === 'undefined' || nodeList === null) { - return [] - } - return Array.prototype.slice.call(nodeList) - }, + if (others.length) { + this.extend(obj1, ...others) + } - getDataAttributes(element) { - if (typeof element === 'undefined' || element === null) { - return {} - } + return obj1 + }, - const attributes = {} + makeArray(nodeList) { + if (typeof nodeList === 'undefined' || nodeList === null) { + return [] + } + return Array.prototype.slice.call(nodeList) + }, + + getDataAttributes(element) { + if (typeof element === 'undefined' || element === null) { + return {} + } + + let attributes + if (Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'dataset')) { + attributes = this.extend({}, element.dataset) + } else { + attributes = {} for (let i = 0; i < element.attributes.length; i++) { const attribute = element.attributes[i] if (attribute.nodeName.indexOf('data-') !== -1) { // remove 'data-' part of the attribute name - const attributeName = attribute.nodeName.substring('data-'.length) + const attributeName = attribute.nodeName.substring('data-'.length).replace(/-./g, (str) => str.charAt(1).toUpperCase()) attributes[attributeName] = attribute.nodeValue } } - return attributes - }, + } - isVisible(element) { - if (typeof element === 'undefined' || element === null) { - return false + for (const key in attributes) { + if (!Object.prototype.hasOwnProperty.call(attributes, key)) { + continue } - if (element.style !== null && element.parentNode !== null && typeof element.parentNode.style !== 'undefined') { - return element.style.display !== 'none' - && element.parentNode.style.display !== 'none' - && element.style.visibility !== 'hidden' - } + attributes[key] = normalizeData(attributes[key]) + } + + return attributes + }, + + getDataAttribute(element, key) { + return normalizeData(element.getAttribute(`data-${key.replace(/[A-Z]/g, (chr) => `-${chr.toLowerCase()}`)}`)) + }, + + isVisible(element) { + if (typeof element === 'undefined' || element === null) { return false } + + if (element.style !== null && element.parentNode !== null && typeof element.parentNode.style !== 'undefined') { + return element.style.display !== 'none' && + element.parentNode.style.display !== 'none' && + element.style.visibility !== 'hidden' + } + return false }, findShadowRoot(element) { @@ -188,6 +226,10 @@ const Util = { } return Util.findShadowRoot(element.parentNode) + }, + + get jQuery() { + return window.$ || window.jQuery } } diff --git a/js/tests/index.html b/js/tests/index.html index b49bbfa58f..19ff53ce8a 100644 --- a/js/tests/index.html +++ b/js/tests/index.html @@ -115,6 +115,7 @@ + diff --git a/js/tests/unit/button.js b/js/tests/unit/button.js index fff6df5bc7..c162e3a9fd 100644 --- a/js/tests/unit/button.js +++ b/js/tests/unit/button.js @@ -107,17 +107,17 @@ $(function () { QUnit.test('should check for closest matching toggle', function (assert) { assert.expect(12) var groupHTML = - '
' - + ' ' - + ' ' - + ' ' - + '
' + '
' + + ' ' + + ' ' + + ' ' + + '
' var $group = $(groupHTML).appendTo('#qunit-fixture') diff --git a/js/tests/unit/carousel.js b/js/tests/unit/carousel.js index 615318ad32..fd9cf85097 100644 --- a/js/tests/unit/carousel.js +++ b/js/tests/unit/carousel.js @@ -619,13 +619,9 @@ $(function () { assert.strictEqual($template.find('.carousel-item')[1], $template.find('.active')[0], 'second item active') -<<<<<<< HEAD - $template.trigger($.Event('keydown', { + EventHandler.trigger($template[0], 'keydown', { which: 37 - })) -======= - EventHandler.trigger($template[0], 'keydown', { which: 37 }) ->>>>>>> fix unit test for carousel + }) assert.strictEqual($template.find('.carousel-item')[0], $template.find('.active')[0], 'first item active') }) @@ -651,13 +647,9 @@ $(function () { assert.strictEqual($template.find('.carousel-item')[0], $template.find('.active')[0], 'first item active') -<<<<<<< HEAD - $template.trigger($.Event('keydown', { + EventHandler.trigger($template[0], 'keydown', { which: 39 - })) -======= - EventHandler.trigger($template[0], 'keydown', { which: 39 }) ->>>>>>> fix unit test for carousel + }) assert.strictEqual($template.find('.carousel-item')[1], $template.find('.active')[0], 'second item active') }) @@ -676,24 +668,14 @@ $(function () { $template.bootstrapCarousel() var done = assert.async() -<<<<<<< HEAD - var eventArrowDown = $.Event('keydown', { - which: 40 - }) - var eventArrowUp = $.Event('keydown', { - which: 38 - }) - - $template.one('keydown', function (event) { - assert.strictEqual(event.isDefaultPrevented(), false) -======= EventHandler.one($template[0], 'keydown', function (event) { assert.strictEqual(event.defaultPrevented, false) ->>>>>>> fix unit test for carousel }) // arrow down - EventHandler.trigger($template[0], 'keydown', { which: 40 }) + EventHandler.trigger($template[0], 'keydown', { + which: 40 + }) EventHandler.one($template[0], 'keydown', function (event) { assert.strictEqual(event.defaultPrevented, false) @@ -701,7 +683,9 @@ $(function () { }) // arrow up - EventHandler.trigger($template[0], 'keydown', { which: 38 }) + EventHandler.trigger($template[0], 'keydown', { + which: 38 + }) }) QUnit.test('should support disabling the keyboard navigation', function (assert) { diff --git a/js/tests/unit/dom/eventHandler.js b/js/tests/unit/dom/eventHandler.js new file mode 100644 index 0000000000..49cc89c65f --- /dev/null +++ b/js/tests/unit/dom/eventHandler.js @@ -0,0 +1,239 @@ +$(function () { + 'use strict' + + QUnit.module('event handler') + + QUnit.test('should be defined', function (assert) { + assert.expect(1) + assert.ok(EventHandler, 'EventHandler is defined') + }) + + QUnit.test('should trigger event correctly', function (assert) { + assert.expect(1) + + var element = document.createElement('div') + element.addEventListener('foobar', function () { + assert.ok(true, 'listener called') + }) + + EventHandler.trigger(element, 'foobar') + }) + + QUnit.test('should trigger event through jQuery event system', function (assert) { + assert.expect(1) + + var element = document.createElement('div') + $(element).on('foobar', function () { + assert.ok(true, 'listener called') + }) + + EventHandler.trigger(element, 'foobar') + }) + + QUnit.test('should trigger namespaced event through jQuery event system', function (assert) { + assert.expect(2) + + var element = document.createElement('div') + $(element).on('foobar.namespace', function () { + assert.ok(true, 'first listener called') + }) + element.addEventListener('foobar.namespace', function () { + assert.ok(true, 'second listener called') + }) + + EventHandler.trigger(element, 'foobar.namespace') + }) + + QUnit.test('should mirror preventDefault', function (assert) { + assert.expect(2) + + var element = document.createElement('div') + $(element).on('foobar.namespace', function (event) { + event.preventDefault() + assert.ok(true, 'first listener called') + }) + element.addEventListener('foobar.namespace', function (event) { + assert.ok(event.defaultPrevented, 'defaultPrevented is true in second listener') + }) + + EventHandler.trigger(element, 'foobar.namespace') + }) + + QUnit.test('on should add event listener', function (assert) { + assert.expect(1) + + var element = document.createElement('div') + EventHandler.on(element, 'foobar', function () { + assert.ok(true, 'listener called') + }) + + EventHandler.trigger(element, 'foobar') + }) + + QUnit.test('on should add namespaced event listener', function (assert) { + assert.expect(1) + + var element = document.createElement('div') + EventHandler.on(element, 'foobar.namespace', function () { + assert.ok(true, 'listener called') + }) + + EventHandler.trigger(element, 'foobar.namespace') + }) + + QUnit.test('on should add native namespaced event listener', function (assert) { + assert.expect(1) + + var element = document.createElement('div') + document.body.appendChild(element) + EventHandler.on(element, 'click.namespace', function () { + assert.ok(true, 'listener called') + }) + + EventHandler.trigger(element, 'click') + document.body.removeChild(element) + }) + + QUnit.test('on should add delegated event listener', function (assert) { + assert.expect(1) + + var element = document.createElement('div') + var subelement = document.createElement('span') + element.appendChild(subelement) + + var anchor = document.createElement('a') + element.appendChild(anchor) + + EventHandler.on(element, 'click.namespace', 'a', function () { + assert.ok(true, 'listener called') + }) + + EventHandler.on(element, 'click', 'span', function () { + assert.notOk(true, 'listener should not be called') + }) + + document.body.appendChild(element) + EventHandler.trigger(anchor, 'click') + document.body.removeChild(element) + }) + + QUnit.test('one should remove the listener after the event', function (assert) { + assert.expect(1) + + var element = document.createElement('div') + EventHandler.one(element, 'foobar', function () { + assert.ok(true, 'listener called') + }) + + EventHandler.trigger(element, 'foobar') + EventHandler.trigger(element, 'foobar') + }) + + QUnit.test('off should remove a listener', function (assert) { + assert.expect(1) + + var element = document.createElement('div') + var handler = function () { + assert.ok(true, 'listener called') + } + + EventHandler.on(element, 'foobar', handler) + EventHandler.trigger(element, 'foobar') + + EventHandler.off(element, 'foobar', handler) + EventHandler.trigger(element, 'foobar') + }) + + QUnit.test('off should remove all the listeners', function (assert) { + assert.expect(2) + + var element = document.createElement('div') + + EventHandler.on(element, 'foobar', function () { + assert.ok(true, 'first listener called') + }) + EventHandler.on(element, 'foobar', function () { + assert.ok(true, 'second listener called') + }) + EventHandler.trigger(element, 'foobar') + + EventHandler.off(element, 'foobar') + EventHandler.trigger(element, 'foobar') + }) + + QUnit.test('off should remove all the namespaced listeners if namespace is passed', function (assert) { + assert.expect(2) + + var element = document.createElement('div') + + EventHandler.on(element, 'foobar.namespace', function () { + assert.ok(true, 'first listener called') + }) + EventHandler.on(element, 'foofoo.namespace', function () { + assert.ok(true, 'second listener called') + }) + EventHandler.trigger(element, 'foobar.namespace') + EventHandler.trigger(element, 'foofoo.namespace') + + EventHandler.off(element, '.namespace') + EventHandler.trigger(element, 'foobar.namespace') + EventHandler.trigger(element, 'foofoo.namespace') + }) + + QUnit.test('off should remove the namespaced listeners', function (assert) { + assert.expect(2) + + var element = document.createElement('div') + + EventHandler.on(element, 'foobar.namespace', function () { + assert.ok(true, 'first listener called') + }) + EventHandler.on(element, 'foofoo.namespace', function () { + assert.ok(true, 'second listener called') + }) + EventHandler.trigger(element, 'foobar.namespace') + + EventHandler.off(element, 'foobar.namespace') + EventHandler.trigger(element, 'foobar.namespace') + + EventHandler.trigger(element, 'foofoo.namespace') + }) + + QUnit.test('off should remove the all the namespaced listeners for native events', function (assert) { + assert.expect(2) + + var element = document.createElement('div') + document.body.appendChild(element) + + EventHandler.on(element, 'click.namespace', function () { + assert.ok(true, 'first listener called') + }) + EventHandler.on(element, 'click.namespace2', function () { + assert.ok(true, 'second listener called') + }) + EventHandler.trigger(element, 'click') + + EventHandler.off(element, 'click') + EventHandler.trigger(element, 'click') + document.body.removeChild(element) + }) + + QUnit.test('off should remove the specified namespaced listeners for native events', function (assert) { + assert.expect(3) + + var element = document.createElement('div') + document.body.appendChild(element) + + EventHandler.on(element, 'click.namespace', function () { + assert.ok(true, 'first listener called') + }) + EventHandler.on(element, 'click.namespace2', function () { + assert.ok(true, 'second listener called') + }) + EventHandler.trigger(element, 'click') + + EventHandler.off(element, 'click.namespace') + EventHandler.trigger(element, 'click') + document.body.removeChild(element) + }) +}) diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js index 9b7fb0aa66..cfac27bba2 100644 --- a/js/tests/unit/modal.js +++ b/js/tests/unit/modal.js @@ -209,9 +209,12 @@ $(function () { .on('shown.bs.modal', function () { assert.ok($('#modal-test').length, 'modal inserted into dom') assert.ok($('#modal-test').is(':visible'), 'modal visible') - $div.trigger($.Event('keydown', { - which: 27 - })) + + var evt = document.createEvent('HTMLEvents') + evt.initEvent('keydown', true, true) + evt.which = 27 + + $div[0].dispatchEvent(evt) setTimeout(function () { assert.ok(!$('#modal-test').is(':visible'), 'modal hidden') @@ -308,15 +311,19 @@ $(function () { .one('hidden.bs.modal', function () { // After one open-close cycle assert.ok(!$('#modal-test').is(':visible'), 'modal hidden') - $(this) - .one('shown.bs.modal', function () { - $('#close').trigger('click') - }) - .one('hidden.bs.modal', function () { - assert.ok(!$('#modal-test').is(':visible'), 'modal hidden') - done() - }) - .bootstrapModal('show') + + var $this = $(this) + setTimeout(function () { + $this + .one('shown.bs.modal', function () { + $('#close').trigger('click') + }) + .one('hidden.bs.modal', function () { + assert.ok(!$('#modal-test').is(':visible'), 'modal hidden') + done() + }) + .bootstrapModal('show') + }, 0) }) .bootstrapModal('show') }) @@ -595,7 +602,6 @@ $(function () { QUnit.test('should not follow link in area tag', function (assert) { assert.expect(2) - var done = assert.async() $('') .appendTo('#qunit-fixture') @@ -603,16 +609,19 @@ $(function () { $('