diff --git a/templates/base/head_navbar_icons.tmpl b/templates/base/head_navbar_icons.tmpl index a2f610bf432..72192a38a9b 100644 --- a/templates/base/head_navbar_icons.tmpl +++ b/templates/base/head_navbar_icons.tmpl @@ -37,7 +37,7 @@ {{end}} {{$notificationUnreadCount := call $data.GetNotificationUnreadCount -}} - +
{{svg "octicon-bell"}} {{$notificationUnreadCount}} diff --git a/templates/devtest/fomantic-modal.tmpl b/templates/devtest/fomantic-modal.tmpl index 19e4caafbcb..3683823c77b 100644 --- a/templates/devtest/fomantic-modal.tmpl +++ b/templates/devtest/fomantic-modal.tmpl @@ -64,8 +64,7 @@
diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index e70ac25a469..3cbd020ce75 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -97,13 +97,14 @@ {{if not (or .Repository.IsBeingCreated .Repository.IsBroken)}}
{{if .Permission.CanRead ctx.Consts.RepoUnitTypeCode}} - - {{svg "octicon-code"}} {{ctx.Locale.Tr "repo.code"}} - + {{$codeLinkUseCurrentRef := and (ne .BranchName .Repository.DefaultBranch) (not $.PageIsWiki)}} + + {{svg "octicon-code"}} {{ctx.Locale.Tr "repo.code"}} + {{end}} {{if .Permission.CanRead ctx.Consts.RepoUnitTypeIssues}} - + {{svg "octicon-issue-opened"}} {{ctx.Locale.Tr "repo.issues"}} {{if .Repository.NumOpenIssues}} {{CountFmt .Repository.NumOpenIssues}} @@ -111,14 +112,15 @@ {{end}} - {{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalTracker}} - + {{if and (.Permission.CanRead ctx.Consts.RepoUnitTypeExternalTracker) .RepoExternalIssuesLink}} + {{/* Internal and external issue trackers can be enabled at the same time, only show the external link if it exists */}} + {{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.issues"}} {{end}} {{if and .Repository.CanEnablePulls (.Permission.CanRead ctx.Consts.RepoUnitTypePullRequests)}} - + {{svg "octicon-git-pull-request"}} {{ctx.Locale.Tr "repo.pulls"}} {{if .Repository.NumOpenPulls}} {{CountFmt .Repository.NumOpenPulls}} @@ -127,7 +129,7 @@ {{end}} {{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo)}} - + {{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}} {{if .Repository.NumOpenActionRuns}} {{CountFmt .Repository.NumOpenActionRuns}} @@ -136,14 +138,14 @@ {{end}} {{if .Permission.CanRead ctx.Consts.RepoUnitTypePackages}} - + {{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}} {{end}} {{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}} {{if and (not ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}} - + {{svg "octicon-project"}} {{ctx.Locale.Tr "repo.projects"}} {{if .Repository.NumOpenProjects}} {{CountFmt .Repository.NumOpenProjects}} @@ -152,7 +154,7 @@ {{end}} {{if and (.Permission.CanRead ctx.Consts.RepoUnitTypeReleases) (not .IsEmptyRepo)}} - + {{svg "octicon-tag"}} {{ctx.Locale.Tr "repo.releases"}} {{if .NumReleases}} {{CountFmt .NumReleases}} @@ -161,19 +163,19 @@ {{end}} {{if .Permission.CanRead ctx.Consts.RepoUnitTypeWiki}} - + {{svg "octicon-book"}} {{ctx.Locale.Tr "repo.wiki"}} {{end}} {{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalWiki}} - + {{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.wiki"}} {{end}} {{if and (.Permission.CanReadAny ctx.Consts.RepoUnitTypePullRequests ctx.Consts.RepoUnitTypeIssues ctx.Consts.RepoUnitTypeReleases ctx.Consts.RepoUnitTypeCode) (not .IsEmptyRepo)}} - + {{svg "octicon-pulse"}} {{ctx.Locale.Tr "repo.activity"}} {{end}} diff --git a/web_src/css/modules/modal.css b/web_src/css/modules/modal.css index b27ef186361..96e7097c650 100644 --- a/web_src/css/modules/modal.css +++ b/web_src/css/modules/modal.css @@ -25,26 +25,30 @@ border-bottom-right-radius: var(--border-radius); } -.ui.modal > .close { +/* TODO: this "close" button was inherited and modified from Fomantic UI, actually it is not good enough: +* * the layout depends on absolute position, which is not good for responsive design +* * it is not good for accessibility, ideally it should use a "button" element */ +.ui.modal .close-modal { cursor: pointer; position: absolute; - top: -2.5rem; - right: -2.5rem; + top: 50%; + transform: translateY(-50%); + right: 1rem; z-index: 1; opacity: 0.8; font-size: 1.25em; - color: var(--color-white); - width: 2.25rem; - height: 2.25rem; - padding: 0.625rem 0 0; + color: var(--color-text); + width: 1.5rem; + height: 1.5rem; } -.ui.modal > .close:hover { +.ui.modal .close-modal:hover { opacity: 1; } .ui.modal > .header { display: block; + position: relative; font-family: var(--fonts-regular); background: var(--color-body); margin: 0; @@ -62,11 +66,6 @@ font-weight: var(--font-weight-medium); } -.ui.modal > .header .svg { - vertical-align: middle; - display: inline-block; -} - .ui.modal > .content, .ui.modal form > .content { display: block; @@ -126,11 +125,6 @@ .ui.modal > .header { padding-right: 2.25rem; } - .ui.modal > .close { - top: 1.0535rem; - right: 1rem; - color: var(--color-text); - } } @media only screen and (max-width: 767.98px) { @@ -142,10 +136,6 @@ display: block; padding: 1rem !important; } - .ui.modal > .close { - top: 0.5rem !important; - right: 0.5rem !important; - } .ui.modal > .actions { padding: 1rem 1rem 0 !important; } @@ -158,21 +148,6 @@ display: block; } -.ui.modal > .close.inside + .header { - padding-right: 2.25rem; -} - -.ui.modal > .close.inside { - top: 1.0535rem; - right: 1rem; - color: inherit; -} - -.ui.modal > .close.icon[height="16"] { - top: 0.7em; - color: var(--color-text-dark); -} - .ui.mini.modal > .header:not(.ui) { font-size: 1.3em; } diff --git a/web_src/fomantic/build/components/modal.js b/web_src/fomantic/build/components/modal.js index 0434af3deb4..21b7a0acb39 100644 --- a/web_src/fomantic/build/components/modal.js +++ b/web_src/fomantic/build/components/modal.js @@ -1180,7 +1180,7 @@ $.fn.modal.settings = { onDeny : function(){ return true; }, selector : { - close : '> .close', + close : '.close-modal', approve : '.actions .positive, .actions .approve, .actions .ok', deny : '.actions .negative, .actions .deny, .actions .cancel', modal : '.ui.modal', diff --git a/web_src/js/features/repo-issue-content.ts b/web_src/js/features/repo-issue-content.ts index 5194f8802f4..3c28f33c4ac 100644 --- a/web_src/js/features/repo-issue-content.ts +++ b/web_src/js/features/repo-issue-content.ts @@ -16,7 +16,6 @@ let i18nTextDeleteFromHistoryConfirm: string; function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) { const elDetailDialog = createElementFromHTML(html`
diff --git a/web_src/js/features/repo-issue-edit.test.ts b/web_src/js/features/repo-issue-edit.test.ts new file mode 100644 index 00000000000..76a24a64760 --- /dev/null +++ b/web_src/js/features/repo-issue-edit.test.ts @@ -0,0 +1,43 @@ +import {tryShortcutQuoteReply} from './repo-issue-edit.ts'; + +test('tryShortcutQuoteReply', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+ +
Comment text
+
Raw text
+
+ `; + document.body.append(container); + const selection = window.getSelection()!; + onTestFinished(() => { + selection.removeAllRanges(); + container.remove(); + }); + const button = container.querySelector('button')!; + const onClick = vi.fn(); + button.addEventListener('click', onClick); + + selection.removeAllRanges(); + expect(tryShortcutQuoteReply()).toBe(false); + + selection.selectAllChildren(container.querySelector('.raw-content')!); + expect(tryShortcutQuoteReply()).toBe(false); + + const range = document.createRange(); + range.selectNodeContents(container.querySelector('.render-content')!.firstChild!); + selection.removeAllRanges(); + selection.addRange(range); + selection.collapseToStart(); + expect(tryShortcutQuoteReply()).toBe(false); + expect(onClick).not.toHaveBeenCalled(); + + selection.removeAllRanges(); + selection.addRange(range); + expect(tryShortcutQuoteReply()).toBe(true); + expect(onClick).toHaveBeenCalledTimes(1); + + button.remove(); + expect(tryShortcutQuoteReply()).toBe(false); +}); diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts index 1621d5919df..3529f0f9d95 100644 --- a/web_src/js/features/repo-issue-edit.ts +++ b/web_src/js/features/repo-issue-edit.ts @@ -7,6 +7,7 @@ import {errorMessage} from '../modules/errors.ts'; import {triggerUploadStateChanged} from './comp/EditorUpload.ts'; import {convertHtmlToMarkdown} from '../markup/html2markdown.ts'; import {applyAreYouSure} from '../modules/are-you-sure.ts'; +import {registerShortcutHandler} from '../modules/shortcut.ts'; async function tryOnEditContent(e: Event) { const clickTarget = (e.target as HTMLElement).closest('.edit-content'); @@ -150,9 +151,32 @@ async function tryOnQuoteReply(e: Event) { editor.moveCursorToEnd(); } +export function tryShortcutQuoteReply(): boolean { + const selection = window.getSelection(); + if (!selection?.rangeCount || selection.isCollapsed) return false; + const node = selection.getRangeAt(0).commonAncestorContainer; + const el = node instanceof HTMLElement ? node : node.parentElement; + + const commentContainer = el?.closest('.render-content.markup')?.closest('.comment-container'); + if (!commentContainer) return false; + + const quoteReplyBtn = commentContainer.querySelector('.quote-reply')!; + if (!quoteReplyBtn) return false; + + quoteReplyBtn.click(); + return true; +} + export function initRepoIssueCommentEdit() { document.addEventListener('click', (e) => { tryOnEditContent(e); // Edit issue or comment content tryOnQuoteReply(e); // Quote reply to the comment editor }); + // TODO: it is not list in the shortcut help modal at the moment, there are many details: + // 1. not every page supports such "r" + // 2. "r" only works when there is a ".quote-reply" menu item, but it is not for a single one + // To make the framework fully support such mechanism correctly, we might also need to consider about dynamically added elements. + // The shortcut "r" was chosen by https://github.com/go-gitea/gitea/issues/5796#issuecomment-5528657082 + // and GitLab: https://gitlab.com/gitlab-org/gitlab-foss/-/commit/ca5d0c82509cedb94f9bfa4a40e77706a58faafe + registerShortcutHandler('r', tryShortcutQuoteReply); } diff --git a/web_src/js/modules/shortcut.test.ts b/web_src/js/modules/shortcut.test.ts new file mode 100644 index 00000000000..d2258334725 --- /dev/null +++ b/web_src/js/modules/shortcut.test.ts @@ -0,0 +1,135 @@ +import '../../fomantic/build/fomantic.js'; +import {userEvent} from 'vitest/browser'; +import {initGlobalShortcut, makeFullKeyFromEvent} from './shortcut.ts'; +import {initGlobalSelectorObserver} from './observer.ts'; +import {initGiteaFomantic} from './fomantic.ts'; + +function press(key: string, options: KeyboardEventInit = {}, target: HTMLElement = document.body) { + const event = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true, ...options}); + target.dispatchEvent(event); + return event; +} + +beforeAll(() => { + document.body.innerHTML = ` + +
+ + S +
+ + +
Editable
+ `; + initGiteaFomantic(); + initGlobalShortcut(); + initGlobalSelectorObserver(null); +}); + +beforeEach(() => press('Escape')); +afterEach(() => vi.restoreAllMocks()); +afterAll(() => document.body.replaceChildren()); + +test('clicks matching sequences and clears completed or expired presses', () => { + const click = vi.spyOn(document.querySelector('button')!, 'click'); + const now = vi.spyOn(Date, 'now').mockReturnValue(1000); + press('x'); + press('g'); + expect(press('i').defaultPrevented).toBe(false); + press('Escape'); + press('G'); + expect(click).not.toHaveBeenCalled(); + expect(press('I').defaultPrevented).toBe(true); + expect(click).toHaveBeenCalledTimes(1); + press('i'); + press('g'); + now.mockReturnValue(2001); + expect(press('i').defaultPrevented).toBe(false); + expect(click).toHaveBeenCalledTimes(1); + press('g'); + press('x'); + press('i'); + expect(click).toHaveBeenCalledTimes(1); +}); + +test('ignores editing, modifiers, composition and repeated keydown events', () => { + const click = vi.spyOn(document.querySelector('button')!, 'click'); + for (const target of document.querySelectorAll('input, textarea, select, [contenteditable] span')) { + press('g'); + press('x', {}, target); + press('i'); + } + for (const options of [{ctrlKey: true}, {metaKey: true}, {altKey: true}, {isComposing: true}]) { + press('g'); + press('i', options); + press('i'); + } + press('g'); + press('Escape'); + press('i'); + press('Escape'); + press('g'); + press('i', {repeat: true}); + expect(click).not.toHaveBeenCalled(); + press('i'); + expect(click).toHaveBeenCalledTimes(1); +}); + +test('keeps input focus, hint visibility and Escape behavior', () => { + const input = document.querySelector('input')!; + const kbd = document.querySelector('kbd')!; + expect(press('S').defaultPrevented).toBe(true); + expect(document.activeElement).toBe(input); + expect(kbd.classList.contains('tw-hidden')).toBe(true); + input.value = 'search'; + press('Escape', {}, input); + expect(input.value).toBe(''); + expect(document.activeElement).not.toBe(input); + expect(kbd.classList.contains('tw-hidden')).toBe(false); +}); + +test('normalizes keys and sorts modifiers alphabetically', () => { + for (const [options, expected] of [ + [{key: 'V'}, 'V'], + [{key: 'v'}, 'V'], + [{key: 'V', ctrlKey: true, shiftKey: true}, 'Control+Shift+V'], + [{key: 'a', ctrlKey: true, altKey: true, metaKey: true, shiftKey: true}, 'Alt+Control+Meta+Shift+A'], + [{key: ' ', shiftKey: true}, 'Shift+Space'], + [{key: '+', altKey: true}, 'Alt+Plus'], + [{key: 'ArrowUp', metaKey: true}, 'Meta+ArrowUp'], + [{key: 'PageDown'}, 'PageDown'], + [{key: 'F12'}, 'F12'], + [{key: 'Escape'}, 'Escape'], + ...['Control', 'Alt', 'Meta', 'Shift', ''].map((key) => [{key}, '']), + ] as [KeyboardEventInit, string][]) { + expect(makeFullKeyFromEvent(new KeyboardEvent('keydown', options))).toBe(expected); + } +}); + +test('shows current shortcuts with ? and closes with Escape or the close button', async () => { + const input = document.querySelector('input')!; + const click = vi.spyOn(document.querySelector('button')!, 'click'); + press('?', {shiftKey: true}, input); + expect(document.querySelector('#global-shortcut-help')).toBeNull(); + press('?', {ctrlKey: true}); + expect(document.querySelector('#global-shortcut-help')).toBeNull(); + press('g'); + expect(press('?', {shiftKey: true}).defaultPrevented).toBe(true); + const modal = document.querySelector('#global-shortcut-help')!; + expect(modal.classList.contains('active')).toBe(true); + expect(modal.querySelector('.shortcut-list tr:nth-child(2) td:last-child')!.textContent).toBe('Issues'); + expect(modal.querySelector('.shortcut-list')!.textContent).toContain('Search code'); + expect(modal.querySelectorAll('.shortcut-list tr')).toHaveLength(3); + press('g'); + press('i'); + expect(click).not.toHaveBeenCalled(); + await userEvent.keyboard('{Escape}'); + expect(modal.classList.contains('active')).toBe(false); + + input.setAttribute('placeholder', 'Updated search'); + press('?'); + expect(document.querySelectorAll('.ui.modal')).toHaveLength(1); + expect(modal.querySelector('.shortcut-list')!.textContent).toContain('Updated search'); + expect(modal.querySelector('.shortcut-list b')).toBeNull(); + expect(modal.querySelectorAll('.shortcut-list tr')).toHaveLength(3); +}); diff --git a/web_src/js/modules/shortcut.ts b/web_src/js/modules/shortcut.ts index 93419ab5391..e28bbc01072 100644 --- a/web_src/js/modules/shortcut.ts +++ b/web_src/js/modules/shortcut.ts @@ -1,5 +1,19 @@ import {registerGlobalInitFunc} from './observer.ts'; -import {hideElem, toggleElem} from '../utils/dom.ts'; +import {createElementFromHTML, hideElem, toggleElem} from '../utils/dom.ts'; +import {showFomanticModal} from './fomantic/modal.ts'; +import {html} from '../utils/html.ts'; +import {svgRaw} from '../svg.ts'; + +type ShortcutHandler = (el: HTMLElement) => boolean; + +const shortcutHandlers: Record = {}; + +type ShortcutPress = { + fullKey: string, + pressTime: number, +}; + +const shortcutPresses: ShortcutPress[] = []; function initShortcutKbd(kbd: HTMLElement) { // Handle initial state: hide the kbd hint if the associated input already has a value @@ -8,6 +22,7 @@ function initShortcutKbd(kbd: HTMLElement) { if (elem?.value) hideElem(kbd); kbd.setAttribute('aria-hidden', 'true'); kbd.setAttribute('aria-keyshortcuts', kbd.getAttribute('data-shortcut-keys')!); + kbd.addEventListener('click', () => elemFromKbd(kbd)?.focus()); } function shortcutWrapper(el: HTMLElement): HTMLElement | null { @@ -23,6 +38,79 @@ function kbdFromElem(input: HTMLElement): HTMLElement | null { return shortcutWrapper(input)?.querySelector('kbd') || null; } +export function makeFullKeyFromEvent(e: KeyboardEvent): string { + // follow the MDN standard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-keyshortcuts + // always use PascalCase (upper case for single letter), and sort the modifiers in the order in alphabetical order + const modifierKeys = ['Alt', 'Control', 'Meta', 'Shift']; + let key = e.key; + if (!key || modifierKeys.includes(key)) return ''; + if (key.length === 1) key = key.toUpperCase(); + if (key === ' ') key = 'Space'; + if (key === '+') key = 'Plus'; + + const modifiers: string[] = []; + for (const mod of modifierKeys) { + if (e.getModifierState(mod)) modifiers.push(mod); + } + return [...modifiers, key].join('+'); +} + +function getShortcutTextContent(elem: Element): string { + for (const child of elem.childNodes) { + let text = ''; + if (child instanceof Text) { + text = child.data.trim(); + } else if (child instanceof Element) { + // in the future, we can also exclude elements that should not contribute to shortcut labels. + text = getShortcutTextContent(child); + } + if (text) return text; + } + return ''; +} + +function showShortcutHelp() { + let modal = document.querySelector('#global-shortcut-help'); + if (!modal) { + modal = createElementFromHTML(html` + + `); + document.body.append(modal); + } + + const list = modal.querySelector('.shortcut-list')!; + list.replaceChildren(createElementFromHTML(html`?Keyboard Shortcuts`)); + for (const elem of document.querySelectorAll('[data-shortcut-keys]')) { + const target = elem.matches('kbd') ? elemFromKbd(elem) : elem; + + // TODO: which element should be triggered by shortcut: can be fine-tuned in the future + if (!target || target.matches(':disabled, .disabled')) continue; + + const label = target.getAttribute('aria-label') || + target.getAttribute('placeholder') || + target.getAttribute('title') || + getShortcutTextContent(target); + list.append(createElementFromHTML(html`${elem.getAttribute('data-shortcut-keys')}${label}`)); + } + showFomanticModal(modal); +} + +export function registerShortcutHandler(fullKey: string, handler: ShortcutHandler) { + fullKey = fullKey.toLowerCase(); + shortcutHandlers[fullKey] ||= []; + shortcutHandlers[fullKey].push(handler); +} + export function initGlobalShortcut() { registerGlobalInitFunc('onGlobalShortcut', initShortcutKbd); @@ -31,13 +119,18 @@ export function initGlobalShortcut() { // 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) => { - // Modifier keys are not supported yet - if (e.ctrlKey || e.metaKey || e.altKey) return; + if (e.isComposing || e.defaultPrevented) { + shortcutPresses.length = 0; + return; + } + if (e.repeat) return; + const isPlainKey = !e.ctrlKey && !e.altKey && !e.metaKey; const target = e.target as HTMLElement; // Handle Escape: clear and blur inputs that have an associated keyboard shortcut - if (e.key === 'Escape') { + if (e.key === 'Escape' && isPlainKey) { + shortcutPresses.length = 0; const kbd = kbdFromElem(target); if (kbd) { (target as HTMLInputElement).value = ''; @@ -47,17 +140,53 @@ export function initGlobalShortcut() { } // Don't trigger shortcuts when typing in input fields or contenteditable areas - if (target.matches('input, textarea, select') || target.isContentEditable) { + let shouldSkip = target.matches('input, textarea, select') || target.isContentEditable; + // Skip shortcuts when a modal or dropdown is open (active), to avoid interfering with their own keyboard handling + shouldSkip = shouldSkip || Boolean(document.querySelector('.ui.modal.active') || document.querySelector('.ui.dropdown.active')); + if (shouldSkip) { + shortcutPresses.length = 0; 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(`.global-shortcut-wrapper > kbd[data-shortcut-keys="${CSS.escape(key)}"]`); - if (!kbd) return; + // Show the keyboard shortcut help modal when '?' is pressed without modifiers + if (e.key === '?' && isPlainKey) { + e.preventDefault(); + shortcutPresses.length = 0; + showShortcutHelp(); + return; + } + + const fullKey = makeFullKeyFromEvent(e); + // we don't support multiple modifier keys as a single shortcut (e.g., press Ctrl twice) at the moment + if (!fullKey) return; + + // just a simple algorithm to detect multi-key shortcuts, so far so good, for daily usage + // if any bad case happens, it can be fine-tuned in the future. + const keyPressDelay = 1000; + const pressTime = Date.now(); + while (shortcutPresses.length && pressTime - shortcutPresses[0].pressTime > keyPressDelay) { + shortcutPresses.shift(); + } + shortcutPresses.push({fullKey, pressTime}); + + const fullKeys = shortcutPresses.map((p) => p.fullKey).join(' '); + for (const handler of shortcutHandlers[fullKeys.toLowerCase()] || []) { + if (handler(target)) { + e.preventDefault(); + shortcutPresses.length = 0; + return; + } + } + + // It's said that "spaces in aria-keyshortcuts separate alternative shortcuts", but we want to support multi-key shortcuts + // So here we use our own data attribute to store the full shortcut keys, and match it case-insensitively. + const matchedElems = document.querySelectorAll(`[data-shortcut-keys="${CSS.escape(fullKeys)}" i]`); + if (!matchedElems.length) return; + // TODO: if there are multiple matches, maybe we could introduce some priority rules to determine which one to trigger + const matchedElem = matchedElems[0]; e.preventDefault(); - elemFromKbd(kbd)!.focus(); + shortcutPresses.length = 0; + matchedElem.click(); }); // Toggle kbd shortcut hint visibility on input focus/blur