Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions docs/_src/version-selector.css
Original file line number Diff line number Diff line change
@@ -1,20 +1,68 @@
/* Open the docs version selector on click, not hover.
MkDocs Material shows `.md-version__list` on :hover / :focus-within. */
/* Version selector: open on click/keyboard, not hover.
Overrides MkDocs Material's :hover / :focus-within open. */

.md-version:hover .md-version__list,
.md-version:focus-within .md-version__list {
max-height: 0;
opacity: 0;
visibility: hidden;
}

.md-version.md-version--open .md-version__list {
.md-version.md-version--open .md-version__list,
.md-version.md-version--open:hover .md-version__list,
.md-version.md-version--open:focus-within .md-version__list {
/* Material places the list over the button; keep the toggle clickable. */
top: calc(100% + 0.15rem);
right: auto;
left: 0;
z-index: 20;
max-height: 10rem;
margin: 0;
overflow: auto;
opacity: 1;
visibility: visible;
pointer-events: auto;
background-color: var(--md-default-bg-color);
background-image: linear-gradient(
color-mix(in srgb, var(--md-default-fg-color) 8%, transparent),
color-mix(in srgb, var(--md-default-fg-color) 8%, transparent)
);
border: 1px solid var(--md-default-fg-color--lightest);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
transition:
max-height 0ms,
opacity 0.25s;
}

[dir='rtl'] .md-version.md-version--open .md-version__list,
[dir='rtl'] .md-version.md-version--open:hover .md-version__list,
[dir='rtl'] .md-version.md-version--open:focus-within .md-version__list {
right: 0;
left: auto;
}

.md-version__list[hidden] {
display: none;
}

.md-version__current:focus,
.md-version__current:focus-visible {
outline: 2px solid var(--md-accent-fg-color);
outline-offset: 2px;
}

.md-version__link:focus,
.md-version__link:focus-visible {
outline: 2px solid var(--md-accent-fg-color);
outline-offset: -2px;
color: var(--md-accent-fg-color);
background-color: var(--md-default-fg-color--lightest);
}

.md-version__link[aria-current='page'] {
font-weight: 700;
}

@media (hover: none), (pointer: coarse) {
.md-version:hover .md-version__list {
animation: none;
Expand Down
93 changes: 79 additions & 14 deletions docs/_src/version-selector.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
* - Fetches versions.json from the root of the domain
* - Supports absolute version URLs
* - Preserves current page path when switching versions
* - Opens the version menu on click (not hover)
* - Opens the version menu on click or keyboard, not hover
* - Follows WAI-ARIA APG disclosure navigation (real links, Tab + arrow keys)
* - Gracefully degrades if versions.json is not found (no errors, just no selector)
*
* If versions.json is not available (404), the page loads normally without the version selector.
* No errors are thrown to ensure documentation remains accessible.
*
* Based on MkDocs Material's version selector implementation
* Based on MkDocs Material's version selector markup
* https://github.com/squidfunk/mkdocs-material
* Keyboard pattern: https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/
*/

(function () {
Expand Down Expand Up @@ -103,41 +105,104 @@
const current = versions.find(v => v.version === currentVersion) || versions[0];
const visibleVersions = versions.filter(v => !v.hidden);

const html = `<div class="md-version"><button type="button" class="md-version__current" aria-label="Select version" aria-expanded="false" aria-haspopup="true" aria-controls="md-version-list">${current.title}</button><ul id="md-version-list" class="md-version__list">${visibleVersions.map(version => `<li class="md-version__item"><a href="${buildVersionURL(version.version, currentVersion)}" class="md-version__link">${version.title}</a></li>`).join('')}</ul></div>`;
const items = visibleVersions
.map(version => {
const isCurrent = version.version === currentVersion;
const currentAttr = isCurrent ? ' aria-current="page"' : '';
const href = buildVersionURL(version.version, currentVersion);
return `<li class="md-version__item"><a href="${href}" class="md-version__link"${currentAttr}>${version.title}</a></li>`;
})
.join('');

return html;
return `<div class="md-version"><button type="button" class="md-version__current" aria-expanded="false" aria-controls="md-version-list">${current.title}</button><ul id="md-version-list" class="md-version__list" hidden>${items}</ul></div>`;
}

/**
* Open/close the version menu on click (Material CSS uses hover).
* Disclosure navigation: click/keyboard open, no hover.
* Tab moves through real links; ArrowUp/Down/Home/End move between them.
*/
function bindVersionSelector(versionEl) {
const button = versionEl.querySelector('.md-version__current');
if (!button) {
const list = versionEl.querySelector('.md-version__list');
if (!button || !list) {
return;
}

const setOpen = open => {
const getLinks = () => Array.from(list.querySelectorAll('.md-version__link'));

const isOpen = () => !list.hidden;

const setOpen = (open, focusTarget) => {
versionEl.classList.toggle('md-version--open', open);
button.setAttribute('aria-expanded', String(open));
};
list.hidden = !open;

if (!open) {
if (focusTarget === 'button') {
button.focus();
}
return;
}

const isOpen = () => versionEl.classList.contains('md-version--open');
const links = getLinks();
const current = list.querySelector('.md-version__link[aria-current="page"]');
if (focusTarget === 'current') {
(current || links[0])?.focus();
} else if (focusTarget === 'first') {
links[0]?.focus();
} else if (focusTarget === 'last') {
links[links.length - 1]?.focus();
}
};

button.addEventListener('click', () => {
setOpen(!isOpen());
});

document.addEventListener('click', event => {
if (isOpen() && !versionEl.contains(event.target)) {
button.addEventListener('keydown', event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
setOpen(true, event.key === 'ArrowDown' ? 'current' : 'last');
} else if (event.key === 'Escape' && isOpen()) {
event.preventDefault();
setOpen(false);
}
});

document.addEventListener('keydown', event => {
if (event.key === 'Escape' && isOpen()) {
list.addEventListener('keydown', event => {
const links = getLinks();
const index = links.indexOf(document.activeElement);
if (index < 0) {
return;
}

if (event.key === 'ArrowDown') {
event.preventDefault();
links[(index + 1) % links.length].focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
links[(index - 1 + links.length) % links.length].focus();
} else if (event.key === 'Home') {
event.preventDefault();
links[0]?.focus();
} else if (event.key === 'End') {
event.preventDefault();
links[links.length - 1]?.focus();
} else if (event.key === 'Escape') {
event.preventDefault();
setOpen(false, 'button');
}
});

versionEl.addEventListener('focusout', event => {
if (!versionEl.contains(event.relatedTarget)) {
setOpen(false);
}
});

document.addEventListener('pointerdown', event => {
if (isOpen() && !versionEl.contains(event.target)) {
setOpen(false);
button.focus();
}
});
}
Expand Down
Loading