Skip to content

Commit 50b84f7

Browse files
jhildenbiddletrusktrKoooooo-7
authored
feat: Add "Skip to main content" link and update nav behavior (#2253)
Co-authored-by: Joe Pea <trusktr@gmail.com> Co-authored-by: Koy Zhuang <koy@ko8e24.top>
1 parent da43bd7 commit 50b84f7

File tree

10 files changed

+285
-52
lines changed

10 files changed

+285
-52
lines changed

docs/configuration.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,39 @@ window.$docsify = {
811811
}
812812
```
813813

814+
## skipLink
815+
816+
- Type: `Boolean|String|Object`
817+
- Default: `'Skip to main content'`
818+
819+
Determines if/how the site's [skip navigation link](https://webaim.org/techniques/skipnav/) will be rendered.
820+
821+
```js
822+
// Render skip link for all routes (default)
823+
window.$docsify = {
824+
skipLink: 'Skip to main content',
825+
};
826+
```
827+
828+
```js
829+
// Render localized skip links based on route paths
830+
window.$docsify = {
831+
skipLink: {
832+
'/es/': 'Saltar al contenido principal',
833+
'/de-de/': 'Ga naar de hoofdinhoud',
834+
'/ru-ru/': 'Перейти к основному содержанию',
835+
'/zh-cn/': '跳到主要内容',
836+
},
837+
};
838+
```
839+
840+
```js
841+
// Do not render skip link
842+
window.$docsify = {
843+
skipLink: false,
844+
};
845+
```
846+
814847
## subMaxLevel
815848

816849
- Type: `Number`

docs/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@
125125
},
126126
pathNamespaces: ['/es', '/de-de', '/ru-ru', '/zh-cn'],
127127
},
128+
skipLink: {
129+
'/es/': 'Saltar al contenido principal',
130+
'/de-de/': 'Ga naar de hoofdinhoud',
131+
'/ru-ru/': 'Перейти к основному содержанию',
132+
'/zh-cn/': '跳到主要内容',
133+
},
128134
vueComponents: {
129135
'button-counter': {
130136
template: /* html */ `<button @click="count += 1">You clicked me {{ count }} times</button>`,

index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@
8989
},
9090
pathNamespaces: ['/es', '/de-de', '/ru-ru', '/zh-cn'],
9191
},
92+
skipLink: {
93+
'/es/': 'Saltar al contenido principal',
94+
'/de-de/': 'Ga naar de hoofdinhoud',
95+
'/ru-ru/': 'Перейти к основному содержанию',
96+
'/zh-cn/': '跳到主要内容',
97+
},
9298
vueComponents: {
9399
'button-counter': {
94100
template: /* html */ `<button @click="count += 1">You clicked me {{ count }} times</button>`,

src/core/event/index.js

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,39 @@ import config from '../config.js';
1414
export function Events(Base) {
1515
return class Events extends Base {
1616
$resetEvents(source) {
17-
const { auto2top } = this.config;
17+
const { auto2top, loadNavbar } = this.config;
18+
const { path, query } = this.route;
1819

19-
// If 'history', rely on the browser's scroll auto-restoration when going back or forward
20+
// Note: Scroll position set by browser on forward/back (i.e. "history")
2021
if (source !== 'history') {
2122
// Scroll to ID if specified
22-
if (this.route.query.id) {
23-
this.#scrollIntoView(this.route.path, this.route.query.id);
23+
if (query.id) {
24+
this.#scrollIntoView(path, query.id, true);
2425
}
2526
// Scroll to top if a link was clicked and auto2top is enabled
26-
if (source === 'navigate') {
27+
else if (source === 'navigate') {
2728
auto2top && this.#scroll2Top(auto2top);
2829
}
2930
}
3031

31-
if (this.config.loadNavbar) {
32+
// Move focus to content
33+
if (query.id || source === 'navigate') {
34+
this.focusContent();
35+
}
36+
37+
if (loadNavbar) {
3238
this.__getAndActive(this.router, 'nav');
3339
}
3440
}
3541

3642
initEvent() {
43+
// Bind skip link
44+
this.#skipLink('#skip-to-content');
45+
3746
// Bind toggle button
3847
this.#btn('button.sidebar-toggle', this.router);
3948
this.#collapse('.sidebar', this.router);
49+
4050
// Bind sticky effect
4151
if (this.config.coverpage) {
4252
!isMobile && on('scroll', this.__sticky);
@@ -53,6 +63,22 @@ export function Events(Base) {
5363
#enableScrollEvent = true;
5464
#coverHeight = 0;
5565

66+
#skipLink(el) {
67+
el = dom.getNode(el);
68+
69+
if (el === null || el === undefined) {
70+
return;
71+
}
72+
73+
dom.on(el, 'click', evt => {
74+
const target = dom.getNode('#main');
75+
76+
evt.preventDefault();
77+
target && target.focus();
78+
this.#scrollTo(target);
79+
});
80+
}
81+
5682
#scrollTo(el, offset = 0) {
5783
if (this.#scroller) {
5884
this.#scroller.stop();
@@ -75,6 +101,20 @@ export function Events(Base) {
75101
.begin();
76102
}
77103

104+
focusContent() {
105+
const { query } = this.route;
106+
const focusEl = query.id
107+
? // Heading ID
108+
dom.find(`#${query.id}`)
109+
: // First heading
110+
dom.find('#main :where(h1, h2, h3, h4, h5, h6)') ||
111+
// Content container
112+
dom.find('#main');
113+
114+
// Move focus to content area
115+
focusEl && focusEl.focus();
116+
}
117+
78118
#highlight(path) {
79119
if (!this.#enableScrollEvent) {
80120
return;

src/core/render/compiler.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,11 @@ export class Compiler {
225225
nextToc.slug = url;
226226
_self.toc.push(nextToc);
227227

228-
return `<h${level} id="${slug}"><a href="${url}" data-id="${slug}" class="anchor"><span>${str}</span></a></h${level}>`;
228+
// Note: tabindex="-1" allows programmatically focusing on heading
229+
// elements after navigation. This is preferred over focusing on the link
230+
// within the heading because it matches the focus behavior of screen
231+
// readers when navigating page content.
232+
return `<h${level} id="${slug}" tabindex="-1"><a href="${url}" data-id="${slug}" class="anchor"><span>${str}</span></a></h${level}>`;
229233
};
230234

231235
origin.code = highlightCodeCompiler({ renderer });

src/core/render/index.js

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,34 @@ export function Render(Base) {
238238
el.setAttribute('href', nameLink[match]);
239239
}
240240
}
241+
242+
#renderSkipLink(vm) {
243+
const { skipLink } = vm.config;
244+
245+
if (skipLink !== false) {
246+
const el = dom.getNode('#skip-to-content');
247+
248+
let skipLinkText =
249+
typeof skipLink === 'string' ? skipLink : 'Skip to main content';
250+
251+
if (skipLink?.constructor === Object) {
252+
const matchingPath = Object.keys(skipLink).find(path =>
253+
vm.route.path.startsWith(path.startsWith('/') ? path : `/${path}`)
254+
);
255+
const matchingText = matchingPath && skipLink[matchingPath];
256+
257+
skipLinkText = matchingText || skipLinkText;
258+
}
259+
260+
if (el) {
261+
el.innerHTML = skipLinkText;
262+
} else {
263+
const html = `<button id="skip-to-content">${skipLinkText}</button>`;
264+
dom.body.insertAdjacentHTML('afterbegin', html);
265+
}
266+
}
267+
}
268+
241269
_renderTo(el, content, replace) {
242270
const node = dom.getNode(el);
243271
if (node) {
@@ -396,6 +424,9 @@ export function Render(Base) {
396424
_updateRender() {
397425
// Render name link
398426
this.#renderNameLink(this);
427+
428+
// Render skip link
429+
this.#renderSkipLink(this);
399430
}
400431

401432
initRender() {
@@ -409,14 +440,10 @@ export function Render(Base) {
409440
}
410441

411442
const id = config.el || '#app';
412-
const navEl = dom.find('nav') || dom.create('nav');
413-
414443
const el = dom.find(id);
415-
let html = '';
416-
let navAppendToTarget = dom.body;
417444

418445
if (el) {
419-
navEl.setAttribute('aria-label', 'secondary');
446+
let html = '';
420447

421448
if (config.repo) {
422449
html += tpl.corner(config.repo, config.cornerExternalLinkTarget);
@@ -437,25 +464,27 @@ export function Render(Base) {
437464
}
438465

439466
html += tpl.main(config);
467+
440468
// Render main app
441469
this._renderTo(el, html, true);
442470
} else {
443471
this.rendered = true;
444472
}
445473

446-
if (config.mergeNavbar && isMobile) {
447-
navAppendToTarget = dom.find('.sidebar');
448-
} else {
449-
navEl.classList.add('app-nav');
450-
451-
if (!config.repo) {
452-
navEl.classList.add('no-badge');
453-
}
454-
}
455-
456474
// Add nav
457475
if (config.loadNavbar) {
458-
dom.before(navAppendToTarget, navEl);
476+
const navEl = dom.find('nav') || dom.create('nav');
477+
const isMergedSidebar = config.mergeNavbar && isMobile;
478+
479+
navEl.setAttribute('aria-label', 'secondary');
480+
481+
if (isMergedSidebar) {
482+
dom.find('.sidebar').prepend(navEl);
483+
} else {
484+
dom.body.prepend(navEl);
485+
navEl.classList.add('app-nav');
486+
navEl.classList.toggle('no-badge', !config.repo);
487+
}
459488
}
460489

461490
if (config.themeColor) {

src/core/render/tpl.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function main(config) {
5959
return /* html */ `
6060
<main role="presentation">${aside}
6161
<section class="content">
62-
<article class="markdown-section" id="main" role="main"><!--main--></article>
62+
<article id="main" class="markdown-section" role="main" tabindex="-1"><!--main--></article>
6363
</section>
6464
</main>
6565
`;

src/themes/basic/_layout.styl

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,38 @@ li input[type='checkbox']
8686
margin 0 0.2em 0.25em 0
8787
vertical-align middle
8888

89+
[tabindex="-1"]:focus
90+
outline none !important
91+
92+
/* skip link */
93+
#skip-to-content
94+
appearance none
95+
display block
96+
position fixed
97+
z-index 2147483647
98+
top 0
99+
left 50%
100+
padding 0.5rem 1.5rem
101+
border 0
102+
border-radius: 100vw
103+
background-color $color-primary
104+
background-color var(--theme-color, $color-primary)
105+
color $color-bg
106+
color var(--theme-bg, $color-bg)
107+
opacity 0
108+
font-size inherit
109+
text-decoration none
110+
transform translate(-50%, -100%)
111+
transition-property opacity, transform
112+
transition-duration 0s, 0.2s
113+
transition-delay 0.2s, 0s
114+
115+
&:focus
116+
opacity 1
117+
transform translate(-50%, 0.75rem)
118+
transition-duration 0s, 0.2s
119+
transition-delay 0s, 0s
120+
89121
/* navbar */
90122
.app-nav
91123
margin 25px 60px 0 0

test/integration/__snapshots__/docs.test.js.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ exports[`Docs Site coverpage renders and is unchanged 1`] = `
99
)
1010
\\">
1111
<div class=\\"mask\\"></div>
12-
<div class=\\"cover-main\\"><p><img src=\\"http://127.0.0.1:3001/_media/icon.svg\\" data-origin=\\"_media/icon.svg\\" alt=\\"logo\\"></p><h1 id=\\"docsify-4130\\"><a href=\\"#/?id=docsify-4130\\" data-id=\\"docsify-4130\\" class=\\"anchor\\"><span>docsify <small>4.13.0</small></span></a></h1><blockquote>
12+
<div class=\\"cover-main\\"><p><img src=\\"http://127.0.0.1:3001/_media/icon.svg\\" data-origin=\\"_media/icon.svg\\" alt=\\"logo\\"></p><h1 id=\\"docsify-4130\\" tabindex=\\"-1\\"><a href=\\"#/?id=docsify-4130\\" data-id=\\"docsify-4130\\" class=\\"anchor\\"><span>docsify <small>4.13.0</small></span></a></h1><blockquote>
1313
<p>A magical documentation site generator.</p></blockquote>
1414
<ul><li>Simple and lightweight</li><li>No statically built html files</li><li>Multiple themes</li></ul><p><a href=\\"https://github.com/docsifyjs/docsify/\\" target=\\"_blank\\" rel=\\"noopener\\">GitHub</a>
1515
<a href=\\"#/?id=docsify\\">Getting Started</a></p></div>

0 commit comments

Comments
 (0)