Skip to content

Commit 0cabec3

Browse files
committed
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.
1 parent ec3420a commit 0cabec3

File tree

8 files changed

+203
-143
lines changed

8 files changed

+203
-143
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{{template "devtest/devtest-header"}}
2+
<div class="page-content devtest ui container">
3+
<h1>Keyboard Shortcut</h1>
4+
<p>
5+
A <code>&lt;kbd data-global-keyboard-shortcut="key"&gt;</code> element next to an <code>&lt;input&gt;</code> declares a keyboard shortcut.
6+
Pressing the key focuses the input. Pressing <kbd>Escape</kbd> clears and blurs it.
7+
The hint is hidden automatically when the input is focused or has a value.
8+
</p>
9+
10+
<h2>Input with "s" shortcut</h2>
11+
<div style="position: relative; display: inline-flex; align-items: center;">
12+
<input class="ui input" placeholder="Press S to focus" style="padding-right: 36px;">
13+
<kbd data-global-keyboard-shortcut="s" class="devtest-shortcut-hint">S</kbd>
14+
</div>
15+
16+
<h2>Input with "f" shortcut</h2>
17+
<div style="position: relative; display: inline-flex; align-items: center;">
18+
<input class="ui input" placeholder="Press F to focus" style="padding-right: 36px;">
19+
<kbd data-global-keyboard-shortcut="f" class="devtest-shortcut-hint">F</kbd>
20+
</div>
21+
</div>
22+
23+
<style>
24+
.devtest-shortcut-hint {
25+
position: absolute;
26+
right: 8px;
27+
top: 50%;
28+
transform: translateY(-50%);
29+
display: inline-block;
30+
padding: 2px 6px;
31+
font-size: 11px;
32+
line-height: 14px;
33+
color: var(--color-text-light-2);
34+
background-color: var(--color-box-body);
35+
border: 1px solid var(--color-secondary);
36+
border-radius: var(--border-radius);
37+
box-shadow: inset 0 -1px 0 var(--color-secondary);
38+
pointer-events: none;
39+
}
40+
</style>
41+
{{template "devtest/devtest-footer"}}

templates/repo/home_sidebar_top.tmpl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
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" data-global-keyboard-shortcut="s" data-global-init="initRepoCodeSearchShortcut" aria-keyshortcuts="s">
5-
<kbd class="repo-search-shortcut-hint" aria-hidden="true">S</kbd>
4+
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}" class="code-search-input" aria-keyshortcuts="s">
5+
<kbd data-global-keyboard-shortcut="s" class="repo-search-shortcut-hint" aria-hidden="true">S</kbd>
66
{{template "shared/search/button"}}
77
</div>
88
</form>

tests/e2e/repo-shortcuts.test.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ test.describe('Repository Keyboard Shortcuts', () => {
8989
await fileSearchInput.click();
9090
await page.keyboard.type('test');
9191

92-
// The hint should now be hidden (Vue component handles this with v-show)
92+
// The hint should now be hidden (generic handler hides kbd on focus)
9393
await expect(fileKbdHint).toBeHidden();
9494
});
9595

