@magic-spells/dialog-panel 0.1.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/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/dialog-panel.cjs.js +371 -0
- package/dist/dialog-panel.cjs.js.map +1 -0
- package/dist/dialog-panel.esm.js +364 -0
- package/dist/dialog-panel.esm.js.map +1 -0
- package/dist/dialog-panel.min.css +1 -0
- package/dist/dialog-panel.min.js +1 -0
- package/package.json +75 -0
- package/src/dialog-panel.css +47 -0
- package/src/dialog-panel.js +174 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Magic Spells (Cory Schulz)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Dialog Panel Web Component
|
|
2
|
+
|
|
3
|
+
A lightweight, customizable Web Component for creating accessible modal dialogs. Ideal for dialogs, alerts, cart panels, or content panels with smooth animations and accessibility features.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- No dependencies
|
|
8
|
+
- Lightweight
|
|
9
|
+
- Follows accessibility best practices with proper ARIA attributes
|
|
10
|
+
- Smooth animations
|
|
11
|
+
- Focus management and keyboard navigation
|
|
12
|
+
- WAI-ARIA compliant modal dialog behavior
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @magic-spells/dialog-panel
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
// Add to your JavaScript file
|
|
22
|
+
import '@magic-spells/dialog-panel';
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or include directly in your HTML:
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<script src="https://unpkg.com/@magic-spells/dialog-panel"></script>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```html
|
|
34
|
+
<button
|
|
35
|
+
id="show-dialog-button"
|
|
36
|
+
aria-haspopup="dialog"
|
|
37
|
+
aria-controls="demo-dialog"
|
|
38
|
+
aria-expanded="false">
|
|
39
|
+
Open Dialog
|
|
40
|
+
</button>
|
|
41
|
+
|
|
42
|
+
<dialog-panel
|
|
43
|
+
id="demo-dialog"
|
|
44
|
+
role="dialog"
|
|
45
|
+
aria-modal="true"
|
|
46
|
+
aria-labelledby="dialog-title"
|
|
47
|
+
aria-describedby="dialog-description"
|
|
48
|
+
aria-hidden="true">
|
|
49
|
+
<dialog-overlay></dialog-overlay>
|
|
50
|
+
<dialog-content>
|
|
51
|
+
<button aria-label="Close dialog" data-action="hide-dialog">
|
|
52
|
+
×
|
|
53
|
+
</button>
|
|
54
|
+
<div>
|
|
55
|
+
<h2 id="dialog-title">Dialog Title</h2>
|
|
56
|
+
<p id="dialog-description">
|
|
57
|
+
This is a demonstration of the dialog panel component. Add
|
|
58
|
+
your content here.
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
</dialog-content>
|
|
62
|
+
</dialog-panel>
|
|
63
|
+
|
|
64
|
+
<script>
|
|
65
|
+
const button = document.getElementById('show-dialog-button');
|
|
66
|
+
const dialog = document.getElementById('demo-dialog');
|
|
67
|
+
button.addEventListener('click', () => dialog.show());
|
|
68
|
+
</script>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## How It Works
|
|
72
|
+
|
|
73
|
+
- The dialog is initially hidden (`aria-hidden="true"`)
|
|
74
|
+
- Clicking the button triggers the `show()` method, making the dialog visible
|
|
75
|
+
- When opened, sets `aria-modal="true"` to indicate modal behavior
|
|
76
|
+
- Clicking the overlay or a close button (`data-action="hide-dialog"`) triggers the `hide()` method
|
|
77
|
+
- Keyboard focus is automatically trapped within the dialog when open
|
|
78
|
+
- Pressing ESC closes the dialog
|
|
79
|
+
- Focus returns to the trigger button when closed
|
|
80
|
+
|
|
81
|
+
## Customization
|
|
82
|
+
|
|
83
|
+
### Styling
|
|
84
|
+
|
|
85
|
+
You can style the Dialog Panel by overriding or extending the provided CSS:
|
|
86
|
+
|
|
87
|
+
```css
|
|
88
|
+
dialog-panel {
|
|
89
|
+
/* Customize your dialog panel */
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
dialog-overlay {
|
|
93
|
+
background-color: rgba(0, 0, 0, 0.5);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
[data-action='hide-dialog'] {
|
|
97
|
+
font-size: 24px;
|
|
98
|
+
color: #333;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### JavaScript API
|
|
103
|
+
|
|
104
|
+
#### Methods
|
|
105
|
+
|
|
106
|
+
- `show()`: Opens the dialog panel
|
|
107
|
+
- `hide()`: Closes the dialog panel
|
|
108
|
+
|
|
109
|
+
## Browser Support
|
|
110
|
+
|
|
111
|
+
This component works in all modern browsers that support Web Components.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Retrieves all focusable elements within a given container.
|
|
7
|
+
*
|
|
8
|
+
* @param {HTMLElement} container - The container element to search for focusable elements.
|
|
9
|
+
* @returns {HTMLElement[]} An array of focusable elements found within the container.
|
|
10
|
+
*/
|
|
11
|
+
const getFocusableElements = (container) => {
|
|
12
|
+
const focusableSelectors =
|
|
13
|
+
'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';
|
|
14
|
+
return Array.from(container.querySelectorAll(focusableSelectors));
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
class FocusTrap extends HTMLElement {
|
|
18
|
+
/** @type {boolean} Indicates whether the styles have been injected into the DOM. */
|
|
19
|
+
static styleInjected = false;
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
super();
|
|
23
|
+
this.trapStart = null;
|
|
24
|
+
this.trapEnd = null;
|
|
25
|
+
|
|
26
|
+
// Inject styles only once, when the first FocusTrap instance is created.
|
|
27
|
+
if (!FocusTrap.styleInjected) {
|
|
28
|
+
this.injectStyles();
|
|
29
|
+
FocusTrap.styleInjected = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Injects necessary styles for the focus trap into the document's head.
|
|
35
|
+
* This ensures that focus-trap-start and focus-trap-end elements are hidden.
|
|
36
|
+
*/
|
|
37
|
+
injectStyles() {
|
|
38
|
+
const style = document.createElement('style');
|
|
39
|
+
style.textContent = `
|
|
40
|
+
focus-trap-start,
|
|
41
|
+
focus-trap-end {
|
|
42
|
+
position: absolute;
|
|
43
|
+
width: 1px;
|
|
44
|
+
height: 1px;
|
|
45
|
+
margin: -1px;
|
|
46
|
+
padding: 0;
|
|
47
|
+
border: 0;
|
|
48
|
+
clip: rect(0, 0, 0, 0);
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
white-space: nowrap;
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
document.head.appendChild(style);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Called when the element is connected to the DOM.
|
|
58
|
+
* Sets up the focus trap and adds the keydown event listener.
|
|
59
|
+
*/
|
|
60
|
+
connectedCallback() {
|
|
61
|
+
this.setupTrap();
|
|
62
|
+
this.addEventListener('keydown', this.handleKeyDown);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Called when the element is disconnected from the DOM.
|
|
67
|
+
* Removes the keydown event listener.
|
|
68
|
+
*/
|
|
69
|
+
disconnectedCallback() {
|
|
70
|
+
this.removeEventListener('keydown', this.handleKeyDown);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sets up the focus trap by adding trap start and trap end elements.
|
|
75
|
+
* Focuses the trap start element to initiate the focus trap.
|
|
76
|
+
*/
|
|
77
|
+
setupTrap() {
|
|
78
|
+
// check to see it there are any focusable children
|
|
79
|
+
const focusableElements = getFocusableElements(this);
|
|
80
|
+
// exit if there aren't any
|
|
81
|
+
if (focusableElements.length === 0) return;
|
|
82
|
+
|
|
83
|
+
// create trap start and end elements
|
|
84
|
+
this.trapStart = document.createElement('focus-trap-start');
|
|
85
|
+
this.trapEnd = document.createElement('focus-trap-end');
|
|
86
|
+
|
|
87
|
+
// add to DOM
|
|
88
|
+
this.prepend(this.trapStart);
|
|
89
|
+
this.append(this.trapEnd);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
|
|
94
|
+
*
|
|
95
|
+
* @param {KeyboardEvent} e - The keyboard event object.
|
|
96
|
+
*/
|
|
97
|
+
handleKeyDown = (e) => {
|
|
98
|
+
if (e.key === 'Escape') {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
this.exitTrap();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Exits the focus trap by hiding the current container and shifting focus
|
|
106
|
+
* back to the trigger element that opened the trap.
|
|
107
|
+
*/
|
|
108
|
+
exitTrap() {
|
|
109
|
+
const container = this.closest('[aria-hidden="false"]');
|
|
110
|
+
if (!container) return;
|
|
111
|
+
|
|
112
|
+
container.setAttribute('aria-hidden', 'true');
|
|
113
|
+
|
|
114
|
+
const trigger = document.querySelector(
|
|
115
|
+
`[aria-expanded="true"][aria-controls="${container.id}"]`
|
|
116
|
+
);
|
|
117
|
+
if (trigger) {
|
|
118
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
119
|
+
trigger.focus();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
class FocusTrapStart extends HTMLElement {
|
|
125
|
+
/**
|
|
126
|
+
* Called when the element is connected to the DOM.
|
|
127
|
+
* Sets the tabindex and adds the focus event listener.
|
|
128
|
+
*/
|
|
129
|
+
connectedCallback() {
|
|
130
|
+
this.setAttribute('tabindex', '0');
|
|
131
|
+
this.addEventListener('focus', this.handleFocus);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Called when the element is disconnected from the DOM.
|
|
136
|
+
* Removes the focus event listener.
|
|
137
|
+
*/
|
|
138
|
+
disconnectedCallback() {
|
|
139
|
+
this.removeEventListener('focus', this.handleFocus);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Handles the focus event. If focus moves backwards from the first focusable element,
|
|
144
|
+
* it is cycled to the last focusable element, and vice versa.
|
|
145
|
+
*
|
|
146
|
+
* @param {FocusEvent} e - The focus event object.
|
|
147
|
+
*/
|
|
148
|
+
handleFocus = (e) => {
|
|
149
|
+
const trap = this.closest('focus-trap');
|
|
150
|
+
const focusableElements = getFocusableElements(trap);
|
|
151
|
+
|
|
152
|
+
if (focusableElements.length === 0) return;
|
|
153
|
+
|
|
154
|
+
const firstElement = focusableElements[0];
|
|
155
|
+
const lastElement =
|
|
156
|
+
focusableElements[focusableElements.length - 1];
|
|
157
|
+
|
|
158
|
+
if (e.relatedTarget === firstElement) {
|
|
159
|
+
lastElement.focus();
|
|
160
|
+
} else {
|
|
161
|
+
firstElement.focus();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
class FocusTrapEnd extends HTMLElement {
|
|
167
|
+
/**
|
|
168
|
+
* Called when the element is connected to the DOM.
|
|
169
|
+
* Sets the tabindex and adds the focus event listener.
|
|
170
|
+
*/
|
|
171
|
+
connectedCallback() {
|
|
172
|
+
this.setAttribute('tabindex', '0');
|
|
173
|
+
this.addEventListener('focus', this.handleFocus);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Called when the element is disconnected from the DOM.
|
|
178
|
+
* Removes the focus event listener.
|
|
179
|
+
*/
|
|
180
|
+
disconnectedCallback() {
|
|
181
|
+
this.removeEventListener('focus', this.handleFocus);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
|
|
186
|
+
*/
|
|
187
|
+
handleFocus = () => {
|
|
188
|
+
const trap = this.closest('focus-trap');
|
|
189
|
+
const trapStart = trap.querySelector('focus-trap-start');
|
|
190
|
+
trapStart.focus();
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
customElements.define('focus-trap', FocusTrap);
|
|
195
|
+
customElements.define('focus-trap-start', FocusTrapStart);
|
|
196
|
+
customElements.define('focus-trap-end', FocusTrapEnd);
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Custom element that creates an accessible modal dialog panel with focus management
|
|
200
|
+
* @extends HTMLElement
|
|
201
|
+
*/
|
|
202
|
+
class DialogPanel extends HTMLElement {
|
|
203
|
+
/**
|
|
204
|
+
* Initializes the dialog panel, sets up focus trap and overlay
|
|
205
|
+
*/
|
|
206
|
+
constructor() {
|
|
207
|
+
super();
|
|
208
|
+
const _ = this;
|
|
209
|
+
_.id = _.getAttribute('id');
|
|
210
|
+
_.setAttribute('role', 'dialog');
|
|
211
|
+
_.setAttribute('aria-modal', 'true');
|
|
212
|
+
_.setAttribute('aria-hidden', 'true');
|
|
213
|
+
|
|
214
|
+
_.contentPanel = _.querySelector('dialog-content');
|
|
215
|
+
_.focusTrap = document.createElement('focus-trap');
|
|
216
|
+
_.triggerEl = null;
|
|
217
|
+
|
|
218
|
+
// Ensure we have labelledby and describedby references
|
|
219
|
+
if (!_.getAttribute('aria-labelledby')) {
|
|
220
|
+
const heading = _.querySelector('h1, h2, h3');
|
|
221
|
+
if (heading && !heading.id) {
|
|
222
|
+
heading.id = `${_.id}-title`;
|
|
223
|
+
}
|
|
224
|
+
if (heading?.id) {
|
|
225
|
+
_.setAttribute('aria-labelledby', heading.id);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_.contentPanel.parentNode.insertBefore(
|
|
230
|
+
_.focusTrap,
|
|
231
|
+
_.contentPanel
|
|
232
|
+
);
|
|
233
|
+
_.focusTrap.appendChild(_.contentPanel);
|
|
234
|
+
|
|
235
|
+
_.focusTrap.setupTrap();
|
|
236
|
+
|
|
237
|
+
// Add modal overlay
|
|
238
|
+
_.prepend(document.createElement('dialog-overlay'));
|
|
239
|
+
_.#bindUI();
|
|
240
|
+
_.#bindKeyboard();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Binds click events for showing and hiding the dialog
|
|
245
|
+
* @private
|
|
246
|
+
*/
|
|
247
|
+
#bindUI() {
|
|
248
|
+
// Handle trigger buttons
|
|
249
|
+
document.addEventListener('click', (e) => {
|
|
250
|
+
const trigger = e.target.closest(
|
|
251
|
+
`[aria-controls="${this.id}"]`
|
|
252
|
+
);
|
|
253
|
+
if (!trigger) return;
|
|
254
|
+
|
|
255
|
+
if (trigger.getAttribute('data-prevent-default') === 'true') {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// this.triggerEl = trigger;
|
|
260
|
+
this.show(trigger);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Handle close buttons
|
|
264
|
+
this.addEventListener('click', (e) => {
|
|
265
|
+
if (!e.target.closest('[data-action="hide-dialog"]')) return;
|
|
266
|
+
this.hide();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Binds keyboard events for accessibility
|
|
272
|
+
* @private
|
|
273
|
+
*/
|
|
274
|
+
#bindKeyboard() {
|
|
275
|
+
this.addEventListener('keydown', (e) => {
|
|
276
|
+
if (e.key === 'Escape') {
|
|
277
|
+
this.hide();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Shows the dialog and traps focus within it
|
|
284
|
+
* @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog
|
|
285
|
+
*/
|
|
286
|
+
show(triggerEl = null) {
|
|
287
|
+
this.triggerEl = triggerEl || false;
|
|
288
|
+
|
|
289
|
+
// Update ARIA states
|
|
290
|
+
this.setAttribute('aria-hidden', 'false');
|
|
291
|
+
if (this.triggerEl) {
|
|
292
|
+
this.triggerEl.setAttribute('aria-expanded', 'true');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// prevent body from scrolling
|
|
296
|
+
document.body.classList.add('overflow-hidden');
|
|
297
|
+
|
|
298
|
+
// Focus management
|
|
299
|
+
const firstFocusable = this.querySelector(
|
|
300
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
301
|
+
);
|
|
302
|
+
if (firstFocusable) {
|
|
303
|
+
requestAnimationFrame(() => {
|
|
304
|
+
firstFocusable.focus();
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Hides the dialog and restores focus
|
|
311
|
+
*/
|
|
312
|
+
hide() {
|
|
313
|
+
// allow body to scroll
|
|
314
|
+
document.body.classList.remove('overflow-hidden');
|
|
315
|
+
|
|
316
|
+
// Update ARIA states
|
|
317
|
+
if (this.triggerEl) {
|
|
318
|
+
this.triggerEl.setAttribute('aria-expanded', 'false');
|
|
319
|
+
// Restore focus to trigger element
|
|
320
|
+
this.triggerEl.focus();
|
|
321
|
+
} else {
|
|
322
|
+
console.log('we need to blur focus');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// hide dialog panel
|
|
326
|
+
setTimeout(() => {
|
|
327
|
+
this.setAttribute('aria-hidden', 'true');
|
|
328
|
+
}, 1);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Custom element that creates a clickable overlay for the dialog
|
|
334
|
+
* @extends HTMLElement
|
|
335
|
+
*/
|
|
336
|
+
class DialogOverlay extends HTMLElement {
|
|
337
|
+
constructor() {
|
|
338
|
+
super();
|
|
339
|
+
this.setAttribute('tabindex', '-1'); // Changed to -1 as it shouldn't be focusable
|
|
340
|
+
this.setAttribute('aria-hidden', 'true');
|
|
341
|
+
this.dialogPanel = this.closest('dialog-panel');
|
|
342
|
+
this.#bindUI();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#bindUI() {
|
|
346
|
+
this.addEventListener('click', () => {
|
|
347
|
+
this.dialogPanel.hide();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Custom element that wraps the content of the dialog
|
|
354
|
+
* @extends HTMLElement
|
|
355
|
+
*/
|
|
356
|
+
class DialogContent extends HTMLElement {
|
|
357
|
+
constructor() {
|
|
358
|
+
super();
|
|
359
|
+
this.setAttribute('role', 'document'); // Optional: helps with document structure
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
customElements.define('dialog-panel', DialogPanel);
|
|
364
|
+
customElements.define('dialog-overlay', DialogOverlay);
|
|
365
|
+
customElements.define('dialog-content', DialogContent);
|
|
366
|
+
|
|
367
|
+
exports.DialogContent = DialogContent;
|
|
368
|
+
exports.DialogOverlay = DialogOverlay;
|
|
369
|
+
exports.DialogPanel = DialogPanel;
|
|
370
|
+
exports.default = DialogPanel;
|
|
371
|
+
//# sourceMappingURL=dialog-panel.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dialog-panel.cjs.js","sources":["../node_modules/@magic-spells/focus-trap/dist/focus-trap.esm.js","../src/dialog-panel.js"],"sourcesContent":["/**\n * Retrieves all focusable elements within a given container.\n *\n * @param {HTMLElement} container - The container element to search for focusable elements.\n * @returns {HTMLElement[]} An array of focusable elements found within the container.\n */\nconst getFocusableElements = (container) => {\n\tconst focusableSelectors =\n\t\t'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^=\"-\"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';\n\treturn Array.from(container.querySelectorAll(focusableSelectors));\n};\n\nclass FocusTrap extends HTMLElement {\n\t/** @type {boolean} Indicates whether the styles have been injected into the DOM. */\n\tstatic styleInjected = false;\n\n\tconstructor() {\n\t\tsuper();\n\t\tthis.trapStart = null;\n\t\tthis.trapEnd = null;\n\n\t\t// Inject styles only once, when the first FocusTrap instance is created.\n\t\tif (!FocusTrap.styleInjected) {\n\t\t\tthis.injectStyles();\n\t\t\tFocusTrap.styleInjected = true;\n\t\t}\n\t}\n\n\t/**\n\t * Injects necessary styles for the focus trap into the document's head.\n\t * This ensures that focus-trap-start and focus-trap-end elements are hidden.\n\t */\n\tinjectStyles() {\n\t\tconst style = document.createElement('style');\n\t\tstyle.textContent = `\n focus-trap-start,\n focus-trap-end {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n border: 0;\n clip: rect(0, 0, 0, 0);\n overflow: hidden;\n white-space: nowrap;\n }\n `;\n\t\tdocument.head.appendChild(style);\n\t}\n\n\t/**\n\t * Called when the element is connected to the DOM.\n\t * Sets up the focus trap and adds the keydown event listener.\n\t */\n\tconnectedCallback() {\n\t\tthis.setupTrap();\n\t\tthis.addEventListener('keydown', this.handleKeyDown);\n\t}\n\n\t/**\n\t * Called when the element is disconnected from the DOM.\n\t * Removes the keydown event listener.\n\t */\n\tdisconnectedCallback() {\n\t\tthis.removeEventListener('keydown', this.handleKeyDown);\n\t}\n\n\t/**\n\t * Sets up the focus trap by adding trap start and trap end elements.\n\t * Focuses the trap start element to initiate the focus trap.\n\t */\n\tsetupTrap() {\n\t\t// check to see it there are any focusable children\n\t\tconst focusableElements = getFocusableElements(this);\n\t\t// exit if there aren't any\n\t\tif (focusableElements.length === 0) return;\n\n\t\t// create trap start and end elements\n\t\tthis.trapStart = document.createElement('focus-trap-start');\n\t\tthis.trapEnd = document.createElement('focus-trap-end');\n\n\t\t// add to DOM\n\t\tthis.prepend(this.trapStart);\n\t\tthis.append(this.trapEnd);\n\t}\n\n\t/**\n\t * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.\n\t *\n\t * @param {KeyboardEvent} e - The keyboard event object.\n\t */\n\thandleKeyDown = (e) => {\n\t\tif (e.key === 'Escape') {\n\t\t\te.preventDefault();\n\t\t\tthis.exitTrap();\n\t\t}\n\t};\n\n\t/**\n\t * Exits the focus trap by hiding the current container and shifting focus\n\t * back to the trigger element that opened the trap.\n\t */\n\texitTrap() {\n\t\tconst container = this.closest('[aria-hidden=\"false\"]');\n\t\tif (!container) return;\n\n\t\tcontainer.setAttribute('aria-hidden', 'true');\n\n\t\tconst trigger = document.querySelector(\n\t\t\t`[aria-expanded=\"true\"][aria-controls=\"${container.id}\"]`\n\t\t);\n\t\tif (trigger) {\n\t\t\ttrigger.setAttribute('aria-expanded', 'false');\n\t\t\ttrigger.focus();\n\t\t}\n\t}\n}\n\nclass FocusTrapStart extends HTMLElement {\n\t/**\n\t * Called when the element is connected to the DOM.\n\t * Sets the tabindex and adds the focus event listener.\n\t */\n\tconnectedCallback() {\n\t\tthis.setAttribute('tabindex', '0');\n\t\tthis.addEventListener('focus', this.handleFocus);\n\t}\n\n\t/**\n\t * Called when the element is disconnected from the DOM.\n\t * Removes the focus event listener.\n\t */\n\tdisconnectedCallback() {\n\t\tthis.removeEventListener('focus', this.handleFocus);\n\t}\n\n\t/**\n\t * Handles the focus event. If focus moves backwards from the first focusable element,\n\t * it is cycled to the last focusable element, and vice versa.\n\t *\n\t * @param {FocusEvent} e - The focus event object.\n\t */\n\thandleFocus = (e) => {\n\t\tconst trap = this.closest('focus-trap');\n\t\tconst focusableElements = getFocusableElements(trap);\n\n\t\tif (focusableElements.length === 0) return;\n\n\t\tconst firstElement = focusableElements[0];\n\t\tconst lastElement =\n\t\t\tfocusableElements[focusableElements.length - 1];\n\n\t\tif (e.relatedTarget === firstElement) {\n\t\t\tlastElement.focus();\n\t\t} else {\n\t\t\tfirstElement.focus();\n\t\t}\n\t};\n}\n\nclass FocusTrapEnd extends HTMLElement {\n\t/**\n\t * Called when the element is connected to the DOM.\n\t * Sets the tabindex and adds the focus event listener.\n\t */\n\tconnectedCallback() {\n\t\tthis.setAttribute('tabindex', '0');\n\t\tthis.addEventListener('focus', this.handleFocus);\n\t}\n\n\t/**\n\t * Called when the element is disconnected from the DOM.\n\t * Removes the focus event listener.\n\t */\n\tdisconnectedCallback() {\n\t\tthis.removeEventListener('focus', this.handleFocus);\n\t}\n\n\t/**\n\t * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.\n\t */\n\thandleFocus = () => {\n\t\tconst trap = this.closest('focus-trap');\n\t\tconst trapStart = trap.querySelector('focus-trap-start');\n\t\ttrapStart.focus();\n\t};\n}\n\ncustomElements.define('focus-trap', FocusTrap);\ncustomElements.define('focus-trap-start', FocusTrapStart);\ncustomElements.define('focus-trap-end', FocusTrapEnd);\n//# sourceMappingURL=focus-trap.esm.js.map\n","import './dialog-panel.css';\nimport '@magic-spells/focus-trap';\n\n/**\n * Custom element that creates an accessible modal dialog panel with focus management\n * @extends HTMLElement\n */\nclass DialogPanel extends HTMLElement {\n\t/**\n\t * Initializes the dialog panel, sets up focus trap and overlay\n\t */\n\tconstructor() {\n\t\tsuper();\n\t\tconst _ = this;\n\t\t_.id = _.getAttribute('id');\n\t\t_.setAttribute('role', 'dialog');\n\t\t_.setAttribute('aria-modal', 'true');\n\t\t_.setAttribute('aria-hidden', 'true');\n\n\t\t_.contentPanel = _.querySelector('dialog-content');\n\t\t_.focusTrap = document.createElement('focus-trap');\n\t\t_.triggerEl = null;\n\n\t\t// Ensure we have labelledby and describedby references\n\t\tif (!_.getAttribute('aria-labelledby')) {\n\t\t\tconst heading = _.querySelector('h1, h2, h3');\n\t\t\tif (heading && !heading.id) {\n\t\t\t\theading.id = `${_.id}-title`;\n\t\t\t}\n\t\t\tif (heading?.id) {\n\t\t\t\t_.setAttribute('aria-labelledby', heading.id);\n\t\t\t}\n\t\t}\n\n\t\t_.contentPanel.parentNode.insertBefore(\n\t\t\t_.focusTrap,\n\t\t\t_.contentPanel\n\t\t);\n\t\t_.focusTrap.appendChild(_.contentPanel);\n\n\t\t_.focusTrap.setupTrap();\n\n\t\t// Add modal overlay\n\t\t_.prepend(document.createElement('dialog-overlay'));\n\t\t_.#bindUI();\n\t\t_.#bindKeyboard();\n\t}\n\n\t/**\n\t * Binds click events for showing and hiding the dialog\n\t * @private\n\t */\n\t#bindUI() {\n\t\t// Handle trigger buttons\n\t\tdocument.addEventListener('click', (e) => {\n\t\t\tconst trigger = e.target.closest(\n\t\t\t\t`[aria-controls=\"${this.id}\"]`\n\t\t\t);\n\t\t\tif (!trigger) return;\n\n\t\t\tif (trigger.getAttribute('data-prevent-default') === 'true') {\n\t\t\t\te.preventDefault();\n\t\t\t}\n\n\t\t\t// this.triggerEl = trigger;\n\t\t\tthis.show(trigger);\n\t\t});\n\n\t\t// Handle close buttons\n\t\tthis.addEventListener('click', (e) => {\n\t\t\tif (!e.target.closest('[data-action=\"hide-dialog\"]')) return;\n\t\t\tthis.hide();\n\t\t});\n\t}\n\n\t/**\n\t * Binds keyboard events for accessibility\n\t * @private\n\t */\n\t#bindKeyboard() {\n\t\tthis.addEventListener('keydown', (e) => {\n\t\t\tif (e.key === 'Escape') {\n\t\t\t\tthis.hide();\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Shows the dialog and traps focus within it\n\t * @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog\n\t */\n\tshow(triggerEl = null) {\n\t\tthis.triggerEl = triggerEl || false;\n\n\t\t// Update ARIA states\n\t\tthis.setAttribute('aria-hidden', 'false');\n\t\tif (this.triggerEl) {\n\t\t\tthis.triggerEl.setAttribute('aria-expanded', 'true');\n\t\t}\n\n\t\t// prevent body from scrolling\n\t\tdocument.body.classList.add('overflow-hidden');\n\n\t\t// Focus management\n\t\tconst firstFocusable = this.querySelector(\n\t\t\t'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n\t\t);\n\t\tif (firstFocusable) {\n\t\t\trequestAnimationFrame(() => {\n\t\t\t\tfirstFocusable.focus();\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Hides the dialog and restores focus\n\t */\n\thide() {\n\t\t// allow body to scroll\n\t\tdocument.body.classList.remove('overflow-hidden');\n\n\t\t// Update ARIA states\n\t\tif (this.triggerEl) {\n\t\t\tthis.triggerEl.setAttribute('aria-expanded', 'false');\n\t\t\t// Restore focus to trigger element\n\t\t\tthis.triggerEl.focus();\n\t\t} else {\n\t\t\tconsole.log('we need to blur focus');\n\t\t}\n\n\t\t// hide dialog panel\n\t\tsetTimeout(() => {\n\t\t\tthis.setAttribute('aria-hidden', 'true');\n\t\t}, 1);\n\t}\n}\n\n/**\n * Custom element that creates a clickable overlay for the dialog\n * @extends HTMLElement\n */\nclass DialogOverlay extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tthis.setAttribute('tabindex', '-1'); // Changed to -1 as it shouldn't be focusable\n\t\tthis.setAttribute('aria-hidden', 'true');\n\t\tthis.dialogPanel = this.closest('dialog-panel');\n\t\tthis.#bindUI();\n\t}\n\n\t#bindUI() {\n\t\tthis.addEventListener('click', () => {\n\t\t\tthis.dialogPanel.hide();\n\t\t});\n\t}\n}\n\n/**\n * Custom element that wraps the content of the dialog\n * @extends HTMLElement\n */\nclass DialogContent extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tthis.setAttribute('role', 'document'); // Optional: helps with document structure\n\t}\n}\n\ncustomElements.define('dialog-panel', DialogPanel);\ncustomElements.define('dialog-overlay', DialogOverlay);\ncustomElements.define('dialog-content', DialogContent);\n\nexport { DialogPanel, DialogOverlay, DialogContent };\nexport default DialogPanel;\n"],"names":[],"mappings":"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","x_google_ignoreList":[0]}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieves all focusable elements within a given container.
|
|
3
|
+
*
|
|
4
|
+
* @param {HTMLElement} container - The container element to search for focusable elements.
|
|
5
|
+
* @returns {HTMLElement[]} An array of focusable elements found within the container.
|
|
6
|
+
*/
|
|
7
|
+
const getFocusableElements = (container) => {
|
|
8
|
+
const focusableSelectors =
|
|
9
|
+
'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';
|
|
10
|
+
return Array.from(container.querySelectorAll(focusableSelectors));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class FocusTrap extends HTMLElement {
|
|
14
|
+
/** @type {boolean} Indicates whether the styles have been injected into the DOM. */
|
|
15
|
+
static styleInjected = false;
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
super();
|
|
19
|
+
this.trapStart = null;
|
|
20
|
+
this.trapEnd = null;
|
|
21
|
+
|
|
22
|
+
// Inject styles only once, when the first FocusTrap instance is created.
|
|
23
|
+
if (!FocusTrap.styleInjected) {
|
|
24
|
+
this.injectStyles();
|
|
25
|
+
FocusTrap.styleInjected = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Injects necessary styles for the focus trap into the document's head.
|
|
31
|
+
* This ensures that focus-trap-start and focus-trap-end elements are hidden.
|
|
32
|
+
*/
|
|
33
|
+
injectStyles() {
|
|
34
|
+
const style = document.createElement('style');
|
|
35
|
+
style.textContent = `
|
|
36
|
+
focus-trap-start,
|
|
37
|
+
focus-trap-end {
|
|
38
|
+
position: absolute;
|
|
39
|
+
width: 1px;
|
|
40
|
+
height: 1px;
|
|
41
|
+
margin: -1px;
|
|
42
|
+
padding: 0;
|
|
43
|
+
border: 0;
|
|
44
|
+
clip: rect(0, 0, 0, 0);
|
|
45
|
+
overflow: hidden;
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
document.head.appendChild(style);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Called when the element is connected to the DOM.
|
|
54
|
+
* Sets up the focus trap and adds the keydown event listener.
|
|
55
|
+
*/
|
|
56
|
+
connectedCallback() {
|
|
57
|
+
this.setupTrap();
|
|
58
|
+
this.addEventListener('keydown', this.handleKeyDown);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Called when the element is disconnected from the DOM.
|
|
63
|
+
* Removes the keydown event listener.
|
|
64
|
+
*/
|
|
65
|
+
disconnectedCallback() {
|
|
66
|
+
this.removeEventListener('keydown', this.handleKeyDown);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sets up the focus trap by adding trap start and trap end elements.
|
|
71
|
+
* Focuses the trap start element to initiate the focus trap.
|
|
72
|
+
*/
|
|
73
|
+
setupTrap() {
|
|
74
|
+
// check to see it there are any focusable children
|
|
75
|
+
const focusableElements = getFocusableElements(this);
|
|
76
|
+
// exit if there aren't any
|
|
77
|
+
if (focusableElements.length === 0) return;
|
|
78
|
+
|
|
79
|
+
// create trap start and end elements
|
|
80
|
+
this.trapStart = document.createElement('focus-trap-start');
|
|
81
|
+
this.trapEnd = document.createElement('focus-trap-end');
|
|
82
|
+
|
|
83
|
+
// add to DOM
|
|
84
|
+
this.prepend(this.trapStart);
|
|
85
|
+
this.append(this.trapEnd);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
|
|
90
|
+
*
|
|
91
|
+
* @param {KeyboardEvent} e - The keyboard event object.
|
|
92
|
+
*/
|
|
93
|
+
handleKeyDown = (e) => {
|
|
94
|
+
if (e.key === 'Escape') {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
this.exitTrap();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Exits the focus trap by hiding the current container and shifting focus
|
|
102
|
+
* back to the trigger element that opened the trap.
|
|
103
|
+
*/
|
|
104
|
+
exitTrap() {
|
|
105
|
+
const container = this.closest('[aria-hidden="false"]');
|
|
106
|
+
if (!container) return;
|
|
107
|
+
|
|
108
|
+
container.setAttribute('aria-hidden', 'true');
|
|
109
|
+
|
|
110
|
+
const trigger = document.querySelector(
|
|
111
|
+
`[aria-expanded="true"][aria-controls="${container.id}"]`
|
|
112
|
+
);
|
|
113
|
+
if (trigger) {
|
|
114
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
115
|
+
trigger.focus();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class FocusTrapStart extends HTMLElement {
|
|
121
|
+
/**
|
|
122
|
+
* Called when the element is connected to the DOM.
|
|
123
|
+
* Sets the tabindex and adds the focus event listener.
|
|
124
|
+
*/
|
|
125
|
+
connectedCallback() {
|
|
126
|
+
this.setAttribute('tabindex', '0');
|
|
127
|
+
this.addEventListener('focus', this.handleFocus);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Called when the element is disconnected from the DOM.
|
|
132
|
+
* Removes the focus event listener.
|
|
133
|
+
*/
|
|
134
|
+
disconnectedCallback() {
|
|
135
|
+
this.removeEventListener('focus', this.handleFocus);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Handles the focus event. If focus moves backwards from the first focusable element,
|
|
140
|
+
* it is cycled to the last focusable element, and vice versa.
|
|
141
|
+
*
|
|
142
|
+
* @param {FocusEvent} e - The focus event object.
|
|
143
|
+
*/
|
|
144
|
+
handleFocus = (e) => {
|
|
145
|
+
const trap = this.closest('focus-trap');
|
|
146
|
+
const focusableElements = getFocusableElements(trap);
|
|
147
|
+
|
|
148
|
+
if (focusableElements.length === 0) return;
|
|
149
|
+
|
|
150
|
+
const firstElement = focusableElements[0];
|
|
151
|
+
const lastElement =
|
|
152
|
+
focusableElements[focusableElements.length - 1];
|
|
153
|
+
|
|
154
|
+
if (e.relatedTarget === firstElement) {
|
|
155
|
+
lastElement.focus();
|
|
156
|
+
} else {
|
|
157
|
+
firstElement.focus();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
class FocusTrapEnd extends HTMLElement {
|
|
163
|
+
/**
|
|
164
|
+
* Called when the element is connected to the DOM.
|
|
165
|
+
* Sets the tabindex and adds the focus event listener.
|
|
166
|
+
*/
|
|
167
|
+
connectedCallback() {
|
|
168
|
+
this.setAttribute('tabindex', '0');
|
|
169
|
+
this.addEventListener('focus', this.handleFocus);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Called when the element is disconnected from the DOM.
|
|
174
|
+
* Removes the focus event listener.
|
|
175
|
+
*/
|
|
176
|
+
disconnectedCallback() {
|
|
177
|
+
this.removeEventListener('focus', this.handleFocus);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
|
|
182
|
+
*/
|
|
183
|
+
handleFocus = () => {
|
|
184
|
+
const trap = this.closest('focus-trap');
|
|
185
|
+
const trapStart = trap.querySelector('focus-trap-start');
|
|
186
|
+
trapStart.focus();
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
customElements.define('focus-trap', FocusTrap);
|
|
191
|
+
customElements.define('focus-trap-start', FocusTrapStart);
|
|
192
|
+
customElements.define('focus-trap-end', FocusTrapEnd);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Custom element that creates an accessible modal dialog panel with focus management
|
|
196
|
+
* @extends HTMLElement
|
|
197
|
+
*/
|
|
198
|
+
class DialogPanel extends HTMLElement {
|
|
199
|
+
/**
|
|
200
|
+
* Initializes the dialog panel, sets up focus trap and overlay
|
|
201
|
+
*/
|
|
202
|
+
constructor() {
|
|
203
|
+
super();
|
|
204
|
+
const _ = this;
|
|
205
|
+
_.id = _.getAttribute('id');
|
|
206
|
+
_.setAttribute('role', 'dialog');
|
|
207
|
+
_.setAttribute('aria-modal', 'true');
|
|
208
|
+
_.setAttribute('aria-hidden', 'true');
|
|
209
|
+
|
|
210
|
+
_.contentPanel = _.querySelector('dialog-content');
|
|
211
|
+
_.focusTrap = document.createElement('focus-trap');
|
|
212
|
+
_.triggerEl = null;
|
|
213
|
+
|
|
214
|
+
// Ensure we have labelledby and describedby references
|
|
215
|
+
if (!_.getAttribute('aria-labelledby')) {
|
|
216
|
+
const heading = _.querySelector('h1, h2, h3');
|
|
217
|
+
if (heading && !heading.id) {
|
|
218
|
+
heading.id = `${_.id}-title`;
|
|
219
|
+
}
|
|
220
|
+
if (heading?.id) {
|
|
221
|
+
_.setAttribute('aria-labelledby', heading.id);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_.contentPanel.parentNode.insertBefore(
|
|
226
|
+
_.focusTrap,
|
|
227
|
+
_.contentPanel
|
|
228
|
+
);
|
|
229
|
+
_.focusTrap.appendChild(_.contentPanel);
|
|
230
|
+
|
|
231
|
+
_.focusTrap.setupTrap();
|
|
232
|
+
|
|
233
|
+
// Add modal overlay
|
|
234
|
+
_.prepend(document.createElement('dialog-overlay'));
|
|
235
|
+
_.#bindUI();
|
|
236
|
+
_.#bindKeyboard();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Binds click events for showing and hiding the dialog
|
|
241
|
+
* @private
|
|
242
|
+
*/
|
|
243
|
+
#bindUI() {
|
|
244
|
+
// Handle trigger buttons
|
|
245
|
+
document.addEventListener('click', (e) => {
|
|
246
|
+
const trigger = e.target.closest(
|
|
247
|
+
`[aria-controls="${this.id}"]`
|
|
248
|
+
);
|
|
249
|
+
if (!trigger) return;
|
|
250
|
+
|
|
251
|
+
if (trigger.getAttribute('data-prevent-default') === 'true') {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// this.triggerEl = trigger;
|
|
256
|
+
this.show(trigger);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Handle close buttons
|
|
260
|
+
this.addEventListener('click', (e) => {
|
|
261
|
+
if (!e.target.closest('[data-action="hide-dialog"]')) return;
|
|
262
|
+
this.hide();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Binds keyboard events for accessibility
|
|
268
|
+
* @private
|
|
269
|
+
*/
|
|
270
|
+
#bindKeyboard() {
|
|
271
|
+
this.addEventListener('keydown', (e) => {
|
|
272
|
+
if (e.key === 'Escape') {
|
|
273
|
+
this.hide();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Shows the dialog and traps focus within it
|
|
280
|
+
* @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog
|
|
281
|
+
*/
|
|
282
|
+
show(triggerEl = null) {
|
|
283
|
+
this.triggerEl = triggerEl || false;
|
|
284
|
+
|
|
285
|
+
// Update ARIA states
|
|
286
|
+
this.setAttribute('aria-hidden', 'false');
|
|
287
|
+
if (this.triggerEl) {
|
|
288
|
+
this.triggerEl.setAttribute('aria-expanded', 'true');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// prevent body from scrolling
|
|
292
|
+
document.body.classList.add('overflow-hidden');
|
|
293
|
+
|
|
294
|
+
// Focus management
|
|
295
|
+
const firstFocusable = this.querySelector(
|
|
296
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
297
|
+
);
|
|
298
|
+
if (firstFocusable) {
|
|
299
|
+
requestAnimationFrame(() => {
|
|
300
|
+
firstFocusable.focus();
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Hides the dialog and restores focus
|
|
307
|
+
*/
|
|
308
|
+
hide() {
|
|
309
|
+
// allow body to scroll
|
|
310
|
+
document.body.classList.remove('overflow-hidden');
|
|
311
|
+
|
|
312
|
+
// Update ARIA states
|
|
313
|
+
if (this.triggerEl) {
|
|
314
|
+
this.triggerEl.setAttribute('aria-expanded', 'false');
|
|
315
|
+
// Restore focus to trigger element
|
|
316
|
+
this.triggerEl.focus();
|
|
317
|
+
} else {
|
|
318
|
+
console.log('we need to blur focus');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// hide dialog panel
|
|
322
|
+
setTimeout(() => {
|
|
323
|
+
this.setAttribute('aria-hidden', 'true');
|
|
324
|
+
}, 1);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Custom element that creates a clickable overlay for the dialog
|
|
330
|
+
* @extends HTMLElement
|
|
331
|
+
*/
|
|
332
|
+
class DialogOverlay extends HTMLElement {
|
|
333
|
+
constructor() {
|
|
334
|
+
super();
|
|
335
|
+
this.setAttribute('tabindex', '-1'); // Changed to -1 as it shouldn't be focusable
|
|
336
|
+
this.setAttribute('aria-hidden', 'true');
|
|
337
|
+
this.dialogPanel = this.closest('dialog-panel');
|
|
338
|
+
this.#bindUI();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#bindUI() {
|
|
342
|
+
this.addEventListener('click', () => {
|
|
343
|
+
this.dialogPanel.hide();
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Custom element that wraps the content of the dialog
|
|
350
|
+
* @extends HTMLElement
|
|
351
|
+
*/
|
|
352
|
+
class DialogContent extends HTMLElement {
|
|
353
|
+
constructor() {
|
|
354
|
+
super();
|
|
355
|
+
this.setAttribute('role', 'document'); // Optional: helps with document structure
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
customElements.define('dialog-panel', DialogPanel);
|
|
360
|
+
customElements.define('dialog-overlay', DialogOverlay);
|
|
361
|
+
customElements.define('dialog-content', DialogContent);
|
|
362
|
+
|
|
363
|
+
export { DialogContent, DialogOverlay, DialogPanel, DialogPanel as default };
|
|
364
|
+
//# sourceMappingURL=dialog-panel.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dialog-panel.esm.js","sources":["../node_modules/@magic-spells/focus-trap/dist/focus-trap.esm.js","../src/dialog-panel.js"],"sourcesContent":["/**\n * Retrieves all focusable elements within a given container.\n *\n * @param {HTMLElement} container - The container element to search for focusable elements.\n * @returns {HTMLElement[]} An array of focusable elements found within the container.\n */\nconst getFocusableElements = (container) => {\n\tconst focusableSelectors =\n\t\t'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^=\"-\"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';\n\treturn Array.from(container.querySelectorAll(focusableSelectors));\n};\n\nclass FocusTrap extends HTMLElement {\n\t/** @type {boolean} Indicates whether the styles have been injected into the DOM. */\n\tstatic styleInjected = false;\n\n\tconstructor() {\n\t\tsuper();\n\t\tthis.trapStart = null;\n\t\tthis.trapEnd = null;\n\n\t\t// Inject styles only once, when the first FocusTrap instance is created.\n\t\tif (!FocusTrap.styleInjected) {\n\t\t\tthis.injectStyles();\n\t\t\tFocusTrap.styleInjected = true;\n\t\t}\n\t}\n\n\t/**\n\t * Injects necessary styles for the focus trap into the document's head.\n\t * This ensures that focus-trap-start and focus-trap-end elements are hidden.\n\t */\n\tinjectStyles() {\n\t\tconst style = document.createElement('style');\n\t\tstyle.textContent = `\n focus-trap-start,\n focus-trap-end {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n border: 0;\n clip: rect(0, 0, 0, 0);\n overflow: hidden;\n white-space: nowrap;\n }\n `;\n\t\tdocument.head.appendChild(style);\n\t}\n\n\t/**\n\t * Called when the element is connected to the DOM.\n\t * Sets up the focus trap and adds the keydown event listener.\n\t */\n\tconnectedCallback() {\n\t\tthis.setupTrap();\n\t\tthis.addEventListener('keydown', this.handleKeyDown);\n\t}\n\n\t/**\n\t * Called when the element is disconnected from the DOM.\n\t * Removes the keydown event listener.\n\t */\n\tdisconnectedCallback() {\n\t\tthis.removeEventListener('keydown', this.handleKeyDown);\n\t}\n\n\t/**\n\t * Sets up the focus trap by adding trap start and trap end elements.\n\t * Focuses the trap start element to initiate the focus trap.\n\t */\n\tsetupTrap() {\n\t\t// check to see it there are any focusable children\n\t\tconst focusableElements = getFocusableElements(this);\n\t\t// exit if there aren't any\n\t\tif (focusableElements.length === 0) return;\n\n\t\t// create trap start and end elements\n\t\tthis.trapStart = document.createElement('focus-trap-start');\n\t\tthis.trapEnd = document.createElement('focus-trap-end');\n\n\t\t// add to DOM\n\t\tthis.prepend(this.trapStart);\n\t\tthis.append(this.trapEnd);\n\t}\n\n\t/**\n\t * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.\n\t *\n\t * @param {KeyboardEvent} e - The keyboard event object.\n\t */\n\thandleKeyDown = (e) => {\n\t\tif (e.key === 'Escape') {\n\t\t\te.preventDefault();\n\t\t\tthis.exitTrap();\n\t\t}\n\t};\n\n\t/**\n\t * Exits the focus trap by hiding the current container and shifting focus\n\t * back to the trigger element that opened the trap.\n\t */\n\texitTrap() {\n\t\tconst container = this.closest('[aria-hidden=\"false\"]');\n\t\tif (!container) return;\n\n\t\tcontainer.setAttribute('aria-hidden', 'true');\n\n\t\tconst trigger = document.querySelector(\n\t\t\t`[aria-expanded=\"true\"][aria-controls=\"${container.id}\"]`\n\t\t);\n\t\tif (trigger) {\n\t\t\ttrigger.setAttribute('aria-expanded', 'false');\n\t\t\ttrigger.focus();\n\t\t}\n\t}\n}\n\nclass FocusTrapStart extends HTMLElement {\n\t/**\n\t * Called when the element is connected to the DOM.\n\t * Sets the tabindex and adds the focus event listener.\n\t */\n\tconnectedCallback() {\n\t\tthis.setAttribute('tabindex', '0');\n\t\tthis.addEventListener('focus', this.handleFocus);\n\t}\n\n\t/**\n\t * Called when the element is disconnected from the DOM.\n\t * Removes the focus event listener.\n\t */\n\tdisconnectedCallback() {\n\t\tthis.removeEventListener('focus', this.handleFocus);\n\t}\n\n\t/**\n\t * Handles the focus event. If focus moves backwards from the first focusable element,\n\t * it is cycled to the last focusable element, and vice versa.\n\t *\n\t * @param {FocusEvent} e - The focus event object.\n\t */\n\thandleFocus = (e) => {\n\t\tconst trap = this.closest('focus-trap');\n\t\tconst focusableElements = getFocusableElements(trap);\n\n\t\tif (focusableElements.length === 0) return;\n\n\t\tconst firstElement = focusableElements[0];\n\t\tconst lastElement =\n\t\t\tfocusableElements[focusableElements.length - 1];\n\n\t\tif (e.relatedTarget === firstElement) {\n\t\t\tlastElement.focus();\n\t\t} else {\n\t\t\tfirstElement.focus();\n\t\t}\n\t};\n}\n\nclass FocusTrapEnd extends HTMLElement {\n\t/**\n\t * Called when the element is connected to the DOM.\n\t * Sets the tabindex and adds the focus event listener.\n\t */\n\tconnectedCallback() {\n\t\tthis.setAttribute('tabindex', '0');\n\t\tthis.addEventListener('focus', this.handleFocus);\n\t}\n\n\t/**\n\t * Called when the element is disconnected from the DOM.\n\t * Removes the focus event listener.\n\t */\n\tdisconnectedCallback() {\n\t\tthis.removeEventListener('focus', this.handleFocus);\n\t}\n\n\t/**\n\t * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.\n\t */\n\thandleFocus = () => {\n\t\tconst trap = this.closest('focus-trap');\n\t\tconst trapStart = trap.querySelector('focus-trap-start');\n\t\ttrapStart.focus();\n\t};\n}\n\ncustomElements.define('focus-trap', FocusTrap);\ncustomElements.define('focus-trap-start', FocusTrapStart);\ncustomElements.define('focus-trap-end', FocusTrapEnd);\n//# sourceMappingURL=focus-trap.esm.js.map\n","import './dialog-panel.css';\nimport '@magic-spells/focus-trap';\n\n/**\n * Custom element that creates an accessible modal dialog panel with focus management\n * @extends HTMLElement\n */\nclass DialogPanel extends HTMLElement {\n\t/**\n\t * Initializes the dialog panel, sets up focus trap and overlay\n\t */\n\tconstructor() {\n\t\tsuper();\n\t\tconst _ = this;\n\t\t_.id = _.getAttribute('id');\n\t\t_.setAttribute('role', 'dialog');\n\t\t_.setAttribute('aria-modal', 'true');\n\t\t_.setAttribute('aria-hidden', 'true');\n\n\t\t_.contentPanel = _.querySelector('dialog-content');\n\t\t_.focusTrap = document.createElement('focus-trap');\n\t\t_.triggerEl = null;\n\n\t\t// Ensure we have labelledby and describedby references\n\t\tif (!_.getAttribute('aria-labelledby')) {\n\t\t\tconst heading = _.querySelector('h1, h2, h3');\n\t\t\tif (heading && !heading.id) {\n\t\t\t\theading.id = `${_.id}-title`;\n\t\t\t}\n\t\t\tif (heading?.id) {\n\t\t\t\t_.setAttribute('aria-labelledby', heading.id);\n\t\t\t}\n\t\t}\n\n\t\t_.contentPanel.parentNode.insertBefore(\n\t\t\t_.focusTrap,\n\t\t\t_.contentPanel\n\t\t);\n\t\t_.focusTrap.appendChild(_.contentPanel);\n\n\t\t_.focusTrap.setupTrap();\n\n\t\t// Add modal overlay\n\t\t_.prepend(document.createElement('dialog-overlay'));\n\t\t_.#bindUI();\n\t\t_.#bindKeyboard();\n\t}\n\n\t/**\n\t * Binds click events for showing and hiding the dialog\n\t * @private\n\t */\n\t#bindUI() {\n\t\t// Handle trigger buttons\n\t\tdocument.addEventListener('click', (e) => {\n\t\t\tconst trigger = e.target.closest(\n\t\t\t\t`[aria-controls=\"${this.id}\"]`\n\t\t\t);\n\t\t\tif (!trigger) return;\n\n\t\t\tif (trigger.getAttribute('data-prevent-default') === 'true') {\n\t\t\t\te.preventDefault();\n\t\t\t}\n\n\t\t\t// this.triggerEl = trigger;\n\t\t\tthis.show(trigger);\n\t\t});\n\n\t\t// Handle close buttons\n\t\tthis.addEventListener('click', (e) => {\n\t\t\tif (!e.target.closest('[data-action=\"hide-dialog\"]')) return;\n\t\t\tthis.hide();\n\t\t});\n\t}\n\n\t/**\n\t * Binds keyboard events for accessibility\n\t * @private\n\t */\n\t#bindKeyboard() {\n\t\tthis.addEventListener('keydown', (e) => {\n\t\t\tif (e.key === 'Escape') {\n\t\t\t\tthis.hide();\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Shows the dialog and traps focus within it\n\t * @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog\n\t */\n\tshow(triggerEl = null) {\n\t\tthis.triggerEl = triggerEl || false;\n\n\t\t// Update ARIA states\n\t\tthis.setAttribute('aria-hidden', 'false');\n\t\tif (this.triggerEl) {\n\t\t\tthis.triggerEl.setAttribute('aria-expanded', 'true');\n\t\t}\n\n\t\t// prevent body from scrolling\n\t\tdocument.body.classList.add('overflow-hidden');\n\n\t\t// Focus management\n\t\tconst firstFocusable = this.querySelector(\n\t\t\t'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n\t\t);\n\t\tif (firstFocusable) {\n\t\t\trequestAnimationFrame(() => {\n\t\t\t\tfirstFocusable.focus();\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Hides the dialog and restores focus\n\t */\n\thide() {\n\t\t// allow body to scroll\n\t\tdocument.body.classList.remove('overflow-hidden');\n\n\t\t// Update ARIA states\n\t\tif (this.triggerEl) {\n\t\t\tthis.triggerEl.setAttribute('aria-expanded', 'false');\n\t\t\t// Restore focus to trigger element\n\t\t\tthis.triggerEl.focus();\n\t\t} else {\n\t\t\tconsole.log('we need to blur focus');\n\t\t}\n\n\t\t// hide dialog panel\n\t\tsetTimeout(() => {\n\t\t\tthis.setAttribute('aria-hidden', 'true');\n\t\t}, 1);\n\t}\n}\n\n/**\n * Custom element that creates a clickable overlay for the dialog\n * @extends HTMLElement\n */\nclass DialogOverlay extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tthis.setAttribute('tabindex', '-1'); // Changed to -1 as it shouldn't be focusable\n\t\tthis.setAttribute('aria-hidden', 'true');\n\t\tthis.dialogPanel = this.closest('dialog-panel');\n\t\tthis.#bindUI();\n\t}\n\n\t#bindUI() {\n\t\tthis.addEventListener('click', () => {\n\t\t\tthis.dialogPanel.hide();\n\t\t});\n\t}\n}\n\n/**\n * Custom element that wraps the content of the dialog\n * @extends HTMLElement\n */\nclass DialogContent extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tthis.setAttribute('role', 'document'); // Optional: helps with document structure\n\t}\n}\n\ncustomElements.define('dialog-panel', DialogPanel);\ncustomElements.define('dialog-overlay', DialogOverlay);\ncustomElements.define('dialog-content', DialogContent);\n\nexport { DialogPanel, DialogOverlay, DialogContent };\nexport default DialogPanel;\n"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA,MAAM,oBAAoB,GAAG,CAAC,SAAS,KAAK;AAC5C,CAAC,MAAM,kBAAkB;AACzB,EAAE,gPAAgP,CAAC;AACnP,CAAC,OAAO,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,gBAAgB,CAAC,kBAAkB,CAAC,CAAC,CAAC;AACnE,CAAC,CAAC;AACF;AACA,MAAM,SAAS,SAAS,WAAW,CAAC;AACpC;AACA,CAAC,OAAO,aAAa,GAAG,KAAK,CAAC;AAC9B;AACA,CAAC,WAAW,GAAG;AACf,EAAE,KAAK,EAAE,CAAC;AACV,EAAE,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;AACxB,EAAE,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;AACtB;AACA;AACA,EAAE,IAAI,CAAC,SAAS,CAAC,aAAa,EAAE;AAChC,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;AACvB,GAAG,SAAS,CAAC,aAAa,GAAG,IAAI,CAAC;AAClC,GAAG;AACH,EAAE;AACF;AACA;AACA;AACA;AACA;AACA,CAAC,YAAY,GAAG;AAChB,EAAE,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;AAChD,EAAE,KAAK,CAAC,WAAW,GAAG,CAAC;AACvB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,IAAI,CAAC,CAAC;AACN,EAAE,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;AACnC,EAAE;AACF;AACA;AACA;AACA;AACA;AACA,CAAC,iBAAiB,GAAG;AACrB,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC;AACnB,EAAE,IAAI,CAAC,gBAAgB,CAAC,SAAS,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;AACvD,EAAE;AACF;AACA;AACA;AACA;AACA;AACA,CAAC,oBAAoB,GAAG;AACxB,EAAE,IAAI,CAAC,mBAAmB,CAAC,SAAS,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;AAC1D,EAAE;AACF;AACA;AACA;AACA;AACA;AACA,CAAC,SAAS,GAAG;AACb;AACA,EAAE,MAAM,iBAAiB,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC;AACvD;AACA,EAAE,IAAI,iBAAiB,CAAC,MAAM,KAAK,CAAC,EAAE,OAAO;AAC7C;AACA;AACA,EAAE,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC,aAAa,CAAC,kBAAkB,CAAC,CAAC;AAC9D,EAAE,IAAI,CAAC,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,gBAAgB,CAAC,CAAC;AAC1D;AACA;AACA,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;AAC/B,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AAC5B,EAAE;AACF;AACA;AACA;AACA;AACA;AACA;AACA,CAAC,aAAa,GAAG,CAAC,CAAC,KAAK;AACxB,EAAE,IAAI,CAAC,CAAC,GAAG,KAAK,QAAQ,EAAE;AAC1B,GAAG,CAAC,CAAC,cAAc,EAAE,CAAC;AACtB,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC;AACnB,GAAG;AACH,EAAE,CAAC;AACH;AACA;AACA;AACA;AACA;AACA,CAAC,QAAQ,GAAG;AACZ,EAAE,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,uBAAuB,CAAC,CAAC;AAC1D,EAAE,IAAI,CAAC,SAAS,EAAE,OAAO;AACzB;AACA,EAAE,SAAS,CAAC,YAAY,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;AAChD;AACA,EAAE,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa;AACxC,GAAG,CAAC,sCAAsC,EAAE,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC;AAC5D,GAAG,CAAC;AACJ,EAAE,IAAI,OAAO,EAAE;AACf,GAAG,OAAO,CAAC,YAAY,CAAC,eAAe,EAAE,OAAO,CAAC,CAAC;AAClD,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;AACnB,GAAG;AACH,EAAE;AACF,CAAC;AACD;AACA,MAAM,cAAc,SAAS,WAAW,CAAC;AACzC;AACA;AACA;AACA;AACA,CAAC,iBAAiB,GAAG;AACrB,EAAE,IAAI,CAAC,YAAY,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;AACrC,EAAE,IAAI,CAAC,gBAAgB,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;AACnD,EAAE;AACF;AACA;AACA;AACA;AACA;AACA,CAAC,oBAAoB,GAAG;AACxB,EAAE,IAAI,CAAC,mBAAmB,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;AACtD,EAAE;AACF;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC,WAAW,GAAG,CAAC,CAAC,KAAK;AACtB,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;AAC1C,EAAE,MAAM,iBAAiB,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC;AACvD;AACA,EAAE,IAAI,iBAAiB,CAAC,MAAM,KAAK,CAAC,EAAE,OAAO;AAC7C;AACA,EAAE,MAAM,YAAY,GAAG,iBAAiB,CAAC,CAAC,CAAC,CAAC;AAC5C,EAAE,MAAM,WAAW;AACnB,GAAG,iBAAiB,CAAC,iBAAiB,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACnD;AACA,EAAE,IAAI,CAAC,CAAC,aAAa,KAAK,YAAY,EAAE;AACxC,GAAG,WAAW,CAAC,KAAK,EAAE,CAAC;AACvB,GAAG,MAAM;AACT,GAAG,YAAY,CAAC,KAAK,EAAE,CAAC;AACxB,GAAG;AACH,EAAE,CAAC;AACH,CAAC;AACD;AACA,MAAM,YAAY,SAAS,WAAW,CAAC;AACvC;AACA;AACA;AACA;AACA,CAAC,iBAAiB,GAAG;AACrB,EAAE,IAAI,CAAC,YAAY,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;AACrC,EAAE,IAAI,CAAC,gBAAgB,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;AACnD,EAAE;AACF;AACA;AACA;AACA;AACA;AACA,CAAC,oBAAoB,GAAG;AACxB,EAAE,IAAI,CAAC,mBAAmB,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;AACtD,EAAE;AACF;AACA;AACA;AACA;AACA,CAAC,WAAW,GAAG,MAAM;AACrB,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;AAC1C,EAAE,MAAM,SAAS,GAAG,IAAI,CAAC,aAAa,CAAC,kBAAkB,CAAC,CAAC;AAC3D,EAAE,SAAS,CAAC,KAAK,EAAE,CAAC;AACpB,EAAE,CAAC;AACH,CAAC;AACD;AACA,cAAc,CAAC,MAAM,CAAC,YAAY,EAAE,SAAS,CAAC,CAAC;AAC/C,cAAc,CAAC,MAAM,CAAC,kBAAkB,EAAE,cAAc,CAAC,CAAC;AAC1D,cAAc,CAAC,MAAM,CAAC,gBAAgB,EAAE,YAAY,CAAC;;AC5LrD;AACA;AACA;AACA;AACA,MAAM,WAAW,SAAS,WAAW,CAAC;AACtC;AACA;AACA;AACA,CAAC,WAAW,GAAG;AACf,EAAE,KAAK,EAAE,CAAC;AACV,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;AACjB,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;AAC9B,EAAE,CAAC,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;AACnC,EAAE,CAAC,CAAC,YAAY,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;AACvC,EAAE,CAAC,CAAC,YAAY,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;AACxC;AACA,EAAE,CAAC,CAAC,YAAY,GAAG,CAAC,CAAC,aAAa,CAAC,gBAAgB,CAAC,CAAC;AACrD,EAAE,CAAC,CAAC,SAAS,GAAG,QAAQ,CAAC,aAAa,CAAC,YAAY,CAAC,CAAC;AACrD,EAAE,CAAC,CAAC,SAAS,GAAG,IAAI,CAAC;AACrB;AACA;AACA,EAAE,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,iBAAiB,CAAC,EAAE;AAC1C,GAAG,MAAM,OAAO,GAAG,CAAC,CAAC,aAAa,CAAC,YAAY,CAAC,CAAC;AACjD,GAAG,IAAI,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE;AAC/B,IAAI,OAAO,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC;AACjC,IAAI;AACJ,GAAG,IAAI,OAAO,EAAE,EAAE,EAAE;AACpB,IAAI,CAAC,CAAC,YAAY,CAAC,iBAAiB,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;AAClD,IAAI;AACJ,GAAG;AACH;AACA,EAAE,CAAC,CAAC,YAAY,CAAC,UAAU,CAAC,YAAY;AACxC,GAAG,CAAC,CAAC,SAAS;AACd,GAAG,CAAC,CAAC,YAAY;AACjB,GAAG,CAAC;AACJ,EAAE,CAAC,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;AAC1C;AACA,EAAE,CAAC,CAAC,SAAS,CAAC,SAAS,EAAE,CAAC;AAC1B;AACA;AACA,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,gBAAgB,CAAC,CAAC,CAAC;AACtD,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;AACd,EAAE,CAAC,CAAC,aAAa,EAAE,CAAC;AACpB,EAAE;AACF;AACA;AACA;AACA;AACA;AACA,CAAC,OAAO,GAAG;AACX;AACA,EAAE,QAAQ,CAAC,gBAAgB,CAAC,OAAO,EAAE,CAAC,CAAC,KAAK;AAC5C,GAAG,MAAM,OAAO,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO;AACnC,IAAI,CAAC,gBAAgB,EAAE,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;AAClC,IAAI,CAAC;AACL,GAAG,IAAI,CAAC,OAAO,EAAE,OAAO;AACxB;AACA,GAAG,IAAI,OAAO,CAAC,YAAY,CAAC,sBAAsB,CAAC,KAAK,MAAM,EAAE;AAChE,IAAI,CAAC,CAAC,cAAc,EAAE,CAAC;AACvB,IAAI;AACJ;AACA;AACA,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACtB,GAAG,CAAC,CAAC;AACL;AACA;AACA,EAAE,IAAI,CAAC,gBAAgB,CAAC,OAAO,EAAE,CAAC,CAAC,KAAK;AACxC,GAAG,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,6BAA6B,CAAC,EAAE,OAAO;AAChE,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;AACf,GAAG,CAAC,CAAC;AACL,EAAE;AACF;AACA;AACA;AACA;AACA;AACA,CAAC,aAAa,GAAG;AACjB,EAAE,IAAI,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,CAAC,KAAK;AAC1C,GAAG,IAAI,CAAC,CAAC,GAAG,KAAK,QAAQ,EAAE;AAC3B,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;AAChB,IAAI;AACJ,GAAG,CAAC,CAAC;AACL,EAAE;AACF;AACA;AACA;AACA;AACA;AACA,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,EAAE;AACxB,EAAE,IAAI,CAAC,SAAS,GAAG,SAAS,IAAI,KAAK,CAAC;AACtC;AACA;AACA,EAAE,IAAI,CAAC,YAAY,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC;AAC5C,EAAE,IAAI,IAAI,CAAC,SAAS,EAAE;AACtB,GAAG,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;AACxD,GAAG;AACH;AACA;AACA,EAAE,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;AACjD;AACA;AACA,EAAE,MAAM,cAAc,GAAG,IAAI,CAAC,aAAa;AAC3C,GAAG,0EAA0E;AAC7E,GAAG,CAAC;AACJ,EAAE,IAAI,cAAc,EAAE;AACtB,GAAG,qBAAqB,CAAC,MAAM;AAC/B,IAAI,cAAc,CAAC,KAAK,EAAE,CAAC;AAC3B,IAAI,CAAC,CAAC;AACN,GAAG;AACH,EAAE;AACF;AACA;AACA;AACA;AACA,CAAC,IAAI,GAAG;AACR;AACA,EAAE,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC;AACpD;AACA;AACA,EAAE,IAAI,IAAI,CAAC,SAAS,EAAE;AACtB,GAAG,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC,eAAe,EAAE,OAAO,CAAC,CAAC;AACzD;AACA,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;AAC1B,GAAG,MAAM;AACT,GAAG,OAAO,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;AACxC,GAAG;AACH;AACA;AACA,EAAE,UAAU,CAAC,MAAM;AACnB,GAAG,IAAI,CAAC,YAAY,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;AAC5C,GAAG,EAAE,CAAC,CAAC,CAAC;AACR,EAAE;AACF,CAAC;AACD;AACA;AACA;AACA;AACA;AACA,MAAM,aAAa,SAAS,WAAW,CAAC;AACxC,CAAC,WAAW,GAAG;AACf,EAAE,KAAK,EAAE,CAAC;AACV,EAAE,IAAI,CAAC,YAAY,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;AACtC,EAAE,IAAI,CAAC,YAAY,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;AAC3C,EAAE,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;AAClD,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;AACjB,EAAE;AACF;AACA,CAAC,OAAO,GAAG;AACX,EAAE,IAAI,CAAC,gBAAgB,CAAC,OAAO,EAAE,MAAM;AACvC,GAAG,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC;AAC3B,GAAG,CAAC,CAAC;AACL,EAAE;AACF,CAAC;AACD;AACA;AACA;AACA;AACA;AACA,MAAM,aAAa,SAAS,WAAW,CAAC;AACxC,CAAC,WAAW,GAAG;AACf,EAAE,KAAK,EAAE,CAAC;AACV,EAAE,IAAI,CAAC,YAAY,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;AACxC,EAAE;AACF,CAAC;AACD;AACA,cAAc,CAAC,MAAM,CAAC,cAAc,EAAE,WAAW,CAAC,CAAC;AACnD,cAAc,CAAC,MAAM,CAAC,gBAAgB,EAAE,aAAa,CAAC,CAAC;AACvD,cAAc,CAAC,MAAM,CAAC,gBAAgB,EAAE,aAAa,CAAC;;;;","x_google_ignoreList":[0]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dialog-panel{position:fixed;z-index:10}dialog-overlay,dialog-panel{height:100vh;left:0;pointer-events:none;top:0;width:100vw}dialog-overlay{backdrop-filter:blur(2px) saturate(120%);background-color:rgba(20,23,26,.4);opacity:0;position:absolute;transition:all .3s ease-out}dialog-content{background:#fff;display:block;opacity:0}dialog-panel[aria-hidden=false]{pointer-events:all}dialog-panel[aria-hidden=false] dialog-overlay{filter:blur(0);opacity:1;pointer-events:all;transform:scale(1)}dialog-panel[aria-hidden=false] dialog-content{filter:blur(0);opacity:1;transform:scale(1);z-index:10}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var DialogPanel=function(e){"use strict";const t=e=>Array.from(e.querySelectorAll('summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe'));class FocusTrap extends HTMLElement{static styleInjected=!1;constructor(){super(),this.trapStart=null,this.trapEnd=null,FocusTrap.styleInjected||(this.injectStyles(),FocusTrap.styleInjected=!0)}injectStyles(){const e=document.createElement("style");e.textContent="\n focus-trap-start,\n focus-trap-end {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n border: 0;\n clip: rect(0, 0, 0, 0);\n overflow: hidden;\n white-space: nowrap;\n }\n ",document.head.appendChild(e)}connectedCallback(){this.setupTrap(),this.addEventListener("keydown",this.handleKeyDown)}disconnectedCallback(){this.removeEventListener("keydown",this.handleKeyDown)}setupTrap(){0!==t(this).length&&(this.trapStart=document.createElement("focus-trap-start"),this.trapEnd=document.createElement("focus-trap-end"),this.prepend(this.trapStart),this.append(this.trapEnd))}handleKeyDown=e=>{"Escape"===e.key&&(e.preventDefault(),this.exitTrap())};exitTrap(){const e=this.closest('[aria-hidden="false"]');if(!e)return;e.setAttribute("aria-hidden","true");const t=document.querySelector(`[aria-expanded="true"][aria-controls="${e.id}"]`);t&&(t.setAttribute("aria-expanded","false"),t.focus())}}class FocusTrapStart extends HTMLElement{connectedCallback(){this.setAttribute("tabindex","0"),this.addEventListener("focus",this.handleFocus)}disconnectedCallback(){this.removeEventListener("focus",this.handleFocus)}handleFocus=e=>{const n=this.closest("focus-trap"),s=t(n);if(0===s.length)return;const a=s[0],i=s[s.length-1];e.relatedTarget===a?i.focus():a.focus()}}class FocusTrapEnd extends HTMLElement{connectedCallback(){this.setAttribute("tabindex","0"),this.addEventListener("focus",this.handleFocus)}disconnectedCallback(){this.removeEventListener("focus",this.handleFocus)}handleFocus=()=>{this.closest("focus-trap").querySelector("focus-trap-start").focus()}}customElements.define("focus-trap",FocusTrap),customElements.define("focus-trap-start",FocusTrapStart),customElements.define("focus-trap-end",FocusTrapEnd);class DialogPanel extends HTMLElement{constructor(){super();const e=this;if(e.id=e.getAttribute("id"),e.setAttribute("role","dialog"),e.setAttribute("aria-modal","true"),e.setAttribute("aria-hidden","true"),e.contentPanel=e.querySelector("dialog-content"),e.focusTrap=document.createElement("focus-trap"),e.triggerEl=null,!e.getAttribute("aria-labelledby")){const t=e.querySelector("h1, h2, h3");t&&!t.id&&(t.id=`${e.id}-title`),t?.id&&e.setAttribute("aria-labelledby",t.id)}e.contentPanel.parentNode.insertBefore(e.focusTrap,e.contentPanel),e.focusTrap.appendChild(e.contentPanel),e.focusTrap.setupTrap(),e.prepend(document.createElement("dialog-overlay")),e.#e(),e.#t()}#e(){document.addEventListener("click",(e=>{const t=e.target.closest(`[aria-controls="${this.id}"]`);t&&("true"===t.getAttribute("data-prevent-default")&&e.preventDefault(),this.show(t))})),this.addEventListener("click",(e=>{e.target.closest('[data-action="hide-dialog"]')&&this.hide()}))}#t(){this.addEventListener("keydown",(e=>{"Escape"===e.key&&this.hide()}))}show(e=null){this.triggerEl=e||!1,this.setAttribute("aria-hidden","false"),this.triggerEl&&this.triggerEl.setAttribute("aria-expanded","true"),document.body.classList.add("overflow-hidden");const t=this.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');t&&requestAnimationFrame((()=>{t.focus()}))}hide(){document.body.classList.remove("overflow-hidden"),this.triggerEl?(this.triggerEl.setAttribute("aria-expanded","false"),this.triggerEl.focus()):console.log("we need to blur focus"),setTimeout((()=>{this.setAttribute("aria-hidden","true")}),1)}}class DialogOverlay extends HTMLElement{constructor(){super(),this.setAttribute("tabindex","-1"),this.setAttribute("aria-hidden","true"),this.dialogPanel=this.closest("dialog-panel"),this.#e()}#e(){this.addEventListener("click",(()=>{this.dialogPanel.hide()}))}}class DialogContent extends HTMLElement{constructor(){super(),this.setAttribute("role","document")}}return customElements.define("dialog-panel",DialogPanel),customElements.define("dialog-overlay",DialogOverlay),customElements.define("dialog-content",DialogContent),e.DialogContent=DialogContent,e.DialogOverlay=DialogOverlay,e.DialogPanel=DialogPanel,e.default=DialogPanel,Object.defineProperty(e,"__esModule",{value:!0}),e}({});
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@magic-spells/dialog-panel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A lightweight, customizable Dialog Panel web component for creating accessible and responsive modal dialogs.",
|
|
5
|
+
"author": "Cory Schulz",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/dialog-panel.cjs.js",
|
|
9
|
+
"module": "dist/dialog-panel.esm.js",
|
|
10
|
+
"unpkg": "dist/dialog-panel.min.js",
|
|
11
|
+
"style": "dist/dialog-panel.min.css",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": "./dist/dialog-panel.esm.js",
|
|
15
|
+
"require": "./dist/dialog-panel.cjs.js",
|
|
16
|
+
"default": "./dist/dialog-panel.esm.js"
|
|
17
|
+
},
|
|
18
|
+
"./style.css": "./dist/dialog-panel.min.css"
|
|
19
|
+
},
|
|
20
|
+
"sideEffects": true,
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/magic-spells/dialog-panel.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/magic-spells/dialog-panel#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/magic-spells/dialog-panel/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"dialog-panel",
|
|
31
|
+
"modal-dialog",
|
|
32
|
+
"web-components",
|
|
33
|
+
"accessibility",
|
|
34
|
+
"a11y",
|
|
35
|
+
"keyboard-navigation",
|
|
36
|
+
"custom-elements",
|
|
37
|
+
"modal",
|
|
38
|
+
"dialog"
|
|
39
|
+
],
|
|
40
|
+
"files": [
|
|
41
|
+
"dist/",
|
|
42
|
+
"src/"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "rollup -c",
|
|
46
|
+
"lint": "eslint src/ rollup.config.mjs",
|
|
47
|
+
"format": "prettier --write .",
|
|
48
|
+
"prepublishOnly": "npm run build",
|
|
49
|
+
"serve": "rollup -c --watch"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"registry": "https://registry.npmjs.org/"
|
|
54
|
+
},
|
|
55
|
+
"browserslist": [
|
|
56
|
+
"last 2 versions",
|
|
57
|
+
"not dead",
|
|
58
|
+
"not ie <= 11"
|
|
59
|
+
],
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@eslint/js": "^8.57.0",
|
|
62
|
+
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
63
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
64
|
+
"eslint": "^8.0.0",
|
|
65
|
+
"globals": "^13.24.0",
|
|
66
|
+
"prettier": "^3.3.3",
|
|
67
|
+
"rollup": "^3.0.0",
|
|
68
|
+
"rollup-plugin-copy": "^3.5.0",
|
|
69
|
+
"rollup-plugin-postcss": "^4.0.2",
|
|
70
|
+
"rollup-plugin-serve": "^1.1.1"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"@magic-spells/focus-trap": "^1.0.6"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
dialog-panel {
|
|
2
|
+
position: fixed;
|
|
3
|
+
top: 0;
|
|
4
|
+
left: 0;
|
|
5
|
+
width: 100vw;
|
|
6
|
+
height: 100vh;
|
|
7
|
+
z-index: 10;
|
|
8
|
+
pointer-events: none;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* transparent background */
|
|
12
|
+
dialog-overlay {
|
|
13
|
+
position: absolute;
|
|
14
|
+
top: 0;
|
|
15
|
+
left: 0;
|
|
16
|
+
width: 100vw;
|
|
17
|
+
height: 100vh;
|
|
18
|
+
opacity: 0;
|
|
19
|
+
pointer-events: none;
|
|
20
|
+
transition: all 300ms ease-out;
|
|
21
|
+
background-color: rgba(20, 23, 26, 0.4);
|
|
22
|
+
backdrop-filter: blur(2px) saturate(120%);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
dialog-content {
|
|
26
|
+
display: block;
|
|
27
|
+
opacity: 0;
|
|
28
|
+
background: white;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
dialog-panel[aria-hidden='false'] {
|
|
32
|
+
pointer-events: all;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
dialog-panel[aria-hidden='false'] dialog-overlay {
|
|
36
|
+
pointer-events: all;
|
|
37
|
+
opacity: 1;
|
|
38
|
+
transform: scale(1);
|
|
39
|
+
filter: blur(0px);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
dialog-panel[aria-hidden='false'] dialog-content {
|
|
43
|
+
opacity: 1;
|
|
44
|
+
z-index: 10;
|
|
45
|
+
transform: scale(1);
|
|
46
|
+
filter: blur(0px);
|
|
47
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import './dialog-panel.css';
|
|
2
|
+
import '@magic-spells/focus-trap';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom element that creates an accessible modal dialog panel with focus management
|
|
6
|
+
* @extends HTMLElement
|
|
7
|
+
*/
|
|
8
|
+
class DialogPanel extends HTMLElement {
|
|
9
|
+
/**
|
|
10
|
+
* Initializes the dialog panel, sets up focus trap and overlay
|
|
11
|
+
*/
|
|
12
|
+
constructor() {
|
|
13
|
+
super();
|
|
14
|
+
const _ = this;
|
|
15
|
+
_.id = _.getAttribute('id');
|
|
16
|
+
_.setAttribute('role', 'dialog');
|
|
17
|
+
_.setAttribute('aria-modal', 'true');
|
|
18
|
+
_.setAttribute('aria-hidden', 'true');
|
|
19
|
+
|
|
20
|
+
_.contentPanel = _.querySelector('dialog-content');
|
|
21
|
+
_.focusTrap = document.createElement('focus-trap');
|
|
22
|
+
_.triggerEl = null;
|
|
23
|
+
|
|
24
|
+
// Ensure we have labelledby and describedby references
|
|
25
|
+
if (!_.getAttribute('aria-labelledby')) {
|
|
26
|
+
const heading = _.querySelector('h1, h2, h3');
|
|
27
|
+
if (heading && !heading.id) {
|
|
28
|
+
heading.id = `${_.id}-title`;
|
|
29
|
+
}
|
|
30
|
+
if (heading?.id) {
|
|
31
|
+
_.setAttribute('aria-labelledby', heading.id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_.contentPanel.parentNode.insertBefore(
|
|
36
|
+
_.focusTrap,
|
|
37
|
+
_.contentPanel
|
|
38
|
+
);
|
|
39
|
+
_.focusTrap.appendChild(_.contentPanel);
|
|
40
|
+
|
|
41
|
+
_.focusTrap.setupTrap();
|
|
42
|
+
|
|
43
|
+
// Add modal overlay
|
|
44
|
+
_.prepend(document.createElement('dialog-overlay'));
|
|
45
|
+
_.#bindUI();
|
|
46
|
+
_.#bindKeyboard();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Binds click events for showing and hiding the dialog
|
|
51
|
+
* @private
|
|
52
|
+
*/
|
|
53
|
+
#bindUI() {
|
|
54
|
+
// Handle trigger buttons
|
|
55
|
+
document.addEventListener('click', (e) => {
|
|
56
|
+
const trigger = e.target.closest(
|
|
57
|
+
`[aria-controls="${this.id}"]`
|
|
58
|
+
);
|
|
59
|
+
if (!trigger) return;
|
|
60
|
+
|
|
61
|
+
if (trigger.getAttribute('data-prevent-default') === 'true') {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// this.triggerEl = trigger;
|
|
66
|
+
this.show(trigger);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Handle close buttons
|
|
70
|
+
this.addEventListener('click', (e) => {
|
|
71
|
+
if (!e.target.closest('[data-action="hide-dialog"]')) return;
|
|
72
|
+
this.hide();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Binds keyboard events for accessibility
|
|
78
|
+
* @private
|
|
79
|
+
*/
|
|
80
|
+
#bindKeyboard() {
|
|
81
|
+
this.addEventListener('keydown', (e) => {
|
|
82
|
+
if (e.key === 'Escape') {
|
|
83
|
+
this.hide();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Shows the dialog and traps focus within it
|
|
90
|
+
* @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog
|
|
91
|
+
*/
|
|
92
|
+
show(triggerEl = null) {
|
|
93
|
+
this.triggerEl = triggerEl || false;
|
|
94
|
+
|
|
95
|
+
// Update ARIA states
|
|
96
|
+
this.setAttribute('aria-hidden', 'false');
|
|
97
|
+
if (this.triggerEl) {
|
|
98
|
+
this.triggerEl.setAttribute('aria-expanded', 'true');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// prevent body from scrolling
|
|
102
|
+
document.body.classList.add('overflow-hidden');
|
|
103
|
+
|
|
104
|
+
// Focus management
|
|
105
|
+
const firstFocusable = this.querySelector(
|
|
106
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
107
|
+
);
|
|
108
|
+
if (firstFocusable) {
|
|
109
|
+
requestAnimationFrame(() => {
|
|
110
|
+
firstFocusable.focus();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Hides the dialog and restores focus
|
|
117
|
+
*/
|
|
118
|
+
hide() {
|
|
119
|
+
// allow body to scroll
|
|
120
|
+
document.body.classList.remove('overflow-hidden');
|
|
121
|
+
|
|
122
|
+
// Update ARIA states
|
|
123
|
+
if (this.triggerEl) {
|
|
124
|
+
this.triggerEl.setAttribute('aria-expanded', 'false');
|
|
125
|
+
// Restore focus to trigger element
|
|
126
|
+
this.triggerEl.focus();
|
|
127
|
+
} else {
|
|
128
|
+
console.log('we need to blur focus');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// hide dialog panel
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
this.setAttribute('aria-hidden', 'true');
|
|
134
|
+
}, 1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Custom element that creates a clickable overlay for the dialog
|
|
140
|
+
* @extends HTMLElement
|
|
141
|
+
*/
|
|
142
|
+
class DialogOverlay extends HTMLElement {
|
|
143
|
+
constructor() {
|
|
144
|
+
super();
|
|
145
|
+
this.setAttribute('tabindex', '-1'); // Changed to -1 as it shouldn't be focusable
|
|
146
|
+
this.setAttribute('aria-hidden', 'true');
|
|
147
|
+
this.dialogPanel = this.closest('dialog-panel');
|
|
148
|
+
this.#bindUI();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#bindUI() {
|
|
152
|
+
this.addEventListener('click', () => {
|
|
153
|
+
this.dialogPanel.hide();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Custom element that wraps the content of the dialog
|
|
160
|
+
* @extends HTMLElement
|
|
161
|
+
*/
|
|
162
|
+
class DialogContent extends HTMLElement {
|
|
163
|
+
constructor() {
|
|
164
|
+
super();
|
|
165
|
+
this.setAttribute('role', 'document'); // Optional: helps with document structure
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
customElements.define('dialog-panel', DialogPanel);
|
|
170
|
+
customElements.define('dialog-overlay', DialogOverlay);
|
|
171
|
+
customElements.define('dialog-content', DialogContent);
|
|
172
|
+
|
|
173
|
+
export { DialogPanel, DialogOverlay, DialogContent };
|
|
174
|
+
export default DialogPanel;
|