diff --git a/docs/configuration.md b/docs/configuration.md index e65ef4434..2a7a3c280 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -811,6 +811,39 @@ window.$docsify = { } ``` +## skipLink + +- Type: `Boolean|String|Object` +- Default: `'Skip to main content'` + +Determines if/how the site's [skip navigation link](https://webaim.org/techniques/skipnav/) will be rendered. + +```js +// Render skip link for all routes (default) +window.$docsify = { + skipLink: 'Skip to main content', +}; +``` + +```js +// Render localized skip links based on route paths +window.$docsify = { + skipLink: { + '/es/': 'Saltar al contenido principal', + '/de-de/': 'Ga naar de hoofdinhoud', + '/ru-ru/': 'Перейти к основному содержанию', + '/zh-cn/': '跳到主要内容', + }, +}; +``` + +```js +// Do not render skip link +window.$docsify = { + skipLink: false, +}; +``` + ## subMaxLevel - Type: `Number` diff --git a/docs/index.html b/docs/index.html index 2eaeb3acb..6c7a3e434 100644 --- a/docs/index.html +++ b/docs/index.html @@ -125,6 +125,12 @@ }, pathNamespaces: ['/es', '/de-de', '/ru-ru', '/zh-cn'], }, + skipLink: { + '/es/': 'Saltar al contenido principal', + '/de-de/': 'Ga naar de hoofdinhoud', + '/ru-ru/': 'Перейти к основному содержанию', + '/zh-cn/': '跳到主要内容', + }, vueComponents: { 'button-counter': { template: /* html */ ``, diff --git a/index.html b/index.html index 17d28d098..6bed6543f 100644 --- a/index.html +++ b/index.html @@ -89,6 +89,12 @@ }, pathNamespaces: ['/es', '/de-de', '/ru-ru', '/zh-cn'], }, + skipLink: { + '/es/': 'Saltar al contenido principal', + '/de-de/': 'Ga naar de hoofdinhoud', + '/ru-ru/': 'Перейти к основному содержанию', + '/zh-cn/': '跳到主要内容', + }, vueComponents: { 'button-counter': { template: /* html */ ``, diff --git a/src/core/event/index.js b/src/core/event/index.js index 516f1fb27..7d5e3aac2 100644 --- a/src/core/event/index.js +++ b/src/core/event/index.js @@ -14,29 +14,39 @@ import config from '../config.js'; export function Events(Base) { return class Events extends Base { $resetEvents(source) { - const { auto2top } = this.config; + const { auto2top, loadNavbar } = this.config; + const { path, query } = this.route; - // If 'history', rely on the browser's scroll auto-restoration when going back or forward + // Note: Scroll position set by browser on forward/back (i.e. "history") if (source !== 'history') { // Scroll to ID if specified - if (this.route.query.id) { - this.#scrollIntoView(this.route.path, this.route.query.id); + if (query.id) { + this.#scrollIntoView(path, query.id, true); } // Scroll to top if a link was clicked and auto2top is enabled - if (source === 'navigate') { + else if (source === 'navigate') { auto2top && this.#scroll2Top(auto2top); } } - if (this.config.loadNavbar) { + // Move focus to content + if (query.id || source === 'navigate') { + this.focusContent(); + } + + if (loadNavbar) { this.__getAndActive(this.router, 'nav'); } } initEvent() { + // Bind skip link + this.#skipLink('#skip-to-content'); + // Bind toggle button this.#btn('button.sidebar-toggle', this.router); this.#collapse('.sidebar', this.router); + // Bind sticky effect if (this.config.coverpage) { !isMobile && on('scroll', this.__sticky); @@ -53,6 +63,22 @@ export function Events(Base) { #enableScrollEvent = true; #coverHeight = 0; + #skipLink(el) { + el = dom.getNode(el); + + if (el === null || el === undefined) { + return; + } + + dom.on(el, 'click', evt => { + const target = dom.getNode('#main'); + + evt.preventDefault(); + target && target.focus(); + this.#scrollTo(target); + }); + } + #scrollTo(el, offset = 0) { if (this.#scroller) { this.#scroller.stop(); @@ -75,6 +101,20 @@ export function Events(Base) { .begin(); } + focusContent() { + const { query } = this.route; + const focusEl = query.id + ? // Heading ID + dom.find(`#${query.id}`) + : // First heading + dom.find('#main :where(h1, h2, h3, h4, h5, h6)') || + // Content container + dom.find('#main'); + + // Move focus to content area + focusEl && focusEl.focus(); + } + #highlight(path) { if (!this.#enableScrollEvent) { return; diff --git a/src/core/render/compiler.js b/src/core/render/compiler.js index 3514136a4..26c66c615 100644 --- a/src/core/render/compiler.js +++ b/src/core/render/compiler.js @@ -225,7 +225,11 @@ export class Compiler { nextToc.slug = url; _self.toc.push(nextToc); - return `${str}`; + // Note: tabindex="-1" allows programmatically focusing on heading + // elements after navigation. This is preferred over focusing on the link + // within the heading because it matches the focus behavior of screen + // readers when navigating page content. + return `${str}`; }; origin.code = highlightCodeCompiler({ renderer }); diff --git a/src/core/render/index.js b/src/core/render/index.js index ea0d985e0..53e029455 100644 --- a/src/core/render/index.js +++ b/src/core/render/index.js @@ -238,6 +238,34 @@ export function Render(Base) { el.setAttribute('href', nameLink[match]); } } + + #renderSkipLink(vm) { + const { skipLink } = vm.config; + + if (skipLink !== false) { + const el = dom.getNode('#skip-to-content'); + + let skipLinkText = + typeof skipLink === 'string' ? skipLink : 'Skip to main content'; + + if (skipLink?.constructor === Object) { + const matchingPath = Object.keys(skipLink).find(path => + vm.route.path.startsWith(path.startsWith('/') ? path : `/${path}`) + ); + const matchingText = matchingPath && skipLink[matchingPath]; + + skipLinkText = matchingText || skipLinkText; + } + + if (el) { + el.innerHTML = skipLinkText; + } else { + const html = ``; + dom.body.insertAdjacentHTML('afterbegin', html); + } + } + } + _renderTo(el, content, replace) { const node = dom.getNode(el); if (node) { @@ -396,6 +424,9 @@ export function Render(Base) { _updateRender() { // Render name link this.#renderNameLink(this); + + // Render skip link + this.#renderSkipLink(this); } initRender() { @@ -409,14 +440,10 @@ export function Render(Base) { } const id = config.el || '#app'; - const navEl = dom.find('nav') || dom.create('nav'); - const el = dom.find(id); - let html = ''; - let navAppendToTarget = dom.body; if (el) { - navEl.setAttribute('aria-label', 'secondary'); + let html = ''; if (config.repo) { html += tpl.corner(config.repo, config.cornerExternalLinkTarget); @@ -437,25 +464,27 @@ export function Render(Base) { } html += tpl.main(config); + // Render main app this._renderTo(el, html, true); } else { this.rendered = true; } - if (config.mergeNavbar && isMobile) { - navAppendToTarget = dom.find('.sidebar'); - } else { - navEl.classList.add('app-nav'); - - if (!config.repo) { - navEl.classList.add('no-badge'); - } - } - // Add nav if (config.loadNavbar) { - dom.before(navAppendToTarget, navEl); + const navEl = dom.find('nav') || dom.create('nav'); + const isMergedSidebar = config.mergeNavbar && isMobile; + + navEl.setAttribute('aria-label', 'secondary'); + + if (isMergedSidebar) { + dom.find('.sidebar').prepend(navEl); + } else { + dom.body.prepend(navEl); + navEl.classList.add('app-nav'); + navEl.classList.toggle('no-badge', !config.repo); + } } if (config.themeColor) { diff --git a/src/core/render/tpl.js b/src/core/render/tpl.js index 622d22a8a..3b8dd77b8 100644 --- a/src/core/render/tpl.js +++ b/src/core/render/tpl.js @@ -59,7 +59,7 @@ export function main(config) { return /* html */ `
${aside}
-
+
`; diff --git a/src/themes/basic/_layout.styl b/src/themes/basic/_layout.styl index 4b9c7d5fb..1e37e4bd4 100644 --- a/src/themes/basic/_layout.styl +++ b/src/themes/basic/_layout.styl @@ -86,6 +86,38 @@ li input[type='checkbox'] margin 0 0.2em 0.25em 0 vertical-align middle +[tabindex="-1"]:focus + outline none !important + +/* skip link */ +#skip-to-content + appearance none + display block + position fixed + z-index 2147483647 + top 0 + left 50% + padding 0.5rem 1.5rem + border 0 + border-radius: 100vw + background-color $color-primary + background-color var(--theme-color, $color-primary) + color $color-bg + color var(--theme-bg, $color-bg) + opacity 0 + font-size inherit + text-decoration none + transform translate(-50%, -100%) + transition-property opacity, transform + transition-duration 0s, 0.2s + transition-delay 0.2s, 0s + + &:focus + opacity 1 + transform translate(-50%, 0.75rem) + transition-duration 0s, 0.2s + transition-delay 0s, 0s + /* navbar */ .app-nav margin 25px 60px 0 0 diff --git a/test/integration/__snapshots__/docs.test.js.snap b/test/integration/__snapshots__/docs.test.js.snap index 06354402e..a9fd62ca6 100644 --- a/test/integration/__snapshots__/docs.test.js.snap +++ b/test/integration/__snapshots__/docs.test.js.snap @@ -9,7 +9,7 @@ exports[`Docs Site coverpage renders and is unchanged 1`] = ` ) \\">
-

