mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-14 13:08:11 +02:00
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.
This commit is contained in:
parent
7226ecde9a
commit
663612cdab
@ -1,7 +1,7 @@
|
|||||||
<div class="repo-home-sidebar-top">
|
<div class="repo-home-sidebar-top">
|
||||||
<form class="ignore-dirty tw-flex tw-flex-1" action="{{.RepoLink}}/search" method="get">
|
<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">
|
<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">
|
<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">
|
||||||
<kbd class="repo-search-shortcut-hint">S</kbd>
|
<kbd class="repo-search-shortcut-hint">S</kbd>
|
||||||
{{template "shared/search/button"}}
|
{{template "shared/search/button"}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -150,6 +150,7 @@ watch([searchQuery, filteredFiles], async () => {
|
|||||||
<input
|
<input
|
||||||
ref="searchInput" :placeholder="placeholder" autocomplete="off"
|
ref="searchInput" :placeholder="placeholder" autocomplete="off"
|
||||||
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
|
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
|
||||||
|
data-global-keyboard-shortcut="t"
|
||||||
@input="handleSearchInput" @keydown="handleKeyDown"
|
@input="handleSearchInput" @keydown="handleKeyDown"
|
||||||
@focus="isInputFocused = true" @blur="isInputFocused = false"
|
@focus="isInputFocused = true" @blur="isInputFocused = false"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,97 +1,34 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import {initRepoShortcuts} from './repo-shortcuts.ts';
|
import {initRepoCodeSearchShortcut} from './repo-shortcuts.ts';
|
||||||
|
|
||||||
describe('Repository Keyboard Shortcuts', () => {
|
describe('Repository Code Search Shortcut Hint', () => {
|
||||||
let fileSearchInput: HTMLInputElement;
|
|
||||||
let codeSearchInput: HTMLInputElement;
|
let codeSearchInput: HTMLInputElement;
|
||||||
let codeSearchHint: HTMLElement;
|
let codeSearchHint: HTMLElement;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Set up DOM structure
|
// Set up DOM structure for code search
|
||||||
document.body.innerHTML = `
|
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-home-sidebar-top">
|
||||||
<div class="repo-code-search-input-wrapper">
|
<div class="repo-code-search-input-wrapper">
|
||||||
<input name="q" class="code-search-input" placeholder="Search code">
|
<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>
|
<kbd class="repo-search-shortcut-hint">S</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
fileSearchInput = document.querySelector('.repo-file-search-container input')!;
|
|
||||||
codeSearchInput = document.querySelector('.code-search-input')!;
|
codeSearchInput = document.querySelector('.code-search-input')!;
|
||||||
codeSearchHint = document.querySelector('.repo-code-search-input-wrapper .repo-search-shortcut-hint')!;
|
codeSearchHint = document.querySelector('.repo-code-search-input-wrapper .repo-search-shortcut-hint')!;
|
||||||
|
|
||||||
initRepoShortcuts();
|
// Initialize the shortcut hint functionality directly
|
||||||
|
initRepoCodeSearchShortcut(codeSearchInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
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', () => {
|
test('Code search hint hides when input has value', () => {
|
||||||
// Initially visible
|
// Initially visible
|
||||||
expect(codeSearchHint.style.display).toBe('');
|
expect(codeSearchHint.style.display).toBe('');
|
||||||
@ -118,16 +55,20 @@ describe('Repository Keyboard Shortcuts', () => {
|
|||||||
expect(codeSearchHint.style.display).toBe('');
|
expect(codeSearchHint.style.display).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Escape key blurs code search input', () => {
|
test('Escape key clears and blurs code search input', () => {
|
||||||
// Focus the code search input first
|
// Set a value and focus the input
|
||||||
|
codeSearchInput.value = 'test';
|
||||||
|
codeSearchInput.dispatchEvent(new Event('input'));
|
||||||
codeSearchInput.focus();
|
codeSearchInput.focus();
|
||||||
expect(document.activeElement).toBe(codeSearchInput);
|
expect(document.activeElement).toBe(codeSearchInput);
|
||||||
|
expect(codeSearchInput.value).toBe('test');
|
||||||
|
|
||||||
// Press Escape directly on the input (the input has its own keydown handler)
|
// Press Escape directly on the input
|
||||||
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
|
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
|
||||||
codeSearchInput.dispatchEvent(event);
|
codeSearchInput.dispatchEvent(event);
|
||||||
|
|
||||||
// Should no longer be focused
|
// Value should be cleared and input should be blurred
|
||||||
|
expect(codeSearchInput.value).toBe('');
|
||||||
expect(document.activeElement).not.toBe(codeSearchInput);
|
expect(document.activeElement).not.toBe(codeSearchInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -149,4 +90,16 @@ describe('Repository Keyboard Shortcuts', () => {
|
|||||||
// Should be visible again
|
// Should be visible again
|
||||||
expect(codeSearchHint.style.display).toBe('');
|
expect(codeSearchHint.style.display).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Change event also updates hint visibility', () => {
|
||||||
|
// Initially visible
|
||||||
|
expect(codeSearchHint.style.display).toBe('');
|
||||||
|
|
||||||
|
// Set value via change event (e.g., browser autofill)
|
||||||
|
codeSearchInput.value = 'autofilled';
|
||||||
|
codeSearchInput.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
// Should be hidden
|
||||||
|
expect(codeSearchHint.style.display).toBe('none');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,76 +1,47 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize global keyboard shortcuts for repository pages.
|
* Initialize the code search input with shortcut hint visibility management.
|
||||||
* - 'T' key: Focus the "Go to file" search input
|
* The shortcut hint is hidden when the input has a value or is focused.
|
||||||
* - 'S' key: Focus the "Search code" input
|
* Pressing Escape clears the input and blurs it.
|
||||||
*
|
|
||||||
* Shortcuts are disabled when the user is typing in an input field,
|
|
||||||
* textarea, or contenteditable element.
|
|
||||||
*/
|
*/
|
||||||
export function initRepoShortcuts(): void {
|
export function initRepoCodeSearchShortcut(el: HTMLInputElement): void {
|
||||||
// Initialize keyboard shortcut listeners
|
const shortcutHint = el.parentElement?.querySelector<HTMLElement>('.repo-search-shortcut-hint');
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
if (!shortcutHint) return;
|
||||||
// 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
|
let isFocused = false;
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 't' || e.key === 'T') {
|
const updateHintVisibility = () => {
|
||||||
const fileSearchInput = document.querySelector<HTMLInputElement>('.repo-file-search-container input');
|
shortcutHint.style.display = (el.value || isFocused) ? 'none' : '';
|
||||||
if (fileSearchInput) {
|
};
|
||||||
e.preventDefault();
|
|
||||||
fileSearchInput.focus();
|
// Check initial value (e.g., from browser autofill or back navigation)
|
||||||
}
|
updateHintVisibility();
|
||||||
} else if (e.key === 's' || e.key === 'S') {
|
|
||||||
const codeSearchInput = document.querySelector<HTMLInputElement>('.repo-home-sidebar-top input[name="q"], .code-search-input');
|
el.addEventListener('input', updateHintVisibility);
|
||||||
if (codeSearchInput) {
|
el.addEventListener('change', updateHintVisibility);
|
||||||
e.preventDefault();
|
el.addEventListener('focus', () => {
|
||||||
codeSearchInput.focus();
|
isFocused = true;
|
||||||
}
|
updateHintVisibility();
|
||||||
}
|
});
|
||||||
|
el.addEventListener('blur', () => {
|
||||||
|
isFocused = false;
|
||||||
|
updateHintVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle shortcut hint visibility for code search input based on input value and focus state
|
// Handle Escape key to clear and blur the code search input
|
||||||
const codeSearchInput = document.querySelector<HTMLInputElement>('.code-search-input');
|
el.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
if (codeSearchInput) {
|
if (e.key === 'Escape') {
|
||||||
const shortcutHint = codeSearchInput.parentElement?.querySelector<HTMLElement>('.repo-search-shortcut-hint');
|
el.value = '';
|
||||||
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();
|
updateHintVisibility();
|
||||||
|
el.blur();
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initRepoShortcuts(): void {
|
||||||
|
registerGlobalInitFunc('initRepoCodeSearchShortcut', initRepoCodeSearchShortcut);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,34 @@ function attachGlobalEvents() {
|
|||||||
if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
|
if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
|
||||||
func(elem, e);
|
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).
|
||||||
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
// Don't trigger shortcuts when typing in input fields
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find element with matching shortcut (case-insensitive)
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
const elem = document.querySelector<HTMLElement>(`[data-global-keyboard-shortcut="${key}"]`);
|
||||||
|
if (!elem) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
if (elem.matches('input, textarea, select')) {
|
||||||
|
elem.focus();
|
||||||
|
} else {
|
||||||
|
elem.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | null): void {
|
export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | null): void {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user