@magic-spells/dialog-panel 0.3.0 → 1.0.1

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,291 +1,363 @@
1
- import './index.scss';
2
- import '@magic-spells/focus-trap';
1
+ import './dialog-panel.css';
3
2
 
4
3
  /**
5
- * 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
+ *
6
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
7
18
  */
8
19
  class DialogPanel extends HTMLElement {
9
- #handleTransitionEnd;
10
-
11
- /**
12
- * Clean up event listeners when component is removed from DOM
13
- */
14
- 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 = 700;
39
+
40
+ connectedCallback() {
15
41
  const _ = this;
16
- if (_.contentPanel) {
17
- _.contentPanel.removeEventListener(
18
- 'transitionend',
19
- _.#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>'
20
49
  );
50
+ return;
21
51
  }
22
- }
23
- /**
24
- * Initializes the dialog panel, sets up focus trap and overlay
25
- */
26
- constructor() {
27
- super();
28
- const _ = this;
29
- _.id = _.getAttribute('id');
30
- _.setAttribute('role', 'dialog');
31
- _.setAttribute('aria-modal', 'true');
32
- _.setAttribute('aria-hidden', 'true');
33
-
34
- _.contentPanel = _.querySelector('dialog-content');
35
- _.triggerEl = null;
36
-
37
- // Create a handler for transition end events
38
- _.#handleTransitionEnd = (e) => {
39
- if (
40
- e.propertyName === 'opacity' &&
41
- _.getAttribute('aria-hidden') === 'true'
42
- ) {
43
- _.contentPanel.classList.add('hidden');
44
-
45
- // Dispatch afterHide event - dialog has completed its transition
46
- _.dispatchEvent(
47
- new CustomEvent('afterHide', {
48
- bubbles: true,
49
- detail: { triggerElement: _.triggerEl },
50
- })
51
- );
52
- }
53
- };
54
52
 
55
- // Set up focus-trap inside dialog-content
56
- // Check if focus-trap already exists
57
- _.focusTrap = _.contentPanel.querySelector('focus-trap');
58
- if (!_.focusTrap) {
59
- _.focusTrap = document.createElement('focus-trap');
53
+ // Auto-create dialog-backdrop if not present
54
+ if (!_.querySelector('dialog-backdrop')) {
55
+ const backdrop = document.createElement('dialog-backdrop');
56
+ _.insertBefore(backdrop, _.firstChild);
57
+ }
60
58
 
61
- // Move all existing dialog-content children into focus-trap
62
- const existingContent = Array.from(_.contentPanel.childNodes);
63
- existingContent.forEach((child) =>
64
- _.focusTrap.appendChild(child)
65
- );
59
+ // Set initial state attribute
60
+ _.#setState('hidden');
61
+
62
+ // Bind event handlers
63
+ _.#bindEvents();
64
+ }
66
65
 
67
- // Insert focus-trap inside dialog-content
68
- _.contentPanel.appendChild(_.focusTrap);
66
+ disconnectedCallback() {
67
+ const _ = this;
68
+
69
+ // Cancel pending animations
70
+ if (_.#pendingRAF) {
71
+ cancelAnimationFrame(_.#pendingRAF);
72
+ _.#pendingRAF = null;
73
+ }
74
+ if (_.#pendingTimeout) {
75
+ clearTimeout(_.#pendingTimeout);
76
+ _.#pendingTimeout = null;
69
77
  }
70
78
 
71
- // Ensure we have labelledby and describedby references
72
- if (!_.getAttribute('aria-labelledby')) {
73
- const heading = _.querySelector('h1, h2, h3');
74
- if (heading && !heading.id) {
75
- heading.id = `${_.id}-title`;
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
+ );
76
89
  }
77
- if (heading?.id) {
78
- _.setAttribute('aria-labelledby', heading.id);
90
+ if (_.#handlers.cancel) {
91
+ _.#dialog.removeEventListener('cancel', _.#handlers.cancel);
79
92
  }
80
93
  }
81
-
82
- // Add modal overlay
83
- _.prepend(document.createElement('dialog-overlay'));
84
- _.#bindUI();
85
- _.#bindKeyboard();
86
94
  }
87
95
 
88
96
  /**
89
- * Binds click events for showing and hiding the dialog
97
+ * Bind event listeners for close buttons, backdrop, and escape key
90
98
  * @private
91
99
  */
92
- #bindUI() {
100
+ #bindEvents() {
93
101
  const _ = this;
94
102
 
95
- // Handle trigger buttons
96
- document.addEventListener('click', (e) => {
97
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
98
- if (!trigger) return;
99
-
100
- if (trigger.getAttribute('data-prevent-default') === 'true') {
101
- e.preventDefault();
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);
102
109
  }
110
+ };
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();
125
+ }
126
+ };
127
+ _.#dialog.addEventListener('click', _.#handlers.dialogClick);
103
128
 
104
- _.show(trigger);
105
- });
106
-
107
- // Handle close buttons
108
- _.addEventListener('click', (e) => {
109
- if (!e.target.closest('[data-action-hide-dialog]')) return;
129
+ // Handle escape key - intercept native cancel and animate close
130
+ _.#handlers.cancel = (e) => {
131
+ e.preventDefault();
132
+ e.stopPropagation();
110
133
  _.hide();
111
- });
112
-
113
- // Add transition end listener
114
- _.contentPanel.addEventListener(
115
- 'transitionend',
116
- _.#handleTransitionEnd
117
- );
118
- }
119
-
120
- /**
121
- * Binds keyboard events for accessibility
122
- * @private
123
- */
124
- #bindKeyboard() {
125
- this.addEventListener('keydown', (e) => {
126
- if (e.key === 'Escape') {
127
- this.hide();
128
- }
129
- });
134
+ };
135
+ _.#dialog.addEventListener('cancel', _.#handlers.cancel);
130
136
  }
