diff --git a/docs/_src/version-selector.css b/docs/_src/version-selector.css index a4522a4b98..67bc7df3ce 100644 --- a/docs/_src/version-selector.css +++ b/docs/_src/version-selector.css @@ -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; diff --git a/docs/_src/version-selector.js b/docs/_src/version-selector.js index 41b635b354..e17dbe2b18 100644 --- a/docs/_src/version-selector.js +++ b/docs/_src/version-selector.js @@ -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 () { @@ -103,41 +105,104 @@ const current = versions.find(v => v.version === currentVersion) || versions[0]; const visibleVersions = versions.filter(v => !v.hidden); - const html = `
`; + const items = visibleVersions + .map(version => { + const isCurrent = version.version === currentVersion; + const currentAttr = isCurrent ? ' aria-current="page"' : ''; + const href = buildVersionURL(version.version, currentVersion); + return `
  • ${version.title}
  • `; + }) + .join(''); - return html; + return `
    `; } /** - * 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(); } }); }