@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.
@@ -1,307 +1,362 @@
1
- import '@magic-spells/focus-trap';
2
-
3
1
  /**
4
- * Custom element that creates an accessible modal dialog panel with focus management
2
+ * DialogPanel - A lightweight web component wrapper for native <dialog> elements
3
+ * with state-driven animations.
4
+ *
5
5
  * @extends HTMLElement
6
+ *
7
+ * @property {string} state - Current state: 'hidden' | 'showing' | 'shown' | 'hiding'
8
+ * @property {HTMLDialogElement} dialog - Reference to inner <dialog> element
9
+ * @property {boolean} isOpen - True if state is 'showing' or 'shown'
10
+ * @property {HTMLElement|null} triggerElement - Element that triggered current action
11
+ *
12
+ * @fires beforeShow - Fired before showing starts (cancelable)
13
+ * @fires shown - Fired after show animation completes
14
+ * @fires beforeHide - Fired before hiding starts (cancelable)
15
+ * @fires hidden - Fired after hide animation completes
6
16
  */
7
17
  class DialogPanel extends HTMLElement {
8
- #handleTransitionEnd;
9
- #scrollPosition = 0;
10
-
11
- /**
12
- * Clean up event listeners when component is removed from DOM
13
- */
14
- disconnectedCallback() {
18
+ // Private fields
19
+ #state = 'hidden';
20
+ #triggerElement = null;
21
+ #dialog = null;
22
+ #result = null;
23
+
24
+ // Event handler references for cleanup
25
+ #handlers = {
26
+ click: null,
27
+ dialogClick: null,
28
+ cancel: null,
29
+ };
30
+
31
+ // Animation cleanup references
32
+ #pendingRAF = null;
33
+ #pendingTimeout = null;
34
+
35
+ // Fallback timeout for transitionend (in ms)
36
+ static TRANSITION_FALLBACK_TIMEOUT = 500;
37
+
38
+ connectedCallback() {
15
39
  const _ = this;
16
- if (_.contentPanel) {
17
- _.contentPanel.removeEventListener(
18
- 'transitionend',
19
- _.#handleTransitionEnd
40
+
41
+ // Find inner dialog element
42
+ _.#dialog = _.querySelector('dialog');
43
+
44
+ if (!_.#dialog) {
45
+ console.warn(
46
+ 'DialogPanel: No <dialog> element found inside <dialog-panel>'
20
47
  );
48
+ return;
21
49
  }
22
50
 
23
- // Ensure body scroll is restored if component is removed while open
24
- document.body.classList.remove('overflow-hidden');
25
- this.#restoreScroll();
51
+ // Auto-create dialog-backdrop if not present
52
+ if (!_.querySelector('dialog-backdrop')) {
53
+ const backdrop = document.createElement('dialog-backdrop');
54
+ _.insertBefore(backdrop, _.firstChild);
55
+ }
56
+
57
+ // Set initial state attribute
58
+ _.#setState('hidden');
59
+
60
+ // Bind event handlers
61
+ _.#bindEvents();
26
62
  }
27
63
 
28
- /**
29
- * Saves current scroll position and locks body scrolling
30
- * @private
31
- */
32
- #lockScroll() {
64
+ disconnectedCallback() {
33
65
  const _ = this;
34
- // Save current scroll position
35
- _.#scrollPosition = window.pageYOffset;
36
66
 
37
- // Apply fixed position to body
38
- document.body.classList.add('overflow-hidden');
39
- document.body.style.top = `-${_.#scrollPosition}px`;
67
+ // Cancel pending animations
68
+ if (_.#pendingRAF) {
69
+ cancelAnimationFrame(_.#pendingRAF);
70
+ _.#pendingRAF = null;
71
+ }
72
+ if (_.#pendingTimeout) {
73
+ clearTimeout(_.#pendingTimeout);
74
+ _.#pendingTimeout = null;
75
+ }
76
+
77
+ // Clean up event listeners
78
+ if (_.#handlers.click) {
79
+ _.removeEventListener('click', _.#handlers.click);
80
+ }
81
+ if (_.#dialog) {
82
+ if (_.#handlers.dialogClick) {
83
+ _.#dialog.removeEventListener(
84
+ 'click',
85
+ _.#handlers.dialogClick
86
+ );
87
+ }
88
+ if (_.#handlers.cancel) {
89
+ _.#dialog.removeEventListener('cancel', _.#handlers.cancel);
90
+ }
91
+ }
40
92
  }
41
93
 
42
94
  /**
43
- * Restores scroll position when dialog is closed
95
+ * Bind event listeners for close buttons, backdrop, and escape key
44
96
  * @private
45
97
  */
46
- #restoreScroll() {
98
+ #bindEvents() {
47
99
  const _ = this;
48
- // Remove fixed positioning
49
- document.body.classList.remove('overflow-hidden');
50
- document.body.style.removeProperty('top');
51
100
 
52
- // Restore scroll position
53
- window.scrollTo(0, _.#scrollPosition);
54
- }
55
- /**
56
- * Initializes the dialog panel, sets up focus trap and overlay
57
- */
58
- constructor() {
59
- super();
60
- const _ = this;
61
- _.id = _.getAttribute('id');
62
- _.setAttribute('role', 'dialog');
63
- _.setAttribute('aria-modal', 'true');
64
- _.setAttribute('aria-hidden', 'true');
65
-
66
- _.contentPanel = _.querySelector('dialog-content');
67
- _.focusTrap = document.createElement('focus-trap');
68
- _.triggerEl = null;
69
-
70
- // Create a handler for transition end events
71
- _.#handleTransitionEnd = (e) => {
72
- if (
73
- e.propertyName === 'opacity' &&
74
- _.getAttribute('aria-hidden') === 'true'
75
- ) {
76
- _.contentPanel.classList.add('hidden');
77
-
78
- // Dispatch afterHide event - dialog has completed its transition
79
- _.dispatchEvent(
80
- new CustomEvent('afterHide', {
81
- bubbles: true,
82
- detail: { triggerElement: _.triggerEl },
83
- })
84
- );
101
+ // Handle close buttons with data-action-hide-dialog
102
+ _.#handlers.click = (e) => {
103
+ const trigger = e.target.closest('[data-action-hide-dialog]');
104
+ if (trigger) {
105
+ e.stopPropagation();
106
+ _.hide(trigger);
85
107
  }
86
108
  };
87
-
88
- // Ensure we have labelledby and describedby references
89
- if (!_.getAttribute('aria-labelledby')) {
90
- const heading = _.querySelector('h1, h2, h3');
91
- if (heading && !heading.id) {
92
- heading.id = `${_.id}-title`;
93
- }
94
- if (heading?.id) {
95
- _.setAttribute('aria-labelledby', heading.id);
109
+ _.addEventListener('click', _.#handlers.click);
110
+
111
+ // Handle backdrop click - detect clicks outside dialog bounds
112
+ // This works because clicks on ::backdrop still fire on the dialog element
113
+ _.#handlers.dialogClick = (e) => {
114
+ const rect = _.#dialog.getBoundingClientRect();
115
+ const clickedOutside =
116
+ e.clientX < rect.left ||
117
+ e.clientX > rect.right ||
118
+ e.clientY < rect.top ||
119
+ e.clientY > rect.bottom;
120
+ if (clickedOutside) {
121
+ e.stopPropagation();
122
+ _.hide();
96
123
  }
97
- }
98
-
99
- _.contentPanel.parentNode.insertBefore(
100
- _.focusTrap,
101
- _.contentPanel
102
- );
103
- _.focusTrap.appendChild(_.contentPanel);
104
-
105
- _.focusTrap.setupTrap();
124
+ };
125
+ _.#dialog.addEventListener('click', _.#handlers.dialogClick);
106
126
 
107
- // Add modal overlay
108
- _.prepend(document.createElement('dialog-overlay'));
109
- _.#bindUI();
110
- _.#bindKeyboard();
127
+ // Handle escape key - intercept native cancel and animate close
128
+ _.#handlers.cancel = (e) => {
129
+ e.preventDefault();
130
+ e.stopPropagation();
131
+ _.hide();
132
+ };
133
+ _.#dialog.addEventListener('cancel', _.#handlers.cancel);
111
134
  }
112
135
 
113
136
  /**
114
- * Binds click events for showing and hiding the dialog
115
- * @private
137
+ * Show the dialog with animation
138
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the show
139
+ * @returns {boolean} False if show was prevented via beforeShow event
116
140
  */
117
- #bindUI() {
141
+ show(triggerEl = null) {
118
142
  const _ = this;
119
143
 
120
- // Handle trigger buttons
121
- document.addEventListener('click', (e) => {
122
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
123
- if (!trigger) return;
144
+ // Check if already showing/shown
145
+ if (_.#state === 'showing' || _.#state === 'shown') return true;
124
146
 
125
- if (trigger.getAttribute('data-prevent-default') === 'true') {
126
- e.preventDefault();
127
- }
147
+ // If currently hiding, ignore (let hide complete first)
148
+ if (_.#state === 'hiding') return false;
128
149
 
129
- _.show(trigger);
130
- });
150
+ // Store trigger element for focus return later
151
+ _.#triggerElement = triggerEl || null;
131
152
 
132
- // Handle close buttons
133
- _.addEventListener('click', (e) => {
134
- if (!e.target.closest('[data-action="hide-dialog"]')) return;
135
- _.hide();
136
- });
153
+ // Fire beforeShow (cancelable)
154
+ if (!_.#emit('beforeShow', { cancelable: true })) return false;
137
155
 
138
- // Add transition end listener
139
- _.contentPanel.addEventListener(
140
- 'transitionend',
141
- _.#handleTransitionEnd
142
- );
143
- }
156
+ // Set state to 'showing' and open native dialog
157
+ _.#setState('showing');
158
+ _.#dialog.showModal();
144
159
 
145
- /**
146
- * Binds keyboard events for accessibility
147
- * @private
148
- */
149
- #bindKeyboard() {
150
- this.addEventListener('keydown', (e) => {
151
- if (e.key === 'Escape') {
152
- this.hide();
153
- }
160
+ // Double RAF ensures browser has painted the 'showing' state
161
+ // before we transition to 'shown'
162
+ _.#pendingRAF = requestAnimationFrame(() => {
163
+ _.#pendingRAF = requestAnimationFrame(() => {
164
+ _.#pendingRAF = null;
165
+ _.#setState('shown');
166
+
167
+ // Wait for transition to complete before firing 'shown' event
168
+ _.#waitForTransition(() => {
169
+ _.#emit('shown');
170
+ });
171
+ });
154
172
  });
173
+
174
+ return true;
155
175
  }
156
176
 
157
177
  /**
158
- * Shows the dialog and traps focus within it
159
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog
160
- * @fires DialogPanel#beforeShow - Fired before the dialog starts to show
161
- * @fires DialogPanel#show - Fired when the dialog has been shown
162
- * @returns {boolean} False if the show was prevented by a beforeShow event handler
178
+ * Hide the dialog with animation
179
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the hide
180
+ * @returns {boolean} False if hide was prevented via beforeHide event
163
181
  */
164
- show(triggerEl = null) {
182
+ hide(triggerEl = null) {
165
183
  const _ = this;
166
- _.triggerEl = triggerEl || false;
167
184
 
168
- // Dispatch beforeShow event - allows preventing the dialog from opening
169
- const beforeShowEvent = new CustomEvent('beforeShow', {
170
- bubbles: true,
171
- cancelable: true,
172
- detail: { triggerElement: _.triggerEl },
173
- });
185
+ // Check if already hiding/hidden
186
+ if (_.#state === 'hiding' || _.#state === 'hidden') return true;
174
187
 
175
- const showAllowed = _.dispatchEvent(beforeShowEvent);
188
+ // If currently showing, ignore (let show complete first)
189
+ if (_.#state === 'showing') return false;
176
190
 
177
- // If event was canceled (preventDefault was called), don't show the dialog
178
- if (!showAllowed) return false;
191
+ // Capture result from trigger element
192
+ _.#result = triggerEl?.dataset?.result ?? null;
179
193
 
180
- // Remove the hidden class first to ensure content is rendered
181
- _.contentPanel.classList.remove('hidden');
194
+ // Fire beforeHide (cancelable)
195
+ if (
196
+ !_.#emit('beforeHide', {
197
+ cancelable: true,
198
+ result: _.#result,
199
+ triggerElement: triggerEl,
200
+ })
201
+ ) {
202
+ return false;
203
+ }
182
204
 
183
- // Give the browser a moment to process before starting animation
184
- requestAnimationFrame(() => {
185
- // Update ARIA states
186
- _.setAttribute('aria-hidden', 'false');
187
- if (_.triggerEl) {
188
- _.triggerEl.setAttribute('aria-expanded', 'true');
189
- }
205
+ // Set state to 'hiding' - this triggers CSS exit animation
206
+ _.#setState('hiding');
190
207
 
191
- // Lock body scrolling and save scroll position
192
- _.#lockScroll();
208
+ // Wait for transition to complete
209
+ _.#waitForTransition(() => {
210
+ _.#dialog.close();
211
+ _.#setState('hidden');
212
+ _.#emit('hidden', {
213
+ result: _.#result,
214
+ triggerElement: triggerEl,
215
+ });
193
216
 
194
- // Focus management
195
- const firstFocusable = _.querySelector(
196
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
197
- );
198
- if (firstFocusable) {
199
- requestAnimationFrame(() => {
200
- firstFocusable.focus();
201
- });
202
- }
217
+ // Return focus to trigger element
218
+ if (_.#triggerElement) _.#triggerElement.focus();
203
219
 
204
- // Dispatch show event - dialog is now visible
205
- _.dispatchEvent(
206
- new CustomEvent('show', {
207
- bubbles: true,
208
- detail: { triggerElement: _.triggerEl },
209
- })
210
- );
220
+ // Clean up
221
+ _.#triggerElement = null;
222
+ _.#result = null;
211
223
  });
212
224
 
213
225
  return true;
214
226
  }
215
227
 
216
228
  /**
217
- * Hides the dialog and restores focus
218
- * @fires DialogPanel#beforeHide - Fired before the dialog starts to hide
219
- * @fires DialogPanel#hide - Fired when the dialog has started hiding (transition begins)
220
- * @fires DialogPanel#afterHide - Fired when the dialog has completed its hide transition
221
- * @returns {boolean} False if the hide was prevented by a beforeHide event handler
229
+ * Wait for CSS transition to complete with fallback timeout
230
+ * @param {Function} callback - Called when transition completes
231
+ * @private
222
232
  */
223
- hide() {
233
+ #waitForTransition(callback) {
224
234
  const _ = this;
235
+ let called = false;
236
+
237
+ const done = () => {
238
+ if (called) return;
239
+ called = true;
240
+ _.#dialog.removeEventListener('transitionend', onTransitionEnd);
241
+ if (_.#pendingTimeout) {
242
+ clearTimeout(_.#pendingTimeout);
243
+ _.#pendingTimeout = null;
244
+ }
245
+ callback();
246
+ };
225
247
 
226
- // Dispatch beforeHide event - allows preventing the dialog from closing
227
- const beforeHideEvent = new CustomEvent('beforeHide', {
228
- bubbles: true,
229
- cancelable: true,
230
- detail: { triggerElement: _.triggerEl },
231
- });
248
+ const onTransitionEnd = (e) => {
249
+ if (e.target === _.#dialog) done();
250
+ };
232
251
 
233
- const hideAllowed = _.dispatchEvent(beforeHideEvent);
252
+ _.#dialog.addEventListener('transitionend', onTransitionEnd);
234
253
 
235
- // If event was canceled (preventDefault was called), don't hide the dialog
236
- if (!hideAllowed) return false;
254
+ _.#pendingTimeout = setTimeout(
255
+ done,
256
+ DialogPanel.TRANSITION_FALLBACK_TIMEOUT
257
+ );
258
+ }
237
259
 
238
- // Restore body scroll and scroll position
239
- _.#restoreScroll();
260
+ /**
261
+ * Set the component state
262
+ * @param {string} newState - The new state
263
+ * @private
264
+ */
265
+ #setState(newState) {
266
+ this.#state = newState;
267
+ this.setAttribute('state', newState);
268
+ }
240
269
 
241
- // Update ARIA states
242
- if (_.triggerEl) {
243
- // remove focus from modal panel first
244
- _.triggerEl.focus();
245
- // mark trigger as no longer expanded
246
- _.triggerEl.setAttribute('aria-expanded', 'false');
247
- }
270
+ /**
271
+ * Emit a custom event
272
+ * @param {string} name - Event name
273
+ * @param {Object} options - Event options
274
+ * @returns {boolean} False if event was cancelled
275
+ * @private
276
+ */
277
+ #emit(name, options = {}) {
278
+ const _ = this;
279
+ const { cancelable = false, ...detail } = options;
248
280
 
249
- // Set aria-hidden to start transition
250
- // The transitionend event handler will add display:none when complete
251
- _.setAttribute('aria-hidden', 'true');
281
+ const event = new CustomEvent(name, {
282
+ bubbles: true,
283
+ composed: true,
284
+ cancelable,
285
+ detail: {
286
+ triggerElement: _.#triggerElement,
287
+ result: _.#result,
288
+ state: _.#state,
289
+ ...detail,
290
+ },
291
+ });
252
292
 
253
- // Dispatch hide event - dialog is now starting to hide
254
- _.dispatchEvent(
255
- new CustomEvent('hide', {
256
- bubbles: true,
257
- detail: { triggerElement: _.triggerEl },
258
- })
259
- );
293
+ return _.dispatchEvent(event);
294
+ }
260
295
 
261
- return true;
296
+ // Read-only properties
297
+ get state() {
298
+ return this.#state;
299
+ }
300
+ get dialog() {
301
+ return this.#dialog;
302
+ }
303
+ get isOpen() {
304
+ return this.#state === 'showing' || this.#state === 'shown';
305
+ }
306
+ get triggerElement() {
307
+ return this.#triggerElement;
262
308
  }
263
309
  }
264
310
 
265
311
  /**
266
- * Custom element that creates a clickable overlay for the dialog
312
+ * DialogBackdrop - A custom backdrop element that animates with the dialog-panel state.
313
+ * Use this instead of native ::backdrop for consistent cross-browser animations.
314
+ *
267
315
  * @extends HTMLElement
268
316
  */
269
- class DialogOverlay extends HTMLElement {
270
- constructor() {
271
- super();
272
- this.setAttribute('tabindex', '-1'); // Changed to -1 as it shouldn't be focusable
273
- this.setAttribute('aria-hidden', 'true');
274
- this.dialogPanel = this.closest('dialog-panel');
275
- this.#bindUI();
276
- }
317
+ class DialogBackdrop extends HTMLElement {
318
+ #panel = null;
319
+ #handlers = {
320
+ click: null,
321
+ };
277
322
 
278
- #bindUI() {
279
- this.addEventListener('click', () => {
280
- this.dialogPanel.hide();
281
- });
323
+ connectedCallback() {
324
+ const _ = this;
325
+
326
+ // Find parent dialog-panel
327
+ _.#panel = _.closest('dialog-panel');
328
+
329
+ if (!_.#panel) {
330
+ console.warn(
331
+ 'DialogBackdrop: Must be inside a <dialog-panel> element'
332
+ );
333
+ return;
334
+ }
335
+
336
+ // Handle click to close
337
+ _.#handlers.click = () => {
338
+ _.#panel.hide();
339
+ };
340
+ _.addEventListener('click', _.#handlers.click);
282
341
  }
283
- }
284
342
 
285
- /**
286
- * Custom element that wraps the content of the dialog
287
- * @extends HTMLElement
288
- */
289
- class DialogContent extends HTMLElement {
290
- constructor() {
291
- super();
292
- this.setAttribute('role', 'document'); // Optional: helps with document structure
343
+ disconnectedCallback() {
344
+ const _ = this;
345
+ if (_.#handlers.click) {
346
+ _.removeEventListener('click', _.#handlers.click);
347
+ }
293
348
  }
294
349
  }
295
350
 
351
+ // Register the custom elements
352
+ // Note: dialog-backdrop must be defined BEFORE dialog-panel,
353
+ // because dialog-panel's connectedCallback may create dialog-backdrop elements
354
+ if (!customElements.get('dialog-backdrop')) {
355
+ customElements.define('dialog-backdrop', DialogBackdrop);
356
+ }
296
357
  if (!customElements.get('dialog-panel')) {
297
358
  customElements.define('dialog-panel', DialogPanel);
298
359
  }
299
- if (!customElements.get('dialog-overlay')) {
300
- customElements.define('dialog-overlay', DialogOverlay);
301
- }
302
- if (!customElements.get('dialog-content')) {
303
- customElements.define('dialog-content', DialogContent);
304
- }
305
360
 
306
- export { DialogContent, DialogOverlay, DialogPanel, DialogPanel as default };
361
+ export { DialogBackdrop, DialogPanel };
307
362
  //# sourceMappingURL=dialog-panel.esm.js.map