@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.
- package/README.md +163 -114
- package/dist/dialog-panel.cjs.js +281 -230
- package/dist/dialog-panel.cjs.js.map +1 -1
- package/dist/dialog-panel.css +108 -62
- package/dist/dialog-panel.esm.js +281 -226
- package/dist/dialog-panel.esm.js.map +1 -1
- package/dist/dialog-panel.js +273 -413
- package/dist/dialog-panel.js.map +1 -1
- package/dist/dialog-panel.min.css +1 -1
- package/dist/dialog-panel.min.js +1 -1
- package/package.json +11 -20
- package/src/dialog-panel.css +117 -0
- package/src/dialog-panel.js +282 -227
- package/dist/dialog-panel.scss +0 -2
- package/dist/scss/dialog-panel.scss +0 -73
- package/dist/scss/variables.scss +0 -54
- package/src/index.scss +0 -2
- package/src/scss/dialog-panel.scss +0 -73
- package/src/scss/variables.scss +0 -54
package/dist/dialog-panel.js
CHANGED
|
@@ -5,507 +5,367 @@
|
|
|
5
5
|
})(this, (function (exports) { 'use strict';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* DialogPanel - A lightweight web component wrapper for native <dialog> elements
|
|
9
|
+
* with state-driven animations.
|
|
9
10
|
*
|
|
10
|
-
* @
|
|
11
|
-
*
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
47
|
+
// Find inner dialog element
|
|
48
|
+
_.#dialog = _.querySelector('dialog');
|
|
155
49
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
50
|
+
if (!_.#dialog) {
|
|
51
|
+
console.warn(
|
|
52
|
+
'DialogPanel: No <dialog> element found inside <dialog-panel>'
|
|
53
|
+
);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
159
56
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
*
|
|
101
|
+
* Bind event listeners for close buttons, backdrop, and escape key
|
|
241
102
|
* @private
|
|
242
103
|
*/
|
|
243
|
-
#
|
|
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
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
//
|
|
305
|
-
_.
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
*
|
|
312
|
-
* @
|
|
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
|
-
|
|
147
|
+
show(triggerEl = null) {
|
|
315
148
|
const _ = this;
|
|
316
149
|
|
|
317
|
-
//
|
|
318
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
153
|
+
// If currently hiding, ignore (let hide complete first)
|
|
154
|
+
if (_.#state === 'hiding') return false;
|
|
325
155
|
|
|
326
|
-
|
|
327
|
-
|
|
156
|
+
// Store trigger element for focus return later
|
|
157
|
+
_.#triggerElement = triggerEl || null;
|
|
328
158
|
|
|
329
|
-
//
|
|
330
|
-
_
|
|
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
|
-
//
|
|
336
|
-
_
|
|
337
|
-
|
|
338
|
-
_.#handleTransitionEnd
|
|
339
|
-
);
|
|
340
|
-
}
|
|
162
|
+
// Set state to 'showing' and open native dialog
|
|
163
|
+
_.#setState('showing');
|
|
164
|
+
_.#dialog.showModal();
|
|
341
165
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
*
|
|
356
|
-
* @param {HTMLElement} [triggerEl=null] - The element that triggered the
|
|
357
|
-
* @
|
|
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
|
-
|
|
188
|
+
hide(triggerEl = null) {
|
|
362
189
|
const _ = this;
|
|
363
|
-
_.triggerEl = triggerEl || false;
|
|
364
190
|
|
|
365
|
-
//
|
|
366
|
-
|
|
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
|
-
|
|
194
|
+
// If currently showing, ignore (let show complete first)
|
|
195
|
+
if (_.#state === 'showing') return false;
|
|
373
196
|
|
|
374
|
-
//
|
|
375
|
-
|
|
197
|
+
// Capture result from trigger element
|
|
198
|
+
_.#result = triggerEl?.dataset?.result ?? null;
|
|
376
199
|
|
|
377
|
-
//
|
|
378
|
-
|
|
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
|
-
//
|
|
381
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
-
//
|
|
392
|
-
|
|
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
|
-
//
|
|
402
|
-
_
|
|
403
|
-
|
|
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
|
-
*
|
|
415
|
-
* @
|
|
416
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
cancelable: true,
|
|
427
|
-
detail: { triggerElement: _.triggerEl },
|
|
428
|
-
});
|
|
254
|
+
const onTransitionEnd = (e) => {
|
|
255
|
+
if (e.target === _.#dialog) done();
|
|
256
|
+
};
|
|
429
257
|
|
|
430
|
-
|
|
258
|
+
_.#dialog.addEventListener('transitionend', onTransitionEnd);
|
|
431
259
|
|
|
432
|
-
|
|
433
|
-
|
|
260
|
+
_.#pendingTimeout = setTimeout(
|
|
261
|
+
done,
|
|
262
|
+
DialogPanel.TRANSITION_FALLBACK_TIMEOUT
|
|
263
|
+
);
|
|
264
|
+
}
|
|
434
265
|
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
new CustomEvent('hide', {
|
|
453
|
-
bubbles: true,
|
|
454
|
-
detail: { triggerElement: _.triggerEl },
|
|
455
|
-
})
|
|
456
|
-
);
|
|
299
|
+
return _.dispatchEvent(event);
|
|
300
|
+
}
|
|
457
301
|
|
|
458
|
-
|
|
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
|
-
*
|
|
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
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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.
|
|
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
|