feat(quick-switcher): modal overlay UI + keyboard nav

createQuickSwitcherOverlay() mounts a centered modal with:
  - input box with autocomplete=off
  - workspace toggle (off by default — recent + open tabs only)
  - keyboard nav: ArrowDown/Up move selection, Enter opens,
    Escape and backdrop-click hide
  - mouse hover also moves selection
  - debounced refresh (default 80ms; 0 in tests)
  - destroy() cleanly removes DOM + listeners

Rank/filter logic delegated to fuzzy-matcher.js. Recent and open tabs
are always in the candidate set; workspace files are added only when
the toggle is on. Selection clamps to bounds after every refresh.

23 new tests covering mount/focus/destroy, initial render, typing &
filtering, full keyboard nav, workspace toggle behavior, and the
selected-path escape hatch.

Amit Haridas
This commit is contained in:
Amit Haridas
2026-09-30 19:58:18 +05:30
parent 33fa14c11a
commit 3bd958c064
2 changed files with 632 additions and 0 deletions
@@ -0,0 +1,316 @@
/**
* Quick-switcher overlay UI.
*
* Modal centered over the viewport. Lists recent files, open tabs, and
* (when toggled) workspace files ranked by the shared fuzzy matcher.
*
* Pure logic — ranking, scoring — lives in fuzzy-matcher.js. This module
* owns DOM and keyboard only.
*
* @module quick-switcher-overlay
*/
const { rankResults: defaultRankResults } = require('./fuzzy-matcher');
/**
* @param {HTMLElement} container Mount point (typically document.body or a
* dedicated overlay host appended by the caller)
* @param {object} deps
* @param {() => string[]} [deps.getOpenTabPaths]
* @param {(dir:string, options?:object) => Promise<Array<{path:string, name:string}>>} [deps.listWorkspaceFiles]
* @param {(filePath:string) => void} [deps.onOpenFile]
* @param {() => string|null} [deps.getWorkspaceDir]
* @param {(query:string, items:Array, boosts:object) => Array} [deps.rankResults]
* @param {number} [deps.debounceMs=80]
* @returns {{
* show(opts?:{recent?:string[]}): void,
* hide(): void,
* destroy(): void,
* isOpen(): boolean,
* setRecent(recent:string[]): void,
* getSelectedPath(): string|null,
* }}
*/
function createQuickSwitcherOverlay(container, deps = {}) {
const {
getOpenTabPaths = () => [],
listWorkspaceFiles = async () => [],
onOpenFile = () => {},
getWorkspaceDir = () => null,
rankResults = defaultRankResults,
debounceMs = 80,
} = deps;
let modalEl = null;
let inputEl = null;
let listEl = null;
let emptyEl = null;
let workspaceToggleEl = null;
let open = false;
let currentQuery = '';
let recentFiles = [];
let workspaceFiles = [];
let workspaceMode = false;
let rankedItems = [];
let selectedIndex = 0;
let debounceTimer = null;
let keyListener = null;
let backdropListener = null;
function buildDom() {
const wrap = document.createElement('div');
wrap.className = 'quick-switcher-wrap';
wrap.innerHTML = `
<div class="quick-switcher-backdrop"></div>
<div class="quick-switcher-modal" role="dialog" aria-label="Quick switcher">
<input class="quick-switcher-input" type="text" autocomplete="off" spellcheck="false"
aria-label="Search files">
<div class="quick-switcher-toolbar">
<span class="quick-switcher-count" aria-live="polite"></span>
<button type="button" class="quick-switcher-workspace-toggle" aria-pressed="false">
Search workspace
</button>
</div>
<ul class="quick-switcher-list" role="listbox"></ul>
<div class="quick-switcher-empty" hidden>No matches</div>
</div>
`;
return wrap;
}
function attachListeners() {
if (!modalEl) return;
inputEl.addEventListener('input', onInput);
keyListener = (e) => {
if (!open) return;
if (e.key === 'Escape') {
e.preventDefault();
hide();
return;
}
// Only arrow/enter handled at document level (Esc, too); input handles
// text natively.
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
updateSelection(e.key === 'ArrowDown' ? 1 : -1);
} else if (e.key === 'Enter') {
e.preventDefault();
openSelected();
}
};
// Capture so Esc works even if focus drifts (it usually shouldn't).
document.addEventListener('keydown', keyListener, true);
backdropListener = () => hide();
modalEl.querySelector('.quick-switcher-backdrop').addEventListener('click', backdropListener);
workspaceToggleEl.addEventListener('click', onToggleWorkspace);
}
function detachListeners() {
if (inputEl) inputEl.removeEventListener('input', onInput);
if (keyListener) document.removeEventListener('keydown', keyListener, true);
if (backdropListener && modalEl) {
const bd = modalEl.querySelector('.quick-switcher-backdrop');
if (bd) bd.removeEventListener('click', backdropListener);
}
if (workspaceToggleEl) workspaceToggleEl.removeEventListener('click', onToggleWorkspace);
keyListener = null;
backdropListener = null;
}
function onInput() {
currentQuery = inputEl.value;
scheduleRefresh();
}
function onToggleWorkspace() {
workspaceMode = !workspaceMode;
workspaceToggleEl.setAttribute('aria-pressed', String(workspaceMode));
workspaceToggleEl.classList.toggle('active', workspaceMode);
scheduleRefresh(true);
}
function scheduleRefresh(immediate = false) {
if (debounceTimer) clearTimeout(debounceTimer);
const run = () => {
debounceTimer = null;
refresh();
};
if (immediate || debounceMs <= 0) run();
else debounceTimer = setTimeout(run, debounceMs);
}
async function refresh() {
const dir = getWorkspaceDir();
if (workspaceMode && workspaceFiles.length === 0 && dir) {
try {
workspaceFiles = await listWorkspaceFiles(dir);
} catch {
workspaceFiles = [];
}
}
const openTabs = getOpenTabPaths();
const recentSet = new Set(recentFiles);
const openSet = new Set(openTabs);
const candidates = [];
if (workspaceMode) {
for (const f of workspaceFiles) candidates.push(f);
}
for (const path of recentFiles) {
if (!candidates.some((c) => c.path === path)) {
const name = path.split(/[\\/]/).pop() || path;
candidates.push({ path, name });
}
}
for (const path of openTabs) {
if (!candidates.some((c) => c.path === path)) {
const name = path.split(/[\\/]/).pop() || path;
candidates.push({ path, name });
}
}
rankedItems = rankResults(currentQuery, candidates, { recent: recentSet, openTabs: openSet });
// Keep selection in bounds
if (selectedIndex >= rankedItems.length) {
selectedIndex = Math.max(0, rankedItems.length - 1);
}
renderList();
}
function renderList() {
if (!modalEl) return;
listEl.innerHTML = '';
emptyEl.hidden = rankedItems.length > 0;
for (let i = 0; i < rankedItems.length; i++) {
const { item } = rankedItems[i];
const li = document.createElement('li');
li.className = 'quick-switcher-item' + (i === selectedIndex ? ' selected' : '');
li.setAttribute('role', 'option');
li.dataset.index = String(i);
li.dataset.path = item.path;
const baseName = item.name || item.path.split(/[\\/]/).pop() || item.path;
const parent = item.path.slice(0, item.path.length - baseName.length);
li.innerHTML = `
<span class="quick-switcher-name"></span>
<span class="quick-switcher-path"></span>
`;
li.querySelector('.quick-switcher-name').textContent = baseName;
li.querySelector('.quick-switcher-path').textContent = parent;
li.addEventListener('click', () => {
selectedIndex = i;
openSelected();
});
li.addEventListener('mouseenter', () => {
selectedIndex = i;
updateHighlight();
});
listEl.appendChild(li);
}
const count = modalEl.querySelector('.quick-switcher-count');
if (count) {
count.textContent = rankedItems.length
? `${rankedItems.length} ${rankedItems.length === 1 ? 'result' : 'results'}`
: '';
}
}
function updateHighlight() {
if (!listEl) return;
const items = listEl.querySelectorAll('.quick-switcher-item');
items.forEach((el, i) => {
el.classList.toggle('selected', i === selectedIndex);
});
}
function updateSelection(delta) {
if (rankedItems.length === 0) return;
selectedIndex = Math.max(0, Math.min(rankedItems.length - 1, selectedIndex + delta));
updateHighlight();
}
function openSelected() {
const sel = rankedItems[selectedIndex];
if (!sel) return;
onOpenFile(sel.item.path);
hide();
}
function show({ recent = [] } = {}) {
recentFiles = recent;
currentQuery = '';
selectedIndex = 0;
workspaceMode = false;
workspaceFiles = [];
if (!modalEl) {
modalEl = buildDom();
container.appendChild(modalEl);
inputEl = modalEl.querySelector('.quick-switcher-input');
listEl = modalEl.querySelector('.quick-switcher-list');
emptyEl = modalEl.querySelector('.quick-switcher-empty');
workspaceToggleEl = modalEl.querySelector('.quick-switcher-workspace-toggle');
attachListeners();
}
inputEl.value = '';
workspaceToggleEl.setAttribute('aria-pressed', 'false');
workspaceToggleEl.classList.remove('active');
modalEl.classList.add('open');
open = true;
inputEl.focus();
scheduleRefresh(true);
}
function hide() {
open = false;
if (modalEl) modalEl.classList.remove('open');
}
function destroy() {
detachListeners();
if (modalEl && modalEl.parentNode) modalEl.parentNode.removeChild(modalEl);
modalEl = null;
inputEl = null;
listEl = null;
emptyEl = null;
workspaceToggleEl = null;
}
function isOpenFn() {
return open;
}
function setRecentFn(recent) {
recentFiles = Array.isArray(recent) ? recent : [];
if (open) scheduleRefresh(true);
}
function getSelectedPathFn() {
const sel = rankedItems[selectedIndex];
return sel ? sel.item.path : null;
}
return {
show,
hide,
destroy,
isOpen: isOpenFn,
setRecent: setRecentFn,
getSelectedPath: getSelectedPathFn,
};
}
module.exports = { createQuickSwitcherOverlay };
+316
View File
@@ -0,0 +1,316 @@
/**
* @jest-environment jsdom
*
* Quick-switcher overlay UI.
*
* Mounts a modal centered over the viewport. Pure controller logic
* (fuzzy match, ranking) lives in fuzzy-matcher.js — this test only
* covers DOM contract, keyboard nav, and the wiring between user
* input and the deps callbacks.
*/
const { createQuickSwitcherOverlay } = require('../src/quick-switcher/quick-switcher-overlay');
const { rankResults } = require('../src/quick-switcher/fuzzy-matcher');
async function flush(ms = 0) {
for (let i = 0; i < 5; i++) await Promise.resolve();
if (ms > 0) await new Promise((r) => setTimeout(r, ms));
}
function mount(deps = {}) {
const container = document.createElement('div');
document.body.appendChild(container);
const overlay = createQuickSwitcherOverlay(container, {
getOpenTabPaths: deps.getOpenTabPaths || (() => []),
listWorkspaceFiles:
deps.listWorkspaceFiles ||
jest.fn().mockResolvedValue([
{ path: '/ws/readme.md', name: 'readme.md' },
{ path: '/ws/notes.md', name: 'notes.md' },
]),
onOpenFile: deps.onOpenFile || jest.fn(),
getWorkspaceDir: deps.getWorkspaceDir || (() => '/ws'),
rankResults: deps.rankResults || rankResults,
debounceMs: 0, // tests don't wait for debounce
...deps,
});
return { container, overlay };
}
function getInput(container) {
return container.querySelector('.quick-switcher-input');
}
function getItems(container) {
return Array.from(container.querySelectorAll('.quick-switcher-item'));
}
function selectedIndex(container) {
const items = getItems(container);
return items.findIndex((el) => el.classList.contains('selected'));
}
function dispatchKey(target, key) {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
target.dispatchEvent(event);
return event;
}
describe('createQuickSwitcherOverlay — show/hide', () => {
test('show() mounts DOM structure into container', () => {
const { container, overlay } = mount();
overlay.show({ recent: [] });
expect(container.querySelector('.quick-switcher-modal')).not.toBeNull();
expect(container.querySelector('.quick-switcher-input')).not.toBeNull();
expect(container.querySelector('.quick-switcher-list')).not.toBeNull();
});
test('show() focuses the input', () => {
const { container, overlay } = mount();
overlay.show({ recent: [] });
const input = getInput(container);
expect(document.activeElement).toBe(input);
});
test('hide() removes the open class but keeps the DOM mounted', () => {
const { container, overlay } = mount();
overlay.show({ recent: [] });
overlay.hide();
const modal = container.querySelector('.quick-switcher-modal');
expect(modal).not.toBeNull();
expect(modal.classList.contains('open')).toBe(false);
});
test('destroy() removes the DOM entirely', () => {
const { container, overlay } = mount();
overlay.show({ recent: [] });
overlay.destroy();
expect(container.querySelector('.quick-switcher-modal')).toBeNull();
});
test('isOpen() reflects state', () => {
const { overlay } = mount();
expect(overlay.isOpen()).toBe(false);
overlay.show({ recent: [] });
expect(overlay.isOpen()).toBe(true);
overlay.hide();
expect(overlay.isOpen()).toBe(false);
});
});
describe('createQuickSwitcherOverlay — initial results', () => {
test('empty query shows recent files first', async () => {
const { container, overlay } = mount();
overlay.show({ recent: ['/ws/recent-a.md', '/ws/recent-b.md'] });
await flush();
const items = getItems(container);
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('recent-a');
});
test('empty query with no recent shows open tabs', async () => {
const { container, overlay } = mount({
getOpenTabPaths: () => ['/ws/open.md'],
});
overlay.show({ recent: [] });
await flush();
const items = getItems(container);
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('open.md');
});
test('empty query with no recent and no tabs shows an empty state', async () => {
const { container, overlay } = mount();
overlay.show({ recent: [] });
await flush();
expect(container.querySelector('.quick-switcher-empty')).not.toBeNull();
expect(getItems(container)).toHaveLength(0);
});
});
describe('createQuickSwitcherOverlay — typing & filtering', () => {
test('typing a query filters the list via fuzzy matcher', async () => {
const { container, overlay } = mount();
overlay.show({ recent: ['/ws/readme.md', '/ws/notes.md', '/ws/idea.md'] });
await flush();
const input = getInput(container);
input.value = 'idea';
input.dispatchEvent(new Event('input', { bubbles: true }));
await flush();
const items = getItems(container);
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('idea.md');
});
test('typing with no match shows the "no matches" state', async () => {
const { container, overlay } = mount();
overlay.show({ recent: ['/ws/readme.md'] });
await flush();
const input = getInput(container);
input.value = 'xyzqwerty';
input.dispatchEvent(new Event('input', { bubbles: true }));
await flush();
expect(container.querySelector('.quick-switcher-empty')).not.toBeNull();
expect(getItems(container)).toHaveLength(0);
});
test('selection defaults to the top result', async () => {
const { container, overlay } = mount();
overlay.show({ recent: ['/ws/readme.md', '/ws/idea.md'] });
await flush();
expect(selectedIndex(container)).toBe(0);
});
});
describe('createQuickSwitcherOverlay — keyboard navigation', () => {
test('ArrowDown moves selection down', async () => {
const { container, overlay } = mount();
overlay.show({ recent: ['/ws/a.md', '/ws/b.md', '/ws/c.md'] });
await flush();
const input = getInput(container);
dispatchKey(input, 'ArrowDown');
await flush();
expect(selectedIndex(container)).toBe(1);
dispatchKey(input, 'ArrowDown');
await flush();
expect(selectedIndex(container)).toBe(2);
});
test('ArrowUp moves selection up', async () => {
const { container, overlay } = mount();
overlay.show({ recent: ['/ws/a.md', '/ws/b.md', '/ws/c.md'] });
await flush();
const input = getInput(container);
// Start at 0, go down twice, back up once
dispatchKey(input, 'ArrowDown');
dispatchKey(input, 'ArrowDown');
dispatchKey(input, 'ArrowUp');
await flush();
expect(selectedIndex(container)).toBe(1);
});
test('ArrowDown clamps at the last item', async () => {
const { container, overlay } = mount();
overlay.show({ recent: ['/ws/a.md', '/ws/b.md'] });
await flush();
const input = getInput(container);
dispatchKey(input, 'ArrowDown');
dispatchKey(input, 'ArrowDown');
dispatchKey(input, 'ArrowDown');
await flush();
expect(selectedIndex(container)).toBe(1);
});
test('ArrowUp clamps at 0', async () => {
const { container, overlay } = mount();
overlay.show({ recent: ['/ws/a.md'] });
await flush();
const input = getInput(container);
dispatchKey(input, 'ArrowUp');
await flush();
expect(selectedIndex(container)).toBe(0);
});
test('Enter calls onOpenFile with the selected path', async () => {
const onOpenFile = jest.fn();
const { container, overlay } = mount({ onOpenFile });
overlay.show({ recent: ['/ws/a.md', '/ws/b.md'] });
await flush();
const input = getInput(container);
dispatchKey(input, 'ArrowDown'); // select /ws/b.md
dispatchKey(input, 'Enter');
await flush();
expect(onOpenFile).toHaveBeenCalledTimes(1);
expect(onOpenFile).toHaveBeenCalledWith('/ws/b.md');
});
test('Esc hides the overlay without calling onOpenFile', async () => {
const onOpenFile = jest.fn();
const { overlay } = mount({ onOpenFile });
overlay.show({ recent: ['/ws/a.md'] });
await flush();
dispatchKey(document, 'Escape');
await flush();
expect(overlay.isOpen()).toBe(false);
expect(onOpenFile).not.toHaveBeenCalled();
});
});
describe('createQuickSwitcherOverlay — workspace toggle', () => {
test('workspace toggle calls listWorkspaceFiles with workspace dir', async () => {
const listWorkspaceFiles = jest
.fn()
.mockResolvedValue([{ path: '/ws/ws-only.md', name: 'ws-only.md' }]);
const { container, overlay } = mount({
listWorkspaceFiles,
getWorkspaceDir: () => '/ws',
});
overlay.show({ recent: [] });
await flush();
const toggle = container.querySelector('.quick-switcher-workspace-toggle');
expect(toggle).not.toBeNull();
toggle.click();
await flush();
expect(listWorkspaceFiles).toHaveBeenCalled();
const args = listWorkspaceFiles.mock.calls[0][0];
expect(args).toBe('/ws');
});
test('workspace toggle off by default (recent + tabs only)', async () => {
const listWorkspaceFiles = jest.fn().mockResolvedValue([]);
const { overlay } = mount({
listWorkspaceFiles,
getWorkspaceDir: () => '/ws',
});
overlay.show({ recent: ['/recent.md'] });
await flush();
expect(listWorkspaceFiles).not.toHaveBeenCalled();
});
});
describe('createQuickSwitcherOverlay — escape hatches', () => {
test('backdrop click hides the overlay', async () => {
const { container, overlay } = mount();
overlay.show({ recent: [] });
await flush();
const backdrop = container.querySelector('.quick-switcher-backdrop');
backdrop.click();
await flush();
expect(overlay.isOpen()).toBe(false);
});
test('multiple show/hide cycles reuse the same DOM', () => {
const { container, overlay } = mount();
overlay.show({ recent: [] });
const modalA = container.querySelector('.quick-switcher-modal');
overlay.hide();
overlay.show({ recent: [] });
const modalB = container.querySelector('.quick-switcher-modal');
expect(modalA).toBe(modalB);
});
});
describe('createQuickSwitcherOverlay — getSelectedPath', () => {
test('returns the path of the current selection', async () => {
const { overlay } = mount();
overlay.show({ recent: ['/ws/a.md', '/ws/b.md'] });
await flush();
expect(overlay.getSelectedPath()).toBe('/ws/a.md');
});
test('returns null when no items', async () => {
const { overlay } = mount();
overlay.show({ recent: [] });
await flush();
expect(overlay.getSelectedPath()).toBeNull();
});
});