From a9d7a62658c5d93dcba5ed5fc47d84f3ddd3e0a3 Mon Sep 17 00:00:00 2001 From: GeoSot Date: Mon, 19 Apr 2021 08:20:25 +0300 Subject: [PATCH] Use the backdrop util in offcanvas, enforcing consistency (#33545) * respect /share modal's backdrop functionality, keeping consistency * listen click events over backdrop (only) and trigger `hide()` without add/remove event tricks * achieve to hide foreign open offcanvas instances without glitches `if (allReadyOpen && allReadyOpen !== target)`, in case another is going to be open, when user clicks on trigger button --- js/src/offcanvas.js | 60 +++++++++-------- js/src/util/backdrop.js | 14 +++- js/tests/unit/offcanvas.spec.js | 100 ++++++++++++++++++++++++++-- js/tests/unit/util/backdrop.spec.js | 25 +++++++ scss/_offcanvas.scss | 11 --- scss/_variables.scss | 5 +- 6 files changed, 165 insertions(+), 50 deletions(-) diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js index 02b0b58a9191..2b6335b39181 100644 --- a/js/src/offcanvas.js +++ b/js/src/offcanvas.js @@ -7,8 +7,8 @@ import { defineJQueryPlugin, + emulateTransitionEnd, getElementFromSelector, - getSelectorFromElement, getTransitionDurationFromElement, isDisabled, isVisible, @@ -20,6 +20,7 @@ import EventHandler from './dom/event-handler' import BaseComponent from './base-component' import SelectorEngine from './dom/selector-engine' import Manipulator from './dom/manipulator' +import Backdrop from './util/backdrop' /** * ------------------------------------------------------------------------ @@ -46,11 +47,8 @@ const DefaultType = { scroll: 'boolean' } -const CLASS_NAME_BACKDROP_BODY = 'offcanvas-backdrop' const CLASS_NAME_SHOW = 'show' -const CLASS_NAME_TOGGLING = 'offcanvas-toggling' const OPEN_SELECTOR = '.offcanvas.show' -const ACTIVE_SELECTOR = `${OPEN_SELECTOR}, .${CLASS_NAME_TOGGLING}` const EVENT_SHOW = `show${EVENT_KEY}` const EVENT_SHOWN = `shown${EVENT_KEY}` @@ -59,6 +57,7 @@ const EVENT_HIDDEN = `hidden${EVENT_KEY}` const EVENT_FOCUSIN = `focusin${EVENT_KEY}` const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}` +const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}` const SELECTOR_DATA_DISMISS = '[data-bs-dismiss="offcanvas"]' const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="offcanvas"]' @@ -75,6 +74,7 @@ class Offcanvas extends BaseComponent { this._config = this._getConfig(config) this._isShown = false + this._backdrop = this._initializeBackDrop() this._addEventListeners() } @@ -108,27 +108,25 @@ class Offcanvas extends BaseComponent { this._isShown = true this._element.style.visibility = 'visible' - if (this._config.backdrop) { - document.body.classList.add(CLASS_NAME_BACKDROP_BODY) - } + this._backdrop.show() if (!this._config.scroll) { scrollBarHide() } - this._element.classList.add(CLASS_NAME_TOGGLING) this._element.removeAttribute('aria-hidden') this._element.setAttribute('aria-modal', true) this._element.setAttribute('role', 'dialog') this._element.classList.add(CLASS_NAME_SHOW) const completeCallBack = () => { - this._element.classList.remove(CLASS_NAME_TOGGLING) EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget }) this._enforceFocusOnElement(this._element) } - setTimeout(completeCallBack, getTransitionDurationFromElement(this._element)) + const transitionDuration = getTransitionDurationFromElement(this._element) + EventHandler.one(this._element, 'transitionend', completeCallBack) + emulateTransitionEnd(this._element, transitionDuration) } hide() { @@ -142,11 +140,11 @@ class Offcanvas extends BaseComponent { return } - this._element.classList.add(CLASS_NAME_TOGGLING) EventHandler.off(document, EVENT_FOCUSIN) this._element.blur() this._isShown = false this._element.classList.remove(CLASS_NAME_SHOW) + this._backdrop.hide() const completeCallback = () => { this._element.setAttribute('aria-hidden', true) @@ -154,19 +152,25 @@ class Offcanvas extends BaseComponent { this._element.removeAttribute('role') this._element.style.visibility = 'hidden' - if (this._config.backdrop) { - document.body.classList.remove(CLASS_NAME_BACKDROP_BODY) - } - if (!this._config.scroll) { scrollBarReset() } EventHandler.trigger(this._element, EVENT_HIDDEN) - this._element.classList.remove(CLASS_NAME_TOGGLING) } - setTimeout(completeCallback, getTransitionDurationFromElement(this._element)) + const transitionDuration = getTransitionDurationFromElement(this._element) + EventHandler.one(this._element, 'transitionend', completeCallback) + emulateTransitionEnd(this._element, transitionDuration) + } + + dispose() { + this._backdrop.dispose() + super.dispose() + EventHandler.off(document, EVENT_FOCUSIN) + + this._config = null + this._backdrop = null } // Private @@ -181,6 +185,15 @@ class Offcanvas extends BaseComponent { return config } + _initializeBackDrop() { + return new Backdrop({ + isVisible: this._config.backdrop, + isAnimated: true, + rootElement: this._element.parentNode, + clickCallback: () => this.hide() + }) + } + _enforceFocusOnElement(element) { EventHandler.off(document, EVENT_FOCUSIN) // guard against infinite focus loop EventHandler.on(document, EVENT_FOCUSIN, event => { @@ -196,18 +209,11 @@ class Offcanvas extends BaseComponent { _addEventListeners() { EventHandler.on(this._element, EVENT_CLICK_DISMISS, SELECTOR_DATA_DISMISS, () => this.hide()) - EventHandler.on(document, 'keydown', event => { + EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { if (this._config.keyboard && event.key === ESCAPE_KEY) { this.hide() } }) - - EventHandler.on(document, EVENT_CLICK_DATA_API, event => { - const target = SelectorEngine.findOne(getSelectorFromElement(event.target)) - if (!this._element.contains(event.target) && target !== this._element) { - this.hide() - } - }) } // Static @@ -254,9 +260,9 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( }) // avoid conflict when clicking a toggler of an offcanvas, while another is open - const allReadyOpen = SelectorEngine.findOne(ACTIVE_SELECTOR) + const allReadyOpen = SelectorEngine.findOne(OPEN_SELECTOR) if (allReadyOpen && allReadyOpen !== target) { - return + Offcanvas.getInstance(allReadyOpen).hide() } const data = Data.get(target, DATA_KEY) || new Offcanvas(target) diff --git a/js/src/util/backdrop.js b/js/src/util/backdrop.js index ab14c23fe466..a9d28bd10a9b 100644 --- a/js/src/util/backdrop.js +++ b/js/src/util/backdrop.js @@ -11,19 +11,23 @@ import { emulateTransitionEnd, execute, getTransitionDurationFromElement, reflow const Default = { isVisible: true, // if false, we use the backdrop helper without adding any element to the dom isAnimated: false, - rootElement: document.body // give the choice to place backdrop under different elements + rootElement: document.body, // give the choice to place backdrop under different elements + clickCallback: null } const DefaultType = { isVisible: 'boolean', isAnimated: 'boolean', - rootElement: 'element' + rootElement: 'element', + clickCallback: '(function|null)' } const NAME = 'backdrop' const CLASS_NAME_BACKDROP = 'modal-backdrop' const CLASS_NAME_FADE = 'fade' const CLASS_NAME_SHOW = 'show' +const EVENT_MOUSEDOWN = `mousedown.bs.${NAME}` + class Backdrop { constructor(config) { this._config = this._getConfig(config) @@ -96,6 +100,10 @@ class Backdrop { this._config.rootElement.appendChild(this._getElement()) + EventHandler.on(this._getElement(), EVENT_MOUSEDOWN, () => { + execute(this._config.clickCallback) + }) + this._isAppended = true } @@ -104,6 +112,8 @@ class Backdrop { return } + EventHandler.off(this._element, EVENT_MOUSEDOWN) + this._getElement().parentNode.removeChild(this._element) this._isAppended = false } diff --git a/js/tests/unit/offcanvas.spec.js b/js/tests/unit/offcanvas.spec.js index 0a921bc9fb4f..67831ad24967 100644 --- a/js/tests/unit/offcanvas.spec.js +++ b/js/tests/unit/offcanvas.spec.js @@ -3,6 +3,7 @@ import EventHandler from '../../src/dom/event-handler' /** Test helpers */ import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture' +import { isVisible } from '../../src/util' describe('Offcanvas', () => { let fixtureEl @@ -59,6 +60,7 @@ describe('Offcanvas', () => { closeEl.click() + expect(offCanvas._config.keyboard).toBe(true) expect(offCanvas.hide).toHaveBeenCalled() }) @@ -72,7 +74,7 @@ describe('Offcanvas', () => { spyOn(offCanvas, 'hide') - document.dispatchEvent(keyDownEsc) + offCanvasEl.dispatchEvent(keyDownEsc) expect(offCanvas.hide).toHaveBeenCalled() }) @@ -104,6 +106,7 @@ describe('Offcanvas', () => { document.dispatchEvent(keyDownEsc) + expect(offCanvas._config.keyboard).toBe(false) expect(offCanvas.hide).not.toHaveBeenCalled() }) }) @@ -119,6 +122,7 @@ describe('Offcanvas', () => { const offCanvas = new Offcanvas(offCanvasEl) expect(offCanvas._config.backdrop).toEqual(true) + expect(offCanvas._backdrop._config.isVisible).toEqual(true) expect(offCanvas._config.keyboard).toEqual(true) expect(offCanvas._config.scroll).toEqual(false) }) @@ -133,6 +137,7 @@ describe('Offcanvas', () => { const offCanvas = new Offcanvas(offCanvasEl) expect(offCanvas._config.backdrop).toEqual(false) + expect(offCanvas._backdrop._config.isVisible).toEqual(false) expect(offCanvas._config.keyboard).toEqual(false) expect(offCanvas._config.scroll).toEqual(true) }) @@ -191,6 +196,30 @@ describe('Offcanvas', () => { }) offCanvas.show() }) + + it('should hide a shown element if user click on backdrop', done => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl, { backdrop: true }) + + const clickEvent = document.createEvent('MouseEvents') + clickEvent.initEvent('mousedown', true, true) + spyOn(offCanvas._backdrop._config, 'clickCallback').and.callThrough() + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(typeof offCanvas._backdrop._config.clickCallback).toBe('function') + + offCanvas._backdrop._getElement().dispatchEvent(clickEvent) + }) + + offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { + expect(offCanvas._backdrop._config.clickCallback).toHaveBeenCalled() + done() + }) + + offCanvas.show() + }) }) describe('toggle', () => { @@ -229,14 +258,16 @@ describe('Offcanvas', () => { const offCanvasEl = fixtureEl.querySelector('div') const offCanvas = new Offcanvas(offCanvasEl) - offCanvas.show() + expect(offCanvasEl.classList.contains('show')).toBe(true) + spyOn(offCanvas._backdrop, 'show').and.callThrough() spyOn(EventHandler, 'trigger').and.callThrough() offCanvas.show() expect(EventHandler.trigger).not.toHaveBeenCalled() + expect(offCanvas._backdrop.show).not.toHaveBeenCalled() }) it('should show a hidden element', done => { @@ -244,9 +275,11 @@ describe('Offcanvas', () => { const offCanvasEl = fixtureEl.querySelector('div') const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'show').and.callThrough() offCanvasEl.addEventListener('shown.bs.offcanvas', () => { expect(offCanvasEl.classList.contains('show')).toEqual(true) + expect(offCanvas._backdrop.show).toHaveBeenCalled() done() }) @@ -258,10 +291,11 @@ describe('Offcanvas', () => { const offCanvasEl = fixtureEl.querySelector('div') const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'show').and.callThrough() const expectEnd = () => { setTimeout(() => { - expect().nothing() + expect(offCanvas._backdrop.show).not.toHaveBeenCalled() done() }, 10) } @@ -304,9 +338,10 @@ describe('Offcanvas', () => { const offCanvasEl = fixtureEl.querySelector('div') const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'hide').and.callThrough() offCanvas.hide() - + expect(offCanvas._backdrop.hide).not.toHaveBeenCalled() expect(EventHandler.trigger).not.toHaveBeenCalled() }) @@ -315,10 +350,12 @@ describe('Offcanvas', () => { const offCanvasEl = fixtureEl.querySelector('div') const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'hide').and.callThrough() offCanvas.show() offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { expect(offCanvasEl.classList.contains('show')).toEqual(false) + expect(offCanvas._backdrop.hide).toHaveBeenCalled() done() }) @@ -330,11 +367,13 @@ describe('Offcanvas', () => { const offCanvasEl = fixtureEl.querySelector('div') const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'hide').and.callThrough() + offCanvas.show() const expectEnd = () => { setTimeout(() => { - expect().nothing() + expect(offCanvas._backdrop.hide).not.toHaveBeenCalled() done() }, 10) } @@ -352,6 +391,27 @@ describe('Offcanvas', () => { }) }) + describe('dispose', () => { + it('should dispose an offcanvas', () => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl) + const backdrop = offCanvas._backdrop + spyOn(backdrop, 'dispose').and.callThrough() + + expect(Offcanvas.getInstance(offCanvasEl)).toEqual(offCanvas) + + spyOn(EventHandler, 'off') + + offCanvas.dispose() + + expect(backdrop.dispose).toHaveBeenCalled() + expect(offCanvas._backdrop).toBeNull() + expect(Offcanvas.getInstance(offCanvasEl)).toEqual(null) + }) + }) + describe('data-api', () => { it('should not prevent event for input', done => { fixtureEl.innerHTML = [ @@ -386,7 +446,7 @@ describe('Offcanvas', () => { expect(Offcanvas.prototype.toggle).not.toHaveBeenCalled() }) - it('should not call toggle if another offcanvas is open', done => { + it('should call hide first, if another offcanvas is open', done => { fixtureEl.innerHTML = [ '', '
', @@ -402,7 +462,7 @@ describe('Offcanvas', () => { trigger2.click() }) offcanvasEl1.addEventListener('hidden.bs.offcanvas', () => { - expect(Offcanvas.getInstance(offcanvasEl2)).toEqual(null) + expect(Offcanvas.getInstance(offcanvasEl2)).not.toBeNull() done() }) offcanvas1.show() @@ -431,6 +491,32 @@ describe('Offcanvas', () => { trigger.click() }) + + it('should not focus on trigger element after closing offcanvas, if it is not visible', done => { + fixtureEl.innerHTML = [ + '', + '
' + ].join('') + + const trigger = fixtureEl.querySelector('#btn') + const offcanvasEl = fixtureEl.querySelector('#offcanvas') + const offcanvas = new Offcanvas(offcanvasEl) + spyOn(trigger, 'focus') + + offcanvasEl.addEventListener('shown.bs.offcanvas', () => { + trigger.style.display = 'none' + offcanvas.hide() + }) + offcanvasEl.addEventListener('hidden.bs.offcanvas', () => { + setTimeout(() => { + expect(isVisible(trigger)).toBe(false) + expect(trigger.focus).not.toHaveBeenCalled() + done() + }, 5) + }) + + trigger.click() + }) }) describe('jQueryInterface', () => { diff --git a/js/tests/unit/util/backdrop.spec.js b/js/tests/unit/util/backdrop.spec.js index c8570f1861e2..0a20a13bc527 100644 --- a/js/tests/unit/util/backdrop.spec.js +++ b/js/tests/unit/util/backdrop.spec.js @@ -158,6 +158,31 @@ describe('Backdrop', () => { }) }) + describe('click callback', () => { + it('it should execute callback on click', done => { + const spy = jasmine.createSpy('spy') + + const instance = new Backdrop({ + isVisible: true, + isAnimated: false, + clickCallback: () => spy() + }) + const endTest = () => { + setTimeout(() => { + expect(spy).toHaveBeenCalled() + done() + }, 10) + } + + instance.show(() => { + const clickEvent = document.createEvent('MouseEvents') + clickEvent.initEvent('mousedown', true, true) + document.querySelector(CLASS_BACKDROP).dispatchEvent(clickEvent) + endTest() + }) + }) + }) + describe('animation callbacks', () => { it('if it is animated, should show and hide backdrop after counting transition duration', done => { const instance = new Backdrop({ diff --git a/scss/_offcanvas.scss b/scss/_offcanvas.scss index 5c11101f6556..f1d9945641bc 100644 --- a/scss/_offcanvas.scss +++ b/scss/_offcanvas.scss @@ -75,14 +75,3 @@ .offcanvas.show { transform: none; } - -.offcanvas-backdrop::before { - position: fixed; - top: 0; - left: 0; - z-index: $zindex-offcanvas - 1; - width: 100vw; - height: 100vh; - content: ""; - background-color: $offcanvas-body-backdrop-color; -} diff --git a/scss/_variables.scss b/scss/_variables.scss index 45b331c1525b..1e17606f41b8 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -902,8 +902,8 @@ $form-validation-states: ( $zindex-dropdown: 1000 !default; $zindex-sticky: 1020 !default; $zindex-fixed: 1030 !default; -$zindex-offcanvas: 1040 !default; -$zindex-modal-backdrop: 1050 !default; +$zindex-modal-backdrop: 1040 !default; +$zindex-offcanvas: 1050 !default; $zindex-modal: 1060 !default; $zindex-popover: 1070 !default; $zindex-tooltip: 1080 !default; @@ -1447,7 +1447,6 @@ $offcanvas-border-width: $modal-content-border-width !default; $offcanvas-title-line-height: $modal-title-line-height !default; $offcanvas-bg-color: $modal-content-bg !default; $offcanvas-color: $modal-content-color !default; -$offcanvas-body-backdrop-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity) !default; $offcanvas-box-shadow: $modal-content-box-shadow-xs !default; // scss-docs-end offcanvas-variables