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:
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">
|
||||
<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>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {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,
|
||||
|
||||
@ -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++) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user