diff --git a/src/core/event/index.js b/src/core/event/index.js index 839576143..418257cc0 100644 --- a/src/core/event/index.js +++ b/src/core/event/index.js @@ -1,5 +1,5 @@ import { isMobile, mobileBreakpoint } from '../util/env.js'; -import { noop } from '../util/core.js'; +import { isExternal, noop } from '../util/core.js'; import * as dom from '../util/dom.js'; import { stripUrlExceptId } from '../router/util.js'; @@ -360,9 +360,10 @@ 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 {Event} [event] Navigation event * @void */ - onNavigate(source) { + onNavigate(source, event) { const { auto2top, topMargin } = this.config; const { path, query } = this.route; const activeSidebarElm = this.#markSidebarActiveElm(); @@ -403,7 +404,13 @@ export function Events(Base) { // Clicked anchor link or page load with anchor ID if (hasId || isNavigate) { - this.#focusContent(); + const sidebarFocused = + isNavigate && + this.#focusSidebarNavigation(this.#getSidebarNavigationHref(event)); + + if (!sidebarFocused) { + this.#focusContent(); + } } } @@ -451,6 +458,62 @@ export function Events(Base) { return focusEl; } + /** + * Get the clicked sidebar link HREF from a navigation event. + * + * @param {Event} [event] Navigation event + * @returns {string|undefined} + */ + #getSidebarNavigationHref(event) { + const target = event?.target; + const linkElm = + target instanceof Element + ? /** @type {HTMLAnchorElement|null} */ (target.closest('a')) + : null; + + if ( + !linkElm || + !linkElm.matches('.app-name-link, .page-link, .section-link') || + isExternal(linkElm.href) + ) { + return; + } + + return linkElm.getAttribute('href') || undefined; + } + + /** + * Restore focus to the rendered sidebar link that initiated navigation. + * + * @param {string} [href] Sidebar link HREF + * @returns {boolean} True when focus was restored + */ + #focusSidebarNavigation(href) { + if (!href || isMobile()) { + return false; + } + + const sidebarElm = dom.find('.sidebar'); + + if (!sidebarElm) { + return false; + } + + const focusElm = /** @type {HTMLElement|null} */ ( + dom.find( + sidebarElm, + `a[href="${href}"], a[href="${decodeURIComponent(href)}"]`, + ) + ); + + if (!focusElm) { + return false; + } + + focusElm.focus({ preventScroll: true }); + return true; + } + /** * Marks the active app nav item */ diff --git a/src/core/router/history/hash.js b/src/core/router/history/hash.js index 8bd091d53..83f56b33d 100644 --- a/src/core/router/history/hash.js +++ b/src/core/router/history/hash.js @@ -41,28 +41,39 @@ export class HashHistory extends 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 navigatingEvent; on('click', e => { const el = e.target.tagName === 'A' ? e.target : e.target.parentNode; if (el && el.tagName === 'A' && !isExternal(el.href)) { navigating = true; + navigatingEvent = e; // Do not compare hash containing these classes. if (['app-name-link', 'page-link'].includes(el.className)) { + if (el.hash === location.hash) { + navigating = false; + navigatingEvent = undefined; + } return; } if (el.hash === location.hash) { cb({ event: e, source: 'navigate' }); + navigating = false; + navigatingEvent = undefined; } } }); on('hashchange', e => { const source = navigating ? 'navigate' : 'history'; + const event = navigating ? navigatingEvent || e : e; + navigating = false; - cb({ event: e, source }); + navigatingEvent = undefined; + cb({ event, source }); }); } diff --git a/src/core/router/index.js b/src/core/router/index.js index f7b985314..dd10362b2 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.event); return; } - this.$fetch(noop, this.onNavigate.bind(this, params.source)); + this.$fetch( + noop, + this.onNavigate.bind(this, params.source, params.event), + ); lastRoute = this.route; }); } diff --git a/test/e2e/sidebar.test.js b/test/e2e/sidebar.test.js index ff4526601..62b1953b6 100644 --- a/test/e2e/sidebar.test.js +++ b/test/e2e/sidebar.test.js @@ -68,6 +68,43 @@ test.describe('Sidebar Tests', () => { await expect(activeLinkElm).toHaveText('Test >'); expect(page.url()).toMatch(/\/test%3Efoo$/); }); + + test('keeps focus on activated sidebar page links', async ({ page }) => { + const docsifyInitConfig = { + 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(); + }); }); test.describe('Configuration: autoHeader', () => {