@undrr/undrr-mangrove 1.3.3 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +33 -29
  2. package/components/BarChart.js +2 -2
  3. package/components/BookCard.js +6 -0
  4. package/components/BookCard.js.LICENSE.txt +11 -0
  5. package/components/Breadcrumbs.js +6 -0
  6. package/components/Breadcrumbs.js.LICENSE.txt +9 -0
  7. package/components/Checkbox.js +6 -0
  8. package/components/Checkbox.js.LICENSE.txt +9 -0
  9. package/components/Chips.js +6 -0
  10. package/components/Chips.js.LICENSE.txt +9 -0
  11. package/components/CtaButton.js +6 -0
  12. package/components/CtaButton.js.LICENSE.txt +9 -0
  13. package/components/EmbedContainer.js +6 -0
  14. package/components/EmbedContainer.js.LICENSE.txt +9 -0
  15. package/components/Fetcher.js +2 -2
  16. package/components/Footer.js +6 -0
  17. package/components/Footer.js.LICENSE.txt +9 -0
  18. package/components/FormErrorSummary.js +6 -0
  19. package/components/FormErrorSummary.js.LICENSE.txt +9 -0
  20. package/components/FormGroup.js +6 -0
  21. package/components/FormGroup.js.LICENSE.txt +9 -0
  22. package/components/FullWidth.js +6 -0
  23. package/components/FullWidth.js.LICENSE.txt +9 -0
  24. package/components/Gallery.js +1 -1
  25. package/components/Hero.js +6 -0
  26. package/components/Hero.js.LICENSE.txt +9 -0
  27. package/components/HighlightBox.js +6 -0
  28. package/components/HighlightBox.js.LICENSE.txt +9 -0
  29. package/components/HorizontalBookCard.js +6 -0
  30. package/components/HorizontalBookCard.js.LICENSE.txt +11 -0
  31. package/components/HorizontalCard.js +6 -0
  32. package/components/HorizontalCard.js.LICENSE.txt +11 -0
  33. package/components/IconCard.js +2 -2
  34. package/components/Loader.js +6 -0
  35. package/components/Loader.js.LICENSE.txt +9 -0
  36. package/components/MapComponent.js +2 -2
  37. package/components/MegaMenu.js +2 -2
  38. package/components/PageHeader.js +6 -0
  39. package/components/PageHeader.js.LICENSE.txt +9 -0
  40. package/components/Pager.js +2 -2
  41. package/components/QuoteHighlight.js +1 -1
  42. package/components/Radio.js +6 -0
  43. package/components/Radio.js.LICENSE.txt +9 -0
  44. package/components/ScrollContainer.js +1 -1
  45. package/components/Select.js +6 -0
  46. package/components/Select.js.LICENSE.txt +9 -0
  47. package/components/ShareButtons.js +1 -1
  48. package/components/ShowMore.js +6 -0
  49. package/components/ShowMore.js.LICENSE.txt +9 -0
  50. package/components/StatsCard.js +2 -2
  51. package/components/SyndicationSearchWidget.js +2 -2
  52. package/components/Tab.js +6 -0
  53. package/components/Tab.js.LICENSE.txt +9 -0
  54. package/components/TextCta.js +6 -0
  55. package/components/TextCta.js.LICENSE.txt +11 -0
  56. package/components/TextInput.js +6 -0
  57. package/components/TextInput.js.LICENSE.txt +9 -0
  58. package/components/Textarea.js +6 -0
  59. package/components/Textarea.js.LICENSE.txt +9 -0
  60. package/components/VerticalCard.js +6 -0
  61. package/components/VerticalCard.js.LICENSE.txt +11 -0
  62. package/components/hydrate.js +1 -1
  63. package/css/style-delta.css +467 -0
  64. package/css/style-delta.css.map +1 -0
  65. package/css/style-gutenberg.css +21 -18
  66. package/css/style-gutenberg.css.map +1 -1
  67. package/css/style-irp-legacy.css +472 -0
  68. package/css/style-irp-legacy.css.map +1 -0
  69. package/css/style-irp.css +99 -103
  70. package/css/style-irp.css.map +1 -1
  71. package/css/style-legacy.css +465 -0
  72. package/css/style-legacy.css.map +1 -0
  73. package/css/style-mcr-legacy.css +472 -0
  74. package/css/style-mcr-legacy.css.map +1 -0
  75. package/css/style-mcr.css +99 -103
  76. package/css/style-mcr.css.map +1 -1
  77. package/css/style-preventionweb-legacy.css +472 -0
  78. package/css/style-preventionweb-legacy.css.map +1 -0
  79. package/css/style-preventionweb.css +99 -103
  80. package/css/style-preventionweb.css.map +1 -1
  81. package/css/style.css +99 -103
  82. package/css/style.css.map +1 -1
  83. package/error-pages/401.html +10 -11
  84. package/error-pages/403.html +11 -12
  85. package/error-pages/404.html +13 -14
  86. package/error-pages/429.html +12 -13
  87. package/error-pages/500.html +10 -11
  88. package/error-pages/502.html +12 -13
  89. package/error-pages/503.html +12 -13
  90. package/error-pages/504.html +10 -11
  91. package/error-pages/5xx.html +10 -11
  92. package/error-pages/challenge.html +12 -13
  93. package/error-pages/managed-challenge.html +12 -13
  94. package/js/README.md +57 -0
  95. package/js/__tests__/show-more.test.js +76 -0
  96. package/js/__tests__/table-of-contents.test.js +198 -0
  97. package/js/on-this-page-nav.js +531 -0
  98. package/js/show-more.js +24 -3
  99. package/js/table-of-contents.js +168 -0
  100. package/js/tabs.js +445 -83
  101. package/package.json +1 -1
  102. package/scss/Atom/BaseTypography/Blockquote/blockquote.scss +1 -8
  103. package/scss/Atom/BaseTypography/Cite/cite.scss +2 -2
  104. package/scss/Atom/Images/AuthorImage/author-image.scss +4 -4
  105. package/scss/Atom/Images/ImageCaptionCredit/image-caption-credit.scss +24 -28
  106. package/scss/Atom/Images/ImageCredit/image-credit.scss +1 -1
  107. package/scss/Atom/Layout/Container/container.scss +2 -2
  108. package/scss/Atom/Layout/Grid/grid.scss +1 -1
  109. package/scss/Atom/ReachElement/Details/details.scss +6 -6
  110. package/scss/Atom/ReachElement/Figcaption/figcaption.scss +1 -1
  111. package/scss/Atom/Table/table.scss +0 -8
  112. package/scss/Components/Boilerplate/boilerplate.scss +2 -2
  113. package/scss/Components/Breadcrumbs/breadcrumbs.scss +2 -9
  114. package/scss/Components/Buttons/Chips/chips.scss +5 -19
  115. package/scss/Components/Buttons/CtaButton/buttons.scss +3 -124
  116. package/scss/Components/Buttons/CtaButton/cta-button.scss +99 -0
  117. package/scss/Components/Buttons/ShareButtons/share-buttons.scss +2 -2
  118. package/scss/Components/Cards/Card/card.scss +45 -6
  119. package/scss/Components/Cards/StatsCard/stats-card.scss +2 -2
  120. package/scss/Components/ErrorPages/error-pages.scss +12 -12
  121. package/scss/Components/Footer/footer.scss +72 -4
  122. package/scss/Components/Forms/Select/select.scss +2 -2
  123. package/scss/Components/Forms/_form-base.scss +5 -5
  124. package/scss/Components/Forms/_form-legacy.scss +1 -1
  125. package/scss/Components/Gallery/gallery.scss +7 -7
  126. package/scss/Components/Hero/hero.scss +43 -23
  127. package/scss/Components/HighlightBox/highlight-box.scss +5 -5
  128. package/scss/Components/MegaMenu/mega-menu.scss +750 -0
  129. package/scss/Components/MegaMenu/megamenu.scss +3 -733
  130. package/scss/Components/OnThisPageNav/on-this-page-nav.scss +206 -0
  131. package/scss/Components/PageHeader/page-header.scss +5 -5
  132. package/scss/Components/Pagination/pagination.scss +2 -2
  133. package/scss/Components/ScrollContainer/scroll-container.scss +1 -1
  134. package/scss/Components/Snackbar/snackbar.scss +1 -1
  135. package/scss/Components/SyndicationSearchWidget/SyndicationSearchWidget.scss +3 -1480
  136. package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +1515 -0
  137. package/scss/Components/Tab/tab.scss +68 -9
  138. package/scss/Components/TableOfContents/TableOfContents.scss +3 -17
  139. package/scss/Components/TableOfContents/table-of-contents.scss +17 -0
  140. package/scss/Components/TextCta/text-cta.scss +139 -0
  141. package/scss/Components/TextCta/textcta.scss +3 -27
  142. package/scss/Molecules/ImageCaption/image-caption.scss +6 -16
  143. package/scss/Molecules/SectionHeader/section-header.scss +8 -0
  144. package/scss/Molecules/SectionHeader/sectionheader.scss +3 -8
  145. package/scss/Utilities/FullWidth/FullWidth.scss +3 -23
  146. package/scss/Utilities/FullWidth/full-width.scss +23 -0
  147. package/scss/Utilities/Loader/loader.scss +1 -1
  148. package/scss/Utilities/ShowMore/ShowMore.scss +3 -26
  149. package/scss/Utilities/ShowMore/show-more.scss +26 -0
  150. package/scss/assets/scss/_components.scss +15 -9
  151. package/scss/assets/scss/_foundational.scss +13 -7
  152. package/scss/assets/scss/_mixins.scss +9 -314
  153. package/scss/assets/scss/_utility.scss +19 -71
  154. package/scss/assets/scss/_variables-delta.scss +110 -0
  155. package/scss/assets/scss/_variables-irp.scss +1 -1
  156. package/scss/assets/scss/_variables-mcr.scss +1 -1
  157. package/scss/assets/scss/_variables-preventionweb.scss +1 -1
  158. package/scss/assets/scss/_variables.scss +101 -34
  159. package/scss/assets/scss/style-delta.scss +8 -0
  160. package/scss/assets/scss/style-gutenberg.scss +2 -2
  161. package/scss/assets/scss/style-irp-legacy.scss +20 -0
  162. package/scss/assets/scss/style-legacy.scss +20 -0
  163. package/scss/assets/scss/style-mcr-legacy.scss +20 -0
  164. package/scss/assets/scss/style-preventionweb-legacy.scss +20 -0
  165. package/scss/Components/BlockquoteComponent/blockquotecomp.scss +0 -31
  166. package/scss/Components/Buttons/CtaLink/cta-link.scss +0 -61
