@undrr/undrr-mangrove 2.0.0-rc.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/components/BookCard.js +1 -1
- package/components/Breadcrumbs.js +2 -2
- package/components/Checkbox.js +1 -1
- package/components/Chips.js +1 -1
- package/components/CopyButton.js +2 -2
- package/components/CtaButton.js +1 -1
- package/components/Drawer.js +1 -1
- package/components/EmbedContainer.js +1 -1
- package/components/Footer.js +1 -1
- package/components/FormAction.js +1 -1
- package/components/FormErrorSummary.js +1 -1
- package/components/FormGroup.js +1 -1
- package/components/FullWidth.js +1 -1
- package/components/Gallery.js +2 -2
- package/components/Hero.js +2 -2
- package/components/HighlightBox.js +1 -1
- package/components/HorizontalBookCard.js +1 -1
- package/components/HorizontalCard.js +1 -1
- package/components/IconCard.js +1 -1
- package/components/Legend.js +1 -1
- package/components/Loader.js +1 -1
- package/components/MegaMenu.js +2 -2
- package/components/Notice.js +2 -2
- package/components/PageHeader.js +2 -2
- package/components/Pager.js +2 -2
- package/components/QuoteHighlight.js +2 -2
- package/components/QuoteHighlight.js.LICENSE.txt +2 -0
- package/components/Radio.js +1 -1
- package/components/Range.js +1 -1
- package/components/ScrollContainer.js +2 -2
- package/components/SegmentedControl.js +9 -0
- package/components/SegmentedControl.js.LICENSE.txt +9 -0
- package/components/Select.js +1 -1
- package/components/ServiceNotice.js +2 -2
- package/components/ShareButtons.js +2 -2
- package/components/ShowMore.js +2 -2
- package/components/SkipLink.js +1 -1
- package/components/StatsCard.js +1 -1
- package/components/SyndicationSearchWidget.js +2 -2
- package/components/Tab.js +2 -2
- package/components/TextCta.js +1 -1
- package/components/TextInput.js +1 -1
- package/components/Textarea.js +1 -1
- package/components/Tree.js +1 -1
- package/components/UserFeedback.js +1 -1
- package/components/VerticalCard.js +1 -1
- package/components/hydrate.js +2 -2
- package/css/style-all.css +60 -40
- package/css/style-all.css.map +1 -1
- package/css/style-delta.css +59 -39
- package/css/style-delta.css.map +1 -1
- package/css/style-gutenberg.css +16 -7
- package/css/style-gutenberg.css.map +1 -1
- package/css/style-irp.css +60 -40
- package/css/style-irp.css.map +1 -1
- package/css/style-mcr.css +59 -39
- package/css/style-mcr.css.map +1 -1
- package/css/style-preventionweb.css +60 -40
- package/css/style-preventionweb.css.map +1 -1
- package/css/style.css +59 -39
- package/css/style.css.map +1 -1
- package/error-pages/401.html +3 -3
- package/error-pages/403.html +3 -3
- package/error-pages/404.html +4 -4
- package/error-pages/429.html +3 -3
- package/error-pages/500.html +6 -6
- package/error-pages/502.html +3 -3
- package/error-pages/503.html +3 -3
- package/error-pages/504.html +3 -3
- package/error-pages/5xx.html +4 -4
- package/error-pages/challenge.html +3 -3
- package/error-pages/managed-challenge.html +3 -3
- package/js/README.md +31 -25
- package/js/copy-button.js +17 -0
- package/js/drawer.js +752 -0
- package/js/hub-header.js +217 -0
- package/js/show-more.js +144 -10
- package/js/switch-pending.js +195 -46
- package/package.json +8 -3
- package/scss/Atom/BaseTypography/Blockquote/blockquote.scss +2 -1
- package/scss/Atom/BaseTypography/Cite/cite.scss +2 -1
- package/scss/Atom/BaseTypography/Code/code.scss +8 -2
- package/scss/Atom/Icons/icons.scss +1 -1
- package/scss/Atom/Images/AuthorImage/author-image.scss +16 -1
- package/scss/Atom/StatusLabel/status-label.scss +331 -8
- package/scss/Atom/Table/table.scss +8 -3
- package/scss/Atom/Tag/tag.scss +20 -2
- package/scss/Components/Breadcrumbs/breadcrumbs.scss +8 -4
- package/scss/Components/Buttons/CtaButton/cta-button.scss +15 -0
- package/scss/Components/Cards/Card/card.scss +37 -10
- package/scss/Components/Cards/StatsCard/stats-card.scss +2 -10
- package/scss/Components/DataViz/Legend/legend.scss +2 -1
- package/scss/Components/ErrorPages/error-pages.scss +8 -0
- package/scss/Components/Forms/SegmentedControl/segmented-control.scss +412 -0
- package/scss/Components/Forms/_form-base.scss +109 -18
- package/scss/Components/Hero/hero.scss +296 -57
- package/scss/Components/MegaMenu/mega-menu.scss +26 -6
- package/scss/Components/OnThisPageNav/on-this-page-nav.scss +3 -1
- package/scss/Components/PageHeader/page-header.scss +13 -28
- package/scss/Components/Pager/pager.scss +3 -1
- package/scss/Components/QuoteHighlight/quote-highlight.scss +6 -1
- package/scss/Components/ScrollContainer/scroll-container.scss +11 -0
- package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +51 -24
- package/scss/Components/Tab/tab.scss +1 -1
- package/scss/Components/TextCta/text-cta.scss +11 -3
- package/scss/Patterns/HubHeader/hub-header.scss +14 -3
- package/scss/Utilities/FullWidth/full-width.scss +14 -1
- package/scss/assets/scss/_components.scss +1 -0
- package/scss/assets/scss/_fonts.scss +11 -0
- package/scss/assets/scss/_foundational.scss +7 -0
- package/scss/assets/scss/_mixins.scss +32 -0
- package/scss/assets/scss/_tokens-data-viz.scss +9 -0
- package/scss/assets/scss/_variables.scss +16 -3
- package/scss/assets/scss/generated/_tokens-irp.scss +20 -4
- package/scss/assets/scss/generated/_tokens-mangrove.scss +6 -0
- package/scss/assets/scss/generated/_tokens-preventionweb.scss +20 -4
- package/js/__tests__/copy-button.test.js +0 -112
- package/js/__tests__/show-more.test.js +0 -80
- package/js/__tests__/table-of-contents.test.js +0 -196
- package/scss/Components/SyndicationSearchWidget/SyndicationSearchWidget.scss +0 -11
- package/scss/Components/TableOfContents/TableOfContents.scss +0 -11
- package/scss/Utilities/FullWidth/FullWidth.scss +0 -11
- package/scss/Utilities/ShowMore/ShowMore.scss +0 -11
package/js/hub-header.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// mg-hub-header
|
|
2
|
+
// Enhances static HubHeader markup: marks the current section from the URL
|
|
3
|
+
// (opt in), keeps the marked section in view and drives the edge fades.
|
|
4
|
+
// See stories/Patterns/ContentHub/ContentHub.mdx.
|
|
5
|
+
|
|
6
|
+
const ROOT_SELECTOR = '[data-mg-js-hub-header]';
|
|
7
|
+
const ANCESTOR_CLASS = 'mg-hub-header__nav-item--ancestor';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reduces a link to a path that can be compared with the current page: same
|
|
11
|
+
* origin only, with no query, fragment, trailing slash or `index.html`.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} href - Link href, relative or absolute.
|
|
14
|
+
* @param {string} [base] - Base URL. Defaults to the current location.
|
|
15
|
+
* @returns {string|null} The path, or null for another origin or a bad URL.
|
|
16
|
+
*/
|
|
17
|
+
export function hubHeaderPath(href, base = window.location.href) {
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(href, base);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (url.origin !== new URL(base).origin) return null;
|
|
25
|
+
const path = url.pathname.replace(/\/index\.html?$/i, '/');
|
|
26
|
+
return path.length > 1 ? path.replace(/\/+$/, '') || '/' : path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether the markup already says which section is current. A server or a
|
|
31
|
+
* React consumer that knows better than the URL always wins.
|
|
32
|
+
*
|
|
33
|
+
* @param {HTMLElement} root - The hub header.
|
|
34
|
+
* @returns {boolean} True when any section is already marked.
|
|
35
|
+
*/
|
|
36
|
+
function hasExplicitState(root) {
|
|
37
|
+
return Boolean(
|
|
38
|
+
root.querySelector(
|
|
39
|
+
`.mg-hub-header__nav [aria-current], .mg-hub-header__nav [data-hub-current], .${ANCESTOR_CLASS}`
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The path a link points at, or null when it does not point at a page: an
|
|
46
|
+
* empty href, a fragment or a bare query resolves to the current page, so it
|
|
47
|
+
* would match wherever the markup is copied.
|
|
48
|
+
*
|
|
49
|
+
* @param {Element} link - The link.
|
|
50
|
+
* @returns {string|null} The link's path.
|
|
51
|
+
*/
|
|
52
|
+
function linkPath(link) {
|
|
53
|
+
const href = (link.getAttribute('href') || '').trim();
|
|
54
|
+
if (!href || href.startsWith('#') || href.startsWith('?')) return null;
|
|
55
|
+
return hubHeaderPath(href);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Marks the section for the current page. An exact match is the page itself
|
|
60
|
+
* and is announced; otherwise the longest section containing the page is
|
|
61
|
+
* marked as its ancestor, styled the same but not announced.
|
|
62
|
+
*
|
|
63
|
+
* @param {HTMLElement} root - The hub header.
|
|
64
|
+
* @returns {Function} Removes the marks this call added.
|
|
65
|
+
*/
|
|
66
|
+
function markCurrent(root) {
|
|
67
|
+
const none = () => {};
|
|
68
|
+
if (hasExplicitState(root)) return none;
|
|
69
|
+
const here = hubHeaderPath(window.location.href);
|
|
70
|
+
const name = root.querySelector('.mg-hub-header__name[href]');
|
|
71
|
+
const home = name ? linkPath(name) : null;
|
|
72
|
+
const links = [...root.querySelectorAll('.mg-hub-header__nav a[href]')];
|
|
73
|
+
const paths = links.map(linkPath);
|
|
74
|
+
|
|
75
|
+
const exact = links.find((link, index) => paths[index] === here);
|
|
76
|
+
if (exact) {
|
|
77
|
+
exact.setAttribute('aria-current', 'page');
|
|
78
|
+
exact.setAttribute('data-hub-current', '');
|
|
79
|
+
return () => {
|
|
80
|
+
exact.removeAttribute('aria-current');
|
|
81
|
+
exact.removeAttribute('data-hub-current');
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (home && home === here && !name.hasAttribute('aria-current')) {
|
|
85
|
+
name.setAttribute('aria-current', 'page');
|
|
86
|
+
return () => name.removeAttribute('aria-current');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A section that is the hub home, or the site root, contains every page of
|
|
90
|
+
// the hub. As an ancestor it would be marked everywhere, so it can only ever
|
|
91
|
+
// match exactly.
|
|
92
|
+
let best = null;
|
|
93
|
+
links.forEach((link, index) => {
|
|
94
|
+
const path = paths[index];
|
|
95
|
+
if (!path || path === '/' || path === home) return;
|
|
96
|
+
if (!here.startsWith(`${path}/`)) return;
|
|
97
|
+
if (!best || path.length > best.path.length) best = { link, path };
|
|
98
|
+
});
|
|
99
|
+
if (!best) return none;
|
|
100
|
+
const item = best.link.closest('li');
|
|
101
|
+
item?.classList.add(ANCESTOR_CLASS);
|
|
102
|
+
best.link.setAttribute('data-hub-current', '');
|
|
103
|
+
return () => {
|
|
104
|
+
item?.classList.remove(ANCESTOR_CLASS);
|
|
105
|
+
best.link.removeAttribute('data-hub-current');
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Keeps the marked section in view and drives the edge fades, which CSS
|
|
111
|
+
* cannot do on its own because it cannot detect overflow.
|
|
112
|
+
*
|
|
113
|
+
* @param {HTMLElement} root - The hub header.
|
|
114
|
+
* @returns {Function} Removes the listeners and markers this added.
|
|
115
|
+
*/
|
|
116
|
+
function enhanceRail(root) {
|
|
117
|
+
const nav = root.querySelector('.mg-hub-header__nav');
|
|
118
|
+
const rail = nav?.querySelector('ul');
|
|
119
|
+
if (!nav || !rail) return () => {};
|
|
120
|
+
|
|
121
|
+
const reveal = () => {
|
|
122
|
+
const selected = rail.querySelector('[data-hub-current]');
|
|
123
|
+
if (!selected || typeof rail.scrollBy !== 'function') return;
|
|
124
|
+
const viewport = rail.getBoundingClientRect();
|
|
125
|
+
const item = selected.getBoundingClientRect();
|
|
126
|
+
let offset = 0;
|
|
127
|
+
if (item.left < viewport.left) offset = item.left - viewport.left;
|
|
128
|
+
else if (item.right > viewport.right) offset = item.right - viewport.right;
|
|
129
|
+
if (offset) rail.scrollBy({ left: offset, behavior: 'instant' });
|
|
130
|
+
};
|
|
131
|
+
const markOverflow = () => {
|
|
132
|
+
const scrolled = Math.abs(rail.scrollLeft);
|
|
133
|
+
nav.toggleAttribute('data-overflow-start', scrolled > 1);
|
|
134
|
+
nav.toggleAttribute(
|
|
135
|
+
'data-overflow-end',
|
|
136
|
+
scrolled + rail.clientWidth < rail.scrollWidth - 1
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
const update = () => {
|
|
140
|
+
reveal();
|
|
141
|
+
markOverflow();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
update();
|
|
145
|
+
// Only a change to the marked label, such as a web font arriving after first
|
|
146
|
+
// paint, moves the rail back to it. Everything else only recomputes the
|
|
147
|
+
// fades: a resize must not undo the reader's own scrolling, and iOS Safari
|
|
148
|
+
// fires one whenever its toolbar collapses.
|
|
149
|
+
const Observer =
|
|
150
|
+
typeof ResizeObserver === 'undefined' ? null : ResizeObserver;
|
|
151
|
+
const railObserver = Observer ? new Observer(markOverflow) : null;
|
|
152
|
+
railObserver?.observe(nav);
|
|
153
|
+
railObserver?.observe(rail);
|
|
154
|
+
const labelObserver = Observer ? new Observer(update) : null;
|
|
155
|
+
const selected = rail.querySelector('[data-hub-current]');
|
|
156
|
+
if (selected) labelObserver?.observe(selected);
|
|
157
|
+
let active = true;
|
|
158
|
+
document.fonts?.ready.then(() => {
|
|
159
|
+
if (active) update();
|
|
160
|
+
});
|
|
161
|
+
rail.addEventListener('scroll', markOverflow, { passive: true });
|
|
162
|
+
window.addEventListener('resize', markOverflow);
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
active = false;
|
|
166
|
+
railObserver?.disconnect();
|
|
167
|
+
labelObserver?.disconnect();
|
|
168
|
+
rail.removeEventListener('scroll', markOverflow);
|
|
169
|
+
window.removeEventListener('resize', markOverflow);
|
|
170
|
+
nav.removeAttribute('data-overflow-start');
|
|
171
|
+
nav.removeAttribute('data-overflow-end');
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Initializes hub headers. Headers already initialized are skipped, so this
|
|
177
|
+
* can be called again after inserting markup.
|
|
178
|
+
*
|
|
179
|
+
* @param {NodeList|HTMLElement[]|HTMLElement} [scope] - Headers to enhance.
|
|
180
|
+
* Defaults to every [data-mg-js-hub-header] in the document, less any
|
|
181
|
+
* carrying data-mg-hub-header-skip-auto-init.
|
|
182
|
+
* @returns {Function} Removes what this call added, so a header can be
|
|
183
|
+
* initialized again. Useful for React effect cleanup.
|
|
184
|
+
*/
|
|
185
|
+
export function mgHubHeader(scope) {
|
|
186
|
+
let roots;
|
|
187
|
+
if (!scope) roots = document.querySelectorAll(ROOT_SELECTOR);
|
|
188
|
+
else if (scope instanceof HTMLElement) roots = [scope];
|
|
189
|
+
else roots = scope;
|
|
190
|
+
|
|
191
|
+
const cleanups = [];
|
|
192
|
+
Array.from(roots).forEach(root => {
|
|
193
|
+
if (!(root instanceof HTMLElement) || !root.matches(ROOT_SELECTOR)) return;
|
|
194
|
+
if (!scope && root.hasAttribute('data-mg-hub-header-skip-auto-init')) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (root.dataset.mgJsHubHeaderInitialized === 'true') return;
|
|
198
|
+
root.dataset.mgJsHubHeaderInitialized = 'true';
|
|
199
|
+
const unmark = root.hasAttribute('data-mg-hub-header-detect-current')
|
|
200
|
+
? markCurrent(root)
|
|
201
|
+
: () => {};
|
|
202
|
+
const teardown = enhanceRail(root);
|
|
203
|
+
cleanups.push(() => {
|
|
204
|
+
teardown();
|
|
205
|
+
unmark();
|
|
206
|
+
delete root.dataset.mgJsHubHeaderInitialized;
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
return () => cleanups.forEach(cleanup => cleanup());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Auto-wrap so the browser Event object is not passed as scope.
|
|
213
|
+
if (document.readyState === 'loading') {
|
|
214
|
+
document.addEventListener('DOMContentLoaded', () => mgHubHeader(), false);
|
|
215
|
+
} else {
|
|
216
|
+
mgHubHeader();
|
|
217
|
+
}
|
package/js/show-more.js
CHANGED
|
@@ -1,14 +1,81 @@
|
|
|
1
1
|
// mg-show-more
|
|
2
2
|
// https://gitlab.com/undrr/web-backlog/-/issues/1082
|
|
3
3
|
|
|
4
|
+
// Used to give an unlabelled target an id so the toggle can point
|
|
5
|
+
// `aria-controls` at it. The counter lives on `globalThis` rather than in
|
|
6
|
+
// module scope so two loaded copies of this module — a site bundle alongside
|
|
7
|
+
// the CDN build, say — share one sequence instead of each minting
|
|
8
|
+
// `mg-show-more-target-1`. Same hazard, and same fix, as the root counter in
|
|
9
|
+
// `src/hydrate.js` (#1178).
|
|
10
|
+
const TARGET_COUNTER_KEY = Symbol.for('undrr.mangrove.showMore.targetCounter');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mints an id that is unused in the current document.
|
|
14
|
+
*
|
|
15
|
+
* @returns {string} A free `mg-show-more-target-N` id.
|
|
16
|
+
*/
|
|
17
|
+
function nextTargetId() {
|
|
18
|
+
for (;;) {
|
|
19
|
+
const current = globalThis[TARGET_COUNTER_KEY];
|
|
20
|
+
const next = Number.isSafeInteger(current) && current >= 0 ? current : 0;
|
|
21
|
+
globalThis[TARGET_COUNTER_KEY] = next + 1;
|
|
22
|
+
const id = `mg-show-more-target-${next}`;
|
|
23
|
+
if (!document.getElementById(id)) return id;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a toggle's target, nearest scope first.
|
|
29
|
+
*
|
|
30
|
+
* The revealed content need not be a sibling or descendant of its toggle, so
|
|
31
|
+
* there is no container the lookup can be confined to. It can still be
|
|
32
|
+
* *ordered*: climb the toggle's ancestors and return the first match found
|
|
33
|
+
* inside one. For the canonical markup that is the adjacent wrapper, and for
|
|
34
|
+
* two blocks in separate subtrees each toggle finds its own.
|
|
35
|
+
*
|
|
36
|
+
* For a toggle that is in the document the walk ends at `<html>`, an ancestor
|
|
37
|
+
* of every other element, so its last iteration *is* the document-wide lookup:
|
|
38
|
+
* a target anywhere on the page is still found, and it is the same element
|
|
39
|
+
* `document.querySelector` returned whenever nothing nearer matched. The walk
|
|
40
|
+
* is therefore never worse, and better whenever two toggles share a selector.
|
|
41
|
+
* The explicit fallback below is only reached when the toggle is not in the
|
|
42
|
+
* document tree — detached, or inside a shadow root or a `<template>` — where
|
|
43
|
+
* it resolves exactly as it did before.
|
|
44
|
+
*
|
|
45
|
+
* Two toggles under the *same* parent still cannot be told apart: the first
|
|
46
|
+
* scope containing one target contains both, and the earlier one in document
|
|
47
|
+
* order wins. That is also why the React `ShowMore` wrapper mints an id per
|
|
48
|
+
* item rather than relying on this walk. See #1228.
|
|
49
|
+
*
|
|
50
|
+
* @param {HTMLElement} toggle - The toggle whose target to resolve.
|
|
51
|
+
* @param {string} selector - The CSS selector to resolve.
|
|
52
|
+
* @returns {Element|null} The nearest match, or null when nothing matches.
|
|
53
|
+
*/
|
|
54
|
+
function resolveTarget(toggle, selector) {
|
|
55
|
+
for (let scope = toggle.parentElement; scope; scope = scope.parentElement) {
|
|
56
|
+
const match = scope.querySelector(selector);
|
|
57
|
+
if (match) return match;
|
|
58
|
+
}
|
|
59
|
+
// `<html>` itself, and anything outside the toggle's ancestor chain (a
|
|
60
|
+
// detached toggle, say), are only reachable from the document.
|
|
61
|
+
return document.querySelector(selector);
|
|
62
|
+
}
|
|
63
|
+
|
|
4
64
|
/**
|
|
5
65
|
* Initializes "show more" toggle buttons.
|
|
6
66
|
*
|
|
67
|
+
* The toggle carries `aria-controls` pointing at the content it collapses.
|
|
68
|
+
* It deliberately does not carry `aria-expanded`: the collapse is visual
|
|
69
|
+
* only. See the accessibility section of `ShowMore.mdx` for why.
|
|
70
|
+
*
|
|
7
71
|
* @param {NodeList|HTMLElement[]|HTMLElement} [scope] - Elements to init.
|
|
8
72
|
* Accepts a NodeList, array, or a single HTMLElement.
|
|
9
73
|
* Defaults to all [data-mg-show-more] in the document.
|
|
74
|
+
* @returns {Function} Removes handlers added by this call and restores each
|
|
75
|
+
* target's initial collapsed state. Useful for React effect cleanup.
|
|
10
76
|
*/
|
|
11
77
|
export function mgShowMore(scope) {
|
|
78
|
+
const cleanups = [];
|
|
12
79
|
const mgShowMoreButtons = scope
|
|
13
80
|
? scope instanceof HTMLElement
|
|
14
81
|
? [scope]
|
|
@@ -19,14 +86,9 @@ export function mgShowMore(scope) {
|
|
|
19
86
|
// Skip auto-init if the element opts out
|
|
20
87
|
if (!scope && item.hasAttribute('data-mg-show-more-skip-auto-init')) return;
|
|
21
88
|
if (item.dataset.mgShowMoreInitialized) return;
|
|
22
|
-
item.dataset.mgShowMoreInitialized = 'true';
|
|
23
|
-
|
|
24
|
-
item.dataset.dataVfGoogleAnalyticsLabel =
|
|
25
|
-
'Show more: ' + item.dataset.mgShowMoreLabelCollapsed || `Show more`;
|
|
26
|
-
|
|
27
89
|
const mgShowMoreTargetClass =
|
|
28
90
|
item.dataset.mgShowMoreTarget || '.mg-show-more--container';
|
|
29
|
-
const mgShowMoreTarget =
|
|
91
|
+
const mgShowMoreTarget = resolveTarget(item, mgShowMoreTargetClass);
|
|
30
92
|
|
|
31
93
|
if (!mgShowMoreTarget) {
|
|
32
94
|
console.warn(
|
|
@@ -35,7 +97,42 @@ export function mgShowMore(scope) {
|
|
|
35
97
|
return;
|
|
36
98
|
}
|
|
37
99
|
|
|
38
|
-
item.
|
|
100
|
+
item.dataset.mgShowMoreInitialized = 'true';
|
|
101
|
+
const wasCollapsed = mgShowMoreTarget.classList.contains(
|
|
102
|
+
'mg-show-more--collapsed'
|
|
103
|
+
);
|
|
104
|
+
const wasOpen = item.classList.contains('mg-show-more--button--open');
|
|
105
|
+
const initialText = item.textContent;
|
|
106
|
+
const initialControls = item.getAttribute('aria-controls');
|
|
107
|
+
const initialRole = item.getAttribute('role');
|
|
108
|
+
const onKeydown = event => {
|
|
109
|
+
if (event.key === ' ' || event.key === 'Spacebar') {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
item.click();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
item.dataset.dataVfGoogleAnalyticsLabel =
|
|
116
|
+
'Show more: ' + (item.dataset.mgShowMoreLabelCollapsed || 'Show more');
|
|
117
|
+
|
|
118
|
+
// Relate the toggle to the content it acts on. A <button> already
|
|
119
|
+
// announces as a button; an anchor copied from an earlier version of the
|
|
120
|
+
// docs needs the role.
|
|
121
|
+
if (mgShowMoreTarget.id === '') {
|
|
122
|
+
mgShowMoreTarget.id = nextTargetId();
|
|
123
|
+
}
|
|
124
|
+
item.setAttribute('aria-controls', mgShowMoreTarget.id);
|
|
125
|
+
if (item.tagName !== 'BUTTON') {
|
|
126
|
+
item.setAttribute('role', 'button');
|
|
127
|
+
|
|
128
|
+
// `role="button"` promises Space activates the control, and an anchor
|
|
129
|
+
// does not honour that on its own (WCAG 4.1.2 Name, Role, Value).
|
|
130
|
+
// `preventDefault` keeps Space from scrolling the page.
|
|
131
|
+
item.addEventListener('keydown', onKeydown);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const onClick = event => {
|
|
135
|
+
// Also neutralises the dead `href="#"` on a legacy anchor toggle.
|
|
39
136
|
event.preventDefault();
|
|
40
137
|
mgShowMoreTarget.classList.toggle('mg-show-more--collapsed');
|
|
41
138
|
|
|
@@ -51,18 +148,55 @@ export function mgShowMore(scope) {
|
|
|
51
148
|
} else {
|
|
52
149
|
item.classList.add('mg-show-more--button--open');
|
|
53
150
|
}
|
|
54
|
-
}
|
|
151
|
+
};
|
|
152
|
+
item.addEventListener('click', onClick);
|
|
153
|
+
|
|
154
|
+
// The collapse is `max-height` + `overflow: hidden`, which clips content
|
|
155
|
+
// visually but leaves it in the tab order. Without this, Tab lands on a
|
|
156
|
+
// link the user cannot see, inside a box that cannot scroll to reveal it
|
|
157
|
+
// (WCAG 2.4.7 Focus Visible, 2.4.11 Focus Not Obscured). Expanding on
|
|
158
|
+
// entry keeps the focused control visible.
|
|
159
|
+
const onFocusin = event => {
|
|
160
|
+
if (item === event.target || item.contains(event.target)) return;
|
|
161
|
+
if (mgShowMoreTarget.classList.contains('mg-show-more--collapsed')) {
|
|
162
|
+
item.click();
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
mgShowMoreTarget.addEventListener('focusin', onFocusin);
|
|
55
166
|
|
|
56
167
|
// Allow items to be shown by clicking anywhere on the collapsed item
|
|
57
168
|
// https://gitlab.com/undrr/web-backlog/-/issues/1612
|
|
58
|
-
|
|
169
|
+
const onTargetClick = () => {
|
|
59
170
|
if (mgShowMoreTarget.classList.contains('mg-show-more--collapsed')) {
|
|
60
171
|
item.click();
|
|
61
172
|
}
|
|
62
|
-
}
|
|
173
|
+
};
|
|
174
|
+
mgShowMoreTarget.addEventListener('click', onTargetClick);
|
|
63
175
|
|
|
64
176
|
item.click();
|
|
177
|
+
cleanups.push(() => {
|
|
178
|
+
item.removeEventListener('click', onClick);
|
|
179
|
+
item.removeEventListener('keydown', onKeydown);
|
|
180
|
+
mgShowMoreTarget.removeEventListener('focusin', onFocusin);
|
|
181
|
+
mgShowMoreTarget.removeEventListener('click', onTargetClick);
|
|
182
|
+
mgShowMoreTarget.classList.toggle(
|
|
183
|
+
'mg-show-more--collapsed',
|
|
184
|
+
wasCollapsed
|
|
185
|
+
);
|
|
186
|
+
item.classList.toggle('mg-show-more--button--open', wasOpen);
|
|
187
|
+
item.textContent = initialText;
|
|
188
|
+
for (const [attribute, value] of [
|
|
189
|
+
['aria-controls', initialControls],
|
|
190
|
+
['role', initialRole],
|
|
191
|
+
]) {
|
|
192
|
+
if (value === null) item.removeAttribute(attribute);
|
|
193
|
+
else item.setAttribute(attribute, value);
|
|
194
|
+
}
|
|
195
|
+
delete item.dataset.mgShowMoreInitialized;
|
|
196
|
+
});
|
|
65
197
|
});
|
|
198
|
+
|
|
199
|
+
return () => cleanups.splice(0).forEach(cleanup => cleanup());
|
|
66
200
|
}
|
|
67
201
|
|
|
68
202
|
// Auto-wrap so the browser Event object is not passed as scope
|