Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,7 @@ export default defineConfig([
'vitest/no-interpolation-in-snapshots': [0],
'vitest/no-large-snapshots': [0],
'vitest/no-mocks-import': [0],
'vitest/no-importing-vitest-globals': [2],
'vitest/no-restricted-matchers': [0],
'vitest/no-restricted-vi-methods': [0],
'vitest/no-standalone-expect': [0],
Expand Down
23 changes: 23 additions & 0 deletions templates/devtest/keyboard-shortcut.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{{template "devtest/devtest-header"}}
<div class="page-content devtest ui container">
<h1>Keyboard Shortcut</h1>

<div>
<div class="ui input global-shortcut-wrapper">
<input class="ui input" placeholder="Press S to focus">
<kbd data-global-init="onGlobalShortcut" data-shortcut-keys="s">S</kbd>
</div>
</div>

<div class="tw-mt-2">
<div class="ui action input">
<div class="ui input global-shortcut-wrapper">
<input class="ui input" placeholder="Press T to focus">
<kbd data-global-init="onGlobalShortcut" data-shortcut-keys="t">T</kbd>
</div>
<button class="ui button">Go</button>
</div>
</div>
</div>

{{template "devtest/devtest-footer"}}
10 changes: 7 additions & 3 deletions templates/repo/home_sidebar_top.tmpl
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<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"}}
<form class="ignore-dirty tw-flex" action="{{.RepoLink}}/search" method="get">
<div class="ui small action input tw-flex tw-flex-1">
<div class="ui input tw-flex tw-flex-1 global-shortcut-wrapper">
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}">
<kbd data-global-init="onGlobalShortcut" data-shortcut-keys="s">S</kbd>
</div>
{{template "shared/search/button"}}
</div>
</form>

Expand Down
1 change: 1 addition & 0 deletions web_src/css/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@import "./modules/modal.css";
@import "./modules/tab.css";
@import "./modules/form.css";
@import "./modules/shortcut.css";

@import "./modules/tippy.css";
@import "./modules/breadcrumb.css";
Expand Down
3 changes: 2 additions & 1 deletion web_src/css/modules/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@
margin: 0;
}

.ui.action.input:not([class*="left action"]) > input {
.ui.action.input:not([class*="left action"]) > input,
.ui.action.input:not([class*="left action"]) > .ui.input > input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right-color: transparent;
Expand Down
20 changes: 20 additions & 0 deletions web_src/css/modules/shortcut.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.global-shortcut-wrapper {
position: relative;
}

.global-shortcut-wrapper > kbd {
position: absolute;
right: 6px;
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;
}
5 changes: 3 additions & 2 deletions web_src/js/components/RepoFileSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,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 +145,13 @@ watch([searchQuery, filteredFiles], async () => {

<template>
<div>
<div class="ui small input">
<div class="ui small input global-shortcut-wrapper">
<input
ref="searchInput" :placeholder="placeholder" autocomplete="off"
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
@input="handleSearchInput" @keydown="handleKeyDown"
>
<kbd data-global-init="onGlobalShortcut" data-shortcut-keys="t">T</kbd>
</div>

<Teleport to="body">
Expand Down
1 change: 0 additions & 1 deletion web_src/js/features/repo-settings-branches.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {beforeEach, describe, expect, test, vi} from 'vitest';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
import {POST} from '../modules/fetch.ts';
import {createSortable} from '../modules/sortable.ts';
Expand Down
2 changes: 2 additions & 0 deletions web_src/js/index-domready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton}
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initGlobalShortcut} from './modules/shortcut.ts';

const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
Expand All @@ -83,6 +84,7 @@ const initPerformanceTracer = callInitFunctions([
initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
initGlobalInput,
initGlobalShortcut,

initCommonOrganization,
initCommonIssueListQuickGoto,
Expand Down
71 changes: 71 additions & 0 deletions web_src/js/modules/shortcut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {registerGlobalInitFunc} from './observer.ts';
import {hideElem, toggleElem} from '../utils/dom.ts';

function initShortcutKbd(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 elem = elemFromKbd(kbd);
if (elem?.value) hideElem(kbd);
kbd.setAttribute('aria-hidden', 'true');
kbd.setAttribute('aria-keyshortcuts', kbd.getAttribute('data-shortcut-keys')!);
}

function elemFromKbd(kbd: HTMLElement): HTMLInputElement | HTMLTextAreaElement | null {
return kbd.parentElement!.querySelector<HTMLInputElement>('input, textarea') || null;
}

function kbdFromElem(input: HTMLElement): HTMLElement | null {
return input.parentElement!.querySelector<HTMLElement>('kbd') || null;
}

export function initGlobalShortcut() {
registerGlobalInitFunc('onGlobalShortcut', initShortcutKbd);

// 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) => {
// Modifier keys are not supported yet
if (e.ctrlKey || e.metaKey || e.altKey) return;

const target = e.target as HTMLElement;

// Handle Escape: clear and blur inputs that have an associated keyboard shortcut
if (e.key === 'Escape') {
const kbd = kbdFromElem(target);
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;
}

// Find kbd element with matching shortcut (case-insensitive), then focus its sibling input
const key = e.key.toLowerCase();
// At the moment, only a simple match. In the future, it can be extended to support modifiers and key combinations
const kbd = document.querySelector<HTMLElement>(`.global-shortcut-wrapper > kbd[data-shortcut-keys="${CSS.escape(key)}"]`);
if (!kbd) return;
e.preventDefault();
elemFromKbd(kbd)!.focus();
});

// Toggle kbd shortcut hint visibility on input focus/blur
document.addEventListener('focusin', (e) => {
const kbd = kbdFromElem(e.target as HTMLElement);
if (!kbd) return;
hideElem(kbd);
});

document.addEventListener('focusout', (e) => {
const kbd = kbdFromElem(e.target as HTMLElement);
if (!kbd) return;
const hasContent = Boolean((e.target as HTMLInputElement).value);
toggleElem(kbd, !hasContent);
});
}