@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,308 +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
- #scrollPosition = 0;
11
-
12
- /**
13
- * Clean up event listeners when component is removed from DOM
14
- */
15
- 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() {
16
41
  const _ = this;
17
- if (_.contentPanel) {
18
- _.contentPanel.removeEventListener(
19
- 'transitionend',
20
- _.#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>'
21
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);
22
57
  }
23
58
 
24
- // Ensure body scroll is restored if component is removed while open
25
- document.body.classList.remove('overflow-hidden');
26
- this.#restoreScroll();
59
+ // Set initial state attribute
60
+ _.#setState('hidden');
61
+
62
+ // Bind event handlers
63
+ _.#bindEvents();
27
64
  }
28
65
 
29
- /**
30
- * Saves current scroll position and locks body scrolling
31
- * @private
32
- */
33
- #lockScroll() {
66
+ disconnectedCallback() {
34
67
  const _ = this;
35
- // Save current scroll position
36
- _.#scrollPosition = window.pageYOffset;
37
68
 
38
- // Apply fixed position to body
39
- document.body.classList.add('overflow-hidden');
40
- 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
+ }
41
94
  }
42
95
 
43
96
  /**
44
- * Restores scroll position when dialog is closed
97
+ * Bind event listeners for close buttons, backdrop, and escape key
45
98
  * @private
46
99
  */
47
- #restoreScroll() {
100
+ #bindEvents() {
48
101
  const _ = this;
49
- // Remove fixed positioning
50
- document.body.classList.remove('overflow-hidden');
51
- document.body.style.removeProperty('top');
52
102
 
53
- // Restore scroll position
54
- window.scrollTo(0, _.#scrollPosition);
55
- }
56
- /**
57
- * Initializes the dialog panel, sets up focus trap and overlay
58
- */
59
- constructor() {
60
- super();
61
- const _ = this;
62
- _.id = _.getAttribute('id');
63
- _.setAttribute('role', 'dialog');
64
- _.setAttribute('aria-modal', 'true');
65
- _.setAttribute('aria-hidden', 'true');
66
-
67
- _.contentPanel = _.querySelector('dialog-content');
68
- _.focusTrap = document.createElement('focus-trap');
69
- _.triggerEl = null;
70
-
71
- // Create a handler for transition end events
72
- _.#handleTransitionEnd = (e) => {
73
- if (
74
- e.propertyName === 'opacity' &&
75
- _.getAttribute('aria-hidden') === 'true'
76
- ) {
77
- _.contentPanel.classList.add('hidden');
78
-
79
- // Dispatch afterHide event - dialog has completed its transition
80
- _.dispatchEvent(
81
- new CustomEvent('afterHide', {
82
- bubbles: true,
83
- detail: { triggerElement: _.triggerEl },
84
- })
85
- );
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);
86
109
  }
87
110
  };
88
-
89
- // Ensure we have labelledby and describedby references
90
- if (!_.getAttribute('aria-labelledby')) {
91
- const heading = _.querySelector('h1, h2, h3');
92
- if (heading && !heading.id) {
93
- 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();
94
125
  }
95
- if (heading?.id) {
96
- _.setAttribute('aria-labelledby', heading.id);
97
- }
98
- }
99
-
100
- _.contentPanel.parentNode.insertBefore(
101
- _.focusTrap,
102
- _.contentPanel
103
- );
104
- _.focusTrap.appendChild(_.contentPanel);
105
-
106
- _.focusTrap.setupTrap();
126
+ };
127
+ _.#dialog.addEventListener('click', _.#handlers.dialogClick);
107
128
 
108
- // Add modal overlay
109
- _.prepend(document.createElement('dialog-overlay'));
110
- _.#bindUI();
111
- _.#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);
112
136
  }
113
137
 
114
138
  /**
115
- * Binds click events for showing and hiding the dialog
116
- * @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
117
142
  */