@@ -0,0 +1,531 @@
1
+ // mg-on-this-page-nav
2
+ // https://gitlab.com/undrr/web-backlog/-/issues/337
3
+ //
4
+ // Sticky horizontal "On this page" navigation with scroll-spy.
5
+ // Two modes:
6
+ // Auto-detect: scans headings from the page and generates nav links
7
+ // Explicit: uses author-provided <ul> with <a> elements
8
+ //
9
+ // Scroll-spy uses IntersectionObserver. CSS-only scroll-spy
10
+ // (scroll-target-group + :target-current) is emerging in CSS Overflow L5
11
+ // but lacks aria-current support and cross-browser coverage - revisit when
12
+ // browsers ship native accessibility semantics for :target-current.
13
+
14
+ const CHEVRON_LEFT_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false"><path d="M11 4L6 8l5 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
15
+ const CHEVRON_RIGHT_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false"><path d="M5 4l5 4-5 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
16
+
17
+ /**
18
+ * Read the offset CSS custom property from the nav element.
19
+ * Returns the pixel value (number). Falls back to 0.
20
+ *
21
+ * @param {HTMLElement} container - The nav element
22
+ * @returns {number} Offset in pixels
23
+ */
24
+ function getOffset(container) {
25
+ const raw = getComputedStyle(container)
26
+ .getPropertyValue('--mg-on-this-page-nav-offset')
27
+ .trim();
28
+ return parseFloat(raw) || 0;
29
+ }
30
+
31
+ /**
32
+ * @returns {boolean} Whether the user currently prefers reduced motion.
33
+ */
34
+ function prefersReducedMotion() {
35
+ return (
36
+ typeof window.matchMedia === 'function' &&
37
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Horizontally scroll a link to the center of its list container.
43
+ *
44
+ * @param {HTMLElement} link - The link element to center
45
+ * @param {HTMLElement} list - The scrollable list container
46
+ */
47
+ function scrollLinkToCenter(link, list) {
48
+ if (!list.scrollTo) return;
49
+ const scrollTarget =
50
+ link.offsetLeft - list.offsetWidth / 2 + link.offsetWidth / 2;
51
+ list.scrollTo({
52
+ left: scrollTarget,
53
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Initializes "On this page" sticky navigation bars.
59
+ *
60
+ * @param {NodeList|HTMLElement[]|HTMLElement} [scope] - Elements to init.
61
+ * Accepts a NodeList, array, or a single HTMLElement.
62
+ * Defaults to all [data-mg-on-this-page-nav] in the document.
63
+ */
64
+ export function mgOnThisPageNav(scope) {
65
+ const containers = scope
66
+ ? (scope instanceof HTMLElement ? [scope] : scope)
67
+ : document.querySelectorAll('[data-mg-on-this-page-nav]');
68
+
69
+ containers.forEach(container => {
70
+ // Skip auto-init if the element opts out
71
+ if (!scope && container.hasAttribute('data-mg-on-this-page-nav-skip-auto-init')) return;
72
+ if (container.dataset.mgOnThisPageNavInitialized) return;
73
+ container.dataset.mgOnThisPageNavInitialized = 'true';
74
+
75
+ // Ensure the nav has an aria-label
76
+ if (!container.getAttribute('aria-label')) {
77
+ container.setAttribute(
78
+ 'aria-label',
79
+ container.dataset.mgOnThisPageNavLabel || 'On this page'
80
+ );
81
+ }
82
+
83
+ // Detect mode: explicit if container already has a <ul>, otherwise auto-detect
84
+ const existingList = container.querySelector('ul');
85
+ if (!existingList) {
86
+ buildNavFromHeadings(container);
87
+ }
88
+
89
+ // Skip setup if no headings were found and nav is hidden
90
+ if (container.classList.contains('mg-on-this-page-nav--hidden')) return;
91
+
92
+ // Guard against VoiceOver + list-style:none bug
93
+ const list = container.querySelector('.mg-on-this-page-nav__list');
94
+ if (list && !list.getAttribute('role')) {
95
+ list.setAttribute('role', 'list');
96
+ }
97
+
98
+ const ac = new AbortController();
99
+ container._mgOnThisPageNavAbort = ac;
100
+
101
+ setupClickHandlers(container, ac.signal);
102
+ setupScrollSpy(container);
103
+ setupFocusScrolling(container, ac.signal);
104
+ setupScrollButtons(container, ac.signal);
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Disconnects the observer, removes event listeners, and clears the
110
+ * initialized flag so the component can be re-initialized or garbage-collected.
111
+ *
112
+ * @param {HTMLElement} container - The nav element to tear down
113
+ */
114
+ export function mgOnThisPageNavDestroy(container) {
115
+ if (container._mgOnThisPageNavAbort) {
116
+ container._mgOnThisPageNavAbort.abort();
117
+ delete container._mgOnThisPageNavAbort;
118
+ }
119
+ if (container._mgOnThisPageNavObserver) {
120
+ container._mgOnThisPageNavObserver.disconnect();
121
+ delete container._mgOnThisPageNavObserver;
122
+ }
123
+ if (container._mgOnThisPageNavResizeObserver) {
124
+ container._mgOnThisPageNavResizeObserver.disconnect();
125
+ delete container._mgOnThisPageNavResizeObserver;
126
+ }
127
+ container.querySelectorAll('.mg-on-this-page-nav__scroll-btn').forEach(btn => btn.remove());
128
+ container.classList.remove('mg-on-this-page-nav--has-left-overflow');
129
+ delete container.dataset.mgOnThisPageNavInitialized;
130
+ }
131
+
132
+ /**
133
+ * Auto-detect mode: scan headings from the page and generate nav links.
134
+ * Preserves any existing CTA element in the container.
135
+ *
136
+ * @param {HTMLElement} container - The nav element with [data-mg-on-this-page-nav]
137
+ */
138
+ function buildNavFromHeadings(container) {
139
+ const depth = parseInt(container.dataset.mgOnThisPageNavDepth || '2', 10);
140
+ const contentSelector = container.dataset.mgOnThisPageNavContent;
141
+ const contentScope = contentSelector
142
+ ? document.querySelector(contentSelector)
143
+ : document.body;
144
+
145
+ if (!contentScope) {
146
+ container.classList.add('mg-on-this-page-nav--hidden');
147
+ return;
148
+ }
149
+
150
+ const levels = [];
151
+ for (let i = 2; i <= Math.min(depth, 6); i++) {
152
+ levels.push(`h${i}`);
153
+ }
154
+
155
+ const headings = contentScope.querySelectorAll(levels.join(', '));
156
+
157
+ // Filter out excluded headings and the TableOfContents "On this page" header
158
+ const filteredHeadings = Array.from(headings).filter(heading => {
159
+ if (heading.classList.contains('mg-on-this-page-nav--exclude')) return false;
160
+ if (heading.classList.contains('mg-u-sr-only')) return false;
161
+ if (heading.id === 'on-this-page') return false;
162
+ return true;
163
+ });
164
+
165
+ if (filteredHeadings.length === 0) {
166
+ container.classList.add('mg-on-this-page-nav--hidden');
167
+ return;
168
+ }
169
+
170
+ // Preserve any existing CTA element before rebuilding
171
+ const cta = container.querySelector('.mg-on-this-page-nav__cta');
172
+ const ctaClone = cta ? cta.cloneNode(true) : null;
173
+
174
+ const ul = document.createElement('ul');
175
+ ul.className = 'mg-on-this-page-nav__list';
176
+ ul.setAttribute('role', 'list');
177
+
178
+ const usedIds = new Set();
179
+
180
+ filteredHeadings.forEach(heading => {
181
+ if (!heading.id) {
182
+ heading.id = generateId(heading.textContent, usedIds);
183
+ }
184
+ usedIds.add(heading.id);
185
+
186
+ const li = document.createElement('li');
187
+ li.className = 'mg-on-this-page-nav__item';
188
+
189
+ const a = document.createElement('a');
190
+ a.className = 'mg-on-this-page-nav__link';
191
+ a.href = `#${heading.id}`;
192
+ a.textContent = heading.textContent.trim();
193
+
194
+ li.appendChild(a);
195
+ ul.appendChild(li);
196
+ });
197
+
198
+ container.innerHTML = '';
199
+ container.appendChild(ul);
200
+
201
+ if (ctaClone) {
202
+ container.appendChild(ctaClone);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Generate a URL-safe ID from text, ensuring uniqueness.
208
+ * Uses Unicode-aware regex so Arabic, Japanese, and other non-Latin
209
+ * heading text produces meaningful IDs instead of falling back to "section".
210
+ *
211
+ * @param {string} text - Heading text to slugify
212
+ * @param {Set<string>} usedIds - Set of IDs already in use
213
+ * @returns {string} A unique ID
214
+ */
215
+ function generateId(text, usedIds) {
216
+ let base = text
217
+ .trim()
218
+ .toLowerCase()
219
+ .replace(/[\p{P}\p{S}]/gu, '')
220
+ .replace(/\s+/g, '-')
221
+ .replace(/^-+|-+$/g, '');
222
+
223
+ if (!base) base = 'section';
224
+
225
+ let id = base;
226
+ let counter = 2;
227
+ while (usedIds.has(id) || document.getElementById(id)) {
228
+ id = `${base}-${counter}`;
229
+ counter++;
230
+ }
231
+
232
+ return id;
233
+ }
234
+
235
+ /**
236
+ * Set up click handlers for smooth scrolling and hash updates.
237
+ *
238
+ * @param {HTMLElement} container - The nav element
239
+ * @param {AbortSignal} signal - Signal to remove listener on destroy
240
+ */
241
+ function setupClickHandlers(container, signal) {
242
+ container.addEventListener('click', e => {
243
+ const link = e.target.closest('.mg-on-this-page-nav__link');
244
+ if (!link) return;
245
+
246
+ const hash = link.getAttribute('href');
247
+ if (!hash || !hash.startsWith('#')) return;
248
+
249
+ const targetId = hash.slice(1);
250
+ const target = document.getElementById(targetId);
251
+ if (!target) return;
252
+
253
+ e.preventDefault();
254
+
255
+ // Read offset per click — the custom property may change at breakpoints
256
+ const offset = getOffset(container);
257
+ const navHeight = container.offsetHeight || 0;
258
+ const top =
259
+ target.getBoundingClientRect().top + window.scrollY - offset - navHeight;
260
+
261
+ window.scrollTo({
262
+ top,
263
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
264
+ });
265
+
266
+ // Update URL hash without triggering scroll
267
+ if (window.history && window.history.pushState) {
268
+ window.history.pushState(null, null, `#${targetId}`);
269
+ }
270
+
271
+ // Move focus to target for keyboard users; remove tabindex on blur
272
+ target.setAttribute('tabindex', '-1');
273
+ target.focus({ preventScroll: true });
274
+ target.addEventListener(
275
+ 'blur',
276
+ () => target.removeAttribute('tabindex'),
277
+ { once: true }
278
+ );
279
+ }, { signal });
280
+ }
281
+
282
+ /**
283
+ * Update scroll button visibility based on the current scroll position of
284
+ * the list. Also manages keyboard focus if a button is hidden while focused,
285
+ * and toggles the left-overflow modifier class so the SCSS can add a
286
+ * left-side gradient mask when content is off-screen to the left.
287
+ *
288
+ * RTL: browsers report scrollLeft as 0 at the right edge (visual start) and
289
+ * decreasing (negative) as the user scrolls left. Math.abs(scrollLeft)
290
+ * normalises "distance from visual start" across both directions.
291
+ *
292
+ * Accepts pre-resolved element references so callers on the scroll/resize hot
293
+ * path avoid repeated querySelector lookups on every event.
294
+ *
295
+ * @param {HTMLElement} container - The nav element
296
+ * @param {HTMLElement} list - The scrollable list element
297
+ * @param {HTMLButtonElement} prevBtn - The previous/start scroll button
298
+ * @param {HTMLButtonElement} nextBtn - The next/end scroll button
299
+ */
300
+ function updateScrollButtons(container, list, prevBtn, nextBtn) {
301
+ const sl = list.scrollLeft;
302
+ const maxScroll = list.scrollWidth - list.clientWidth;
303
+ const rtl = getComputedStyle(list).direction === 'rtl';
304
+
305
+ // distFromStart: absolute distance scrolled from the visual start edge.
306
+ // The asymmetric thresholds (> 1 vs < maxScroll - 1) absorb sub-pixel
307
+ // rounding that can prevent scrollLeft from reaching exactly 0 or maxScroll.
308
+ const distFromStart = Math.abs(sl);
309
+ const scrolledFromStart = distFromStart > 1;
310
+ const canScrollFurther = maxScroll > 1 && distFromStart < maxScroll - 1;
311
+
312
+ // prevBtn lives at the visual-start edge (left in LTR, right in RTL via flex).
313
+ // It is shown whenever the user can scroll back toward that edge.
314
+ if (!scrolledFromStart && prevBtn === document.activeElement) {
315
+ const firstLink = list.querySelector('.mg-on-this-page-nav__link');
316
+ if (firstLink) firstLink.focus();
317
+ }
318
+ prevBtn.hidden = !scrolledFromStart;
319
+
320
+ // nextBtn lives at the visual-end edge (right in LTR, left in RTL via flex).
321
+ // It is shown whenever more content exists in the scroll-forward direction.
322
+ // When it hides while focused, prefer the CTA (the DOM-adjacent element after
323
+ // nextBtn) over the last list link, so focus follows natural tab order.
324
+ if (!canScrollFurther && nextBtn === document.activeElement) {
325
+ const cta = container.querySelector('.mg-on-this-page-nav__cta');
326
+ const links = list.querySelectorAll('.mg-on-this-page-nav__link');
327
+ const lastLink = links.length ? links[links.length - 1] : null;
328
+ const fallback = cta || lastLink;
329
+ if (fallback) fallback.focus();
330
+ }
331
+ nextBtn.hidden = !canScrollFurther;
332
+
333
+ // has-left-overflow drives the left-edge gradient in SCSS.
334
+ // Content is physically off-screen to the left when:
335
+ // LTR: the user has scrolled right past the start (scrolledFromStart)
336
+ // RTL: more visual-end content sits to the left (canScrollFurther)
337
+ container.classList.toggle(
338
+ 'mg-on-this-page-nav--has-left-overflow',
339
+ rtl ? canScrollFurther : scrolledFromStart
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Inject prev/next scroll buttons and wire scroll and resize listeners.
345
+ * Buttons start hidden and become visible only when the list overflows in
346
+ * the corresponding direction.
347
+ *
348
+ * @param {HTMLElement} container - The nav element
349
+ * @param {AbortSignal} signal - Signal to remove listeners on destroy
350
+ */
351
+ function setupScrollButtons(container, signal) {
352
+ const list = container.querySelector('.mg-on-this-page-nav__list');
353
+ if (!list) return;
354
+
355
+ // Disconnect any pre-existing observer before creating a new one (defensive
356
+ // guard in case setupScrollButtons is ever called twice on the same container).
357
+ if (container._mgOnThisPageNavResizeObserver) {
358
+ container._mgOnThisPageNavResizeObserver.disconnect();
359
+ delete container._mgOnThisPageNavResizeObserver;
360
+ }
361
+
362
+ // Remove any previously injected buttons so re-init never duplicates them
363
+ container.querySelectorAll('.mg-on-this-page-nav__scroll-btn').forEach(btn => btn.remove());
364
+
365
+ const prevBtn = document.createElement('button');
366
+ prevBtn.type = 'button';
367
+ prevBtn.className = 'mg-on-this-page-nav__scroll-btn mg-on-this-page-nav__scroll-btn--prev';
368
+ // Direction-neutral labels avoid confusion for RTL screen reader users
369
+ // ("left"/"right" are ambiguous when the reading direction is reversed).
370
+ // Override via data-mg-on-this-page-nav-scroll-prev-label for translated text.
371
+ prevBtn.setAttribute(
372
+ 'aria-label',
373
+ container.dataset.mgOnThisPageNavScrollPrevLabel || 'Previous navigation items'
374
+ );
375
+ prevBtn.hidden = true;
376
+ prevBtn.innerHTML = CHEVRON_LEFT_SVG;
377
+
378
+ const nextBtn = document.createElement('button');
379
+ nextBtn.type = 'button';
380
+ nextBtn.className = 'mg-on-this-page-nav__scroll-btn mg-on-this-page-nav__scroll-btn--next';
381
+ // Override via data-mg-on-this-page-nav-scroll-next-label for translated text.
382
+ nextBtn.setAttribute(
383
+ 'aria-label',
384
+ container.dataset.mgOnThisPageNavScrollNextLabel || 'Next navigation items'
385
+ );
386
+ nextBtn.hidden = true;
387
+ nextBtn.innerHTML = CHEVRON_RIGHT_SVG;
388
+
389
+ list.insertAdjacentElement('beforebegin', prevBtn);
390
+ list.insertAdjacentElement('afterend', nextBtn);
391
+
392
+ // prevBtn scrolls toward the visual start: left in LTR (-), right in RTL (+).
393
+ prevBtn.addEventListener('click', () => {
394
+ const isRTL = getComputedStyle(list).direction === 'rtl';
395
+ list.scrollBy({
396
+ left: (isRTL ? 1 : -1) * (list.offsetWidth * 0.5),
397
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
398
+ });
399
+ }, { signal });
400
+
401
+ // nextBtn scrolls toward the visual end: right in LTR (+), left in RTL (-).
402
+ nextBtn.addEventListener('click', () => {
403
+ const isRTL = getComputedStyle(list).direction === 'rtl';
404
+ list.scrollBy({
405
+ left: (isRTL ? -1 : 1) * (list.offsetWidth * 0.5),
406
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
407
+ });
408
+ }, { signal });
409
+
410
+ list.addEventListener('scroll', () => updateScrollButtons(container, list, prevBtn, nextBtn), { signal, passive: true });
411
+
412
+ const ro = new ResizeObserver(() => updateScrollButtons(container, list, prevBtn, nextBtn));
413
+ ro.observe(list);
414
+ container._mgOnThisPageNavResizeObserver = ro;
415
+ signal.addEventListener('abort', () => ro.disconnect(), { once: true });
416
+
417
+ updateScrollButtons(container, list, prevBtn, nextBtn);
418
+ }
419
+
420
+ /**
421
+ * Ensure keyboard users can scroll overflowing nav links into view.
422
+ * Browsers may not auto-scroll a hidden-scrollbar container on focus.
423
+ *
424
+ * @param {HTMLElement} container - The nav element
425
+ * @param {AbortSignal} signal - Signal to remove listener on destroy
426
+ */
427
+ function setupFocusScrolling(container, signal) {
428
+ const list = container.querySelector('.mg-on-this-page-nav__list');
429
+ if (!list) return;
430
+
431
+ list.addEventListener('focusin', e => {
432
+ const link = e.target.closest('.mg-on-this-page-nav__link');
433
+ if (link) scrollLinkToCenter(link, list);
434
+ }, { signal });
435
+ }
436
+
437
+ /**
438
+ * Set up IntersectionObserver-based scroll-spy to highlight the active link.
439
+ *
440
+ * @param {HTMLElement} container - The nav element
441
+ */
442
+ function setupScrollSpy(container) {
443
+ const links = container.querySelectorAll('.mg-on-this-page-nav__link');
444
+ const list = container.querySelector('.mg-on-this-page-nav__list');
445
+
446
+ const linkMap = new Map();
447
+ const targets = [];
448
+
449
+ links.forEach(link => {
450
+ const href = link.getAttribute('href');
451
+ if (!href || !href.startsWith('#')) return;
452
+ const id = href.slice(1);
453
+ const target = document.getElementById(id);
454
+ if (!target) return;
455
+ linkMap.set(id, link);
456
+ targets.push(target);
457
+ });
458
+
459
+ if (targets.length === 0) return;
460
+
461
+ let activeId = null;
462
+ // Track which targets are currently intersecting (avoids DOM expando properties)
463
+ const intersecting = new Set();
464
+
465
+ function setActive(id) {
466
+ if (id === activeId) return;
467
+
468
+ if (activeId && linkMap.has(activeId)) {
469
+ const oldLink = linkMap.get(activeId);
470
+ oldLink.classList.remove('mg-on-this-page-nav__link--active');
471
+ oldLink.removeAttribute('aria-current');
472
+ }
473
+
474
+ activeId = id;
475
+
476
+ if (id && linkMap.has(id)) {
477
+ const newLink = linkMap.get(id);
478
+ newLink.classList.add('mg-on-this-page-nav__link--active');
479
+ newLink.setAttribute('aria-current', 'true');
480
+
481
+ // Scroll the nav list horizontally to keep active link visible.
482
+ // ONS user research found independently-scrolling ToC lists confused users;
483
+ // auto-scrolling to the active link avoids that. We use scrollTo on the
484
+ // list (not scrollIntoView on the link) to avoid vertical page scrolling.
485
+ if (list) scrollLinkToCenter(newLink, list);
486
+ }
487
+ }
488
+
489
+ // rootMargin is static after construction — if the offset custom property
490
+ // changes at a breakpoint, the observer will use the value from init time.
491
+ // The click handler re-reads offset per click to stay responsive.
492
+ const offset = getOffset(container);
493
+ const observer = new IntersectionObserver(
494
+ entries => {
495
+ entries.forEach(entry => {
496
+ if (entry.isIntersecting) {
497
+ intersecting.add(entry.target);
498
+ } else {
499
+ intersecting.delete(entry.target);
500
+ }
501
+ });
502
+
503
+ // Pick the first intersecting target in DOM order
504
+ const firstVisible = targets.find(t => intersecting.has(t));
505
+ if (firstVisible) {
506
+ setActive(firstVisible.id);
507
+ }
508
+ },
509
+ {
510
+ rootMargin: `-${offset + (container.offsetHeight || 0)}px 0px -66% 0px`,
511
+ threshold: [0],
512
+ }
513
+ );
514
+
515
+ targets.forEach(target => observer.observe(target));
516
+ container._mgOnThisPageNavObserver = observer;
517
+
518
+ const hashId = window.location.hash ? window.location.hash.slice(1) : null;
519
+ if (hashId && linkMap.has(hashId)) {
520
+ setActive(hashId);
521
+ } else if (targets.length > 0) {
522
+ setActive(targets[0].id);
523
+ }
524
+ }
525
+
526
+ // Auto-wrap so the browser Event object is not passed as scope
527
+ if (document.readyState === 'loading') {
528
+ document.addEventListener('DOMContentLoaded', () => mgOnThisPageNav(), false);
529
+ } else {
530
+ mgOnThisPageNav();
531
+ }
package/js/show-more.js CHANGED
@@ -1,10 +1,21 @@
1
1
  // mg-show-more
2
2
  // https://gitlab.com/undrr/web-backlog/-/issues/1082
3
3
 
4
- export function mgShowMore() {
5
- const mgShowMoreButtons = document.querySelectorAll('[data-mg-show-more]');
4
+ /**
5
+ * Initializes "show more" toggle buttons.
6
+ *
7
+ * @param {NodeList|HTMLElement[]|HTMLElement} [scope] - Elements to init.
8
+ * Accepts a NodeList, array, or a single HTMLElement.
9
+ * Defaults to all [data-mg-show-more] in the document.
10
+ */
11
+ export function mgShowMore(scope) {
12
+ const mgShowMoreButtons = scope
13
+ ? (scope instanceof HTMLElement ? [scope] : scope)
14
+ : document.querySelectorAll('[data-mg-show-more]');
6
15
 
7
16
  mgShowMoreButtons.forEach(item => {
17
+ // Skip auto-init if the element opts out
18
+ if (!scope && item.hasAttribute('data-mg-show-more-skip-auto-init')) return;
8
19
  if (item.dataset.mgShowMoreInitialized) return;
9
20
  item.dataset.mgShowMoreInitialized = 'true';
10
21
 
@@ -15,6 +26,11 @@ export function mgShowMore() {
15
26
  item.dataset.mgShowMoreTarget || '.mg-show-more--container';
16
27
  const mgShowMoreTarget = document.querySelector(mgShowMoreTargetClass);
17
28
 
29
+ if (!mgShowMoreTarget) {
30
+ console.warn(`[mg-show-more] Target not found: "${mgShowMoreTargetClass}"`);
31
+ return;
32
+ }
33
+
18
34
  item.addEventListener('click', event => {
19
35
  event.preventDefault();
20
36
  mgShowMoreTarget.classList.toggle('mg-show-more--collapsed');
@@ -45,4 +61,9 @@ export function mgShowMore() {
45
61
  });
46
62
  }
47
63
 
48
- document.addEventListener('DOMContentLoaded', mgShowMore, false);
64
+ // Auto-wrap so the browser Event object is not passed as scope
65
+ if (document.readyState === 'loading') {
66
+ document.addEventListener('DOMContentLoaded', () => mgShowMore(), false);
67
+ } else {
68
+ mgShowMore();
69
+ }