\\"logo\\"

docsify 4.13.0

+

\\"logo\\"

docsify 4.13.0

A magical documentation site generator.

  • Simple and lightweight
  • No statically built html files
  • Multiple themes

GitHub Getting Started

diff --git a/test/integration/render.test.js b/test/integration/render.test.js index 8b65f11ca..4b6fbc1b3 100644 --- a/test/integration/render.test.js +++ b/test/integration/render.test.js @@ -1,18 +1,17 @@ import stripIndent from 'common-tags/lib/stripIndent/index.js'; import docsifyInit from '../helpers/docsify-init.js'; +import { waitForText } from '../helpers/wait-for.js'; // Suite // ----------------------------------------------------------------------------- describe('render', function () { - // Setup & Teardown - // ------------------------------------------------------------------------- - beforeEach(async () => { - await docsifyInit(); - }); - // Helpers // --------------------------------------------------------------------------- describe('helpers', () => { + beforeEach(async () => { + await docsifyInit(); + }); + test('important content', () => { const output = window.marked('!> Important content'); @@ -33,6 +32,10 @@ describe('render', function () { // Lists // --------------------------------------------------------------------------- describe('lists', function () { + beforeEach(async () => { + await docsifyInit(); + }); + test('as unordered task list', async function () { const output = window.marked(stripIndent` - [x] Task 1 @@ -100,6 +103,10 @@ describe('render', function () { // Images // --------------------------------------------------------------------------- describe('images', function () { + beforeEach(async () => { + await docsifyInit(); + }); + test('regular', async function () { const output = window.marked('![alt text](http://imageUrl)'); @@ -136,35 +143,37 @@ describe('render', function () { ); }); - describe('size', function () { - test('width and height', async function () { - const output = window.marked( - "![alt text](http://imageUrl ':size=WIDTHxHEIGHT')" - ); + test('width and height', async function () { + const output = window.marked( + "![alt text](http://imageUrl ':size=WIDTHxHEIGHT')" + ); - expect(output).toMatchInlineSnapshot( - `"

