@magic-spells/dialog-panel 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,496 +5,367 @@
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
7
  /**
8
- * Retrieves all focusable elements within a given container.
8
+ * DialogPanel - A lightweight web component wrapper for native <dialog> elements
9
+ * with state-driven animations.
9
10
  *
10
- * @param {HTMLElement} container - The container element to search for focusable elements.
11
- * @returns {HTMLElement[]} An array of focusable elements found within the container.
11
+ * @extends HTMLElement
12
+ *
13
+ * @property {string} state - Current state: 'hidden' | 'showing' | 'shown' | 'hiding'
14
+ * @property {HTMLDialogElement} dialog - Reference to inner <dialog> element
15
+ * @property {boolean} isOpen - True if state is 'showing' or 'shown'
16
+ * @property {HTMLElement|null} triggerElement - Element that triggered current action
17
+ *
18
+ * @fires beforeShow - Fired before showing starts (cancelable)
19
+ * @fires shown - Fired after show animation completes
20
+ * @fires beforeHide - Fired before hiding starts (cancelable)
21
+ * @fires hidden - Fired after hide animation completes
12
22
  */
13
- const getFocusableElements = (container) => {
14
- const focusableSelectors =
15
- 'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';
16
- return Array.from(container.querySelectorAll(focusableSelectors));
17
- };
18
-
19
- class FocusTrap extends HTMLElement {
20
- /** @type {boolean} Indicates whether the styles have been injected into the DOM. */
21
- static styleInjected = false;
22
-
23
- constructor() {
24
- super();
25
- this.trapStart = null;
26
- this.trapEnd = null;
27
-
28
- // Inject styles only once, when the first FocusTrap instance is created.
29
- if (!FocusTrap.styleInjected) {
30
- this.injectStyles();
31
- FocusTrap.styleInjected = true;
32
- }
33
- }
23
+ class DialogPanel extends HTMLElement {
24
+ // Private fields
25
+ #state = 'hidden';
26
+ #triggerElement = null;
27
+ #dialog = null;
28
+ #result = null;
29
+
30
+ // Event handler references for cleanup
31
+ #handlers = {
32
+ click: null,
33
+ dialogClick: null,
34
+ cancel: null,
35
+ };
34
36
 
35
- /**
36
- * Injects necessary styles for the focus trap into the document's head.
37
- * This ensures that focus-trap-start and focus-trap-end elements are hidden.
38
- */
39
- injectStyles() {
40
- const style = document.createElement('style');
41
- style.textContent = `
42
- focus-trap-start,
43
- focus-trap-end {
44
- position: absolute;
45
- width: 1px;
46
- height: 1px;
47
- margin: -1px;
48
- padding: 0;
49
- border: 0;
50
- clip: rect(0, 0, 0, 0);
51
- overflow: hidden;
52
- white-space: nowrap;
53
- }
54
- `;
55
- document.head.appendChild(style);
56
- }
37
+ // Animation cleanup references
38
+ #pendingRAF = null;
39
+ #pendingTimeout = null;
40
+
41
+ // Fallback timeout for transitionend (in ms)
42
+ static TRANSITION_FALLBACK_TIMEOUT = 500;
57
43
 
58
- /**
59
- * Called when the element is connected to the DOM.
60
- * Sets up the focus trap and adds the keydown event listener.
61
- */
62
44
  connectedCallback() {
63
- this.setupTrap();
64
- this.addEventListener('keydown', this.handleKeyDown);
65
- }
45
+ const _ = this;
66
46
 
67
- /**
68
- * Called when the element is disconnected from the DOM.
69
- * Removes the keydown event listener.
70
- */
71
- disconnectedCallback() {
72
- this.removeEventListener('keydown', this.handleKeyDown);
73
- }
47
+ // Find inner dialog element
48
+ _.#dialog = _.querySelector('dialog');
74
49
 
75
- /**
76
- * Sets up the focus trap by adding trap start and trap end elements.
77
- * Focuses the trap start element to initiate the focus trap.
78
- */
79
- setupTrap() {
80
- // check to see it there are any focusable children
81
- const focusableElements = getFocusableElements(this);
82
- // exit if there aren't any
83
- if (focusableElements.length === 0) return;
84
-
85
- // create trap start and end elements
86
- this.trapStart = document.createElement('focus-trap-start');
87
- this.trapEnd = document.createElement('focus-trap-end');
88
-
89
- // add to DOM
90
- this.prepend(this.trapStart);
91
- this.append(this.trapEnd);
92
- }
50
+ if (!_.#dialog) {
51
+ console.warn(
52
+ 'DialogPanel: No <dialog> element found inside <dialog-panel>'
53
+ );
54
+ return;
55
+ }
93
56
 
94
- /**
95
- * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
96
- *
97
- * @param {KeyboardEvent} e - The keyboard event object.
98
- */
99
- handleKeyDown = (e) => {
100
- if (e.key === 'Escape') {
101
- e.preventDefault();
102
- this.exitTrap();
57
+ // Auto-create dialog-backdrop if not present
58
+ if (!_.querySelector('dialog-backdrop')) {
59
+ const backdrop = document.createElement('dialog-backdrop');
60
+ _.insertBefore(backdrop, _.firstChild);
103
61
  }
104
- };
105
62
 
106
- /**
107
- * Exits the focus trap by hiding the current container and shifting focus
108
- * back to the trigger element that opened the trap.
109
- */
110
- exitTrap() {
111
- const container = this.closest('[aria-hidden="false"]');
112
- if (!container) return;
63
+ // Set initial state attribute
64
+ _.#setState('hidden');
113
65
 
114
- container.setAttribute('aria-hidden', 'true');
66
+ // Bind event handlers
67
+ _.#bindEvents();
68
+ }
115
69
 
116
- const trigger = document.querySelector(
117
- `[aria-expanded="true"][aria-controls="${container.id}"]`
118
- );
119
- if (trigger) {
120
- trigger.setAttribute('aria-expanded', 'false');
121
- trigger.focus();
70
+ disconnectedCallback() {
71
+ const _ = this;
72
+
73
+ // Cancel pending animations
74
+ if (_.#pendingRAF) {
75
+ cancelAnimationFrame(_.#pendingRAF);
76
+ _.#pendingRAF = null;
77
+ }
78
+ if (_.#pendingTimeout) {
79
+ clearTimeout(_.#pendingTimeout);
80
+ _.#pendingTimeout = null;
122
81
  }
123
- }
124
- }
125
82
 
126
- class FocusTrapStart extends HTMLElement {
127
- /**
128
- * Called when the element is connected to the DOM.
129
- * Sets the tabindex and adds the focus event listener.
130
- */
131
- connectedCallback() {
132
- this.setAttribute('tabindex', '0');
133
- this.addEventListener('focus', this.handleFocus);
83
+ // Clean up event listeners
84
+ if (_.#handlers.click) {
85
+ _.removeEventListener('click', _.#handlers.click);
86
+ }
87
+ if (_.#dialog) {
88
+ if (_.#handlers.dialogClick) {
89
+ _.#dialog.removeEventListener(
90
+ 'click',
91
+ _.#handlers.dialogClick
92
+ );
93
+ }
94
+ if (_.#handlers.cancel) {
95
+ _.#dialog.removeEventListener('cancel', _.#handlers.cancel);
96
+ }
97
+ }
134
98
  }
135
99
 
136
100
  /**
137
- * Called when the element is disconnected from the DOM.
138
- * Removes the focus event listener.
101
+ * Bind event listeners for close buttons, backdrop, and escape key
102
+ * @private
139
103
  */
140
- disconnectedCallback() {
141
- this.removeEventListener('focus', this.handleFocus);
104
+ #bindEvents() {
105
+ const _ = this;
106
+
107
+ // Handle close buttons with data-action-hide-dialog
108
+ _.#handlers.click = (e) => {
109
+ const trigger = e.target.closest('[data-action-hide-dialog]');
110
+ if (trigger) {
111
+ e.stopPropagation();
112
+ _.hide(trigger);
113
+ }
114
+ };
115
+ _.addEventListener('click', _.#handlers.click);
116
+
117
+ // Handle backdrop click - detect clicks outside dialog bounds
118
+ // This works because clicks on ::backdrop still fire on the dialog element
119
+ _.#handlers.dialogClick = (e) => {
120
+ const rect = _.#dialog.getBoundingClientRect();
121
+ const clickedOutside =
122
+ e.clientX < rect.left ||
123
+ e.clientX > rect.right ||
124
+ e.clientY < rect.top ||
125
+ e.clientY > rect.bottom;
126
+ if (clickedOutside) {
127
+ e.stopPropagation();
128
+ _.hide();
129
+ }
130
+ };
131
+ _.#dialog.addEventListener('click', _.#handlers.dialogClick);
132
+
133
+ // Handle escape key - intercept native cancel and animate close
134
+ _.#handlers.cancel = (e) => {
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ _.hide();
138
+ };
139
+ _.#dialog.addEventListener('cancel', _.#handlers.cancel);
142
140
  }
143
141
 
144
142
  /**
145
- * Handles the focus event. If focus moves backwards from the first focusable element,
146
- * it is cycled to the last focusable element, and vice versa.
147
- *
148
- * @param {FocusEvent} e - The focus event object.
143
+ * Show the dialog with animation
144
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the show
145
+ * @returns {boolean} False if show was prevented via beforeShow event
149
146
  */
150
- handleFocus = (e) => {
151
- const trap = this.closest('focus-trap');
152
- const focusableElements = getFocusableElements(trap);
147
+ show(triggerEl = null) {
148
+ const _ = this;
153
149
 
154
- if (focusableElements.length === 0) return;
150
+ // Check if already showing/shown
151
+ if (_.#state === 'showing' || _.#state === 'shown') return true;
155
152
 
156
- const firstElement = focusableElements[0];
157
- const lastElement =
158
- focusableElements[focusableElements.length - 1];
153
+ // If currently hiding, ignore (let hide complete first)
154
+ if (_.#state === 'hiding') return false;
159
155
 
160
- if (e.relatedTarget === firstElement) {
161
- lastElement.focus();
162
- } else {
163
- firstElement.focus();
164
- }
165
- };
166
- }
156
+ // Store trigger element for focus return later
157
+ _.#triggerElement = triggerEl || null;
167
158
 
168
- class FocusTrapEnd extends HTMLElement {
169
- /**
170
- * Called when the element is connected to the DOM.
171
- * Sets the tabindex and adds the focus event listener.
172
- */
173
- connectedCallback() {
174
- this.setAttribute('tabindex', '0');
175
- this.addEventListener('focus', this.handleFocus);
176
- }
159
+ // Fire beforeShow (cancelable)
160
+ if (!_.#emit('beforeShow', { cancelable: true })) return false;
177
161
 
178
- /**
179
- * Called when the element is disconnected from the DOM.
180
- * Removes the focus event listener.
181
- */
182
- disconnectedCallback() {
183
- this.removeEventListener('focus', this.handleFocus);
184
- }
162
+ // Set state to 'showing' and open native dialog
163
+ _.#setState('showing');
164
+ _.#dialog.showModal();
185
165
 
186
- /**
187
- * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
188
- */
189
- handleFocus = () => {
190
- const trap = this.closest('focus-trap');
191
- const trapStart = trap.querySelector('focus-trap-start');
192
- trapStart.focus();
193
- };
194
- }
166
+ // Double RAF ensures browser has painted the 'showing' state
167
+ // before we transition to 'shown'
168
+ _.#pendingRAF = requestAnimationFrame(() => {
169
+ _.#pendingRAF = requestAnimationFrame(() => {
170
+ _.#pendingRAF = null;
171
+ _.#setState('shown');
195
172
 
196
- if (!customElements.get('focus-trap')) {
197
- customElements.define('focus-trap', FocusTrap);
198
- }
199
- if (!customElements.get('focus-trap-start')) {
200
- customElements.define('focus-trap-start', FocusTrapStart);
201
- }
202
- if (!customElements.get('focus-trap-end')) {
203
- customElements.define('focus-trap-end', FocusTrapEnd);
204
- }
205
-
206
- /**
207
- * Custom element that creates an accessible modal dialog panel with focus management
208
- * @extends HTMLElement
209
- */
210
- class DialogPanel extends HTMLElement {
211
- #handleTransitionEnd;
173
+ // Wait for transition to complete before firing 'shown' event
174
+ _.#waitForTransition(() => {
175
+ _.#emit('shown');
176
+ });
177
+ });
178
+ });
212
179
 
213
- /**
214
- * Clean up event listeners when component is removed from DOM
215
- */
216
- disconnectedCallback() {
217
- const _ = this;
218
- if (_.contentPanel) {
219
- _.contentPanel.removeEventListener(
220
- 'transitionend',
221
- _.#handleTransitionEnd
222
- );
223
- }
180
+ return true;
224
181
  }
182
+
225
183
  /**
226
- * Initializes the dialog panel, sets up focus trap and overlay
184
+ * Hide the dialog with animation
185
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the hide
186
+ * @returns {boolean} False if hide was prevented via beforeHide event
227
187
  */
228
- constructor() {
229
- super();
188
+ hide(triggerEl = null) {
230
189
  const _ = this;
231
- _.id = _.getAttribute('id');
232
- _.setAttribute('role', 'dialog');
233
- _.setAttribute('aria-modal', 'true');
234
- _.setAttribute('aria-hidden', 'true');
235
-
236
- _.contentPanel = _.querySelector('dialog-content');
237
- _.triggerEl = null;
238
-
239
- // Create a handler for transition end events
240
- _.#handleTransitionEnd = (e) => {
241
- if (
242
- e.propertyName === 'opacity' &&
243
- _.getAttribute('aria-hidden') === 'true'
244
- ) {
245
- _.contentPanel.classList.add('hidden');
246
-
247
- // Dispatch afterHide event - dialog has completed its transition
248
- _.dispatchEvent(
249
- new CustomEvent('afterHide', {
250
- bubbles: true,
251
- detail: { triggerElement: _.triggerEl },
252
- })
253
- );
254
- }
255
- };
256
190
 
257
- // Set up focus-trap inside dialog-content
258
- // Check if focus-trap already exists
259
- _.focusTrap = _.contentPanel.querySelector('focus-trap');
260
- if (!_.focusTrap) {
261
- _.focusTrap = document.createElement('focus-trap');
191
+ // Check if already hiding/hidden
192
+ if (_.#state === 'hiding' || _.#state === 'hidden') return true;
262
193
 
263
- // Move all existing dialog-content children into focus-trap
264
- const existingContent = Array.from(_.contentPanel.childNodes);
265
- existingContent.forEach((child) =>
266
- _.focusTrap.appendChild(child)
267
- );
194
+ // If currently showing, ignore (let show complete first)
195
+ if (_.#state === 'showing') return false;
268
196
 
269
- // Insert focus-trap inside dialog-content
270
- _.contentPanel.appendChild(_.focusTrap);
271
- }
197
+ // Capture result from trigger element
198
+ _.#result = triggerEl?.dataset?.result ?? null;
272
199
 
273
- // Ensure we have labelledby and describedby references
274
- if (!_.getAttribute('aria-labelledby')) {
275
- const heading = _.querySelector('h1, h2, h3');
276
- if (heading && !heading.id) {
277
- heading.id = `${_.id}-title`;
278
- }
279
- if (heading?.id) {
280
- _.setAttribute('aria-labelledby', heading.id);
281
- }
200
+ // Fire beforeHide (cancelable)
201
+ if (
202
+ !_.#emit('beforeHide', {
203
+ cancelable: true,
204
+ result: _.#result,
205
+ triggerElement: triggerEl,
206
+ })
207
+ ) {
208
+ return false;
282
209
  }
283
210
 
284
- // Add modal overlay
285
- _.prepend(document.createElement('dialog-overlay'));
286
- _.#bindUI();
287
- _.#bindKeyboard();
211
+ // Set state to 'hiding' - this triggers CSS exit animation
212
+ _.#setState('hiding');
213
+
214
+ // Wait for transition to complete
215
+ _.#waitForTransition(() => {
216
+ _.#dialog.close();
217
+ _.#setState('hidden');
218
+ _.#emit('hidden', {
219
+ result: _.#result,
220
+ triggerElement: triggerEl,
221
+ });
222
+
223
+ // Return focus to trigger element
224
+ if (_.#triggerElement) _.#triggerElement.focus();
225
+
226
+ // Clean up
227
+ _.#triggerElement = null;
228
+ _.#result = null;
229
+ });
230
+
231
+ return true;
288
232
  }
