@magic-spells/dialog-panel 0.2.3 → 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,507 +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
- }
34
-
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
- }
57
-
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
- connectedCallback() {
63
- this.setupTrap();
64
- this.addEventListener('keydown', this.handleKeyDown);
65
- }
66
-
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
- }
74
-
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
- }
93
-
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();
103
- }
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,
104
35
  };
105
36
 
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;
113
-
114
- container.setAttribute('aria-hidden', 'true');
37
+ // Animation cleanup references
38
+ #pendingRAF = null;
39
+ #pendingTimeout = null;
115
40
 
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();
122
- }
123
- }
124
- }
41
+ // Fallback timeout for transitionend (in ms)
42
+ static TRANSITION_FALLBACK_TIMEOUT = 500;
125
43
 
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
44
  connectedCallback() {
132
- this.setAttribute('tabindex', '0');
133
- this.addEventListener('focus', this.handleFocus);
134
- }
135
-
136
- /**
137
- * Called when the element is disconnected from the DOM.
138
- * Removes the focus event listener.
139
- */
140
- disconnectedCallback() {
141
- this.removeEventListener('focus', this.handleFocus);
142
- }
143
-
144
- /**
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.
149
- */
150
- handleFocus = (e) => {
151
- const trap = this.closest('focus-trap');
152
- const focusableElements = getFocusableElements(trap);
45
+ const _ = this;
153
46
 
154
- if (focusableElements.length === 0) return;
47
+ // Find inner dialog element
48
+ _.#dialog = _.querySelector('dialog');
155
49
 
156
- const firstElement = focusableElements[0];
157
- const lastElement =
158
- focusableElements[focusableElements.length - 1];
50
+ if (!_.#dialog) {
51
+ console.warn(
52
+ 'DialogPanel: No <dialog> element found inside <dialog-panel>'
53
+ );
54
+ return;
55
+ }
159
56
 
160
- if (e.relatedTarget === firstElement) {
161
- lastElement.focus();
162
- } else {
163
- firstElement.focus();
57
+ // Auto-create dialog-backdrop if not present
58
+ if (!_.querySelector('dialog-backdrop')) {
59
+ const backdrop = document.createElement('dialog-backdrop');
60
+ _.insertBefore(backdrop, _.firstChild);
164
61
  }
165
- };
166
- }
167
62
 
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
- }
63
+ // Set initial state attribute
64
+ _.#setState('hidden');
177
65
 
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);
66
+ // Bind event handlers
67
+ _.#bindEvents();
184
68
  }
185
69
 
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
- }
195
-
196
- customElements.define('focus-trap', FocusTrap);
197
- customElements.define('focus-trap-start', FocusTrapStart);
198
- customElements.define('focus-trap-end', FocusTrapEnd);
199
-
200
- /**
201
- * Custom element that creates an accessible modal dialog panel with focus management
202
- * @extends HTMLElement
203
- */
204
- class DialogPanel extends HTMLElement {
205
- #handleTransitionEnd;
206
- #scrollPosition = 0;
207
-
208
- /**
209
- * Clean up event listeners when component is removed from DOM
210
- */
211
70
  disconnectedCallback() {
212
71
  const _ = this;
213
- if (_.contentPanel) {
214
- _.contentPanel.removeEventListener(
215
- 'transitionend',
216
- _.#handleTransitionEnd
217
- );
218
- }
219
-
220
- // Ensure body scroll is restored if component is removed while open
221
- document.body.classList.remove('overflow-hidden');
222
- this.#restoreScroll();
223
- }
224
72
 
225
- /**
226
- * Saves current scroll position and locks body scrolling
227
- * @private
228
- */
229
- #lockScroll() {
230
- const _ = this;
231
- // Save current scroll position
232
- _.#scrollPosition = window.pageYOffset;
73
+ // Cancel pending animations
74
+ if (_.#pendingRAF) {
75
+ cancelAnimationFrame(_.#pendingRAF);
76
+ _.#pendingRAF = null;
77
+ }
78
+ if (_.#pendingTimeout) {
79
+ clearTimeout(_.#pendingTimeout);
80
+ _.#pendingTimeout = null;
81
+ }
233
82
 
234
- // Apply fixed position to body
235
- document.body.classList.add('overflow-hidden');
236
- document.body.style.top = `-${_.#scrollPosition}px`;
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
+ }
237
98
  }
238
99
 
239
100
  /**
240
- * Restores scroll position when dialog is closed
101
+ * Bind event listeners for close buttons, backdrop, and escape key
241
102
  * @private
242
103
  */
243
- #restoreScroll() {
104
+ #bindEvents() {
244
105
  const _ = this;
245
- // Remove fixed positioning
246
- document.body.classList.remove('overflow-hidden');
247
- document.body.style.removeProperty('top');
248
106
 
249
- // Restore scroll position
250
- window.scrollTo(0, _.#scrollPosition);
251
- }
252
- /**
253
- * Initializes the dialog panel, sets up focus trap and overlay
254
- */
255
- constructor() {
256
- super();
257
- const _ = this;
258
- _.id = _.getAttribute('id');
259
- _.setAttribute('role', 'dialog');
260
- _.setAttribute('aria-modal', 'true');
261
- _.setAttribute('aria-hidden', 'true');
262
-
263
- _.contentPanel = _.querySelector('dialog-content');
264
- _.focusTrap = document.createElement('focus-trap');
265
- _.triggerEl = null;
266
-
267
- // Create a handler for transition end events
268
- _.#handleTransitionEnd = (e) => {
269
- if (
270
- e.propertyName === 'opacity' &&
271
- _.getAttribute('aria-hidden') === 'true'
272
- ) {
273
- _.contentPanel.classList.add('hidden');
274
-
275
- // Dispatch afterHide event - dialog has completed its transition
276
- _.dispatchEvent(
277
- new CustomEvent('afterHide', {
278
- bubbles: true,
279
- detail: { triggerElement: _.triggerEl },
280
- })
281
- );
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);
282
113
  }
283
114
  };
