storebound/apps/desktop/tests/context-menu.test.ts
CRT dece83c3e8 Desktop: custom context menus everywhere, never the browser's menu
One ContextMenu in the design system (keyboard, type-ahead, submenus, check items,
disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The
native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all.
Menus on sidebar and portfolio apps, app hero, releases, store art, live art,
reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
2026-10-23 08:59:14 +08:00

93 lines
4.8 KiB
TypeScript

// Context menus: the entry model, keyboard moves, type-ahead and placement (ui/menu-logic.ts), and the rules that keep
// the browser's own menu out of the window (ui/ContextMenu.tsx, main.tsx, the Windows webview settings).
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { first, isEditable, last, type MenuEntry, move, placeAt, placeBeside, tidy, typeahead } from '../src/ui/menu-logic';
const ROOT = join(import.meta.dir, '..');
const src = (f: string) => readFileSync(join(ROOT, f), 'utf8');
const item = (label: string, extra: object = {}) => ({ label, ...extra });
describe('menu entries', () => {
test('tidy drops falsy entries, empty submenus and stray separators', () => {
const out = tidy(['sep', item('Open'), false, null, 'sep', 'sep', item('More', { submenu: [false, 'sep'] }), item('Copy'), 'sep', undefined, '']);
expect(out.map(e => (e === 'sep' ? '-' : e.label))).toEqual(['Open', '-', 'Copy']);
});
test('tidy keeps a submenu with entries, tidied', () => {
const [m] = tidy([item('Copy', { submenu: ['sep', item('Name'), null, 'sep'] })]);
expect(m !== 'sep' && (m!.submenu as MenuEntry[]).map(e => (e === 'sep' ? '-' : e.label))).toEqual(['Name']);
});
});
describe('keyboard', () => {
const items: MenuEntry[] = [item('Open'), 'sep', item('Copy', { disabled: 'nothing selected' }), item('Cut'), item('Paste'), item('Remove', { danger: true })];
test('arrows skip separators and disabled items and wrap', () => {
expect(first(items)).toBe(0);
expect(last(items)).toBe(5);
expect(move(items, 0, 1)).toBe(3);
expect(move(items, 3, -1)).toBe(0);
expect(move(items, 5, 1)).toBe(0);
expect(move(items, 0, -1)).toBe(5);
expect(move([], -1, 1)).toBe(-1);
expect(move(['sep', item('x', { disabled: true })], -1, 1)).toBe(-1);
});
test('type-ahead finds the next enabled match; a repeated letter cycles', () => {
expect(typeahead(items, -1, 'p')).toBe(4);
expect(typeahead(items, -1, 'c')).toBe(3); // Copy is disabled: Cut
expect(typeahead(items, 3, 'c')).toBe(3);
expect(typeahead(items, 0, 'o')).toBe(0);
expect(typeahead(items, 3, 'cu')).toBe(3);
expect(typeahead(items, 0, 'zz')).toBe(-1);
});
});
describe('placement', () => {
const view = { w: 1000, h: 700 }, box = { w: 220, h: 300 };
test('opens right of and below the cursor', () => expect(placeAt(100, 100, box, view)).toEqual({ left: 100, top: 100 }));
test('flips left and up at the edges, and stays inside', () => {
expect(placeAt(900, 600, box, view)).toEqual({ left: 680, top: 300 });
expect(placeAt(5, 5, { w: 2000, h: 2000 }, view)).toEqual({ left: 8, top: 8 });
});
test('submenus open beside the item, or on the other side near the edge', () => {
expect(placeBeside({ left: 100, right: 320, top: 50 }, box, view)).toEqual({ left: 316, top: 45 });
expect(placeBeside({ left: 700, right: 920, top: 600 }, box, view)).toEqual({ left: 484, top: 392 });
});
});
test('text fields get the edit menu', () => {
expect(isEditable({ tagName: 'INPUT', type: 'text' })).toBe(true);
expect(isEditable({ tagName: 'INPUT', type: 'checkbox' })).toBe(false);
expect(isEditable({ tagName: 'TEXTAREA' })).toBe(true);
expect(isEditable({ tagName: 'DIV', isContentEditable: true })).toBe(true);
expect(isEditable({ tagName: 'BUTTON' })).toBe(false);
expect(isEditable(null)).toBe(false);
});
describe('no browser menu', () => {
test('the provider prevents every native contextmenu and handles Shift+F10 / the Menu key', () => {
const s = src('src/ui/ContextMenu.tsx');
expect(s).toMatch(/document\.addEventListener\('contextmenu', onMenu\)/);
// the fallback handler prevents the default before anything else
expect(s).toMatch(/const onMenu = \(e: MouseEvent\) => \{\s*e\.preventDefault\(\);/);
expect(s).toMatch(/e\.key === 'F10' && e\.shiftKey/);
expect(s).toMatch(/e\.key === 'ContextMenu'/);
expect(s).toMatch(/role="menu"/);
expect(s).toMatch(/menuitemcheckbox/);
});
test('main.tsx wraps the app in the provider', () => {
expect(src('src/main.tsx')).toMatch(/<ContextMenuProvider><App /);
});
test('release builds on Windows turn off the WebView2 menu and browser accelerator keys', () => {
const rs = src('src-tauri/src/lib.rs');
expect(rs).toMatch(/#\[cfg\(all\(windows, not\(debug_assertions\)\)\)\]\s*fn harden_webview/);
expect(rs).toMatch(/SetAreDefaultContextMenusEnabled\(false\)/);
expect(rs).toMatch(/SetAreBrowserAcceleratorKeysEnabled\(false\)/);
});
test('menus use the catalog in every language', () => {
for (const k of ['cm.cut', 'cm.copy', 'cm.paste', 'cm.selectAll']) {
expect(src('src/i18n/en.ts')).toContain(`'${k}'`);
expect(src('src/i18n/tr.ts')).toContain(`'${k}'`);
}
});
});