From 76402ca0631121413be3ee567e7753222bc1d6d7 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 31 Jul 2026 09:28:56 +0200 Subject: [PATCH 1/2] Improve docs site HTML accessibility and keyboard navigation Fix invalid interactive nesting, add skip navigation and landmark labels, replace checkbox/label sidebar controls with buttons, and use native dialog for image previews. Add regression tests for the updated markup. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../Assets/image-carousel.ts | 17 -- .../Assets/image-dialog.ts | 61 ++++++ src/Elastic.Documentation.Site/Assets/main.ts | 2 + .../Assets/modal.css | 26 ++- .../Assets/pages-nav.ts | 178 ++++++++++++++---- .../Assets/styles.css | 28 +-- .../Layout/_PagesNav.cshtml | 22 ++- .../Layout/_SecondaryNav.cshtml | 1 + .../Navigation/_TocTree.cshtml | 50 ++--- .../Navigation/_TocTreeNav.cshtml | 22 +-- .../_GlobalLayout.cshtml | 7 +- .../journeys/navigation-test.journey.ts | 62 +++++- .../Layout/_TableOfContents.cshtml | 12 +- .../Directives/Image/ImageCarouselView.cshtml | 26 +-- .../Myst/Directives/Image/ImageView.cshtml | 29 +-- .../Renderers/SectionedHeadingRenderer.cs | 1 + src/Elastic.Markdown/_Layout.cshtml | 3 +- .../Directives/ImageTests.cs | 12 ++ .../Inline/InlineAnchorTests.cs | 4 +- .../Inline/SubstitutionTest.cs | 2 +- tests/authoring/Blocks/Admonitions.fs | 10 +- tests/authoring/Directives/IncludeBlocks.fs | 4 +- .../authoring/Generator/LinkReferenceFile.fs | 4 +- tests/authoring/Layout/GlobalLayoutShell.fs | 51 +++++ tests/authoring/authoring.fsproj | 1 + 25 files changed, 466 insertions(+), 169 deletions(-) create mode 100644 src/Elastic.Documentation.Site/Assets/image-dialog.ts create mode 100644 tests/authoring/Layout/GlobalLayoutShell.fs diff --git a/src/Elastic.Documentation.Site/Assets/image-carousel.ts b/src/Elastic.Documentation.Site/Assets/image-carousel.ts index 0091e65773..1ebb2a5dc0 100644 --- a/src/Elastic.Documentation.Site/Assets/image-carousel.ts +++ b/src/Elastic.Documentation.Site/Assets/image-carousel.ts @@ -70,23 +70,6 @@ class ImageCarousel { indicator.addEventListener('click', () => this.goToSlide(index)) }) - // Handle image clicks for modal - this.slides.forEach((slide) => { - const imageLink = slide.querySelector('.carousel-image-reference') - if (imageLink) { - imageLink.addEventListener('click', (e) => { - e.preventDefault() - const modalId = imageLink.getAttribute('data-modal-id') - if (modalId) { - const modal = document.getElementById(modalId) - if (modal) { - modal.style.display = 'flex' - } - } - }) - } - }) - // Keyboard navigation document.addEventListener('keydown', (e) => { if (!this.isInViewport()) return diff --git a/src/Elastic.Documentation.Site/Assets/image-dialog.ts b/src/Elastic.Documentation.Site/Assets/image-dialog.ts new file mode 100644 index 0000000000..a97b3d5235 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/image-dialog.ts @@ -0,0 +1,61 @@ +const triggers = new WeakMap() +let delegatedListenersInitialized = false + +function closeDialog(dialog: HTMLDialogElement) { + if (dialog.open) dialog.close() +} + +function initializeDelegatedListeners() { + if (delegatedListenersInitialized) return + delegatedListenersInitialized = true + + document.addEventListener('click', (event) => { + if (!(event.target instanceof Element)) return + + const openButton = event.target.closest('[data-image-dialog-open]') + if (openButton instanceof HTMLButtonElement) { + const dialogId = openButton.getAttribute('aria-controls') + if (!dialogId) return + + const dialog = document.getElementById(dialogId) + if (!(dialog instanceof HTMLDialogElement)) return + + triggers.set(dialog, openButton) + dialog.showModal() + const closeButton = dialog.querySelector( + '[data-image-dialog-close]' + ) + if (closeButton instanceof HTMLButtonElement) closeButton.focus() + return + } + + const closeButton = event.target.closest('[data-image-dialog-close]') + if (closeButton instanceof HTMLButtonElement) { + const dialog = closeButton.closest('dialog') + if (dialog instanceof HTMLDialogElement) closeDialog(dialog) + return + } + + if ( + event.target instanceof HTMLDialogElement && + event.target.matches('[data-image-dialog]') + ) { + closeDialog(event.target) + } + }) +} + +export function initImageDialogs() { + initializeDelegatedListeners() + + document + .querySelectorAll('dialog[data-image-dialog]') + .forEach((dialog) => { + if (dialog.dataset.initialized === 'true') return + dialog.dataset.initialized = 'true' + dialog.addEventListener('close', () => { + triggers.get(dialog)?.focus() + triggers.delete(dialog) + }) + }) +} diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 20e94dfebf..2f58efd87a 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -5,6 +5,7 @@ import { config } from './config' import { initCopyButton } from './copybutton' import { initHighlight } from './hljs' import { initImageCarousel } from './image-carousel' +import { initImageDialogs } from './image-dialog' import { initMermaid } from './mermaid' import { openDetailsWithAnchor } from './open-details-with-anchor' import { initNav } from './pages-nav' @@ -208,6 +209,7 @@ document.addEventListener('htmx:load', function () { ['initSmoothScroll', initSmoothScroll], ['openDetailsWithAnchor', openDetailsWithAnchor], ['initImageCarousel', initImageCarousel], + ['initImageDialogs', initImageDialogs], ['initTable', initTable], ['initApiDocs', initApiDocs], ['applyEditParam', applyEditParam], diff --git a/src/Elastic.Documentation.Site/Assets/modal.css b/src/Elastic.Documentation.Site/Assets/modal.css index 207742cde0..18ac13cb2e 100644 --- a/src/Elastic.Documentation.Site/Assets/modal.css +++ b/src/Elastic.Documentation.Site/Assets/modal.css @@ -1,9 +1,21 @@ .modal { - @apply fixed inset-0 z-50 flex hidden items-center justify-center bg-black/50; + @apply fixed inset-0 z-50 m-auto max-h-none max-w-none items-center justify-center border-0 bg-transparent p-0; + width: 100%; + height: 100%; +} + +.modal[open] { + display: flex; +} + +.modal::backdrop { + background: rgb(0 0 0 / 50%); } .modal-content { @apply relative w-full max-w-3xl items-center rounded-lg bg-white p-6 shadow-lg; + max-height: calc(100vh - 2rem); + overflow: auto; } .modal-content figure { @@ -23,11 +35,15 @@ } .modal-close { - @apply absolute top-2 right-4 cursor-pointer transition-colors; + @apply text-ink-dark hover:text-ink absolute top-2 right-2 z-10 flex min-h-10 min-w-10 cursor-pointer items-center justify-center rounded-sm bg-white text-2xl font-bold transition-colors; +} - & > a { - @apply text-ink-dark hover:text-ink text-2xl font-bold no-underline; - } +.image-dialog-trigger { + @apply cursor-zoom-in border-0 bg-transparent p-0 text-left; +} + +body:has(dialog.modal[open]) { + overflow: hidden; } /* Search or Ask AI animation for secondary buttons */ diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav.ts b/src/Elastic.Documentation.Site/Assets/pages-nav.ts index 7c70c7bf1a..b4ba1dc074 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav.ts @@ -2,28 +2,50 @@ import { throttle } from 'lodash' import { $optional, $$optional } from 'select-dom' const NAV_STATE_KEY = 'nav-expanded' +let controlsInitialized = false +let pagesNavTrigger: HTMLButtonElement | null = null function isDevMode() { return !!document.querySelector('diagnostics-panel') } function saveNavState(nav: HTMLElement) { - const expanded = $$optional('input[type="checkbox"]:checked', nav) - .map((el) => el.id) + const expanded = $$optional( + 'button[data-nav-toggle][aria-expanded="true"]', + nav + ) + .map((el) => el.getAttribute('aria-controls')) .filter(Boolean) sessionStorage.setItem(NAV_STATE_KEY, JSON.stringify(expanded)) } +function setExpanded(button: HTMLButtonElement, expanded: boolean) { + const controlledId = button.getAttribute('aria-controls') + if (!controlledId) return + + const controlled = document.getElementById(controlledId) + if (!controlled) return + + button.setAttribute('aria-expanded', expanded.toString()) + controlled.hidden = !expanded +} + function restoreNavState(nav: HTMLElement) { const raw = sessionStorage.getItem(NAV_STATE_KEY) if (!raw) return try { const ids: string[] = JSON.parse(raw) for (const id of ids) { - const input = $optional(`#${CSS.escape(id)}`, nav) - if (input instanceof HTMLInputElement) { - input.checked = true - } + const button = + $optional( + `button[data-nav-toggle][aria-controls="${CSS.escape(id)}"]`, + nav + ) ?? + $optional( + `button[data-nav-toggle][aria-controls="${CSS.escape(`nav-subtree-${id}`)}"]`, + nav + ) + if (button instanceof HTMLButtonElement) setExpanded(button, true) } } catch { /* ignore corrupt storage */ @@ -33,14 +55,121 @@ function restoreNavState(nav: HTMLElement) { function expandAllParents(navItem: HTMLElement) { let parent: HTMLLIElement | null | undefined = navItem?.closest('li') while (parent) { - const input = parent.querySelector('input') - if (input instanceof HTMLInputElement) { - input.checked = true - } + const button = parent.querySelector( + ':scope > div > button[data-nav-toggle]' + ) + if (button instanceof HTMLButtonElement) setExpanded(button, true) parent = parent.parentElement?.closest('li') } } +function setPagesNavOpen(open: boolean, restoreFocus = false) { + const panel = document.querySelector('[data-pages-nav-panel]') + const backdrop = document.querySelector('[data-pages-nav-backdrop]') + if (!(panel instanceof HTMLElement)) return + + panel.dataset.open = open.toString() + if (backdrop instanceof HTMLButtonElement) backdrop.hidden = !open + document.body.classList.toggle('overflow-hidden', open) + $$optional('[data-pages-nav-open]').forEach((button) => + button.setAttribute('aria-expanded', open.toString()) + ) + + if (open) { + const closeButton = panel.querySelector('[data-pages-nav-close]') + if (closeButton instanceof HTMLButtonElement) closeButton.focus() + } else if (restoreFocus) { + pagesNavTrigger?.focus() + } +} + +function initializeControls() { + if (controlsInitialized) return + controlsInitialized = true + + document.addEventListener('click', (event) => { + if (!(event.target instanceof Element)) return + + const openDropdown = document.querySelector( + '[data-pages-dropdown-toggle][aria-expanded="true"]' + ) + if ( + openDropdown instanceof HTMLButtonElement && + !event.target.closest('#pages-dropdown') + ) { + setExpanded(openDropdown, false) + } + + const openButton = event.target.closest('[data-pages-nav-open]') + if (openButton instanceof HTMLButtonElement) { + pagesNavTrigger = openButton + setPagesNavOpen(true) + return + } + + if ( + event.target.closest('[data-pages-nav-close]') || + event.target.closest('[data-pages-nav-backdrop]') + ) { + setPagesNavOpen(false, true) + return + } + + const navToggle = event.target.closest('[data-nav-toggle]') + if (navToggle instanceof HTMLButtonElement) { + setExpanded( + navToggle, + navToggle.getAttribute('aria-expanded') !== 'true' + ) + const pagesNav = $optional('#pages-nav') + if (isDevMode() && pagesNav) saveNavState(pagesNav) + return + } + + const dropdownToggle = event.target.closest( + '[data-pages-dropdown-toggle]' + ) + if (dropdownToggle instanceof HTMLButtonElement) { + setExpanded( + dropdownToggle, + dropdownToggle.getAttribute('aria-expanded') !== 'true' + ) + } + }) + + document.addEventListener('focusin', (event) => { + if ( + event.target instanceof Element && + !event.target.closest('#pages-dropdown') + ) { + const openDropdown = document.querySelector( + '[data-pages-dropdown-toggle][aria-expanded="true"]' + ) + if (openDropdown instanceof HTMLButtonElement) { + setExpanded(openDropdown, false) + } + } + }) + + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return + + const openDropdown = document.querySelector( + '[data-pages-dropdown-toggle][aria-expanded="true"]' + ) + if (openDropdown instanceof HTMLButtonElement) { + setExpanded(openDropdown, false) + openDropdown.focus() + return + } + + const panel = document.querySelector('[data-pages-nav-panel]') + if (panel instanceof HTMLElement && panel.dataset.open === 'true') { + setPagesNavOpen(false, true) + } + }) +} + function scrollCurrentNaviItemIntoViewImpl(nav: HTMLElement) { const currentNavItem = $optional('.current', nav) @@ -94,36 +223,14 @@ export const scrollCurrentNaviItemIntoView = throttle( { leading: false, trailing: true } ) -/** - * Prevents focus-based dropdowns from closing before link navigation completes. - * Without this, clicking a link inside the dropdown would transfer focus away, - * causing the dropdown to close via CSS :focus-within before navigation happens. - */ -function preventFocusLossOnLinkClick(anchor: HTMLAnchorElement) { - anchor.addEventListener('mousedown', (e) => { - e.preventDefault() - }) - // Close dropdown after click completes - anchor.addEventListener('mouseup', () => { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur() - } - }) -} - export function initNav() { + initializeControls() + const pagesNav = $optional('#pages-nav') if (!pagesNav) { return } - const dropdownActiveAnchor = $optional( - '#pages-dropdown a.pages-dropdown_active' - ) - if (dropdownActiveAnchor) { - preventFocusLossOnLinkClick(dropdownActiveAnchor) - } - if (isDevMode()) { restoreNavState(pagesNav) } @@ -155,8 +262,5 @@ export function initNav() { if (isDevMode()) { saveNavState(pagesNav) - for (const cb of $$optional('input[type="checkbox"]', pagesNav)) { - cb.addEventListener('change', () => saveNavState(pagesNav)) - } } } diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 2f3fb6f1c7..6b068278c9 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -161,23 +161,22 @@ body { } .nav-toggle-btn { - @apply hover:bg-grey-20 cursor-pointer rounded-sm p-1; + @apply hover:bg-grey-20 flex min-h-8 min-w-8 cursor-pointer items-center justify-center rounded-sm p-1; } .nav-chevron { - @apply w-3.5 shrink-0 -rotate-90 group-has-checked/label:rotate-0; + @apply w-3.5 shrink-0 -rotate-90; fill: none; stroke-width: 1.5; stroke: currentColor; } - .nav-subtree { - @apply relative ml-4 hidden w-full; + .nav-toggle-btn[aria-expanded='true'] .nav-chevron { + @apply rotate-0; + } - /* revealed when the sibling .peer div contains a checked checkbox */ - .peer:has(:checked) ~ & { - display: block; - } + .nav-subtree { + @apply relative ml-4 w-full; &::before { content: ''; @@ -215,6 +214,12 @@ body { } } +@media (width < 48rem) { + .pages-nav-panel[data-open='true'] { + left: 0; + } +} + * { scroll-margin-top: calc(var(--offset-top) + var(--spacing) * 6); } @@ -505,8 +510,7 @@ h6 { text-decoration: none; } - & .headerlink::after { - content: '#'; + & .headerlink-marker { position: absolute; right: calc(100% + 0.4rem); top: 50%; @@ -519,8 +523,8 @@ h6 { pointer-events: none; } - &:hover .headerlink::after, - & .headerlink:focus::after { + &:hover .headerlink-marker, + & .headerlink:focus .headerlink-marker { opacity: 1; } } diff --git a/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml b/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml index 3cef9573dd..8a802c9832 100644 --- a/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml @@ -1,18 +1,24 @@ @inherits RazorSlice -