mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
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:
@@ -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 };
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user