0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-21 11:28:12 +01:00

Merge 0cabec3cfa409ea67ff73063d11d87ff9dee61fb into 87f729190918e957b1d80c5e94c4e3ff440a387c

This commit is contained in:
Micah Kepe 2026-02-20 23:41:50 +08:00 committed by GitHub
commit 03424ea997
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 465 additions and 5 deletions

View File

@ -0,0 +1,41 @@
{{template "devtest/devtest-header"}}
<div class="page-content devtest ui container">
<h1>Keyboard Shortcut</h1>
<p>
A <code>&lt;kbd data-global-keyboard-shortcut="key"&gt;</code> element next to an <code>&lt;input&gt;</code> declares a keyboard shortcut.
Pressing the key focuses the input. Pressing <kbd>Escape</kbd> clears and blurs it.
The hint is hidden automatically when the input is focused or has a value.
</p>
<h2>Input with "s" shortcut</h2>
<div style="position: relative; display: inline-flex; align-items: center;">
<input class="ui input" placeholder="Press S to focus" style="padding-right: 36px;">
<kbd data-global-keyboard-shortcut="s" class="devtest-shortcut-hint">S</kbd>
</div>
<h2>Input with "f" shortcut</h2>
<div style="position: relative; display: inline-flex; align-items: center;">
<input class="ui input" placeholder="Press F to focus" style="padding-right: 36px;">
<kbd data-global-keyboard-shortcut="f" class="devtest-shortcut-hint">F</kbd>
</div>
</div>
<style>
.devtest-shortcut-hint {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: inline-block;
padding: 2px 6px;
font-size: 11px;
line-height: 14px;
color: var(--color-text-light-2);
background-color: var(--color-box-body);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
box-shadow: inset 0 -1px 0 var(--color-secondary);
pointer-events: none;
}
</style>
{{template "devtest/devtest-footer"}}

View File

@ -1,7 +1,9 @@
<div class="repo-home-sidebar-top">
<form class="ignore-dirty tw-flex tw-flex-1" action="{{.RepoLink}}/search" method="get">
<div class="ui small action input tw-flex-1">
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}"> {{template "shared/search/button"}}
<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" aria-keyshortcuts="s">
<kbd data-global-keyboard-shortcut="s" class="repo-search-shortcut-hint" aria-hidden="true">S</kbd>
{{template "shared/search/button"}}
</div>
</form>

View File