118
- #bindUI() {
143
+ show(triggerEl = null) {
119
144
  const _ = this;
120
145
 
121
- // Handle trigger buttons
122
- document.addEventListener('click', (e) => {
123
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
124
- if (!trigger) return;
146
+ // Check if already showing/shown
147
+ if (_.#state === 'showing' || _.#state === 'shown') return true;
125
148
 
126
- if (trigger.getAttribute('data-prevent-default') === 'true') {
127
- e.preventDefault();
128
- }
149
+ // If currently hiding, ignore (let hide complete first)
150
+ if (_.#state === 'hiding') return false;
129
151
 
130
- _.show(trigger);
131
- });
152
+ // Store trigger element for focus return later
153
+ _.#triggerElement = triggerEl || null;
132
154
 
133
- // Handle close buttons
134
- _.addEventListener('click', (e) => {
135
- if (!e.target.closest('[data-action="hide-dialog"]')) return;
136
- _.hide();
137
- });
155
+ // Fire beforeShow (cancelable)
156
+ if (!_.#emit('beforeShow', { cancelable: true })) return false;
138
157
 
139
- // Add transition end listener
140
- _.contentPanel.addEventListener(
141
- 'transitionend',
142
- _.#handleTransitionEnd
143
- );
144
- }
158
+ // Set state to 'showing' and open native dialog
159
+ _.#setState('showing');
160
+ _.#dialog.showModal();
145
161
 
146
- /**
147
- * Binds keyboard events for accessibility
148
- * @private
149
- */
150
- #bindKeyboard() {
151
- this.addEventListener('keydown', (e) => {
152
- if (e.key === 'Escape') {
153
- this.hide();
154
- }
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
+ });
155
174
  });
175
+
176
+ return true;
156
177
  }
157
178
 
158
179
  /**
159
- * Shows the dialog and traps focus within it
160
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog
161
- * @fires DialogPanel#beforeShow - Fired before the dialog starts to show
162
- * @fires DialogPanel#show - Fired when the dialog has been shown
163
- * @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
164
183
  */
165
- show(triggerEl = null) {
184
+ hide(triggerEl = null) {
166
185
  const _ = this;
167
- _.triggerEl = triggerEl || false;
168
186
 
169
- // Dispatch beforeShow event - allows preventing the dialog from opening
170
- const beforeShowEvent = new CustomEvent('beforeShow', {
171
- bubbles: true,
172
- cancelable: true,
173
- detail: { triggerElement: _.triggerEl },
174
- });
187
+ // Check if already hiding/hidden
188
+ if (_.#state === 'hiding' || _.#state === 'hidden') return true;
175
189
 
176
- const showAllowed = _.dispatchEvent(beforeShowEvent);
190
+ // If currently showing, ignore (let show complete first)
191
+ if (_.#state === 'showing') return false;
177
192
 
178
- // If event was canceled (preventDefault was called), don't show the dialog
179
- if (!showAllowed) return false;
193
+ // Capture result from trigger element
194
+ _.#result = triggerEl?.dataset?.result ?? null;
180
195
 
181
- // Remove the hidden class first to ensure content is rendered
182
- _.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
+ }
183
206
 
184
- // Give the browser a moment to process before starting animation
185
- requestAnimationFrame(() => {
186
- // Update ARIA states
187
- _.setAttribute('aria-hidden', 'false');
188
- if (_.triggerEl) {
189
- _.triggerEl.setAttribute('aria-expanded', 'true');
190
- }
207
+ // Set state to 'hiding' - this triggers CSS exit animation
208
+ _.#setState('hiding');
191
209
 
192
- // Lock body scrolling and save scroll position
193
- _.#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
+ });
194
218
 
195
- // Focus management
196
- const firstFocusable = _.querySelector(
197
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
198
- );
199
- if (firstFocusable) {
200
- requestAnimationFrame(() => {
201
- firstFocusable.focus();
202
- });
203
- }
219
+ // Return focus to trigger element
220
+ if (_.#triggerElement) _.#triggerElement.focus();
204
221
 
205
- // Dispatch show event - dialog is now visible
206
- _.dispatchEvent(
207
- new CustomEvent('show', {
208
- bubbles: true,
209
- detail: { triggerElement: _.triggerEl },
210
- })
211
- );
222
+ // Clean up
223
+ _.#triggerElement = null;
224
+ _.#result = null;
212
225
  });
213
226
 
214
227
  return true;
215
228
  }
216
229
 
217
230
  /**
218
- * Hides the dialog and restores focus
219
- * @fires DialogPanel#beforeHide - Fired before the dialog starts to hide
220
- * @fires DialogPanel#hide - Fired when the dialog has started hiding (transition begins)
221
- * @fires DialogPanel#afterHide - Fired when the dialog has completed its hide transition
222
- * @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
223
234
  */
224
- hide() {
235
+ #waitForTransition(callback) {
225
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
+ };
226
249
 
227
- // Dispatch beforeHide event - allows preventing the dialog from closing
228
- const beforeHideEvent = new CustomEvent('beforeHide', {
229
- bubbles: true,
230
- cancelable: true,
231
- detail: { triggerElement: _.triggerEl },
232
- });
250
+ const onTransitionEnd = (e) => {
251
+ if (e.target === _.#dialog) done();
252
+ };
233
253
 
234
- const hideAllowed = _.dispatchEvent(beforeHideEvent);
254
+ _.#dialog.addEventListener('transitionend', onTransitionEnd);
235
255
 
236
- // If event was canceled (preventDefault was called), don't hide the dialog
237
- if (!hideAllowed) return false;
256
+ _.#pendingTimeout = setTimeout(
257
+ done,
258
+ DialogPanel.TRANSITION_FALLBACK_TIMEOUT
259
+ );
260
+ }
238
261
 
