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