feat: global shortcut support (#39604)

* Fix #5796

Global shortcuts can be easily introduced by adding "data-shortcut-keys"
to the elements

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
wxiaoguang authored and GitHub committed 2026-10-07 13:35:38 +08:00
1 parent 9b1d6f47f1
commit 4cba1e18ed
10 files changed
+374 -67

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
import '../../fomantic/build/fomantic.js';
import {userEvent} from 'vitest/browser';
import {initGlobalShortcut, makeFullKeyFromEvent} from './shortcut.ts';
import {initGlobalSelectorObserver} from './observer.ts';
import {initGiteaFomantic} from './fomantic.ts';
function press(key: string, options: KeyboardEventInit = {}, target: HTMLElement = document.body) {
const event = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true, ...options});
target.dispatchEvent(event);
return event;
}
beforeAll(() => {
document.body.innerHTML = `
<button data-shortcut-keys="g i"> <span> </span><span>Issues</span><span>42</span></button>
<div class="global-shortcut-wrapper">
<input placeholder="Search code">
<kbd data-global-init="onGlobalShortcut" data-shortcut-keys="s">S</kbd>
</div>
<textarea></textarea>
<select><option>Value</option></select>
<div contenteditable="true"><span>Editable</span></div>
`;
initGiteaFomantic();
initGlobalShortcut();
initGlobalSelectorObserver(null);
});
beforeEach(() => press('Escape'));
afterEach(() => vi.restoreAllMocks());
afterAll(() => document.body.replaceChildren());
test('clicks matching sequences and clears completed or expired presses', () => {
const click = vi.spyOn(document.querySelector('button')!, 'click');
const now = vi.spyOn(Date, 'now').mockReturnValue(1000);
press('x');
press('g');
expect(press('i').defaultPrevented).toBe(false);
press('Escape');
press('G');
expect(click).not.toHaveBeenCalled();
expect(press('I').defaultPrevented).toBe(true);
expect(click).toHaveBeenCalledTimes(1);
press('i');
press('g');
now.mockReturnValue(2001);
expect(press('i').defaultPrevented).toBe(false);
expect(click).toHaveBeenCalledTimes(1);
press('g');
press('x');
press('i');
expect(click).toHaveBeenCalledTimes(1);
});
test('ignores editing, modifiers, composition and repeated keydown events', () => {
const click = vi.spyOn(document.querySelector('button')!, 'click');
for (const target of document.querySelectorAll<HTMLElement>('input, textarea, select, [contenteditable] span')) {
press('g');
press('x', {}, target);
press('i');
}
for (const options of [{ctrlKey: true}, {metaKey: true}, {altKey: true}, {isComposing: true}]) {
press('g');
press('i', options);
press('i');
}
press('g');
press('Escape');
press('i');
press('Escape');
press('g');
press('i', {repeat: true});
expect(click).not.toHaveBeenCalled();
press('i');
expect(click).toHaveBeenCalledTimes(1);
});
test('keeps input focus, hint visibility and Escape behavior', () => {
const input = document.querySelector('input')!;
const kbd = document.querySelector('kbd')!;
expect(press('S').defaultPrevented).toBe(true);
expect(document.activeElement).toBe(input);
expect(kbd.classList.contains('tw-hidden')).toBe(true);
input.value = 'search';
press('Escape', {}, input);
expect(input.value).toBe('');
expect(document.activeElement).not.toBe(input);
expect(kbd.classList.contains('tw-hidden')).toBe(false);
});
test('normalizes keys and sorts modifiers alphabetically', () => {
for (const [options, expected] of [
[{key: 'V'}, 'V'],
[{key: 'v'}, 'V'],
[{key: 'V', ctrlKey: true, shiftKey: true}, 'Control+Shift+V'],
[{key: 'a', ctrlKey: true, altKey: true, metaKey: true, shiftKey: true}, 'Alt+Control+Meta+Shift+A'],
[{key: ' ', shiftKey: true}, 'Shift+Space'],
[{key: '+', altKey: true}, 'Alt+Plus'],
[{key: 'ArrowUp', metaKey: true}, 'Meta+ArrowUp'],
[{key: 'PageDown'}, 'PageDown'],
[{key: 'F12'}, 'F12'],
[{key: 'Escape'}, 'Escape'],
...['Control', 'Alt', 'Meta', 'Shift', ''].map((key) => [{key}, '']),
] as [KeyboardEventInit, string][]) {
expect(makeFullKeyFromEvent(new KeyboardEvent('keydown', options))).toBe(expected);
}
});
test('shows current shortcuts with ? and closes with Escape or the close button', async () => {
const input = document.querySelector('input')!;
const click = vi.spyOn(document.querySelector('button')!, 'click');
press('?', {shiftKey: true}, input);
expect(document.querySelector('#global-shortcut-help')).toBeNull();
press('?', {ctrlKey: true});
expect(document.querySelector('#global-shortcut-help')).toBeNull();
press('g');
expect(press('?', {shiftKey: true}).defaultPrevented).toBe(true);
const modal = document.querySelector<HTMLElement>('#global-shortcut-help')!;
expect(modal.classList.contains('active')).toBe(true);
expect(modal.querySelector('.shortcut-list tr:nth-child(2) td:last-child')!.textContent).toBe('Issues');
expect(modal.querySelector('.shortcut-list')!.textContent).toContain('Search code');
expect(modal.querySelectorAll('.shortcut-list tr')).toHaveLength(3);
press('g');
press('i');
expect(click).not.toHaveBeenCalled();
await userEvent.keyboard('{Escape}');
expect(modal.classList.contains('active')).toBe(false);
input.setAttribute('placeholder', '<b>Updated search</b>');
press('?');
expect(document.querySelectorAll('.ui.modal')).toHaveLength(1);
expect(modal.querySelector('.shortcut-list')!.textContent).toContain('<b>Updated search</b>');
expect(modal.querySelector('.shortcut-list b')).toBeNull();
expect(modal.querySelectorAll('.shortcut-list tr')).toHaveLength(3);
});
+140 -11
View File
@@ -1,5 +1,19 @@
import {registerGlobalInitFunc} from './observer.ts';
import {hideElem, toggleElem} from '../utils/dom.ts';
import {createElementFromHTML, hideElem, toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from './fomantic/modal.ts';
import {html} from '../utils/html.ts';
import {svgRaw} from '../svg.ts';
type ShortcutHandler = (el: HTMLElement) => boolean;
const shortcutHandlers: Record<string, ShortcutHandler[]> = {};
type ShortcutPress = {
fullKey: string,
pressTime: number,
};
const shortcutPresses: ShortcutPress[] = [];
function initShortcutKbd(kbd: HTMLElement) {
// Handle initial state: hide the kbd hint if the associated input already has a value
@@ -8,6 +22,7 @@ function initShortcutKbd(kbd: HTMLElement) {
if (elem?.value) hideElem(kbd);
kbd.setAttribute('aria-hidden', 'true');
kbd.setAttribute('aria-keyshortcuts', kbd.getAttribute('data-shortcut-keys')!);
kbd.addEventListener('click', () => elemFromKbd(kbd)?.focus());
}
function shortcutWrapper(el: HTMLElement): HTMLElement | null {
@@ -23,6 +38,79 @@ function kbdFromElem(input: HTMLElement): HTMLElement | null {
return shortcutWrapper(input)?.querySelector<HTMLElement>('kbd') || null;
}
export function makeFullKeyFromEvent(e: KeyboardEvent): string {
// follow the MDN standard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-keyshortcuts
// always use PascalCase (upper case for single letter), and sort the modifiers in the order in alphabetical order
const modifierKeys = ['Alt', 'Control', 'Meta', 'Shift'];
let key = e.key;
if (!key || modifierKeys.includes(key)) return '';
if (key.length === 1) key = key.toUpperCase();
if (key === ' ') key = 'Space';
if (key === '+') key = 'Plus';
const modifiers: string[] = [];
for (const mod of modifierKeys) {
if (e.getModifierState(mod)) modifiers.push(mod);
}
return [...modifiers, key].join('+');
}
function getShortcutTextContent(elem: Element): string {
for (const child of elem.childNodes) {
let text = '';
if (child instanceof Text) {
text = child.data.trim();
} else if (child instanceof Element) {
// in the future, we can also exclude elements that should not contribute to shortcut labels.
text = getShortcutTextContent(child);
}
if (text) return text;
}
return '';
}
function showShortcutHelp() {
let modal = document.querySelector<HTMLElement>('#global-shortcut-help');
if (!modal) {
modal = createElementFromHTML<HTMLElement>(html`
<div id="global-shortcut-help" class="ui small modal" role="dialog" aria-modal="true">
<div class="header">
Keyboard Shortcuts
${svgRaw('octicon-x', 16, 'close-modal')}
</div>
<div class="scrolling content">
<table class="ui very basic compact unstackable table">
<tbody class="shortcut-list"></tbody>
</table>
</div>
</div>
`);
document.body.append(modal);
}
const list = modal.querySelector<HTMLElement>('.shortcut-list')!;
list.replaceChildren(createElementFromHTML(html`<tr><td><kbd>?</kbd></td><td>Keyboard Shortcuts</td></tr>`));
for (const elem of document.querySelectorAll<HTMLElement>('[data-shortcut-keys]')) {
const target = elem.matches('kbd') ? elemFromKbd(elem) : elem;
// TODO: which element should be triggered by shortcut: can be fine-tuned in the future
if (!target || target.matches(':disabled, .disabled')) continue;
const label = target.getAttribute('aria-label') ||
target.getAttribute('placeholder') ||
target.getAttribute('title') ||
getShortcutTextContent(target);
list.append(createElementFromHTML(html`<tr><td><kbd>${elem.getAttribute('data-shortcut-keys')}</kbd></td><td>${label}</td></tr>`));
}
showFomanticModal(modal);
}
export function registerShortcutHandler(fullKey: string, handler: ShortcutHandler) {
fullKey = fullKey.toLowerCase();
shortcutHandlers[fullKey] ||= [];
shortcutHandlers[fullKey].push(handler);
}
export function initGlobalShortcut() {
registerGlobalInitFunc('onGlobalShortcut', initShortcutKbd);
@@ -31,13 +119,18 @@ export function initGlobalShortcut() {
// When Escape is pressed inside such an input, the input is cleared and blurred.
// The <kbd> element is shown/hidden automatically based on input focus and value.
document.addEventListener('keydown', (e: KeyboardEvent) => {
// Modifier keys are not supported yet
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.isComposing || e.defaultPrevented) {
shortcutPresses.length = 0;
return;
}
if (e.repeat) return;
const isPlainKey = !e.ctrlKey && !e.altKey && !e.metaKey;
const target = e.target as HTMLElement;
// Handle Escape: clear and blur inputs that have an associated keyboard shortcut
if (e.key === 'Escape') {
if (e.key === 'Escape' && isPlainKey) {
shortcutPresses.length = 0;
const kbd = kbdFromElem(target);
if (kbd) {
(target as HTMLInputElement).value = '';
@@ -47,17 +140,53 @@ export function initGlobalShortcut() {
}
// Don't trigger shortcuts when typing in input fields or contenteditable areas
if (target.matches('input, textarea, select') || target.isContentEditable) {
let shouldSkip = target.matches('input, textarea, select') || target.isContentEditable;
// Skip shortcuts when a modal or dropdown is open (active), to avoid interfering with their own keyboard handling
shouldSkip = shouldSkip || Boolean(document.querySelector('.ui.modal.active') || document.querySelector('.ui.dropdown.active'));
if (shouldSkip) {
shortcutPresses.length = 0;
return;
}
// Find kbd element with matching shortcut (case-insensitive), then focus its sibling input
const key = e.key.toLowerCase();
// At the moment, only a simple match. In the future, it can be extended to support modifiers and key combinations
const kbd = document.querySelector<HTMLElement>(`.global-shortcut-wrapper > kbd[data-shortcut-keys="${CSS.escape(key)}"]`);
if (!kbd) return;
// Show the keyboard shortcut help modal when '?' is pressed without modifiers
if (e.key === '?' && isPlainKey) {
e.preventDefault();
shortcutPresses.length = 0;
showShortcutHelp();
return;
}
const fullKey = makeFullKeyFromEvent(e);
// we don't support multiple modifier keys as a single shortcut (e.g., press Ctrl twice) at the moment
if (!fullKey) return;
// just a simple algorithm to detect multi-key shortcuts, so far so good, for daily usage
// if any bad case happens, it can be fine-tuned in the future.
const keyPressDelay = 1000;
const pressTime = Date.now();
while (shortcutPresses.length && pressTime - shortcutPresses[0].pressTime > keyPressDelay) {
shortcutPresses.shift();
}
shortcutPresses.push({fullKey, pressTime});
const fullKeys = shortcutPresses.map((p) => p.fullKey).join(' ');
for (const handler of shortcutHandlers[fullKeys.toLowerCase()] || []) {
if (handler(target)) {
e.preventDefault();
shortcutPresses.length = 0;
return;
}
}
// It's said that "spaces in aria-keyshortcuts separate alternative shortcuts", but we want to support multi-key shortcuts
// So here we use our own data attribute to store the full shortcut keys, and match it case-insensitively.
const matchedElems = document.querySelectorAll<HTMLElement>(`[data-shortcut-keys="${CSS.escape(fullKeys)}" i]`);
if (!matchedElems.length) return;
// TODO: if there are multiple matches, maybe we could introduce some priority rules to determine which one to trigger
const matchedElem = matchedElems[0];
e.preventDefault();
elemFromKbd(kbd)!.focus();
shortcutPresses.length = 0;
matchedElem.click();
});
// Toggle kbd shortcut hint visibility on input focus/blur