239
- // Restore body scroll and scroll position
240
- _.#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
+ }
241
271
 
242
- // Update ARIA states
243
- if (_.triggerEl) {
244
- // remove focus from modal panel first
245
- _.triggerEl.focus();
246
- // mark trigger as no longer expanded
247
- _.triggerEl.setAttribute('aria-expanded', 'false');
248
- }
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;
249
282
 
250
- // Set aria-hidden to start transition
251
- // The transitionend event handler will add display:none when complete
252
- _.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
+ });
253
294
 
254
- // Dispatch hide event - dialog is now starting to hide
255
- _.dispatchEvent(
256
- new CustomEvent('hide', {
257
- bubbles: true,
258
- detail: { triggerElement: _.triggerEl },
259
- })
260
- );
295
+ return _.dispatchEvent(event);
296
+ }
261
297
 
262
- 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;
263
310
  }
264
311
  }
265
312
 
266
313
  /**
267
- * 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
+ *
268
317
  * @extends HTMLElement
269
318
  */
270
- class DialogOverlay extends HTMLElement {
271
- constructor() {
272
- super();
273
- this.setAttribute('tabindex', '-1'); // Changed to -1 as it shouldn't be focusable
274
- this.setAttribute('aria-hidden', 'true');
275
- this.dialogPanel = this.closest('dialog-panel');
276
- this.#bindUI();
277
- }
319
+ class DialogBackdrop extends HTMLElement {
320
+ #panel = null;
321
+ #handlers = {
322
+ click: null,
323
+ };
278
324
 
279
- #bindUI() {
280
- this.addEventListener('click', () => {
281
- this.dialogPanel.hide();
282
- });
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);
283
343
  }
284
- }
285
344
 
286
- /**
287
- * Custom element that wraps the content of the dialog
288
- * @extends HTMLElement
289
- */
290
- class DialogContent extends HTMLElement {
291
- constructor() {
292
- super();
293
- this.setAttribute('role', 'document'); // Optional: helps with document structure
345
+ disconnectedCallback() {
346
+ const _ = this;
347
+ if (_.#handlers.click) {
348
+ _.removeEventListener('click', _.#handlers.click);
349
+ }
294
350
  }
295
351
  }
296
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
+ }
297
359
  if (!customElements.get('dialog-panel')) {
298
360
  customElements.define('dialog-panel', DialogPanel);
299
361
  }
300
- if (!customElements.get('dialog-overlay')) {
301
- customElements.define('dialog-overlay', DialogOverlay);
302
- }
303
- if (!customElements.get('dialog-content')) {
304
- customElements.define('dialog-content', DialogContent);
305
- }
306
362
 
307
- export { DialogPanel, DialogOverlay, DialogContent };
308
- export default DialogPanel;
363
+ export { DialogPanel, DialogBackdrop };
@@ -1,2 +0,0 @@
1
- @forward 'scss/variables';
2
- @forward 'scss/dialog-panel';
@@ -1,73 +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
- /* When explicitly hidden, remove from layout */
70
- &.hidden {
71
- display: none;
72
- }
73
- }