131
137
 
132
138
  /**
133
- * Shows the dialog and traps focus within it
134
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog
135
- * @fires DialogPanel#beforeShow - Fired before the dialog starts to show
136
- * @fires DialogPanel#show - Fired when the dialog has been shown
137
- * @returns {boolean} False if the show was prevented by a beforeShow event handler
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
138
142
  */
139
143
  show(triggerEl = null) {
140
144
  const _ = this;
141
- _.triggerEl = triggerEl || false;
142
145
 
143
- // Dispatch beforeShow event - allows preventing the dialog from opening
144
- const beforeShowEvent = new CustomEvent('beforeShow', {
145
- bubbles: true,
146
- cancelable: true,
147
- detail: { triggerElement: _.triggerEl },
148
- });
146
+ // Check if already showing/shown
147
+ if (_.#state === 'showing' || _.#state === 'shown') return true;
149
148
 
150
- const showAllowed = _.dispatchEvent(beforeShowEvent);
149
+ // If currently hiding, ignore (let hide complete first)
150
+ if (_.#state === 'hiding') return false;
151
151
 
152
- // If event was canceled (preventDefault was called), don't show the dialog
153
- if (!showAllowed) return false;
152
+ // Store trigger element for focus return later
153
+ _.#triggerElement = triggerEl || null;
154
154
 
155
- // Add open attribute for CSS :has() selector
156
- _.setAttribute('open', '');
155
+ // Fire beforeShow (cancelable)
156
+ if (!_.#emit('beforeShow', { cancelable: true })) return false;
157
157
 
158
- // Remove the hidden class first to ensure content is rendered
159
- _.contentPanel.classList.remove('hidden');
158
+ // Set state to 'showing' and open native dialog
159
+ _.#setState('showing');
160
+ _.#dialog.showModal();
160
161
 
161
- // Give the browser a moment to process before starting animation
162
- requestAnimationFrame(() => {
163
- // Update ARIA states
164
- _.setAttribute('aria-hidden', 'false');
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');
165
168
 
166
- // set trigger element to expanded: true
167
- if (_.triggerEl) {
168
- _.triggerEl.setAttribute('aria-expanded', 'true');
169
- }
170
-
171
- // Focus management
172
- const firstFocusable = _.querySelector(
173
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
174
- );
175
- if (firstFocusable) {
176
- requestAnimationFrame(() => {
177
- firstFocusable.focus();
169
+ // Wait for transition to complete before firing 'shown' event
170
+ _.#waitForTransition(() => {
171
+ _.#emit('shown');
178
172
  });
179
- }
180
-
181
- // Dispatch show event - dialog is now visible
182
- _.dispatchEvent(
183
- new CustomEvent('show', {
184
- bubbles: true,
185
- detail: { triggerElement: _.triggerEl },
186
- })
187
- );
173
+ });
188
174
  });
175
+
176
+ return true;
189
177
  }
190
178
 
191
179
  /**
192
- * Hides the dialog and restores focus
193
- * @fires DialogPanel#beforeHide - Fired before the dialog starts to hide
194
- * @fires DialogPanel#hide - Fired when the dialog has started hiding (transition begins)
195
- * @fires DialogPanel#afterHide - Fired when the dialog has completed its hide transition
196
- * @returns {boolean} False if the hide was prevented by a beforeHide 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
197
183
  */
198
- hide() {
184
+ hide(triggerEl = null) {
199
185
  const _ = this;
200
186
 
201
- // Dispatch beforeHide event - allows preventing the dialog from closing
202
- const beforeHideEvent = new CustomEvent('beforeHide', {
203
- bubbles: true,
204
- cancelable: true,
205
- detail: { triggerElement: _.triggerEl },
206
- });
187
+ // Check if already hiding/hidden
188
+ if (_.#state === 'hiding' || _.#state === 'hidden') return true;
207
189
 
208
- const hideAllowed = _.dispatchEvent(beforeHideEvent);
190
+ // If currently showing, ignore (let show complete first)
191
+ if (_.#state === 'showing') return false;
209
192
 
210
- // If event was canceled (preventDefault was called), don't hide the dialog
211
- if (!hideAllowed) return false;
193
+ // Capture result from trigger element
194
+ _.#result = triggerEl?.dataset?.result ?? null;
212
195
 
213
- // ensure focus is moved out of the dialog
214
- const activeElement = document.activeElement;
215
- if (activeElement && _.contains(activeElement)) {
216
- // Blur the currently focused element if it's inside the dialog
217
- activeElement.blur();
196
+ // Fire beforeHide (cancelable)
197
+ if (
198
+ !_.#emit('beforeHide', {
199
+ cancelable: true,
200
+ result: _.#result,
201
+ triggerElement: triggerEl,
202
+ })
203
+ ) {
204
+ return false;
218
205
  }
219
206
 
220
- // Update ARIA states and restore focus
221
- if (_.triggerEl) {
222
- // remove focus from modal panel first
223
- _.triggerEl.focus();
224
- // mark trigger as no longer expanded
225
- _.triggerEl.setAttribute('aria-expanded', 'false');
226
- } else {
227
- // Move focus to body to ensure it's not trapped
228
- document.body.focus();
229
- }
207
+ // Set state to 'hiding' - this triggers CSS exit animation
208
+ _.#setState('hiding');
230
209
 
231
- // Set aria-hidden to start transition
232
- // The transitionend event handler will add display:none when complete
233
- _.setAttribute('aria-hidden', 'true');
234
- // Remove open attribute for CSS :has() selector
210
+ // Wait for transition to complete
211
+ _.#waitForTransition(() => {
212
+ _.#dialog.close();
213
+ _.#setState('hidden');
214
+ _.#emit('hidden', {
215
+ result: _.#result,
216
+ triggerElement: triggerEl,
217
+ });
235
218
 
236
- _.removeAttribute('open');
219
+ // Return focus to trigger element
220
+ if (_.#triggerElement) _.#triggerElement.focus();
237
221
 
238
- // Dispatch hide event - dialog is now starting to hide
239
- _.dispatchEvent(
240
- new CustomEvent('hide', {
241
- bubbles: true,
242
- detail: { triggerElement: _.triggerEl },
243
- })
244
- );
222
+ // Clean up
223
+ _.#triggerElement = null;
224
+ _.#result = null;
225
+ });
245
226
 
246
227
  return true;
247
228
  }
