Fix(ui): Suspend keyboard shortcuts while an overlay is open

Every shortcut in the map is a home-page action, so firing one from
behind a Dialog, Sheet or Drawer acted out of sight — and the
network-heavy ones (speed test, connectivity, DNS leak) spent the
visitor's bandwidth on a result they could not see. Pressing "?" inside
the Advanced Tools drawer also stacked the Help dialog on top of it.

The dispatcher now ignores keystrokes while any overlay is open. The
rule keys off the component's form rather than its purpose: the ui/
roots register themselves through use-overlay-shortcuts.js, so anything
built on them inherits it and overlays nest. Esc and the native
scrolling keys are untouched.

Drops the "f" fullscreen shortcut, which only ever acted on an open
drawer; the drawer header keeps its button.

Also fixes two long-standing dispatcher bugs the same code touches:
registration appended to a module-level map, so re-mounting Home (route
away and back) stacked a second copy of every action and left the first
pointing at unmounted refs; and the typing guard covered INPUT but not
TEXTAREA or contenteditable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 5 committed 2026-08-20 10:47:31 +08:00
1 parent d6fb27deac
commit f5d2601589
15 files changed
+362 -54

No files matched your search

+20 -5
View File
@@ -91,6 +91,19 @@ Check is the sharpest case: its live results carry a `detail` per check
id / axis / verdict triple reaches the section. Invisibility follows the
same rule with key + flag. Keep new sections on that side of the line.
### Overlays take no keyboard shortcuts
`utils/shortcut.js` runs one document-level keydown dispatcher over the map
`composables/use-shortcuts.js` registers — every entry there is a home-page
action, and the whole map is suspended while any overlay is open. The rule keys
off the component's form, not its purpose: the `ui/` roots (`Dialog` / `Sheet` /
`Drawer`) call `composables/use-overlay-shortcuts.js`, so anything built on them
inherits it, and overlays nest. Esc and the native scrolling keys still work —
reka-ui / vaul and the browser own those.
`registerShortcuts()` replaces the map rather than appending, and Home clears it
on unmount — shortcuts belong to the home route alone.
### Error monitoring (Sentry) is env-gated and invisible to app code
`sentry-init.js` loads via a build-time-gated dynamic import: no
@@ -127,10 +140,11 @@ upload at build, gated on `SENTRY_AUTH_TOKEN`.
**shadcn-vue first.** Check `components/ui/` (copied-in primitives), then
https://www.shadcn-vue.com/docs/components for something to copy in;
hand-rolled Tailwind only when neither fits. Two local notes: `Spinner` is
hand-rolled Tailwind only when neither fits. Three local notes: `Spinner` is
project-specific (lucide `Loader2` + `role="status"`); `toggle` /
`toggle-group` deliberately use the `primary` pair for the pressed state —
don't revert that when syncing upstream.
`toggle-group` deliberately use the `primary` pair for the pressed state; and
the `Dialog` / `Sheet` / `Drawer` roots suspend the keyboard shortcuts while
open — keep all three when syncing upstream.
### Design tokens
@@ -189,8 +203,9 @@ Copy from the named exemplar instead of re-inventing:
- **Tables vs lists** — real per-column header semantics → `<table>`;
otherwise a bordered `<ul class="rounded-lg border bg-card divide-y">`.
- **Dialog header** — the `<DialogHeader :icon :title />` primitive.
- **Drawer vs Sheet** — the vaul-vue bottom Drawer is reserved for the
Advanced Tools panel; side panels use `Sheet`.
- **Drawer vs Sheet** — the vaul-vue bottom Drawer is for the Advanced Tools
panel and full-bleed expansions of an inline visual (ASNConnectivity); side
panels use `Sheet`.
- **Motion** — hover lift `transition-transform duration-300 ease-out
hover:-translate-y-1.5`; loading is `<Spinner />`, never pulse-dot clusters.
+1 -6
View File
@@ -60,7 +60,6 @@ const QueryIP = defineAsyncComponent(() => import('./widgets/QueryIP.vue'));
const HelpModal = defineAsyncComponent(() => import('./widgets/Help.vue'));
const IPHistory = defineAsyncComponent(() => import('./widgets/IPHistory.vue'));
const ShareReport = defineAsyncComponent(() => import('./report/ShareReportDialog.vue'));
import { useRoute } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
@@ -73,12 +72,8 @@ import { useDocumentMeta } from '@/composables/use-document-meta.js';
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const configs = computed(() => store.configs);
const userPreferences = computed(() => store.userPreferences);
// A tool drawer is open iff the home route carries a `?tool=` query (set by
// Advanced.vue). Drives the `f` fullscreen shortcut gate.
const isToolOpen = computed(() => !!route.query.tool);
// Template refs
const userRef = ref(null);
@@ -113,7 +108,7 @@ const { loadShortcuts } = useShortcuts({
refs: {
queryIPRef, helpModalRef, shareReportRef,
speedTestRef, advancedToolsRef, IPCheckRef, connectivityRef, webRTCRef, dnsLeaksRef,
isInfosLoaded, isToolOpen, toggleInfoMask,
isInfosLoaded, toggleInfoMask,
},
store, t, configs, userPreferences,
});
+6 -1
View File
@@ -1,12 +1,17 @@
<script setup>
// shadcn-vue Dialog 根组件(refactor/01 阶段 B)
import { DialogRoot } from 'reka-ui';
import { useOverlayShortcuts } from '@/composables/use-overlay-shortcuts.js';
defineProps({
const props = defineProps({
open: { type: Boolean, default: undefined },
modal: { type: Boolean, default: true },
});
defineEmits(['update:open']);
// Overlays suspend the page's keyboard shortcuts while open — see
// composables/use-overlay-shortcuts.js.
useOverlayShortcuts(() => props.open);
</script>
<template>
+6 -1
View File
@@ -3,8 +3,9 @@
// 与 Sheet 不同:vaul 自带拖拽手势 + body scale(需配合 index.html 的 [vaul-drawer-wrapper])
// 用法:<Drawer :open="..." @update:open="..."> 或 v-model:open
import { DrawerRoot } from 'vaul-vue';
import { useOverlayShortcuts } from '@/composables/use-overlay-shortcuts.js';
defineProps({
const props = defineProps({
open: { type: Boolean, default: undefined },
modal: { type: Boolean, default: true },
// 'top' | 'bottom' | 'left' | 'right',默认 bottom
@@ -31,6 +32,10 @@ defineProps({
handleOnly: { type: Boolean, default: true },
});
defineEmits(['update:open', 'update:activeSnapPoint']);
// Overlays suspend the page's keyboard shortcuts while open — see
// composables/use-overlay-shortcuts.js.
useOverlayShortcuts(() => props.open);
</script>
<template>
+6 -1
View File
@@ -3,12 +3,17 @@
// 用法:<Sheet :open="..." @update:open="..."> 或 v-model:open
// 见 refactor/01 阶段 B Offcanvas 迁移
import { DialogRoot } from 'reka-ui';
import { useOverlayShortcuts } from '@/composables/use-overlay-shortcuts.js';
defineProps({
const props = defineProps({
open: { type: Boolean, default: undefined },
modal: { type: Boolean, default: true },
});
defineEmits(['update:open']);
// Overlays suspend the page's keyboard shortcuts while open — see
// composables/use-overlay-shortcuts.js.
useOverlayShortcuts(() => props.open);
</script>
<template>
@@ -0,0 +1,29 @@
// Suspends the global keyboard shortcuts while an overlay is open.
//
// Called from the `ui/` overlay roots (Dialog / Sheet / Drawer) rather than
// from their callers, so every overlay built on them is covered without any
// per-component wiring — including ones no shortcut opens. Esc and the native
// scrolling keys still work: reka-ui / vaul and the browser own those.
//
// Sync flush on purpose: a keystroke arriving in the same tick as the open
// flip must already see the overlay.
import { getCurrentScope, onScopeDispose, watch } from 'vue';
import { openOverlay, closeOverlay } from '@/utils/shortcut.js';
export const useOverlayShortcuts = (isOpen) => {
let counted = false;
const sync = (open) => {
const next = Boolean(open);
if (next === counted) return;
counted = next;
(next ? openOverlay : closeOverlay)();
};
watch(isOpen, sync, { immediate: true, flush: 'sync' });
// An overlay torn down while still open (its parent unmounting) never
// flips `open` back to false — release the count here instead of leaking.
if (getCurrentScope()) onScopeDispose(() => sync(false));
};
+22 -15
View File
@@ -14,11 +14,15 @@
// Note:
// - all scrolling + navigation actions use scrollToElement + advancedToolsRef.openTool(slug)
// - `h` key infoMask switch only executes when isInfosLoaded is true
// - every entry here is a home-page action. Overlays (Dialog / Sheet /
// Drawer) suspend the whole map while they are open — see
// utils/shortcut.js — so nothing needs a per-key "is something covering
// the page?" check.
import { watch } from 'vue';
import { getCurrentScope, onScopeDispose, watch } from 'vue';
import { trackEvent } from '../utils/analytics.js';
import { emitAppEvent } from '../utils/app-events.js';
import { mappingKeys, keyMap, navigateCards } from '../utils/shortcut.js';
import { registerShortcuts, keyMap, navigateCards } from '../utils/shortcut.js';
import { scrollToElement } from '../utils/scroll-to.js';
import { isPulseEnabled } from '../utils/pulse-beacon.js';
@@ -34,7 +38,6 @@ const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => {
webRTCRef,
dnsLeaksRef,
isInfosLoaded,
isToolOpen,
toggleInfoMask,
} = refs;
@@ -154,16 +157,6 @@ const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => {
{ keys: 'b', action: () => goToAdvancedTool('browserinfo', 'BrowserInfo'), description: t('shortcutKeys.BrowserInfo') },
{ keys: 'W', action: () => goToAdvancedTool('whois', 'Whois'), description: t('shortcutKeys.Whois') },
{ keys: 'v', action: () => goToAdvancedTool('servicestatus', 'ServiceStatus'), description: t('shortcutKeys.ServiceStatus') },
{
keys: 'f',
action: () => {
if (isToolOpen.value) {
advancedToolsRef.value.fullScreen();
trackEvent('ShortCut', 'ShortCut', 'FullScreen');
}
},
description: t('shortcutKeys.fullScreenAdvancedTools'),
},
{
keys: 'q',
// Async components (see Home.vue): ref is null until the chunk
@@ -242,13 +235,27 @@ const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => {
};
export const useShortcuts = ({ refs, store, t, configs, userPreferences }) => {
// Suspending the map behind an overlay is the primitives' job
// (composables/use-overlay-shortcuts.js), so nothing is wired here.
//
// Home is the only route that registers shortcuts; drop them when it
// unmounts, so keystrokes on /privacy or /r/:id can't reach refs that no
// longer point at anything.
let disposed = false;
if (getCurrentScope()) {
onScopeDispose(() => {
disposed = true;
registerShortcuts([]);
});
}
const registerShortcutKeys = () => {
const shortcuts = buildShortcutConfig({ refs, store, t, configs, userPreferences });
shortcuts.forEach((entry) => mappingKeys(entry));
registerShortcuts(buildShortcutConfig({ refs, store, t, configs, userPreferences }));
};
const loadShortcuts = () => {
setTimeout(() => {
if (disposed) return;
registerShortcutKeys();
// Help is an async component (Home.vue): on slow networks its
// chunk may land after this timer, so wait for the ref instead
-1
View File
@@ -1413,7 +1413,6 @@
"EnhancedDnsLeakTest": "Open In-depth DNS Leak Test panel",
"MacChecker": "Open MAC lookup panel",
"BrowserInfo": "Open Browser Info panel",
"fullScreenAdvancedTools": "Full Screen Advanced Tools",
"SecurityChecklist": "Open Security Checklist panel",
"PersonaCheck": "Open In-depth Persona Check panel"
},
-1
View File
@@ -1413,7 +1413,6 @@
"EnhancedDnsLeakTest": "Ouvrir le Test de fuite DNS approfondi",
"MacChecker": "Ouvrir le Recherche de MAC",
"BrowserInfo": "Ouvrir l'Info du navigateur",
"fullScreenAdvancedTools": "Outils avancés en plein écran",
"SecurityChecklist": "Ouvrir la Liste de sécurité",
"PersonaCheck": "Ouvrir le panneau Vérification de persona approfondie"
},
-1
View File
@@ -1413,7 +1413,6 @@
"EnhancedDnsLeakTest": "Открыть панель углублённого теста утечки DNS",
"MacChecker": "Открыть панель поиска MAC-адреса",
"BrowserInfo": "Открыть панель сведений о браузере",
"fullScreenAdvancedTools": "Расширенные инструменты во весь экран",
"SecurityChecklist": "Открыть панель контрольного списка безопасности",
"PersonaCheck": "Открыть панель углублённой проверки портрета"
},
-1
View File
@@ -1413,7 +1413,6 @@
"EnhancedDnsLeakTest": "打开深度 DNS 泄露测试面板",
"MacChecker": "打开物理地址查询面板",
"BrowserInfo": "打开浏览器信息面板",
"fullScreenAdvancedTools": "全屏展开高级工具面板",
"SecurityChecklist": "打开安全检查清单",
"PersonaCheck": "打开深度画像检测面板"
},
+62 -16
View File
@@ -1,4 +1,21 @@
let keyMap = [];
// Global keyboard-shortcut dispatcher.
//
// One document-level keydown listener collects the typed keys and runs every
// registered entry that matches — unless an overlay is open.
//
// Overlays (Dialog / Sheet / Drawer) suspend every shortcut while they cover
// the page: their effects would happen out of sight, and a home-page action
// fired from inside a tool panel is never what the visitor meant. The rule is
// about the component's form, not its purpose, so it needs no per-overlay
// bookkeeping — the `ui/` roots register themselves through
// composables/use-overlay-shortcuts.js and anything built on them inherits it.
// Esc and the native scrolling keys are untouched (see ignoreKeys); they
// belong to reka-ui / vaul and the browser.
const keyMap = [];
// How many overlays are currently on screen — a count, not a flag, because a
// dialog can open over a drawer and each closes independently.
let overlayDepth = 0;
let keyPool = "";
let timer = null;
const keyDelay = 10;
@@ -24,10 +41,36 @@ const ignoreKeys = [
"Insert",
];
const mappingKeys = (...keys) => {
keyMap = [...keyMap, ...keys];
// Replaces the whole map rather than appending: Home re-mounting (route away
// and back) re-registers instead of stacking a second copy of every action.
// Called with [] when Home unmounts, so a keystroke on another route can't
// reach its unmounted refs.
const registerShortcuts = (entries) => {
keyMap.length = 0;
keyMap.push(...entries);
};
const openOverlay = () => {
overlayDepth += 1;
};
const closeOverlay = () => {
overlayDepth = Math.max(0, overlayDepth - 1);
};
const isOverlayOpen = () => overlayDepth > 0;
// Pure: the actions the given keystrokes match. Regex entries pass their
// captured groups through as arguments.
const collectShortcutActions = (pool, entries = keyMap) =>
entries.flatMap(({ keys, action, type }) => {
if (type === 'regex') {
const [matched, ...args] = pool.match(new RegExp(`^${keys}$`)) ?? [];
return matched && action ? [() => action(...args)] : [];
}
return keys === pool && action ? [action] : [];
});
// Navigation
const navigateCards = (direction) => {
const mainPart = document.getElementById('mainpart');
@@ -71,7 +114,12 @@ document.addEventListener(
// only, so prod is unaffected (the flag is undefined → falsy).
if (window.__vConsoleActive) return;
if (target.tagName === "INPUT") return;
// Before anything else, including the preventDefault below: inside an
// overlay the page's keys belong to the browser, not to us.
if (isOverlayOpen()) return;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
if (target.isContentEditable) return;
if (metaKey || altKey || ctrlKey) return;
if (key === 'j' || key === 'k') {
@@ -81,21 +129,19 @@ document.addEventListener(
keyPool += ignoreKeys.includes(key) ? "" : key;
timer && clearTimeout(timer);
timer = setTimeout(() => {
keyMap.forEach(({ keys, action, type }) => {
if (type === "regex") {
const keyReg = new RegExp(`^${keys}$`);
const [key, ...args] = keyPool.match(keyReg) ?? [];
!!key && action && action(...args);
} else {
if (keys === keyPool) {
action && action();
}
}
});
collectShortcutActions(keyPool).forEach((run) => run());
keyPool = "";
}, keyDelay);
}
);
export { mappingKeys, navigateCards, keyMap };
export {
registerShortcuts,
openOverlay,
closeOverlay,
isOverlayOpen,
collectShortcutActions,
navigateCards,
keyMap,
};
@@ -0,0 +1,76 @@
// useOverlayShortcuts() — the hook the ui/ overlay roots use to tell the
// dispatcher that they are covering the page.
//
// shortcut.js attaches a document listener at import time, so a stub goes in
// before the dynamic import. A resolve hook rewrites the composable's
// `@/utils/shortcut.js` import, since the Vite alias doesn't exist in the
// Node runner.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { registerHooks } from 'node:module';
import { effectScope, ref } from 'vue';
globalThis.document = { addEventListener() {} };
globalThis.window = {};
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@/utils/shortcut.js') {
return nextResolve('../utils/shortcut.js', context);
}
return nextResolve(specifier, context);
},
});
const { isOverlayOpen } = await import('../frontend/utils/shortcut.js');
const { useOverlayShortcuts } = await import('../frontend/composables/use-overlay-shortcuts.js');
describe('useOverlayShortcuts()', () => {
it('counts the overlay while open and releases it on close', () => {
const open = ref(false);
const scope = effectScope();
scope.run(() => useOverlayShortcuts(() => open.value));
assert.equal(isOverlayOpen(), false);
open.value = true;
assert.equal(isOverlayOpen(), true, 'sync flush — no tick to wait for');
open.value = false;
assert.equal(isOverlayOpen(), false);
scope.stop();
});
it('counts an overlay that mounts already open', () => {
const scope = effectScope();
scope.run(() => useOverlayShortcuts(() => true));
assert.equal(isOverlayOpen(), true);
scope.stop();
assert.equal(isOverlayOpen(), false, 'no leak after the overlay is torn down while open');
});
it('a dialog over a drawer keeps the page suspended until both close', () => {
const dialog = ref(true);
const drawer = ref(true);
const scope = effectScope();
scope.run(() => {
useOverlayShortcuts(() => drawer.value);
useOverlayShortcuts(() => dialog.value);
});
dialog.value = false;
assert.equal(isOverlayOpen(), true, 'the drawer underneath is still covering the page');
drawer.value = false;
assert.equal(isOverlayOpen(), false);
scope.stop();
});
it('repeated identical states count the overlay only once', () => {
const open = ref(true);
const scope = effectScope();
scope.run(() => useOverlayShortcuts(() => open.value));
open.value = true;
open.value = false;
assert.equal(isOverlayOpen(), false, 'one open ⇒ one close');
scope.stop();
});
});
+6 -5
View File
@@ -98,7 +98,6 @@ function makeRefs() {
webRTCRef: ref({ checkAllWebRTC: (flag) => { calls.webrtc.push(flag); } }),
dnsLeaksRef: ref({ checkAllDNSLeakTest: (flag) => { calls.dnsleak.push(flag); } }),
isInfosLoaded: ref(true),
isToolOpen: ref(true),
toggleInfoMask: () => { calls.mask += 1; },
},
calls,
@@ -136,12 +135,11 @@ function loadAndGetKeyMap({
}
describe('useShortcuts()', () => {
it('loadShortcuts() registers a keymap of 27+ entries on a non-original site', () => {
it('loadShortcuts() registers a keymap of 26+ entries on a non-original site', () => {
const { keyMap } = loadAndGetKeyMap({ originalSite: false, pulseEnabled: false });
// 27 base entries (no invisibility / enhanced-DNS / pulse); keyMap is
// append-only globally so ≥ 27
// 26 base entries (no invisibility / enhanced-DNS / pulse).
const distinctKeys = new Set(keyMap.map((e) => e.keys));
assert.ok(distinctKeys.size >= 27, `expected ≥27 distinct shortcut keys, got ${distinctKeys.size}`);
assert.ok(distinctKeys.size >= 26, `expected ≥26 distinct shortcut keys, got ${distinctKeys.size}`);
assert.ok(distinctKeys.has('R'));
assert.ok(distinctKeys.has('?'));
assert.ok(distinctKeys.has('g'));
@@ -149,6 +147,9 @@ describe('useShortcuts()', () => {
assert.ok(distinctKeys.has('H'));
assert.equal(distinctKeys.has('p'), false, 'pulse shortcut stays off when isPulseEnabled is false');
assert.equal(distinctKeys.has('P'), false, 'persona shortcut stays off a self-hosted instance');
// Overlays take no keys at all (utils/shortcut.js), so there is no
// drawer-only entry left to register.
assert.equal(distinctKeys.has('f'), false, 'no shortcut acts on an open overlay');
});
it('originalSite=true adds invisibility ("i"), enhanced DNS-leak ("D") and persona ("P") shortcuts', () => {
+128
View File
@@ -0,0 +1,128 @@
// The keydown dispatcher in frontend/utils/shortcut.js: what a keystroke
// actually runs, and when it runs nothing.
//
// The module attaches its listener at import time, so the document stub below
// captures the handler and the tests drive it with synthetic events.
import assert from 'node:assert/strict';
import { describe, it, beforeEach } from 'node:test';
let keydown = null;
globalThis.document = {
addEventListener(type, handler) { if (type === 'keydown') keydown = handler; },
};
globalThis.window = {};
const {
registerShortcuts,
openOverlay,
closeOverlay,
isOverlayOpen,
collectShortcutActions,
keyMap,
} = await import('../frontend/utils/shortcut.js');
// Actions record here; `press()` drains it so each assertion sees only what
// the keystrokes it just sent triggered.
let log = [];
const record = (label) => (...args) => log.push(args.length ? [label, ...args] : label);
// Sends keystrokes and waits out the dispatcher's 10ms key-pool window.
const press = async (...keys) => {
log = [];
for (const key of keys) {
keydown({ key, target: { tagName: 'DIV' }, preventDefault() {} });
}
await new Promise((resolve) => setTimeout(resolve, 40));
return log;
};
// Same, but the keystroke lands in the given element instead of the page.
const pressInto = async (key, target) => {
log = [];
keydown({ key, target, preventDefault() {} });
await new Promise((resolve) => setTimeout(resolve, 40));
return log;
};
describe('shortcut dispatcher', () => {
beforeEach(() => {
while (isOverlayOpen()) closeOverlay();
registerShortcuts([]);
});
it('runs the action registered for the typed key', async () => {
registerShortcuts([{ keys: 's', action: record('speedtest') }]);
assert.deepEqual(await press('s'), ['speedtest']);
assert.deepEqual(await press('x'), [], 'unregistered keys do nothing');
});
it('stays silent while an overlay is open, whatever the key', async () => {
registerShortcuts([
{ keys: 's', action: record('speedtest') },
{ keys: '?', action: record('help') },
{ keys: 'j', action: record('next-card') },
]);
openOverlay();
assert.deepEqual(await press('s'), [], 'no bandwidth spent behind a dialog');
assert.deepEqual(await press('?'), []);
assert.deepEqual(await press('j'), []);
closeOverlay();
assert.deepEqual(await press('s'), ['speedtest'], 'live again once it closes');
});
it('needs every stacked overlay to close before shortcuts come back', async () => {
registerShortcuts([{ keys: 's', action: record('speedtest') }]);
openOverlay();
openOverlay();
closeOverlay();
assert.deepEqual(await press('s'), [], 'one overlay is still covering the page');
closeOverlay();
assert.deepEqual(await press('s'), ['speedtest']);
});
it('an unbalanced close cannot leave the count negative', async () => {
registerShortcuts([{ keys: 's', action: record('speedtest') }]);
closeOverlay();
closeOverlay();
openOverlay();
assert.deepEqual(await press('s'), [], 'the next overlay still suspends shortcuts');
closeOverlay();
});
it('ignores keystrokes typed into a field', async () => {
registerShortcuts([{ keys: 's', action: record('speedtest') }]);
assert.deepEqual(await pressInto('s', { tagName: 'INPUT' }), []);
assert.deepEqual(await pressInto('s', { tagName: 'TEXTAREA' }), []);
assert.deepEqual(await pressInto('s', { tagName: 'DIV', isContentEditable: true }), []);
});
it('passes regex captures to the action', async () => {
registerShortcuts([{ keys: '([1-6])', type: 'regex', action: record('ipcard') }]);
assert.deepEqual(await press('3'), [['ipcard', '3']]);
assert.deepEqual(await press('9'), [], 'outside the range, nothing matches');
});
it('registering again replaces the map instead of stacking a second copy', async () => {
const entry = { keys: 'w', action: record('webrtc') };
registerShortcuts([entry]);
registerShortcuts([entry]);
assert.deepEqual(await press('w'), ['webrtc'], 'a remount must not double-fire actions');
});
it('keeps the exported keyMap identity across registrations (Help holds a reference)', () => {
const before = keyMap;
registerShortcuts([{ keys: 'g', action: () => {}, description: 'top' }]);
assert.equal(keyMap, before);
assert.deepEqual(keyMap.map((e) => e.keys), ['g']);
registerShortcuts([]);
assert.deepEqual(keyMap, []);
});
it('collectShortcutActions matches without running anything', () => {
registerShortcuts([{ keys: 'g', action: record('top') }]);
log = [];
assert.equal(collectShortcutActions('g').length, 1);
assert.deepEqual(log, [], 'matching is separate from running');
});
});