@ -0,0 +1,137 @@
import {test, expect} from '@playwright/test';
import {login_user, load_logged_in_context} from './utils_e2e.ts';
test.beforeAll(async ({browser}, workerInfo) => {
await login_user(browser, workerInfo, 'user2');
});
test.describe('Repository Keyboard Shortcuts', () => {
test('T key focuses file search input', async ({browser}, workerInfo) => {
const context = await load_logged_in_context(browser, workerInfo, 'user2');
const page = await context.newPage();
// Navigate to a repository page with file listing
await page.goto('/user2/repo1');
// Verify the file search input exists and has the keyboard hint
const fileSearchInput = page.getByPlaceholder('Go to file');
await expect(fileSearchInput).toBeVisible();
// Verify the keyboard hint is visible
const kbdHint = page.locator('.repo-file-search-input-wrapper kbd');
await expect(kbdHint).toBeVisible();
await expect(kbdHint).toHaveText('T');
// Press T key to focus the file search input
await page.keyboard.press('t');
// Verify the input is focused
await expect(fileSearchInput).toBeFocused();
});
test('T key does not trigger when typing in input', async ({browser}, workerInfo) => {
const context = await load_logged_in_context(browser, workerInfo, 'user2');
const page = await context.newPage();
// Navigate to a repository page
await page.goto('/user2/repo1');
// Focus on file search first
const fileSearchInput = page.getByPlaceholder('Go to file');
await fileSearchInput.click();
// Type something including 't'
await page.keyboard.type('test');
// Verify the input still has focus and contains the typed text
await expect(fileSearchInput).toBeFocused();
await expect(fileSearchInput).toHaveValue('test');
});
test('S key focuses code search input on repo home', async ({browser}, workerInfo) => {
const context = await load_logged_in_context(browser, workerInfo, 'user2');
const page = await context.newPage();
// Navigate to repo home page where code search is available
await page.goto('/user2/repo1');
// The code search input is in the sidebar
const codeSearchInput = page.getByPlaceholder('Search code…');
await expect(codeSearchInput).toBeVisible();
// Verify the keyboard hint is visible
const kbdHint = page.locator('.repo-code-search-input-wrapper .repo-search-shortcut-hint');
await expect(kbdHint).toBeVisible();
await expect(kbdHint).toHaveText('S');
// Press S key to focus the code search input
await page.keyboard.press('s');
// Verify the input is focused
await expect(codeSearchInput).toBeFocused();
});
test('File search keyboard hint hides when input has value', async ({browser}, workerInfo) => {
const context = await load_logged_in_context(browser, workerInfo, 'user2');
const page = await context.newPage();
// Navigate to a repository page
await page.goto('/user2/repo1');
// Check file search kbd hint
const fileSearchInput = page.getByPlaceholder('Go to file');
const fileKbdHint = page.locator('.repo-file-search-input-wrapper kbd');
// Initially the hint should be visible
await expect(fileKbdHint).toBeVisible();
// Focus and type in the file search
await fileSearchInput.click();
await page.keyboard.type('test');
// The hint should now be hidden (generic handler hides kbd on focus)
await expect(fileKbdHint).toBeHidden();
});
test('Code search keyboard hint hides when input has value', async ({browser}, workerInfo) => {
const context = await load_logged_in_context(browser, workerInfo, 'user2');
const page = await context.newPage();
// Navigate to a repository page
await page.goto('/user2/repo1');
const codeSearchInput = page.getByPlaceholder('Search code…');
await expect(codeSearchInput).toBeVisible();
const codeKbdHint = page.locator('.repo-code-search-input-wrapper .repo-search-shortcut-hint');
// Initially the hint should be visible
await expect(codeKbdHint).toBeVisible();
// Focus and type in the code search
await codeSearchInput.click();
await page.keyboard.type('search');
// The hint should now be hidden
await expect(codeKbdHint).toBeHidden();
});
test('Shortcuts do not trigger with modifier keys', async ({browser}, workerInfo) => {
const context = await load_logged_in_context(browser, workerInfo, 'user2');
const page = await context.newPage();
// Navigate to a repository page
await page.goto('/user2/repo1');
const fileSearchInput = page.getByPlaceholder('Go to file');
// Click somewhere else first to ensure nothing is focused
await page.locator('body').click();
// Press Ctrl+T (should not focus file search - this is typically "new tab" in browsers)
await page.keyboard.press('Control+t');
// The file search input should NOT be focused
await expect(fileSearchInput).not.toBeFocused();
});
});

View File