248
- }
249
229
 
250
- /**
251
- * Custom element that creates a clickable overlay for the dialog
252
- * @extends HTMLElement
253
- */
254
- class DialogOverlay extends HTMLElement {
255
- constructor() {
256
- super();
257
- this.setAttribute('tabindex', '-1');
258
- this.dialogPanel = this.closest('dialog-panel');
259
- this.#bindUI();
230
+ /**
231
+ * Wait for CSS transition to complete with fallback timeout
232
+ * @param {Function} callback - Called when transition completes
233
+ * @private
234
+ */
235
+ #waitForTransition(callback) {
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
+ };
249
+
250
+ const onTransitionEnd = (e) => {
251
+ if (e.target === _.#dialog) done();
252
+ };
253
+
254
+ _.#dialog.addEventListener('transitionend', onTransitionEnd);
255
+
256
+ _.#pendingTimeout = setTimeout(
257
+ done,
258
+ DialogPanel.TRANSITION_FALLBACK_TIMEOUT
259
+ );
260
260
  }
261
261
 
262
- #bindUI() {
263
- this.addEventListener('click', () => {
264
- this.dialogPanel.hide();
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
+ }
271
+
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;
282
+
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
+ },
265
293
  });
294
+
295
+ return _.dispatchEvent(event);
296
+ }
297
+
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 wraps the content of 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 DialogContent extends HTMLElement {
274
- constructor() {
275
- super();
276
- this.setAttribute('role', 'document');
319
+ class DialogBackdrop extends HTMLElement {
320
+ #panel = null;
321
+ #handlers = {
322
+ click: null,
323
+ };
324
+
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);
343
+ }
344
+
345
+ disconnectedCallback() {
346
+ const _ = this;
347
+ if (_.#handlers.click) {
348
+ _.removeEventListener('click', _.#handlers.click);
349
+ }
277
350
  }
278
351
  }
