Skip to content

Commit 89df9d6

Browse files
committed
Add Dialog component using native HTML dialog element
New component that leverages the native HTML <dialog> 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
1 parent 39edb8d commit 89df9d6

File tree

7 files changed

+2025
-22
lines changed

7 files changed

+2025
-22
lines changed

js/src/dialog.js

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/**
2+
* --------------------------------------------------------------------------
3+
* Bootstrap dialog.js
4+
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5+
* --------------------------------------------------------------------------
6+
*/
7+
8+
import BaseComponent from './base-component.js'
9+
import EventHandler from './dom/event-handler.js'
10+
import Manipulator from './dom/manipulator.js'
11+
import SelectorEngine from './dom/selector-engine.js'
12+
import { enableDismissTrigger } from './util/component-functions.js'
13+
import { isVisible } from './util/index.js'
14+
15+
/**
16+
* Constants
17+
*/
18+
19+
const NAME = 'dialog'
20+
const DATA_KEY = 'bs.dialog'
21+
const EVENT_KEY = `.${DATA_KEY}`
22+
const DATA_API_KEY = '.data-api'
23+
24+
const EVENT_SHOW = `show${EVENT_KEY}`
25+
const EVENT_SHOWN = `shown${EVENT_KEY}`
26+
const EVENT_HIDE = `hide${EVENT_KEY}`
27+
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
28+
const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`
29+
const EVENT_CANCEL = `cancel${EVENT_KEY}`
30+
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
31+
32+
const CLASS_NAME_STATIC = 'dialog-static'
33+
const CLASS_NAME_OPEN = 'dialog-open'
34+
const CLASS_NAME_NONMODAL = 'dialog-nonmodal'
35+
36+
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dialog"]'
37+
const SELECTOR_OPEN_MODAL_DIALOG = 'dialog.dialog[open]:not(.dialog-nonmodal)'
38+
39+
const Default = {
40+
backdrop: true, // true (click dismisses) or 'static' (click does nothing) - only applies to modal dialogs
41+
keyboard: true,
42+
modal: true // true uses showModal(), false uses show() for non-modal dialogs
43+
}
44+
45+
const DefaultType = {
46+
backdrop: '(boolean|string)',
47+
keyboard: 'boolean',
48+
modal: 'boolean'
49+
}
50+
51+
/**
52+
* Class definition
53+
*/
54+
55+
class Dialog extends BaseComponent {
56+
constructor(element, config) {
57+
super(element, config)
58+
59+
this._isTransitioning = false
60+
this._addEventListeners()
61+
}
62+
63+
// Getters
64+
static get Default() {
65+
return Default
66+
}
67+
68+
static get DefaultType() {
69+
return DefaultType
70+
}
71+
72+
static get NAME() {
73+
return NAME
74+
}
75+
76+
// Public
77+
toggle(relatedTarget) {
78+
return this._element.open ? this.hide() : this.show(relatedTarget)
79+
}
80+
81+
show(relatedTarget) {
82+
if (this._element.open || this._isTransitioning) {
83+
return
84+
}
85+
86+
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, {
87+
relatedTarget
88+
})
89+
90+
if (showEvent.defaultPrevented) {
91+
return
92+
}
93+
94+
this._isTransitioning = true
95+
96+
if (this._config.modal) {
97+
// Modal dialog: use showModal() for focus trapping, backdrop, and top layer
98+
this._element.showModal()
99+
// Prevent body scroll for modal dialogs
100+
document.body.classList.add(CLASS_NAME_OPEN)
101+
} else {
102+
// Non-modal dialog: use show() - no backdrop, no focus trap, no top layer
103+
this._element.classList.add(CLASS_NAME_NONMODAL)
104+
this._element.show()
105+
}
106+
107+
this._queueCallback(() => {
108+
this._isTransitioning = false
109+
EventHandler.trigger(this._element, EVENT_SHOWN, {
110+
relatedTarget
111+
})
112+
}, this._element, this._isAnimated())
113+
}
114+
115+
hide() {
116+
if (!this._element.open || this._isTransitioning) {
117+
return
118+
}
119+
120+
const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
121+
122+
if (hideEvent.defaultPrevented) {
123+
return
124+
}
125+
126+
this._isTransitioning = true
127+
128+
this._queueCallback(() => this._hideDialog(), this._element, this._isAnimated())
129+
}
130+
131+
dispose() {
132+
EventHandler.off(this._element, EVENT_KEY)
133+
super.dispose()
134+
}
135+
136+
handleUpdate() {
137+
// Provided for API consistency with Modal.
138+
// Native dialogs handle their own positioning.
139+
}
140+
141+
// Private
142+
_hideDialog() {
143+
this._element.close()
144+
this._element.classList.remove(CLASS_NAME_NONMODAL)
145+
this._isTransitioning = false
146+
147+
// Only restore body scroll if no other modal dialogs are open
148+
if (!document.querySelector(SELECTOR_OPEN_MODAL_DIALOG)) {
149+
document.body.classList.remove(CLASS_NAME_OPEN)
150+
}
151+
152+
EventHandler.trigger(this._element, EVENT_HIDDEN)
153+
}
154+
155+
_isAnimated() {
156+
return this._element.classList.contains('fade')
157+
}
158+
159+
_triggerBackdropTransition() {
160+
const hidePreventedEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
161+
if (hidePreventedEvent.defaultPrevented) {
162+
return
163+
}
164+
165+
this._element.classList.add(CLASS_NAME_STATIC)
166+
this._queueCallback(() => {
167+
this._element.classList.remove(CLASS_NAME_STATIC)
168+
}, this._element)
169+
}
170+
171+
_addEventListeners() {
172+
// Handle native cancel event (Escape key) - only fires for modal dialogs
173+
EventHandler.on(this._element, 'cancel', event => {
174+
// Prevent native close behavior - we'll handle it
175+
event.preventDefault()
176+
177+
if (!this._config.keyboard) {
178+
this._triggerBackdropTransition()
179+
return
180+
}
181+
182+
EventHandler.trigger(this._element, EVENT_CANCEL)
183+
this.hide()
184+
})
185+
186+
// Handle Escape key for non-modal dialogs (native cancel doesn't fire for show())
187+
EventHandler.on(this._element, 'keydown', event => {
188+
if (event.key !== 'Escape' || this._config.modal) {
189+
return
190+
}
191+
192+
event.preventDefault()
193+
194+
if (!this._config.keyboard) {
195+
return
196+
}
197+
198+
EventHandler.trigger(this._element, EVENT_CANCEL)
199+
this.hide()
200+
})
201+
202+
// Handle backdrop clicks (only applies to modal dialogs)
203+
// Native <dialog> fires click on the dialog element when backdrop is clicked
204+
EventHandler.on(this._element, 'click', event => {
205+
// Only handle clicks directly on the dialog (backdrop area)
206+
// Non-modal dialogs don't have a backdrop
207+
if (event.target !== this._element || !this._config.modal) {
208+
return
209+
}
210+
211+
if (this._config.backdrop === 'static') {
212+
this._triggerBackdropTransition()
213+
return
214+
}
215+
216+
// Default: click backdrop to dismiss
217+
this.hide()
218+
})
219+
}
220+
}
221+
222+
/**
223+
* Data API implementation
224+
*/
225+
226+
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
227+
const target = SelectorEngine.getElementFromSelector(this)
228+
229+
if (['A', 'AREA'].includes(this.tagName)) {
230+
event.preventDefault()
231+
}
232+
233+
EventHandler.one(target, EVENT_SHOW, showEvent => {
234+
if (showEvent.defaultPrevented) {
235+
return
236+
}
237+
238+
EventHandler.one(target, EVENT_HIDDEN, () => {
239+
if (isVisible(this)) {
240+
this.focus()
241+
}
242+
})
243+
})
244+
245+
// Get config from trigger's data attributes
246+
const config = Manipulator.getDataAttributes(this)
247+
248+
// Check if trigger is inside an open dialog
249+
const currentDialog = this.closest('dialog[open]')
250+
const shouldSwap = currentDialog && currentDialog !== target
251+
252+
if (shouldSwap) {
253+
// Open new dialog first (its backdrop appears over current)
254+
const newDialog = Dialog.getOrCreateInstance(target, config)
255+
newDialog.show(this)
256+
257+
// Close the current dialog (no backdrop flash since new one is already open)
258+
const currentInstance = Dialog.getInstance(currentDialog)
259+
if (currentInstance) {
260+
currentInstance.hide()
261+
}
262+
263+
return
264+
}
265+
266+
const data = Dialog.getOrCreateInstance(target, config)
267+
data.toggle(this)
268+
})
269+
270+
enableDismissTrigger(Dialog)
271+
272+
export default Dialog

0 commit comments

Comments
 (0)