From 548be2ed6604ddfc8488cd4a793c6271c2caf485 Mon Sep 17 00:00:00 2001 From: GeoSot Date: Tue, 2 Mar 2021 19:10:10 +0200 Subject: [PATCH] Offcanvas as component (#29017) * Add a new offcanvas component * offcanvas.js: switch to string constants and `event.key` * Remove unneeded code * Sass optimizations * Fixes Make sure the element is hidden and not offscreen when inactive fix close icon negative margins Add content in right & bottom examples Re-fix bottom offcanvas height not to cover all viewport * Wording tweaks * update tests and offcanvas class * separate scrollbar functionality and use it in offcanvas * Update .bundlewatch.config.json * fix focus * update btn-close / fix focus on close * add aria-modal and role return focus on trigger when offcanvas is closed change body scrolling timings * move common code to reusable functions * add aria-labelledby * Replace lorem ipsum text * fix focus when offcanvas is closed * updates * revert modal, add tests for scrollbar * show backdrop by default * Update offcanvas.md * Update offcanvas CSS to better match modals - Add background-clip for borders - Move from outline to border (less clever, more consistent) - Add scss-docs in vars * Revamp offcanvas docs - Add static example to show and explain the components - Split live examples and rename them - Simplify example content - Expand docs notes elsewhere - Add sass docs * Add .offcanvas-title instead of .modal-title * Rename offcanvas example to offcanvas-navbar to reflect it's purpose * labelledby references title and not header * Add default shadow to offcanvas * enable offcanvas-body to fill all the remaining wrapper area * Be more descriptive, on Accessibility area * remove redundant classes * ensure in case of an already open offcanvas, not to open another one * bring back backdrop|scroll combinations * bring back toggling class * refactor scrollbar method, plus tests * add check if element is not full-width, according to #30621 * revert all in modal * use documentElement innerWidth * Rename classes to -start and -end Also copyedit some docs wording * omit some things on scrollbar * PASS BrowserStack tests -- IOS devices, Android devices and Browsers on Mac, hide scrollbar by default and appear it, only while scrolling. * Rename '_handleClosing' to '_addEventListeners' * change pipe usage to comma * change Data.getData to Data.get Co-authored-by: XhmikosR Co-authored-by: Martijn Cuppens Co-authored-by: Mark Otto --- .bundlewatch.config.json | 16 +- build/build-plugins.js | 3 +- js/index.esm.js | 2 + js/index.umd.js | 2 + js/src/offcanvas.js | 239 +++++++++++++ js/src/util/scrollbar.js | 70 ++++ js/tests/unit/offcanvas.spec.js | 324 ++++++++++++++++++ js/tests/unit/util/scrollbar.spec.js | 182 ++++++++++ scss/_offcanvas.scss | 77 +++++ scss/_variables.scss | 26 +- scss/bootstrap.scss | 1 + site/assets/scss/_component-examples.scss | 12 + site/content/docs/5.0/components/offcanvas.md | 256 ++++++++++++++ .../index.html | 2 +- .../offcanvas.css | 0 .../offcanvas.js | 0 site/data/examples.yml | 8 +- site/data/sidebar.yml | 1 + .../{offcanvas.png => offcanvas-navbar.png} | Bin ...fcanvas@2x.png => offcanvas-navbar@2x.png} | Bin 20 files changed, 1201 insertions(+), 20 deletions(-) create mode 100644 js/src/offcanvas.js create mode 100644 js/src/util/scrollbar.js create mode 100644 js/tests/unit/offcanvas.spec.js create mode 100644 js/tests/unit/util/scrollbar.spec.js create mode 100644 scss/_offcanvas.scss create mode 100644 site/content/docs/5.0/components/offcanvas.md rename site/content/docs/5.0/examples/{offcanvas => offcanvas-navbar}/index.html (99%) rename site/content/docs/5.0/examples/{offcanvas => offcanvas-navbar}/offcanvas.css (100%) rename site/content/docs/5.0/examples/{offcanvas => offcanvas-navbar}/offcanvas.js (100%) rename site/static/docs/5.0/assets/img/examples/{offcanvas.png => offcanvas-navbar.png} (100%) rename site/static/docs/5.0/assets/img/examples/{offcanvas@2x.png => offcanvas-navbar@2x.png} (100%) diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index a68eeb85be48..4a28264b3a99 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -26,35 +26,35 @@ }, { "path": "./dist/css/bootstrap.css", - "maxSize": "24 kB" + "maxSize": "24.25 kB" }, { "path": "./dist/css/bootstrap.min.css", - "maxSize": "22 kB" + "maxSize": "22.25 kB" }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "42 kB" + "maxSize": "43 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "22 kB" + "maxSize": "22.5 kB" }, { "path": "./dist/js/bootstrap.esm.js", - "maxSize": "27 kB" + "maxSize": "28.5 kB" }, { "path": "./dist/js/bootstrap.esm.min.js", - "maxSize": "18 kB" + "maxSize": "19 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "28 kB" + "maxSize": "29 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "15.75 kB" + "maxSize": "16.25 kB" } ], "ci": { diff --git a/build/build-plugins.js b/build/build-plugins.js index d1930b8556ed..7175df4dd562 100644 --- a/build/build-plugins.js +++ b/build/build-plugins.js @@ -35,6 +35,7 @@ const bsPlugins = { Collapse: path.resolve(__dirname, '../js/src/collapse.js'), Dropdown: path.resolve(__dirname, '../js/src/dropdown.js'), Modal: path.resolve(__dirname, '../js/src/modal.js'), + OffCanvas: path.resolve(__dirname, '../js/src/offcanvas.js'), Popover: path.resolve(__dirname, '../js/src/popover.js'), ScrollSpy: path.resolve(__dirname, '../js/src/scrollspy.js'), Tab: path.resolve(__dirname, '../js/src/tab.js'), @@ -71,7 +72,7 @@ const getConfigByPluginKey = pluginKey => { } } - if (pluginKey === 'Alert' || pluginKey === 'Tab') { + if (pluginKey === 'Alert' || pluginKey === 'Tab' || pluginKey === 'OffCanvas') { return defaultPluginConfig } diff --git a/js/index.esm.js b/js/index.esm.js index b1bbf53a8f71..c041beffe255 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -11,6 +11,7 @@ import Carousel from './src/carousel' import Collapse from './src/collapse' import Dropdown from './src/dropdown' import Modal from './src/modal' +import OffCanvas from './src/offcanvas' import Popover from './src/popover' import ScrollSpy from './src/scrollspy' import Tab from './src/tab' @@ -24,6 +25,7 @@ export { Collapse, Dropdown, Modal, + OffCanvas, Popover, ScrollSpy, Tab, diff --git a/js/index.umd.js b/js/index.umd.js index 5c44ce8d2def..a99a3be896a3 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -11,6 +11,7 @@ import Carousel from './src/carousel' import Collapse from './src/collapse' import Dropdown from './src/dropdown' import Modal from './src/modal' +import OffCanvas from './src/offcanvas' import Popover from './src/popover' import ScrollSpy from './src/scrollspy' import Tab from './src/tab' @@ -24,6 +25,7 @@ export default { Collapse, Dropdown, Modal, + OffCanvas, Popover, ScrollSpy, Tab, diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js new file mode 100644 index 000000000000..148f003e9634 --- /dev/null +++ b/js/src/offcanvas.js @@ -0,0 +1,239 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.0.0-beta2): offcanvas.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { + defineJQueryPlugin, + getElementFromSelector, + getSelectorFromElement, + getTransitionDurationFromElement, + isVisible +} 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' + +/** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + +const NAME = 'offcanvas' +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 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}` + +const EVENT_SHOW = `show${EVENT_KEY}` +const EVENT_SHOWN = `shown${EVENT_KEY}` +const EVENT_HIDE = `hide${EVENT_KEY}` +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 SELECTOR_DATA_DISMISS = '[data-bs-dismiss="offcanvas"]' +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="offcanvas"]' + +/** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + +class OffCanvas extends BaseComponent { + constructor(element) { + super(element) + + this._isShown = element.classList.contains(CLASS_NAME_SHOW) + this._bodyOptions = element.getAttribute(DATA_BODY_ACTIONS) || '' + this._addEventListeners() + } + + // Public + + toggle(relatedTarget) { + return this._isShown ? this.hide() : this.show(relatedTarget) + } + + show(relatedTarget) { + if (this._isShown) { + return + } + + const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget }) + + if (showEvent.defaultPrevented) { + return + } + + this._isShown = true + this._element.style.visibility = 'visible' + + if (this._bodyOptionsHas('backdrop') || !this._bodyOptions.length) { + document.body.classList.add(CLASS_NAME_BACKDROP_BODY) + } + + if (!this._bodyOptionsHas('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)) + } + + hide() { + if (!this._isShown) { + return + } + + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) + + if (hideEvent.defaultPrevented) { + 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) + + const completeCallback = () => { + this._element.setAttribute('aria-hidden', true) + this._element.removeAttribute('aria-modal') + this._element.removeAttribute('role') + this._element.style.visibility = 'hidden' + + if (this._bodyOptionsHas('backdrop') || !this._bodyOptions.length) { + document.body.classList.remove(CLASS_NAME_BACKDROP_BODY) + } + + if (!this._bodyOptionsHas('scroll')) { + scrollBarReset() + } + + EventHandler.trigger(this._element, EVENT_HIDDEN) + this._element.classList.remove(CLASS_NAME_TOGGLING) + } + + setTimeout(completeCallback, getTransitionDurationFromElement(this._element)) + } + + _enforceFocusOnElement(element) { + EventHandler.off(document, EVENT_FOCUSIN) // guard against infinite focus loop + EventHandler.on(document, EVENT_FOCUSIN, event => { + if (document !== event.target && + element !== event.target && + !element.contains(event.target)) { + element.focus() + } + }) + 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) { + 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 + + static jQueryInterface(config) { + return this.each(function () { + const data = Data.get(this, DATA_KEY) || new OffCanvas(this) + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config](this) + } + }) + } +} + +/** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { + const target = getElementFromSelector(this) + + if (['A', 'AREA'].includes(this.tagName)) { + event.preventDefault() + } + + if (this.disabled || this.classList.contains(CLASS_NAME_DISABLED)) { + return + } + + EventHandler.one(target, EVENT_HIDDEN, () => { + // focus on trigger when it is closed + if (isVisible(this)) { + this.focus() + } + }) + + // avoid conflict when clicking a toggler of an offcanvas, while another is open + const allReadyOpen = SelectorEngine.findOne(ACTIVE_SELECTOR) + if (allReadyOpen && allReadyOpen !== target) { + return + } + + const data = Data.get(target, DATA_KEY) || new OffCanvas(target) + data.toggle(this) +}) + +/** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + +defineJQueryPlugin(NAME, OffCanvas) + +export default OffCanvas diff --git a/js/src/util/scrollbar.js b/js/src/util/scrollbar.js new file mode 100644 index 000000000000..d2f3919e6c7a --- /dev/null +++ b/js/src/util/scrollbar.js @@ -0,0 +1,70 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.0.0-beta2): util/scrollBar.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import SelectorEngine from '../dom/selector-engine' +import Manipulator from '../dom/manipulator' + +const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed' +const SELECTOR_STICKY_CONTENT = '.sticky-top' + +const getWidth = () => { + // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes + const documentWidth = document.documentElement.clientWidth + return Math.abs(window.innerWidth - documentWidth) +} + +const hide = (width = getWidth()) => { + document.body.style.overflow = 'hidden' + _setElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight', calculatedValue => calculatedValue + width) + _setElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight', calculatedValue => calculatedValue - width) + _setElementAttributes('body', 'paddingRight', calculatedValue => calculatedValue + width) +} + +const _setElementAttributes = (selector, styleProp, callback) => { + const scrollbarWidth = getWidth() + SelectorEngine.find(selector) + .forEach(element => { + if (element !== document.body && window.innerWidth > element.clientWidth + scrollbarWidth) { + return + } + + const actualValue = element.style[styleProp] + const calculatedValue = window.getComputedStyle(element)[styleProp] + Manipulator.setDataAttribute(element, styleProp, actualValue) + element.style[styleProp] = callback(Number.parseFloat(calculatedValue)) + 'px' + }) +} + +const reset = () => { + document.body.style.overflow = 'auto' + _resetElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight') + _resetElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight') + _resetElementAttributes('body', 'paddingRight') +} + +const _resetElementAttributes = (selector, styleProp) => { + SelectorEngine.find(selector).forEach(element => { + const value = Manipulator.getDataAttribute(element, styleProp) + if (typeof value === 'undefined' && element === document.body) { + element.style.removeProperty(styleProp) + } else { + Manipulator.removeDataAttribute(element, styleProp) + element.style[styleProp] = value + } + }) +} + +const isBodyOverflowing = () => { + return getWidth() > 0 +} + +export { + getWidth, + hide, + isBodyOverflowing, + reset +} diff --git a/js/tests/unit/offcanvas.spec.js b/js/tests/unit/offcanvas.spec.js new file mode 100644 index 000000000000..d07c0c61021f --- /dev/null +++ b/js/tests/unit/offcanvas.spec.js @@ -0,0 +1,324 @@ +import OffCanvas from '../../src/offcanvas' +import EventHandler from '../../src/dom/event-handler' + +/** Test helpers */ +import { clearFixture, getFixture, jQueryMock, createEvent } from '../helpers/fixture' + +describe('OffCanvas', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + document.body.classList.remove('offcanvas-open') + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(OffCanvas.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('constructor', () => { + it('should call hide when a element with data-bs-dismiss="offcanvas" is clicked', () => { + fixtureEl.innerHTML = [ + '
', + ' Close', + '
' + ].join('') + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const closeEl = fixtureEl.querySelector('a') + const offCanvas = new OffCanvas(offCanvasEl) + + spyOn(offCanvas, 'hide') + + closeEl.click() + + expect(offCanvas.hide).toHaveBeenCalled() + }) + + it('should hide if esc is pressed', () => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new OffCanvas(offCanvasEl) + const keyDownEsc = createEvent('keydown') + keyDownEsc.key = 'Escape' + + spyOn(offCanvas, 'hide') + + document.dispatchEvent(keyDownEsc) + + expect(offCanvas.hide).toHaveBeenCalled() + }) + + it('should not hide if esc is not pressed', () => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new OffCanvas(offCanvasEl) + const keydownTab = createEvent('keydown') + keydownTab.key = 'Tab' + + spyOn(offCanvas, 'hide') + + document.dispatchEvent(keydownTab) + + expect(offCanvas.hide).not.toHaveBeenCalled() + }) + }) + + describe('toggle', () => { + it('should call show method if show class is not present', () => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new OffCanvas(offCanvasEl) + + spyOn(offCanvas, 'show') + + offCanvas.toggle() + + expect(offCanvas.show).toHaveBeenCalled() + }) + + it('should call hide method if show class is present', () => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('.show') + const offCanvas = new OffCanvas(offCanvasEl) + + spyOn(offCanvas, 'hide') + + offCanvas.toggle() + + expect(offCanvas.hide).toHaveBeenCalled() + }) + }) + + describe('show', () => { + it('should do nothing if already shown', () => { + fixtureEl.innerHTML = '
' + + spyOn(EventHandler, 'trigger') + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new OffCanvas(offCanvasEl) + + offCanvas.show() + + expect(EventHandler.trigger).not.toHaveBeenCalled() + }) + + it('should show a hidden element', done => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new OffCanvas(offCanvasEl) + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(offCanvasEl.classList.contains('show')).toEqual(true) + done() + }) + + offCanvas.show() + }) + + it('should not fire shown when show is prevented', done => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new OffCanvas(offCanvasEl) + + const expectEnd = () => { + setTimeout(() => { + expect().nothing() + done() + }, 10) + } + + offCanvasEl.addEventListener('show.bs.offcanvas', e => { + e.preventDefault() + expectEnd() + }) + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + throw new Error('should not fire shown event') + }) + + offCanvas.show() + }) + }) + + describe('hide', () => { + it('should do nothing if already shown', () => { + fixtureEl.innerHTML = '
' + + spyOn(EventHandler, 'trigger') + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new OffCanvas(offCanvasEl) + + offCanvas.hide() + + expect(EventHandler.trigger).not.toHaveBeenCalled() + }) + + it('should hide a shown element', done => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new OffCanvas(offCanvasEl) + + offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { + expect(offCanvasEl.classList.contains('show')).toEqual(false) + done() + }) + + offCanvas.hide() + }) + + it('should not fire hidden when hide is prevented', done => { + fixtureEl.innerHTML = '
' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new OffCanvas(offCanvasEl) + + const expectEnd = () => { + setTimeout(() => { + expect().nothing() + done() + }, 10) + } + + offCanvasEl.addEventListener('hide.bs.offcanvas', e => { + e.preventDefault() + expectEnd() + }) + + offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { + throw new Error('should not fire hidden event') + }) + + offCanvas.hide() + }) + }) + + describe('data-api', () => { + it('should not prevent event for input', done => { + fixtureEl.innerHTML = [ + '', + '
' + ].join('') + + const target = fixtureEl.querySelector('input') + const offCanvasEl = fixtureEl.querySelector('#offcanvasdiv1') + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(offCanvasEl.classList.contains('show')).toEqual(true) + expect(target.checked).toEqual(true) + done() + }) + + target.click() + }) + + it('should not call toggle on disabled elements', () => { + fixtureEl.innerHTML = [ + '', + '
' + ].join('') + + const target = fixtureEl.querySelector('a') + + spyOn(OffCanvas.prototype, 'toggle') + + target.click() + + expect(OffCanvas.prototype.toggle).not.toHaveBeenCalled() + }) + }) + + describe('jQueryInterface', () => { + it('should create an offcanvas', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.offcanvas = OffCanvas.jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.offcanvas.call(jQueryMock) + + expect(OffCanvas.getInstance(div)).toBeDefined() + }) + + it('should not re create an offcanvas', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const offCanvas = new OffCanvas(div) + + jQueryMock.fn.offcanvas = OffCanvas.jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.offcanvas.call(jQueryMock) + + expect(OffCanvas.getInstance(div)).toEqual(offCanvas) + }) + + it('should throw error on undefined method', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const action = 'undefinedMethod' + + 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 call offcanvas method', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + + spyOn(OffCanvas.prototype, 'show') + + jQueryMock.fn.offcanvas = OffCanvas.jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.offcanvas.call(jQueryMock, 'show') + expect(OffCanvas.prototype.show).toHaveBeenCalled() + }) + }) + + describe('getInstance', () => { + it('should return offcanvas instance', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const offCanvas = new OffCanvas(div) + + expect(OffCanvas.getInstance(div)).toEqual(offCanvas) + expect(OffCanvas.getInstance(div)).toBeInstanceOf(OffCanvas) + }) + + it('should return null when there is no offcanvas instance', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + + expect(OffCanvas.getInstance(div)).toEqual(null) + }) + }) +}) diff --git a/js/tests/unit/util/scrollbar.spec.js b/js/tests/unit/util/scrollbar.spec.js new file mode 100644 index 000000000000..eb344a0aa64c --- /dev/null +++ b/js/tests/unit/util/scrollbar.spec.js @@ -0,0 +1,182 @@ +import * as Scrollbar from '../../../src/util/scrollbar' +import { clearFixture, getFixture } from '../../helpers/fixture' + +describe('ScrollBar', () => { + let fixtureEl + const windowCalculations = () => { + return { + htmlClient: document.documentElement.clientWidth, + htmlOffset: document.documentElement.offsetWidth, + docClient: document.body.clientWidth, + htmlBound: document.documentElement.getBoundingClientRect().width, + bodyBound: document.body.getBoundingClientRect().width, + window: window.innerWidth, + width: Math.abs(window.innerWidth - document.documentElement.clientWidth) + } + } + + const isScrollBarHidden = () => { // IOS devices, Android devices and Browsers on Mac, hide scrollbar by default and appear it, only while scrolling. So the tests for scrollbar would fail + const calc = windowCalculations() + return calc.htmlClient === calc.htmlOffset && calc.htmlClient === calc.window + } + + beforeAll(() => { + fixtureEl = getFixture() + // custom fixture to avoid extreme style values + fixtureEl.removeAttribute('style') + }) + + afterAll(() => { + fixtureEl.remove() + document.documentElement.style.overflowY = 'auto' + document.body.style.overflowY = 'auto' + }) + + afterEach(() => { + clearFixture() + document.documentElement.removeAttribute('style') + }) + + beforeEach(() => { + document.documentElement.removeAttribute('style') + }) + + describe('isBodyOverflowing', () => { + it('should return true if body is overflowing', () => { + document.documentElement.style.overflowY = 'scroll' + document.body.style.overflowY = 'scroll' + fixtureEl.innerHTML = [ + '
' + ].join('') + const result = Scrollbar.isBodyOverflowing() + + if (isScrollBarHidden()) { + expect(result).toEqual(false) + } else { + expect(result).toEqual(true) + } + }) + + it('should return false if body is overflowing', () => { + document.documentElement.style.overflowY = 'hidden' + document.body.style.overflowY = 'hidden' + fixtureEl.innerHTML = [ + '
' + ].join('') + const result = Scrollbar.isBodyOverflowing() + + expect(result).toEqual(false) + }) + }) + + describe('getWidth', () => { + it('should return an integer greater than zero, if body is overflowing', () => { + document.documentElement.style.overflowY = 'scroll' + document.body.style.overflowY = 'scroll' + fixtureEl.innerHTML = [ + '
' + ].join('') + const result = Scrollbar.getWidth() + + if (isScrollBarHidden()) { + expect(result).toBe(0) + } else { + expect(result).toBeGreaterThan(1) + } + }) + + it('should return 0 if body is not overflowing', () => { + document.documentElement.style.overflowY = 'hidden' + document.body.style.overflowY = 'hidden' + fixtureEl.innerHTML = [ + '
' + ].join('') + + const result = Scrollbar.getWidth() + + expect(result).toEqual(0) + }) + }) + + describe('hide - reset', () => { + it('should adjust the inline padding of fixed elements which are full-width', done => { + fixtureEl.innerHTML = [ + '
' + + '
', + '
', + '
' + ].join('') + document.documentElement.style.overflowY = 'scroll' + + const fixedEl = fixtureEl.querySelector('#fixed1') + const fixedEl2 = fixtureEl.querySelector('#fixed2') + const originalPadding = Number.parseInt(window.getComputedStyle(fixedEl).paddingRight, 10) + const originalPadding2 = Number.parseInt(window.getComputedStyle(fixedEl2).paddingRight, 10) + const expectedPadding = originalPadding + Scrollbar.getWidth() + const expectedPadding2 = originalPadding2 + Scrollbar.getWidth() + + Scrollbar.hide() + + let currentPadding = Number.parseInt(window.getComputedStyle(fixedEl).paddingRight, 10) + let currentPadding2 = Number.parseInt(window.getComputedStyle(fixedEl2).paddingRight, 10) + expect(fixedEl.getAttribute('data-bs-padding-right')).toEqual('0px', 'original fixed element padding should be stored in data-bs-padding-right') + expect(fixedEl2.getAttribute('data-bs-padding-right')).toEqual('5px', 'original fixed element padding should be stored in data-bs-padding-right') + expect(currentPadding).toEqual(expectedPadding, 'fixed element padding should be adjusted while opening') + expect(currentPadding2).toEqual(expectedPadding2, 'fixed element padding should be adjusted while opening') + + Scrollbar.reset() + currentPadding = Number.parseInt(window.getComputedStyle(fixedEl).paddingRight, 10) + currentPadding2 = Number.parseInt(window.getComputedStyle(fixedEl2).paddingRight, 10) + expect(fixedEl.getAttribute('data-bs-padding-right')).toEqual(null, 'data-bs-padding-right should be cleared after closing') + expect(fixedEl2.getAttribute('data-bs-padding-right')).toEqual(null, 'data-bs-padding-right should be cleared after closing') + expect(currentPadding).toEqual(originalPadding, 'fixed element padding should be reset after closing') + expect(currentPadding2).toEqual(originalPadding2, 'fixed element padding should be reset after closing') + done() + }) + + it('should adjust the inline margin of sticky elements', done => { + fixtureEl.innerHTML = [ + '
' + + '
', + '
' + ].join('') + document.documentElement.style.overflowY = 'scroll' + + const stickyTopEl = fixtureEl.querySelector('.sticky-top') + const originalMargin = Number.parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) + const expectedMargin = originalMargin - Scrollbar.getWidth() + Scrollbar.hide() + + let currentMargin = Number.parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) + expect(stickyTopEl.getAttribute('data-bs-margin-right')).toEqual('0px', 'original sticky element margin should be stored in data-bs-margin-right') + expect(currentMargin).toEqual(expectedMargin, 'sticky element margin should be adjusted while opening') + + Scrollbar.reset() + currentMargin = Number.parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) + + expect(stickyTopEl.getAttribute('data-bs-margin-right')).toEqual(null, 'data-bs-margin-right should be cleared after closing') + expect(currentMargin).toEqual(originalMargin, 'sticky element margin should be reset after closing') + done() + }) + + it('should not adjust the inline margin and padding of sticky and fixed elements when element do not have full width', () => { + fixtureEl.innerHTML = [ + '
' + ].join('') + + const stickyTopEl = fixtureEl.querySelector('.sticky-top') + const originalMargin = Number.parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) + const originalPadding = Number.parseInt(window.getComputedStyle(stickyTopEl).paddingRight, 10) + + Scrollbar.hide() + + const currentMargin = Number.parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) + const currentPadding = Number.parseInt(window.getComputedStyle(stickyTopEl).paddingRight, 10) + + expect(currentMargin).toEqual(originalMargin, 'sticky element\'s margin should not be adjusted while opening') + expect(currentPadding).toEqual(originalPadding, 'sticky element\'s padding should not be adjusted while opening') + + Scrollbar.reset() + }) + }) +}) diff --git a/scss/_offcanvas.scss b/scss/_offcanvas.scss new file mode 100644 index 000000000000..1eeb989666d1 --- /dev/null +++ b/scss/_offcanvas.scss @@ -0,0 +1,77 @@ +.offcanvas { + position: fixed; + bottom: 0; + z-index: $zindex-offcanvas; + display: flex; + flex-direction: column; + max-width: 100%; + color: $offcanvas-color; + visibility: hidden; + background-color: $offcanvas-bg-color; + background-clip: padding-box; + outline: 0; + @include box-shadow($offcanvas-box-shadow); + @include transition(transform $offcanvas-transition-duration ease-in-out); +} + +.offcanvas-header { + display: flex; + justify-content: space-between; + padding: $offcanvas-padding-y $offcanvas-padding-x; + + .btn-close { + padding: ($offcanvas-padding-y / 2) ($offcanvas-padding-x / 2); + margin: ($offcanvas-padding-y / -2) ($offcanvas-padding-x / -2) ($offcanvas-padding-y / -2) auto; + } +} + +.offcanvas-title { + margin-bottom: 0; + line-height: $offcanvas-title-line-height; +} + +.offcanvas-body { + flex-grow: 1; + padding: $offcanvas-padding-y $offcanvas-padding-x; + overflow-y: auto; +} + +.offcanvas-start { + top: 0; + left: 0; + width: $offcanvas-horizontal-width; + border-right: $offcanvas-border-width solid $offcanvas-border-color; + transform: translateX(-100%); +} + +.offcanvas-end { + top: 0; + right: 0; + width: $offcanvas-horizontal-width; + border-left: $offcanvas-border-width solid $offcanvas-border-color; + transform: translateX(100%); +} + +.offcanvas-bottom { + right: 0; + left: 0; + height: $offcanvas-vertical-height; + max-height: 100%; + border-top: $offcanvas-border-width solid $offcanvas-border-color; + transform: translateY(100%); +} + +.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 4ffcf18edc45..2668a790cb77 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -902,10 +902,11 @@ $form-validation-states: ( $zindex-dropdown: 1000 !default; $zindex-sticky: 1020 !default; $zindex-fixed: 1030 !default; -$zindex-modal-backdrop: 1040 !default; -$zindex-modal: 1050 !default; -$zindex-popover: 1060 !default; -$zindex-tooltip: 1070 !default; +$zindex-offcanvas: 1040 !default; +$zindex-modal-backdrop: 1050 !default; +$zindex-modal: 1060 !default; +$zindex-popover: 1070 !default; +$zindex-tooltip: 1080 !default; // scss-docs-end zindex-stack @@ -1431,6 +1432,23 @@ $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%) !default // scss-docs-end close-variables +// Offcanvas + +// scss-docs-start offcanvas-variables +$offcanvas-padding-y: $modal-inner-padding !default; +$offcanvas-padding-x: $modal-inner-padding !default; +$offcanvas-horizontal-width: 400px !default; +$offcanvas-vertical-height: 30vh !default; +$offcanvas-transition-duration: .3s !default; +$offcanvas-border-color: $modal-content-border-color !default; +$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 + // Code $code-font-size: $small-font-size !default; diff --git a/scss/bootstrap.scss b/scss/bootstrap.scss index f5f411753399..2687d099154a 100644 --- a/scss/bootstrap.scss +++ b/scss/bootstrap.scss @@ -42,6 +42,7 @@ @import "popover"; @import "carousel"; @import "spinners"; +@import "offcanvas"; // Helpers @import "helpers"; diff --git a/site/assets/scss/_component-examples.scss b/site/assets/scss/_component-examples.scss index b997689ed1af..91b5bbfd3e9a 100644 --- a/site/assets/scss/_component-examples.scss +++ b/site/assets/scss/_component-examples.scss @@ -201,6 +201,18 @@ } } +.bd-example-offcanvas { + @include border-start-radius(0); + + .offcanvas { + position: static; + display: block; + height: 200px; + visibility: visible; + transform: translateX(0); + } +} + // Tooltips .tooltip-demo a { white-space: nowrap; diff --git a/site/content/docs/5.0/components/offcanvas.md b/site/content/docs/5.0/components/offcanvas.md new file mode 100644 index 000000000000..9eacfd6b40fa --- /dev/null +++ b/site/content/docs/5.0/components/offcanvas.md @@ -0,0 +1,256 @@ +--- +layout: docs +title: Offcanvas +description: Build hidden sidebars into your project for navigation, shopping carts, and more with a few classes and our JavaScript plugin. +group: components +toc: true +--- + +## How it works + +Offcanvas is a sidebar component that can be toggled via JavaScript to appear from the left, right, or bottom edge of the viewport. Buttons or anchors are used as triggers that are attached to specific elements you toggle, and `data` attributes are used to invoke our JavaScript. + +- Offcanvas shares some of the same JavaScript code as modals. Conceptually, they are quite similar, but they are separate plugins. +- Similarly, some [source Sass](#sass) variables for offcanvas's styles and dimensions are inherited from the modal's variables. +- When shown, offcanvas includes a default backdrop that can be clicked to hide the offcanvas. +- Similar to modals, only one offcanvas can be shown at a time. + +**Heads up!** Given how CSS handles animations, you cannot use `margin` or `translate` on an `.offcanvas` element. Instead, use the class as an independent wrapping element. + +{{< callout info >}} +{{< partial "callout-info-prefersreducedmotion.md" >}} +{{< /callout >}} + +## Examples + +### Offcanvas components + +Below is a _static_ offcanvas example (meaning its `position`, `display`, and `visibility` have been overridden). Offcanvas includes support for a header with a close button and an optional body class for some initial `padding`. We suggest that you include offcanvas headers with dismiss actions whenever possible, or provide an explicit dismiss action. + +{{< example class="bd-example-offcanvas p-0 bg-light" >}} +
+
+
Offcanvas
+ +
+
+ Content for the offcanvas goes here. You can place just about any Bootstrap component or custom elements here. +
+
+{{< /example >}} + +### Live demo + +Use the buttons below to show and hide an offcanvas element via JavaScript that toggles the `.show` class on an element with the `.offcanvas` class. + +- `.offcanvas` hides content (default) +- `.offcanvas.show` shows content + +You can use a link with the `href` attribute, or a button with the `data-bs-target` attribute. In both cases, the `data-bs-toggle="offcanvas"` is required. + +{{< example >}} + + Link with href + + + +
+
+
Offcanvas
+ +
+
+
+ Some text as placeholder. In real life you can have the elements you have chosen. Like, text, images, lists, etc. +
+ +
+
+{{< /example >}} + +## Placement + +There's no default placement for offcanvas components, so you must add one of the modifier classes below; + +- `.offcanvas-start` places offcanvas on the left of the viewport (shown above) +- `.offcanvas-end` places offcanvas on the right of the viewport +- `.offcanvas-bottom` places offcanvas on the bottom of the viewport + +Try the right and bottom examples out below. + +{{< example >}} + + +
+
+
Offcanvas right
+ +
+
+ ... +
+
+{{< /example >}} + +{{< example >}} + + +
+
+
Offcanvas bottom
+ +
+
+ ... +
+
+{{< /example >}} + +## Options + +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 + +{{< example >}} + + + + +
+
+
Colored with scrolling
+ +
+
+

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

+
+
+
+
+
Offcanvas with backdrop
+ +
+
+

.....

+
+
+
+
+
Backdroped with scrolling
+ +
+
+

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

+
+
+{{< /example >}} + +## Accessibility + +Since the offcanvas panel is conceptually a modal dialog, be sure to add `aria-labelledby="..."`—referencing the offcanvas title—to `.offcanvas`. Note that you don’t need to add `role="dialog"` since we already add it via JavaScript. + +## Sass + +### Variables + +{{< scss-docs name="offcanvas-variables" file="scss/_variables.scss" >}} + +## Usage + +The offcanvas plugin utilizes a few classes and attributes to handle the heavy lifting: + +- `.offcanvas` hides the content +- `.offcanvas.show` shows the content +- `.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 `