279
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
+ }
280
359
  if (!customElements.get('dialog-panel')) {
281
360
  customElements.define('dialog-panel', DialogPanel);
282
361
  }
283
- if (!customElements.get('dialog-overlay')) {
284
- customElements.define('dialog-overlay', DialogOverlay);
285
- }
286
- if (!customElements.get('dialog-content')) {
287
- customElements.define('dialog-content', DialogContent);
288
- }
289
362
 
290
- export { DialogPanel, DialogOverlay, DialogContent };
291
- export default DialogPanel;
363
+ export { DialogPanel, DialogBackdrop };
@@ -1,2 +0,0 @@
1
- @forward 'scss/variables';
2
- @forward 'scss/dialog-panel';
@@ -1,78 +0,0 @@
1
- // Import variables using the modern @use rule
2
- @use 'variables' as vars;
3
-
4
- dialog-panel {
5
- /* Make it take no space and be invisible in the document flow */
6
- display: contents;
7
-
8
- &[aria-hidden='false'] {
9
- dialog-overlay,
10
- dialog-content {
11
- pointer-events: auto;
12
- opacity: 1;
13
- transform: scale(1);
14
- filter: blur(0px);
15
- }
16
- }
17
- }
18
-
19
- /* Overlay background */
20
- dialog-overlay {
21
- position: fixed;
22
- top: 0;
23
- left: 0;
24
- width: 100vw;
25
- height: 100vh;
26
- opacity: 0;
27
- pointer-events: none;
28
- z-index: var(--dp-overlay-z-index, 1000);
29
- transition: var(--dp-overlay-transition, all 300ms ease-out);
30
- background-color: var(
31
- --dp-overlay-background,
32
- rgba(20, 23, 26, 0.4)
33
- );
34
- backdrop-filter: var(
35
- --dp-overlay-backdrop-filter,
36
- blur(2px) saturate(120%)
37
- );
38
- }
39
-
40
- dialog-content {
41
- position: fixed;
42
- top: 50%;
43
- left: 50%;
44
- transform: translate(-50%, -50%) scale(0.95);
45
- max-width: 90vw;
46
- max-height: 85vh;
47
- display: var(--dp-content-display, block);
48
- opacity: 0;
49
- background: var(--dp-content-background, white);
50
- pointer-events: none;
51
- z-index: var(--dp-content-z-index, 1001);
52
- box-shadow: var(
53
- --dp-content-shadow,
54
- 0 10px 25px rgba(0, 0, 0, 0.15)
55
- );
56
- border-radius: var(--dp-content-border-radius, 8px);
57
- overflow: auto;
58
- transition:
59
- opacity var(--dp-transition-duration, 300ms)
60
- var(--dp-transition-timing, ease-out),
61
- transform var(--dp-transition-duration, 300ms)
62
- var(--dp-transition-timing, ease-out);
63
-
64
- /* When shown, reset transform to center */
65
- dialog-panel[aria-hidden='false'] & {
66
- transform: translate(-50%, -50%) scale(1);
67
- }
68
- }
69
-
70
- /* When explicitly hidden, remove from layout */
71
- dialog-content.hidden {
72
- display: none;
73
- }
74
-
75
- // Body scroll lock when dialog is open using :has() selector
76
- body:has(dialog-panel[open]) {
77
- overflow: hidden;
78
- }