diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js index f4927aacd68f..4b98565e2ce0 100644 --- a/js/src/offcanvas.js +++ b/js/src/offcanvas.js @@ -10,13 +10,16 @@ import { getElementFromSelector, getSelectorFromElement, getTransitionDurationFromElement, - isVisible + isDisabled, + isVisible, + typeCheckConfig } from './util/index' import { hide as scrollBarHide, reset as scrollBarReset } from './util/scrollbar' import Data from './dom/data' import EventHandler from './dom/event-handler' import BaseComponent from './base-component' import SelectorEngine from './dom/selector-engine' +import Manipulator from './dom/manipulator' /** * ------------------------------------------------------------------------ @@ -29,10 +32,20 @@ const DATA_KEY = 'bs.offcanvas' const EVENT_KEY = `.${DATA_KEY}` const DATA_API_KEY = '.data-api' const ESCAPE_KEY = 'Escape' -const DATA_BODY_ACTIONS = 'data-bs-body' + +const Default = { + backdrop: true, + keyboard: true, + scroll: false +} + +const DefaultType = { + backdrop: 'boolean', + keyboard: 'boolean', + scroll: 'boolean' +} const CLASS_NAME_BACKDROP_BODY = 'offcanvas-backdrop' -const CLASS_NAME_DISABLED = 'disabled' const CLASS_NAME_SHOW = 'show' const CLASS_NAME_TOGGLING = 'offcanvas-toggling' const ACTIVE_SELECTOR = `.offcanvas.show, .${CLASS_NAME_TOGGLING}` @@ -55,14 +68,24 @@ const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="offcanvas"]' */ class Offcanvas extends BaseComponent { - constructor(element) { + constructor(element, config) { super(element) + this._config = this._getConfig(config) this._isShown = element.classList.contains(CLASS_NAME_SHOW) - this._bodyOptions = element.getAttribute(DATA_BODY_ACTIONS) || '' this._addEventListeners() } + // Getters + + static get Default() { + return Default + } + + static get DATA_KEY() { + return DATA_KEY + } + // Public toggle(relatedTarget) { @@ -83,11 +106,11 @@ class Offcanvas extends BaseComponent { this._isShown = true this._element.style.visibility = 'visible' - if (this._bodyOptionsHas('backdrop') || !this._bodyOptions.length) { + if (this._config.backdrop) { document.body.classList.add(CLASS_NAME_BACKDROP_BODY) } - if (!this._bodyOptionsHas('scroll')) { + if (!this._config.scroll) { scrollBarHide() } @@ -129,11 +152,11 @@ class Offcanvas extends BaseComponent { this._element.removeAttribute('role') this._element.style.visibility = 'hidden' - if (this._bodyOptionsHas('backdrop') || !this._bodyOptions.length) { + if (this._config.backdrop) { document.body.classList.remove(CLASS_NAME_BACKDROP_BODY) } - if (!this._bodyOptionsHas('scroll')) { + if (!this._config.scroll) { scrollBarReset() } @@ -144,6 +167,18 @@ class Offcanvas extends BaseComponent { setTimeout(completeCallback, getTransitionDurationFromElement(this._element)) } + // Private + + _getConfig(config) { + config = { + ...Default, + ...Manipulator.getDataAttributes(this._element), + ...(typeof config === 'object' ? config : {}) + } + typeCheckConfig(NAME, config, DefaultType) + return config + } + _enforceFocusOnElement(element) { EventHandler.off(document, EVENT_FOCUSIN) // guard against infinite focus loop EventHandler.on(document, EVENT_FOCUSIN, event => { @@ -156,15 +191,11 @@ class Offcanvas extends BaseComponent { element.focus() } - _bodyOptionsHas(option) { - return this._bodyOptions.split(',').includes(option) - } - _addEventListeners() { EventHandler.on(this._element, EVENT_CLICK_DISMISS, SELECTOR_DATA_DISMISS, () => this.hide()) EventHandler.on(document, 'keydown', event => { - if (event.key === ESCAPE_KEY) { + if (this._config.keyboard && event.key === ESCAPE_KEY) { this.hide() } }) @@ -181,15 +212,17 @@ class Offcanvas extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - const data = Data.get(this, DATA_KEY) || new Offcanvas(this) + const data = Data.get(this, DATA_KEY) || new Offcanvas(this, typeof config === 'object' ? config : {}) - if (typeof config === 'string') { - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`) - } + if (typeof config !== 'string') { + return + } - data[config](this) + if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { + throw new TypeError(`No method named "${config}"`) } + + data[config](this) }) } } @@ -207,7 +240,7 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( event.preventDefault() } - if (this.disabled || this.classList.contains(CLASS_NAME_DISABLED)) { + if (isDisabled(this)) { return } @@ -225,6 +258,7 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( } const data = Data.get(target, DATA_KEY) || new Offcanvas(target) + data.toggle(this) }) diff --git a/js/src/util/index.js b/js/src/util/index.js index ae3cd2ac00a9..e268b07287a0 100644 --- a/js/src/util/index.js +++ b/js/src/util/index.js @@ -153,6 +153,22 @@ const isVisible = element => { return false } +const isDisabled = element => { + if (!element || element.nodeType !== Node.ELEMENT_NODE) { + return true + } + + if (element.classList.contains('disabled')) { + return true + } + + if (typeof element.disabled !== 'undefined') { + return element.disabled + } + + return element.getAttribute('disabled') !== 'false' +} + const findShadowRoot = element => { if (!document.documentElement.attachShadow) { return null @@ -226,6 +242,7 @@ export { emulateTransitionEnd, typeCheckConfig, isVisible, + isDisabled, findShadowRoot, noop, reflow, diff --git a/js/tests/unit/offcanvas.spec.js b/js/tests/unit/offcanvas.spec.js index 07a7cf682b33..4fb6c17ecb9c 100644 --- a/js/tests/unit/offcanvas.spec.js +++ b/js/tests/unit/offcanvas.spec.js @@ -2,7 +2,7 @@ import Offcanvas from '../../src/offcanvas' import EventHandler from '../../src/dom/event-handler' /** Test helpers */ -import { clearFixture, getFixture, jQueryMock, createEvent } from '../helpers/fixture' +import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture' describe('Offcanvas', () => { let fixtureEl @@ -22,6 +22,18 @@ describe('Offcanvas', () => { }) }) + describe('Default', () => { + it('should return plugin default config', () => { + expect(Offcanvas.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Offcanvas.DATA_KEY).toEqual('bs.offcanvas') + }) + }) + describe('constructor', () => { it('should call hide when a element with data-bs-dismiss="offcanvas" is clicked', () => { fixtureEl.innerHTML = [ @@ -70,6 +82,68 @@ describe('Offcanvas', () => { expect(offCanvas.hide).not.toHaveBeenCalled() }) + + it('should not hide if esc is pressed but with keyboard = false', () => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl, { keyboard: false }) + const keyDownEsc = createEvent('keydown') + keyDownEsc.key = 'Escape' + + spyOn(offCanvas, 'hide') + + document.dispatchEvent(keyDownEsc) + + expect(offCanvas.hide).not.toHaveBeenCalled() + }) + }) + + describe('config', () => { + it('should have default values', () => { + fixtureEl.innerHTML = [ + '
', + '
' + ].join('') + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl) + + expect(offCanvas._config.backdrop).toEqual(true) + expect(offCanvas._config.keyboard).toEqual(true) + expect(offCanvas._config.scroll).toEqual(false) + }) + + it('should read data attributes and override default config', () => { + fixtureEl.innerHTML = [ + '
', + '
' + ].join('') + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl) + + expect(offCanvas._config.backdrop).toEqual(false) + expect(offCanvas._config.keyboard).toEqual(false) + expect(offCanvas._config.scroll).toEqual(true) + }) + + it('given a config object must override data attributes', () => { + fixtureEl.innerHTML = [ + '
', + '
' + ].join('') + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl, { + backdrop: true, + keyboard: true, + scroll: false + }) + expect(offCanvas._config.backdrop).toEqual(true) + expect(offCanvas._config.keyboard).toEqual(true) + expect(offCanvas._config.scroll).toEqual(false) + }) }) describe('toggle', () => { @@ -280,6 +354,64 @@ describe('Offcanvas', () => { jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface jQueryMock.elements = [div] + expect(() => { + jQueryMock.fn.offcanvas.call(jQueryMock, action) + }).toThrowError(TypeError, `No method named "${action}"`) + }) + + it('should throw error on protected method', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const action = '_getConfig' + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + expect(() => { + jQueryMock.fn.offcanvas.call(jQueryMock, action) + }).toThrowError(TypeError, `No method named "${action}"`) + }) + + it('should throw error if method "constructor" is being called', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const action = 'constructor' + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + expect(() => { + jQueryMock.fn.offcanvas.call(jQueryMock, action) + }).toThrowError(TypeError, `No method named "${action}"`) + }) + + it('should throw error on protected method', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const action = '_getConfig' + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + try { + jQueryMock.fn.offcanvas.call(jQueryMock, action) + } catch (error) { + expect(error.message).toEqual(`No method named "${action}"`) + } + }) + + it('should throw error if method "constructor" is being called', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const action = 'constructor' + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + try { jQueryMock.fn.offcanvas.call(jQueryMock, action) } catch (error) { diff --git a/js/tests/unit/util/index.spec.js b/js/tests/unit/util/index.spec.js index 935e021dd7db..24921d730e7b 100644 --- a/js/tests/unit/util/index.spec.js +++ b/js/tests/unit/util/index.spec.js @@ -317,6 +317,111 @@ describe('Util', () => { }) }) + describe('isDisabled', () => { + it('should return true if the element is not defined', () => { + expect(Util.isDisabled(null)).toEqual(true) + expect(Util.isDisabled(undefined)).toEqual(true) + expect(Util.isDisabled()).toEqual(true) + }) + + it('should return true if the element provided is not a dom element', () => { + expect(Util.isDisabled({})).toEqual(true) + expect(Util.isDisabled('test')).toEqual(true) + }) + + it('should return true if the element has disabled attribute', () => { + fixtureEl.innerHTML = [ + '
', + '
', + '
', + '
', + '
' + ].join('') + + const div = fixtureEl.querySelector('#element') + const div1 = fixtureEl.querySelector('#element1') + const div2 = fixtureEl.querySelector('#element2') + + expect(Util.isDisabled(div)).toEqual(true) + expect(Util.isDisabled(div1)).toEqual(true) + expect(Util.isDisabled(div2)).toEqual(true) + }) + + it('should return false if the element has disabled attribute with "false" value', () => { + fixtureEl.innerHTML = [ + '
', + '
', + '
' + ].join('') + + const div = fixtureEl.querySelector('#element') + + expect(Util.isDisabled(div)).toEqual(false) + }) + + it('should return false if the element is not disabled ', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + ' ', + ' ', + '
' + ].join('') + + const el = selector => fixtureEl.querySelector(selector) + + expect(Util.isDisabled(el('#button'))).toEqual(false) + expect(Util.isDisabled(el('#select'))).toEqual(false) + expect(Util.isDisabled(el('#input'))).toEqual(false) + }) + it('should return true if the element has disabled attribute', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
' + ].join('') + + const el = selector => fixtureEl.querySelector(selector) + + expect(Util.isDisabled(el('#input'))).toEqual(true) + expect(Util.isDisabled(el('#input1'))).toEqual(true) + expect(Util.isDisabled(el('#button'))).toEqual(true) + expect(Util.isDisabled(el('#button1'))).toEqual(true) + expect(Util.isDisabled(el('#button2'))).toEqual(true) + expect(Util.isDisabled(el('#input'))).toEqual(true) + }) + + it('should return true if the element has class "disabled"', () => { + fixtureEl.innerHTML = [ + '
', + '
', + '
' + ].join('') + + const div = fixtureEl.querySelector('#element') + + expect(Util.isDisabled(div)).toEqual(true) + }) + + it('should return true if the element has class "disabled" but disabled attribute is false', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const div = fixtureEl.querySelector('#input') + + expect(Util.isDisabled(div)).toEqual(true) + }) + }) + describe('findShadowRoot', () => { it('should return null if shadow dom is not available', () => { // Only for newer browsers diff --git a/site/content/docs/5.0/components/offcanvas.md b/site/content/docs/5.0/components/offcanvas.md index 9eacfd6b40fa..347242bb9351 100644 --- a/site/content/docs/5.0/components/offcanvas.md +++ b/site/content/docs/5.0/components/offcanvas.md @@ -117,16 +117,16 @@ Try the right and bottom examples out below. {{< /example >}} -## Options +## Backdrop -By default, we disable scrolling on the `` when an offcanvas is visible and use a gray backdrop. Use the `data-bs-body` attribute to enable `` scrolling, or a combination of both options +Scrolling the `` element is disabled when an offcanvas and its backdrop are visible. Use the `data-bs-scroll` attribute to toggle `` scrolling and `data-bs-backdrop` to toggle the backdrop. {{< example >}} -
+
Colored with scrolling
@@ -135,7 +135,7 @@ By default, we disable scrolling on the `` when an offcanvas is visible an

Try scrolling the rest of the page to see this option in action.

-
+
Offcanvas with backdrop
@@ -144,7 +144,7 @@ By default, we disable scrolling on the `` when an offcanvas is visible an

.....

-
+
Backdroped with scrolling
@@ -174,9 +174,6 @@ The offcanvas plugin utilizes a few classes and attributes to handle the heavy l - `.offcanvas-start` hides the offcanvas on the left - `.offcanvas-end` hides the offcanvas on the right - `.offcanvas-bottom` hides the offcanvas on the bottom -- `data-bs-body="scroll"` enables `` scrolling when offcanvas is open -- `data-bs-body="backdrop"` disables scrolling and creates a backdrop over the `` when offcanvas is open `(default)` -- `data-bs-body="backdrop,scroll"` combines both options to enable `` scrolling and create a backdrop over the `` when offcanvas is open Add a dismiss button with the `data-bs-dismiss="offcanvas"` attribute, which triggers the JavaScript functionality. Be sure to use the `