@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.
- package/README.md +33 -29
- package/components/BarChart.js +2 -2
- package/components/BookCard.js +6 -0
- package/components/BookCard.js.LICENSE.txt +11 -0
- package/components/Breadcrumbs.js +6 -0
- package/components/Breadcrumbs.js.LICENSE.txt +9 -0
- package/components/Checkbox.js +6 -0
- package/components/Checkbox.js.LICENSE.txt +9 -0
- package/components/Chips.js +6 -0
- package/components/Chips.js.LICENSE.txt +9 -0
- package/components/CtaButton.js +6 -0
- package/components/CtaButton.js.LICENSE.txt +9 -0
- package/components/EmbedContainer.js +6 -0
- package/components/EmbedContainer.js.LICENSE.txt +9 -0
- package/components/Fetcher.js +2 -2
- package/components/Footer.js +6 -0
- package/components/Footer.js.LICENSE.txt +9 -0
- package/components/FormErrorSummary.js +6 -0
- package/components/FormErrorSummary.js.LICENSE.txt +9 -0
- package/components/FormGroup.js +6 -0
- package/components/FormGroup.js.LICENSE.txt +9 -0
- package/components/FullWidth.js +6 -0
- package/components/FullWidth.js.LICENSE.txt +9 -0
- package/components/Gallery.js +1 -1
- package/components/Hero.js +6 -0
- package/components/Hero.js.LICENSE.txt +9 -0
- package/components/HighlightBox.js +6 -0
- package/components/HighlightBox.js.LICENSE.txt +9 -0
- package/components/HorizontalBookCard.js +6 -0
- package/components/HorizontalBookCard.js.LICENSE.txt +11 -0
- package/components/HorizontalCard.js +6 -0
- package/components/HorizontalCard.js.LICENSE.txt +11 -0
- package/components/IconCard.js +2 -2
- package/components/Loader.js +6 -0
- package/components/Loader.js.LICENSE.txt +9 -0
- package/components/MapComponent.js +2 -2
- package/components/MegaMenu.js +2 -2
- package/components/PageHeader.js +6 -0
- package/components/PageHeader.js.LICENSE.txt +9 -0
- package/components/Pager.js +2 -2
- package/components/QuoteHighlight.js +1 -1
- package/components/Radio.js +6 -0
- package/components/Radio.js.LICENSE.txt +9 -0
- package/components/ScrollContainer.js +1 -1
- package/components/Select.js +6 -0
- package/components/Select.js.LICENSE.txt +9 -0
- package/components/ShareButtons.js +1 -1
- package/components/ShowMore.js +6 -0
- package/components/ShowMore.js.LICENSE.txt +9 -0
- package/components/StatsCard.js +2 -2
- package/components/SyndicationSearchWidget.js +2 -2
- package/components/Tab.js +6 -0
- package/components/Tab.js.LICENSE.txt +9 -0
- package/components/TextCta.js +6 -0
- package/components/TextCta.js.LICENSE.txt +11 -0
- package/components/TextInput.js +6 -0
- package/components/TextInput.js.LICENSE.txt +9 -0
- package/components/Textarea.js +6 -0
- package/components/Textarea.js.LICENSE.txt +9 -0
- package/components/VerticalCard.js +6 -0
- package/components/VerticalCard.js.LICENSE.txt +11 -0
- package/components/hydrate.js +1 -1
- package/css/style-delta.css +467 -0
- package/css/style-delta.css.map +1 -0
- package/css/style-gutenberg.css +21 -18
- package/css/style-gutenberg.css.map +1 -1
- package/css/style-irp-legacy.css +472 -0
- package/css/style-irp-legacy.css.map +1 -0
- package/css/style-irp.css +99 -103
- package/css/style-irp.css.map +1 -1
- package/css/style-legacy.css +465 -0
- package/css/style-legacy.css.map +1 -0
- package/css/style-mcr-legacy.css +472 -0
- package/css/style-mcr-legacy.css.map +1 -0
- package/css/style-mcr.css +99 -103
- package/css/style-mcr.css.map +1 -1
- package/css/style-preventionweb-legacy.css +472 -0
- package/css/style-preventionweb-legacy.css.map +1 -0
- package/css/style-preventionweb.css +99 -103
- package/css/style-preventionweb.css.map +1 -1
- package/css/style.css +99 -103
- package/css/style.css.map +1 -1
- package/error-pages/401.html +10 -11
- package/error-pages/403.html +11 -12
- package/error-pages/404.html +13 -14
- package/error-pages/429.html +12 -13
- package/error-pages/500.html +10 -11
- package/error-pages/502.html +12 -13
- package/error-pages/503.html +12 -13
- package/error-pages/504.html +10 -11
- package/error-pages/5xx.html +10 -11
- package/error-pages/challenge.html +12 -13
- package/error-pages/managed-challenge.html +12 -13
- package/js/README.md +57 -0
- package/js/__tests__/show-more.test.js +76 -0
- package/js/__tests__/table-of-contents.test.js +198 -0
- package/js/on-this-page-nav.js +531 -0
- package/js/show-more.js +24 -3
- package/js/table-of-contents.js +168 -0
- package/js/tabs.js +445 -83
- package/package.json +1 -1
- package/scss/Atom/BaseTypography/Blockquote/blockquote.scss +1 -8
- package/scss/Atom/BaseTypography/Cite/cite.scss +2 -2
- package/scss/Atom/Images/AuthorImage/author-image.scss +4 -4
- package/scss/Atom/Images/ImageCaptionCredit/image-caption-credit.scss +24 -28
- package/scss/Atom/Images/ImageCredit/image-credit.scss +1 -1
- package/scss/Atom/Layout/Container/container.scss +2 -2
- package/scss/Atom/Layout/Grid/grid.scss +1 -1
- package/scss/Atom/ReachElement/Details/details.scss +6 -6
- package/scss/Atom/ReachElement/Figcaption/figcaption.scss +1 -1
- package/scss/Atom/Table/table.scss +0 -8
- package/scss/Components/Boilerplate/boilerplate.scss +2 -2
- package/scss/Components/Breadcrumbs/breadcrumbs.scss +2 -9
- package/scss/Components/Buttons/Chips/chips.scss +5 -19
- package/scss/Components/Buttons/CtaButton/buttons.scss +3 -124
- package/scss/Components/Buttons/CtaButton/cta-button.scss +99 -0
- package/scss/Components/Buttons/ShareButtons/share-buttons.scss +2 -2
- package/scss/Components/Cards/Card/card.scss +45 -6
- package/scss/Components/Cards/StatsCard/stats-card.scss +2 -2
- package/scss/Components/ErrorPages/error-pages.scss +12 -12
- package/scss/Components/Footer/footer.scss +72 -4
- package/scss/Components/Forms/Select/select.scss +2 -2
- package/scss/Components/Forms/_form-base.scss +5 -5
- package/scss/Components/Forms/_form-legacy.scss +1 -1
- package/scss/Components/Gallery/gallery.scss +7 -7
- package/scss/Components/Hero/hero.scss +43 -23
- package/scss/Components/HighlightBox/highlight-box.scss +5 -5
- package/scss/Components/MegaMenu/mega-menu.scss +750 -0
- package/scss/Components/MegaMenu/megamenu.scss +3 -733
- package/scss/Components/OnThisPageNav/on-this-page-nav.scss +206 -0
- package/scss/Components/PageHeader/page-header.scss +5 -5
- package/scss/Components/Pagination/pagination.scss +2 -2
- package/scss/Components/ScrollContainer/scroll-container.scss +1 -1
- package/scss/Components/Snackbar/snackbar.scss +1 -1
- package/scss/Components/SyndicationSearchWidget/SyndicationSearchWidget.scss +3 -1480
- package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +1515 -0
- package/scss/Components/Tab/tab.scss +68 -9
- package/scss/Components/TableOfContents/TableOfContents.scss +3 -17
- package/scss/Components/TableOfContents/table-of-contents.scss +17 -0
- package/scss/Components/TextCta/text-cta.scss +139 -0
- package/scss/Components/TextCta/textcta.scss +3 -27
- package/scss/Molecules/ImageCaption/image-caption.scss +6 -16
- package/scss/Molecules/SectionHeader/section-header.scss +8 -0
- package/scss/Molecules/SectionHeader/sectionheader.scss +3 -8
- package/scss/Utilities/FullWidth/FullWidth.scss +3 -23
- package/scss/Utilities/FullWidth/full-width.scss +23 -0
- package/scss/Utilities/Loader/loader.scss +1 -1
- package/scss/Utilities/ShowMore/ShowMore.scss +3 -26
- package/scss/Utilities/ShowMore/show-more.scss +26 -0
- package/scss/assets/scss/_components.scss +15 -9
- package/scss/assets/scss/_foundational.scss +13 -7
- package/scss/assets/scss/_mixins.scss +9 -314
- package/scss/assets/scss/_utility.scss +19 -71
- package/scss/assets/scss/_variables-delta.scss +110 -0
- package/scss/assets/scss/_variables-irp.scss +1 -1
- package/scss/assets/scss/_variables-mcr.scss +1 -1
- package/scss/assets/scss/_variables-preventionweb.scss +1 -1
- package/scss/assets/scss/_variables.scss +101 -34
- package/scss/assets/scss/style-delta.scss +8 -0
- package/scss/assets/scss/style-gutenberg.scss +2 -2
- package/scss/assets/scss/style-irp-legacy.scss +20 -0
- package/scss/assets/scss/style-legacy.scss +20 -0
- package/scss/assets/scss/style-mcr-legacy.scss +20 -0
- package/scss/assets/scss/style-preventionweb-legacy.scss +20 -0
- package/scss/Components/BlockquoteComponent/blockquotecomp.scss +0 -31
- 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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
+
}
|