mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-12 19:53:12 +02:00
refactor(shortcuts): decouple keyboard shortcut mechanism into generic kbd handler
Move data-global-keyboard-shortcut from <input> to <kbd> elements so the shortcut hint itself declares the shortcut key. The generic handler in observer.ts now manages everything: focusing the sibling input on shortcut key press, toggling kbd visibility on focus/blur, and clearing+blurring on Escape. This removes the element-specific initRepoCodeSearchShortcut function and Vue v-show logic in favor of a fully decoupled approach. Any input can now gain keyboard shortcut support by placing a sibling <kbd data-global-keyboard-shortcut="key"> element. Adds a devtest page at /devtest/keyboard-shortcut for manual testing.
This commit is contained in:
parent
ec3420a2c9
commit
0cabec3cfa
41
templates/devtest/keyboard-shortcut.tmpl
Normal file
41
templates/devtest/keyboard-shortcut.tmpl
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{{template "devtest/devtest-header"}}
|
||||||
|
<div class="page-content devtest ui container">
|
||||||
|
<h1>Keyboard Shortcut</h1>
|
||||||
|
<p>
|
||||||
|
A <code><kbd data-global-keyboard-shortcut="key"></code> element next to an <code><input></code> declares a keyboard shortcut.
|
||||||
|
Pressing the key focuses the input. Pressing <kbd>Escape</kbd> clears and blurs it.
|
||||||
|
The hint is hidden automatically when the input is focused or has a value.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Input with "s" shortcut</h2>
|
||||||
|
<div style="position: relative; display: inline-flex; align-items: center;">
|
||||||
|
<input class="ui input" placeholder="Press S to focus" style="padding-right: 36px;">
|
||||||
|
<kbd data-global-keyboard-shortcut="s" class="devtest-shortcut-hint">S</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Input with "f" shortcut</h2>
|
||||||
|
<div style="position: relative; display: inline-flex; align-items: center;">
|
||||||
|
<input class="ui input" placeholder="Press F to focus" style="padding-right: 36px;">
|
||||||
|
<kbd data-global-keyboard-shortcut="f" class="devtest-shortcut-hint">F</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.devtest-shortcut-hint {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 14px;
|
||||||
|
color: var(--color-text-light-2);
|
||||||
|
background-color: var(--color-box-body);
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: inset 0 -1px 0 var(--color-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{template "devtest/devtest-footer"}}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
<div class="repo-home-sidebar-top">
|
<div class="repo-home-sidebar-top">
|
||||||
<form class="ignore-dirty tw-flex tw-flex-1" action="{{.RepoLink}}/search" method="get">
|
<form class="ignore-dirty tw-flex tw-flex-1" action="{{.RepoLink}}/search" method="get">
|
||||||
<div class="ui small action input tw-flex-1 repo-code-search-input-wrapper">
|
<div class="ui small action input tw-flex-1 repo-code-search-input-wrapper">
|
||||||
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}" class="code-search-input" data-global-keyboard-shortcut="s" data-global-init="initRepoCodeSearchShortcut" aria-keyshortcuts="s">
|
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}" class="code-search-input" aria-keyshortcuts="s">
|
||||||
<kbd class="repo-search-shortcut-hint" aria-hidden="true">S</kbd>
|
<kbd data-global-keyboard-shortcut="s" class="repo-search-shortcut-hint" aria-hidden="true">S</kbd>
|
||||||
{{template "shared/search/button"}}
|
{{template "shared/search/button"}}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -89,7 +89,7 @@ test.describe('Repository Keyboard Shortcuts', () => {
|
|||||||
await fileSearchInput.click();
|
await fileSearchInput.click();
|
||||||
await page.keyboard.type('test');
|
await page.keyboard.type('test');
|
||||||
|
|
||||||
// The hint should now be hidden (Vue component handles this with v-show)
|
// The hint should now be hidden (generic handler hides kbd on focus)
|
||||||
await expect(fileKbdHint).toBeHidden();
|
await expect(fileKbdHint).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -23,8 +23,6 @@ const allFiles = ref<string[]>([]);
|
|||||||
const selectedIndex = ref(0);
|
const selectedIndex = ref(0);
|
||||||
const isLoadingFileList = ref(false);
|
const isLoadingFileList = ref(false);
|
||||||
const hasLoadedFileList = ref(false);
|
const hasLoadedFileList = ref(false);
|
||||||
const isInputFocused = ref(false);
|
|
||||||
|
|
||||||
const showPopup = computed(() => searchQuery.value.length > 0);
|
const showPopup = computed(() => searchQuery.value.length > 0);
|
||||||
|
|
||||||
const filteredFiles = computed(() => {
|
const filteredFiles = computed(() => {
|
||||||
@ -150,11 +148,10 @@ watch([searchQuery, filteredFiles], async () => {
|
|||||||
<input
|
<input
|
||||||
ref="searchInput" :placeholder="placeholder" autocomplete="off"
|
ref="searchInput" :placeholder="placeholder" autocomplete="off"
|
||||||
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
|
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
|
||||||
data-global-keyboard-shortcut="t" aria-keyshortcuts="t"
|
aria-keyshortcuts="t"
|
||||||
@input="handleSearchInput" @keydown="handleKeyDown"
|
@input="handleSearchInput" @keydown="handleKeyDown"
|
||||||
@focus="isInputFocused = true" @blur="isInputFocused = false"
|
|
||||||
>
|
>
|
||||||
<kbd v-show="!searchQuery && !isInputFocused" class="repo-file-search-shortcut-hint" aria-hidden="true">T</kbd>
|
<kbd data-global-keyboard-shortcut="t" class="repo-file-search-shortcut-hint" aria-hidden="true">T</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@ -218,11 +215,6 @@ watch([searchQuery, filteredFiles], async () => {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide kbd when input is focused so it doesn't interfere with focus border */
|
|
||||||
.repo-file-search-input-wrapper input:focus + .repo-file-search-shortcut-hint {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-search-popup {
|
.file-search-popup {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--color-box-body);
|
background: var(--color-box-body);
|
||||||
|
|||||||
@ -1,105 +1,148 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
import {describe, test, expect, beforeEach, afterEach} from 'vitest';
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
import {initRepoCodeSearchShortcut} from './repo-shortcuts.ts';
|
// The keyboard shortcut mechanism is driven by global event delegation in observer.ts.
|
||||||
|
// These tests set up the same event listeners to verify the behavior in isolation.
|
||||||
|
|
||||||
describe('Repository Code Search Shortcut Hint', () => {
|
function setupKeyboardShortcutListeners() {
|
||||||
let codeSearchInput: HTMLInputElement;
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
let codeSearchHint: HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
if (e.key === 'Escape' && target.matches('input, textarea, select')) {
|
||||||
|
const kbd = target.parentElement?.querySelector<HTMLElement>('kbd[data-global-keyboard-shortcut]');
|
||||||
|
if (kbd) {
|
||||||
|
(target as HTMLInputElement).value = '';
|
||||||
|
(target as HTMLInputElement).blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.matches('input, textarea, select') || target.isContentEditable) return;
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
const escapedKey = CSS.escape(key);
|
||||||
|
const kbd = document.querySelector<HTMLElement>(`kbd[data-global-keyboard-shortcut="${escapedKey}"]`);
|
||||||
|
if (!kbd) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const input = kbd.parentElement?.querySelector<HTMLInputElement>('input, textarea, select');
|
||||||
|
if (input) input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('focusin', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.matches('input, textarea, select')) return;
|
||||||
|
const kbd = target.parentElement?.querySelector<HTMLElement>('kbd[data-global-keyboard-shortcut]');
|
||||||
|
if (kbd) kbd.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('focusout', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.matches('input, textarea, select')) return;
|
||||||
|
const kbd = target.parentElement?.querySelector<HTMLElement>('kbd[data-global-keyboard-shortcut]');
|
||||||
|
if (kbd) kbd.style.display = (target as HTMLInputElement).value ? 'none' : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Keyboard Shortcut Mechanism', () => {
|
||||||
|
let input: HTMLInputElement;
|
||||||
|
let kbd: HTMLElement;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Set up DOM structure for code search
|
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div class="repo-home-sidebar-top">
|
<div>
|
||||||
<div class="repo-code-search-input-wrapper">
|
<input placeholder="Search" type="text">
|
||||||
<input name="q" class="code-search-input" placeholder="Search code" data-global-keyboard-shortcut="s" data-global-init="initRepoCodeSearchShortcut">
|
<kbd data-global-keyboard-shortcut="s">S</kbd>
|
||||||
<kbd class="repo-search-shortcut-hint">S</kbd>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
input = document.querySelector('input')!;
|
||||||
codeSearchInput = document.querySelector('.code-search-input')!;
|
kbd = document.querySelector('kbd')!;
|
||||||
codeSearchHint = document.querySelector('.repo-code-search-input-wrapper .repo-search-shortcut-hint')!;
|
|
||||||
|
|
||||||
// Initialize the shortcut hint functionality directly
|
|
||||||
initRepoCodeSearchShortcut(codeSearchInput);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Code search hint hides when input has value', () => {
|
// Register listeners once for all tests (they persist across tests on document)
|
||||||
// Initially visible
|
setupKeyboardShortcutListeners();
|
||||||
expect(codeSearchHint.style.display).toBe('');
|
|
||||||
|
|
||||||
// Type something in the code search
|
test('Shortcut key focuses the associated input', () => {
|
||||||
codeSearchInput.value = 'test';
|
expect(document.activeElement).not.toBe(input);
|
||||||
codeSearchInput.dispatchEvent(new Event('input'));
|
|
||||||
|
|
||||||
// Should be hidden
|
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', bubbles: true}));
|
||||||
expect(codeSearchHint.style.display).toBe('none');
|
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Code search hint shows when input is cleared', () => {
|
test('Kbd hint hides when input is focused', () => {
|
||||||
// Set a value and trigger input
|
expect(kbd.style.display).toBe('');
|
||||||
codeSearchInput.value = 'test';
|
|
||||||
codeSearchInput.dispatchEvent(new Event('input'));
|
|
||||||
expect(codeSearchHint.style.display).toBe('none');
|
|
||||||
|
|
||||||
// Clear the value
|
input.focus();
|
||||||
codeSearchInput.value = '';
|
|
||||||
codeSearchInput.dispatchEvent(new Event('input'));
|
|
||||||
|
|
||||||
// Should be visible again
|
expect(kbd.style.display).toBe('none');
|
||||||
expect(codeSearchHint.style.display).toBe('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Escape key clears and blurs code search input', () => {
|
test('Kbd hint shows when input is blurred with empty value', () => {
|
||||||
// Set a value and focus the input
|
input.focus();
|
||||||
codeSearchInput.value = 'test';
|
expect(kbd.style.display).toBe('none');
|
||||||
codeSearchInput.dispatchEvent(new Event('input'));
|
|
||||||
codeSearchInput.focus();
|
input.blur();
|
||||||
expect(document.activeElement).toBe(codeSearchInput);
|
|
||||||
expect(codeSearchInput.value).toBe('test');
|
expect(kbd.style.display).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kbd hint stays hidden when input is blurred with a value', () => {
|
||||||
|
input.focus();
|
||||||
|
input.value = 'test';
|
||||||
|
|
||||||
|
input.blur();
|
||||||
|
|
||||||
|
expect(kbd.style.display).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape key clears and blurs the input', () => {
|
||||||
|
input.focus();
|
||||||
|
input.value = 'test';
|
||||||
|
|
||||||
// Press Escape directly on the input
|
|
||||||
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
|
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
|
||||||
codeSearchInput.dispatchEvent(event);
|
input.dispatchEvent(event);
|
||||||
|
|
||||||
// Value should be cleared and input should be blurred
|
expect(input.value).toBe('');
|
||||||
expect(codeSearchInput.value).toBe('');
|
expect(document.activeElement).not.toBe(input);
|
||||||
expect(document.activeElement).not.toBe(codeSearchInput);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Code search kbd hint hides on focus', () => {
|
test('Escape key shows kbd hint after clearing', () => {
|
||||||
// Initially visible
|
input.focus();
|
||||||
expect(codeSearchHint.style.display).toBe('');
|
input.value = 'test';
|
||||||
|
expect(kbd.style.display).toBe('none');
|
||||||
|
|
||||||
// Focus the input
|
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
|
||||||
codeSearchInput.focus();
|
input.dispatchEvent(event);
|
||||||
codeSearchInput.dispatchEvent(new Event('focus'));
|
|
||||||
|
|
||||||
// Should be hidden
|
expect(kbd.style.display).toBe('');
|
||||||
expect(codeSearchHint.style.display).toBe('none');
|
|
||||||
|
|
||||||
// Blur the input
|
|
||||||
codeSearchInput.blur();
|
|
||||||
codeSearchInput.dispatchEvent(new Event('blur'));
|
|
||||||
|
|
||||||
// Should be visible again
|
|
||||||
expect(codeSearchHint.style.display).toBe('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Change event also updates hint visibility', () => {
|
test('Shortcut does not trigger with modifier keys', () => {
|
||||||
// Initially visible
|
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', ctrlKey: true, bubbles: true}));
|
||||||
expect(codeSearchHint.style.display).toBe('');
|
expect(document.activeElement).not.toBe(input);
|
||||||
|
|
||||||
// Set value via change event (e.g., browser autofill)
|
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', metaKey: true, bubbles: true}));
|
||||||
codeSearchInput.value = 'autofilled';
|
expect(document.activeElement).not.toBe(input);
|
||||||
codeSearchInput.dispatchEvent(new Event('change'));
|
|
||||||
|
|
||||||
// Should be hidden
|
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', altKey: true, bubbles: true}));
|
||||||
expect(codeSearchHint.style.display).toBe('none');
|
expect(document.activeElement).not.toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Shortcut does not trigger when typing in another input', () => {
|
||||||
|
// Add a second input without a shortcut
|
||||||
|
const otherInput = document.createElement('input');
|
||||||
|
document.body.append(otherInput);
|
||||||
|
otherInput.focus();
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', {key: 's', bubbles: true});
|
||||||
|
otherInput.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(otherInput);
|
||||||
|
expect(document.activeElement).not.toBe(input);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the code search input with shortcut hint visibility management.
|
|
||||||
* The shortcut hint is hidden when the input has a value or is focused.
|
|
||||||
* Pressing Escape clears the input and blurs it.
|
|
||||||
*/
|
|
||||||
export function initRepoCodeSearchShortcut(el: HTMLInputElement): void {
|
|
||||||
const shortcutHint = el.parentElement?.querySelector<HTMLElement>('.repo-search-shortcut-hint');
|
|
||||||
if (!shortcutHint) return;
|
|
||||||
|
|
||||||
let isFocused = false;
|
|
||||||
|
|
||||||
const updateHintVisibility = () => {
|
|
||||||
shortcutHint.style.display = (el.value || isFocused) ? 'none' : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check initial value (e.g., from browser autofill or back navigation)
|
|
||||||
updateHintVisibility();
|
|
||||||
|
|
||||||
el.addEventListener('input', updateHintVisibility);
|
|
||||||
el.addEventListener('change', updateHintVisibility);
|
|
||||||
el.addEventListener('focus', () => {
|
|
||||||
isFocused = true;
|
|
||||||
updateHintVisibility();
|
|
||||||
});
|
|
||||||
el.addEventListener('blur', () => {
|
|
||||||
isFocused = false;
|
|
||||||
updateHintVisibility();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle Escape key to clear and blur the code search input
|
|
||||||
el.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
el.value = '';
|
|
||||||
updateHintVisibility();
|
|
||||||
el.blur();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initRepoShortcuts(): void {
|
|
||||||
registerGlobalInitFunc('initRepoCodeSearchShortcut', initRepoCodeSearchShortcut);
|
|
||||||
}
|
|
||||||
@ -55,7 +55,6 @@ import {initRepoRecentCommits} from './features/recent-commits.ts';
|
|||||||
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
|
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
|
||||||
import {initGlobalSelectorObserver} from './modules/observer.ts';
|
import {initGlobalSelectorObserver} from './modules/observer.ts';
|
||||||
import {initRepositorySearch} from './features/repo-search.ts';
|
import {initRepositorySearch} from './features/repo-search.ts';
|
||||||
import {initRepoShortcuts} from './features/repo-shortcuts.ts';
|
|
||||||
import {initColorPickers} from './features/colorpicker.ts';
|
import {initColorPickers} from './features/colorpicker.ts';
|
||||||
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
|
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
|
||||||
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
|
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
|
||||||
@ -141,7 +140,6 @@ const initPerformanceTracer = callInitFunctions([
|
|||||||
initRepository,
|
initRepository,
|
||||||
initRepositoryActionView,
|
initRepositoryActionView,
|
||||||
initRepositorySearch,
|
initRepositorySearch,
|
||||||
initRepoShortcuts,
|
|
||||||
initRepoContributors,
|
initRepoContributors,
|
||||||
initRepoCodeFrequency,
|
initRepoCodeFrequency,
|
||||||
initRepoRecentCommits,
|
initRepoRecentCommits,
|
||||||
|
|||||||
@ -54,6 +54,13 @@ function callGlobalInitFunc(el: HTMLElement) {
|
|||||||
func(el);
|
func(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initKeyboardShortcutKbd(kbd: HTMLElement) {
|
||||||
|
// Handle initial state: hide the kbd hint if the associated input already has a value
|
||||||
|
// (e.g., from browser autofill or back/forward navigation cache)
|
||||||
|
const input = kbd.parentElement?.querySelector<HTMLInputElement>('input, textarea, select');
|
||||||
|
if (input?.value) kbd.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function attachGlobalEvents() {
|
function attachGlobalEvents() {
|
||||||
// add global "[data-global-click]" event handler
|
// add global "[data-global-click]" event handler
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
@ -65,12 +72,25 @@ function attachGlobalEvents() {
|
|||||||
func(elem, e);
|
func(elem, e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// add global "[data-global-keyboard-shortcut]" event handler
|
// add global "kbd[data-global-keyboard-shortcut]" event handlers
|
||||||
// Elements declare their keyboard shortcuts via data-global-keyboard-shortcut attribute.
|
// A <kbd> element next to an <input> declares a keyboard shortcut for that input.
|
||||||
// When a matching key is pressed, the element is focused (for inputs) or clicked (for buttons/links).
|
// When the matching key is pressed, the sibling input is focused.
|
||||||
|
// 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) => {
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
// Don't trigger shortcuts when typing in input fields or contenteditable areas
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Handle Escape: clear and blur inputs that have an associated keyboard shortcut
|
||||||
|
if (e.key === 'Escape' && target.matches('input, textarea, select')) {
|
||||||
|
const kbd = target.parentElement?.querySelector<HTMLElement>('kbd[data-global-keyboard-shortcut]');
|
||||||
|
if (kbd) {
|
||||||
|
(target as HTMLInputElement).value = '';
|
||||||
|
(target as HTMLInputElement).blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't trigger shortcuts when typing in input fields or contenteditable areas
|
||||||
if (target.matches('input, textarea, select') || target.isContentEditable) {
|
if (target.matches('input, textarea, select') || target.isContentEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -80,18 +100,30 @@ function attachGlobalEvents() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find element with matching shortcut (case-insensitive)
|
// Find kbd element with matching shortcut (case-insensitive), then focus its sibling input
|
||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
const escapedKey = CSS.escape(key);
|
const escapedKey = CSS.escape(key);
|
||||||
const elem = document.querySelector<HTMLElement>(`[data-global-keyboard-shortcut="${escapedKey}"]`);
|
const kbd = document.querySelector<HTMLElement>(`kbd[data-global-keyboard-shortcut="${escapedKey}"]`);
|
||||||
if (!elem) return;
|
if (!kbd) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (elem.matches('input, textarea, select')) {
|
const input = kbd.parentElement?.querySelector<HTMLInputElement>('input, textarea, select');
|
||||||
elem.focus();
|
if (input) input.focus();
|
||||||
} else {
|
});
|
||||||
elem.click();
|
|
||||||
}
|
// Toggle kbd shortcut hint visibility on input focus/blur
|
||||||
|
document.addEventListener('focusin', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.matches('input, textarea, select')) return;
|
||||||
|
const kbd = target.parentElement?.querySelector<HTMLElement>('kbd[data-global-keyboard-shortcut]');
|
||||||
|
if (kbd) kbd.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('focusout', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.matches('input, textarea, select')) return;
|
||||||
|
const kbd = target.parentElement?.querySelector<HTMLElement>('kbd[data-global-keyboard-shortcut]');
|
||||||
|
if (kbd) kbd.style.display = (target as HTMLInputElement).value ? 'none' : '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +134,7 @@ export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | n
|
|||||||
attachGlobalEvents();
|
attachGlobalEvents();
|
||||||
|
|
||||||
selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc});
|
selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc});
|
||||||
|
selectorHandlers.push({selector: 'kbd[data-global-keyboard-shortcut]', handler: initKeyboardShortcutKbd});
|
||||||
const observer = new MutationObserver((mutationList) => {
|
const observer = new MutationObserver((mutationList) => {
|
||||||
const len = mutationList.length;
|
const len = mutationList.length;
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user