289
233
 
290
234
  /**
291
- * Binds click events for showing and hiding the dialog
235
+ * Wait for CSS transition to complete with fallback timeout
236
+ * @param {Function} callback - Called when transition completes
292
237
  * @private
293
238
  */
294
- #bindUI() {
239
+ #waitForTransition(callback) {
295
240
  const _ = this;
296
-
297
- // Handle trigger buttons
298
- document.addEventListener('click', (e) => {
299
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
300
- if (!trigger) return;
301
-
302
- if (trigger.getAttribute('data-prevent-default') === 'true') {
303
- e.preventDefault();
241
+ let called = false;
242
+
243
+ const done = () => {
244
+ if (called) return;
245
+ called = true;
246
+ _.#dialog.removeEventListener('transitionend', onTransitionEnd);
247
+ if (_.#pendingTimeout) {
248
+ clearTimeout(_.#pendingTimeout);
249
+ _.#pendingTimeout = null;
304
250
  }
251
+ callback();
252
+ };
305
253
 
306
- _.show(trigger);
307
- });
254
+ const onTransitionEnd = (e) => {
255
+ if (e.target === _.#dialog) done();
256
+ };
308
257
 
309
- // Handle close buttons
310
- _.addEventListener('click', (e) => {
311
- if (!e.target.closest('[data-action-hide-dialog]')) return;
312
- _.hide();
313
- });
258
+ _.#dialog.addEventListener('transitionend', onTransitionEnd);
314
259
 
315
- // Add transition end listener
316
- _.contentPanel.addEventListener(
317
- 'transitionend',
318
- _.#handleTransitionEnd
260
+ _.#pendingTimeout = setTimeout(
261
+ done,
262
+ DialogPanel.TRANSITION_FALLBACK_TIMEOUT
319
263
  );
320
264
  }
321
265
 
322
266
  /**
323
- * Binds keyboard events for accessibility
267
+ * Set the component state
268
+ * @param {string} newState - The new state
324
269
  * @private
325
270
  */
326
- #bindKeyboard() {
327
- this.addEventListener('keydown', (e) => {
328
- if (e.key === 'Escape') {
329
- this.hide();
330
- }
331
- });
271
+ #setState(newState) {
272
+ this.#state = newState;
273
+ this.setAttribute('state', newState);
332
274
  }
333
275
 
334
276
  /**
335
- * Shows the dialog and traps focus within it
336
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog
337
- * @fires DialogPanel#beforeShow - Fired before the dialog starts to show
338
- * @fires DialogPanel#show - Fired when the dialog has been shown
339
- * @returns {boolean} False if the show was prevented by a beforeShow event handler
277
+ * Emit a custom event
278
+ * @param {string} name - Event name
279
+ * @param {Object} options - Event options
280
+ * @returns {boolean} False if event was cancelled
281
+ * @private
340
282
  */
341
- show(triggerEl = null) {
283
+ #emit(name, options = {}) {
342
284
  const _ = this;
343
- _.triggerEl = triggerEl || false;
285
+ const { cancelable = false, ...detail } = options;
344
286
 
345
- // Dispatch beforeShow event - allows preventing the dialog from opening
346
- const beforeShowEvent = new CustomEvent('beforeShow', {
287
+ const event = new CustomEvent(name, {
347
288
  bubbles: true,
348
- cancelable: true,
349
- detail: { triggerElement: _.triggerEl },
289
+ composed: true,
290
+ cancelable,
291
+ detail: {
292
+ triggerElement: _.#triggerElement,
293
+ result: _.#result,
294
+ state: _.#state,
295
+ ...detail,
296
+ },
350
297
  });
351
298
 
352
- const showAllowed = _.dispatchEvent(beforeShowEvent);
353
-
354
- // If event was canceled (preventDefault was called), don't show the dialog
355
- if (!showAllowed) return false;
299
+ return _.dispatchEvent(event);
300
+ }
356
301
 
357
- // Add open attribute for CSS :has() selector
358
- _.setAttribute('open', '');
302
+ // Read-only properties
303
+ get state() {
304
+ return this.#state;
305
+ }
306
+ get dialog() {
307
+ return this.#dialog;
308
+ }
309
+ get isOpen() {
310
+ return this.#state === 'showing' || this.#state === 'shown';
311
+ }
312
+ get triggerElement() {
313
+ return this.#triggerElement;
314
+ }
315
+ }
359
316
 
360
- // Remove the hidden class first to ensure content is rendered
361
- _.contentPanel.classList.remove('hidden');
317
+ /**
318
+ * DialogBackdrop - A custom backdrop element that animates with the dialog-panel state.
319
+ * Use this instead of native ::backdrop for consistent cross-browser animations.
320
+ *
321
+ * @extends HTMLElement
322
+ */
323
+ class DialogBackdrop extends HTMLElement {
324
+ #panel = null;
325
+ #handlers = {
326
+ click: null,
327
+ };
362
328
 
363
- // Give the browser a moment to process before starting animation
364
- requestAnimationFrame(() => {
365
- // Update ARIA states
366
- _.setAttribute('aria-hidden', 'false');
329
+ connectedCallback() {
330
+ const _ = this;
367
331
 
368
- // set trigger element to expanded: true
369
- if (_.triggerEl) {
370
- _.triggerEl.setAttribute('aria-expanded', 'true');
371
- }
332
+ // Find parent dialog-panel
333
+ _.#panel = _.closest('dialog-panel');
372
334
 
373
- // Focus management
374
- const firstFocusable = _.querySelector(
375
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
335
+ if (!_.#panel) {
336
+ console.warn(
337
+ 'DialogBackdrop: Must be inside a <dialog-panel> element'
376
338
  );
377
- if (firstFocusable) {
378
- requestAnimationFrame(() => {
379
- firstFocusable.focus();
380
- });
381
- }
339
+ return;
340
+ }
382
341
 
383
- // Dispatch show event - dialog is now visible
384
- _.dispatchEvent(
385
- new CustomEvent('show', {
386
- bubbles: true,
387
- detail: { triggerElement: _.triggerEl },
388
- })
389
- );
390
- });
342
+ // Handle click to close
343
+ _.#handlers.click = () => {
344
+ _.#panel.hide();
345
+ };
346
+ _.addEventListener('click', _.#handlers.click);
391
347
  }
392
348
 
393
- /**
394
- * Hides the dialog and restores focus
395
- * @fires DialogPanel#beforeHide - Fired before the dialog starts to hide
396
- * @fires DialogPanel#hide - Fired when the dialog has started hiding (transition begins)
397
- * @fires DialogPanel#afterHide - Fired when the dialog has completed its hide transition
398
- * @returns {boolean} False if the hide was prevented by a beforeHide event handler
399
- */
400
- hide() {
349
+ disconnectedCallback() {
401
350
  const _ = this;
402
-
403
- // Dispatch beforeHide event - allows preventing the dialog from closing
404
- const beforeHideEvent = new CustomEvent('beforeHide', {
405
- bubbles: true,
406
- cancelable: true,
407
- detail: { triggerElement: _.triggerEl },
408
- });
409
-
410
- const hideAllowed = _.dispatchEvent(beforeHideEvent);
411
-
412
- // If event was canceled (preventDefault was called), don't hide the dialog
413
- if (!hideAllowed) return false;
414
-
415
- // ensure focus is moved out of the dialog
416
- const activeElement = document.activeElement;
417
- if (activeElement && _.contains(activeElement)) {
418
- // Blur the currently focused element if it's inside the dialog
419
- activeElement.blur();
351
+ if (_.#handlers.click) {
352
+ _.removeEventListener('click', _.#handlers.click);
420
353
  }
421
-
422
- // Update ARIA states and restore focus
423
- if (_.triggerEl) {
424
- // remove focus from modal panel first
425
- _.triggerEl.focus();
426
- // mark trigger as no longer expanded
427
- _.triggerEl.setAttribute('aria-expanded', 'false');
428
- } else {
429
- // Move focus to body to ensure it's not trapped
430
- document.body.focus();
431
- }
432
-
433
- // Set aria-hidden to start transition
434
- // The transitionend event handler will add display:none when complete
435
- _.setAttribute('aria-hidden', 'true');
436
- // Remove open attribute for CSS :has() selector
437
-
438
- _.removeAttribute('open');
439
-
440
- // Dispatch hide event - dialog is now starting to hide
441
- _.dispatchEvent(
442
- new CustomEvent('hide', {
443
- bubbles: true,
444
- detail: { triggerElement: _.triggerEl },
445
- })
446
- );
447
-
448
- return true;
449
354
  }
450
355
  }
451
356
 
452
- /**
453
- * Custom element that creates a clickable overlay for the dialog
454
- * @extends HTMLElement
455
- */
456
- class DialogOverlay extends HTMLElement {
457
- constructor() {
458
- super();
459
- this.setAttribute('tabindex', '-1');
460
- this.dialogPanel = this.closest('dialog-panel');
461
- this.#bindUI();
462
- }
463
-
464
- #bindUI() {
465
- this.addEventListener('click', () => {
466
- this.dialogPanel.hide();
467
- });
468
- }
357
+ // Register the custom elements
358
+ // Note: dialog-backdrop must be defined BEFORE dialog-panel,
359
+ // because dialog-panel's connectedCallback may create dialog-backdrop elements
360
+ if (!customElements.get('dialog-backdrop')) {
361
+ customElements.define('dialog-backdrop', DialogBackdrop);
469
362
  }
470
-
471
- /**
472
- * Custom element that wraps the content of the dialog
473
- * @extends HTMLElement
474
- */
475
- class DialogContent extends HTMLElement {
476
- constructor() {
477
- super();
478
- this.setAttribute('role', 'document');
479
- }
480
- }
481
-
482
363
  if (!customElements.get('dialog-panel')) {
483
364
  customElements.define('dialog-panel', DialogPanel);
484
365
  }
485
- if (!customElements.get('dialog-overlay')) {
486
- customElements.define('dialog-overlay', DialogOverlay);
487
- }
488
- if (!customElements.get('dialog-content')) {
489
- customElements.define('dialog-content', DialogContent);
490
- }
491
366
 
492
- exports.DialogContent = DialogContent;
493
- exports.DialogOverlay = DialogOverlay;
367
+ exports.DialogBackdrop = DialogBackdrop;
494
368
  exports.DialogPanel = DialogPanel;
495
- exports.default = DialogPanel;
496
-
497
- Object.defineProperty(exports, '__esModule', { value: true });
498
369
 
499
370
  }));
500
371
  //# sourceMappingURL=dialog-panel.js.map