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.
This commit is contained in:
parent
944b24fe59
commit
dece83c3e8
23 changed files with 839 additions and 28 deletions
93
apps/desktop/tests/context-menu.test.ts
Normal file
93
apps/desktop/tests/context-menu.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// 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}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue