@undrr/undrr-mangrove 2.0.0-rc.2 → 2.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.
- package/README.md +11 -6
- package/components/BookCard.js +1 -1
- package/components/Breadcrumbs.js +2 -2
- package/components/Checkbox.js +1 -1
- package/components/Chips.js +1 -1
- package/components/CopyButton.js +2 -2
- package/components/CtaButton.js +1 -1
- package/components/Drawer.js +1 -1
- package/components/EmbedContainer.js +1 -1
- package/components/Footer.js +1 -1
- package/components/FormAction.js +1 -1
- package/components/FormErrorSummary.js +1 -1
- package/components/FormGroup.js +1 -1
- package/components/FullWidth.js +1 -1
- package/components/Gallery.js +2 -2
- package/components/Hero.js +2 -2
- package/components/HighlightBox.js +1 -1
- package/components/HorizontalBookCard.js +1 -1
- package/components/HorizontalCard.js +1 -1
- package/components/IconCard.js +1 -1
- package/components/Legend.js +1 -1
- package/components/Loader.js +1 -1
- package/components/MegaMenu.js +2 -2
- package/components/Notice.js +2 -2
- package/components/PageHeader.js +2 -2
- package/components/Pager.js +2 -2
- package/components/QuoteHighlight.js +2 -2
- package/components/QuoteHighlight.js.LICENSE.txt +2 -0
- package/components/Radio.js +1 -1
- package/components/Range.js +1 -1
- package/components/ScrollContainer.js +2 -2
- package/components/SegmentedControl.js +9 -0
- package/components/SegmentedControl.js.LICENSE.txt +9 -0
- package/components/Select.js +1 -1
- package/components/ServiceNotice.js +2 -2
- package/components/ShareButtons.js +2 -2
- package/components/ShowMore.js +2 -2
- package/components/SkipLink.js +1 -1
- package/components/StatsCard.js +1 -1
- package/components/SyndicationSearchWidget.js +2 -2
- package/components/Tab.js +2 -2
- package/components/TextCta.js +1 -1
- package/components/TextInput.js +1 -1
- package/components/Textarea.js +1 -1
- package/components/Tree.js +1 -1
- package/components/UserFeedback.js +1 -1
- package/components/VerticalCard.js +1 -1
- package/components/hydrate.js +2 -2
- package/css/style-all.css +60 -40
- package/css/style-all.css.map +1 -1
- package/css/style-delta.css +59 -39
- package/css/style-delta.css.map +1 -1
- package/css/style-gutenberg.css +16 -7
- package/css/style-gutenberg.css.map +1 -1
- package/css/style-irp.css +60 -40
- package/css/style-irp.css.map +1 -1
- package/css/style-mcr.css +59 -39
- package/css/style-mcr.css.map +1 -1
- package/css/style-preventionweb.css +60 -40
- package/css/style-preventionweb.css.map +1 -1
- package/css/style.css +59 -39
- package/css/style.css.map +1 -1
- package/error-pages/401.html +3 -3
- package/error-pages/403.html +3 -3
- package/error-pages/404.html +4 -4
- package/error-pages/429.html +3 -3
- package/error-pages/500.html +6 -6
- package/error-pages/502.html +3 -3
- package/error-pages/503.html +3 -3
- package/error-pages/504.html +3 -3
- package/error-pages/5xx.html +4 -4
- package/error-pages/challenge.html +3 -3
- package/error-pages/managed-challenge.html +3 -3
- package/js/README.md +31 -25
- package/js/copy-button.js +17 -0
- package/js/drawer.js +752 -0
- package/js/hub-header.js +217 -0
- package/js/show-more.js +144 -10
- package/js/switch-pending.js +195 -46
- package/package.json +8 -3
- package/scss/Atom/BaseTypography/Blockquote/blockquote.scss +2 -1
- package/scss/Atom/BaseTypography/Cite/cite.scss +2 -1
- package/scss/Atom/BaseTypography/Code/code.scss +8 -2
- package/scss/Atom/Icons/icons.scss +1 -1
- package/scss/Atom/Images/AuthorImage/author-image.scss +16 -1
- package/scss/Atom/StatusLabel/status-label.scss +331 -8
- package/scss/Atom/Table/table.scss +8 -3
- package/scss/Atom/Tag/tag.scss +20 -2
- package/scss/Components/Breadcrumbs/breadcrumbs.scss +8 -4
- package/scss/Components/Buttons/CtaButton/cta-button.scss +15 -0
- package/scss/Components/Cards/Card/card.scss +37 -10
- package/scss/Components/Cards/StatsCard/stats-card.scss +2 -10
- package/scss/Components/DataViz/Legend/legend.scss +2 -1
- package/scss/Components/ErrorPages/error-pages.scss +8 -0
- package/scss/Components/Forms/SegmentedControl/segmented-control.scss +412 -0
- package/scss/Components/Forms/_form-base.scss +109 -18
- package/scss/Components/Hero/hero.scss +296 -57
- package/scss/Components/MegaMenu/mega-menu.scss +26 -6
- package/scss/Components/OnThisPageNav/on-this-page-nav.scss +3 -1
- package/scss/Components/PageHeader/page-header.scss +13 -28
- package/scss/Components/Pager/pager.scss +3 -1
- package/scss/Components/QuoteHighlight/quote-highlight.scss +6 -1
- package/scss/Components/ScrollContainer/scroll-container.scss +11 -0
- package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +51 -24
- package/scss/Components/Tab/tab.scss +1 -1
- package/scss/Components/TextCta/text-cta.scss +11 -3
- package/scss/Patterns/HubHeader/hub-header.scss +14 -3
- package/scss/Utilities/FullWidth/full-width.scss +14 -1
- package/scss/assets/scss/_components.scss +1 -0
- package/scss/assets/scss/_fonts.scss +11 -0
- package/scss/assets/scss/_foundational.scss +7 -0
- package/scss/assets/scss/_mixins.scss +32 -0
- package/scss/assets/scss/_tokens-data-viz.scss +9 -0
- package/scss/assets/scss/_variables.scss +16 -3
- package/scss/assets/scss/generated/_tokens-irp.scss +20 -4
- package/scss/assets/scss/generated/_tokens-mangrove.scss +6 -0
- package/scss/assets/scss/generated/_tokens-preventionweb.scss +20 -4
- package/js/__tests__/copy-button.test.js +0 -112
- package/js/__tests__/show-more.test.js +0 -80
- package/js/__tests__/table-of-contents.test.js +0 -196
- package/scss/Components/SyndicationSearchWidget/SyndicationSearchWidget.scss +0 -11
- package/scss/Components/TableOfContents/TableOfContents.scss +0 -11
- package/scss/Utilities/FullWidth/FullWidth.scss +0 -11
- package/scss/Utilities/ShowMore/ShowMore.scss +0 -11
package/js/drawer.js
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
// mg-drawer / mg-floating-panel
|
|
2
|
+
// Vanilla lifecycle for the off-canvas drawer and the floating panel: open and
|
|
3
|
+
// close, Escape, a focus trap for the modal case, focus return, and cleanup.
|
|
4
|
+
// See Drawer.mdx. Requested in unisdr/undrr-mangrove#1197.
|
|
5
|
+
//
|
|
6
|
+
// This module enhances markup you already wrote. It never builds a drawer, and
|
|
7
|
+
// it holds no user-visible strings: the close button's accessible name, the
|
|
8
|
+
// title and the body all come from your HTML, so translation stays with the
|
|
9
|
+
// page that renders it. The one element it creates is the modal backdrop,
|
|
10
|
+
// which carries no text.
|
|
11
|
+
|
|
12
|
+
const DRAWER_SELECTOR = '[data-mg-js-drawer]';
|
|
13
|
+
const SKIP_ATTR = 'data-mg-js-drawer-skip-auto-init';
|
|
14
|
+
const INITIALISED_ATTR = 'data-mg-js-drawer-initialized';
|
|
15
|
+
const HYDRATION_ATTR = 'data-mg-drawer';
|
|
16
|
+
const TRIGGER_SELECTOR = '[data-mg-drawer-trigger]';
|
|
17
|
+
const FLOATING_PANEL_CLASS = 'mg-floating-panel';
|
|
18
|
+
const BACKDROP_CLASS = 'mg-drawer__backdrop';
|
|
19
|
+
|
|
20
|
+
/** Command events a script can dispatch on the container. */
|
|
21
|
+
export const OPEN_EVENT = 'mg-drawer:open';
|
|
22
|
+
export const CLOSE_EVENT = 'mg-drawer:close';
|
|
23
|
+
export const TOGGLE_EVENT = 'mg-drawer:toggle';
|
|
24
|
+
|
|
25
|
+
/** Notification events the drawer dispatches after it has moved. */
|
|
26
|
+
export const OPENED_EVENT = 'mg-drawer:opened';
|
|
27
|
+
export const CLOSED_EVENT = 'mg-drawer:closed';
|
|
28
|
+
|
|
29
|
+
// Everything that can hold focus. Wider than a `[tabindex]`-and-form-controls
|
|
30
|
+
// list on purpose: an iframe, a contenteditable region or a `<summary>` inside
|
|
31
|
+
// an open drawer is reachable by Tab, and a trap that cannot see them either
|
|
32
|
+
// strands them outside the ring (unreachable) or lets Tab walk straight past
|
|
33
|
+
// the end of the drawer. Both were reproduced before this list was widened.
|
|
34
|
+
const FOCUSABLE = [
|
|
35
|
+
'a[href]',
|
|
36
|
+
'area[href]',
|
|
37
|
+
'button',
|
|
38
|
+
'input',
|
|
39
|
+
'select',
|
|
40
|
+
'textarea',
|
|
41
|
+
'iframe',
|
|
42
|
+
'object',
|
|
43
|
+
'embed',
|
|
44
|
+
'audio[controls]',
|
|
45
|
+
'video[controls]',
|
|
46
|
+
'summary',
|
|
47
|
+
'[contenteditable]',
|
|
48
|
+
'[tabindex]',
|
|
49
|
+
].join(', ');
|
|
50
|
+
|
|
51
|
+
// Tag names the browser puts in the tab order without a tabindex attribute.
|
|
52
|
+
const NATIVELY_TABBABLE = new Set([
|
|
53
|
+
'A',
|
|
54
|
+
'AREA',
|
|
55
|
+
'BUTTON',
|
|
56
|
+
'INPUT',
|
|
57
|
+
'SELECT',
|
|
58
|
+
'TEXTAREA',
|
|
59
|
+
'IFRAME',
|
|
60
|
+
'OBJECT',
|
|
61
|
+
'EMBED',
|
|
62
|
+
'AUDIO',
|
|
63
|
+
'VIDEO',
|
|
64
|
+
'SUMMARY',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
// Shared by every copy of this module on the page — a CDN build alongside a
|
|
68
|
+
// bundled one, or two versions — so one drawer is never enhanced twice and
|
|
69
|
+
// either copy can destroy an instance the other created. The per-element
|
|
70
|
+
// dataset flag is what survives that split; a module-scope WeakMap alone would
|
|
71
|
+
// not (docs/HYDRATION-AUTHORING.md).
|
|
72
|
+
//
|
|
73
|
+
// Deliberately unversioned. `INITIALISED_ATTR` is the marker that decides
|
|
74
|
+
// whether a container is already enhanced and it carries no version either, so
|
|
75
|
+
// a versioned registry key would let a v2 copy skip a v1 container as
|
|
76
|
+
// "initialised" and then fail to find its handle — leaving the container
|
|
77
|
+
// marked, its listeners live and nothing able to destroy it. The handle shape
|
|
78
|
+
// (`open` / `close` / `toggle` / `isOpen` / `destroy`) is the compatibility
|
|
79
|
+
// contract between copies, and it must stay additive.
|
|
80
|
+
const REGISTRY_KEY = Symbol.for('@undrr/mangrove/drawer');
|
|
81
|
+
const registry =
|
|
82
|
+
globalThis[REGISTRY_KEY] ||
|
|
83
|
+
(globalThis[REGISTRY_KEY] = { instances: new WeakMap(), openStack: [] });
|
|
84
|
+
// A copy that predates `openStack` may have created the registry first.
|
|
85
|
+
if (!registry.openStack) registry.openStack = [];
|
|
86
|
+
|
|
87
|
+
// Used to give an unlabelled drawer title an id so the dialog can point
|
|
88
|
+
// `aria-labelledby` at it. The counter lives on `globalThis` so two loaded
|
|
89
|
+
// copies of this module share one sequence instead of each minting
|
|
90
|
+
// `mg-drawer-title-0`, the same hazard and the same fix as show-more.js.
|
|
91
|
+
const TITLE_COUNTER_KEY = Symbol.for('undrr.mangrove.drawer.titleCounter');
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mints an id that is unused in `doc`.
|
|
95
|
+
*
|
|
96
|
+
* @param {Document} doc
|
|
97
|
+
* @returns {string} A free `mg-drawer-title-N` id.
|
|
98
|
+
*/
|
|
99
|
+
function nextTitleId(doc) {
|
|
100
|
+
for (;;) {
|
|
101
|
+
const current = globalThis[TITLE_COUNTER_KEY];
|
|
102
|
+
const next = Number.isSafeInteger(current) && current >= 0 ? current : 0;
|
|
103
|
+
globalThis[TITLE_COUNTER_KEY] = next + 1;
|
|
104
|
+
const id = `mg-drawer-title-${next}`;
|
|
105
|
+
if (!doc.getElementById(id)) return id;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every element in `scope` that matches `selector`, including `scope` itself.
|
|
111
|
+
*
|
|
112
|
+
* @param {Document|Element|Iterable<Element>} scope
|
|
113
|
+
* @param {string} selector
|
|
114
|
+
* @returns {Element[]}
|
|
115
|
+
*/
|
|
116
|
+
function collect(scope, selector) {
|
|
117
|
+
const roots =
|
|
118
|
+
scope && typeof scope[Symbol.iterator] === 'function'
|
|
119
|
+
? Array.from(scope)
|
|
120
|
+
: [scope || document];
|
|
121
|
+
const found = new Set();
|
|
122
|
+
roots.forEach(root => {
|
|
123
|
+
if (!root) return;
|
|
124
|
+
if (root.matches?.(selector)) found.add(root);
|
|
125
|
+
root.querySelectorAll?.(selector).forEach(element => found.add(element));
|
|
126
|
+
});
|
|
127
|
+
return Array.from(found);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether the container is a floating panel rather than an edge drawer.
|
|
132
|
+
*
|
|
133
|
+
* Read from the class the markup already carries, which is the same signal the
|
|
134
|
+
* React component's `isFloatingPanel` prop sets.
|
|
135
|
+
*
|
|
136
|
+
* @param {Element} container
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
function isFloatingPanel(container) {
|
|
140
|
+
return container.classList.contains(FLOATING_PANEL_CLASS);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether the drawer is modal: an edge drawer with a backdrop. A floating
|
|
145
|
+
* panel is never modal, and `data-backdrop="false"` opts an edge drawer out.
|
|
146
|
+
*
|
|
147
|
+
* @param {Element} container
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
function isModal(container) {
|
|
151
|
+
if (isFloatingPanel(container)) return false;
|
|
152
|
+
return container.getAttribute('data-backdrop') !== 'false';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Triggers anywhere in the document that point at this container's id.
|
|
157
|
+
*
|
|
158
|
+
* @param {Element} container
|
|
159
|
+
* @returns {Element[]}
|
|
160
|
+
*/
|
|
161
|
+
function triggersFor(container) {
|
|
162
|
+
if (!container.id) return [];
|
|
163
|
+
return Array.from(
|
|
164
|
+
container.ownerDocument.querySelectorAll(TRIGGER_SELECTOR)
|
|
165
|
+
).filter(
|
|
166
|
+
trigger => trigger.getAttribute('data-mg-drawer-trigger') === container.id
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether the element is hidden from rendering, and so out of the tab order.
|
|
172
|
+
*
|
|
173
|
+
* Walks ancestors rather than reading a layout box: `getClientRects()` is
|
|
174
|
+
* empty in every non-rendering environment, which would make the trap think an
|
|
175
|
+
* open drawer had nothing in it at all under test. Computed style answers the
|
|
176
|
+
* same question in both.
|
|
177
|
+
*
|
|
178
|
+
* @param {Element} element
|
|
179
|
+
* @returns {boolean}
|
|
180
|
+
*/
|
|
181
|
+
function isHidden(element) {
|
|
182
|
+
const view = element.ownerDocument?.defaultView;
|
|
183
|
+
if (!view?.getComputedStyle) return false;
|
|
184
|
+
for (let node = element; node?.nodeType === 1; node = node.parentElement) {
|
|
185
|
+
if (node.hasAttribute('hidden') || node.inert) return true;
|
|
186
|
+
const style = view.getComputedStyle(node);
|
|
187
|
+
if (
|
|
188
|
+
style.display === 'none' ||
|
|
189
|
+
style.visibility === 'hidden' ||
|
|
190
|
+
style.visibility === 'collapse'
|
|
191
|
+
) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
// A closed <details> does not render anything but its <summary>.
|
|
195
|
+
const parent = node.parentElement;
|
|
196
|
+
if (
|
|
197
|
+
parent?.tagName === 'DETAILS' &&
|
|
198
|
+
!parent.open &&
|
|
199
|
+
node.tagName !== 'SUMMARY'
|
|
200
|
+
) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The element's effective tabindex.
|
|
209
|
+
*
|
|
210
|
+
* Read from the attribute, then the tag, rather than from `element.tabIndex`:
|
|
211
|
+
* the property's default for elements like `<iframe>` and `<summary>` is not
|
|
212
|
+
* consistent across engines, and the trap has to agree with the browser about
|
|
213
|
+
* what Tab will do.
|
|
214
|
+
*
|
|
215
|
+
* @param {Element} element
|
|
216
|
+
* @returns {number}
|
|
217
|
+
*/
|
|
218
|
+
function tabIndexOf(element) {
|
|
219
|
+
const attribute = element.getAttribute('tabindex');
|
|
220
|
+
if (attribute !== null) {
|
|
221
|
+
const value = Number(attribute);
|
|
222
|
+
return Number.isNaN(value) ? -1 : value;
|
|
223
|
+
}
|
|
224
|
+
const editable = element.getAttribute('contenteditable');
|
|
225
|
+
if (editable !== null && editable !== 'false') return 0;
|
|
226
|
+
if (element.tagName === 'SUMMARY') {
|
|
227
|
+
return element.parentElement?.tagName === 'DETAILS' ? 0 : -1;
|
|
228
|
+
}
|
|
229
|
+
return NATIVELY_TABBABLE.has(element.tagName) ? 0 : -1;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Drops every radio in a group except the one the browser will actually stop
|
|
234
|
+
* on: the checked one, or the first when none is checked. Keeping all three
|
|
235
|
+
* made the last radio in a group the trap's `last`, which Tab never reaches —
|
|
236
|
+
* so Tab walked out of the drawer instead of wrapping.
|
|
237
|
+
*
|
|
238
|
+
* @param {Element[]} elements
|
|
239
|
+
* @returns {Element[]}
|
|
240
|
+
*/
|
|
241
|
+
function collapseRadioGroups(elements) {
|
|
242
|
+
const isRadio = element =>
|
|
243
|
+
element.tagName === 'INPUT' && element.type === 'radio' && element.name;
|
|
244
|
+
const stops = new Map();
|
|
245
|
+
elements.forEach(element => {
|
|
246
|
+
if (!isRadio(element)) return;
|
|
247
|
+
// The first radio is the stop until a checked one is found.
|
|
248
|
+
if (!stops.has(element.name) || element.checked) {
|
|
249
|
+
stops.set(element.name, element);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return elements.filter(
|
|
253
|
+
element => !isRadio(element) || stops.get(element.name) === element
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Everything inside the container that Tab will actually stop on, in tab
|
|
259
|
+
* order.
|
|
260
|
+
*
|
|
261
|
+
* @param {Element} container
|
|
262
|
+
* @returns {Element[]}
|
|
263
|
+
*/
|
|
264
|
+
function tabbablesIn(container) {
|
|
265
|
+
const matches = Array.from(container.querySelectorAll(FOCUSABLE)).filter(
|
|
266
|
+
element =>
|
|
267
|
+
!element.disabled &&
|
|
268
|
+
!element.closest('[inert]') &&
|
|
269
|
+
tabIndexOf(element) >= 0 &&
|
|
270
|
+
!isHidden(element)
|
|
271
|
+
);
|
|
272
|
+
return collapseRadioGroups(matches);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Moves focus to the first of `candidates` that will actually take it, and
|
|
277
|
+
* reports whether any did.
|
|
278
|
+
*
|
|
279
|
+
* `element.focus()` is a silent no-op on something unrendered, so a single
|
|
280
|
+
* call cannot be trusted: an opener that was removed, hidden or never existed
|
|
281
|
+
* used to leave focus on a control inside the drawer that had just been made
|
|
282
|
+
* `inert` and `aria-hidden` — invisible, unannounced and worse than `<body>`.
|
|
283
|
+
*
|
|
284
|
+
* @param {Element[]} candidates
|
|
285
|
+
* @param {Document} doc
|
|
286
|
+
* @returns {boolean}
|
|
287
|
+
*/
|
|
288
|
+
function focusFirstAvailable(candidates, doc) {
|
|
289
|
+
for (const element of candidates) {
|
|
290
|
+
if (!element?.isConnected || typeof element.focus !== 'function') continue;
|
|
291
|
+
if (isHidden(element)) continue;
|
|
292
|
+
element.focus();
|
|
293
|
+
if (doc.activeElement === element) return true;
|
|
294
|
+
}
|
|
295
|
+
// Nothing is left to go back to. Park focus on the document body so the next
|
|
296
|
+
// Tab starts from the top of the page and a screen reader resumes there,
|
|
297
|
+
// rather than leaving it inside a drawer that is now closed. The tabindex is
|
|
298
|
+
// temporary so the body does not become a permanent tab stop.
|
|
299
|
+
const body = doc.body;
|
|
300
|
+
if (!body) return false;
|
|
301
|
+
const hadTabIndex = body.hasAttribute('tabindex');
|
|
302
|
+
if (!hadTabIndex) body.setAttribute('tabindex', '-1');
|
|
303
|
+
body.focus();
|
|
304
|
+
if (!hadTabIndex) body.removeAttribute('tabindex');
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Puts one attribute back to the value it had before this module ran.
|
|
310
|
+
*
|
|
311
|
+
* @param {Element} element
|
|
312
|
+
* @param {string} name
|
|
313
|
+
* @param {string|null} value
|
|
314
|
+
*/
|
|
315
|
+
function restoreOn(element, name, value) {
|
|
316
|
+
if (value === null) element.removeAttribute(name);
|
|
317
|
+
else element.setAttribute(name, value);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Enhances one drawer container.
|
|
322
|
+
*
|
|
323
|
+
* @param {Element} container
|
|
324
|
+
* @returns {{ open: () => void, close: () => void, toggle: () => void, isOpen: () => boolean, destroy: () => void }}
|
|
325
|
+
*/
|
|
326
|
+
function enhance(container) {
|
|
327
|
+
const doc = container.ownerDocument;
|
|
328
|
+
const controller = new AbortController();
|
|
329
|
+
const { signal } = controller;
|
|
330
|
+
let open = container.classList.contains('is-open');
|
|
331
|
+
let returnFocusTo = null;
|
|
332
|
+
let backdrop = null;
|
|
333
|
+
let destroyed = false;
|
|
334
|
+
|
|
335
|
+
// What the markup looked like before this module touched it, so destroy()
|
|
336
|
+
// can put it back rather than leave a half-managed dialog behind.
|
|
337
|
+
const initial = {
|
|
338
|
+
role: container.getAttribute('role'),
|
|
339
|
+
tabindex: container.getAttribute('tabindex'),
|
|
340
|
+
ariaHidden: container.getAttribute('aria-hidden'),
|
|
341
|
+
ariaModal: container.getAttribute('aria-modal'),
|
|
342
|
+
ariaLabelledBy: container.getAttribute('aria-labelledby'),
|
|
343
|
+
inert: container.inert,
|
|
344
|
+
isOpen: container.classList.contains('is-open'),
|
|
345
|
+
// Per trigger, because a hand-written page may already have wired
|
|
346
|
+
// aria-expanded / aria-controls itself. Removing them unconditionally on
|
|
347
|
+
// destroy took markup away that this module never added.
|
|
348
|
+
triggers: triggersFor(container).map(trigger => ({
|
|
349
|
+
trigger,
|
|
350
|
+
ariaExpanded: trigger.getAttribute('aria-expanded'),
|
|
351
|
+
ariaControls: trigger.getAttribute('aria-controls'),
|
|
352
|
+
})),
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// A hand-written container may be missing the two attributes that make it a
|
|
356
|
+
// dialog you can move focus into. Adding them is the same kind of repair
|
|
357
|
+
// copy-button.js makes for a missing accessible name.
|
|
358
|
+
if (!container.getAttribute('role')) container.setAttribute('role', 'dialog');
|
|
359
|
+
if (!container.hasAttribute('tabindex'))
|
|
360
|
+
container.setAttribute('tabindex', '-1');
|
|
361
|
+
|
|
362
|
+
// An unnamed dialog announces as just "dialog" (WCAG 4.1.2). The React
|
|
363
|
+
// component names it from its title, and a hand-written drawer almost always
|
|
364
|
+
// has the same title element — it just has no id to point at. Name it from
|
|
365
|
+
// there rather than invent a string, which would be untranslated English in
|
|
366
|
+
// a module that otherwise holds no copy. A title minted an id here is given
|
|
367
|
+
// it back on destroy.
|
|
368
|
+
let mintedTitleId = null;
|
|
369
|
+
if (
|
|
370
|
+
!container.getAttribute('aria-label') &&
|
|
371
|
+
!container.getAttribute('aria-labelledby')
|
|
372
|
+
) {
|
|
373
|
+
const title = container.querySelector(
|
|
374
|
+
`.mg-drawer__title, .${FLOATING_PANEL_CLASS}__title`
|
|
375
|
+
);
|
|
376
|
+
if (title) {
|
|
377
|
+
if (!title.id) {
|
|
378
|
+
mintedTitleId = nextTitleId(doc);
|
|
379
|
+
title.id = mintedTitleId;
|
|
380
|
+
}
|
|
381
|
+
container.setAttribute('aria-labelledby', title.id);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const closeButton = container.querySelector(
|
|
386
|
+
`.mg-drawer__close, .${FLOATING_PANEL_CLASS}__close`
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const emit = (type, detail) => {
|
|
390
|
+
container.dispatchEvent(
|
|
391
|
+
new CustomEvent(type, { bubbles: true, detail: { ...detail, container } })
|
|
392
|
+
);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const syncTriggers = () => {
|
|
396
|
+
triggersFor(container).forEach(trigger => {
|
|
397
|
+
trigger.setAttribute('aria-expanded', String(open));
|
|
398
|
+
trigger.setAttribute('aria-controls', container.id);
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const removeBackdrop = () => {
|
|
403
|
+
backdrop?.remove();
|
|
404
|
+
backdrop = null;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const addBackdrop = () => {
|
|
408
|
+
if (backdrop || !isModal(container)) return;
|
|
409
|
+
backdrop = doc.createElement('div');
|
|
410
|
+
backdrop.className = BACKDROP_CLASS;
|
|
411
|
+
backdrop.setAttribute('aria-hidden', 'true');
|
|
412
|
+
// Created by this module, so it is removed on close and on destroy; a
|
|
413
|
+
// backdrop in the source markup is left alone.
|
|
414
|
+
backdrop.setAttribute('data-mg-js-drawer-backdrop', '');
|
|
415
|
+
backdrop.addEventListener('click', () => close(), { signal });
|
|
416
|
+
container.before(backdrop);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// A closed drawer stays in the DOM but off-screen. `inert` keeps its
|
|
420
|
+
// controls out of the tab order and away from assistive technology, which
|
|
421
|
+
// `visibility: hidden` alone does not guarantee mid-transition.
|
|
422
|
+
const applyState = () => {
|
|
423
|
+
container.classList.toggle('is-open', open);
|
|
424
|
+
container.inert = !open;
|
|
425
|
+
if (open) {
|
|
426
|
+
container.removeAttribute('aria-hidden');
|
|
427
|
+
if (isModal(container)) container.setAttribute('aria-modal', 'true');
|
|
428
|
+
} else {
|
|
429
|
+
container.setAttribute('aria-hidden', 'true');
|
|
430
|
+
container.removeAttribute('aria-modal');
|
|
431
|
+
}
|
|
432
|
+
syncTriggers();
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// Which drawers are open, innermost last. Shared through the registry so a
|
|
436
|
+
// second copy of the module sees the same stack: only the topmost drawer
|
|
437
|
+
// answers Escape, and only the topmost modal traps Tab.
|
|
438
|
+
const pushOpen = () => {
|
|
439
|
+
const index = registry.openStack.indexOf(container);
|
|
440
|
+
if (index !== -1) registry.openStack.splice(index, 1);
|
|
441
|
+
registry.openStack.push(container);
|
|
442
|
+
};
|
|
443
|
+
const popOpen = () => {
|
|
444
|
+
const index = registry.openStack.indexOf(container);
|
|
445
|
+
if (index !== -1) registry.openStack.splice(index, 1);
|
|
446
|
+
};
|
|
447
|
+
const isTopmost = () =>
|
|
448
|
+
registry.openStack[registry.openStack.length - 1] === container;
|
|
449
|
+
|
|
450
|
+
function openDrawer() {
|
|
451
|
+
if (destroyed || open) return;
|
|
452
|
+
open = true;
|
|
453
|
+
returnFocusTo = doc.activeElement;
|
|
454
|
+
pushOpen();
|
|
455
|
+
addBackdrop();
|
|
456
|
+
applyState();
|
|
457
|
+
(closeButton || container).focus();
|
|
458
|
+
emit(OPENED_EVENT);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function close() {
|
|
462
|
+
if (destroyed || !open) return;
|
|
463
|
+
// Read where focus is before applyState() makes the container inert, which
|
|
464
|
+
// in a browser blurs whatever was inside it to <body> and would otherwise
|
|
465
|
+
// make this look like focus had always been outside.
|
|
466
|
+
const active = doc.activeElement;
|
|
467
|
+
// Only take focus back if it is still ours to move: a script that focused
|
|
468
|
+
// something else while the drawer was open keeps it.
|
|
469
|
+
const focusWasInside =
|
|
470
|
+
container.contains(active) || !active || active === doc.body;
|
|
471
|
+
open = false;
|
|
472
|
+
popOpen();
|
|
473
|
+
removeBackdrop();
|
|
474
|
+
applyState();
|
|
475
|
+
const target = returnFocusTo;
|
|
476
|
+
returnFocusTo = null;
|
|
477
|
+
if (focusWasInside) {
|
|
478
|
+
// The opener first; then any trigger still on the page, which is where a
|
|
479
|
+
// keyboard user expects to land; then the body as the last resort.
|
|
480
|
+
focusFirstAvailable([target, ...triggersFor(container)], doc);
|
|
481
|
+
}
|
|
482
|
+
emit(CLOSED_EVENT);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function toggle() {
|
|
486
|
+
if (open) close();
|
|
487
|
+
else openDrawer();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Whether an event came from inside this drawer or from one of its triggers. */
|
|
491
|
+
const isOurs = target =>
|
|
492
|
+
container.contains(target) || triggersFor(container).includes(target);
|
|
493
|
+
|
|
494
|
+
// Escape closes, matching the React component, which listens on the document
|
|
495
|
+
// so a modal drawer closes even when focus has left it.
|
|
496
|
+
doc.addEventListener(
|
|
497
|
+
'keydown',
|
|
498
|
+
event => {
|
|
499
|
+
if (event.key !== 'Escape' || !open || destroyed) return;
|
|
500
|
+
// Something nearer the user already answered this Escape.
|
|
501
|
+
if (event.defaultPrevented) return;
|
|
502
|
+
// A nested <dialog>, and anything else that opens its own layer inside
|
|
503
|
+
// the drawer, owns Escape first. Without this, one Escape dismissed both
|
|
504
|
+
// the nested dialog and the drawer behind it.
|
|
505
|
+
if (event.target?.closest?.('dialog[open]')) return;
|
|
506
|
+
// Escape peels one layer: only the innermost open drawer answers.
|
|
507
|
+
// Otherwise a drawer opened from inside another closed both at once.
|
|
508
|
+
if (!isTopmost()) return;
|
|
509
|
+
// A non-modal floating panel leaves the rest of the page operable, so
|
|
510
|
+
// Escape there belongs to whatever the user is actually in — typing
|
|
511
|
+
// Escape in a page search field should not dismiss the map's layer
|
|
512
|
+
// panel. A modal drawer owns the page, so it answers from anywhere.
|
|
513
|
+
if (!isModal(container) && !isOurs(event.target)) return;
|
|
514
|
+
close();
|
|
515
|
+
},
|
|
516
|
+
{ signal }
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
// Keep Tab inside a modal drawer. A floating panel is non-modal: the rest of
|
|
520
|
+
// the page is still operable, so trapping Tab there would be wrong.
|
|
521
|
+
//
|
|
522
|
+
// Listening on the document rather than the container is what makes this a
|
|
523
|
+
// trap rather than a suggestion. A container listener only ever sees Tab
|
|
524
|
+
// while focus is already inside, so once focus had left — through an iframe,
|
|
525
|
+
// or a script moving it — nothing could pull it back.
|
|
526
|
+
doc.addEventListener(
|
|
527
|
+
'keydown',
|
|
528
|
+
event => {
|
|
529
|
+
if (event.key !== 'Tab' || !open || destroyed) return;
|
|
530
|
+
if (!isModal(container) || !isTopmost()) return;
|
|
531
|
+
const tabbables = tabbablesIn(container);
|
|
532
|
+
if (tabbables.length === 0) {
|
|
533
|
+
// Nothing to move to. Hold focus on the dialog itself so Tab cannot
|
|
534
|
+
// walk out into the page behind the backdrop.
|
|
535
|
+
event.preventDefault();
|
|
536
|
+
if (doc.activeElement !== container) container.focus();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const first = tabbables[0];
|
|
540
|
+
const last = tabbables[tabbables.length - 1];
|
|
541
|
+
const active = doc.activeElement;
|
|
542
|
+
// Focus is outside the drawer: pull it back to whichever end the user is
|
|
543
|
+
// heading for.
|
|
544
|
+
if (!container.contains(active)) {
|
|
545
|
+
event.preventDefault();
|
|
546
|
+
(event.shiftKey ? last : first).focus();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
// The container itself holds focus (it is `tabindex="-1"`, and open()
|
|
550
|
+
// focuses it when there is no close button). Tab forward from there
|
|
551
|
+
// reaches `first` on its own, but Shift+Tab would leave the drawer.
|
|
552
|
+
if (active === container) {
|
|
553
|
+
if (event.shiftKey) {
|
|
554
|
+
event.preventDefault();
|
|
555
|
+
last.focus();
|
|
556
|
+
}
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (event.shiftKey && active === first) {
|
|
560
|
+
event.preventDefault();
|
|
561
|
+
last.focus();
|
|
562
|
+
} else if (!event.shiftKey && active === last) {
|
|
563
|
+
event.preventDefault();
|
|
564
|
+
first.focus();
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
{ signal }
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
closeButton?.addEventListener('click', () => close(), { signal });
|
|
571
|
+
|
|
572
|
+
// One delegated listener, so triggers added to the page later still work.
|
|
573
|
+
doc.addEventListener(
|
|
574
|
+
'click',
|
|
575
|
+
event => {
|
|
576
|
+
const trigger = event.target.closest?.(TRIGGER_SELECTOR);
|
|
577
|
+
if (
|
|
578
|
+
!trigger ||
|
|
579
|
+
trigger.getAttribute('data-mg-drawer-trigger') !== container.id
|
|
580
|
+
) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
event.preventDefault();
|
|
584
|
+
toggle();
|
|
585
|
+
},
|
|
586
|
+
{ signal }
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
// Scoped to this container. A command event dispatched on a drawer nested
|
|
590
|
+
// inside another one bubbles, and without this the outer drawer answered it
|
|
591
|
+
// too and opened itself.
|
|
592
|
+
const onCommand = run => event => {
|
|
593
|
+
if (event.target !== container) return;
|
|
594
|
+
run();
|
|
595
|
+
};
|
|
596
|
+
container.addEventListener(OPEN_EVENT, onCommand(openDrawer), { signal });
|
|
597
|
+
container.addEventListener(CLOSE_EVENT, onCommand(close), { signal });
|
|
598
|
+
container.addEventListener(TOGGLE_EVENT, onCommand(toggle), { signal });
|
|
599
|
+
|
|
600
|
+
if (open) {
|
|
601
|
+
pushOpen();
|
|
602
|
+
addBackdrop();
|
|
603
|
+
}
|
|
604
|
+
applyState();
|
|
605
|
+
|
|
606
|
+
const handle = {
|
|
607
|
+
open: openDrawer,
|
|
608
|
+
close,
|
|
609
|
+
toggle,
|
|
610
|
+
isOpen: () => open,
|
|
611
|
+
destroy() {
|
|
612
|
+
if (destroyed) return;
|
|
613
|
+
destroyed = true;
|
|
614
|
+
controller.abort();
|
|
615
|
+
popOpen();
|
|
616
|
+
removeBackdrop();
|
|
617
|
+
if (initial.inert === undefined) delete container.inert;
|
|
618
|
+
else container.inert = initial.inert;
|
|
619
|
+
// Put the open/closed class back where the markup had it. Destroying an
|
|
620
|
+
// open drawer used to leave `is-open` behind, so the page was left
|
|
621
|
+
// showing a drawer nothing could close.
|
|
622
|
+
container.classList.toggle('is-open', initial.isOpen);
|
|
623
|
+
const restore = (name, value) => restoreOn(container, name, value);
|
|
624
|
+
restore('role', initial.role);
|
|
625
|
+
restore('tabindex', initial.tabindex);
|
|
626
|
+
restore('aria-hidden', initial.ariaHidden);
|
|
627
|
+
restore('aria-modal', initial.ariaModal);
|
|
628
|
+
restore('aria-labelledby', initial.ariaLabelledBy);
|
|
629
|
+
if (mintedTitleId) {
|
|
630
|
+
const title = doc.getElementById(mintedTitleId);
|
|
631
|
+
if (title) title.removeAttribute('id');
|
|
632
|
+
mintedTitleId = null;
|
|
633
|
+
}
|
|
634
|
+
// Restore each trigger to what the markup had, rather than stripping
|
|
635
|
+
// both attributes: a page that wired its own aria-expanded /
|
|
636
|
+
// aria-controls got them deleted by a destroy that never added them.
|
|
637
|
+
const known = new Map(
|
|
638
|
+
initial.triggers.map(entry => [entry.trigger, entry])
|
|
639
|
+
);
|
|
640
|
+
triggersFor(container).forEach(trigger => {
|
|
641
|
+
const entry = known.get(trigger);
|
|
642
|
+
restoreOn(trigger, 'aria-expanded', entry ? entry.ariaExpanded : null);
|
|
643
|
+
restoreOn(trigger, 'aria-controls', entry ? entry.ariaControls : null);
|
|
644
|
+
});
|
|
645
|
+
registry.instances.delete(container);
|
|
646
|
+
container.removeAttribute(INITIALISED_ATTR);
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
container.setAttribute(INITIALISED_ATTR, 'true');
|
|
651
|
+
registry.instances.set(container, handle);
|
|
652
|
+
return handle;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Enhances every `[data-mg-js-drawer]` container in `scope`, including `scope`
|
|
657
|
+
* itself. A container that is already enhanced is skipped, so calling this
|
|
658
|
+
* again after adding markup is safe.
|
|
659
|
+
*
|
|
660
|
+
* @param {Document|Element|Iterable<Element>} [scope=document]
|
|
661
|
+
* @returns {Array<object>} Handles for the drawers this call enhanced
|
|
662
|
+
*/
|
|
663
|
+
export function mgDrawer(scope = document) {
|
|
664
|
+
return collect(scope, DRAWER_SELECTOR)
|
|
665
|
+
.filter(container => !container.hasAttribute(INITIALISED_ATTR))
|
|
666
|
+
.filter(container => {
|
|
667
|
+
// The two markers are separate so that a container is driven by one
|
|
668
|
+
// lifecycle and never both. Nothing stopped a page putting both on the
|
|
669
|
+
// same element, though, and then React would re-render away the markup
|
|
670
|
+
// this module had just bound listeners to. Refuse, and say which one
|
|
671
|
+
// to remove.
|
|
672
|
+
if (!container.hasAttribute(HYDRATION_ATTR)) return true;
|
|
673
|
+
console.warn(
|
|
674
|
+
`[mg-drawer] #${container.id || '(no id)'} carries both ${HYDRATION_ATTR} and ${DRAWER_SELECTOR.slice(1, -1)}. ` +
|
|
675
|
+
'It is left to the hydration lifecycle; remove one marker to choose.',
|
|
676
|
+
container
|
|
677
|
+
);
|
|
678
|
+
return false;
|
|
679
|
+
})
|
|
680
|
+
.map(container => enhance(container));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Destroys every enhanced drawer in `scope`, including `scope` itself:
|
|
685
|
+
* removes listeners and any backdrop this module added, and restores the
|
|
686
|
+
* attributes it set.
|
|
687
|
+
*
|
|
688
|
+
* @param {Document|Element|Iterable<Element>} [scope=document]
|
|
689
|
+
*/
|
|
690
|
+
export function mgDrawerDestroy(scope = document) {
|
|
691
|
+
collect(scope, `[${INITIALISED_ATTR}]`).forEach(container => {
|
|
692
|
+
const handle = registry.instances.get(container);
|
|
693
|
+
if (handle) {
|
|
694
|
+
handle.destroy();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// Marked as enhanced, but no handle in the shared registry. That means a
|
|
698
|
+
// copy of this module keyed its registry differently, so its listeners
|
|
699
|
+
// cannot be removed from here. Say so rather than silently doing nothing
|
|
700
|
+
// and reporting success.
|
|
701
|
+
console.warn(
|
|
702
|
+
`[mg-drawer] #${container.id || '(no id)'} is marked ${INITIALISED_ATTR} but has no handle in the shared registry, ` +
|
|
703
|
+
'so it cannot be destroyed from this copy of the module.',
|
|
704
|
+
container
|
|
705
|
+
);
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The handle for an enhanced drawer, or null.
|
|
711
|
+
*
|
|
712
|
+
* @param {Element} container
|
|
713
|
+
* @returns {object|null}
|
|
714
|
+
*/
|
|
715
|
+
export function mgDrawerInstance(container) {
|
|
716
|
+
return registry.instances.get(container) || null;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Opens one enhanced drawer. @param {Element} container */
|
|
720
|
+
export const mgDrawerOpen = container =>
|
|
721
|
+
registry.instances.get(container)?.open();
|
|
722
|
+
|
|
723
|
+
/** Closes one enhanced drawer. @param {Element} container */
|
|
724
|
+
export const mgDrawerClose = container =>
|
|
725
|
+
registry.instances.get(container)?.close();
|
|
726
|
+
|
|
727
|
+
/** Toggles one enhanced drawer. @param {Element} container */
|
|
728
|
+
export const mgDrawerToggle = container =>
|
|
729
|
+
registry.instances.get(container)?.toggle();
|
|
730
|
+
|
|
731
|
+
function autoInit() {
|
|
732
|
+
mgDrawer(
|
|
733
|
+
collect(document, DRAWER_SELECTOR).filter(
|
|
734
|
+
container => !container.hasAttribute(SKIP_ATTR)
|
|
735
|
+
)
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Expose for manual re-init after a route change, matching mgShowMore and
|
|
740
|
+
// mgPreviewAccess.
|
|
741
|
+
if (typeof window !== 'undefined') {
|
|
742
|
+
window.mgDrawer = mgDrawer;
|
|
743
|
+
window.mgDrawerDestroy = mgDrawerDestroy;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (typeof document !== 'undefined') {
|
|
747
|
+
if (document.readyState === 'loading') {
|
|
748
|
+
document.addEventListener('DOMContentLoaded', autoInit, { once: true });
|
|
749
|
+
} else {
|
|
750
|
+
autoInit();
|
|
751
|
+
}
|
|
752
|
+
}
|