web_src/js/components/RepoFileSearch.vue

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ const allFiles = ref<string[]>([]);
2323
const selectedIndex = ref(0);
2424
const isLoadingFileList = ref(false);
2525
const hasLoadedFileList = ref(false);
26-
const isInputFocused = ref(false);
27-
2826
const showPopup = computed(() => searchQuery.value.length > 0);
2927
3028
const filteredFiles = computed(() => {
@@ -150,11 +148,10 @@ watch([searchQuery, filteredFiles], async () => {
150148
<input
151149
ref="searchInput" :placeholder="placeholder" autocomplete="off"
152150
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
153-
data-global-keyboard-shortcut="t" aria-keyshortcuts="t"
151+
aria-keyshortcuts="t"
154152
@input="handleSearchInput" @keydown="handleKeyDown"
155-
@focus="isInputFocused = true" @blur="isInputFocused = false"
156153
>
157-
<kbd v-show="!searchQuery && !isInputFocused" class="repo-file-search-shortcut-hint" aria-hidden="true">T</kbd>
154+
<kbd data-global-keyboard-shortcut="t" class="repo-file-search-shortcut-hint" aria-hidden="true">T</kbd>
158155
</div>
159156

160157
<Teleport to="body">
@@ -218,11 +215,6 @@ watch([searchQuery, filteredFiles], async () => {
218215
pointer-events: none;
219216
}
220217
221-
/* Hide kbd when input is focused so it doesn't interfere with focus border */
222-
.repo-file-search-input-wrapper input:focus + .repo-file-search-shortcut-hint {
223-
display: none;
224-
}
225-
226218
.file-search-popup {
227219
position: absolute;
228220
background: var(--color-box-body);
Lines changed: 112 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,148 @@
1-
// Copyright 2026 The Gitea Authors. All rights reserved.
2-
// SPDX-License-Identifier: MIT
1+
import {describe, test, expect, beforeEach, afterEach} from 'vitest';
2+
3+
// The keyboard shortcut mechanism is driven by global event delegation in observer.ts.
4+
// These tests set up the same event listeners to verify the behavior in isolation.
5+
6+
function setupKeyboardShortcutListeners() {
7+
document.addEventListener('keydown', (e: KeyboardEvent) => {
8+
const target = e.target as HTMLElement;
9+
10+
if (e.key === 'Escape' && target.matches('input, textarea, select')) {
11+
const kbd = target.parentElement?.querySelector<HTMLElement>('kbd[data-global-keyboard-shortcut]');
12+
if (kbd) {
13+
(target as HTMLInputElement).value = '';
14+
(target as HTMLInputElement).blur();
15+
return;
16+
}
17+
}
18+
19+
if (target.matches('input, textarea, select') || target.isContentEditable) return;
20+
if (e.ctrlKey || e.metaKey || e.altKey) return;
21+
22+
const key = e.key.toLowerCase();
23+
const escapedKey = CSS.escape(key);
24+
const kbd = document.querySelector<HTMLElement>(`kbd[data-global-keyboard-shortcut="${escapedKey}"]`);
25+
if (!kbd) return;
26+
27+
e.preventDefault();
28+
const input = kbd.parentElement?.querySelector<HTMLInputElement>('input, textarea, select');
29+
if (input) input.focus();
30+
});
331

4-
import {initRepoCodeSearchShortcut} from './repo-shortcuts.ts';
32+
document.addEventListener('focusin', (e) => {
33+
const target = e.target as HTMLElement;
34+
if (!target.matches('input, textarea, select')) return;
35+
const kbd = target.parentElement?.querySelector<HTMLElement>('kbd[data-global-keyboard-shortcut]');
36+
if (kbd) kbd.style.display = 'none';
37+
});
538

6-
describe('Repository Code Search Shortcut Hint', () => {
7-
let codeSearchInput: HTMLInputElement;
8-
let codeSearchHint: HTMLElement;
39+
document.addEventListener('focusout', (e) => {
40+
const target = e.target as HTMLElement;
41+
if (!target.matches('input, textarea, select')) return;
42+
const kbd = target.parentElement?.querySelector<HTMLElement>('kbd[data-global-keyboard-shortcut]');
43+
if (kbd) kbd.style.display = (target as HTMLInputElement).value ? 'none' : '';
44+
});
45+
}
46+
47+
describe('Keyboard Shortcut Mechanism', () => {
48+
let input: HTMLInputElement;
49+
let kbd: HTMLElement;
950

1051
beforeEach(() => {
11-
// Set up DOM structure for code search
1252
document.body.innerHTML = `
13-
<div class="repo-home-sidebar-top">
14-
<div class="repo-code-search-input-wrapper">
15-
<input name="q" class="code-search-input" placeholder="Search code" data-global-keyboard-shortcut="s" data-global-init="initRepoCodeSearchShortcut">
16-
<kbd class="repo-search-shortcut-hint">S</kbd>
17-
</div>
53+
<div>
54+
<input placeholder="Search" type="text">
55+
<kbd data-global-keyboard-shortcut="s">S</kbd>
1856
</div>
1957
`;
20-
21-
codeSearchInput = document.querySelector('.code-search-input')!;
22-
codeSearchHint = document.querySelector('.repo-code-search-input-wrapper .repo-search-shortcut-hint')!;
23-
24-
// Initialize the shortcut hint functionality directly
25-
initRepoCodeSearchShortcut(codeSearchInput);
58+
input = document.querySelector('input')!;
59+
kbd = document.querySelector('kbd')!;
2660
});
2761

2862
afterEach(() => {
2963
document.body.innerHTML = '';
3064
});
3165

32-
test('Code search hint hides when input has value', () => {
33-
// Initially visible
34-
expect(codeSearchHint.style.display).toBe('');
66+
// Register listeners once for all tests (they persist across tests on document)
67+
setupKeyboardShortcutListeners();
68+
69+
test('Shortcut key focuses the associated input', () => {
70+
expect(document.activeElement).not.toBe(input);
71+
72+
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', bubbles: true}));
73+
74+
expect(document.activeElement).toBe(input);
75+
});
76+
77+
test('Kbd hint hides when input is focused', () => {
78+
expect(kbd.style.display).toBe('');
3579

36-
// Type something in the code search
37-
codeSearchInput.value = 'test';
38-
codeSearchInput.dispatchEvent(new Event('input'));
80+
input.focus();
3981

40-
// Should be hidden
41-
expect(codeSearchHint.style.display).toBe('none');
82+
expect(kbd.style.display).toBe('none');
4283
});
4384

44-
test('Code search hint shows when input is cleared', () => {
45-
// Set a value and trigger input
46-
codeSearchInput.value = 'test';
47-
codeSearchInput.dispatchEvent(new Event('input'));
48-
expect(codeSearchHint.style.display).toBe('none');
85+
test('Kbd hint shows when input is blurred with empty value', () => {
86+
input.focus();
87+
expect(kbd.style.display).toBe('none');
4988

50-
// Clear the value
51-
codeSearchInput.value = '';
52-
codeSearchInput.dispatchEvent(new Event('input'));
89+
input.blur();
5390

54-
// Should be visible again
55-
expect(codeSearchHint.style.display).toBe('');
91+
expect(kbd.style.display).toBe('');
5692
});
5793

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'));
62-
codeSearchInput.focus();
63-
expect(document.activeElement).toBe(codeSearchInput);
64-
expect(codeSearchInput.value).toBe('test');
94+
test('Kbd hint stays hidden when input is blurred with a value', () => {
95+
input.focus();
96+
input.value = 'test';
97+
98+
input.blur();
99+
100+
expect(kbd.style.display).toBe('none');
101+
});
102+
103+
test('Escape key clears and blurs the input', () => {
104+
input.focus();
105+
input.value = 'test';
65106

66-
// Press Escape directly on the input
67107
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
68-
codeSearchInput.dispatchEvent(event);
108+
input.dispatchEvent(event);
69109

70-
// Value should be cleared and input should be blurred
71-
expect(codeSearchInput.value).toBe('');
72-
expect(document.activeElement).not.toBe(codeSearchInput);
110+
expect(input.value).toBe('');
111+
expect(document.activeElement).not.toBe(input);
73112
});
74113

75-
test('Code search kbd hint hides on focus', () => {
76-
// Initially visible
77-
expect(codeSearchHint.style.display).toBe('');
114+
test('Escape key shows kbd hint after clearing', () => {
115+
input.focus();
116+
input.value = 'test';
117+
expect(kbd.style.display).toBe('none');
118+
119+
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
120+
input.dispatchEvent(event);
78121

79-
// Focus the input
80-
codeSearchInput.focus();
81-
codeSearchInput.dispatchEvent(new Event('focus'));
122+
expect(kbd.style.display).toBe('');
123+
});
82124

83-
// Should be hidden
84-
expect(codeSearchHint.style.display).toBe('none');
125+
test('Shortcut does not trigger with modifier keys', () => {
126+
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', ctrlKey: true, bubbles: true}));
127+
expect(document.activeElement).not.toBe(input);
85128

86-
// Blur the input
87-
codeSearchInput.blur();
88-
codeSearchInput.dispatchEvent(new Event('blur'));
129+
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', metaKey: true, bubbles: true}));
130+
expect(document.activeElement).not.toBe(input);
89131

90-
// Should be visible again
91-
expect(codeSearchHint.style.display).toBe('');
132+
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', altKey: true, bubbles: true}));
133+
expect(document.activeElement).not.toBe(input);
92134
});
93135

94-
test('Change event also updates hint visibility', () => {
95-
// Initially visible
96-
expect(codeSearchHint.style.display).toBe('');
136+
test('Shortcut does not trigger when typing in another input', () => {
137+
// Add a second input without a shortcut
138+
const otherInput = document.createElement('input');
139+
document.body.append(otherInput);
140+
otherInput.focus();
97141

98-
// Set value via change event (e.g., browser autofill)
99-
codeSearchInput.value = 'autofilled';
100-
codeSearchInput.dispatchEvent(new Event('change'));
142+
const event = new KeyboardEvent('keydown', {key: 's', bubbles: true});
143+
otherInput.dispatchEvent(event);
101144

102-
// Should be hidden
103-
expect(codeSearchHint.style.display).toBe('none');
145+
expect(document.activeElement).toBe(otherInput);
146+
expect(document.activeElement).not.toBe(input);
104147
});
105148
});

web_src/js/features/repo-shortcuts.ts

Lines changed: 0 additions & 47 deletions
This file was deleted.

web_src/js/index-domready.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ import {initRepoRecentCommits} from './features/recent-commits.ts';
5555
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
5656
import {initGlobalSelectorObserver} from './modules/observer.ts';
5757
import {initRepositorySearch} from './features/repo-search.ts';
58-
import {initRepoShortcuts} from './features/repo-shortcuts.ts';
5958
import {initColorPickers} from './features/colorpicker.ts';
6059
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
6160
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
@@ -141,7 +140,6 @@ const initPerformanceTracer = callInitFunctions([
141140
initRepository,
142141
initRepositoryActionView,
143142
initRepositorySearch,
144-
initRepoShortcuts,
145143
initRepoContributors,
146144
initRepoCodeFrequency,
147145
initRepoRecentCommits,

0 commit comments

Comments
 (0)