Skip to content

Commit e2de74c

Browse files
committed
Add a11y, hash handling, print support, and override separation for tabs
Four enhancements to the tabs infrastructure: - A new theme/tabs-a11y.js layers ARIA roles and keyboard navigation onto the plugin's markup: role=tablist, role=tab, role=tabpanel, aria-selected, aria-controls, aria-labelledby, aria-hidden, and roving-tabindex arrow-key navigation with Home/End support - Extend theme/tabs-url-sync.js so a URL hash targeting an element inside a hidden tab pane activates the enclosing tab and scrolls the target into view - Print stylesheet: reveal all tab panes with a heading-style label and hide the interactive nav under @media print - Relocate tab style overrides from theme/tabs.css into a new theme/tabs-overrides.css so future `mdbook-tabs install` runs cannot overwrite them
1 parent 0424811 commit e2de74c

5 files changed

Lines changed: 212 additions & 29 deletions

File tree

component-model/book.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ title = "The WebAssembly Component Model"
88
[output.html]
99
git-repository-url = "https://github.com/bytecodealliance/component-docs/tree/main/component-model"
1010
edit-url-template = "https://github.com/bytecodealliance/component-docs/tree/main/component-model/{path}"
11-
additional-css = ["theme/head.hbs", "theme/tabs.css", "theme/version-notice.css"]
12-
additional-js = ["theme/tabs.js", "theme/tabs-url-sync.js"]
11+
additional-css = ["theme/head.hbs", "theme/tabs.css", "theme/tabs-overrides.css", "theme/version-notice.css"]
12+
additional-js = ["theme/tabs.js", "theme/tabs-url-sync.js", "theme/tabs-a11y.js"]
1313

1414
[output.html.fold]
1515
enable = true

