blob: 29c66278311b31c2590b5985bf3192f93c24f4af [file] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap dialog-base.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import Data from './dom/data.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
/**
* Constants
*/
const CLASS_NAME_OPEN = 'dialog-open'
/**
* Class definition
*
* Shared base class for Dialog and Drawer components that use
* the native <dialog> element. Provides common behavior for:
* - Show/hide/toggle lifecycle with events
* - Opening/closing via showModal()/show()/close()
* - Escape key handling (modal and non-modal)
* - Backdrop click handling
* - Static backdrop transition ("bounce")
* - Body scroll prevention
* - Transition coordination
* - Child component cleanup (tooltips, popovers, toasts)
*/
class DialogBase extends BaseComponent {
constructor(element, config) {
super(element, config)
this._isTransitioning = false
this._openedAsModal = false
this._addDialogListeners()
}
// Getters — subclasses override NAME with their own component name.
static get NAME() {
return 'dialogbase'
}
// Public — shared lifecycle methods
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,
this.constructor.eventName('show'),
{ relatedTarget }
)
if (showEvent.defaultPrevented) {
return
}
this._isTransitioning = true
this._onBeforeShow()
const { modal, preventBodyScroll } = this._getShowOptions()
this._showElement({ modal, preventBodyScroll })
this._queueCallback(() => {
this._isTransitioning = false
EventHandler.trigger(
this._element,
this.constructor.eventName('shown'),
{ relatedTarget }
)
}, this._element, this._isAnimated())
}
hide() {
if (!this._element.open || this._isTransitioning) {
return
}
const hideEvent = EventHandler.trigger(
this._element,
this.constructor.eventName('hide')
)
if (hideEvent.defaultPrevented) {
return
}
this._isTransitioning = true
this._hideElement()
this._queueCallback(() => {
// For subclasses that defer close() until the exit transition ends
// (so the dialog stays in the top layer with its ::backdrop), close()
// happens here instead of in _hideElement().
if (this._element.open) {
this._closeAndCleanup()
}
this._element.classList.remove('hiding')
this._onAfterHide()
this._isTransitioning = false
EventHandler.trigger(
this._element,
this.constructor.eventName('hidden')
)
}, this._element, this._isAnimated())
}
// Protected — hooks for subclasses to override
_getShowOptions() {
return { modal: true, preventBodyScroll: true }
}
_onBeforeShow() {
// No-op by default — Dialog overrides to add nonmodal class
}
_onAfterHide() {
// No-op by default — Dialog overrides to remove nonmodal class
}
_isAnimated() {
return !this._element.classList.contains(this._getInstantClassName())
}
_getInstantClassName() {
return 'dialog-instant'
}
_getStaticClassName() {
return 'dialog-static'
}
_onCancel() {
// No-op by default — Dialog overrides to fire cancel event
}
// Protected — shared mechanics
_showElement({ modal = true, preventBodyScroll = true } = {}) {
this._openedAsModal = modal
if (modal) {
this._element.showModal()
} else {
this._element.show()
}
if (preventBodyScroll) {
document.body.classList.add(CLASS_NAME_OPEN)
}
}
_hideElement() {
this._hideChildComponents()
// Add .hiding before close() so CSS exit transitions can play.
// Without this, the navbar's `:not([open])` transition-kill rule
// would prevent the slide-out animation.
this._element.classList.add('hiding')
// Subclasses can defer close() until after the exit transition by
// returning true from _shouldDeferClose(). This is needed for the
// native modal <dialog> centered case: close() removes the dialog
// from the top layer immediately, which strips its auto-centering
// and the ::backdrop, breaking the exit animation.
if (!this._shouldDeferClose()) {
this._closeAndCleanup()
}
}
// Closes the native <dialog> and tears down body-scroll prevention.
// Safe to call multiple times — close() is a no-op on a closed dialog.
_closeAndCleanup() {
this._element.close()
this._openedAsModal = false
// Only restore body scroll if no other modal dialogs are open
if (!document.querySelector('dialog[open]:modal')) {
document.body.classList.remove(CLASS_NAME_OPEN)
}
}
// Hook: return true to keep the dialog in the top layer (i.e., delay
// calling close()) until the exit transition completes. The base class
// closes synchronously; Dialog overrides this for animated modal cases.
_shouldDeferClose() {
return false
}
_triggerBackdropTransition() {
const hidePreventedEvent = EventHandler.trigger(
this._element,
this.constructor.eventName('hidePrevented')
)
if (hidePreventedEvent.defaultPrevented) {
return
}
const staticClass = this._getStaticClassName()
this._element.classList.add(staticClass)
this._queueCallback(() => {
this._element.classList.remove(staticClass)
}, this._element)
}
// Hide any tooltips, popovers, or toasts inside the dialog before closing.
// These components append to the dialog (for top-layer rendering) and would
// otherwise persist visibly after close().
_hideChildComponents() {
const selector = '[data-bs-toggle="tooltip"], [data-bs-toggle="popover"]'
for (const el of SelectorEngine.find(selector, this._element)) {
const instance = Data.getAny(el)
if (instance && typeof instance.hide === 'function') {
instance.hide()
}
}
// Hide any visible toasts
for (const el of SelectorEngine.find('.toast.show', this._element)) {
const instance = Data.getAny(el)
if (instance && typeof instance.hide === 'function') {
instance.hide()
}
}
}
// Private
_addDialogListeners() {
const eventKey = this.constructor.EVENT_KEY
// Handle native cancel event (Escape key) — only fires for modal dialogs
EventHandler.on(this._element, 'cancel', event => {
event.preventDefault()
if (!this._config.keyboard) {
this._triggerBackdropTransition()
return
}
this._onCancel()
this.hide()
})
// Handle Escape key for non-modal dialogs (native cancel doesn't fire for show())
EventHandler.on(this._element, `keydown${eventKey}`, event => {
if (event.key !== 'Escape' || this._openedAsModal) {
return
}
event.preventDefault()
if (!this._config.keyboard) {
return
}
this._onCancel()
this.hide()
})
// Handle backdrop clicks — only applies to modal dialogs
EventHandler.on(this._element, `click${eventKey}`, event => {
if (event.target !== this._element || !this._openedAsModal) {
return
}
if (this._config.backdrop === 'static') {
this._triggerBackdropTransition()
return
}
this.hide()
})
}
}
export default DialogBase