284
-
285
- // Ensure we have labelledby and describedby references
286
- if (!_.getAttribute('aria-labelledby')) {
287
- const heading = _.querySelector('h1, h2, h3');
288
- if (heading && !heading.id) {
289
- heading.id = `${_.id}-title`;
290
- }
291
- if (heading?.id) {
292
- _.setAttribute('aria-labelledby', heading.id);
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();
293
129
  }
294
- }
295
-
296
- _.contentPanel.parentNode.insertBefore(
297
- _.focusTrap,
298
- _.contentPanel
299
- );
300
- _.focusTrap.appendChild(_.contentPanel);
301
-
302
- _.focusTrap.setupTrap();
130
+ };
131
+ _.#dialog.addEventListener('click', _.#handlers.dialogClick);
303
132
 
304
- // Add modal overlay
305
- _.prepend(document.createElement('dialog-overlay'));
306
- _.#bindUI();
307
- _.#bindKeyboard();
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);
308
140
  }
309
141
 
310
142
  /**
311
- * Binds click events for showing and hiding the dialog
312
- * @private
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
313
146
  */
314
- #bindUI() {
147
+ show(triggerEl = null) {
315
148
  const _ = this;
316
149
 
317
- // Handle trigger buttons
318
- document.addEventListener('click', (e) => {
319
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
320
- if (!trigger) return;
150
+ // Check if already showing/shown
151
+ if (_.#state === 'showing' || _.#state === 'shown') return true;
321
152
 
322
- if (trigger.getAttribute('data-prevent-default') === 'true') {
323
- e.preventDefault();
324
- }
153
+ // If currently hiding, ignore (let hide complete first)
154
+ if (_.#state === 'hiding') return false;
325
155
 
326
- _.show(trigger);
327
- });
156
+ // Store trigger element for focus return later
157
+ _.#triggerElement = triggerEl || null;
328
158
 
329
- // Handle close buttons
330
- _.addEventListener('click', (e) => {
331
- if (!e.target.closest('[data-action="hide-dialog"]')) return;
332
- _.hide();
333
- });
159
+ // Fire beforeShow (cancelable)
160
+ if (!_.#emit('beforeShow', { cancelable: true })) return false;
334
161
 
335
- // Add transition end listener
336
- _.contentPanel.addEventListener(
337
- 'transitionend',
338
- _.#handleTransitionEnd
339
- );
340
- }
162
+ // Set state to 'showing' and open native dialog
163
+ _.#setState('showing');
164
+ _.#dialog.showModal();
341
165
 
342
- /**
343
- * Binds keyboard events for accessibility
344
- * @private
345
- */
346
- #bindKeyboard() {
347
- this.addEventListener('keydown', (e) => {
348
- if (e.key === 'Escape') {
349
- this.hide();
350
- }
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');
172
+
173
+ // Wait for transition to complete before firing 'shown' event
174
+ _.#waitForTransition(() => {
175
+ _.#emit('shown');
176
+ });
177
+ });
351
178
  });
179
+
180
+ return true;
352
181
  }
353
182
 
354
183
  /**
355
- * Shows the dialog and traps focus within it
356
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog
357
- * @fires DialogPanel#beforeShow - Fired before the dialog starts to show
358
- * @fires DialogPanel#show - Fired when the dialog has been shown
359
- * @returns {boolean} False if the show was prevented by a beforeShow event handler
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
360
187
  */
