mirror of
https://github.com/docsifyjs/docsify.git
synced 2025-12-08 19:55:52 +00:00
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:
parent
da43bd7937
commit
50b84f74b2
@ -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`
|
||||
|
||||
@ -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>`,
|
||||
|
||||
@ -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>`,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
`"<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("");
|
||||
test('width', async function () {
|
||||
const output = window.marked("");
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user