diff --git a/templates/devtest/keyboard-shortcut.tmpl b/templates/devtest/keyboard-shortcut.tmpl new file mode 100644 index 0000000000..8888bcbf2f --- /dev/null +++ b/templates/devtest/keyboard-shortcut.tmpl @@ -0,0 +1,41 @@ +{{template "devtest/devtest-header"}} +
+

Keyboard Shortcut

+

+ A <kbd data-global-keyboard-shortcut="key"> element next to an <input> declares a keyboard shortcut. + Pressing the key focuses the input. Pressing Escape clears and blurs it. + The hint is hidden automatically when the input is focused or has a value. +

+ +

Input with "s" shortcut

+
+ + S +
+ +

Input with "f" shortcut

+
+ + F +
+
+ + +{{template "devtest/devtest-footer"}} diff --git a/templates/repo/home_sidebar_top.tmpl b/templates/repo/home_sidebar_top.tmpl index be25b0d9f3..0d6bd10a19 100644 --- a/templates/repo/home_sidebar_top.tmpl +++ b/templates/repo/home_sidebar_top.tmpl @@ -1,8 +1,8 @@
- - + + {{template "shared/search/button"}}
diff --git a/tests/e2e/repo-shortcuts.test.e2e.ts b/tests/e2e/repo-shortcuts.test.e2e.ts index 1d7dcecab5..504630f452 100644 --- a/tests/e2e/repo-shortcuts.test.e2e.ts +++ b/tests/e2e/repo-shortcuts.test.e2e.ts @@ -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(); }); diff --git a/web_src/js/components/RepoFileSearch.vue b/web_src/js/components/RepoFileSearch.vue index c4cb3d812f..a519acaccf 100644 --- a/web_src/js/components/RepoFileSearch.vue +++ b/web_src/js/components/RepoFileSearch.vue @@ -23,8 +23,6 @@ const allFiles = ref([]); 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 () => { - +
@@ -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); diff --git a/web_src/js/features/repo-shortcuts.test.ts b/web_src/js/features/repo-shortcuts.test.ts index 2c32dcfb66..2937762180 100644 --- a/web_src/js/features/repo-shortcuts.test.ts +++ b/web_src/js/features/repo-shortcuts.test.ts @@ -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('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(`kbd[data-global-keyboard-shortcut="${escapedKey}"]`); + if (!kbd) return; + + e.preventDefault(); + const input = kbd.parentElement?.querySelector('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('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('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 = ` -
-
- - S -
+
+ + S
`; - - 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); }); }); diff --git a/web_src/js/features/repo-shortcuts.ts b/web_src/js/features/repo-shortcuts.ts deleted file mode 100644 index 22dd7c8a6a..0000000000 --- a/web_src/js/features/repo-shortcuts.ts +++ /dev/null @@ -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('.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); -} diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts index e6357e9b9e..660e5c0989 100644 --- a/web_src/js/index-domready.ts +++ b/web_src/js/index-domready.ts @@ -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, diff --git a/web_src/js/modules/observer.ts b/web_src/js/modules/observer.ts index f1d5d65613..fd971a42d4 100644 --- a/web_src/js/modules/observer.ts +++ b/web_src/js/modules/observer.ts @@ -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('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 element next to an 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 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('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(`[data-global-keyboard-shortcut="${escapedKey}"]`); - if (!elem) return; + const kbd = document.querySelector(`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('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('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('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++) {