mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +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 @@
|
||||
/**
|
||||
* @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