| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import React, { act } from "react"; |
| 4 | |
| 5 | const dom = new JSDOM('<div id="root"></div><button id="outside">Outside</button>', { url: "http://localhost", pretendToBeVisual: true }); |
| 6 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, Node: dom.window.Node, |
| 7 | HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); |
| 8 | window.matchMedia = (() => ({ matches: true, addEventListener() {}, removeEventListener() {} })) as any; |
| 9 | dom.window.HTMLElement.prototype.scrollIntoView = function () {}; |
| 10 | const { createRoot } = await import("react-dom/client"); |
| 11 | const { SettingsSelect } = await import("../components/SettingsSelect"); |
| 12 | const root = createRoot(document.getElementById("root")!); |
| 13 | const changes: string[] = []; |
| 14 | const options = [ |
| 15 | { value: "one/shared", label: "shared", group: "one", groupLabel: "Connection one" }, |
| 16 | { value: "unsupported", label: "Unsupported", disabled: true }, |
| 17 | { value: "two/shared", label: "shared", group: "two", groupLabel: "Connection two" }, |
| 18 | ]; |
| 19 | async function render(search = false, disabled = false) { |
| 20 | await act(async () => root.render(<SettingsSelect aria-label="Model" name="model" value="one/shared" |
| 21 | options={options} disabled={disabled} searchPlaceholder={search ? "Search models" : undefined} |
| 22 | emptyLabel="No matches" onValueChange={value => changes.push(value)} />)); |
| 23 | } |
| 24 | const trigger = () => document.querySelector<HTMLButtonElement>(".settings-select")!; |
| 25 | const list = () => document.querySelector<HTMLElement>('[role="listbox"]')!; |
| 26 | async function key(node: HTMLElement, value: string) { |
| 27 | await act(async () => node.dispatchEvent(new dom.window.KeyboardEvent("keydown", {key: value, bubbles: true, cancelable: true}))); |
| 28 | } |
| 29 | await render(); |
| 30 | await key(trigger(), "ArrowDown"); |
| 31 | assert.equal(document.activeElement, list(), "plain selector opens with keyboard focus in its list"); |
| 32 | await key(list(), "ArrowDown"); |
| 33 | assert.equal(document.getElementById(list().getAttribute("aria-activedescendant")!)?.dataset.value, "two/shared", "keyboard navigation skips disabled options"); |
| 34 | await key(list(), "Enter"); |
| 35 | assert.deepEqual(changes, ["two/shared"], "identical labels preserve connection identity"); |
| 36 | assert.equal(document.activeElement, trigger(), "selection returns focus to the trigger"); |
| 37 | await act(async () => trigger().click()); |
| 38 | await key(list(), "Escape"); |
| 39 | assert.equal(trigger().getAttribute("aria-expanded"), "false"); |
| 40 | assert.equal(changes.length, 1, "escape does not save"); |
| 41 | await act(async () => trigger().click()); |
| 42 | await act(async () => document.getElementById("outside")!.click()); |
| 43 | assert.equal(trigger().getAttribute("aria-expanded"), "false", "outside click closes menu"); |
| 44 | await render(true); |
| 45 | await act(async () => trigger().click()); |
| 46 | const search = document.querySelector<HTMLInputElement>('[role="combobox"]')!; |
| 47 | async function searchFor(value: string) { |
| 48 | await act(async () => { |
| 49 | Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(search, value); |
| 50 | search.dispatchEvent(new dom.window.Event("input", { bubbles: true })); |
| 51 | }); |
| 52 | } |
| 53 | const beforeComposition = changes.length; |
| 54 | for (const key of ["ArrowDown", "Enter", "Escape"]) { |
| 55 | const event = new dom.window.KeyboardEvent("keydown", { key, isComposing: true, bubbles: true, cancelable: true }); |
| 56 | await act(async () => { search.dispatchEvent(event); }); |
| 57 | assert.equal(event.defaultPrevented, false, "IME owns candidate keys"); |
| 58 | } |
| 59 | assert.equal(trigger().getAttribute("aria-expanded"), "true", "IME confirmation/cancellation keeps the dropdown open"); |
| 60 | assert.equal(document.getElementById(search.getAttribute("aria-activedescendant")!)?.dataset.value, "one/shared", "IME arrows do not change the active option"); |
| 61 | await key(search, "ArrowDown"); |
| 62 | await act(async () => { search.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Enter", keyCode: 229, bubbles: true, cancelable: true })); }); |
| 63 | assert.equal(changes.length, beforeComposition, "WebKit IME confirmation does not select a model"); |
| 64 | assert.equal(trigger().getAttribute("aria-expanded"), "true", "WebKit confirmation keeps the dropdown open"); |
| 65 | await searchFor("Connection two"); |
| 66 | assert.equal(document.querySelectorAll('[role="option"]').length, 1, "search matches connection labels"); |
| 67 | await key(search, "Enter"); |
| 68 | assert.equal(changes.at(-1), "two/shared"); |
| 69 | await act(async () => trigger().click()); |
| 70 | assert.equal(document.querySelector<HTMLInputElement>('[role="combobox"]')!.value, "", "reopening resets search"); |
| 71 | await key(document.querySelector<HTMLElement>('[role="combobox"]')!, "Escape"); |
| 72 | await render(false, true); |
| 73 | await act(async () => trigger().click()); |
| 74 | assert.equal(trigger().getAttribute("aria-expanded"), "false", "disabled selector cannot open"); |
| 75 | assert.equal(document.querySelector<HTMLInputElement>('input[name="model"]')!.disabled, true); |
| 76 | await act(async () => root.unmount()); |
| 77 | console.log("PASS shared settings select: keyboard, disabled, search, connection identity, dismiss and focus"); |
| 78 |