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 `
+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(''); @@ -136,35 +143,37 @@ describe('render', function () { ); }); - describe('size', function () { - test('width and height', async function () { - const output = window.marked( - "" - ); + test('width and height', async function () { + const output = window.marked( + "" + ); - expect(output).toMatchInlineSnapshot( - `"
docsify 4.13.0
A magical documentation site generator.
- Simple and lightweight
- No statically built html files
- Multiple themes
"` - ); - }); + expect(output).toMatchInlineSnapshot( + `"
"` + ); + }); - test('width', async function () { - const output = window.marked(""); + test('width', async function () { + const output = window.marked(""); - expect(output).toMatchInlineSnapshot( - `"
"` - ); - }); + expect(output).toMatchInlineSnapshot( + `"
"` + ); }); }); // 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); + }); + }); });