Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions templates/devtest/keyboard-shortcut.tmpl
Original file line number Diff line number Diff line change
@@ -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"}}
6 changes: 4 additions & 2 deletions templates/repo/home_sidebar_top.tmpl
Original file line number Diff line number Diff line change
@@ -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>

Expand Down
38 changes: 38 additions & 0 deletions web_src/css/repo.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
38 changes: 35 additions & 3 deletions web_src/js/components/RepoFileSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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);
Expand Down
148 changes: 148 additions & 0 deletions web_src/js/features/repo-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
62 changes: 62 additions & 0 deletions web_src/js/modules/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 {
Expand All @@ -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++) {
Expand Down