component-model/theme/tabs-a11y.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
(function () {
2+
'use strict';
3+
4+
// Layer ARIA semantics and keyboard navigation onto the mdbook-tabs
5+
// plugin's markup. The plugin emits plain <button> / <div> elements
6+
// without tab/tablist/tabpanel roles; this script fills that gap.
7+
8+
let uidCounter = 0;
9+
function nextUid() {
10+
uidCounter += 1;
11+
return uidCounter;
12+
}
13+
14+
function initContainer(container) {
15+
const nav = container.querySelector('.mdbook-tabs');
16+
if (nav && !nav.hasAttribute('role')) {
17+
nav.setAttribute('role', 'tablist');
18+
}
19+
20+
const tabs = Array.from(container.querySelectorAll('.mdbook-tab'));
21+
const panes = Array.from(container.querySelectorAll('.mdbook-tab-content'));
22+
23+
tabs.forEach(function (tab) {
24+
if (tab.hasAttribute('role')) return;
25+
const uid = nextUid();
26+
const tabId = 'mdbook-tab-' + uid;
27+
const paneId = 'mdbook-tabpanel-' + uid;
28+
29+
tab.setAttribute('role', 'tab');
30+
tab.id = tabId;
31+
32+
const pane = panes.find(function (p) {
33+
return p.dataset.tabname === tab.dataset.tabname;
34+
});
35+
if (pane) {
36+
pane.setAttribute('role', 'tabpanel');
37+
pane.id = paneId;
38+
if (!pane.hasAttribute('tabindex')) {
39+
pane.setAttribute('tabindex', '0');
40+
}
41+
tab.setAttribute('aria-controls', paneId);
42+
pane.setAttribute('aria-labelledby', tabId);
43+
}
44+
});
45+
}
46+
47+
function syncContainer(container) {
48+
container.querySelectorAll('.mdbook-tab').forEach(function (tab) {
49+
const isActive = tab.classList.contains('active');
50+
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
51+
// Roving tabindex: only the active tab is reachable via Tab key;
52+
// arrow keys move focus within the tablist.
53+
tab.setAttribute('tabindex', isActive ? '0' : '-1');
54+
});
55+
container.querySelectorAll('.mdbook-tab-content').forEach(function (pane) {
56+
pane.setAttribute('aria-hidden', pane.classList.contains('hidden') ? 'true' : 'false');
57+
});
58+
}
59+
60+
function syncAll() {
61+
document.querySelectorAll('.mdbook-tabs-container').forEach(syncContainer);
62+
}
63+
64+
function handleKeyDown(event) {
65+
const tab = event.target.closest && event.target.closest('.mdbook-tab');
66+
if (!tab) return;
67+
const nav = tab.parentElement;
68+
if (!nav || !nav.classList.contains('mdbook-tabs')) return;
69+
70+
const tabs = Array.from(nav.querySelectorAll('.mdbook-tab'));
71+
const currentIndex = tabs.indexOf(tab);
72+
if (currentIndex < 0) return;
73+
74+
let newIndex = null;
75+
switch (event.key) {
76+
case 'ArrowRight':
77+
case 'Right':
78+
newIndex = (currentIndex + 1) % tabs.length;
79+
break;
80+
case 'ArrowLeft':
81+
case 'Left':
82+
newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
83+
break;
84+
case 'Home':
85+
newIndex = 0;
86+
break;
87+
case 'End':
88+
newIndex = tabs.length - 1;
89+
break;
90+
}
91+
if (newIndex === null) return;
92+
93+
event.preventDefault();
94+
tabs[newIndex].focus();
95+
// Automatic activation: mirrors common tab widgets (Bootstrap, Radix,
96+
// etc.) where focusing a tab also selects it.
97+
tabs[newIndex].click();
98+
}
99+
100+
document.addEventListener('DOMContentLoaded', function () {
101+
document.querySelectorAll('.mdbook-tabs-container').forEach(initContainer);
102+
syncAll();
103+
104+
document.addEventListener('keydown', handleKeyDown);
105+
106+
// Re-sync ARIA state after every click. Clicks may propagate across
107+
// multiple containers when a global="..." state is shared, so we
108+
// resync every container, not just the one that was clicked.
109+
document.addEventListener('click', function (event) {
110+
if (!event.target.closest || !event.target.closest('.mdbook-tab')) return;
111+
syncAll();
112+
});
113+
});
114+
})();
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/* Custom overrides to the mdbook-tabs plugin's default styling.
2+
Kept separate from theme/tabs.css so plugin updates (via
3+
`mdbook-tabs install`) do not overwrite these rules. */
4+
5+
/* Container: left rail marking version-specific content. */
6+
.mdbook-tabs-container {
7+
border-left: 3px solid var(--sidebar-active, var(--table-border-color));
8+
padding-left: 1.25rem;
9+
}
10+
11+
/* Nav: thin baseline below the tab row. */
12+
.mdbook-tabs {
13+
border-bottom: 1px solid var(--table-border-color);
14+
}
15+
16+
/* Tab button: explicit foreground color and transparent active
17+
indicator so hover and active states have somewhere to land. */
18+
.mdbook-tab {
19+
color: var(--fg);
20+
border-bottom: 2px solid transparent;
21+
}
22+
23+
@media (prefers-reduced-motion: no-preference) {
24+
.mdbook-tab {
25+
transition: color 120ms ease, border-color 120ms ease;
26+
}
27+
}
28+
29+
.mdbook-tab:hover:not(.active) {
30+
color: var(--sidebar-active, var(--fg));
31+
}
32+
33+
.mdbook-tab.active {
34+
color: var(--sidebar-active, var(--fg));
35+
border-bottom-color: var(--sidebar-active, var(--fg));
36+
}
37+
38+
/* Rust theme: --sidebar-active (#e69f67) has low contrast against the
39+
warm tan tab backgrounds. Fall back to --fg for the label text; the
40+
accent still carries the active signal via the bottom border. */
41+
.rust .mdbook-tab.active {
42+
color: var(--fg);
43+
}
44+
45+
.rust .mdbook-tab:hover:not(.active) {
46+
color: var(--fg);
47+
border-bottom-color: var(--sidebar-active, var(--fg));
48+
}
49+
50+
/* Print: show every tab pane with a heading-like label, and hide the
51+
interactive nav since clicking has no meaning on paper. */
52+
@media print {
53+
.mdbook-tabs {
54+
display: none;
55+
}
56+
57+
.mdbook-tab-content.hidden {
58+
display: block;
59+
}
60+
61+
.mdbook-tab-content::before {
62+
content: attr(data-tabname);
63+
display: block;
64+
font-size: 1.3em;
65+
font-weight: bold;
66+
margin: 1em 0 0.5em;
67+
}
68+
}

