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 <deepakkudi23@adsl-172-10-9-116.dsl.sndg02.sbcglobal.net>
Co-authored-by: Luffy <lufei@docsifyjs.org>
This commit is contained in:
Puneet Dixit
2026-09-19 08:32:04 +05:30
committed by GitHub
parent 72fd7f93ef
commit a408e76cc9
8 changed files with 502 additions and 43 deletions
+61 -22
View File
@@ -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');
+7 -5
View File
@@ -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');
+26 -8
View File
@@ -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 });
});
}
+15 -6
View File
@@ -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' });
});
}
+5 -2
View File
@@ -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;
});
}
+103
View File
@@ -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<string, string>}
+199
View File
@@ -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 = {
+86
View File
@@ -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 = '<a href="/guide"><span>Guide</span></a>';
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 = `
<aside class="sidebar">
<a class="app-name-link" href="/guide">Docsify</a>
<nav>
<a class="page-link" href="/guide">Guide</a>
</nav>
</aside>
`;
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 = `<nav><a href="${href}">Link</a></nav>`;
const navElm = /** @type {HTMLElement} */ (document.querySelector('nav'));
expect(findLinkByHref(navElm, href)).toBe(document.querySelector('a'));
});
});
// resolvePath()
// ---------------------------------------------------------------------------
describe('resolvePath()', () => {