| 1 | import { strict as assert } from "node:assert"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | |
| 4 | const { window } = new JSDOM('<!doctype html><html><body><button id="trigger">Trigger</button><button id="outside">Outside</button><div id="root"></div></body></html>'); |
| 5 | Object.assign(globalThis, { |
| 6 | window, document: window.document, HTMLElement: window.HTMLElement, Node: window.Node, |
| 7 | IS_REACT_ACT_ENVIRONMENT: true, |
| 8 | }); |
| 9 | const { createElement, act, StrictMode } = await import("react"); |
| 10 | const { createRoot } = await import("react-dom/client"); |
| 11 | const { ContextMenu } = await import("../components/ContextMenu"); |
| 12 | const root = createRoot(document.getElementById("root")!); |
| 13 | const trigger = document.getElementById("trigger")!; |
| 14 | const outside = document.getElementById("outside")!; |
| 15 | const point = { left: 20, top: 20, keyboardTarget: trigger }; |
| 16 | let open = true; |
| 17 | let selected = ""; |
| 18 | const close = () => { open = false; render(); }; |
| 19 | const items = [ |
| 20 | { key: "disabled", label: "Disabled", disabled: true, onSelect() {} }, |
| 21 | { key: "first", label: "First", onSelect() { selected = "first"; close(); } }, |
| 22 | { type: "separator" as const, key: "separator" }, |
| 23 | { key: "last", label: "Last", onSelect() { selected = "last"; close(); } }, |
| 24 | ]; |
| 25 | function render(keyboard = true) { |
| 26 | root.render(createElement(StrictMode, null, createElement(ContextMenu, { |
| 27 | open, point: keyboard ? point : { left: 20, top: 20 }, items, onClose: close, |
| 28 | }))); |
| 29 | } |
| 30 | async function key(key: string) { |
| 31 | await act(async () => { |
| 32 | document.activeElement!.dispatchEvent(new window.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); |
| 33 | }); |
| 34 | } |
| 35 | trigger.focus(); |
| 36 | await act(async () => render()); |
| 37 | assert.equal(document.activeElement?.textContent, "First", "skip disabled item under StrictMode"); |
| 38 | for (const [pressed, expected] of [["ArrowDown", "Last"], ["ArrowDown", "First"], ["ArrowUp", "Last"], ["Home", "First"], ["End", "Last"]]) { |
| 39 | await key(pressed); |
| 40 | assert.equal(document.activeElement?.textContent, expected, pressed); |
| 41 | } |
| 42 | await act(async () => (document.activeElement as HTMLButtonElement).click()); |
| 43 | assert.equal(selected, "last"); |
| 44 | assert.equal(document.querySelector('[role="menu"]'), null); |
| 45 | assert.equal(document.activeElement, trigger, "selection restores focus"); |
| 46 | for (const pressed of ["Escape", "Tab"]) { |
| 47 | open = true; |
| 48 | await act(async () => render()); |
| 49 | await key(pressed); |
| 50 | assert.equal(document.activeElement, trigger, pressed); |
| 51 | assert.equal(document.querySelector('[role="menu"]'), null); |
| 52 | } |
| 53 | open = true; |
| 54 | await act(async () => render()); |
| 55 | await act(async () => { outside.focus(); close(); }); |
| 56 | assert.equal(document.activeElement, outside, "close does not steal externally moved focus"); |
| 57 | trigger.focus(); |
| 58 | open = true; |
| 59 | await act(async () => render(false)); |
| 60 | assert.equal(document.activeElement, trigger, "pointer menu preserves focus and selection"); |
| 61 | await act(async () => { open = false; render(false); }); |
| 62 | open = true; |
| 63 | await act(async () => render()); |
| 64 | await act(async () => { trigger.remove(); root.unmount(); }); |
| 65 | assert.equal(document.querySelector('[role="menu"]'), null, "unmount tolerates removed trigger"); |
| 66 | console.log("context-menu keyboard: all assertions passed"); |
| 67 |