From: Mark Otto Date: Thu, 11 Dec 2025 17:48:35 +0000 (-0800) Subject: Add Dialog component using native HTML dialog element X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=e5fad9df752c448114fe730613fc8b164619aaff;p=thirdparty%2Fbootstrap.git 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 --- 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/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/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/scss/_dialog.scss b/scss/_dialog.scss new file mode 100644 index 0000000000..eafc838c35 --- /dev/null +++ b/scss/_dialog.scss @@ -0,0 +1,191 @@ +@use "config" as *; +@use "variables" 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-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 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/_variables.scss b/scss/_variables.scss index a3abd350f1..6d742f672a 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -437,8 +437,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; diff --git a/scss/bootstrap.scss b/scss/bootstrap.scss index 57f8ed03bc..f849163feb 100644 --- a/scss/bootstrap.scss +++ b/scss/bootstrap.scss @@ -17,6 +17,7 @@ @forward "breadcrumb"; @forward "card"; @forward "carousel"; +@forward "dialog"; @forward "dropdown"; @forward "list-group"; @forward "modal"; diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index 53a6f200af..bfa5c522a5 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -92,6 +92,7 @@ - title: Carousel - title: Close button - title: Collapse + - title: Dialog - title: Dropdowns - title: List group - title: Modal diff --git a/site/src/content/docs/components/dialog.mdx b/site/src/content/docs/components/dialog.mdx new file mode 100644 index 0000000000..d0869eed80 --- /dev/null +++ b/site/src/content/docs/components/dialog.mdx @@ -0,0 +1,391 @@ +--- +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.

+

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.

+

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.

+

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.

+

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.

+

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.

+

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.

+

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.

+

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.

+

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.

+

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() +``` + +## 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 index 56b59600fe..0f8b5104a0 100644 --- a/site/src/content/docs/components/modal.mdx +++ b/site/src/content/docs/components/modal.mdx @@ -34,8 +34,7 @@ Keep reading for demos and usage guidelines. 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 -