]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Use the backdrop util in offcanvas, enforcing consistency (#33545)
authorGeoSot <geo.sotis@gmail.com>
Mon, 19 Apr 2021 05:20:25 +0000 (08:20 +0300)
committerGitHub <noreply@github.com>
Mon, 19 Apr 2021 05:20:25 +0000 (08:20 +0300)
* 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
js/src/util/backdrop.js
js/tests/unit/offcanvas.spec.js
js/tests/unit/util/backdrop.spec.js
scss/_offcanvas.scss
scss/_variables.scss

index 02b0b58a91919d88bc857b6b17e03b98ac0a106b..2b6335b39181c25cfed24bd4e2414d6644565a77 100644 (file)
@@ -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)
index ab14c23fe466a9f00ac8626cbb0d02b60bb90ff6..a9d28bd10a9b77289f06e45c023ea9ec6a0aed86 100644 (file)
@@ -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
   }
index 0a921bc9fb4ffe67fd6d63339fb205cb58a0de62..67831ad24967d4e90a53d9a30ea0f17627df8f44 100644 (file)
@@ -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 = '<div class="offcanvas"></div>'
+
+      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 = '<div class="offcanvas"></div>'
+
+      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 = [
         '<button id="btn2" data-bs-toggle="offcanvas" data-bs-target="#offcanvas2" ></button>',
         '<div id="offcanvas1" class="offcanvas"></div>',
@@ -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 = [
+        '<button id="btn" data-bs-toggle="offcanvas" data-bs-target="#offcanvas" ></button>',
+        '<div id="offcanvas" class="offcanvas"></div>'
+      ].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', () => {
index c8570f1861e2bcf19af9a18a64212e87d14d1d79..0a20a13bc527b234324f7c101b4c9617243a2127 100644 (file)
@@ -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({
index 5c11101f65565693c8eb348236db868a035690e6..f1d9945641bc9217564694d2adaf35089f6dfeed 100644 (file)
 .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;
-}
index 45b331c1525be04b86c8df27c61e9fd816605220..1e17606f41b87b96d8b7ffee074d768e92e5b19c 100644 (file)
@@ -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