@ -2064,3 +2064,41 @@ tbody.commit-list {
.branch-selector-dropdown .scrolling.menu .loading-indicator {
height: 4em;
}
/* Keyboard shortcut hint styles for repo search inputs */
.repo-code-search-input-wrapper {
position: relative;
}
.repo-code-search-input-wrapper input {
padding-right: 32px !important;
}
.repo-search-shortcut-hint {
position: absolute;
right: 40px; /* account for the search button */
top: 50%;
transform: translateY(-50%);
display: inline-block;
padding: 2px 6px;
font-size: 11px;
line-height: 14px;
color: var(--color-text-light-2);
background-color: var(--color-box-body);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
box-shadow: inset 0 -1px 0 var(--color-secondary);
pointer-events: none;
}
/* Override Fomantic UI action input styles for file search - need high specificity */
.repo-file-search-input-wrapper.ui.input input,
.repo-file-search-input-wrapper.ui.input input:hover {
border-right: 1px solid var(--color-input-border) !important;
border-top-right-radius: var(--border-radius) !important;
border-bottom-right-radius: var(--border-radius) !important;
}
.repo-file-search-input-wrapper.ui.input input:focus {
border-color: var(--color-primary) !important;
}

View File

@ -23,7 +23,6 @@ const allFiles = ref<string[]>([]);
const selectedIndex = ref(0);
const isLoadingFileList = ref(false);
const hasLoadedFileList = ref(false);
const showPopup = computed(() => searchQuery.value.length > 0);
const filteredFiles = computed(() => {
@ -45,8 +44,8 @@ const handleKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return;
if (e.key === 'Escape') {
e.preventDefault();
clearSearch();
nextTick(() => refElemInput.value.blur());
return;
}
if (!searchQuery.value || filteredFiles.value.length === 0) return;
@ -145,12 +144,14 @@ watch([searchQuery, filteredFiles], async () => {
<template>
<div>
<div class="ui small input">
<div class="ui small input repo-file-search-input-wrapper">
<input
ref="searchInput" :placeholder="placeholder" autocomplete="off"
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
aria-keyshortcuts="t"
@input="handleSearchInput" @keydown="handleKeyDown"
>
<kbd data-global-keyboard-shortcut="t" class="repo-file-search-shortcut-hint" aria-hidden="true">T</kbd>
</div>
<Teleport to="body">
@ -183,6 +184,37 @@ watch([searchQuery, filteredFiles], async () => {
</template>
<style scoped>
.repo-file-search-input-wrapper {
position: relative;
}
.repo-file-search-input-wrapper input {
padding-right: 32px !important;
border-right: 1px solid var(--color-input-border) !important;
border-top-right-radius: var(--border-radius) !important;
border-bottom-right-radius: var(--border-radius) !important;
}
.repo-file-search-input-wrapper input:focus {
border-color: var(--color-primary) !important;
}
.repo-file-search-shortcut-hint {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: inline-block;
padding: 2px 5px;
font-size: 11px;
line-height: 12px;
color: var(--color-text-light-2);
background-color: var(--color-box-body);
border: 1px solid var(--color-secondary);
border-radius: 3px;
pointer-events: none;
}
.file-search-popup {
position: absolute;
background: var(--color-box-body);

View File

@ -0,0 +1,148 @@
import {describe, test, expect, beforeEach, afterEach} from 'vitest';
// 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.
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<HTMLElement>('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<HTMLElement>(`kbd[data-global-keyboard-shortcut="${escapedKey}"]`);
if (!kbd) return;
e.preventDefault();
const input = kbd.parentElement?.querySelector<HTMLInputElement>('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<HTMLElement>('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<HTMLElement>('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(() => {
document.body.innerHTML = `
<div>
<input placeholder="Search" type="text">
<kbd data-global-keyboard-shortcut="s">S</kbd>
</div>
`;
input = document.querySelector('input')!;
kbd = document.querySelector('kbd')!;
});
afterEach(() => {
document.body.innerHTML = '';
});
// Register listeners once for all tests (they persist across tests on document)
setupKeyboardShortcutListeners();
test('Shortcut key focuses the associated input', () => {
expect(document.activeElement).not.toBe(input);
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', bubbles: true}));
expect(document.activeElement).toBe(input);
});
test('Kbd hint hides when input is focused', () => {
expect(kbd.style.display).toBe('');
input.focus();
expect(kbd.style.display).toBe('none');
});
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';
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
input.dispatchEvent(event);
expect(input.value).toBe('');
expect(document.activeElement).not.toBe(input);
});
test('Escape key shows kbd hint after clearing', () => {
input.focus();
input.value = 'test';
expect(kbd.style.display).toBe('none');
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
input.dispatchEvent(event);
expect(kbd.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);
document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 's', metaKey: true, bubbles: true}));
expect(document.activeElement).not.toBe(input);
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);
});
});

View File

@ -55,6 +55,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<HTMLInputElement>('input, textarea, select');
if (input?.value) kbd.style.display = 'none';
}
function attachGlobalEvents() {
// add global "[data-global-click]" event handler
document.addEventListener('click', (e) => {
@ -65,6 +72,60 @@ function attachGlobalEvents() {
if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
func(elem, e);
});
// add global "kbd[data-global-keyboard-shortcut]" event handlers
// A <kbd> element next to an <input> 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 <kbd> element is shown/hidden automatically based on input focus and value.
document.addEventListener('keydown', (e: KeyboardEvent) => {
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<HTMLElement>('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;
}
// Don't trigger shortcuts when modifier keys are pressed
if (e.ctrlKey || e.metaKey || e.altKey) {
return;
}
// Find kbd element with matching shortcut (case-insensitive), then focus its sibling input
const key = e.key.toLowerCase();
const escapedKey = CSS.escape(key);
const kbd = document.querySelector<HTMLElement>(`kbd[data-global-keyboard-shortcut="${escapedKey}"]`);
if (!kbd) return;
e.preventDefault();
const input = kbd.parentElement?.querySelector<HTMLInputElement>('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<HTMLElement>('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<HTMLElement>('kbd[data-global-keyboard-shortcut]');
if (kbd) kbd.style.display = (target as HTMLInputElement).value ? 'none' : '';
});
}
export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | null): void {
@ -74,6 +135,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++) {