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>
This commit is contained in:
John Hildenbiddle 2023-11-29 23:11:21 -06:00 committed by GitHub
parent da43bd7937
commit 50b84f74b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 285 additions and 52 deletions

View File

@ -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`

View File

@ -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 */ `<button @click="count += 1">You clicked me {{ count }} times</button>`,

View File

@ -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 */ `<button @click="count += 1">You clicked me {{ count }} times</button>`,

View File

@ -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;

View File

@ -225,7 +225,11 @@ export class Compiler {
nextToc.slug = url;
_self.toc.push(nextToc);
return `<h${level} id="${slug}"><a href="${url}" data-id="${slug}" class="anchor"><span>${str}</span></a></h${level}>`;
// 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 `<h${level} id="${slug}" tabindex="-1"><a href="${url}" data-id="${slug}" class="anchor"><span>${str}</span></a></h${level}>`;
};
origin.code = highlightCodeCompiler({ renderer });

View File

@ -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 = `<button id="skip-to-content">${skipLinkText}</button>`;
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) {

View File

@ -59,7 +59,7 @@ export function main(config) {
return /* html */ `
<main role="presentation">${aside}
<section class="content">
<article class="markdown-section" id="main" role="main"><!--main--></article>
<article id="main" class="markdown-section" role="main" tabindex="-1"><!--main--></article>
</section>
</main>
`;

View File

@ -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

View File

@ -9,7 +9,7 @@ exports[`Docs Site coverpage renders and is unchanged 1`] = `
)
\\">
<div class=\\"mask\\"></div>
<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>
<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>
<p>A magical documentation site generator.</p></blockquote>
<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>
<a href=\\"#/?id=docsify\\">Getting Started</a></p></div>

View File

@ -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(
`"<p><img src=\\"http://imageUrl\\" data-origin=\\"http://imageUrl\\" alt=\\"alt text\\" width=\\"WIDTH\\" height=\\"HEIGHT\\" /></p>"`
);
});
expect(output).toMatchInlineSnapshot(
`"<p><img src=\\"http://imageUrl\\" data-origin=\\"http://imageUrl\\" alt=\\"alt text\\" width=\\"WIDTH\\" height=\\"HEIGHT\\" /></p>"`
);
});
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(
`"<p><img src=\\"http://imageUrl\\" data-origin=\\"http://imageUrl\\" alt=\\"alt text\\" width=\\"50\\" /></p>"`
);
});
expect(output).toMatchInlineSnapshot(
`"<p><img src=\\"http://imageUrl\\" data-origin=\\"http://imageUrl\\" alt=\\"alt text\\" width=\\"50\\" /></p>"`
);
});
});
// Headings
// ---------------------------------------------------------------------------
describe('headings', function () {
beforeEach(async () => {
await docsifyInit();
});
test('h1', async function () {
const output = window.marked('# h1 tag');
expect(output).toMatchInlineSnapshot(
`"<h1 id=\\"h1-tag\\"><a href=\\"#/?id=h1-tag\\" data-id=\\"h1-tag\\" class=\\"anchor\\"><span>h1 tag</span></a></h1>"`
`"<h1 id=\\"h1-tag\\" tabindex=\\"-1\\"><a href=\\"#/?id=h1-tag\\" data-id=\\"h1-tag\\" class=\\"anchor\\"><span>h1 tag</span></a></h1>"`
);
});
@ -172,7 +181,7 @@ describe('render', function () {
const output = window.marked('## h2 tag');
expect(output).toMatchInlineSnapshot(
`"<h2 id=\\"h2-tag\\"><a href=\\"#/?id=h2-tag\\" data-id=\\"h2-tag\\" class=\\"anchor\\"><span>h2 tag</span></a></h2>"`
`"<h2 id=\\"h2-tag\\" tabindex=\\"-1\\"><a href=\\"#/?id=h2-tag\\" data-id=\\"h2-tag\\" class=\\"anchor\\"><span>h2 tag</span></a></h2>"`
);
});
@ -180,7 +189,7 @@ describe('render', function () {
const output = window.marked('### h3 tag');
expect(output).toMatchInlineSnapshot(
`"<h3 id=\\"h3-tag\\"><a href=\\"#/?id=h3-tag\\" data-id=\\"h3-tag\\" class=\\"anchor\\"><span>h3 tag</span></a></h3>"`
`"<h3 id=\\"h3-tag\\" tabindex=\\"-1\\"><a href=\\"#/?id=h3-tag\\" data-id=\\"h3-tag\\" class=\\"anchor\\"><span>h3 tag</span></a></h3>"`
);
});
@ -188,7 +197,7 @@ describe('render', function () {
const output = window.marked('#### h4 tag');
expect(output).toMatchInlineSnapshot(
`"<h4 id=\\"h4-tag\\"><a href=\\"#/?id=h4-tag\\" data-id=\\"h4-tag\\" class=\\"anchor\\"><span>h4 tag</span></a></h4>"`
`"<h4 id=\\"h4-tag\\" tabindex=\\"-1\\"><a href=\\"#/?id=h4-tag\\" data-id=\\"h4-tag\\" class=\\"anchor\\"><span>h4 tag</span></a></h4>"`
);
});
@ -196,7 +205,7 @@ describe('render', function () {
const output = window.marked('##### h5 tag');
expect(output).toMatchInlineSnapshot(
`"<h5 id=\\"h5-tag\\"><a href=\\"#/?id=h5-tag\\" data-id=\\"h5-tag\\" class=\\"anchor\\"><span>h5 tag</span></a></h5>"`
`"<h5 id=\\"h5-tag\\" tabindex=\\"-1\\"><a href=\\"#/?id=h5-tag\\" data-id=\\"h5-tag\\" class=\\"anchor\\"><span>h5 tag</span></a></h5>"`
);
});
@ -204,12 +213,18 @@ describe('render', function () {
const output = window.marked('###### h6 tag');
expect(output).toMatchInlineSnapshot(
`"<h6 id=\\"h6-tag\\"><a href=\\"#/?id=h6-tag\\" data-id=\\"h6-tag\\" class=\\"anchor\\"><span>h6 tag</span></a></h6>"`
`"<h6 id=\\"h6-tag\\" tabindex=\\"-1\\"><a href=\\"#/?id=h6-tag\\" data-id=\\"h6-tag\\" class=\\"anchor\\"><span>h6 tag</span></a></h6>"`
);
});
});
// 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(
`"<button id=\\"skip-to-content\\">Skip to main content</button>"`
);
});
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);
});
});
});