361
- show(triggerEl = null) {
188
+ hide(triggerEl = null) {
362
189
  const _ = this;
363
- _.triggerEl = triggerEl || false;
364
190
 
365
- // Dispatch beforeShow event - allows preventing the dialog from opening
366
- const beforeShowEvent = new CustomEvent('beforeShow', {
367
- bubbles: true,
368
- cancelable: true,
369
- detail: { triggerElement: _.triggerEl },
370
- });
191
+ // Check if already hiding/hidden
192
+ if (_.#state === 'hiding' || _.#state === 'hidden') return true;
371
193
 
372
- const showAllowed = _.dispatchEvent(beforeShowEvent);
194
+ // If currently showing, ignore (let show complete first)
195
+ if (_.#state === 'showing') return false;
373
196
 
374
- // If event was canceled (preventDefault was called), don't show the dialog
375
- if (!showAllowed) return false;
197
+ // Capture result from trigger element
198
+ _.#result = triggerEl?.dataset?.result ?? null;
376
199
 
377
- // Remove the hidden class first to ensure content is rendered
378
- _.contentPanel.classList.remove('hidden');
200
+ // Fire beforeHide (cancelable)
201
+ if (
202
+ !_.#emit('beforeHide', {
203
+ cancelable: true,
204
+ result: _.#result,
205
+ triggerElement: triggerEl,
206
+ })
207
+ ) {
208
+ return false;
209
+ }
379
210
 
380
- // Give the browser a moment to process before starting animation
381
- requestAnimationFrame(() => {
382
- // Update ARIA states
383
- _.setAttribute('aria-hidden', 'false');
384
- if (_.triggerEl) {
385
- _.triggerEl.setAttribute('aria-expanded', 'true');
386
- }
211
+ // Set state to 'hiding' - this triggers CSS exit animation
212
+ _.#setState('hiding');
387
213
 
388
- // Lock body scrolling and save scroll position
389
- _.#lockScroll();
214
+ // Wait for transition to complete
215
+ _.#waitForTransition(() => {
216
+ _.#dialog.close();
217
+ _.#setState('hidden');
218
+ _.#emit('hidden', {
219
+ result: _.#result,
220
+ triggerElement: triggerEl,
221
+ });
390
222
 
391
- // Focus management
392
- const firstFocusable = _.querySelector(
393
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
394
- );
395
- if (firstFocusable) {
396
- requestAnimationFrame(() => {
397
- firstFocusable.focus();
398
- });
399
- }
223
+ // Return focus to trigger element
224
+ if (_.#triggerElement) _.#triggerElement.focus();
400
225
 
401
- // Dispatch show event - dialog is now visible
402
- _.dispatchEvent(
403
- new CustomEvent('show', {
404
- bubbles: true,
405
- detail: { triggerElement: _.triggerEl },
406
- })
407
- );
226
+ // Clean up
227
+ _.#triggerElement = null;
228
+ _.#result = null;
408
229
  });
409
230
 
410
231
  return true;
411
232
  }
412
233
 
413
234
  /**
414
- * Hides the dialog and restores focus
415
- * @fires DialogPanel#beforeHide - Fired before the dialog starts to hide
416
- * @fires DialogPanel#hide - Fired when the dialog has started hiding (transition begins)
417
- * @fires DialogPanel#afterHide - Fired when the dialog has completed its hide transition
418
- * @returns {boolean} False if the hide was prevented by a beforeHide event handler
235
+ * Wait for CSS transition to complete with fallback timeout
236
+ * @param {Function} callback - Called when transition completes
237
+ * @private
419
238
  */
420
- hide() {
239
+ #waitForTransition(callback) {
421
240
  const _ = this;
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;
250
+ }
251
+ callback();
252
+ };
422
253
 
423
- // Dispatch beforeHide event - allows preventing the dialog from closing
424
- const beforeHideEvent = new CustomEvent('beforeHide', {
425
- bubbles: true,
426
- cancelable: true,
427
- detail: { triggerElement: _.triggerEl },
428
- });
254
+ const onTransitionEnd = (e) => {
255
+ if (e.target === _.#dialog) done();
256
+ };
429
257
 
430
- const hideAllowed = _.dispatchEvent(beforeHideEvent);
258
+ _.#dialog.addEventListener('transitionend', onTransitionEnd);
431
259
 
432
- // If event was canceled (preventDefault was called), don't hide the dialog
433
- if (!hideAllowed) return false;
260
+ _.#pendingTimeout = setTimeout(
261
+ done,
262
+ DialogPanel.TRANSITION_FALLBACK_TIMEOUT
263
+ );
264
+ }
434
265
 
