feat: Keyboard bindings (#2279)

Co-authored-by: Lu Fei <52o@qq52o.cn>
This commit is contained in:
John Hildenbiddle 2023-12-04 18:09:52 -06:00 committed by GitHub
parent b8a5feaafd
commit cf61192f9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 3793 additions and 15961 deletions

View File

@ -288,6 +288,54 @@ window.$docsify = {
};
```
## keyBindings
- Type: `Boolean|Object`
- Default: `Object`
- <kbd>\\</kbd> Toggle the sidebar menu
- <kbd>/</kbd> Focus on [search](plugins#full-text-search) field. Also supports <kbd>alt</kbd>&nbsp;/&nbsp;<kbd>ctrl</kbd>&nbsp;+&nbsp;<kbd>k</kbd>.
Binds key combination(s) to a custom callback function.
Key `bindings` are defined as case insensitive string values separated by `+`. Modifier key values include `alt`, `ctrl`, `meta`, and `shift`. Non-modifier key values should match the keyboard event's [key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) or [code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) value.
The `callback` function receive a [keydown event](https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event) as an argument.
!> Let site visitors know your custom key bindings are available! If a binding is associated with a DOM element, consider inserting a `<kbd>` element as a visual cue (e.g., <kbd>alt</kbd> + <kbd>a</kbd>) or adding [title](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title) and [aria-keyshortcuts](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts) attributes for hover/focus hints.
```js
window.$docsify = {
keyBindings: {
// Custom key binding
myCustomBinding: {
bindings: ['alt+a', 'shift+a'],
callback(event) {
alert('Hello, World!');
},
},
},
};
```
Key bindings can be disabled entirely or individually by setting the binding configuration to `false`.
```js
window.$docsify = {
// Disable all key bindings
keyBindings: false,
};
```
```js
window.$docsify = {
keyBindings: {
// Disable individual key bindings
focusSearch: false,
toggleSidebar: false,
},
};
```
## loadNavbar
- Type: `Boolean|String`

19425
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -79,7 +79,7 @@
"@babel/eslint-parser": "^7.16.5",
"@babel/preset-env": "^7.11.5",
"@eslint/js": "^8.43.0",
"@playwright/test": "^1.18.1",
"@playwright/test": "^1.39.0",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-node-resolve": "^15.1.0",
"@rollup/plugin-replace": "^5.0.2",

View File

@ -66,6 +66,28 @@ export default function (vm) {
: window.$docsify
);
// Merge default and user-specified key bindings
if (config.keyBindings !== false) {
config.keyBindings = Object.assign(
// Default
{
toggleSidebar: {
bindings: ['\\'],
callback(e) {
const toggleElm = document.querySelector('.sidebar-toggle');
if (toggleElm) {
toggleElm.click();
toggleElm.focus();
}
},
},
},
// User-specified
config.keyBindings
);
}
const script =
currentScript ||
Array.from(document.getElementsByTagName('script')).filter(n =>

View File

@ -40,6 +40,9 @@ export function Events(Base) {
}
initEvent() {
const { coverpage, keyBindings } = this.config;
const modifierKeys = ['alt', 'ctrl', 'meta', 'shift'];
// Bind skip link
this.#skipLink('#skip-to-content');
@ -48,11 +51,84 @@ export function Events(Base) {
this.#collapse('.sidebar', this.router);
// Bind sticky effect
if (this.config.coverpage) {
if (coverpage) {
!isMobile && on('scroll', this.__sticky);
} else {
body.classList.add('sticky');
}
// Bind keyboard shortcuts
if (keyBindings && keyBindings.constructor === Object) {
// Prepare key binding configurations
Object.values(keyBindings || []).forEach(bindingConfig => {
const { bindings } = bindingConfig;
if (!bindings) {
return;
}
// Convert bindings to arrays
// Ex: 'alt+t' => ['alt+t']
bindingConfig.bindings = Array.isArray(bindings)
? bindings
: [bindings];
// Convert key sequences to sorted arrays (modifiers first)
// Ex: ['alt+t', 't+ctrl'] => [['alt', 't'], ['ctrl', 't']]
bindingConfig.bindings = bindingConfig.bindings.map(keys => {
const sortedKeys = [[], []]; // Modifier keys, non-modifier keys
if (typeof keys === 'string') {
keys = keys.split('+');
}
keys.forEach(key => {
const isModifierKey = modifierKeys.includes(key);
const targetArray = sortedKeys[isModifierKey ? 0 : 1];
const newKeyValue = key.trim().toLowerCase();
targetArray.push(newKeyValue);
});
sortedKeys.forEach(arr => arr.sort());
return sortedKeys.flat();
});
});
// Handle keyboard events
on('keydown', e => {
const isTextEntry = document.activeElement.matches(
'input, select, textarea'
);
if (isTextEntry) {
return;
}
const bindingConfigs = Object.values(keyBindings || []);
const matchingConfigs = bindingConfigs.filter(
({ bindings }) =>
bindings &&
// bindings: [['alt', 't'], ['ctrl', 't']]
bindings.some(keys =>
// keys: ['alt', 't']
keys.every(
// k: 'alt'
k =>
(modifierKeys.includes(k) && e[k + 'Key']) ||
e.key === k || // Ex: " ", "a"
e.code.toLowerCase() === k || // "space"
e.code.toLowerCase() === `key${k}` // "keya"
)
)
);
matchingConfigs.forEach(({ callback }) => {
e.preventDefault();
callback(e);
});
});
}
}
/** @readonly */

View File

@ -37,7 +37,7 @@ export function main(config) {
const name = config.name ? config.name : '';
const aside = /* html */ `
<button class="sidebar-toggle" aria-label="Toggle primary navigation" aria-controls="__sidebar">
<button class="sidebar-toggle" title="Press \\ to toggle" aria-label="Toggle primary navigation" aria-keyshortcuts="\\" aria-controls="__sidebar">
<div class="sidebar-toggle-button" aria-hidden="true">
<span></span><span></span><span></span>
</div>

View File

@ -77,6 +77,17 @@ function style() {
transform: scale(.5);
}
.search kbd {
position: absolute;
right: 8px;
margin: 0;
}
.search input:focus ~ kbd,
.search input:not(:empty) ~ kbd {
display: none;
}
.search h2 {
font-size: 17px;
margin: 10px 0;
@ -118,7 +129,7 @@ function style() {
function tpl(defaultValue = '') {
const html = /* html */ `
<div class="input-wrap">
<input type="search" value="${defaultValue}" />
<input type="search" value="${defaultValue}" aria-keyshortcuts="/ control+k meta+k" />
<div class="clear-button">
<svg width="26" height="24">
<circle cx="12" cy="12" r="11" fill="#ccc" />
@ -126,6 +137,7 @@ function tpl(defaultValue = '') {
<path stroke="white" stroke-width="2"d="M8.25,15.75,15.75,8.25" />
</svg>
</div>
<kbd title="Press / to search">/</kbd>
</div>
<div class="results-status" aria-live="polite"></div>
<div class="results-panel"></div>

View File

@ -14,6 +14,7 @@ const CONFIG = {
hideOtherSidebarContent: false,
namespace: undefined,
pathNamespaces: undefined,
keyBindings: ['/', 'meta+k', 'ctrl+k'],
};
const install = function (hook, vm) {
@ -32,10 +33,31 @@ const install = function (hook, vm) {
opts.hideOtherSidebarContent || CONFIG.hideOtherSidebarContent;
CONFIG.namespace = opts.namespace || CONFIG.namespace;
CONFIG.pathNamespaces = opts.pathNamespaces || CONFIG.pathNamespaces;
CONFIG.keyBindings = opts.keyBindings || CONFIG.keyBindings;
}
const isAuto = CONFIG.paths === 'auto';
hook.init(() => {
const { keyBindings } = vm.config;
// Add key bindings
if (keyBindings.constructor === Object) {
keyBindings.focusSearch = {
bindings: CONFIG.keyBindings,
callback(e) {
const sidebarElm = document.querySelector('.sidebar');
const sidebarToggleElm = document.querySelector('.sidebar-toggle');
const searchElm = sidebarElm?.querySelector('input[type="search"]');
const isSidebarHidden = sidebarElm?.getBoundingClientRect().x < 0;
isSidebarHidden && sidebarToggleElm?.click();
setTimeout(() => searchElm?.focus(), isSidebarHidden ? 250 : 0);
},
};
}
});
hook.mounted(_ => {
initComponent(CONFIG, vm);
!isAuto && initSearch(CONFIG, vm);

View File

@ -146,3 +146,108 @@ test.describe('Configuration options', () => {
});
});
});
test.describe('keyBindings', () => {
test('handles toggleSidebar binding (default)', async ({ page }) => {
const docsifyInitConfig = {
markdown: {
homepage: `
# Heading 1
`,
},
};
await docsifyInit(docsifyInitConfig);
const bodyElm = page.locator('body');
await expect(bodyElm).not.toHaveClass(/close/);
await page.keyboard.press('\\');
await expect(bodyElm).toHaveClass(/close/);
});
test('handles custom binding', async ({ page }) => {
const docsifyInitConfig = {
config: {
keyBindings: {
customBinding: {
bindings: 'z',
callback(e) {
const elm = document.querySelector('main input[type="text"]');
elm.value = 'foo';
},
},
},
},
markdown: {
homepage: `
<input type="text">
`,
},
};
const inputElm = page.locator('main input[type="text"]');
await docsifyInit(docsifyInitConfig);
await expect(inputElm).toHaveValue('');
await page.keyboard.press('z');
await expect(inputElm).toHaveValue('foo');
});
test('ignores event when focused on text input elements', async ({
page,
}) => {
const docsifyInitConfig = {
config: {
keyBindings: {
customBinding: {
bindings: 'z',
callback(e) {
document.body.setAttribute('data-foo', '');
},
},
},
},
markdown: {
homepage: `
<input type="text">
<select>
<option value="a" selected>a</option>
<option value="z">z</option>
</select>
<textarea></textarea>
`,
},
};
const bodyElm = page.locator('body');
const inputElm = page.locator('input[type="text"]');
const selectElm = page.locator('select');
const textareaElm = page.locator('textarea');
await docsifyInit(docsifyInitConfig);
await inputElm.focus();
await expect(inputElm).toHaveValue('');
await page.keyboard.press('z');
await expect(inputElm).toHaveValue('z');
await inputElm.blur();
await textareaElm.focus();
await expect(textareaElm).toHaveValue('');
await page.keyboard.press('z');
await expect(textareaElm).toHaveValue('z');
await textareaElm.blur();
await selectElm.focus();
await page.keyboard.press('z');
await expect(selectElm).toHaveValue('z');
await selectElm.blur();
await expect(bodyElm).not.toHaveAttribute('data-foo');
await page.keyboard.press('z');
await expect(bodyElm).toHaveAttribute('data-foo');
});
});

View File

@ -176,6 +176,7 @@ test.describe('Search Plugin Tests', () => {
await searchFieldElm.fill('hello');
await expect(resultsHeadingElm).toHaveText('Changelog Title');
});
test('search when there is no body', async ({ page }) => {
const docsifyInitConfig = {
markdown: {
@ -196,4 +197,39 @@ test.describe('Search Plugin Tests', () => {
await searchFieldElm.fill('empty');
await expect(resultsHeadingElm).toHaveText('EmptyContent');
});
test('handles default focusSearch binding', async ({ page }) => {
const docsifyInitConfig = {
scriptURLs: ['/lib/plugins/search.min.js'],
};
const searchFieldElm = page.locator('input[type="search"]');
await docsifyInit(docsifyInitConfig);
await expect(searchFieldElm).not.toBeFocused();
await page.keyboard.press('/');
await expect(searchFieldElm).toBeFocused();
});
test('handles custom focusSearch binding', async ({ page }) => {
const docsifyInitConfig = {
config: {
search: {
keyBindings: ['z'],
},
},
scriptURLs: ['/lib/plugins/search.min.js'],
};
const searchFieldElm = page.locator('input[type="search"]');
await docsifyInit(docsifyInitConfig);
await expect(searchFieldElm).not.toBeFocused();
await page.keyboard.press('/');
await expect(searchFieldElm).not.toBeFocused();
await page.keyboard.press('z');
await expect(searchFieldElm).toBeFocused();
});
});