mirror of
https://github.com/docsifyjs/docsify.git
synced 2026-10-16 20:21:38 +08:00
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:
+61
-22
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()', () => {
|
||||
|
||||
Reference in New Issue
Block a user