component-model/theme/tabs-url-sync.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,40 @@
2727
}
2828
}
2929

30+
// If `element` lives inside a tab pane, activate that pane's tab.
31+
// Returns true if a tab was activated.
32+
function activateTabContainingElement(element) {
33+
const pane = element.closest && element.closest('.mdbook-tab-content');
34+
if (!pane) return false;
35+
const container = pane.closest('.mdbook-tabs-container');
36+
if (!container) return false;
37+
const name = pane.dataset.tabname;
38+
if (!name) return false;
39+
const tab = container.querySelector('.mdbook-tab[data-tabname="' + name + '"]');
40+
if (!tab) return false;
41+
tab.click();
42+
return true;
43+
}
44+
3045
document.addEventListener('DOMContentLoaded', function () {
46+
// Apply URL-param tab selection first.
3147
const slug = readSlugFromUrl();
3248
if (slug) {
3349
activateByName(SLUG_TO_NAME[slug]);
3450
}
3551

52+
// If the page was loaded with a hash pointing to an element inside a
53+
// hidden tab pane, activate that pane and re-scroll. Hash wins over
54+
// the ?wasi= param since the hash targets a specific element.
55+
if (window.location.hash.length > 1) {
56+
const targetId = decodeURIComponent(window.location.hash.slice(1));
57+
const target = document.getElementById(targetId);
58+
if (target && activateTabContainingElement(target)) {
59+
target.scrollIntoView();
60+
}
61+
}
62+
63+
// Reflect wasi-version tab clicks back into the URL.
3664
document.addEventListener('click', function (event) {
3765
const tab = event.target.closest && event.target.closest('.mdbook-tab');
3866
if (!tab) return;

component-model/theme/tabs.css

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,21 @@
11
.mdbook-tabs {
22
display: flex;
3-
border-bottom: 1px solid var(--table-border-color);
43
}
54

65
.mdbook-tab {
76
background-color: var(--table-alternate-bg);
8-
color: var(--fg);
97
padding: 0.5rem 1rem;
108
cursor: pointer;
119
border: none;
12-
border-bottom: 2px solid transparent;
1310
font-size: 1.6rem;
1411
line-height: 1.45em;
1512
}
1613

17-
@media (prefers-reduced-motion: no-preference) {
18-
.mdbook-tab {
19-
transition: color 120ms ease, border-color 120ms ease;
20-
}
21-
}
22-
23-
.mdbook-tab:hover:not(.active) {
24-
color: var(--sidebar-active, var(--fg));
25-
}
26-
2714
.mdbook-tab.active {
2815
background-color: var(--table-header-bg);
29-
color: var(--sidebar-active, var(--fg));
30-
border-bottom-color: var(--sidebar-active, var(--fg));
3116
font-weight: bold;
3217
}
3318

34-
/* Rust theme: --sidebar-active (#e69f67) has low contrast against the warm
35-
tan tab backgrounds. Fall back to --fg for the label text; the accent
36-
color still carries the active signal via the bottom border. */
37-
.rust .mdbook-tab.active {
38-
color: var(--fg);
39-
}
40-
41-
.rust .mdbook-tab:hover:not(.active) {
42-
color: var(--fg);
43-
border-bottom-color: var(--sidebar-active, var(--fg));
44-
}
45-
4619
.mdbook-tab-content {
4720
padding: 1rem 0rem;
4821
}

0 commit comments

Comments
 (0)