0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-11 20:05:38 +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:
Micah Kepe 2026-02-14 21:39:30 -08:00
parent ec3420a2c9
commit 0cabec3cfa
8 changed files with 205 additions and 145 deletions

View File

@ -0,0 +1,41 @@
{{template "devtest/devtest-header"}}
<div class="page-content devtest ui container">
<h1>Keyboard Shortcut</h1>
<p>
A <code>&lt;kbd data-global-keyboard-shortcut="key"&gt;</code> element next to an <code>&lt;input&gt;</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"}}

View File

@ -1,8 +1,8 @@
<div class="repo-home-sidebar-top">
<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">
<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">
<kbd class="repo-search-shortcut-hint" aria-hidden="true">S</kbd>
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}" class="code-search-input" aria-keyshortcuts="s">
<kbd data-global-keyboard-shortcut="s" class="repo-search-shortcut-hint" aria-hidden="true">S</kbd>
{{template "shared/search/button"}}
</div>
</form>

View File

@ -89,7 +89,7 @@ test.describe('Repository Keyboard Shortcuts', () => {
await fileSearchInput.click();
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();
});

View File

@ -23,8 +23,6 @@ const allFiles = ref<string[]>([]);
const selectedIndex = ref(0);
const isLoadingFileList = ref(false);
const hasLoadedFileList = ref(false);
const isInputFocused = ref(false);
const showPopup = computed(() => searchQuery.value.length > 0);
const filteredFiles = computed(() => {
@ -150,11 +148,10 @@ watch([searchQuery, filteredFiles], async () => {
<input
ref="searchInput" :placeholder="placeholder" autocomplete="off"
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"
@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>
<Teleport to="body">
@ -218,11 +215,6 @@ watch([searchQuery, filteredFiles], async () => {
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 {
position: absolute;
background: var(--color-box-body);

View File

@ -1,105 +1,148 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
import {describe, test, expect, beforeEach, afterEach} from 'vitest';
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', () => {
let codeSearchInput: HTMLInputElement;
let codeSearchHint: HTMLElement;
function setupKeyboardShortcutListeners() {
document.addEventListener('keydown', (e: KeyboardEvent) => {
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(() => {
// Set up DOM structure for code search
document.body.innerHTML = `
<div class="repo-home-sidebar-top">
<div class="repo-code-search-input-wrapper">
<input name="q" class="code-search-input" placeholder="Search code" data-global-keyboard-shortcut="s" data-global-init="initRepoCodeSearchShortcut">
<kbd class="repo-search-shortcut-hint">S</kbd>
</div>
<div>
<input placeholder="Search" type="text">
<kbd data-global-keyboard-shortcut="s">S</kbd>
</div>
`;
codeSearchInput = document.querySelector('.code-search-input')!;
codeSearchHint = document.querySelector('.repo-code-search-input-wrapper .repo-search-shortcut-hint')!;
// Initialize the shortcut hint functionality directly
initRepoCodeSearchShortcut(codeSearchInput);
input = document.querySelector('input')!;
kbd = document.querySelector('kbd')!;
});
afterEach(() => {
document.body.innerHTML = '';
});
test('Code search hint hides when input has value', () => {
// Initially visible
expect(codeSearchHint.style.display).toBe('');
// Register listeners once for all tests (they persist across tests on document)
setupKeyboardShortcutListeners();
// Type something in the code search
codeSearchInput.value = 'test';
codeSearchInput.dispatchEvent(new Event('input'));
test('Shortcut key focuses the associated input', () => {
expect(document.activeElement).not.toBe(input);
// Should be hidden
expect(codeSearchHint.style.display).toBe('none');
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', bubbles: true}));
expect(document.activeElement).toBe(input);
});
test('Code search hint shows when input is cleared', () => {
// Set a value and trigger input
codeSearchInput.value = 'test';
codeSearchInput.dispatchEvent(new Event('input'));
expect(codeSearchHint.style.display).toBe('none');
test('Kbd hint hides when input is focused', () => {
expect(kbd.style.display).toBe('');
// Clear the value
codeSearchInput.value = '';
codeSearchInput.dispatchEvent(new Event('input'));
input.focus();
// Should be visible again
expect(codeSearchHint.style.display).toBe('');
expect(kbd.style.display).toBe('none');
});
test('Escape key clears and blurs code search input', () => {
// Set a value and focus the input
codeSearchInput.value = 'test';
codeSearchInput.dispatchEvent(new Event('input'));
codeSearchInput.focus();
expect(document.activeElement).toBe(codeSearchInput);
expect(codeSearchInput.value).toBe('test');
test('Kbd hint shows when input is blurred with empty value', () => {
input.focus();
expect(kbd.style.display).toBe('none');
input.blur();
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});
codeSearchInput.dispatchEvent(event);
input.dispatchEvent(event);
// Value should be cleared and input should be blurred
expect(codeSearchInput.value).toBe('');
expect(document.activeElement).not.toBe(codeSearchInput);
expect(input.value).toBe('');
expect(document.activeElement).not.toBe(input);
});
test('Code search kbd hint hides on focus', () => {
// Initially visible
expect(codeSearchHint.style.display).toBe('');
test('Escape key shows kbd hint after clearing', () => {
input.focus();
input.value = 'test';
expect(kbd.style.display).toBe('none');
// Focus the input
codeSearchInput.focus();
codeSearchInput.dispatchEvent(new Event('focus'));
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
input.dispatchEvent(event);
// Should be hidden
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('');
expect(kbd.style.display).toBe('');
});
test('Change event also updates hint visibility', () => {
// Initially visible
expect(codeSearchHint.style.display).toBe('');
test('Shortcut does not trigger with modifier keys', () => {
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', ctrlKey: true, bubbles: true}));
expect(document.activeElement).not.toBe(input);
// Set value via change event (e.g., browser autofill)
codeSearchInput.value = 'autofilled';
codeSearchInput.dispatchEvent(new Event('change'));
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', metaKey: true, bubbles: true}));
expect(document.activeElement).not.toBe(input);
// Should be hidden
expect(codeSearchHint.style.display).toBe('none');
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', altKey: true, bubbles: true}));
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);
});
});

View File

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

View File

@ -55,7 +55,6 @@ import {initRepoRecentCommits} from './features/recent-commits.ts';
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
import {initGlobalSelectorObserver} from './modules/observer.ts';
import {initRepositorySearch} from './features/repo-search.ts';
import {initRepoShortcuts} from './features/repo-shortcuts.ts';
import {initColorPickers} from './features/colorpicker.ts';
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
@ -141,7 +140,6 @@ const initPerformanceTracer = callInitFunctions([
initRepository,
initRepositoryActionView,
initRepositorySearch,
initRepoShortcuts,
initRepoContributors,
initRepoCodeFrequency,
initRepoRecentCommits,

View File

@ -54,6 +54,13 @@ function callGlobalInitFunc(el: HTMLElement) {
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() {
// add global "[data-global-click]" event handler
document.addEventListener('click', (e) => {
@ -65,12 +72,25 @@ function attachGlobalEvents() {
func(elem, e);
});
// add global "[data-global-keyboard-shortcut]" event handler
// Elements declare their keyboard shortcuts via data-global-keyboard-shortcut attribute.
// When a matching key is pressed, the element is focused (for inputs) or clicked (for buttons/links).
// add global "kbd[data-global-keyboard-shortcut]" event handlers
// A <kbd> element next to an <input> declares a keyboard shortcut for that input.
// 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) => {
// Don't trigger shortcuts when typing in input fields or contenteditable areas
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) {
return;
}
@ -80,18 +100,30 @@ function attachGlobalEvents() {
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 escapedKey = CSS.escape(key);
const elem = document.querySelector<HTMLElement>(`[data-global-keyboard-shortcut="${escapedKey}"]`);
if (!elem) return;
const kbd = document.querySelector<HTMLElement>(`kbd[data-global-keyboard-shortcut="${escapedKey}"]`);
if (!kbd) return;
e.preventDefault();
if (elem.matches('input, textarea, select')) {
elem.focus();
} else {
elem.click();
}
const input = kbd.parentElement?.querySelector<HTMLInputElement>('input, textarea, select');
if (input) input.focus();
});
// 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();
selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc});
selectorHandlers.push({selector: 'kbd[data-global-keyboard-shortcut]', handler: initKeyboardShortcutKbd});
const observer = new MutationObserver((mutationList) => {
const len = mutationList.length;
for (let i = 0; i < len; i++) {