feat(shortcut): Add keyboard shortcuts for repository file and code search

Add GitHub-like keyboard shortcuts for repository navigation:
- Press 'T' to focus the "Go to file" search input
- Press 'S' to focus the "Search code" input
- Press 'Escape' to blur focused search inputs

Both search inputs display a keyboard hint (kbd element) showing
the available shortcut. The hint hides when the input is focused
or has a value.

Includes unit tests and e2e tests for the new functionality.
This commit is contained in:
micahkepe
2026-02-14 22:43:52 -08:00
committed by Micah Kepe
parent a6282c98d7
commit 7226ecde9a
7 changed files with 460 additions and 4 deletions
+152
View File
@@ -0,0 +1,152 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
import {initRepoShortcuts} from './repo-shortcuts.ts';
describe('Repository Keyboard Shortcuts', () => {
let fileSearchInput: HTMLInputElement;
let codeSearchInput: HTMLInputElement;
let codeSearchHint: HTMLElement;
beforeEach(() => {
// Set up DOM structure
document.body.innerHTML = `
<div class="repo-file-search-container">
<div class="repo-file-search-input-wrapper">
<input type="text" placeholder="Go to file">
<kbd class="repo-search-shortcut-hint">T</kbd>
</div>
</div>
<div class="repo-home-sidebar-top">
<div class="repo-code-search-input-wrapper">
<input name="q" class="code-search-input" placeholder="Search code">
<kbd class="repo-search-shortcut-hint">S</kbd>
</div>
</div>
`;
fileSearchInput = document.querySelector('.repo-file-search-container input')!;
codeSearchInput = document.querySelector('.code-search-input')!;
codeSearchHint = document.querySelector('.repo-code-search-input-wrapper .repo-search-shortcut-hint')!;
initRepoShortcuts();
});
afterEach(() => {
document.body.innerHTML = '';
});
test('T key focuses file search input', () => {
const event = new KeyboardEvent('keydown', {key: 't', bubbles: true});
document.dispatchEvent(event);
expect(document.activeElement).toBe(fileSearchInput);
});
test('Shift+T (uppercase T) focuses file search input', () => {
const event = new KeyboardEvent('keydown', {key: 'T', bubbles: true});
document.dispatchEvent(event);
expect(document.activeElement).toBe(fileSearchInput);
});
test('S key focuses code search input', () => {
const event = new KeyboardEvent('keydown', {key: 's', bubbles: true});
document.dispatchEvent(event);
expect(document.activeElement).toBe(codeSearchInput);
});
test('Shortcuts do not trigger when typing in input', () => {
// Focus on an input field first
const otherInput = document.createElement('input');
document.body.append(otherInput);
otherInput.focus();
const event = new KeyboardEvent('keydown', {key: 't', bubbles: true});
Object.defineProperty(event, 'target', {value: otherInput});
document.dispatchEvent(event);
// File search should not be focused because we're already in an input
expect(document.activeElement).toBe(otherInput);
});
test('Shortcuts do not trigger with Ctrl modifier', () => {
const event = new KeyboardEvent('keydown', {key: 't', ctrlKey: true, bubbles: true});
document.dispatchEvent(event);
expect(document.activeElement).not.toBe(fileSearchInput);
});
test('Shortcuts do not trigger with Meta modifier', () => {
const event = new KeyboardEvent('keydown', {key: 's', metaKey: true, bubbles: true});
document.dispatchEvent(event);
expect(document.activeElement).not.toBe(codeSearchInput);
});
test('Shortcuts do not trigger with Alt modifier', () => {
const event = new KeyboardEvent('keydown', {key: 't', altKey: true, bubbles: true});
document.dispatchEvent(event);
expect(document.activeElement).not.toBe(fileSearchInput);
});
test('Code search hint hides when input has value', () => {
// Initially visible
expect(codeSearchHint.style.display).toBe('');
// Type something in the code search
codeSearchInput.value = 'test';
codeSearchInput.dispatchEvent(new Event('input'));
// Should be hidden
expect(codeSearchHint.style.display).toBe('none');
});
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');
// Clear the value
codeSearchInput.value = '';
codeSearchInput.dispatchEvent(new Event('input'));
// Should be visible again
expect(codeSearchHint.style.display).toBe('');
});
test('Escape key blurs code search input', () => {
// Focus the code search input first
codeSearchInput.focus();
expect(document.activeElement).toBe(codeSearchInput);
// Press Escape directly on the input (the input has its own keydown handler)
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
codeSearchInput.dispatchEvent(event);
// Should no longer be focused
expect(document.activeElement).not.toBe(codeSearchInput);
});
test('Code search kbd hint hides on focus', () => {
// Initially visible
expect(codeSearchHint.style.display).toBe('');
// Focus the input
codeSearchInput.focus();
codeSearchInput.dispatchEvent(new Event('focus'));
// 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('');
});
});
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
/**
* Initialize global keyboard shortcuts for repository pages.
* - 'T' key: Focus the "Go to file" search input
* - 'S' key: Focus the "Search code" input
*
* Shortcuts are disabled when the user is typing in an input field,
* textarea, or contenteditable element.
*/
export function initRepoShortcuts(): void {
// Initialize keyboard shortcut listeners
document.addEventListener('keydown', (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in input fields
const target = e.target as HTMLElement;
if (target instanceof HTMLElement && target.matches('input, textarea, select, [contenteditable="true"]')) {
return;
}
// Don't trigger shortcuts when modifier keys are pressed
if (e.ctrlKey || e.metaKey || e.altKey) {
return;
}
if (e.key === 't' || e.key === 'T') {
const fileSearchInput = document.querySelector<HTMLInputElement>('.repo-file-search-container input');
if (fileSearchInput) {
e.preventDefault();
fileSearchInput.focus();
}
} else if (e.key === 's' || e.key === 'S') {
const codeSearchInput = document.querySelector<HTMLInputElement>('.repo-home-sidebar-top input[name="q"], .code-search-input');
if (codeSearchInput) {
e.preventDefault();
codeSearchInput.focus();
}
}
});
// Toggle shortcut hint visibility for code search input based on input value and focus state
const codeSearchInput = document.querySelector<HTMLInputElement>('.code-search-input');
if (codeSearchInput) {
const shortcutHint = codeSearchInput.parentElement?.querySelector<HTMLElement>('.repo-search-shortcut-hint');
if (shortcutHint) {
let isFocused = false;
const updateHintVisibility = () => {
shortcutHint.style.display = (codeSearchInput.value || isFocused) ? 'none' : '';
};
// Check initial value (e.g., from browser autofill or back navigation)
updateHintVisibility();
codeSearchInput.addEventListener('input', updateHintVisibility);
codeSearchInput.addEventListener('change', updateHintVisibility);
codeSearchInput.addEventListener('focus', () => {
isFocused = true;
updateHintVisibility();
});
codeSearchInput.addEventListener('blur', () => {
isFocused = false;
updateHintVisibility();
});
// Handle Escape key to clear and blur the code search input
codeSearchInput.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
codeSearchInput.value = '';
updateHintVisibility();
codeSearchInput.blur();
}
});
}
}
}