From: Mark Otto Date: Tue, 16 Dec 2025 19:19:06 +0000 (-0800) Subject: Replace Modal with new Dialog component (#41917) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=a19a8fb911c2348c7e6c8c80c5fd1d3c0c3ba2ba;p=thirdparty%2Fbootstrap.git Replace Modal with new Dialog component (#41917) * Add Dialog component using native HTML dialog element New component that leverages the native HTML element for modals and non-modal dialogs with built-in backdrop and accessibility support. Features: - Modal dialogs using showModal() with automatic backdrop - Non-modal dialogs using show() for persistent UI elements - Static backdrop option (prevents close on outside click) - Keyboard support (Escape to close, focus trapping for modals) - Smooth open/close animations via CSS - Events: show, shown, hide, hidden, hidePrevented - Data API for toggling with data-bs-toggle="dialog" JavaScript: - js/src/dialog.js - Main component class - js/tests/unit/dialog.spec.js - Unit tests - js/tests/visual/dialog.html - Visual test page SCSS: - scss/_dialog.scss - Component styles Docs: - Add dialog component documentation - Update modal docs with dialog references * Modal examples now Dialog examples, needs improvement * Remove all of Modal * real words * Fix layout while I'm here * Lint Markdown * New dialog size options --- diff --git a/js/dist/modal.js b/js/dist/modal.js deleted file mode 100644 index 21fe4e0923..0000000000 --- a/js/dist/modal.js +++ /dev/null @@ -1,300 +0,0 @@ -/*! - * Bootstrap modal.js v5.3.8 (https://getbootstrap.com/) - * Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('./base-component.js'), require('./dom/event-handler.js'), require('./dom/selector-engine.js'), require('./util/backdrop.js'), require('./util/component-functions.js'), require('./util/focustrap.js'), require('./util/index.js'), require('./util/scrollbar.js')) : - typeof define === 'function' && define.amd ? define(['./base-component', './dom/event-handler', './dom/selector-engine', './util/backdrop', './util/component-functions', './util/focustrap', './util/index', './util/scrollbar'], factory) : - (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Modal = factory(global.BaseComponent, global.EventHandler, global.SelectorEngine, global.Backdrop, global.ComponentFunctions, global.Focustrap, global.Index, global.Scrollbar)); -})(this, (function (BaseComponent, EventHandler, SelectorEngine, Backdrop, componentFunctions_js, FocusTrap, index_js, ScrollBarHelper) { 'use strict'; - - /** - * -------------------------------------------------------------------------- - * Bootstrap modal.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - - - /** - * Constants - */ - - const NAME = 'modal'; - const DATA_KEY = 'bs.modal'; - const EVENT_KEY = `.${DATA_KEY}`; - const DATA_API_KEY = '.data-api'; - const ESCAPE_KEY = 'Escape'; - const EVENT_HIDE = `hide${EVENT_KEY}`; - const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`; - const EVENT_HIDDEN = `hidden${EVENT_KEY}`; - const EVENT_SHOW = `show${EVENT_KEY}`; - const EVENT_SHOWN = `shown${EVENT_KEY}`; - const EVENT_RESIZE = `resize${EVENT_KEY}`; - const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`; - const EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY}`; - const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`; - const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`; - const CLASS_NAME_OPEN = 'modal-open'; - const CLASS_NAME_FADE = 'fade'; - const CLASS_NAME_SHOW = 'show'; - const CLASS_NAME_STATIC = 'modal-static'; - const OPEN_SELECTOR = '.modal.show'; - const SELECTOR_DIALOG = '.modal-dialog'; - const SELECTOR_MODAL_BODY = '.modal-body'; - const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="modal"]'; - const Default = { - backdrop: true, - focus: true, - keyboard: true - }; - const DefaultType = { - backdrop: '(boolean|string)', - focus: 'boolean', - keyboard: 'boolean' - }; - - /** - * Class definition - */ - - class Modal extends BaseComponent { - constructor(element, config) { - super(element, config); - this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element); - this._backdrop = this._initializeBackDrop(); - this._focustrap = this._initializeFocusTrap(); - this._isShown = false; - this._isTransitioning = false; - this._scrollBar = new ScrollBarHelper(); - this._addEventListeners(); - } - - // Getters - static get Default() { - return Default; - } - static get DefaultType() { - return DefaultType; - } - static get NAME() { - return NAME; - } - - // Public - toggle(relatedTarget) { - return this._isShown ? this.hide() : this.show(relatedTarget); - } - show(relatedTarget) { - if (this._isShown || this._isTransitioning) { - return; - } - const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { - relatedTarget - }); - if (showEvent.defaultPrevented) { - return; - } - this._isShown = true; - this._isTransitioning = true; - this._scrollBar.hide(); - document.body.classList.add(CLASS_NAME_OPEN); - this._adjustDialog(); - this._backdrop.show(() => this._showElement(relatedTarget)); - } - hide() { - if (!this._isShown || this._isTransitioning) { - return; - } - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE); - if (hideEvent.defaultPrevented) { - return; - } - this._isShown = false; - this._isTransitioning = true; - this._focustrap.deactivate(); - this._element.classList.remove(CLASS_NAME_SHOW); - this._queueCallback(() => this._hideModal(), this._element, this._isAnimated()); - } - dispose() { - EventHandler.off(window, EVENT_KEY); - EventHandler.off(this._dialog, EVENT_KEY); - this._backdrop.dispose(); - this._focustrap.deactivate(); - super.dispose(); - } - handleUpdate() { - this._adjustDialog(); - } - - // Private - _initializeBackDrop() { - return new Backdrop({ - isVisible: Boolean(this._config.backdrop), - // 'static' option will be translated to true, and booleans will keep their value, - isAnimated: this._isAnimated() - }); - } - _initializeFocusTrap() { - return new FocusTrap({ - trapElement: this._element - }); - } - _showElement(relatedTarget) { - // try to append dynamic modal - if (!document.body.contains(this._element)) { - document.body.append(this._element); - } - this._element.style.display = 'block'; - this._element.removeAttribute('aria-hidden'); - this._element.setAttribute('aria-modal', true); - this._element.setAttribute('role', 'dialog'); - this._element.scrollTop = 0; - const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog); - if (modalBody) { - modalBody.scrollTop = 0; - } - index_js.reflow(this._element); - this._element.classList.add(CLASS_NAME_SHOW); - const transitionComplete = () => { - if (this._config.focus) { - this._focustrap.activate(); - } - this._isTransitioning = false; - EventHandler.trigger(this._element, EVENT_SHOWN, { - relatedTarget - }); - }; - this._queueCallback(transitionComplete, this._dialog, this._isAnimated()); - } - _addEventListeners() { - EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { - if (event.key !== ESCAPE_KEY) { - return; - } - if (this._config.keyboard) { - this.hide(); - return; - } - this._triggerBackdropTransition(); - }); - EventHandler.on(window, EVENT_RESIZE, () => { - if (this._isShown && !this._isTransitioning) { - this._adjustDialog(); - } - }); - EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, event => { - // a bad trick to segregate clicks that may start inside dialog but end outside, and avoid listen to scrollbar clicks - EventHandler.one(this._element, EVENT_CLICK_DISMISS, event2 => { - if (this._element !== event.target || this._element !== event2.target) { - return; - } - if (this._config.backdrop === 'static') { - this._triggerBackdropTransition(); - return; - } - if (this._config.backdrop) { - this.hide(); - } - }); - }); - } - _hideModal() { - this._element.style.display = 'none'; - this._element.setAttribute('aria-hidden', true); - this._element.removeAttribute('aria-modal'); - this._element.removeAttribute('role'); - this._isTransitioning = false; - this._backdrop.hide(() => { - document.body.classList.remove(CLASS_NAME_OPEN); - this._resetAdjustments(); - this._scrollBar.reset(); - EventHandler.trigger(this._element, EVENT_HIDDEN); - }); - } - _isAnimated() { - return this._element.classList.contains(CLASS_NAME_FADE); - } - _triggerBackdropTransition() { - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED); - if (hideEvent.defaultPrevented) { - return; - } - const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight; - const initialOverflowY = this._element.style.overflowY; - // return if the following background transition hasn't yet completed - if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) { - return; - } - if (!isModalOverflowing) { - this._element.style.overflowY = 'hidden'; - } - this._element.classList.add(CLASS_NAME_STATIC); - this._queueCallback(() => { - this._element.classList.remove(CLASS_NAME_STATIC); - this._queueCallback(() => { - this._element.style.overflowY = initialOverflowY; - }, this._dialog); - }, this._dialog); - this._element.focus(); - } - - /** - * The following methods are used to handle overflowing modals - */ - - _adjustDialog() { - const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight; - const scrollbarWidth = this._scrollBar.getWidth(); - const isBodyOverflowing = scrollbarWidth > 0; - if (isBodyOverflowing && !isModalOverflowing) { - const property = index_js.isRTL() ? 'paddingLeft' : 'paddingRight'; - this._element.style[property] = `${scrollbarWidth}px`; - } - if (!isBodyOverflowing && isModalOverflowing) { - const property = index_js.isRTL() ? 'paddingRight' : 'paddingLeft'; - this._element.style[property] = `${scrollbarWidth}px`; - } - } - _resetAdjustments() { - this._element.style.paddingLeft = ''; - this._element.style.paddingRight = ''; - } - } - - /** - * Data API implementation - */ - - EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { - const target = SelectorEngine.getElementFromSelector(this); - if (['A', 'AREA'].includes(this.tagName)) { - event.preventDefault(); - } - EventHandler.one(target, EVENT_SHOW, showEvent => { - if (showEvent.defaultPrevented) { - // only register focus restorer if modal will actually get shown - return; - } - EventHandler.one(target, EVENT_HIDDEN, () => { - if (index_js.isVisible(this)) { - this.focus(); - } - }); - }); - - // avoid conflict when clicking modal toggler while another one is open - const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR); - if (alreadyOpen) { - Modal.getInstance(alreadyOpen).hide(); - } - const data = Modal.getOrCreateInstance(target); - data.toggle(this); - }); - componentFunctions_js.enableDismissTrigger(Modal); - - return Modal; - -})); -//# sourceMappingURL=modal.js.map diff --git a/js/dist/modal.js.map b/js/dist/modal.js.map deleted file mode 100644 index 227a507232..0000000000 --- a/js/dist/modal.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"modal.js","sources":["../src/modal.js"],"sourcesContent":["/**\n * --------------------------------------------------------------------------\n * Bootstrap modal.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nimport BaseComponent from './base-component.js'\nimport EventHandler from './dom/event-handler.js'\nimport SelectorEngine from './dom/selector-engine.js'\nimport Backdrop from './util/backdrop.js'\nimport { enableDismissTrigger } from './util/component-functions.js'\nimport FocusTrap from './util/focustrap.js'\nimport {\n isRTL, isVisible, reflow\n} from './util/index.js'\nimport ScrollBarHelper from './util/scrollbar.js'\n\n/**\n * Constants\n */\n\nconst NAME = 'modal'\nconst DATA_KEY = 'bs.modal'\nconst EVENT_KEY = `.${DATA_KEY}`\nconst DATA_API_KEY = '.data-api'\nconst ESCAPE_KEY = 'Escape'\n\nconst EVENT_HIDE = `hide${EVENT_KEY}`\nconst EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`\nconst EVENT_HIDDEN = `hidden${EVENT_KEY}`\nconst EVENT_SHOW = `show${EVENT_KEY}`\nconst EVENT_SHOWN = `shown${EVENT_KEY}`\nconst EVENT_RESIZE = `resize${EVENT_KEY}`\nconst EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`\nconst EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY}`\nconst EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`\nconst EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`\n\nconst CLASS_NAME_OPEN = 'modal-open'\nconst CLASS_NAME_FADE = 'fade'\nconst CLASS_NAME_SHOW = 'show'\nconst CLASS_NAME_STATIC = 'modal-static'\n\nconst OPEN_SELECTOR = '.modal.show'\nconst SELECTOR_DIALOG = '.modal-dialog'\nconst SELECTOR_MODAL_BODY = '.modal-body'\nconst SELECTOR_DATA_TOGGLE = '[data-bs-toggle=\"modal\"]'\n\nconst Default = {\n backdrop: true,\n focus: true,\n keyboard: true\n}\n\nconst DefaultType = {\n backdrop: '(boolean|string)',\n focus: 'boolean',\n keyboard: 'boolean'\n}\n\n/**\n * Class definition\n */\n\nclass Modal extends BaseComponent {\n constructor(element, config) {\n super(element, config)\n\n this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element)\n this._backdrop = this._initializeBackDrop()\n this._focustrap = this._initializeFocusTrap()\n this._isShown = false\n this._isTransitioning = false\n this._scrollBar = new ScrollBarHelper()\n\n this._addEventListeners()\n }\n\n // Getters\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n static get NAME() {\n return NAME\n }\n\n // Public\n toggle(relatedTarget) {\n return this._isShown ? this.hide() : this.show(relatedTarget)\n }\n\n show(relatedTarget) {\n if (this._isShown || this._isTransitioning) {\n return\n }\n\n const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, {\n relatedTarget\n })\n\n if (showEvent.defaultPrevented) {\n return\n }\n\n this._isShown = true\n this._isTransitioning = true\n\n this._scrollBar.hide()\n\n document.body.classList.add(CLASS_NAME_OPEN)\n\n this._adjustDialog()\n\n this._backdrop.show(() => this._showElement(relatedTarget))\n }\n\n hide() {\n if (!this._isShown || this._isTransitioning) {\n return\n }\n\n const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)\n\n if (hideEvent.defaultPrevented) {\n return\n }\n\n this._isShown = false\n this._isTransitioning = true\n this._focustrap.deactivate()\n\n this._element.classList.remove(CLASS_NAME_SHOW)\n\n this._queueCallback(() => this._hideModal(), this._element, this._isAnimated())\n }\n\n dispose() {\n EventHandler.off(window, EVENT_KEY)\n EventHandler.off(this._dialog, EVENT_KEY)\n\n this._backdrop.dispose()\n this._focustrap.deactivate()\n\n super.dispose()\n }\n\n handleUpdate() {\n this._adjustDialog()\n }\n\n // Private\n _initializeBackDrop() {\n return new Backdrop({\n isVisible: Boolean(this._config.backdrop), // 'static' option will be translated to true, and booleans will keep their value,\n isAnimated: this._isAnimated()\n })\n }\n\n _initializeFocusTrap() {\n return new FocusTrap({\n trapElement: this._element\n })\n }\n\n _showElement(relatedTarget) {\n // try to append dynamic modal\n if (!document.body.contains(this._element)) {\n document.body.append(this._element)\n }\n\n this._element.style.display = 'block'\n this._element.removeAttribute('aria-hidden')\n this._element.setAttribute('aria-modal', true)\n this._element.setAttribute('role', 'dialog')\n this._element.scrollTop = 0\n\n const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog)\n if (modalBody) {\n modalBody.scrollTop = 0\n }\n\n reflow(this._element)\n\n this._element.classList.add(CLASS_NAME_SHOW)\n\n const transitionComplete = () => {\n if (this._config.focus) {\n this._focustrap.activate()\n }\n\n this._isTransitioning = false\n EventHandler.trigger(this._element, EVENT_SHOWN, {\n relatedTarget\n })\n }\n\n this._queueCallback(transitionComplete, this._dialog, this._isAnimated())\n }\n\n _addEventListeners() {\n EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {\n if (event.key !== ESCAPE_KEY) {\n return\n }\n\n if (this._config.keyboard) {\n this.hide()\n return\n }\n\n this._triggerBackdropTransition()\n })\n\n EventHandler.on(window, EVENT_RESIZE, () => {\n if (this._isShown && !this._isTransitioning) {\n this._adjustDialog()\n }\n })\n\n EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, event => {\n // a bad trick to segregate clicks that may start inside dialog but end outside, and avoid listen to scrollbar clicks\n EventHandler.one(this._element, EVENT_CLICK_DISMISS, event2 => {\n if (this._element !== event.target || this._element !== event2.target) {\n return\n }\n\n if (this._config.backdrop === 'static') {\n this._triggerBackdropTransition()\n return\n }\n\n if (this._config.backdrop) {\n this.hide()\n }\n })\n })\n }\n\n _hideModal() {\n this._element.style.display = 'none'\n this._element.setAttribute('aria-hidden', true)\n this._element.removeAttribute('aria-modal')\n this._element.removeAttribute('role')\n this._isTransitioning = false\n\n this._backdrop.hide(() => {\n document.body.classList.remove(CLASS_NAME_OPEN)\n this._resetAdjustments()\n this._scrollBar.reset()\n EventHandler.trigger(this._element, EVENT_HIDDEN)\n })\n }\n\n _isAnimated() {\n return this._element.classList.contains(CLASS_NAME_FADE)\n }\n\n _triggerBackdropTransition() {\n const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)\n if (hideEvent.defaultPrevented) {\n return\n }\n\n const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight\n const initialOverflowY = this._element.style.overflowY\n // return if the following background transition hasn't yet completed\n if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) {\n return\n }\n\n if (!isModalOverflowing) {\n this._element.style.overflowY = 'hidden'\n }\n\n this._element.classList.add(CLASS_NAME_STATIC)\n this._queueCallback(() => {\n this._element.classList.remove(CLASS_NAME_STATIC)\n this._queueCallback(() => {\n this._element.style.overflowY = initialOverflowY\n }, this._dialog)\n }, this._dialog)\n\n this._element.focus()\n }\n\n /**\n * The following methods are used to handle overflowing modals\n */\n\n _adjustDialog() {\n const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight\n const scrollbarWidth = this._scrollBar.getWidth()\n const isBodyOverflowing = scrollbarWidth > 0\n\n if (isBodyOverflowing && !isModalOverflowing) {\n const property = isRTL() ? 'paddingLeft' : 'paddingRight'\n this._element.style[property] = `${scrollbarWidth}px`\n }\n\n if (!isBodyOverflowing && isModalOverflowing) {\n const property = isRTL() ? 'paddingRight' : 'paddingLeft'\n this._element.style[property] = `${scrollbarWidth}px`\n }\n }\n\n _resetAdjustments() {\n this._element.style.paddingLeft = ''\n this._element.style.paddingRight = ''\n }\n}\n\n/**\n * Data API implementation\n */\n\nEventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {\n const target = SelectorEngine.getElementFromSelector(this)\n\n if (['A', 'AREA'].includes(this.tagName)) {\n event.preventDefault()\n }\n\n EventHandler.one(target, EVENT_SHOW, showEvent => {\n if (showEvent.defaultPrevented) {\n // only register focus restorer if modal will actually get shown\n return\n }\n\n EventHandler.one(target, EVENT_HIDDEN, () => {\n if (isVisible(this)) {\n this.focus()\n }\n })\n })\n\n // avoid conflict when clicking modal toggler while another one is open\n const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR)\n if (alreadyOpen) {\n Modal.getInstance(alreadyOpen).hide()\n }\n\n const data = Modal.getOrCreateInstance(target)\n\n data.toggle(this)\n})\n\nenableDismissTrigger(Modal)\n\nexport default Modal\n"],"names":["NAME","DATA_KEY","EVENT_KEY","DATA_API_KEY","ESCAPE_KEY","EVENT_HIDE","EVENT_HIDE_PREVENTED","EVENT_HIDDEN","EVENT_SHOW","EVENT_SHOWN","EVENT_RESIZE","EVENT_CLICK_DISMISS","EVENT_MOUSEDOWN_DISMISS","EVENT_KEYDOWN_DISMISS","EVENT_CLICK_DATA_API","CLASS_NAME_OPEN","CLASS_NAME_FADE","CLASS_NAME_SHOW","CLASS_NAME_STATIC","OPEN_SELECTOR","SELECTOR_DIALOG","SELECTOR_MODAL_BODY","SELECTOR_DATA_TOGGLE","Default","backdrop","focus","keyboard","DefaultType","Modal","BaseComponent","constructor","element","config","_dialog","SelectorEngine","findOne","_element","_backdrop","_initializeBackDrop","_focustrap","_initializeFocusTrap","_isShown","_isTransitioning","_scrollBar","ScrollBarHelper","_addEventListeners","toggle","relatedTarget","hide","show","showEvent","EventHandler","trigger","defaultPrevented","document","body","classList","add","_adjustDialog","_showElement","hideEvent","deactivate","remove","_queueCallback","_hideModal","_isAnimated","dispose","off","window","handleUpdate","Backdrop","isVisible","Boolean","_config","isAnimated","FocusTrap","trapElement","contains","append","style","display","removeAttribute","setAttribute","scrollTop","modalBody","reflow","transitionComplete","activate","on","event","key","_triggerBackdropTransition","one","event2","target","_resetAdjustments","reset","isModalOverflowing","scrollHeight","documentElement","clientHeight","initialOverflowY","overflowY","scrollbarWidth","getWidth","isBodyOverflowing","property","isRTL","paddingLeft","paddingRight","getElementFromSelector","includes","tagName","preventDefault","alreadyOpen","getInstance","data","getOrCreateInstance","enableDismissTrigger"],"mappings":"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"} \ No newline at end of file diff --git a/js/index.esm.js b/js/index.esm.js index 155d9fb6a5..d67e5f69c8 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -9,8 +9,8 @@ export { default as Alert } from './src/alert.js' export { default as Button } from './src/button.js' export { default as Carousel } from './src/carousel.js' export { default as Collapse } from './src/collapse.js' +export { default as Dialog } from './src/dialog.js' export { default as Dropdown } from './src/dropdown.js' -export { default as Modal } from './src/modal.js' export { default as Offcanvas } from './src/offcanvas.js' export { default as Popover } from './src/popover.js' export { default as ScrollSpy } from './src/scrollspy.js' diff --git a/js/index.umd.js b/js/index.umd.js index a33df74657..d228e7f2b9 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -9,8 +9,8 @@ import Alert from './src/alert.js' import Button from './src/button.js' import Carousel from './src/carousel.js' import Collapse from './src/collapse.js' +import Dialog from './src/dialog.js' import Dropdown from './src/dropdown.js' -import Modal from './src/modal.js' import Offcanvas from './src/offcanvas.js' import Popover from './src/popover.js' import ScrollSpy from './src/scrollspy.js' @@ -23,8 +23,8 @@ export default { Button, Carousel, Collapse, + Dialog, Dropdown, - Modal, Offcanvas, Popover, ScrollSpy, diff --git a/js/src/dialog.js b/js/src/dialog.js new file mode 100644 index 0000000000..4cd59f85f0 --- /dev/null +++ b/js/src/dialog.js @@ -0,0 +1,272 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap dialog.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import Manipulator from './dom/manipulator.js' +import SelectorEngine from './dom/selector-engine.js' +import { enableDismissTrigger } from './util/component-functions.js' +import { isVisible } from './util/index.js' + +/** + * Constants + */ + +const NAME = 'dialog' +const DATA_KEY = 'bs.dialog' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +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_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}` +const EVENT_CANCEL = `cancel${EVENT_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` + +const CLASS_NAME_STATIC = 'dialog-static' +const CLASS_NAME_OPEN = 'dialog-open' +const CLASS_NAME_NONMODAL = 'dialog-nonmodal' + +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dialog"]' +const SELECTOR_OPEN_MODAL_DIALOG = 'dialog.dialog[open]:not(.dialog-nonmodal)' + +const Default = { + backdrop: true, // true (click dismisses) or 'static' (click does nothing) - only applies to modal dialogs + keyboard: true, + modal: true // true uses showModal(), false uses show() for non-modal dialogs +} + +const DefaultType = { + backdrop: '(boolean|string)', + keyboard: 'boolean', + modal: 'boolean' +} + +/** + * Class definition + */ + +class Dialog extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._isTransitioning = false + this._addEventListeners() + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + toggle(relatedTarget) { + return this._element.open ? this.hide() : this.show(relatedTarget) + } + + show(relatedTarget) { + if (this._element.open || this._isTransitioning) { + return + } + + const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { + relatedTarget + }) + + if (showEvent.defaultPrevented) { + return + } + + this._isTransitioning = true + + if (this._config.modal) { + // Modal dialog: use showModal() for focus trapping, backdrop, and top layer + this._element.showModal() + // Prevent body scroll for modal dialogs + document.body.classList.add(CLASS_NAME_OPEN) + } else { + // Non-modal dialog: use show() - no backdrop, no focus trap, no top layer + this._element.classList.add(CLASS_NAME_NONMODAL) + this._element.show() + } + + this._queueCallback(() => { + this._isTransitioning = false + EventHandler.trigger(this._element, EVENT_SHOWN, { + relatedTarget + }) + }, this._element, this._isAnimated()) + } + + hide() { + if (!this._element.open || this._isTransitioning) { + return + } + + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) + + if (hideEvent.defaultPrevented) { + return + } + + this._isTransitioning = true + + this._queueCallback(() => this._hideDialog(), this._element, this._isAnimated()) + } + + dispose() { + EventHandler.off(this._element, EVENT_KEY) + super.dispose() + } + + handleUpdate() { + // Provided for API consistency with Modal. + // Native dialogs handle their own positioning. + } + + // Private + _hideDialog() { + this._element.close() + this._element.classList.remove(CLASS_NAME_NONMODAL) + this._isTransitioning = false + + // Only restore body scroll if no other modal dialogs are open + if (!document.querySelector(SELECTOR_OPEN_MODAL_DIALOG)) { + document.body.classList.remove(CLASS_NAME_OPEN) + } + + EventHandler.trigger(this._element, EVENT_HIDDEN) + } + + _isAnimated() { + return this._element.classList.contains('fade') + } + + _triggerBackdropTransition() { + const hidePreventedEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED) + if (hidePreventedEvent.defaultPrevented) { + return + } + + this._element.classList.add(CLASS_NAME_STATIC) + this._queueCallback(() => { + this._element.classList.remove(CLASS_NAME_STATIC) + }, this._element) + } + + _addEventListeners() { + // Handle native cancel event (Escape key) - only fires for modal dialogs + EventHandler.on(this._element, 'cancel', event => { + // Prevent native close behavior - we'll handle it + event.preventDefault() + + if (!this._config.keyboard) { + this._triggerBackdropTransition() + return + } + + EventHandler.trigger(this._element, EVENT_CANCEL) + this.hide() + }) + + // Handle Escape key for non-modal dialogs (native cancel doesn't fire for show()) + EventHandler.on(this._element, 'keydown', event => { + if (event.key !== 'Escape' || this._config.modal) { + return + } + + event.preventDefault() + + if (!this._config.keyboard) { + return + } + + EventHandler.trigger(this._element, EVENT_CANCEL) + this.hide() + }) + + // Handle backdrop clicks (only applies to modal dialogs) + // Native fires click on the dialog element when backdrop is clicked + EventHandler.on(this._element, 'click', event => { + // Only handle clicks directly on the dialog (backdrop area) + // Non-modal dialogs don't have a backdrop + if (event.target !== this._element || !this._config.modal) { + return + } + + if (this._config.backdrop === 'static') { + this._triggerBackdropTransition() + return + } + + // Default: click backdrop to dismiss + this.hide() + }) + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { + const target = SelectorEngine.getElementFromSelector(this) + + if (['A', 'AREA'].includes(this.tagName)) { + event.preventDefault() + } + + EventHandler.one(target, EVENT_SHOW, showEvent => { + if (showEvent.defaultPrevented) { + return + } + + EventHandler.one(target, EVENT_HIDDEN, () => { + if (isVisible(this)) { + this.focus() + } + }) + }) + + // Get config from trigger's data attributes + const config = Manipulator.getDataAttributes(this) + + // Check if trigger is inside an open dialog + const currentDialog = this.closest('dialog[open]') + const shouldSwap = currentDialog && currentDialog !== target + + if (shouldSwap) { + // Open new dialog first (its backdrop appears over current) + const newDialog = Dialog.getOrCreateInstance(target, config) + newDialog.show(this) + + // Close the current dialog (no backdrop flash since new one is already open) + const currentInstance = Dialog.getInstance(currentDialog) + if (currentInstance) { + currentInstance.hide() + } + + return + } + + const data = Dialog.getOrCreateInstance(target, config) + data.toggle(this) +}) + +enableDismissTrigger(Dialog) + +export default Dialog diff --git a/js/src/modal.js b/js/src/modal.js deleted file mode 100644 index 538d218653..0000000000 --- a/js/src/modal.js +++ /dev/null @@ -1,355 +0,0 @@ -/** - * -------------------------------------------------------------------------- - * Bootstrap modal.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - * -------------------------------------------------------------------------- - */ - -import BaseComponent from './base-component.js' -import EventHandler from './dom/event-handler.js' -import SelectorEngine from './dom/selector-engine.js' -import Backdrop from './util/backdrop.js' -import { enableDismissTrigger } from './util/component-functions.js' -import FocusTrap from './util/focustrap.js' -import { - isRTL, isVisible, reflow -} from './util/index.js' -import ScrollBarHelper from './util/scrollbar.js' - -/** - * Constants - */ - -const NAME = 'modal' -const DATA_KEY = 'bs.modal' -const EVENT_KEY = `.${DATA_KEY}` -const DATA_API_KEY = '.data-api' -const ESCAPE_KEY = 'Escape' - -const EVENT_HIDE = `hide${EVENT_KEY}` -const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}` -const EVENT_HIDDEN = `hidden${EVENT_KEY}` -const EVENT_SHOW = `show${EVENT_KEY}` -const EVENT_SHOWN = `shown${EVENT_KEY}` -const EVENT_RESIZE = `resize${EVENT_KEY}` -const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}` -const EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY}` -const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}` -const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` - -const CLASS_NAME_OPEN = 'modal-open' -const CLASS_NAME_FADE = 'fade' -const CLASS_NAME_SHOW = 'show' -const CLASS_NAME_STATIC = 'modal-static' - -const OPEN_SELECTOR = '.modal.show' -const SELECTOR_DIALOG = '.modal-dialog' -const SELECTOR_MODAL_BODY = '.modal-body' -const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="modal"]' - -const Default = { - backdrop: true, - focus: true, - keyboard: true -} - -const DefaultType = { - backdrop: '(boolean|string)', - focus: 'boolean', - keyboard: 'boolean' -} - -/** - * Class definition - */ - -class Modal extends BaseComponent { - constructor(element, config) { - super(element, config) - - this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element) - this._backdrop = this._initializeBackDrop() - this._focustrap = this._initializeFocusTrap() - this._isShown = false - this._isTransitioning = false - this._scrollBar = new ScrollBarHelper() - - this._addEventListeners() - } - - // Getters - static get Default() { - return Default - } - - static get DefaultType() { - return DefaultType - } - - static get NAME() { - return NAME - } - - // Public - toggle(relatedTarget) { - return this._isShown ? this.hide() : this.show(relatedTarget) - } - - show(relatedTarget) { - if (this._isShown || this._isTransitioning) { - return - } - - const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { - relatedTarget - }) - - if (showEvent.defaultPrevented) { - return - } - - this._isShown = true - this._isTransitioning = true - - this._scrollBar.hide() - - document.body.classList.add(CLASS_NAME_OPEN) - - this._adjustDialog() - - this._backdrop.show(() => this._showElement(relatedTarget)) - } - - hide() { - if (!this._isShown || this._isTransitioning) { - return - } - - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) - - if (hideEvent.defaultPrevented) { - return - } - - this._isShown = false - this._isTransitioning = true - this._focustrap.deactivate() - - this._element.classList.remove(CLASS_NAME_SHOW) - - this._queueCallback(() => this._hideModal(), this._element, this._isAnimated()) - } - - dispose() { - EventHandler.off(window, EVENT_KEY) - EventHandler.off(this._dialog, EVENT_KEY) - - this._backdrop.dispose() - this._focustrap.deactivate() - - super.dispose() - } - - handleUpdate() { - this._adjustDialog() - } - - // Private - _initializeBackDrop() { - return new Backdrop({ - isVisible: Boolean(this._config.backdrop), // 'static' option will be translated to true, and booleans will keep their value, - isAnimated: this._isAnimated() - }) - } - - _initializeFocusTrap() { - return new FocusTrap({ - trapElement: this._element - }) - } - - _showElement(relatedTarget) { - // try to append dynamic modal - if (!document.body.contains(this._element)) { - document.body.append(this._element) - } - - this._element.style.display = 'block' - this._element.removeAttribute('aria-hidden') - this._element.setAttribute('aria-modal', true) - this._element.setAttribute('role', 'dialog') - this._element.scrollTop = 0 - - const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog) - if (modalBody) { - modalBody.scrollTop = 0 - } - - reflow(this._element) - - this._element.classList.add(CLASS_NAME_SHOW) - - const transitionComplete = () => { - if (this._config.focus) { - this._focustrap.activate() - } - - this._isTransitioning = false - EventHandler.trigger(this._element, EVENT_SHOWN, { - relatedTarget - }) - } - - this._queueCallback(transitionComplete, this._dialog, this._isAnimated()) - } - - _addEventListeners() { - EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { - if (event.key !== ESCAPE_KEY) { - return - } - - if (this._config.keyboard) { - this.hide() - return - } - - this._triggerBackdropTransition() - }) - - EventHandler.on(window, EVENT_RESIZE, () => { - if (this._isShown && !this._isTransitioning) { - this._adjustDialog() - } - }) - - EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, event => { - // a bad trick to segregate clicks that may start inside dialog but end outside, and avoid listen to scrollbar clicks - EventHandler.one(this._element, EVENT_CLICK_DISMISS, event2 => { - if (this._element !== event.target || this._element !== event2.target) { - return - } - - if (this._config.backdrop === 'static') { - this._triggerBackdropTransition() - return - } - - if (this._config.backdrop) { - this.hide() - } - }) - }) - } - - _hideModal() { - this._element.style.display = 'none' - this._element.setAttribute('aria-hidden', true) - this._element.removeAttribute('aria-modal') - this._element.removeAttribute('role') - this._isTransitioning = false - - this._backdrop.hide(() => { - document.body.classList.remove(CLASS_NAME_OPEN) - this._resetAdjustments() - this._scrollBar.reset() - EventHandler.trigger(this._element, EVENT_HIDDEN) - }) - } - - _isAnimated() { - return this._element.classList.contains(CLASS_NAME_FADE) - } - - _triggerBackdropTransition() { - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED) - if (hideEvent.defaultPrevented) { - return - } - - const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight - const initialOverflowY = this._element.style.overflowY - // return if the following background transition hasn't yet completed - if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) { - return - } - - if (!isModalOverflowing) { - this._element.style.overflowY = 'hidden' - } - - this._element.classList.add(CLASS_NAME_STATIC) - this._queueCallback(() => { - this._element.classList.remove(CLASS_NAME_STATIC) - this._queueCallback(() => { - this._element.style.overflowY = initialOverflowY - }, this._dialog) - }, this._dialog) - - this._element.focus() - } - - /** - * The following methods are used to handle overflowing modals - */ - - _adjustDialog() { - const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight - const scrollbarWidth = this._scrollBar.getWidth() - const isBodyOverflowing = scrollbarWidth > 0 - - if (isBodyOverflowing && !isModalOverflowing) { - const property = isRTL() ? 'paddingLeft' : 'paddingRight' - this._element.style[property] = `${scrollbarWidth}px` - } - - if (!isBodyOverflowing && isModalOverflowing) { - const property = isRTL() ? 'paddingRight' : 'paddingLeft' - this._element.style[property] = `${scrollbarWidth}px` - } - } - - _resetAdjustments() { - this._element.style.paddingLeft = '' - this._element.style.paddingRight = '' - } -} - -/** - * Data API implementation - */ - -EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { - const target = SelectorEngine.getElementFromSelector(this) - - if (['A', 'AREA'].includes(this.tagName)) { - event.preventDefault() - } - - EventHandler.one(target, EVENT_SHOW, showEvent => { - if (showEvent.defaultPrevented) { - // only register focus restorer if modal will actually get shown - return - } - - EventHandler.one(target, EVENT_HIDDEN, () => { - if (isVisible(this)) { - this.focus() - } - }) - }) - - // avoid conflict when clicking modal toggler while another one is open - const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR) - if (alreadyOpen) { - Modal.getInstance(alreadyOpen).hide() - } - - const data = Modal.getOrCreateInstance(target) - - data.toggle(this) -}) - -enableDismissTrigger(Modal) - -export default Modal diff --git a/js/tests/unit/dialog.spec.js b/js/tests/unit/dialog.spec.js new file mode 100644 index 0000000000..1a2ff536e0 --- /dev/null +++ b/js/tests/unit/dialog.spec.js @@ -0,0 +1,947 @@ +import EventHandler from '../../src/dom/event-handler.js' +import Dialog from '../../src/dialog.js' +import { + clearBodyAndDocument, clearFixture, createEvent, getFixture +} from '../helpers/fixture.js' + +describe('Dialog', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + clearBodyAndDocument() + document.body.classList.remove('dialog-open') + + for (const dialog of document.querySelectorAll('dialog[open]')) { + dialog.close() + } + }) + + beforeEach(() => { + clearBodyAndDocument() + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(Dialog.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(Dialog.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Dialog.DATA_KEY).toEqual('bs.dialog') + }) + }) + + describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialogBySelector = new Dialog('#testDialog') + const dialogByElement = new Dialog(dialogEl) + + expect(dialogBySelector._element).toEqual(dialogEl) + expect(dialogByElement._element).toEqual(dialogEl) + }) + }) + + describe('toggle', () => { + it('should toggle the dialog open state', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('shown.bs.dialog', () => { + expect(dialogEl.open).toBeTrue() + dialog.toggle() + }) + + dialogEl.addEventListener('hidden.bs.dialog', () => { + expect(dialogEl.open).toBeFalse() + resolve() + }) + + dialog.toggle() + }) + }) + }) + + describe('show', () => { + it('should show a dialog', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('show.bs.dialog', event => { + expect(event).toBeDefined() + }) + + dialogEl.addEventListener('shown.bs.dialog', () => { + expect(dialogEl.open).toBeTrue() + expect(document.body.classList.contains('dialog-open')).toBeTrue() + resolve() + }) + + dialog.show() + }) + }) + + it('should pass relatedTarget to show event', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].join('') + + const dialogEl = fixtureEl.querySelector('.dialog') + const trigger = fixtureEl.querySelector('#trigger') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('show.bs.dialog', event => { + expect(event.relatedTarget).toEqual(trigger) + }) + + dialogEl.addEventListener('shown.bs.dialog', event => { + expect(event.relatedTarget).toEqual(trigger) + resolve() + }) + + dialog.show(trigger) + }) + }) + + it('should do nothing if a dialog is already open', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + // Manually open the dialog + dialogEl.showModal() + + const spy = spyOn(EventHandler, 'trigger') + dialog.show() + + expect(spy).not.toHaveBeenCalled() + }) + + it('should do nothing if a dialog is transitioning', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + const spy = spyOn(EventHandler, 'trigger') + dialog._isTransitioning = true + + dialog.show() + + expect(spy).not.toHaveBeenCalled() + }) + + it('should not fire shown event when show is prevented', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('show.bs.dialog', event => { + event.preventDefault() + + const expectedDone = () => { + expect().nothing() + resolve() + } + + setTimeout(expectedDone, 10) + }) + + dialogEl.addEventListener('shown.bs.dialog', () => { + reject(new Error('shown event triggered')) + }) + + dialog.show() + }) + }) + + it('should set is transitioning if fade class is present', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('show.bs.dialog', () => { + setTimeout(() => { + expect(dialog._isTransitioning).toBeTrue() + }) + }) + + dialogEl.addEventListener('shown.bs.dialog', () => { + expect(dialog._isTransitioning).toBeFalse() + resolve() + }) + + dialog.show() + }) + }) + + it('should close dialog when a click occurred on data-bs-dismiss="dialog" inside dialog', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '
', + ' ', + '
', + '
' + ].join('') + + const dialogEl = fixtureEl.querySelector('.dialog') + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="dialog"]') + const dialog = new Dialog(dialogEl) + + const spy = spyOn(dialog, 'hide').and.callThrough() + + dialogEl.addEventListener('shown.bs.dialog', () => { + btnClose.click() + }) + + dialogEl.addEventListener('hidden.bs.dialog', () => { + expect(spy).toHaveBeenCalled() + resolve() + }) + + dialog.show() + }) + }) + }) + + describe('hide', () => { + it('should hide a dialog', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('shown.bs.dialog', () => { + dialog.hide() + }) + + dialogEl.addEventListener('hide.bs.dialog', event => { + expect(event).toBeDefined() + }) + + dialogEl.addEventListener('hidden.bs.dialog', () => { + expect(dialogEl.open).toBeFalse() + expect(document.body.classList.contains('dialog-open')).toBeFalse() + resolve() + }) + + dialog.show() + }) + }) + + it('should do nothing if the dialog is not shown', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialog.hide() + + expect().nothing() + }) + + it('should do nothing if the dialog is transitioning', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.showModal() + dialog._isTransitioning = true + dialog.hide() + + expect().nothing() + }) + + it('should not hide a dialog if hide is prevented', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('shown.bs.dialog', () => { + dialog.hide() + }) + + const hideCallback = () => { + setTimeout(() => { + expect(dialogEl.open).toBeTrue() + resolve() + }, 10) + } + + dialogEl.addEventListener('hide.bs.dialog', event => { + event.preventDefault() + hideCallback() + }) + + dialogEl.addEventListener('hidden.bs.dialog', () => { + reject(new Error('should not trigger hidden')) + }) + + dialog.show() + }) + }) + + it('should close dialog when backdrop is clicked', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + const spy = spyOn(dialog, 'hide').and.callThrough() + + dialogEl.addEventListener('shown.bs.dialog', () => { + // Click directly on the dialog element (backdrop area) + const clickEvent = createEvent('click') + Object.defineProperty(clickEvent, 'target', { value: dialogEl }) + dialogEl.dispatchEvent(clickEvent) + }) + + dialogEl.addEventListener('hidden.bs.dialog', () => { + expect(spy).toHaveBeenCalled() + resolve() + }) + + dialog.show() + }) + }) + + it('should not close dialog when clicking inside dialog content', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '
Content
', + '
' + ].join('') + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialogBody = fixtureEl.querySelector('.dialog-body') + const dialog = new Dialog(dialogEl) + + const spy = spyOn(dialog, 'hide') + + dialogEl.addEventListener('shown.bs.dialog', () => { + // Click on inner content - should not close + const clickEvent = createEvent('click', { bubbles: true }) + dialogBody.dispatchEvent(clickEvent) + + setTimeout(() => { + expect(spy).not.toHaveBeenCalled() + resolve() + }, 10) + }) + + dialog.show() + }) + }) + }) + + describe('backdrop static', () => { + it('should not close dialog when backdrop is static and backdrop is clicked', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + backdrop: 'static' + }) + + const spy = spyOn(dialog, 'hide') + + dialogEl.addEventListener('shown.bs.dialog', () => { + const clickEvent = createEvent('click') + Object.defineProperty(clickEvent, 'target', { value: dialogEl }) + dialogEl.dispatchEvent(clickEvent) + + setTimeout(() => { + expect(spy).not.toHaveBeenCalled() + expect(dialogEl.open).toBeTrue() + resolve() + }, 10) + }) + + dialog.show() + }) + }) + + it('should add dialog-static class when backdrop is static and clicked', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + backdrop: 'static' + }) + + dialogEl.addEventListener('shown.bs.dialog', () => { + const clickEvent = createEvent('click') + Object.defineProperty(clickEvent, 'target', { value: dialogEl }) + dialogEl.dispatchEvent(clickEvent) + + expect(dialogEl.classList.contains('dialog-static')).toBeTrue() + + setTimeout(() => { + expect(dialogEl.classList.contains('dialog-static')).toBeFalse() + resolve() + }, 300) + }) + + dialog.show() + }) + }) + + it('should fire hidePrevented.bs.dialog event when static backdrop is clicked', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + backdrop: 'static' + }) + + dialogEl.addEventListener('shown.bs.dialog', () => { + const clickEvent = createEvent('click') + Object.defineProperty(clickEvent, 'target', { value: dialogEl }) + dialogEl.dispatchEvent(clickEvent) + }) + + dialogEl.addEventListener('hidePrevented.bs.dialog', () => { + resolve() + }) + + dialog.show() + }) + }) + }) + + describe('non-modal dialogs', () => { + it('should open a non-modal dialog with show() when modal = false', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + modal: false + }) + + dialogEl.addEventListener('shown.bs.dialog', () => { + expect(dialogEl.open).toBeTrue() + expect(dialogEl.classList.contains('dialog-nonmodal')).toBeTrue() + // Non-modal dialogs should not add dialog-open to body + expect(document.body.classList.contains('dialog-open')).toBeFalse() + resolve() + }) + + dialog.show() + }) + }) + + it('should remove dialog-nonmodal class on hide', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + modal: false + }) + + dialogEl.addEventListener('shown.bs.dialog', () => { + expect(dialogEl.classList.contains('dialog-nonmodal')).toBeTrue() + dialog.hide() + }) + + dialogEl.addEventListener('hidden.bs.dialog', () => { + expect(dialogEl.classList.contains('dialog-nonmodal')).toBeFalse() + resolve() + }) + + dialog.show() + }) + }) + + it('should not respond to backdrop clicks for non-modal dialogs', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + modal: false + }) + + const spy = spyOn(dialog, 'hide') + + dialogEl.addEventListener('shown.bs.dialog', () => { + const clickEvent = createEvent('click') + Object.defineProperty(clickEvent, 'target', { value: dialogEl }) + dialogEl.dispatchEvent(clickEvent) + + setTimeout(() => { + expect(spy).not.toHaveBeenCalled() + resolve() + }, 10) + }) + + dialog.show() + }) + }) + + it('should close non-modal dialog with escape key when keyboard = true', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + modal: false, + keyboard: true + }) + + dialogEl.addEventListener('shown.bs.dialog', () => { + const keydownEvent = createEvent('keydown') + keydownEvent.key = 'Escape' + dialogEl.dispatchEvent(keydownEvent) + }) + + dialogEl.addEventListener('hidden.bs.dialog', () => { + resolve() + }) + + dialog.show() + }) + }) + + it('should not close non-modal dialog with escape key when keyboard = false', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + modal: false, + keyboard: false + }) + + const spy = spyOn(dialog, 'hide') + + dialogEl.addEventListener('shown.bs.dialog', () => { + const keydownEvent = createEvent('keydown') + keydownEvent.key = 'Escape' + dialogEl.dispatchEvent(keydownEvent) + + setTimeout(() => { + expect(spy).not.toHaveBeenCalled() + resolve() + }, 10) + }) + + dialog.show() + }) + }) + + it('should use data-bs-modal="false" to create non-modal dialog', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].join('') + + const dialogEl = fixtureEl.querySelector('.dialog') + const trigger = fixtureEl.querySelector('[data-bs-toggle="dialog"]') + + dialogEl.addEventListener('shown.bs.dialog', () => { + const dialog = Dialog.getInstance(dialogEl) + expect(dialog._config.modal).toBeFalse() + expect(dialogEl.classList.contains('dialog-nonmodal')).toBeTrue() + resolve() + }) + + trigger.click() + }) + }) + }) + + describe('handleUpdate', () => { + it('should exist for API consistency', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + expect(typeof dialog.handleUpdate).toEqual('function') + // Should not throw + dialog.handleUpdate() + }) + }) + + describe('keyboard', () => { + it('should close dialog when escape key is pressed', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('shown.bs.dialog', () => { + const cancelEvent = createEvent('cancel') + dialogEl.dispatchEvent(cancelEvent) + }) + + dialogEl.addEventListener('hidden.bs.dialog', () => { + resolve() + }) + + dialog.show() + }) + }) + + it('should fire cancel.bs.dialog event when escape is pressed', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('shown.bs.dialog', () => { + const cancelEvent = createEvent('cancel') + dialogEl.dispatchEvent(cancelEvent) + }) + + dialogEl.addEventListener('cancel.bs.dialog', () => { + resolve() + }) + + dialog.show() + }) + }) + + it('should not close dialog when escape key is pressed with keyboard = false', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + keyboard: false + }) + + const spy = spyOn(dialog, 'hide') + + dialogEl.addEventListener('shown.bs.dialog', () => { + const cancelEvent = createEvent('cancel') + dialogEl.dispatchEvent(cancelEvent) + + setTimeout(() => { + expect(spy).not.toHaveBeenCalled() + expect(dialogEl.open).toBeTrue() + resolve() + }, 10) + }) + + dialog.show() + }) + }) + + it('should show static backdrop animation when escape pressed and keyboard = false', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl, { + keyboard: false + }) + + const spy = spyOn(dialog, '_triggerBackdropTransition').and.callThrough() + + dialogEl.addEventListener('shown.bs.dialog', () => { + const cancelEvent = createEvent('cancel') + dialogEl.dispatchEvent(cancelEvent) + + setTimeout(() => { + expect(spy).toHaveBeenCalled() + resolve() + }, 10) + }) + + dialog.show() + }) + }) + }) + + describe('dispose', () => { + it('should dispose a dialog', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + expect(Dialog.getInstance(dialogEl)).toEqual(dialog) + + const spyOff = spyOn(EventHandler, 'off') + + dialog.dispose() + + expect(Dialog.getInstance(dialogEl)).toBeNull() + expect(spyOff).toHaveBeenCalled() + }) + }) + + describe('data-api', () => { + it('should toggle dialog', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].join('') + + const dialogEl = fixtureEl.querySelector('.dialog') + const trigger = fixtureEl.querySelector('[data-bs-toggle="dialog"]') + + dialogEl.addEventListener('shown.bs.dialog', () => { + expect(dialogEl.open).toBeTrue() + setTimeout(() => trigger.click(), 10) + }) + + dialogEl.addEventListener('hidden.bs.dialog', () => { + expect(dialogEl.open).toBeFalse() + resolve() + }) + + trigger.click() + }) + }) + + it('should not recreate a new dialog', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].join('') + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + const trigger = fixtureEl.querySelector('[data-bs-toggle="dialog"]') + + const spy = spyOn(dialog, 'toggle').and.callThrough() + + dialogEl.addEventListener('shown.bs.dialog', () => { + expect(spy).toHaveBeenCalled() + resolve() + }) + + trigger.click() + }) + }) + + it('should prevent default when the trigger is or ', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].join('') + + const dialogEl = fixtureEl.querySelector('.dialog') + const trigger = fixtureEl.querySelector('[data-bs-toggle="dialog"]') + + const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough() + + dialogEl.addEventListener('shown.bs.dialog', () => { + expect(dialogEl.open).toBeTrue() + expect(spy).toHaveBeenCalled() + resolve() + }) + + trigger.click() + }) + }) + + it('should focus the trigger on hide', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].join('') + + const dialogEl = fixtureEl.querySelector('.dialog') + const trigger = fixtureEl.querySelector('[data-bs-toggle="dialog"]') + + const spy = spyOn(trigger, 'focus') + + dialogEl.addEventListener('shown.bs.dialog', () => { + const dialog = Dialog.getInstance(dialogEl) + dialog.hide() + }) + + const hideListener = () => { + setTimeout(() => { + expect(spy).toHaveBeenCalled() + resolve() + }, 20) + } + + dialogEl.addEventListener('hidden.bs.dialog', () => { + hideListener() + }) + + trigger.click() + }) + }) + + it('should use data attributes for config', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].join('') + + const dialogEl = fixtureEl.querySelector('.dialog') + const trigger = fixtureEl.querySelector('[data-bs-toggle="dialog"]') + + dialogEl.addEventListener('shown.bs.dialog', () => { + const dialog = Dialog.getInstance(dialogEl) + expect(dialog._config.backdrop).toEqual('static') + resolve() + }) + + trigger.click() + }) + }) + }) + + describe('dialog swapping', () => { + it('should swap dialogs when trigger is inside an open dialog', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '', + ' ', + '', + '' + ].join('') + + const dialog1El = fixtureEl.querySelector('#dialog1') + const dialog2El = fixtureEl.querySelector('#dialog2') + const firstTrigger = fixtureEl.querySelector('[data-bs-target="#dialog1"]') + const swapTrigger = dialog1El.querySelector('[data-bs-target="#dialog2"]') + + dialog1El.addEventListener('shown.bs.dialog', () => { + // Now click the swap trigger inside dialog1 + swapTrigger.click() + }) + + dialog2El.addEventListener('shown.bs.dialog', () => { + expect(dialog2El.open).toBeTrue() + }) + + dialog1El.addEventListener('hidden.bs.dialog', () => { + expect(dialog1El.open).toBeFalse() + expect(dialog2El.open).toBeTrue() + resolve() + }) + + firstTrigger.click() + }) + }) + }) + + describe('getInstance', () => { + it('should return dialog instance', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('dialog') + const dialog = new Dialog(dialogEl) + + expect(Dialog.getInstance(dialogEl)).toEqual(dialog) + expect(Dialog.getInstance(dialogEl)).toBeInstanceOf(Dialog) + }) + + it('should return null when there is no dialog instance', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('dialog') + + expect(Dialog.getInstance(dialogEl)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return dialog instance', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('dialog') + const dialog = new Dialog(dialogEl) + + expect(Dialog.getOrCreateInstance(dialogEl)).toEqual(dialog) + expect(Dialog.getInstance(dialogEl)).toEqual(Dialog.getOrCreateInstance(dialogEl, {})) + expect(Dialog.getOrCreateInstance(dialogEl)).toBeInstanceOf(Dialog) + }) + + it('should return new instance when there is no dialog instance', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('dialog') + + expect(Dialog.getInstance(dialogEl)).toBeNull() + expect(Dialog.getOrCreateInstance(dialogEl)).toBeInstanceOf(Dialog) + }) + + it('should return new instance when there is no dialog instance with given configuration', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('dialog') + + expect(Dialog.getInstance(dialogEl)).toBeNull() + const dialog = Dialog.getOrCreateInstance(dialogEl, { + backdrop: 'static' + }) + expect(dialog).toBeInstanceOf(Dialog) + expect(dialog._config.backdrop).toEqual('static') + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('dialog') + const dialog = new Dialog(dialogEl, { + backdrop: 'static' + }) + expect(Dialog.getInstance(dialogEl)).toEqual(dialog) + + const dialog2 = Dialog.getOrCreateInstance(dialogEl, { + backdrop: true + }) + expect(dialog).toBeInstanceOf(Dialog) + expect(dialog2).toEqual(dialog) + + expect(dialog2._config.backdrop).toEqual('static') + }) + }) +}) diff --git a/js/tests/unit/modal.spec.js b/js/tests/unit/modal.spec.js deleted file mode 100644 index 1480521206..0000000000 --- a/js/tests/unit/modal.spec.js +++ /dev/null @@ -1,1239 +0,0 @@ -import EventHandler from '../../src/dom/event-handler.js' -import Modal from '../../src/modal.js' -import ScrollBarHelper from '../../src/util/scrollbar.js' -import { - clearBodyAndDocument, clearFixture, createEvent, getFixture -} from '../helpers/fixture.js' - -describe('Modal', () => { - let fixtureEl - - beforeAll(() => { - fixtureEl = getFixture() - }) - - afterEach(() => { - clearFixture() - clearBodyAndDocument() - document.body.classList.remove('modal-open') - - for (const backdrop of document.querySelectorAll('.modal-backdrop')) { - backdrop.remove() - } - }) - - beforeEach(() => { - clearBodyAndDocument() - }) - - describe('VERSION', () => { - it('should return plugin version', () => { - expect(Modal.VERSION).toEqual(jasmine.any(String)) - }) - }) - - describe('Default', () => { - it('should return plugin default config', () => { - expect(Modal.Default).toEqual(jasmine.any(Object)) - }) - }) - - describe('DATA_KEY', () => { - it('should return plugin data key', () => { - expect(Modal.DATA_KEY).toEqual('bs.modal') - }) - }) - - describe('constructor', () => { - it('should take care of element either passed as a CSS selector or DOM element', () => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modalBySelector = new Modal('.modal') - const modalByElement = new Modal(modalEl) - - expect(modalBySelector._element).toEqual(modalEl) - expect(modalByElement._element).toEqual(modalEl) - }) - }) - - describe('toggle', () => { - it('should call ScrollBarHelper to handle scrollBar on body', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const spyHide = spyOn(ScrollBarHelper.prototype, 'hide').and.callThrough() - const spyReset = spyOn(ScrollBarHelper.prototype, 'reset').and.callThrough() - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('shown.bs.modal', () => { - expect(spyHide).toHaveBeenCalled() - modal.toggle() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(spyReset).toHaveBeenCalled() - resolve() - }) - - modal.toggle() - }) - }) - }) - - describe('show', () => { - it('should show a modal', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('show.bs.modal', event => { - expect(event).toBeDefined() - }) - - modalEl.addEventListener('shown.bs.modal', () => { - expect(modalEl.getAttribute('aria-modal')).toEqual('true') - expect(modalEl.getAttribute('role')).toEqual('dialog') - expect(modalEl.getAttribute('aria-hidden')).toBeNull() - expect(modalEl.style.display).toEqual('block') - expect(document.querySelector('.modal-backdrop')).not.toBeNull() - resolve() - }) - - modal.show() - }) - }) - - it('should show a modal without backdrop', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl, { - backdrop: false - }) - - modalEl.addEventListener('show.bs.modal', event => { - expect(event).toBeDefined() - }) - - modalEl.addEventListener('shown.bs.modal', () => { - expect(modalEl.getAttribute('aria-modal')).toEqual('true') - expect(modalEl.getAttribute('role')).toEqual('dialog') - expect(modalEl.getAttribute('aria-hidden')).toBeNull() - expect(modalEl.style.display).toEqual('block') - expect(document.querySelector('.modal-backdrop')).toBeNull() - resolve() - }) - - modal.show() - }) - }) - - it('should show a modal and append the element', () => { - return new Promise(resolve => { - const modalEl = document.createElement('div') - const id = 'dynamicModal' - - modalEl.setAttribute('id', id) - modalEl.classList.add('modal') - modalEl.innerHTML = '' - - const modal = new Modal(modalEl) - - modalEl.addEventListener('shown.bs.modal', () => { - const dynamicModal = document.getElementById(id) - expect(dynamicModal).not.toBeNull() - dynamicModal.remove() - resolve() - }) - - modal.show() - }) - }) - - it('should do nothing if a modal is shown', () => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - const spy = spyOn(EventHandler, 'trigger') - modal._isShown = true - - modal.show() - - expect(spy).not.toHaveBeenCalled() - }) - - it('should do nothing if a modal is transitioning', () => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - const spy = spyOn(EventHandler, 'trigger') - modal._isTransitioning = true - - modal.show() - - expect(spy).not.toHaveBeenCalled() - }) - - it('should not fire shown event when show is prevented', () => { - return new Promise((resolve, reject) => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('show.bs.modal', event => { - event.preventDefault() - - const expectedDone = () => { - expect().nothing() - resolve() - } - - setTimeout(expectedDone, 10) - }) - - modalEl.addEventListener('shown.bs.modal', () => { - reject(new Error('shown event triggered')) - }) - - modal.show() - }) - }) - - it('should be shown after the first call to show() has been prevented while fading is enabled ', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - let prevented = false - modalEl.addEventListener('show.bs.modal', event => { - if (!prevented) { - event.preventDefault() - prevented = true - - setTimeout(() => { - modal.show() - }) - } - }) - - modalEl.addEventListener('shown.bs.modal', () => { - expect(prevented).toBeTrue() - expect(modal._isAnimated()).toBeTrue() - resolve() - }) - - modal.show() - }) - }) - it('should set is transitioning if fade class is present', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('show.bs.modal', () => { - setTimeout(() => { - expect(modal._isTransitioning).toBeTrue() - }) - }) - - modalEl.addEventListener('shown.bs.modal', () => { - expect(modal._isTransitioning).toBeFalse() - resolve() - }) - - modal.show() - }) - }) - - it('should close modal when a click occurred on data-bs-dismiss="modal" inside modal', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const btnClose = fixtureEl.querySelector('[data-bs-dismiss="modal"]') - const modal = new Modal(modalEl) - - const spy = spyOn(modal, 'hide').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - btnClose.click() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(spy).toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - - it('should close modal when a click occurred on a data-bs-dismiss="modal" with "bs-target" outside of modal element', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const btnClose = fixtureEl.querySelector('[data-bs-dismiss="modal"]') - const modal = new Modal(modalEl) - - const spy = spyOn(modal, 'hide').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - btnClose.click() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(spy).toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - - it('should set .modal\'s scroll top to 0', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('shown.bs.modal', () => { - expect(modalEl.scrollTop).toEqual(0) - resolve() - }) - - modal.show() - }) - }) - - it('should set modal body scroll top to 0 if modal body do not exists', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const modalBody = modalEl.querySelector('.modal-body') - const modal = new Modal(modalEl) - - modalEl.addEventListener('shown.bs.modal', () => { - expect(modalBody.scrollTop).toEqual(0) - resolve() - }) - - modal.show() - }) - }) - - it('should not trap focus if focus equal to false', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl, { - focus: false - }) - - const spy = spyOn(modal._focustrap, 'activate').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - expect(spy).not.toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - - it('should add listener when escape touch is pressed', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - const spy = spyOn(modal, 'hide').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - const keydownEscape = createEvent('keydown') - keydownEscape.key = 'Escape' - - modalEl.dispatchEvent(keydownEscape) - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(spy).toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - - it('should do nothing when the pressed key is not escape', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - const spy = spyOn(modal, 'hide') - - const expectDone = () => { - expect(spy).not.toHaveBeenCalled() - - resolve() - } - - modalEl.addEventListener('shown.bs.modal', () => { - const keydownTab = createEvent('keydown') - keydownTab.key = 'Tab' - - modalEl.dispatchEvent(keydownTab) - setTimeout(expectDone, 30) - }) - - modal.show() - }) - }) - - it('should adjust dialog on resize', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - const spy = spyOn(modal, '_adjustDialog').and.callThrough() - - const expectDone = () => { - expect(spy).toHaveBeenCalled() - - resolve() - } - - modalEl.addEventListener('shown.bs.modal', () => { - const resizeEvent = createEvent('resize') - - window.dispatchEvent(resizeEvent) - setTimeout(expectDone, 10) - }) - - modal.show() - }) - }) - - it('should not close modal when clicking on modal-content', () => { - return new Promise((resolve, reject) => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - const shownCallback = () => { - setTimeout(() => { - expect(modal._isShown).toEqual(true) - resolve() - }, 10) - } - - modalEl.addEventListener('shown.bs.modal', () => { - fixtureEl.querySelector('.modal-dialog').click() - fixtureEl.querySelector('.modal-content').click() - shownCallback() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - reject(new Error('Should not hide a modal')) - }) - - modal.show() - }) - }) - - it('should not close modal when clicking outside of modal-content if backdrop = false', () => { - return new Promise((resolve, reject) => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl, { - backdrop: false - }) - - const shownCallback = () => { - setTimeout(() => { - expect(modal._isShown).toBeTrue() - resolve() - }, 10) - } - - modalEl.addEventListener('shown.bs.modal', () => { - modalEl.click() - shownCallback() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - reject(new Error('Should not hide a modal')) - }) - - modal.show() - }) - }) - - it('should not close modal when clicking outside of modal-content if backdrop = static', () => { - return new Promise((resolve, reject) => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl, { - backdrop: 'static' - }) - - const shownCallback = () => { - setTimeout(() => { - expect(modal._isShown).toBeTrue() - resolve() - }, 10) - } - - modalEl.addEventListener('shown.bs.modal', () => { - modalEl.click() - shownCallback() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - reject(new Error('Should not hide a modal')) - }) - - modal.show() - }) - }) - it('should close modal when escape key is pressed with keyboard = true and backdrop is static', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl, { - backdrop: 'static', - keyboard: true - }) - - const shownCallback = () => { - setTimeout(() => { - expect(modal._isShown).toBeFalse() - resolve() - }, 10) - } - - modalEl.addEventListener('shown.bs.modal', () => { - const keydownEscape = createEvent('keydown') - keydownEscape.key = 'Escape' - - modalEl.dispatchEvent(keydownEscape) - shownCallback() - }) - - modal.show() - }) - }) - - it('should not close modal when escape key is pressed with keyboard = false', () => { - return new Promise((resolve, reject) => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl, { - keyboard: false - }) - - const shownCallback = () => { - setTimeout(() => { - expect(modal._isShown).toBeTrue() - resolve() - }, 10) - } - - modalEl.addEventListener('shown.bs.modal', () => { - const keydownEscape = createEvent('keydown') - keydownEscape.key = 'Escape' - - modalEl.dispatchEvent(keydownEscape) - shownCallback() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - reject(new Error('Should not hide a modal')) - }) - - modal.show() - }) - }) - - it('should not overflow when clicking outside of modal-content if backdrop = static', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl, { - backdrop: 'static' - }) - - modalEl.addEventListener('shown.bs.modal', () => { - modalEl.click() - setTimeout(() => { - expect(modalEl.clientHeight).toEqual(modalEl.scrollHeight) - resolve() - }, 20) - }) - - modal.show() - }) - }) - - it('should not queue multiple callbacks when clicking outside of modal-content and backdrop = static', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl, { - backdrop: 'static' - }) - - modalEl.addEventListener('shown.bs.modal', () => { - const spy = spyOn(modal, '_queueCallback').and.callThrough() - const mouseDown = createEvent('mousedown') - - modalEl.dispatchEvent(mouseDown) - modalEl.click() - modalEl.dispatchEvent(mouseDown) - modalEl.click() - - setTimeout(() => { - expect(spy).toHaveBeenCalledTimes(1) - resolve() - }, 20) - }) - - modal.show() - }) - }) - - it('should trap focus', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - const spy = spyOn(modal._focustrap, 'activate').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - expect(spy).toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - }) - - describe('hide', () => { - it('should hide a modal', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - const backdropSpy = spyOn(modal._backdrop, 'hide').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - modal.hide() - }) - - modalEl.addEventListener('hide.bs.modal', event => { - expect(event).toBeDefined() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(modalEl.getAttribute('aria-modal')).toBeNull() - expect(modalEl.getAttribute('role')).toBeNull() - expect(modalEl.getAttribute('aria-hidden')).toEqual('true') - expect(modalEl.style.display).toEqual('none') - expect(backdropSpy).toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - - it('should close modal when clicking outside of modal-content', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const dialogEl = modalEl.querySelector('.modal-dialog') - const modal = new Modal(modalEl) - - const spy = spyOn(modal, 'hide') - - modalEl.addEventListener('shown.bs.modal', () => { - const mouseDown = createEvent('mousedown') - - dialogEl.dispatchEvent(mouseDown) - modalEl.click() - expect(spy).not.toHaveBeenCalled() - - modalEl.dispatchEvent(mouseDown) - modalEl.click() - expect(spy).toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - - it('should not close modal when clicking on an element removed from modal content', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const buttonEl = modalEl.querySelector('.btn') - const modal = new Modal(modalEl) - - const spy = spyOn(modal, 'hide') - buttonEl.addEventListener('click', () => { - buttonEl.remove() - }) - - modalEl.addEventListener('shown.bs.modal', () => { - modalEl.dispatchEvent(createEvent('mousedown')) - buttonEl.click() - expect(spy).not.toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - - it('should do nothing is the modal is not shown', () => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modal.hide() - - expect().nothing() - }) - - it('should do nothing is the modal is transitioning', () => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modal._isTransitioning = true - modal.hide() - - expect().nothing() - }) - - it('should not hide a modal if hide is prevented', () => { - return new Promise((resolve, reject) => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('shown.bs.modal', () => { - modal.hide() - }) - - const hideCallback = () => { - setTimeout(() => { - expect(modal._isShown).toBeTrue() - resolve() - }, 10) - } - - modalEl.addEventListener('hide.bs.modal', event => { - event.preventDefault() - hideCallback() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - reject(new Error('should not trigger hidden')) - }) - - modal.show() - }) - }) - - it('should release focus trap', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - const spy = spyOn(modal._focustrap, 'deactivate').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - modal.hide() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(spy).toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - }) - - describe('dispose', () => { - it('should dispose a modal', () => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - const focustrap = modal._focustrap - const spyDeactivate = spyOn(focustrap, 'deactivate').and.callThrough() - - expect(Modal.getInstance(modalEl)).toEqual(modal) - - const spyOff = spyOn(EventHandler, 'off') - - modal.dispose() - - expect(Modal.getInstance(modalEl)).toBeNull() - expect(spyOff).toHaveBeenCalledTimes(3) - expect(spyDeactivate).toHaveBeenCalled() - }) - }) - - describe('handleUpdate', () => { - it('should call adjust dialog', () => { - fixtureEl.innerHTML = '' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - const spy = spyOn(modal, '_adjustDialog') - - modal.handleUpdate() - - expect(spy).toHaveBeenCalled() - }) - }) - - describe('data-api', () => { - it('should toggle modal', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]') - - modalEl.addEventListener('shown.bs.modal', () => { - expect(modalEl.getAttribute('aria-modal')).toEqual('true') - expect(modalEl.getAttribute('role')).toEqual('dialog') - expect(modalEl.getAttribute('aria-hidden')).toBeNull() - expect(modalEl.style.display).toEqual('block') - expect(document.querySelector('.modal-backdrop')).not.toBeNull() - setTimeout(() => trigger.click(), 10) - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(modalEl.getAttribute('aria-modal')).toBeNull() - expect(modalEl.getAttribute('role')).toBeNull() - expect(modalEl.getAttribute('aria-hidden')).toEqual('true') - expect(modalEl.style.display).toEqual('none') - expect(document.querySelector('.modal-backdrop')).toBeNull() - resolve() - }) - - trigger.click() - }) - }) - - it('should not recreate a new modal', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]') - - const spy = spyOn(modal, 'show').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - expect(spy).toHaveBeenCalled() - resolve() - }) - - trigger.click() - }) - }) - - it('should prevent default when the trigger is or ', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]') - - const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - expect(modalEl.getAttribute('aria-modal')).toEqual('true') - expect(modalEl.getAttribute('role')).toEqual('dialog') - expect(modalEl.getAttribute('aria-hidden')).toBeNull() - expect(modalEl.style.display).toEqual('block') - expect(document.querySelector('.modal-backdrop')).not.toBeNull() - expect(spy).toHaveBeenCalled() - resolve() - }) - - trigger.click() - }) - }) - - it('should focus the trigger on hide', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]') - - const spy = spyOn(trigger, 'focus') - - modalEl.addEventListener('shown.bs.modal', () => { - const modal = Modal.getInstance(modalEl) - - modal.hide() - }) - - const hideListener = () => { - setTimeout(() => { - expect(spy).toHaveBeenCalled() - resolve() - }, 20) - } - - modalEl.addEventListener('hidden.bs.modal', () => { - hideListener() - }) - - trigger.click() - }) - }) - - it('should open modal, having special characters in its id', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]') - - modalEl.addEventListener('shown.bs.modal', () => { - resolve() - }) - - trigger.click() - }) - }) - - it('should not prevent default when a click occurred on data-bs-dismiss="modal" where tagName is DIFFERENT than or ', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const btnClose = fixtureEl.querySelector('button[data-bs-dismiss="modal"]') - const modal = new Modal(modalEl) - - const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - btnClose.click() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(spy).not.toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - - it('should prevent default when a click occurred on data-bs-dismiss="modal" where tagName is or ', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const btnClose = fixtureEl.querySelector('a[data-bs-dismiss="modal"]') - const modal = new Modal(modalEl) - - const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough() - - modalEl.addEventListener('shown.bs.modal', () => { - btnClose.click() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(spy).toHaveBeenCalled() - resolve() - }) - - modal.show() - }) - }) - it('should not focus the trigger if the modal is not visible', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]') - - const spy = spyOn(trigger, 'focus') - - modalEl.addEventListener('shown.bs.modal', () => { - const modal = Modal.getInstance(modalEl) - - modal.hide() - }) - - const hideListener = () => { - setTimeout(() => { - expect(spy).not.toHaveBeenCalled() - resolve() - }, 20) - } - - modalEl.addEventListener('hidden.bs.modal', () => { - hideListener() - }) - - trigger.click() - }) - }) - it('should not focus the trigger if the modal is not shown', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '' - ].join('') - - const modalEl = fixtureEl.querySelector('.modal') - const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]') - - const spy = spyOn(trigger, 'focus') - - const showListener = () => { - setTimeout(() => { - expect(spy).not.toHaveBeenCalled() - resolve() - }, 10) - } - - modalEl.addEventListener('show.bs.modal', event => { - event.preventDefault() - showListener() - }) - - trigger.click() - }) - }) - - it('should call hide first, if another modal is open', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '', - '' - ].join('') - - const trigger2 = fixtureEl.querySelector('button') - const modalEl1 = document.querySelector('#modal1') - const modalEl2 = document.querySelector('#modal2') - const modal1 = new Modal(modalEl1) - - modalEl1.addEventListener('shown.bs.modal', () => { - trigger2.click() - }) - modalEl1.addEventListener('hidden.bs.modal', () => { - expect(Modal.getInstance(modalEl2)).not.toBeNull() - expect(modalEl2).toHaveClass('show') - resolve() - }) - modal1.show() - }) - }) - }) - - describe('getInstance', () => { - it('should return modal instance', () => { - fixtureEl.innerHTML = '' - - const div = fixtureEl.querySelector('div') - const modal = new Modal(div) - - expect(Modal.getInstance(div)).toEqual(modal) - expect(Modal.getInstance(div)).toBeInstanceOf(Modal) - }) - - it('should return null when there is no modal instance', () => { - fixtureEl.innerHTML = '' - - const div = fixtureEl.querySelector('div') - - expect(Modal.getInstance(div)).toBeNull() - }) - }) - - describe('getOrCreateInstance', () => { - it('should return modal instance', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - const modal = new Modal(div) - - expect(Modal.getOrCreateInstance(div)).toEqual(modal) - expect(Modal.getInstance(div)).toEqual(Modal.getOrCreateInstance(div, {})) - expect(Modal.getOrCreateInstance(div)).toBeInstanceOf(Modal) - }) - - it('should return new instance when there is no modal instance', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - - expect(Modal.getInstance(div)).toBeNull() - expect(Modal.getOrCreateInstance(div)).toBeInstanceOf(Modal) - }) - - it('should return new instance when there is no modal instance with given configuration', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - - expect(Modal.getInstance(div)).toBeNull() - const modal = Modal.getOrCreateInstance(div, { - backdrop: true - }) - expect(modal).toBeInstanceOf(Modal) - - expect(modal._config.backdrop).toBeTrue() - }) - - it('should return the instance when exists without given configuration', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - const modal = new Modal(div, { - backdrop: true - }) - expect(Modal.getInstance(div)).toEqual(modal) - - const modal2 = Modal.getOrCreateInstance(div, { - backdrop: false - }) - expect(modal).toBeInstanceOf(Modal) - expect(modal2).toEqual(modal) - - expect(modal2._config.backdrop).toBeTrue() - }) - }) -}) diff --git a/js/tests/visual/dialog.html b/js/tests/visual/dialog.html new file mode 100644 index 0000000000..68ed33612f --- /dev/null +++ b/js/tests/visual/dialog.html @@ -0,0 +1,207 @@ + + + + + + + Dialog + + + +
+

Dialog Bootstrap Visual Test

+ + +
+

Dialog title

+ +
+
+

Text in a dialog

+

Duis mollis, est non commodo luctus, nisi erat porttitor ligula.

+ +

Popover in a dialog

+

This should trigger a popover on click.

+ +

Tooltips in a dialog

+

This link and that link should have tooltips on hover.

+ +
+ +

Overflowing text to show scroll behavior

+

Cras mattis consectetur purus sit amet fermentum. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.

+

Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.

+

Aenean lacinia bibendum nulla sed consectetur. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Donec sed odio dui. Donec ullamcorper nulla non metus auctor fringilla.

+
+ +
+ + +
+

Static Backdrop Dialog

+ +
+
+

This dialog has a static backdrop. Clicking outside won't close it.

+

Press Escape or click the close button to dismiss.

+
+ +
+ + +
+

No Keyboard Dismiss

+ +
+
+

This dialog cannot be dismissed with the Escape key.

+

You must use the close button.

+
+ +
+ + +
+

First Dialog

+ +
+
+

Click the button below to swap to the second dialog.

+

Notice how the backdrop stays visible during the swap.

+
+ +
+ + +
+

Second Dialog

+ +
+
+

You're now in the second dialog!

+

You can swap back to the first dialog or close this one.

+
+ +
+ + +
+

Non-Modal Dialog

+ +
+
+

This is a non-modal dialog opened with show() instead of showModal().

+
    +
  • No backdrop
  • +
  • No focus trapping
  • +
  • Can interact with the page behind
  • +
  • Escape key still closes (if keyboard: true)
  • +
+

Try clicking the buttons on the page behind this dialog!

+
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + (uses show() instead of showModal()) +
+ +
+ +
+ +
+ +
+
+ +

+ + +
+ + + + + diff --git a/js/tests/visual/modal.html b/js/tests/visual/modal.html deleted file mode 100644 index efb5127b5a..0000000000 --- a/js/tests/visual/modal.html +++ /dev/null @@ -1,279 +0,0 @@ - - - - - - - Modal - - - - - -
-

Modal Bootstrap Visual Test

- - - - - - - - - - - -

- - - (See Issue #18365) - -

- - - -

- - - - - -

- - -
- - - - - diff --git a/scss/_dialog.scss b/scss/_dialog.scss new file mode 100644 index 0000000000..9989dba3e4 --- /dev/null +++ b/scss/_dialog.scss @@ -0,0 +1,234 @@ +@use "sass:map"; +@use "config" as *; +@use "variables" as *; +@use "layout/breakpoints" as *; +@use "mixins/border-radius" as *; +@use "mixins/box-shadow" as *; +@use "mixins/transition" as *; + +// Native component +// Uses the browser's native dialog element with showModal()/show()/close() APIs +// Leverages native [open] attribute and ::backdrop pseudo-element + +// scss-docs-start dialog-css-vars +$dialog-padding: 1rem !default; +$dialog-width: 500px !default; +$dialog-width-sm: 280px !default; +$dialog-width-lg: 800px !default; +$dialog-width-xl: 1140px !default; +$dialog-margin: 1.75rem !default; +$dialog-color: var(--#{$prefix}fg-body) !default; +$dialog-bg: var(--#{$prefix}bg-body) !default; +$dialog-border-color: var(--#{$prefix}border-color-translucent) !default; +$dialog-border-width: var(--#{$prefix}border-width) !default; +$dialog-border-radius: var(--#{$prefix}border-radius-lg) !default; +$dialog-box-shadow: var(--#{$prefix}box-shadow-lg) !default; +$dialog-backdrop-bg: rgba(0, 0, 0, .5) !default; +$dialog-header-padding: 1rem !default; +$dialog-header-border-color: var(--#{$prefix}border-color) !default; +$dialog-header-border-width: var(--#{$prefix}border-width) !default; +$dialog-footer-padding: 1rem !default; +$dialog-footer-border-color: var(--#{$prefix}border-color) !default; +$dialog-footer-border-width: var(--#{$prefix}border-width) !default; +$dialog-footer-gap: .5rem !default; +// scss-docs-end dialog-css-vars + +@layer components { + // Prevent body scroll when dialog is open + .dialog-open { + overflow: hidden; + scrollbar-gutter: stable; + } + + .dialog { + --#{$prefix}dialog-padding: #{$dialog-padding}; + --#{$prefix}dialog-width: #{$dialog-width}; + --#{$prefix}dialog-margin: #{$dialog-margin}; + --#{$prefix}dialog-color: #{$dialog-color}; + --#{$prefix}dialog-bg: #{$dialog-bg}; + --#{$prefix}dialog-border-color: #{$dialog-border-color}; + --#{$prefix}dialog-border-width: #{$dialog-border-width}; + --#{$prefix}dialog-border-radius: #{$dialog-border-radius}; + --#{$prefix}dialog-box-shadow: #{$dialog-box-shadow}; + --#{$prefix}dialog-backdrop-bg: #{$dialog-backdrop-bg}; + --#{$prefix}dialog-header-padding: #{$dialog-header-padding}; + --#{$prefix}dialog-header-border-color: #{$dialog-header-border-color}; + --#{$prefix}dialog-header-border-width: #{$dialog-header-border-width}; + --#{$prefix}dialog-footer-padding: #{$dialog-footer-padding}; + --#{$prefix}dialog-footer-border-color: #{$dialog-footer-border-color}; + --#{$prefix}dialog-footer-border-width: #{$dialog-footer-border-width}; + --#{$prefix}dialog-footer-gap: #{$dialog-footer-gap}; + + // Reset native dialog styles + max-width: var(--#{$prefix}dialog-width); + max-height: calc(100% - var(--#{$prefix}dialog-margin) * 2); + padding: 0; + margin: auto; + color: var(--#{$prefix}dialog-color); + background-color: var(--#{$prefix}dialog-bg); + background-clip: padding-box; + border: var(--#{$prefix}dialog-border-width) solid var(--#{$prefix}dialog-border-color); + @include border-radius(var(--#{$prefix}dialog-border-radius)); + @include box-shadow(var(--#{$prefix}dialog-box-shadow)); + + // Native backdrop styling via ::backdrop pseudo-element + &::backdrop { + background-color: var(--#{$prefix}dialog-backdrop-bg); + } + + // Animation support using native [open] attribute + &.fade { + opacity: 0; + @include transition(opacity .15s linear); + + &::backdrop { + opacity: 0; + @include transition(opacity .15s linear); + } + + &[open] { + opacity: 1; + + &::backdrop { + opacity: 1; + } + } + } + + // Static backdrop "bounce" animation (modal dialogs only) + &.dialog-static { + transform: scale(1.02); + } + + // Non-modal dialog positioning + // show() doesn't use the top layer, so we need explicit positioning and z-index + &.dialog-nonmodal { + position: fixed; + inset-block-start: 50%; + inset-inline-start: 50%; + z-index: $zindex-dialog; + margin-inline: 0; + transform: translate(-50%, -50%); + } + + // Overflow dialog - scrollable viewport container with dialog box inside + &.dialog-overflow { + // Make dialog element the full-viewport scrollable container + position: fixed; + inset: 0; + width: 100%; + max-width: 100%; + height: 100%; + max-height: 100%; + padding: var(--#{$prefix}dialog-margin); + margin: 0; + overflow-y: auto; + overscroll-behavior: contain; + background: transparent; + border: 0; + box-shadow: none; + + // The visual dialog box is a child wrapper + > .dialog-box { + max-width: var(--#{$prefix}dialog-width); + margin-block-end: var(--#{$prefix}dialog-margin); + margin-inline: auto; + color: var(--#{$prefix}dialog-color); + background-color: var(--#{$prefix}dialog-bg); + background-clip: padding-box; + border: var(--#{$prefix}dialog-border-width) solid var(--#{$prefix}dialog-border-color); + @include border-radius(var(--#{$prefix}dialog-border-radius)); + @include box-shadow(var(--#{$prefix}dialog-box-shadow)); + } + } + + // Scrollable dialog body (header/footer stay fixed) + &.dialog-scrollable[open] { + display: flex; + flex-direction: column; + max-height: calc(100% - var(--#{$prefix}dialog-margin) * 2); + + .dialog-body { + overflow-y: auto; + } + } + } + + // Dialog sizes + .dialog-sm { --#{$prefix}dialog-width: #{$dialog-width-sm}; } + .dialog-lg { --#{$prefix}dialog-width: #{$dialog-width-lg}; } + .dialog-xl { --#{$prefix}dialog-width: #{$dialog-width-xl}; } + + // Fullscreen dialog + .dialog-fullscreen { + --#{$prefix}dialog-width: 100vw; + --#{$prefix}dialog-margin: 0; + --#{$prefix}dialog-border-radius: 0; + + width: 100%; + max-width: none; + height: 100%; + max-height: none; + } + + // Responsive fullscreen dialogs + @each $breakpoint in map.keys($grid-breakpoints) { + $infix: breakpoint-infix($breakpoint, $grid-breakpoints); + $postfix: if($infix != "", #{$infix}-down, ""); + + @if $postfix != "" { + @include media-breakpoint-down($breakpoint) { + .dialog-fullscreen#{$postfix} { + --#{$prefix}dialog-width: 100vw; + --#{$prefix}dialog-margin: 0; + --#{$prefix}dialog-border-radius: 0; + + width: 100%; + max-width: none; + height: 100%; + max-height: none; + } + } + } + } + + // Dialog header + .dialog-header { + display: flex; + flex-shrink: 0; + align-items: center; + padding: var(--#{$prefix}dialog-header-padding); + border-block-end: var(--#{$prefix}dialog-header-border-width) solid var(--#{$prefix}dialog-header-border-color); + + .btn-close { + margin-inline-start: auto; + } + } + + // Dialog title + .dialog-title { + margin-bottom: 0; + font-size: var(--#{$prefix}font-size-md); + line-height: 1.5; + } + + // Dialog body + .dialog-body { + position: relative; + flex: 1 1 auto; + padding: var(--#{$prefix}dialog-padding); + overflow-y: auto; + } + + // Dialog footer + .dialog-footer { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: var(--#{$prefix}dialog-footer-gap); + align-items: center; + justify-content: flex-end; + padding: var(--#{$prefix}dialog-footer-padding); + border-block-start: var(--#{$prefix}dialog-footer-border-width) solid var(--#{$prefix}dialog-footer-border-color); + } +} diff --git a/scss/_modal.scss b/scss/_modal.scss deleted file mode 100644 index b6cee700d9..0000000000 --- a/scss/_modal.scss +++ /dev/null @@ -1,248 +0,0 @@ -@use "sass:map"; -@use "config" as *; -@use "variables" as *; -@use "mixins/border-radius" as *; -@use "mixins/box-shadow" as *; -@use "mixins/transition" as *; -@use "mixins/gradients" as *; -@use "mixins/backdrop" as *; -@use "vendor/rfs" as *; -@use "layout/breakpoints" as *; - -// .modal-open - body class for killing the scroll -// .modal - container to scroll within -// .modal-dialog - positioning shell for the actual modal -// .modal-content - actual modal w/ bg and corners and stuff - -@layer components { - // Container that the modal scrolls within - .modal { - // scss-docs-start modal-css-vars - --#{$prefix}modal-zindex: #{$zindex-modal}; - --#{$prefix}modal-width: #{$modal-md}; - --#{$prefix}modal-padding: #{$modal-inner-padding}; - --#{$prefix}modal-margin: #{$modal-dialog-margin}; - --#{$prefix}modal-color: #{$modal-content-color}; - --#{$prefix}modal-bg: #{$modal-content-bg}; - --#{$prefix}modal-border-color: #{$modal-content-border-color}; - --#{$prefix}modal-border-width: #{$modal-content-border-width}; - --#{$prefix}modal-border-radius: #{$modal-content-border-radius}; - --#{$prefix}modal-box-shadow: #{$modal-content-box-shadow-xs}; - --#{$prefix}modal-inner-border-radius: #{$modal-content-inner-border-radius}; - --#{$prefix}modal-header-padding-x: #{$modal-header-padding-x}; - --#{$prefix}modal-header-padding-y: #{$modal-header-padding-y}; - --#{$prefix}modal-header-padding: #{$modal-header-padding}; // Todo in v6: Split this padding into x and y - --#{$prefix}modal-header-border-color: #{$modal-header-border-color}; - --#{$prefix}modal-header-border-width: #{$modal-header-border-width}; - --#{$prefix}modal-title-line-height: #{$modal-title-line-height}; - --#{$prefix}modal-footer-gap: #{$modal-footer-margin-between}; - --#{$prefix}modal-footer-bg: #{$modal-footer-bg}; - --#{$prefix}modal-footer-border-color: #{$modal-footer-border-color}; - --#{$prefix}modal-footer-border-width: #{$modal-footer-border-width}; - // scss-docs-end modal-css-vars - - position: fixed; - inset: 0 auto auto 0; - z-index: var(--#{$prefix}modal-zindex); - display: none; - width: 100%; - height: 100%; - overflow-x: hidden; - overflow-y: auto; - // Prevent Chrome on Windows from adding a focus outline. For details, see - // https://github.com/twbs/bootstrap/pull/10951. - outline: 0; - // We deliberately don't use `-webkit-overflow-scrolling: touch;` due to a - // gnarly iOS Safari bug: https://bugs.webkit.org/show_bug.cgi?id=158342 - // See also https://github.com/twbs/bootstrap/issues/17695 - } - - // Shell div to position the modal with bottom padding - .modal-dialog { - position: relative; - width: auto; - margin: var(--#{$prefix}modal-margin); - // allow clicks to pass through for custom click handling to close modal - pointer-events: none; - - // When fading in the modal, animate it to slide down - .modal.fade & { - transform: $modal-fade-transform; - @include transition($modal-transition); - } - .modal.show & { - transform: $modal-show-transform; - } - - // When trying to close, animate focus to scale - .modal.modal-static & { - transform: $modal-scale-transform; - } - } - - .modal-dialog-scrollable { - height: calc(100% - var(--#{$prefix}modal-margin) * 2); - - .modal-content { - max-height: 100%; - overflow: hidden; - } - - .modal-body { - overflow-y: auto; - } - } - - .modal-dialog-centered { - display: flex; - align-items: center; - min-height: calc(100% - var(--#{$prefix}modal-margin) * 2); - } - - // Actual modal - .modal-content { - position: relative; - display: flex; - flex-direction: column; - width: 100%; // Ensure `.modal-content` extends the full width of the parent `.modal-dialog` - // counteract the pointer-events: none; in the .modal-dialog - color: var(--#{$prefix}modal-color); - pointer-events: auto; - background-color: var(--#{$prefix}modal-bg); - background-clip: padding-box; - border: var(--#{$prefix}modal-border-width) solid var(--#{$prefix}modal-border-color); - @include border-radius(var(--#{$prefix}modal-border-radius)); - @include box-shadow(var(--#{$prefix}modal-box-shadow)); - // Remove focus outline from opened modal - outline: 0; - } - - // Modal background - .modal-backdrop { - // scss-docs-start modal-backdrop-css-vars - --#{$prefix}backdrop-zindex: #{$zindex-modal-backdrop}; - --#{$prefix}backdrop-bg: #{$modal-backdrop-bg}; - --#{$prefix}backdrop-opacity: #{$modal-backdrop-opacity}; - // scss-docs-end modal-backdrop-css-vars - - @include overlay-backdrop(var(--#{$prefix}backdrop-zindex), var(--#{$prefix}backdrop-bg), var(--#{$prefix}backdrop-opacity)); - } - - // Modal header - // Top section of the modal w/ title and dismiss - .modal-header { - display: flex; - flex-shrink: 0; - align-items: center; - padding: var(--#{$prefix}modal-header-padding); - border-block-end: var(--#{$prefix}modal-header-border-width) solid var(--#{$prefix}modal-header-border-color); - @include border-top-radius(var(--#{$prefix}modal-inner-border-radius)); - - .btn-close { - padding: calc(var(--#{$prefix}modal-header-padding-y) * .5) calc(var(--#{$prefix}modal-header-padding-x) * .5); - // Split properties to avoid invalid calc() function if value is 0 - margin-inline-start: auto; - margin-inline-end: calc(-.5 * var(--#{$prefix}modal-header-padding-x)); - margin-top: calc(-.5 * var(--#{$prefix}modal-header-padding-y)); - margin-bottom: calc(-.5 * var(--#{$prefix}modal-header-padding-y)); - } - } - - // Title text within header - .modal-title { - margin-bottom: 0; - line-height: var(--#{$prefix}modal-title-line-height); - } - - // Modal body - // Where all modal content resides (sibling of .modal-header and .modal-footer) - .modal-body { - position: relative; - // Enable `flex-grow: 1` so that the body take up as much space as possible - // when there should be a fixed height on `.modal-dialog`. - flex: 1 1 auto; - padding: var(--#{$prefix}modal-padding); - } - - // Footer (for actions) - .modal-footer { - display: flex; - flex-shrink: 0; - flex-wrap: wrap; - align-items: center; // vertically center - justify-content: flex-end; // Right align buttons with flex property because text-align doesn't work on flex items - padding: calc(var(--#{$prefix}modal-padding) - var(--#{$prefix}modal-footer-gap) * .5); - background-color: var(--#{$prefix}modal-footer-bg); - border-block-start: var(--#{$prefix}modal-footer-border-width) solid var(--#{$prefix}modal-footer-border-color); - @include border-bottom-radius(var(--#{$prefix}modal-inner-border-radius)); - - // Place margin between footer elements - // This solution is far from ideal because of the universal selector usage, - // but is needed to fix https://github.com/twbs/bootstrap/issues/24800 - > * { - margin: calc(var(--#{$prefix}modal-footer-gap) * .5); // Todo in v6: replace with gap on parent class - } - } - - // Scale up the modal - @include media-breakpoint-up(sm) { - .modal { - --#{$prefix}modal-margin: #{$modal-dialog-margin-y-sm-up}; - --#{$prefix}modal-box-shadow: #{$modal-content-box-shadow-sm-up}; - } - - // Automatically set modal's width for larger viewports - .modal-dialog { - max-width: var(--#{$prefix}modal-width); - margin-inline: auto; - } - - .modal-sm { - --#{$prefix}modal-width: #{$modal-sm}; - } - } - - @include media-breakpoint-up(lg) { - .modal-lg, - .modal-xl { - --#{$prefix}modal-width: #{$modal-lg}; - } - } - - @include media-breakpoint-up(xl) { - .modal-xl { - --#{$prefix}modal-width: #{$modal-xl}; - } - } - - // scss-docs-start modal-fullscreen-loop - @each $breakpoint in map.keys($grid-breakpoints) { - $infix: breakpoint-infix($breakpoint, $grid-breakpoints); - $postfix: if($infix != "", $infix + "-down", ""); - - @include media-breakpoint-down($breakpoint) { - .modal-fullscreen#{$postfix} { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - - .modal-content { - height: 100%; - border: 0; - @include border-radius(0); - } - - .modal-header, - .modal-footer { - @include border-radius(0); - } - - .modal-body { - overflow-y: auto; - } - } - } - } - // scss-docs-end modal-fullscreen-loop -} diff --git a/scss/_variables.scss b/scss/_variables.scss index 7a657e2f0c..cffadfe33c 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -7,7 +7,7 @@ // Variables // // Variables should follow the `$component-state-property-size` formula for -// consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs. +// consistent naming. Ex: $nav-link-disabled-color and $btn-padding-y-lg. // scss-docs-start theme-color-variables // $primary: $blue-500 !default; @@ -428,8 +428,7 @@ $zindex-sticky: 1020 !default; $zindex-fixed: 1030 !default; $zindex-offcanvas-backdrop: 1040 !default; $zindex-offcanvas: 1045 !default; -$zindex-modal-backdrop: 1050 !default; -$zindex-modal: 1055 !default; +$zindex-dialog: 1055 !default; $zindex-popover: 1070 !default; $zindex-tooltip: 1080 !default; $zindex-toast: 1090 !default; @@ -446,67 +445,22 @@ $zindex-levels: ( // scss-docs-end zindex-levels-map -// Modals - -// scss-docs-start modal-variables -$modal-inner-padding: $spacer !default; - -$modal-footer-margin-between: .5rem !default; - -$modal-dialog-margin: .5rem !default; -$modal-dialog-margin-y-sm-up: 1.75rem !default; - -$modal-title-line-height: $line-height-base !default; - -$modal-content-color: var(--#{$prefix}color-body) !default; -$modal-content-bg: var(--#{$prefix}bg-body) !default; -$modal-content-border-color: var(--#{$prefix}border-color-translucent) !default; -$modal-content-border-width: var(--#{$prefix}border-width) !default; -$modal-content-border-radius: var(--#{$prefix}border-radius-lg) !default; -$modal-content-inner-border-radius: calc(#{$modal-content-border-radius} - #{$modal-content-border-width}) !default; -$modal-content-box-shadow-xs: var(--#{$prefix}box-shadow-sm) !default; -$modal-content-box-shadow-sm-up: var(--#{$prefix}box-shadow) !default; - -$modal-backdrop-bg: $black !default; -$modal-backdrop-opacity: .5 !default; - -$modal-header-border-color: var(--#{$prefix}border-color) !default; -$modal-header-border-width: $modal-content-border-width !default; -$modal-header-padding-y: $modal-inner-padding !default; -$modal-header-padding-x: $modal-inner-padding !default; -$modal-header-padding: $modal-header-padding-y $modal-header-padding-x !default; // Keep this for backwards compatibility - -$modal-footer-bg: null !default; -$modal-footer-border-color: $modal-header-border-color !default; -$modal-footer-border-width: $modal-header-border-width !default; - -$modal-sm: 300px !default; -$modal-md: 500px !default; -$modal-lg: 800px !default; -$modal-xl: 1200px !default; - -$modal-fade-transform: translate(0, -50px) !default; -$modal-show-transform: none !default; -$modal-transition: transform .3s ease-out !default; -$modal-scale-transform: scale(1.02) !default; -// scss-docs-end modal-variables - // Offcanvas // scss-docs-start offcanvas-variables -$offcanvas-padding-y: $modal-inner-padding !default; -$offcanvas-padding-x: $modal-inner-padding !default; +$offcanvas-padding-y: $spacer !default; +$offcanvas-padding-x: $spacer !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-border-color: var(--#{$prefix}border-color-translucent) !default; +$offcanvas-border-width: var(--#{$prefix}border-width) !default; +$offcanvas-title-line-height: $line-height-base !default; $offcanvas-bg-color: var(--#{$prefix}bg-body) !default; $offcanvas-color: var(--#{$prefix}color-body) !default; -$offcanvas-box-shadow: $modal-content-box-shadow-xs !default; -$offcanvas-backdrop-bg: $modal-backdrop-bg !default; -$offcanvas-backdrop-opacity: $modal-backdrop-opacity !default; +$offcanvas-box-shadow: var(--#{$prefix}box-shadow-sm) !default; +$offcanvas-backdrop-bg: $black !default; +$offcanvas-backdrop-opacity: .5 !default; // scss-docs-end offcanvas-variables // Code diff --git a/scss/bootstrap.scss b/scss/bootstrap.scss index 57f8ed03bc..52fc04d4b1 100644 --- a/scss/bootstrap.scss +++ b/scss/bootstrap.scss @@ -17,9 +17,9 @@ @forward "breadcrumb"; @forward "card"; @forward "carousel"; +@forward "dialog"; @forward "dropdown"; @forward "list-group"; -@forward "modal"; @forward "nav"; @forward "navbar"; @forward "offcanvas"; diff --git a/site/data/examples.yml b/site/data/examples.yml index ad9c5d2cc2..30d24da58b 100644 --- a/site/data/examples.yml +++ b/site/data/examples.yml @@ -53,8 +53,8 @@ description: 'Enhance your dropdowns with filters, icons, custom styles, and more.' - name: List groups description: 'Extend list groups with utilities and custom styles for any content.' - - name: Modals - description: 'Transform modals to serve any purpose, from feature tours to dialogs.' + - name: Dialogs + description: 'Transform dialogs to serve any purpose, from feature tours to confirmations.' - name: Badges description: 'Make badges work with custom inner HTML and new looks.' - name: Breadcrumbs diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index 53a6f200af..2cbfc90999 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -92,9 +92,9 @@ - title: Carousel - title: Close button - title: Collapse + - title: Dialog - title: Dropdowns - title: List group - - title: Modal - title: Navbar - title: Navs & tabs - title: Offcanvas diff --git a/site/src/assets/examples/cheatsheet-rtl/index.astro b/site/src/assets/examples/cheatsheet-rtl/index.astro index 66a2ce8ef7..eb494e1572 100644 --- a/site/src/assets/examples/cheatsheet-rtl/index.astro +++ b/site/src/assets/examples/cheatsheet-rtl/index.astro @@ -57,7 +57,6 @@ import Placeholder from "@shortcodes/Placeholder.astro"
  • شرائح العرض
  • القوائم المنسدلة
  • مجموعة العناصر
  • -
  • الصندوق العائم
  • التنقل
  • شريط التنقل
  • ترقيم الصفحات
  • @@ -1118,31 +1117,6 @@ import Placeholder from "@shortcodes/Placeholder.astro" `]} /> - - - - - - diff --git a/site/src/assets/examples/cheatsheet/index.astro b/site/src/assets/examples/cheatsheet/index.astro index 08b18bb6a9..d5e5c6ad34 100644 --- a/site/src/assets/examples/cheatsheet/index.astro +++ b/site/src/assets/examples/cheatsheet/index.astro @@ -54,9 +54,9 @@ export const body_class = 'bg-body-tertiary'
  • Button group
  • Card
  • Carousel
  • +
  • Dialog
  • Dropdowns
  • List group
  • -
  • Modal
  • Navs
  • Navbar
  • Pagination
  • @@ -879,6 +879,23 @@ export const body_class = 'bg-body-tertiary' `} /> +
    +
    +

    Dialog

    + Documentation +
    + +
    + + Launch demo dialog + + + `} /> +
    +
    -
    + +
    +

    Static backdrop

    + +
    +
    +

    I will not close if you click outside of me. Use the close button or press Escape.

    +
    + +
    diff --git a/site/src/assets/examples/dialogs/dialogs.css b/site/src/assets/examples/dialogs/dialogs.css new file mode 100644 index 0000000000..5982b43d45 --- /dev/null +++ b/site/src/assets/examples/dialogs/dialogs.css @@ -0,0 +1,9 @@ +/* Override default dialog positioning for static examples */ +.dialog-example { + position: relative; + max-width: 500px; +} + +.w-340px { + max-width: 340px; +} diff --git a/site/src/assets/examples/dialogs/index.astro b/site/src/assets/examples/dialogs/index.astro new file mode 100644 index 0000000000..cd88884a96 --- /dev/null +++ b/site/src/assets/examples/dialogs/index.astro @@ -0,0 +1,138 @@ +--- +export const title = 'Dialogs' +export const extra_css = ['dialogs.css'] +--- + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +

    Modal title

    + +
    +
    +

    This is a modal sheet, a variation of the modal that docs itself to the bottom of the viewport like the newer share sheets in iOS.

    +
    + +
    +
    + +
    + + +
    + +
    +
    Enable this setting?
    +

    You can always change your mind in your account settings.

    +
    + +
    +
    + +
    + + +
    + +
    +

    What's new

    +
    +
    +
      +
    • + +
      +
      Grid view
      +

      Not into lists? Try the new grid view.

      +
      +
    • +
    • + +
      +
      Bookmarks
      +

      Save items you love for easy access later.

      +
      +
    • +
    • + +
      +
      Video embeds
      +

      Share videos wherever you go.

      +
      +
    • +
    +
    + +
    +
    + +
    + + +
    + +
    +

    Sign up for free

    + +
    +
    +
    +
    + + +
    +
    + + +
    + + By clicking Sign up, you agree to the terms of use. +
    +
    Or use a third-party
    + + + +
    +
    +
    +
    diff --git a/site/src/assets/examples/modals/index.astro b/site/src/assets/examples/modals/index.astro deleted file mode 100644 index 9514f6f1fb..0000000000 --- a/site/src/assets/examples/modals/index.astro +++ /dev/null @@ -1,147 +0,0 @@ ---- -export const title = 'Modals' -export const extra_css = ['modals.css'] ---- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - -
    - - - -
    - - - -
    diff --git a/site/src/assets/examples/modals/modals.css b/site/src/assets/examples/modals/modals.css deleted file mode 100644 index 194e16aca6..0000000000 --- a/site/src/assets/examples/modals/modals.css +++ /dev/null @@ -1,7 +0,0 @@ -.modal-sheet .modal-dialog { - width: 380px; - transition: bottom .75s ease-in-out; -} -.modal-sheet .modal-footer { - padding-bottom: 2rem; -} diff --git a/site/src/assets/partials/snippets.js b/site/src/assets/partials/snippets.js index 498071b415..3be645a3ba 100644 --- a/site/src/assets/partials/snippets.js +++ b/site/src/assets/partials/snippets.js @@ -128,31 +128,6 @@ export default () => { }) }) - // ------------------------------- - // Modal - // ------------------------------- - // Modal 'Varying modal content' example in docs and StackBlitz - // js-docs-start varying-modal-content - const exampleModal = document.getElementById('exampleModal') - if (exampleModal) { - exampleModal.addEventListener('show.bs.modal', event => { - // Button that triggered the modal - const button = event.relatedTarget - // Extract info from data-bs-* attributes - const recipient = button.getAttribute('data-bs-whatever') - // If necessary, you could initiate an Ajax request here - // and then do the updating in a callback. - - // Update the modal's content. - const modalTitle = exampleModal.querySelector('.modal-title') - const modalBodyInput = exampleModal.querySelector('.modal-body input') - - modalTitle.textContent = `New message to ${recipient}` - modalBodyInput.value = recipient - }) - } - // js-docs-end varying-modal-content - // ------------------------------- // Offcanvas // ------------------------------- diff --git a/site/src/components/home/Plugins.astro b/site/src/components/home/Plugins.astro index 236ac5a151..e8afcbbb8f 100644 --- a/site/src/components/home/Plugins.astro +++ b/site/src/components/home/Plugins.astro @@ -13,7 +13,7 @@ const plugins = getData('plugins')

    Powerful JavaScript plugins without jQuery

    - Add toggleable hidden elements, modals and offcanvas menus, popovers and tooltips, and so much more—all without + Add toggleable hidden elements, dialogs and offcanvas menus, popovers and tooltips, and so much more—all without jQuery. Bootstrap's JavaScript is HTML-first, meaning most plugins are added with data attributes in your HTML. Need more control? Include individual plugins programmatically.

    diff --git a/site/src/content/docs/components/dialog.mdx b/site/src/content/docs/components/dialog.mdx new file mode 100644 index 0000000000..486c32b21e --- /dev/null +++ b/site/src/content/docs/components/dialog.mdx @@ -0,0 +1,492 @@ +--- +title: Dialog +description: A modern component built on the native `` element with built-in accessibility and backdrop support that replaces the old Modal component. +toc: true +# aliases: + # - "/docs/[[config:docs_version]]/components/modal/" +--- + +## How it works + +The Dialog component leverages the browser's native `` element, providing built-in accessibility features, focus management, and backdrop handling without the complexity of custom implementations. + +Key features of the native dialog: + +- **Native modal behavior** via `showModal()` with automatic focus trapping +- **Built-in backdrop** using the `::backdrop` pseudo-element +- **Escape key handling** closes the dialog by default +- **Accessibility** with proper focus management and ARIA attributes +- **Top layer rendering** ensures the dialog appears above all other content + +Native `` elements support two methods: `show()` opens the dialog inline without a backdrop or focus trapping, while `showModal()` opens it as a true modal in the browser's top layer with a backdrop, focus trapping, and Escape key handling. Bootstrap's Dialog component uses `showModal()` to provide the expected modal experience. + + + +## Example + +Toggle a dialog by clicking the button below. The dialog uses the native `showModal()` API for true modal behavior. + + +
    +

    Dialog title

    + +
    +
    +

    This is a native dialog element. It uses the browser's built-in modal behavior for accessibility and focus management.

    +
    + +
    + + + Open dialog +`} /> + +The markup for a dialog is straightforward: + +```html + +
    +

    Dialog title

    + +
    +
    +

    Dialog body content goes here.

    +
    + +
    + + +``` + +## Static backdrop + +When `backdrop` is set to `static`, the dialog will not close when clicking outside of it. Click the button below to try it. + + +
    +

    Static backdrop

    + +
    +
    +

    I will not close if you click outside of me. Use the close button or press Escape.

    +
    + +
    + + + Open static backdrop dialog +`} /> + +## Scrolling long content + +When dialogs have content that exceeds the viewport height, the entire dialog scrolls within the viewport. The header, body, and footer all scroll together. + + +
    +

    Scrolling dialog

    + +
    +
    +

    This is some placeholder content to show the scrolling behavior for dialogs. When the content exceeds the viewport height, you can scroll the entire dialog within the window—the header, body, and footer all move together.

    +

    The dialog component is built on the native HTML <dialog> element, which provides built-in accessibility features like focus trapping, backdrop handling, and proper keyboard interactions. This approach leverages browser-native functionality rather than custom JavaScript implementations.

    +

    When using showModal(), the dialog is rendered in the browser's top layer, ensuring it appears above all other content regardless of z-index values. The backdrop is created using the ::backdrop pseudo-element, which can be styled with CSS.

    +

    Focus management is handled automatically by the browser. When a modal dialog opens, focus moves to the first focusable element inside. When closed, focus returns to the element that triggered the dialog. This behavior is essential for accessibility and keyboard navigation.

    +

    The Escape key closes modal dialogs by default, though this can be prevented using the cancel event. Non-modal dialogs opened with show() don't trap focus or block interaction with the rest of the page.

    +

    Bootstrap's Dialog component wraps the native element with additional features: custom animations, static backdrop option, programmatic control via JavaScript, and integration with data attributes for declarative usage.

    +

    Scrolling behavior varies based on content length. Short dialogs center in the viewport. Long dialogs scroll within the viewport by default, though you can use .dialog-scrollable to scroll only the body while keeping header and footer fixed.

    +

    The component fires standard Bootstrap events: show.bs.dialog, shown.bs.dialog, hide.bs.dialog, and hidden.bs.dialog. These allow you to hook into the dialog lifecycle and execute custom logic.

    +

    This content should appear at the bottom after you scroll.

    +
    + +
    + + + Launch scrolling dialog +`} /> + +You can also create a scrollable dialog that scrolls the dialog body while keeping the header and footer fixed. Add `.dialog-scrollable` to the `.dialog` element. + + +
    +

    Scrollable body

    + +
    +
    +

    This is some placeholder content to show the scrolling behavior for dialogs. We use repeated line breaks to demonstrate how content can exceed the dialog's inner height, showing scrolling within the body while the header and footer remain fixed.

    +









    +









    +









    +









    +









    +









    +

    This content should appear at the bottom after you scroll.

    +
    + +
    + + + Launch scrollable body dialog +`} /> + +```html + + +
    +

    Scrollable body

    + +
    +
    + +
    + +
    +``` + +For a dialog that extends beyond the viewport and scrolls as a whole, add `.dialog-overflow` and wrap the content in a `.dialog-box` element. The `` becomes a full-viewport scrollable container, and the `.dialog-box` is the visual dialog that scrolls up and down. + + +
    +
    +

    Overflow dialog

    + +
    +
    +

    This dialog extends beyond the viewport height. Scroll to see more content. Notice how the entire dialog—including header and footer—shifts up and down as you scroll.

    +

    The .dialog-overflow modifier creates a full-viewport scrollable container. The .dialog-box wrapper contains the visual dialog that moves up and down as you scroll. This pattern is useful for very long content like terms of service or detailed forms.

    +

    Unlike the default scrolling behavior where the dialog is constrained to the viewport, overflow dialogs can extend beyond it. This gives users a more document-like reading experience for lengthy content.

    +

    The backdrop remains fixed while the dialog content scrolls. Clicking outside the dialog box still closes it (unless using a static backdrop). Keyboard navigation and focus trapping work the same as standard dialogs.

    +

    Consider using .dialog-scrollable instead if you want the header and footer to remain visible while scrolling. The scrollable variant keeps navigation controls accessible at all times, which may be preferable for forms with submit buttons.

    +

    Both approaches leverage the native <dialog> element's top layer rendering. This ensures the dialog appears above all other content, including elements with high z-index values, fixed positioning, or transforms.

    +

    The choice between scrolling behaviors depends on your content and user experience goals. Document-like content often works well with overflow scrolling, while interactive content may benefit from fixed header and footer.

    +

    You've reached the bottom of the overflow dialog!

    +
    + +
    +
    + + + Launch overflow dialog +`} /> + +```html + + +
    +
    ...
    +
    + +
    + +
    +
    +``` + +## Swapping dialogs + +When a toggle trigger is inside an open dialog, clicking it will **swap** dialogs—opening the new one before closing the current. This ensures the backdrop stays visible throughout the transition with no flash. + + +
    +

    First dialog

    + +
    +
    +

    Click below to swap to a second dialog. Notice the backdrop stays visible—no flash!

    +
    + +
    + + +
    +

    Second dialog

    + +
    +
    +

    This is the second dialog. You can swap back to the first, or close this one entirely.

    +
    + +
    + + + Open first dialog +`} /> + +The swap behavior is automatic when a `data-bs-toggle="dialog"` trigger is inside an already-open dialog: + +```html + + +
    +

    Click below to swap to dialog 2.

    +
    + +
    + + + +
    +

    You're now in dialog 2.

    +
    + +
    +``` + +## Non-modal dialogs + +By default, dialogs open as modals using the native `showModal()` method. You can also open dialogs as non-modal using `show()` by setting `modal` to `false`. Non-modal dialogs: + +- Have no backdrop +- Don't trap focus +- Don't block interaction with the rest of the page +- Don't render in the browser's top layer +- Still respond to Escape key (if `keyboard: true`) + + +
    +

    Non-modal dialog

    + +
    +
    +

    This dialog doesn't block the page. You can still interact with content behind it.

    +
    + +
    + + + Open non-modal dialog + `} /> + +```js +const dialog = new bootstrap.Dialog('#myDialog', { modal: false }) +dialog.show() +``` + +## Optional sizes + +Dialogs have three optional sizes, available via modifier classes to be placed on a `.dialog`. These sizes kick in at certain breakpoints to avoid horizontal scrollbars on narrower viewports. + + + | Size | Class | Dialog max-width | + | --- | --- | --- | + | Small | `.dialog-sm` | `300px` | + | Default | — | `500px` | + | Large | `.dialog-lg` | `800px` | + | Extra large | `.dialog-xl` | `1140px` | + + + +
    +

    Extra large dialog

    + +
    +
    +

    This is an extra large dialog using the .dialog-xl class.

    +
    +
    + + +
    +

    Large dialog

    + +
    +
    +

    This is a large dialog using the .dialog-lg class.

    +
    +
    + + +
    +

    Small dialog

    + +
    +
    +

    This is a small dialog using the .dialog-sm class.

    +
    +
    + + + Extra large dialog + + +`} /> + +```html +... +... +... +``` + +## Fullscreen dialog + +Use `.dialog-fullscreen` to make the dialog cover the entire viewport. + + +
    +

    Fullscreen dialog

    + +
    +
    +

    This dialog covers the entire viewport.

    +
    + +
    + + + Fullscreen dialog +`} /> + +Responsive fullscreen variants are also available. These make the dialog fullscreen only below a specific breakpoint. + +| Class | Fullscreen below | +| --- | --- | +| `.dialog-fullscreen` | Always | +| `.dialog-fullscreen-sm-down` | `576px` | +| `.dialog-fullscreen-md-down` | `768px` | +| `.dialog-fullscreen-lg-down` | `1024px` | +| `.dialog-fullscreen-xl-down` | `1280px` | +| `.dialog-fullscreen-2xl-down` | `1536px` | + + +
    +

    Fullscreen below lg

    + +
    +
    +

    This dialog is fullscreen below the lg breakpoint.

    +
    + +
    + + + Fullscreen below lg +`} /> + +```html +... +``` + +## JavaScript behavior + +### Via data attributes + +Toggle a dialog without writing JavaScript. Set `data-bs-toggle="dialog"` on a controller element, like a button, along with a `data-bs-target="#foo"` to target a specific dialog to toggle. + +```html + +``` + +#### Dismiss + +Dismissal can be achieved with the `data-bs-dismiss` attribute on a button **within the dialog**: + +```html + +``` + +### Via JavaScript + +Create a dialog with a single line of JavaScript: + +```js +const myDialog = new bootstrap.Dialog('#myDialog') +``` + +### Options + +Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-backdrop="static"`. + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `backdrop` | boolean or `'static'` | `true` | For modal dialogs, clicking the backdrop dismisses the dialog. Specify `static` for a backdrop which doesn't close the dialog when clicked. Has no effect on non-modal dialogs. | +| `keyboard` | boolean | `true` | Closes the dialog when escape key is pressed. | +| `modal` | boolean | `true` | When `true`, opens the dialog as a modal using `showModal()` with backdrop, focus trapping, and top layer rendering. When `false`, opens as a non-modal dialog using `show()` without backdrop or focus trapping. | + +### Methods + +#### Passing options + +Activates your content as a dialog. Accepts an optional options object. + +```js +const myDialog = new bootstrap.Dialog('#myDialog', { + keyboard: false +}) +``` + +| Method | Description | +| --- | --- | +| `show` | Opens the dialog. **Returns to the caller before the dialog has actually been shown** (i.e. before the `shown.bs.dialog` event occurs). | +| `hide` | Hides the dialog. **Returns to the caller before the dialog has actually been hidden** (i.e. before the `hidden.bs.dialog` event occurs). | +| `toggle` | Toggles the dialog. **Returns to the caller before the dialog has actually been shown or hidden** (i.e. before the `shown.bs.dialog` or `hidden.bs.dialog` event occurs). | +| `handleUpdate` | Provided for API consistency with Modal. Native dialogs handle their own positioning. | +| `dispose` | Destroys an element's dialog. | +| `getInstance` | Static method which allows you to get the dialog instance associated with a DOM element. | +| `getOrCreateInstance` | Static method which allows you to get the dialog instance associated with a DOM element, or create a new one in case it wasn't initialized. | + +### Events + +Bootstrap's dialog class exposes a few events for hooking into dialog functionality. + +| Event | Description | +| --- | --- | +| `show.bs.dialog` | Fires immediately when the `show` instance method is called. | +| `shown.bs.dialog` | Fired when the dialog has been made visible to the user (will wait for CSS transitions to complete). | +| `hide.bs.dialog` | Fires immediately when the `hide` instance method is called. | +| `hidden.bs.dialog` | Fired when the dialog has finished being hidden from the user (will wait for CSS transitions to complete). | +| `hidePrevented.bs.dialog` | Fired when the dialog is shown, its backdrop is `static`, and a click outside the dialog or an escape key press is performed (with `keyboard` set to `false`). | +| `cancel.bs.dialog` | Fired when the user presses Escape and the dialog is about to close. | + +```js +const myDialog = document.getElementById('myDialog') +myDialog.addEventListener('hidden.bs.dialog', event => { + // do something... +}) +``` diff --git a/site/src/content/docs/components/modal.mdx b/site/src/content/docs/components/modal.mdx deleted file mode 100644 index 56b59600fe..0000000000 --- a/site/src/content/docs/components/modal.mdx +++ /dev/null @@ -1,833 +0,0 @@ ---- -title: Modal -description: Use Bootstrap’s JavaScript modal plugin to add dialogs to your site for lightboxes, user notifications, or completely custom content. -toc: true ---- - -## How it works - -Before getting started with Bootstrap’s modal component, be sure to read the following as our menu options have recently changed. - -- Modals are built with HTML, CSS, and JavaScript. They’re positioned over everything else in the document and remove scroll from the `` so that modal content scrolls instead. -- Clicking on the modal “backdrop” will automatically close the modal. -- Bootstrap only supports one modal window at a time. Nested modals aren’t supported as we believe them to be poor user experiences. -- Modals use `position: fixed`, which can sometimes be a bit particular about its rendering. Whenever possible, place your modal HTML in a top-level position to avoid potential interference from other elements. You’ll likely run into issues when nesting a `.modal` within another fixed element. -- Once again, due to `position: fixed`, there are some caveats with using modals on mobile devices. -- Due to how HTML5 defines its semantics, [the `autofocus` HTML attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autofocus) has no effect in Bootstrap modals. To achieve the same effect, use some custom JavaScript: - -```js -const myModal = document.getElementById('myModal') -const myInput = document.getElementById('myInput') - -myModal.addEventListener('shown.bs.modal', () => { - myInput.focus() -}) -``` - - - -Keep reading for demos and usage guidelines. - -## Examples - -### Modal components - -Below is a _static_ modal example (meaning its `position` and `display` have been overridden). Included are the modal header, modal body (required for `padding`), and modal footer (optional). We ask that you include modal headers with dismiss actions whenever possible, or provide another explicit dismiss action. - -
    - -
    - -```html - -``` - - -In the above static example, we use `
    `, to avoid issues with the heading hierarchy in the documentation page. Structurally, however, a modal dialog represents its own separate document/context, so the `.modal-title` should ideally be an `

    `. If necessary, you can use the [font size utilities]([[docsref:/utilities/font-size]]) to control the heading’s appearance. All the following live examples use this approach. - - -### Live demo - -Toggle a working modal demo by clicking the button below. It will slide down and fade in from the top of the page. - - - -Launch demo modal -`} /> - -```html - - - - - -``` - -### Static backdrop - -When backdrop is set to static, the modal will not close when clicking outside of it. Click the button below to try it. - - - -Launch static backdrop modal -`} /> - -```html - - - - - -``` - -### Scrolling long content - -When modals become too long for the user’s viewport or device, they scroll independent of the page itself. Try the demo below to see what we mean. - - - -Launch demo modal -`} /> - -You can also create a scrollable modal that allows scrolling the modal body by adding `.modal-dialog-scrollable` to `.modal-dialog`. - - - -Launch demo modal -`} /> - -```html - - -``` - -### Vertically centered - -Add `.modal-dialog-centered` to `.modal-dialog` to vertically center the modal. - -