From a408e76cc9115ce40c54463acfb5f8b181f2f291 Mon Sep 17 00:00:00 2001 From: Puneet Dixit Date: Sat, 19 Sep 2026 08:32:04 +0530 Subject: [PATCH] fix(a11y): preserve sidebar focus after navigation (#2740) * Keep focus on sidebar links after navigation * fix(a11y): preserve sidebar focus after navigation --------- Co-authored-by: Deepak kudi Co-authored-by: Luffy --- src/core/event/index.js | 83 +++++++++---- src/core/render/index.js | 12 +- src/core/router/history/hash.js | 34 ++++-- src/core/router/history/html5.js | 21 +++- src/core/router/index.js | 7 +- src/core/router/util.js | 103 ++++++++++++++++ test/e2e/sidebar.test.js | 199 +++++++++++++++++++++++++++++++ test/unit/router-util.test.js | 86 +++++++++++++ 8 files changed, 502 insertions(+), 43 deletions(-) diff --git a/src/core/event/index.js b/src/core/event/index.js index 8c70108eb3..74120afa32 100644 --- a/src/core/event/index.js +++ b/src/core/event/index.js @@ -1,7 +1,11 @@ import { isMobile, mobileBreakpoint } from '../util/env.js'; import { noop } from '../util/core.js'; import * as dom from '../util/dom.js'; -import { stripUrlExceptId } from '../router/util.js'; +import { + findLinkByHref, + resolveHref, + stripUrlExceptId, +} from '../router/util.js'; /** @typedef {import('../Docsify.js').Constructor} Constructor */ @@ -403,9 +407,11 @@ export function Events(Base) { * @param {undefined|"history"|"navigate"} source Type of navigation where * undefined is initial load, "history" is forward/back, and "navigate" is * user click/tap + * @param {import('../router/util.js').SidebarNavigationTarget} [focusTarget] + * Sidebar link to restore after rendering * @void */ - onNavigate(source) { + onNavigate(source, focusTarget) { const { auto2top, topMargin } = this.config; const { path, query } = this.route; const activeSidebarElm = this.#markSidebarActiveElm(); @@ -446,7 +452,12 @@ export function Events(Base) { // Clicked anchor link or page load with anchor ID if (hasId || isNavigate) { - this.#focusContent(); + const sidebarFocused = + isNavigate && this.#focusSidebarNavigation(focusTarget); + + if (!sidebarFocused) { + this.#focusContent(); + } } } @@ -494,11 +505,47 @@ export function Events(Base) { return focusEl; } + /** + * Restore focus to the rendered sidebar link that initiated navigation. + * + * @param {import('../router/util.js').SidebarNavigationTarget} [target] + * Sidebar navigation target + * @returns {boolean} True when focus was restored + */ + #focusSidebarNavigation(target) { + if (!target || isMobile()) { + return false; + } + + const sidebarElm = dom.find('.sidebar'); + + if (!sidebarElm) { + return false; + } + + const focusElm = /** @type {HTMLElement|undefined} */ ( + dom + .findAll(sidebarElm, 'a') + .find( + linkElm => + linkElm.classList.contains(target.className) && + /** @type {HTMLAnchorElement} */ (linkElm).href === target.href, + ) + ); + + if (!focusElm) { + return false; + } + + focusElm.focus({ preventScroll: true }); + return true; + } + /** * Marks the active app nav item */ #markAppNavActiveElm() { - const href = decodeURIComponent(this.router.toURL(this.route.path)); + const href = resolveHref(this.router.toURL(this.route.path)); ['.app-nav', '.app-nav-merged'].forEach(selector => { const navElm = dom.find(selector); @@ -511,13 +558,7 @@ export function Events(Base) { dom.findAll(navElm, 'a') ) .sort((a, b) => b.href.length - a.href.length) - .find( - a => - href.includes(/** @type {string} */ (a.getAttribute('href'))) || - href.includes( - decodeURI(/** @type {string} */ (a.getAttribute('href'))), - ), - ) + .find(a => href.includes(a.href)) ?.closest('li'); const oldActive = dom.find(navElm, 'li.active'); @@ -544,13 +585,14 @@ export function Events(Base) { return; } - href = stripUrlExceptId(href); + const matchingHref = stripUrlExceptId(/** @type {string} */ (href)); const oldActive = dom.find(sidebar, 'li.active'); - const sidebarSelector = `.sidebar-nav a[href="${href}"], .sidebar-nav a[href="${decodeURIComponent( - /** @type {string} */ (href), - )}"]`; - const newActive = dom.find(sidebar, sidebarSelector)?.closest('li'); + const newActive = findLinkByHref( + sidebar, + matchingHref, + '.sidebar-nav a', + )?.closest('li'); if (newActive && newActive !== oldActive) { oldActive?.classList.remove('active'); @@ -578,12 +620,9 @@ export function Events(Base) { const path = href?.split('?')[0]; const oldPage = dom.find(sidebar, 'li[aria-current]'); - const newPage = dom - .find( - sidebar, - `a[href="${path}"], a[href="${decodeURIComponent(/** @type {string} */ (path))}"]`, - ) - ?.closest('li'); + const newPage = path + ? findLinkByHref(sidebar, path, '.sidebar-nav a')?.closest('li') + : undefined; if (newPage && newPage !== oldPage) { oldPage?.removeAttribute('aria-current'); diff --git a/src/core/render/index.js b/src/core/render/index.js index f69a8fa7ca..76ed6acbf8 100644 --- a/src/core/render/index.js +++ b/src/core/render/index.js @@ -1,6 +1,11 @@ import tinydate from 'tinydate'; import * as dom from '../util/dom.js'; -import { cleanPath, getPath, isAbsolutePath } from '../router/util.js'; +import { + cleanPath, + findLinkByHref, + getPath, + isAbsolutePath, +} from '../router/util.js'; import { isMobile } from '../util/env.js'; import { isExternal, isPrimitive } from '../util/core.js'; import { Compiler } from './compiler.js'; @@ -360,11 +365,8 @@ export function Render(Base) { sidebarToggleEl.setAttribute('aria-expanded', String(!isMobile())); - const activeElmHref = decodeURIComponent( - this.router.toURL(this.route.path), - ); const activeEl = /** @type {HTMLElement | null} */ ( - dom.find(`.sidebar-nav a[href="${activeElmHref}"]`) + findLinkByHref(sidebarNavEl, this.router.toURL(this.route.path), 'a') ); this.#addTextAsTitleAttribute('.sidebar-nav a'); diff --git a/src/core/router/history/hash.js b/src/core/router/history/hash.js index 8bd091d53d..76a7c91005 100644 --- a/src/core/router/history/hash.js +++ b/src/core/router/history/hash.js @@ -1,6 +1,13 @@ import { isExternal, noop } from '../../util/core.js'; import { on } from '../../util/dom.js'; -import { parseQuery, cleanPath, replaceSlug } from '../util.js'; +import { + cleanPath, + getClickedLink, + getSidebarNavigationTarget, + isCurrentContextNavigation, + parseQuery, + replaceSlug, +} from '../util.js'; import { History } from './base.js'; function replaceHash(path) { @@ -34,35 +41,46 @@ export class HashHistory extends History { return index === -1 ? '' : href.slice(index + 1); } - /** @param {(params: {source: any, event?: any}) => void} [cb] */ + /** @param {(params: {source: any, focusTarget?: import('../util.js').SidebarNavigationTarget}) => void} [cb] */ onchange(cb = noop) { // The hashchange event does not tell us if it originated from // a clicked link or by moving back/forward in the history; // therefore we set a `navigating` flag when a link is clicked // to be able to tell these two scenarios apart let navigating = false; + let navigatingFocusTarget; on('click', e => { - const el = e.target.tagName === 'A' ? e.target : e.target.parentNode; + const el = getClickedLink(e); - if (el && el.tagName === 'A' && !isExternal(el.href)) { + if (el && isCurrentContextNavigation(e, el) && !isExternal(el.href)) { navigating = true; + navigatingFocusTarget = getSidebarNavigationTarget(el); // Do not compare hash containing these classes. - if (['app-name-link', 'page-link'].includes(el.className)) { + if (el.matches('.app-name-link, .page-link')) { + if (el.hash === location.hash) { + navigating = false; + navigatingFocusTarget = undefined; + } return; } if (el.hash === location.hash) { - cb({ event: e, source: 'navigate' }); + cb({ focusTarget: navigatingFocusTarget, source: 'navigate' }); + navigating = false; + navigatingFocusTarget = undefined; } } }); - on('hashchange', e => { + on('hashchange', () => { const source = navigating ? 'navigate' : 'history'; + const focusTarget = navigating ? navigatingFocusTarget : undefined; + navigating = false; - cb({ event: e, source }); + navigatingFocusTarget = undefined; + cb({ focusTarget, source }); }); } diff --git a/src/core/router/history/html5.js b/src/core/router/history/html5.js index 48729b57f5..e2164adcc7 100644 --- a/src/core/router/history/html5.js +++ b/src/core/router/history/html5.js @@ -1,6 +1,12 @@ import { isExternal, noop } from '../../util/core.js'; import { on } from '../../util/dom.js'; -import { parseQuery, getPath } from '../util.js'; +import { + getClickedLink, + getPath, + getSidebarNavigationTarget, + isCurrentContextNavigation, + parseQuery, +} from '../util.js'; import { History } from './base.js'; export class HTML5History extends History { @@ -20,18 +26,21 @@ export class HTML5History extends History { /** @param {(params: any) => void} [cb] */ onchange(cb = noop) { on('click', e => { - const el = e.target.tagName === 'A' ? e.target : e.target.parentNode; + const el = getClickedLink(e); - if (el && el.tagName === 'A' && !isExternal(el.href)) { + if (el && isCurrentContextNavigation(e, el) && !isExternal(el.href)) { e.preventDefault(); const url = el.href; window.history.pushState({ key: url }, '', url); - cb({ event: e, source: 'navigate' }); + cb({ + focusTarget: getSidebarNavigationTarget(el), + source: 'navigate', + }); } }); - on('popstate', e => { - cb({ event: e, source: 'history' }); + on('popstate', () => { + cb({ source: 'history' }); }); } diff --git a/src/core/router/index.js b/src/core/router/index.js index f7b985314c..80ec1fe445 100644 --- a/src/core/router/index.js +++ b/src/core/router/index.js @@ -52,11 +52,14 @@ export function Router(Base) { this._updateRender(); if (lastRoute.path === this.route.path) { - this.onNavigate(params.source); + this.onNavigate(params.source, params.focusTarget); return; } - this.$fetch(noop, this.onNavigate.bind(this, params.source)); + this.$fetch( + noop, + this.onNavigate.bind(this, params.source, params.focusTarget), + ); lastRoute = this.route; }); } diff --git a/src/core/router/util.js b/src/core/router/util.js index 54118b82df..218c14f828 100644 --- a/src/core/router/util.js +++ b/src/core/router/util.js @@ -3,6 +3,109 @@ import { cached } from '../util/core.js'; const decode = decodeURIComponent; const encode = encodeURIComponent; +/** + * @typedef {{ + * className: 'app-name-link' | 'page-link' | 'section-link'; + * href: string; + * }} SidebarNavigationTarget + */ + +const sidebarNavigationClassNames = /** @type {const} */ ([ + 'app-name-link', + 'page-link', + 'section-link', +]); + +/** + * Resolve a link value using the same URL normalization as an anchor element. + * + * @param {string} href Link value + * @returns {string} + */ +export function resolveHref(href) { + try { + return new URL(href, location.href).href; + } catch { + return href; + } +} + +/** + * Find an anchor by its normalized URL without interpolating the URL into a + * CSS selector. + * + * @param {Element} rootElm Element to search within + * @param {string} href Link value + * @param {string} [selector] Anchor selector + * @returns {HTMLAnchorElement|null} + */ +export function findLinkByHref(rootElm, href, selector = 'a') { + const resolvedHref = resolveHref(href); + + return ( + /** @type {HTMLAnchorElement[]} */ ( + Array.from(rootElm.querySelectorAll(selector)) + ).find(linkElm => linkElm.href === resolvedHref) || null + ); +} + +/** + * Get the anchor associated with a click event. + * + * @param {MouseEvent} event Click event + * @returns {HTMLAnchorElement|null} + */ +export function getClickedLink(event) { + const target = event.target; + + return target instanceof Element + ? /** @type {HTMLAnchorElement|null} */ (target.closest('a')) + : null; +} + +/** + * Check whether a click will navigate the current browsing context. + * + * @param {MouseEvent} event Click event + * @param {HTMLAnchorElement} linkElm Clicked link + * @returns {boolean} + */ +export function isCurrentContextNavigation(event, linkElm) { + return !( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + linkElm.hasAttribute('download') || + (linkElm.target && linkElm.target !== '_self') + ); +} + +/** + * Create a stable description of the clicked sidebar link so it can be found + * again after the sidebar has been rendered. + * + * @param {HTMLAnchorElement} linkElm Clicked link + * @returns {SidebarNavigationTarget|undefined} + */ +export function getSidebarNavigationTarget(linkElm) { + const sidebarElm = linkElm.closest('.sidebar'); + const className = sidebarNavigationClassNames.find(className => + linkElm.classList.contains(className), + ); + + if (!sidebarElm || !className) { + return; + } + + return { + className, + href: linkElm.href, + }; +} + /** * @param {string} query * @return {Record} diff --git a/test/e2e/sidebar.test.js b/test/e2e/sidebar.test.js index 88f49a5810..ebc18355a1 100644 --- a/test/e2e/sidebar.test.js +++ b/test/e2e/sidebar.test.js @@ -641,6 +641,36 @@ test.describe('Sidebar Tests', () => { }); test.describe('Mobile sidebar toggle', () => { + test('moves focus to content after sidebar navigation', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + + await docsifyInit({ + markdown: { + homepage: '# Home', + sidebar: ` + - [Home](/) + - [Guide](guide) + `, + }, + routes: { + '/guide.md': '# Guide', + }, + styleURLs: ['/dist/themes/core.css'], + }); + + await page.locator('.sidebar-toggle-button').click(); + + const guideLinkElm = page + .locator('.sidebar-nav') + .getByRole('link', { name: 'Guide' }); + + await guideLinkElm.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#guide')).toBeVisible(); + await expect(page.locator('#guide')).toBeFocused(); + await expect(page.locator('.sidebar')).not.toHaveClass(/show/); + }); + test('wraps long links without causing horizontal overflow', async ({ page, }) => { @@ -677,6 +707,175 @@ test.describe('Mobile sidebar toggle', () => { }); }); +test('keeps focus on activated sidebar page links', async ({ page }) => { + const docsifyInitConfig = { + config: { + name: 'Docsify', + nameLink: '#/', + }, + markdown: { + homepage: ` + # Home + `, + sidebar: ` + - [Home](/) + - [Guide](guide) + `, + }, + routes: { + '/guide.md': ` + # Guide + `, + }, + }; + + await docsifyInit(docsifyInitConfig); + + const guideLinkElm = page.locator('.sidebar-nav a[href="#/guide"]'); + + await guideLinkElm.focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/#\/guide$/); + await expect(page.locator('#guide')).toBeVisible(); + await expect(guideLinkElm).toBeFocused(); + + const homeLinkElm = page.locator('.sidebar-nav a[href="#/"]'); + + await homeLinkElm.focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/#\/$/); + await expect(page.locator('#home')).toBeVisible(); + await expect(homeLinkElm).toBeFocused(); + await expect(page.locator('.app-name-link')).not.toBeFocused(); +}); + +test('keeps focus on activated app name and section links', async ({ + page, +}) => { + await docsifyInit({ + config: { + name: 'Docsify', + nameLink: '#/guide', + subMaxLevel: 2, + }, + markdown: { + homepage: '# Home', + sidebar: ` + - [Home](/) + - [Guide](guide) + `, + }, + routes: { + '/guide.md': ` + # Guide + + ## Details + `, + }, + }); + + const appNameLinkElm = page.locator('.app-name-link'); + + await appNameLinkElm.focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/#\/guide$/); + await expect(page.locator('#guide')).toBeVisible(); + await expect(appNameLinkElm).toBeFocused(); + + const sectionLinkElm = page + .locator('.sidebar-nav') + .getByRole('link', { name: 'Details' }); + + await sectionLinkElm.focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/#\/guide\?id=details$/); + await expect(sectionLinkElm).toBeFocused(); +}); + +test('restores sidebar focus for encoded URLs', async ({ page }) => { + await docsifyInit({ + markdown: { + homepage: '# Home', + sidebar: '- [Quoted path](say%22hi)', + }, + routes: { + '/say%22hi.md': '# Quoted path', + }, + }); + + const quotedLinkElm = page + .locator('.sidebar-nav') + .getByRole('link', { name: 'Quoted path' }); + + await quotedLinkElm.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#quoted-path')).toBeVisible(); + await expect(quotedLinkElm).toBeFocused(); +}); + +test('does not reuse focus from a cancelled sidebar navigation', async ({ + page, +}) => { + await docsifyInit({ + markdown: { + homepage: '# Home', + sidebar: ` + - [Guide](guide) + - [Other](other) + `, + }, + routes: { + '/guide.md': '# Guide', + '/other.md': '# Other', + }, + }); + + const guideLinkElm = page.getByRole('link', { name: 'Guide' }); + + await guideLinkElm.evaluate(linkElm => { + linkElm.addEventListener('click', event => event.preventDefault(), { + once: true, + }); + }); + await guideLinkElm.focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/#\/$/); + + await page.evaluate(() => { + location.hash = '#/other'; + }); + await expect(page).toHaveURL(/#\/other$/); + await expect(page.locator('#other')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Guide' })).not.toBeFocused(); +}); + +test('keeps focus on sidebar page links in history mode', async ({ page }) => { + await docsifyInit({ + config: { + name: 'Docsify', + nameLink: '/guide', + routerMode: 'history', + }, + markdown: { + homepage: '# Home', + sidebar: '- [Guide](guide)', + }, + routes: { + '/guide.md': '# Guide', + }, + waitForSelector: '.sidebar-nav a[href="/guide"]', + }); + + const guideLinkElm = page.locator('.sidebar-nav a[href="/guide"]'); + + await guideLinkElm.focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/guide$/); + await expect(page.locator('#guide')).toBeVisible(); + await expect(guideLinkElm).toBeFocused(); + await expect(page.locator('.app-name-link')).not.toBeFocused(); +}); + test.describe('Configuration: autoHeader', () => { test('autoHeader=false', async ({ page }) => { const docsifyInitConfig = { diff --git a/test/unit/router-util.test.js b/test/unit/router-util.test.js index 82973fa3a5..e724f165c7 100644 --- a/test/unit/router-util.test.js +++ b/test/unit/router-util.test.js @@ -1,8 +1,94 @@ import { resolvePath } from '../../src/core/util/index.js'; +import { + findLinkByHref, + getClickedLink, + getSidebarNavigationTarget, + isCurrentContextNavigation, +} from '../../src/core/router/util.js'; // Suite // ----------------------------------------------------------------------------- describe('router/util', () => { + describe('navigation click helpers', () => { + test('finds a link from a nested click target', () => { + document.body.innerHTML = 'Guide'; + const spanElm = /** @type {HTMLElement} */ ( + document.querySelector('span') + ); + const event = new MouseEvent('click', { bubbles: true }); + + spanElm.dispatchEvent(event); + + expect(getClickedLink(event)).toBe(document.querySelector('a')); + }); + + test.each([ + ['alternate button', { button: 1 }], + ['Alt modifier', { altKey: true }], + ['Control modifier', { ctrlKey: true }], + ['Meta modifier', { metaKey: true }], + ['Shift modifier', { shiftKey: true }], + ])('ignores %s clicks', (name, eventInit) => { + const linkElm = document.createElement('a'); + const event = new MouseEvent('click', { + button: 0, + ...eventInit, + }); + + expect(isCurrentContextNavigation(event, linkElm)).toBe(false); + }); + + test('ignores cancelled, download, and new-context navigation', () => { + const linkElm = document.createElement('a'); + const cancelledEvent = new MouseEvent('click', { + button: 0, + cancelable: true, + }); + + cancelledEvent.preventDefault(); + expect(isCurrentContextNavigation(cancelledEvent, linkElm)).toBe(false); + + linkElm.setAttribute('download', ''); + expect(isCurrentContextNavigation(new MouseEvent('click'), linkElm)).toBe( + false, + ); + + linkElm.removeAttribute('download'); + linkElm.target = '_blank'; + expect(isCurrentContextNavigation(new MouseEvent('click'), linkElm)).toBe( + false, + ); + }); + + test('describes a sidebar link by class and URL', () => { + document.body.innerHTML = ` + + `; + const linkElm = /** @type {HTMLAnchorElement} */ ( + document.querySelector('.page-link') + ); + + expect(getSidebarNavigationTarget(linkElm)).toEqual({ + className: 'page-link', + href: linkElm.href, + }); + }); + + test('finds encoded URLs without using them as selectors', () => { + const href = '#/say%22hi'; + + document.body.innerHTML = ``; + const navElm = /** @type {HTMLElement} */ (document.querySelector('nav')); + + expect(findLinkByHref(navElm, href)).toBe(document.querySelector('a')); + }); + }); + // resolvePath() // --------------------------------------------------------------------------- describe('resolvePath()', () => {