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.
93 lines
4.8 KiB
TypeScript
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}'`);
|
|
}
|
|
});
|
|
});
|