@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.
Files changed (124) hide show
  1. package/README.md +11 -6
  2. package/components/BookCard.js +1 -1
  3. package/components/Breadcrumbs.js +2 -2
  4. package/components/Checkbox.js +1 -1
  5. package/components/Chips.js +1 -1
  6. package/components/CopyButton.js +2 -2
  7. package/components/CtaButton.js +1 -1
  8. package/components/Drawer.js +1 -1
  9. package/components/EmbedContainer.js +1 -1
  10. package/components/Footer.js +1 -1
  11. package/components/FormAction.js +1 -1
  12. package/components/FormErrorSummary.js +1 -1
  13. package/components/FormGroup.js +1 -1
  14. package/components/FullWidth.js +1 -1
  15. package/components/Gallery.js +2 -2
  16. package/components/Hero.js +2 -2
  17. package/components/HighlightBox.js +1 -1
  18. package/components/HorizontalBookCard.js +1 -1
  19. package/components/HorizontalCard.js +1 -1
  20. package/components/IconCard.js +1 -1
  21. package/components/Legend.js +1 -1
  22. package/components/Loader.js +1 -1
  23. package/components/MegaMenu.js +2 -2
  24. package/components/Notice.js +2 -2
  25. package/components/PageHeader.js +2 -2
  26. package/components/Pager.js +2 -2
  27. package/components/QuoteHighlight.js +2 -2
  28. package/components/QuoteHighlight.js.LICENSE.txt +2 -0
  29. package/components/Radio.js +1 -1
  30. package/components/Range.js +1 -1
  31. package/components/ScrollContainer.js +2 -2
  32. package/components/SegmentedControl.js +9 -0
  33. package/components/SegmentedControl.js.LICENSE.txt +9 -0
  34. package/components/Select.js +1 -1
  35. package/components/ServiceNotice.js +2 -2
  36. package/components/ShareButtons.js +2 -2
  37. package/components/ShowMore.js +2 -2
  38. package/components/SkipLink.js +1 -1
  39. package/components/StatsCard.js +1 -1
  40. package/components/SyndicationSearchWidget.js +2 -2
  41. package/components/Tab.js +2 -2
  42. package/components/TextCta.js +1 -1
  43. package/components/TextInput.js +1 -1
  44. package/components/Textarea.js +1 -1
  45. package/components/Tree.js +1 -1
  46. package/components/UserFeedback.js +1 -1
  47. package/components/VerticalCard.js +1 -1
  48. package/components/hydrate.js +2 -2
  49. package/css/style-all.css +60 -40
  50. package/css/style-all.css.map +1 -1
  51. package/css/style-delta.css +59 -39
  52. package/css/style-delta.css.map +1 -1
  53. package/css/style-gutenberg.css +16 -7
  54. package/css/style-gutenberg.css.map +1 -1
  55. package/css/style-irp.css +60 -40
  56. package/css/style-irp.css.map +1 -1
  57. package/css/style-mcr.css +59 -39
  58. package/css/style-mcr.css.map +1 -1
  59. package/css/style-preventionweb.css +60 -40
  60. package/css/style-preventionweb.css.map +1 -1
  61. package/css/style.css +59 -39
  62. package/css/style.css.map +1 -1
  63. package/error-pages/401.html +3 -3
  64. package/error-pages/403.html +3 -3
  65. package/error-pages/404.html +4 -4
  66. package/error-pages/429.html +3 -3
  67. package/error-pages/500.html +6 -6
  68. package/error-pages/502.html +3 -3
  69. package/error-pages/503.html +3 -3
  70. package/error-pages/504.html +3 -3
  71. package/error-pages/5xx.html +4 -4
  72. package/error-pages/challenge.html +3 -3
  73. package/error-pages/managed-challenge.html +3 -3
  74. package/js/README.md +31 -25
  75. package/js/copy-button.js +17 -0
  76. package/js/drawer.js +752 -0
  77. package/js/hub-header.js +217 -0
  78. package/js/show-more.js +144 -10
  79. package/js/switch-pending.js +195 -46
  80. package/package.json +8 -3
  81. package/scss/Atom/BaseTypography/Blockquote/blockquote.scss +2 -1
  82. package/scss/Atom/BaseTypography/Cite/cite.scss +2 -1
  83. package/scss/Atom/BaseTypography/Code/code.scss +8 -2
  84. package/scss/Atom/Icons/icons.scss +1 -1
  85. package/scss/Atom/Images/AuthorImage/author-image.scss +16 -1
  86. package/scss/Atom/StatusLabel/status-label.scss +331 -8
  87. package/scss/Atom/Table/table.scss +8 -3
  88. package/scss/Atom/Tag/tag.scss +20 -2
  89. package/scss/Components/Breadcrumbs/breadcrumbs.scss +8 -4
  90. package/scss/Components/Buttons/CtaButton/cta-button.scss +15 -0
  91. package/scss/Components/Cards/Card/card.scss +37 -10
  92. package/scss/Components/Cards/StatsCard/stats-card.scss +2 -10
  93. package/scss/Components/DataViz/Legend/legend.scss +2 -1
  94. package/scss/Components/ErrorPages/error-pages.scss +8 -0
  95. package/scss/Components/Forms/SegmentedControl/segmented-control.scss +412 -0
  96. package/scss/Components/Forms/_form-base.scss +109 -18
  97. package/scss/Components/Hero/hero.scss +296 -57
  98. package/scss/Components/MegaMenu/mega-menu.scss +26 -6
  99. package/scss/Components/OnThisPageNav/on-this-page-nav.scss +3 -1
  100. package/scss/Components/PageHeader/page-header.scss +13 -28
  101. package/scss/Components/Pager/pager.scss +3 -1
  102. package/scss/Components/QuoteHighlight/quote-highlight.scss +6 -1
  103. package/scss/Components/ScrollContainer/scroll-container.scss +11 -0
  104. package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +51 -24
  105. package/scss/Components/Tab/tab.scss +1 -1
  106. package/scss/Components/TextCta/text-cta.scss +11 -3
  107. package/scss/Patterns/HubHeader/hub-header.scss +14 -3
  108. package/scss/Utilities/FullWidth/full-width.scss +14 -1
  109. package/scss/assets/scss/_components.scss +1 -0
  110. package/scss/assets/scss/_fonts.scss +11 -0
  111. package/scss/assets/scss/_foundational.scss +7 -0
  112. package/scss/assets/scss/_mixins.scss +32 -0
  113. package/scss/assets/scss/_tokens-data-viz.scss +9 -0
  114. package/scss/assets/scss/_variables.scss +16 -3
  115. package/scss/assets/scss/generated/_tokens-irp.scss +20 -4
  116. package/scss/assets/scss/generated/_tokens-mangrove.scss +6 -0
  117. package/scss/assets/scss/generated/_tokens-preventionweb.scss +20 -4
  118. package/js/__tests__/copy-button.test.js +0 -112
  119. package/js/__tests__/show-more.test.js +0 -80
  120. package/js/__tests__/table-of-contents.test.js +0 -196
  121. package/scss/Components/SyndicationSearchWidget/SyndicationSearchWidget.scss +0 -11
  122. package/scss/Components/TableOfContents/TableOfContents.scss +0 -11
  123. package/scss/Utilities/FullWidth/FullWidth.scss +0 -11
  124. 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
+ }