Skip to content

Commit 6b95dc0

Browse files
committed
refactor(shortcut): Use declarative data attributes for keyboard shortcuts
Instead of having JavaScript code guess which elements exist on a page, elements now declare their keyboard shortcuts via data-global-keyboard-shortcut attribute. This makes it easier to add new shortcuts and follows Gitea's existing patterns for data-global-init and data-global-click.
1 parent 6333d01 commit 6b95dc0

File tree

5 files changed

+90
-137
lines changed

5 files changed

+90
-137
lines changed

templates/repo/home_sidebar_top.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<div class="repo-home-sidebar-top">
22
<form class="ignore-dirty tw-flex tw-flex-1" action="{{.RepoLink}}/search" method="get">
33
<div class="ui small action input tw-flex-1 repo-code-search-input-wrapper">
4-
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}" class="code-search-input">
4+
<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">
55
<kbd class="repo-search-shortcut-hint">S</kbd>
66
{{template "shared/search/button"}}
77
</div>

web_src/js/components/RepoFileSearch.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ watch([searchQuery, filteredFiles], async () => {
148148
<input
149149
ref="searchInput" :placeholder="placeholder" autocomplete="off"
150150
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
151+
data-global-keyboard-shortcut="t"
151152
@input="handleSearchInput" @keydown="handleKeyDown"
152153
@focus="isInputFocused = true" @blur="isInputFocused = false"
153154
>
Lines changed: 26 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,34 @@
11
// Copyright 2026 The Gitea Authors. All rights reserved.
22
// SPDX-License-Identifier: MIT
33

4-
import {initRepoShortcuts} from './repo-shortcuts.ts';
4+
import {initRepoCodeSearchShortcut} from './repo-shortcuts.ts';
55

6-
describe('Repository Keyboard Shortcuts', () => {
7-
let fileSearchInput: HTMLInputElement;
6+
describe('Repository Code Search Shortcut Hint', () => {
87
let codeSearchInput: HTMLInputElement;
98
let codeSearchHint: HTMLElement;
109

1110
beforeEach(() => {
12-
// Set up DOM structure
11+
// Set up DOM structure for code search
1312
document.body.innerHTML = `
14-
<div class="repo-file-search-container">
15-
<div class="repo-file-search-input-wrapper">
16-
<input type="text" placeholder="Go to file">
17-
<kbd class="repo-search-shortcut-hint">T</kbd>
18-
</div>
19-
</div>
2013
<div class="repo-home-sidebar-top">
2114
<div class="repo-code-search-input-wrapper">
22-
<input name="q" class="code-search-input" placeholder="Search code">
15+
<input name="q" class="code-search-input" placeholder="Search code" data-global-keyboard-shortcut="s" data-global-init="initRepoCodeSearchShortcut">
2316
<kbd class="repo-search-shortcut-hint">S</kbd>
2417
</div>
2518
</div>
2619
`;
2720

28-
fileSearchInput = document.querySelector('.repo-file-search-container input')!;
2921
codeSearchInput = document.querySelector('.code-search-input')!;
3022
codeSearchHint = document.querySelector('.repo-code-search-input-wrapper .repo-search-shortcut-hint')!;
3123

32-
initRepoShortcuts();
24+
// Initialize the shortcut hint functionality directly
25+
initRepoCodeSearchShortcut(codeSearchInput);
3326
});
3427

3528
afterEach(() => {
3629
document.body.innerHTML = '';
3730
});
3831

39-
test('T key focuses file search input', () => {
40-
const event = new KeyboardEvent('keydown', {key: 't', bubbles: true});
41-
document.dispatchEvent(event);
42-
43-
expect(document.activeElement).toBe(fileSearchInput);
44-
});
45-
46-
test('Shift+T (uppercase T) focuses file search input', () => {
47-
const event = new KeyboardEvent('keydown', {key: 'T', bubbles: true});
48-
document.dispatchEvent(event);
49-
50-
expect(document.activeElement).toBe(fileSearchInput);
51-
});
52-
53-
test('S key focuses code search input', () => {
54-
const event = new KeyboardEvent('keydown', {key: 's', bubbles: true});
55-
document.dispatchEvent(event);
56-
57-
expect(document.activeElement).toBe(codeSearchInput);
58-
});
59-
60-
test('Shortcuts do not trigger when typing in input', () => {
61-
// Focus on an input field first
62-
const otherInput = document.createElement('input');
63-
document.body.append(otherInput);
64-
otherInput.focus();
65-
66-
const event = new KeyboardEvent('keydown', {key: 't', bubbles: true});
67-
Object.defineProperty(event, 'target', {value: otherInput});
68-
document.dispatchEvent(event);
69-
70-
// File search should not be focused because we're already in an input
71-
expect(document.activeElement).toBe(otherInput);
72-
});
73-
74-
test('Shortcuts do not trigger with Ctrl modifier', () => {
75-
const event = new KeyboardEvent('keydown', {key: 't', ctrlKey: true, bubbles: true});
76-
document.dispatchEvent(event);
77-
78-
expect(document.activeElement).not.toBe(fileSearchInput);
79-
});
80-
81-
test('Shortcuts do not trigger with Meta modifier', () => {
82-
const event = new KeyboardEvent('keydown', {key: 's', metaKey: true, bubbles: true});
83-
document.dispatchEvent(event);
84-
85-
expect(document.activeElement).not.toBe(codeSearchInput);
86-
});
87-
88-
test('Shortcuts do not trigger with Alt modifier', () => {
89-
const event = new KeyboardEvent('keydown', {key: 't', altKey: true, bubbles: true});
90-
document.dispatchEvent(event);
91-
92-
expect(document.activeElement).not.toBe(fileSearchInput);
93-
});
94-
9532
test('Code search hint hides when input has value', () => {
9633
// Initially visible
9734
expect(codeSearchHint.style.display).toBe('');
@@ -118,16 +55,20 @@ describe('Repository Keyboard Shortcuts', () => {
11855
expect(codeSearchHint.style.display).toBe('');
11956
});
12057

121-
test('Escape key blurs code search input', () => {
122-
// Focus the code search input first
58+
test('Escape key clears and blurs code search input', () => {
59+
// Set a value and focus the input
60+
codeSearchInput.value = 'test';
61+
codeSearchInput.dispatchEvent(new Event('input'));
12362
codeSearchInput.focus();
12463
expect(document.activeElement).toBe(codeSearchInput);
64+
expect(codeSearchInput.value).toBe('test');
12565

126-
// Press Escape directly on the input (the input has its own keydown handler)
66+
// Press Escape directly on the input
12767
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
12868
codeSearchInput.dispatchEvent(event);
12969

130-
// Should no longer be focused
70+
// Value should be cleared and input should be blurred
71+
expect(codeSearchInput.value).toBe('');
13172
expect(document.activeElement).not.toBe(codeSearchInput);
13273
});
13374

@@ -149,4 +90,16 @@ describe('Repository Keyboard Shortcuts', () => {
14990
// Should be visible again
15091
expect(codeSearchHint.style.display).toBe('');
15192
});
93+
94+
test('Change event also updates hint visibility', () => {
95+
// Initially visible
96+
expect(codeSearchHint.style.display).toBe('');
97+
98+
// Set value via change event (e.g., browser autofill)
99+
codeSearchInput.value = 'autofilled';
100+
codeSearchInput.dispatchEvent(new Event('change'));
101+
102+
// Should be hidden
103+
expect(codeSearchHint.style.display).toBe('none');
104+
});
152105
});
Lines changed: 34 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,47 @@
11
// Copyright 2026 The Gitea Authors. All rights reserved.
22
// SPDX-License-Identifier: MIT
33

4+
import {registerGlobalInitFunc} from '../modules/observer.ts';
5+
46
/**
5-
* Initialize global keyboard shortcuts for repository pages.
6-
* - 'T' key: Focus the "Go to file" search input
7-
* - 'S' key: Focus the "Search code" input
8-
*
9-
* Shortcuts are disabled when the user is typing in an input field,
10-
* textarea, or contenteditable element.
7+
* Initialize the code search input with shortcut hint visibility management.
8+
* The shortcut hint is hidden when the input has a value or is focused.
9+
* Pressing Escape clears the input and blurs it.
1110
*/
12-
export function initRepoShortcuts(): void {
13-
// Initialize keyboard shortcut listeners
14-
document.addEventListener('keydown', (e: KeyboardEvent) => {
15-
// Don't trigger shortcuts when typing in input fields
16-
const target = e.target as HTMLElement;
17-
if (target instanceof HTMLElement && target.matches('input, textarea, select, [contenteditable="true"]')) {
18-
return;
19-
}
11+
export function initRepoCodeSearchShortcut(el: HTMLInputElement): void {
12+
const shortcutHint = el.parentElement?.querySelector<HTMLElement>('.repo-search-shortcut-hint');
13+
if (!shortcutHint) return;
2014

21-
// Don't trigger shortcuts when modifier keys are pressed
22-
if (e.ctrlKey || e.metaKey || e.altKey) {
23-
return;
24-
}
15+
let isFocused = false;
2516

26-
if (e.key === 't' || e.key === 'T') {
27-
const fileSearchInput = document.querySelector<HTMLInputElement>('.repo-file-search-container input');
28-
if (fileSearchInput) {
29-
e.preventDefault();
30-
fileSearchInput.focus();
31-
}
32-
} else if (e.key === 's' || e.key === 'S') {
33-
const codeSearchInput = document.querySelector<HTMLInputElement>('.repo-home-sidebar-top input[name="q"], .code-search-input');
34-
if (codeSearchInput) {
35-
e.preventDefault();
36-
codeSearchInput.focus();
37-
}
38-
}
39-
});
17+
const updateHintVisibility = () => {
18+
shortcutHint.style.display = (el.value || isFocused) ? 'none' : '';
19+
};
4020

41-
// Toggle shortcut hint visibility for code search input based on input value and focus state
42-
const codeSearchInput = document.querySelector<HTMLInputElement>('.code-search-input');
43-
if (codeSearchInput) {
44-
const shortcutHint = codeSearchInput.parentElement?.querySelector<HTMLElement>('.repo-search-shortcut-hint');
45-
if (shortcutHint) {
46-
let isFocused = false;
21+
// Check initial value (e.g., from browser autofill or back navigation)
22+
updateHintVisibility();
4723

48-
const updateHintVisibility = () => {
49-
shortcutHint.style.display = (codeSearchInput.value || isFocused) ? 'none' : '';
50-
};
24+
el.addEventListener('input', updateHintVisibility);
25+
el.addEventListener('change', updateHintVisibility);
26+
el.addEventListener('focus', () => {
27+
isFocused = true;
28+
updateHintVisibility();
29+
});
30+
el.addEventListener('blur', () => {
31+
isFocused = false;
32+
updateHintVisibility();
33+
});
5134

52-
// Check initial value (e.g., from browser autofill or back navigation)
35+
// Handle Escape key to clear and blur the code search input
36+
el.addEventListener('keydown', (e: KeyboardEvent) => {
37+
if (e.key === 'Escape') {
38+
el.value = '';
5339
updateHintVisibility();
54-
55-
codeSearchInput.addEventListener('input', updateHintVisibility);
56-
codeSearchInput.addEventListener('change', updateHintVisibility);
57-
codeSearchInput.addEventListener('focus', () => {
58-
isFocused = true;
59-
updateHintVisibility();
60-
});
61-
codeSearchInput.addEventListener('blur', () => {
62-
isFocused = false;
63-
updateHintVisibility();
64-
});
65-
66-
// Handle Escape key to clear and blur the code search input
67-
codeSearchInput.addEventListener('keydown', (e: KeyboardEvent) => {
68-
if (e.key === 'Escape') {
69-
codeSearchInput.value = '';
70-
updateHintVisibility();
71-
codeSearchInput.blur();
72-
}
73-
});
40+
el.blur();
7441
}
75-
}
42+
});
43+
}
44+
45+
export function initRepoShortcuts(): void {
46+
registerGlobalInitFunc('initRepoCodeSearchShortcut', initRepoCodeSearchShortcut);
7647
}

web_src/js/modules/observer.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,34 @@ function attachGlobalEvents() {
6464
if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
6565
func(elem, e);
6666
});
67+
68+
// add global "[data-global-keyboard-shortcut]" event handler
69+
// Elements declare their keyboard shortcuts via data-global-keyboard-shortcut attribute.
70+
// When a matching key is pressed, the element is focused (for inputs) or clicked (for buttons/links).
71+
document.addEventListener('keydown', (e: KeyboardEvent) => {
72+
// Don't trigger shortcuts when typing in input fields
73+
const target = e.target as HTMLElement;
74+
if (target.matches('input, textarea, select, [contenteditable="true"]')) {
75+
return;
76+
}
77+
78+
// Don't trigger shortcuts when modifier keys are pressed
79+
if (e.ctrlKey || e.metaKey || e.altKey) {
80+
return;
81+
}
82+
83+
// Find element with matching shortcut (case-insensitive)
84+
const key = e.key.toLowerCase();
85+
const elem = document.querySelector<HTMLElement>(`[data-global-keyboard-shortcut="${key}"]`);
86+
if (!elem) return;
87+
88+
e.preventDefault();
89+
if (elem.matches('input, textarea, select')) {
90+
elem.focus();
91+
} else {
92+
elem.click();
93+
}
94+
});
6795
}
6896

6997
export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | null): void {

0 commit comments

Comments
 (0)