435
- // Restore body scroll and scroll position
436
- _.#restoreScroll();
266
+ /**
267
+ * Set the component state
268
+ * @param {string} newState - The new state
269
+ * @private
270
+ */
271
+ #setState(newState) {
272
+ this.#state = newState;
273
+ this.setAttribute('state', newState);
274
+ }
437
275
 
438
- // Update ARIA states
439
- if (_.triggerEl) {
440
- // remove focus from modal panel first
441
- _.triggerEl.focus();
442
- // mark trigger as no longer expanded
443
- _.triggerEl.setAttribute('aria-expanded', 'false');
444
- }
276
+ /**
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
282
+ */
283
+ #emit(name, options = {}) {
284
+ const _ = this;
285
+ const { cancelable = false, ...detail } = options;
445
286
 
446
- // Set aria-hidden to start transition
447
- // The transitionend event handler will add display:none when complete
448
- _.setAttribute('aria-hidden', 'true');
287
+ const event = new CustomEvent(name, {
288
+ bubbles: true,
289
+ composed: true,
290
+ cancelable,
291
+ detail: {
292
+ triggerElement: _.#triggerElement,
293
+ result: _.#result,
294
+ state: _.#state,
295
+ ...detail,
296
+ },
297
+ });
449
298
 
450
- // Dispatch hide event - dialog is now starting to hide
451
- _.dispatchEvent(
452
- new CustomEvent('hide', {
453
- bubbles: true,
454
- detail: { triggerElement: _.triggerEl },
455
- })
456
- );
299
+ return _.dispatchEvent(event);
300
+ }
457
301
 
458
- return true;
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;
459
314
  }
460
315
  }
461
316
 
462
317
  /**
463
- * Custom element that creates a clickable overlay for the dialog
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
+ *
464
321
  * @extends HTMLElement
465
322
  */
466
- class DialogOverlay extends HTMLElement {
467
- constructor() {
468
- super();
469
- this.setAttribute('tabindex', '-1'); // Changed to -1 as it shouldn't be focusable
470
- this.setAttribute('aria-hidden', 'true');
471
- this.dialogPanel = this.closest('dialog-panel');
472
- this.#bindUI();
473
- }
323
+ class DialogBackdrop extends HTMLElement {
324
+ #panel = null;
325
+ #handlers = {
326
+ click: null,
327
+ };
474
328
 
475
- #bindUI() {
476
- this.addEventListener('click', () => {
477
- this.dialogPanel.hide();
478
- });
329
+ connectedCallback() {
330
+ const _ = this;
331
+
332
+ // Find parent dialog-panel
333
+ _.#panel = _.closest('dialog-panel');
334
+
335
+ if (!_.#panel) {
336
+ console.warn(
337
+ 'DialogBackdrop: Must be inside a <dialog-panel> element'
338
+ );
339
+ return;
340
+ }
341
+
342
+ // Handle click to close
343
+ _.#handlers.click = () => {
344
+ _.#panel.hide();
345
+ };
346
+ _.addEventListener('click', _.#handlers.click);
479
347
  }
480
- }
481
348
 
482
- /**
483
- * Custom element that wraps the content of the dialog
484
- * @extends HTMLElement
485
- */
486
- class DialogContent extends HTMLElement {
487
- constructor() {
488
- super();
489
- this.setAttribute('role', 'document'); // Optional: helps with document structure
349
+ disconnectedCallback() {
350
+ const _ = this;
351
+ if (_.#handlers.click) {
352
+ _.removeEventListener('click', _.#handlers.click);
353
+ }
490
354
  }
491
355
  }
492
356
 
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);
362
+ }
493
363
  if (!customElements.get('dialog-panel')) {
494
364
  customElements.define('dialog-panel', DialogPanel);
495
365
  }
496
- if (!customElements.get('dialog-overlay')) {
497
- customElements.define('dialog-overlay', DialogOverlay);
498
- }
499
- if (!customElements.get('dialog-content')) {
500
- customElements.define('dialog-content', DialogContent);
501
- }
502
366
 
503
- exports.DialogContent = DialogContent;
504
- exports.DialogOverlay = DialogOverlay;
367
+ exports.DialogBackdrop = DialogBackdrop;
505
368
  exports.DialogPanel = DialogPanel;
506
- exports.default = DialogPanel;
507
-
508
- Object.defineProperty(exports, '__esModule', { value: true });
509
369
 
510
370
  }));
511
371
  //# sourceMappingURL=dialog-panel.js.map