\\"alt

"` - ); - }); + expect(output).toMatchInlineSnapshot( + `"

\\"alt

"` + ); + }); - test('width', async function () { - const output = window.marked("![alt text](http://imageUrl ':size=50')"); + test('width', async function () { + const output = window.marked("![alt text](http://imageUrl ':size=50')"); - expect(output).toMatchInlineSnapshot( - `"

\\"alt

"` - ); - }); + expect(output).toMatchInlineSnapshot( + `"

\\"alt

"` + ); }); }); // Headings // --------------------------------------------------------------------------- describe('headings', function () { + beforeEach(async () => { + await docsifyInit(); + }); + test('h1', async function () { const output = window.marked('# h1 tag'); expect(output).toMatchInlineSnapshot( - `"

h1 tag

"` + `"

h1 tag

"` ); }); @@ -172,7 +181,7 @@ describe('render', function () { const output = window.marked('## h2 tag'); expect(output).toMatchInlineSnapshot( - `"

h2 tag

"` + `"

h2 tag

"` ); }); @@ -180,7 +189,7 @@ describe('render', function () { const output = window.marked('### h3 tag'); expect(output).toMatchInlineSnapshot( - `"

h3 tag

"` + `"

h3 tag

"` ); }); @@ -188,7 +197,7 @@ describe('render', function () { const output = window.marked('#### h4 tag'); expect(output).toMatchInlineSnapshot( - `"

h4 tag

"` + `"

h4 tag

"` ); }); @@ -196,7 +205,7 @@ describe('render', function () { const output = window.marked('##### h5 tag'); expect(output).toMatchInlineSnapshot( - `"
h5 tag
"` + `"
h5 tag
"` ); }); @@ -204,12 +213,18 @@ describe('render', function () { const output = window.marked('###### h6 tag'); expect(output).toMatchInlineSnapshot( - `"
h6 tag
"` + `"
h6 tag
"` ); }); }); + // Links + // --------------------------------------------------------------------------- describe('link', function () { + beforeEach(async () => { + await docsifyInit(); + }); + test('regular', async function () { const output = window.marked('[alt text](http://url)'); @@ -264,4 +279,72 @@ describe('render', function () { ); }); }); + + // Skip Link + // --------------------------------------------------------------------------- + describe('skip link', () => { + test('renders default skip link and label', async () => { + await docsifyInit(); + + const elm = document.getElementById('skip-to-content'); + const expectText = 'Skip to main content'; + + expect(elm.textContent).toBe(expectText); + expect(elm.outerHTML).toMatchInlineSnapshot( + `""` + ); + }); + + test('renders custom label from config string', async () => { + const expectText = 'test'; + + await docsifyInit({ + config: { + skipLink: expectText, + }, + }); + + const elm = document.getElementById('skip-to-content'); + + expect(elm.textContent).toBe(expectText); + }); + + test('renders custom label from config object', async () => { + const getSkipLinkText = () => + document.getElementById('skip-to-content').textContent; + + await docsifyInit({ + config: { + skipLink: { + '/dir1/dir2/': 'baz', + '/dir1/': 'bar', + }, + }, + }); + + window.location.hash = '/dir1/dir2/'; + await waitForText('#skip-to-content', 'baz'); + expect(getSkipLinkText()).toBe('baz'); + + window.location.hash = '/dir1/'; + await waitForText('#skip-to-content', 'bar'); + expect(getSkipLinkText()).toBe('bar'); + + // Fallback to default + window.location.hash = ''; + await waitForText('#skip-to-content', 'Skip to main content'); + expect(getSkipLinkText()).toBe('Skip to main content'); + }); + + test('does not render skip link when false', async () => { + await docsifyInit({ + config: { + skipLink: false, + }, + }); + const elm = document.getElementById('skip-to-content') || false; + + expect(elm).toBe(false); + }); + }); });