import { act, type ReactElement, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Dialog, Field, SelectMenu } from "../src/index.js"; import { EKKLESIA_UI_STYLESHEET } from "../src/styles.js"; declare global { // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; } /** * SelectMenu (ADR 0065): a single choice drawn by Ekklesia — the WAI-ARIA select-only combobox — * rather than the browser's native menu. */ let container: HTMLDivElement; let root: Root; beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); }); const OPTIONS = [ { label: "Nothing yet", value: "" }, { label: "Signage", value: "signage" }, { label: "Bulletin", value: "bulletin" }, { label: "Parent Call", value: "parent-call" }, ]; function Harness(props: { readonly onChange?: (value: string) => void; readonly disabled?: boolean; }) { const [value, setValue] = useState(""); return ( <> {(control) => ( { setValue(next); props.onChange?.(next); }} options={OPTIONS} value={value} /> )} ); } function render(node: ReactElement): void { act(() => root.render(node)); } function trigger(): HTMLButtonElement { return container.querySelector('[data-testid="menu"]') as HTMLButtonElement; } function list(): HTMLElement { return container.querySelector('[role="listbox"]') as HTMLElement; } function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent { const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...init }); act(() => { trigger().dispatchEvent(event); }); return event; } function activeLabel(): string | undefined { const id = trigger().getAttribute("aria-activedescendant"); return id === null ? undefined : (container.querySelector(`#${CSS.escape(id)}`)?.textContent ?? ""); } describe("SelectMenu", () => { it("is a labelled combobox controlling a listbox of options, never a native select", () => { render(); expect(container.querySelector("select")).toBeNull(); const control = trigger(); expect(control.tagName).toBe("BUTTON"); // The shared control, so its border, height, and 16px phone size (no iOS zoom) are the field's. expect(control.classList.contains("ek-control")).toBe(true); expect(control.getAttribute("role")).toBe("combobox"); expect(control.getAttribute("aria-haspopup")).toBe("listbox"); expect(control.getAttribute("aria-expanded")).toBe("false"); expect(control.getAttribute("aria-controls")).toBe(list().id); // The Field's label names it. expect(container.querySelector("label")?.getAttribute("for")).toBe(control.id); expect(control.textContent).toBe("Nothing yet"); expect(list().hidden).toBe(true); const options = [...list().querySelectorAll('[role="option"]')]; expect(options.map((option) => option.textContent)).toEqual(OPTIONS.map((o) => o.label)); expect(options.map((option) => option.getAttribute("aria-selected"))).toEqual([ "true", "false", "false", "false", ]); }); it("opens, moves, and chooses from the keyboard", () => { const onChange = vi.fn(); render(); act(() => trigger().focus()); expect(press("ArrowDown").defaultPrevented).toBe(true); expect(trigger().getAttribute("aria-expanded")).toBe("true"); expect(list().hidden).toBe(false); // It opens on the current choice. expect(activeLabel()).toBe("Nothing yet"); press("ArrowDown"); press("ArrowDown"); expect(activeLabel()).toBe("Bulletin"); press("ArrowUp"); expect(activeLabel()).toBe("Signage"); press("End"); expect(activeLabel()).toBe("Parent Call"); press("Home"); expect(activeLabel()).toBe("Nothing yet"); // Typing a letter moves to the next choice that starts with it. press("b"); expect(activeLabel()).toBe("Bulletin"); press("Enter"); expect(onChange).toHaveBeenCalledWith("bulletin"); expect(trigger().getAttribute("aria-expanded")).toBe("false"); expect(trigger().textContent).toBe("Bulletin"); // Focus never left the control. expect(document.activeElement).toBe(trigger()); press(" "); expect(activeLabel()).toBe("Bulletin"); press("ArrowDown"); press(" "); expect(onChange).toHaveBeenLastCalledWith("parent-call"); }); it("closes on Escape without choosing, and without closing a dialog it sits in", () => { const onChange = vi.fn(); const onDismiss = vi.fn(); render( , ); act(() => trigger().focus()); press("ArrowDown"); press("ArrowDown"); const escapeKey = press("Escape"); expect(escapeKey.defaultPrevented).toBe(true); expect(trigger().getAttribute("aria-expanded")).toBe("false"); expect(onChange).not.toHaveBeenCalled(); expect(onDismiss).not.toHaveBeenCalled(); expect(document.activeElement).toBe(trigger()); // Closed, Escape is the dialog's again. press("Escape"); expect(onDismiss).toHaveBeenCalledTimes(1); }); it("chooses the active option on Tab, as a native select does", () => { const onChange = vi.fn(); render(); act(() => trigger().focus()); press("ArrowDown"); press("ArrowDown"); const tab = press("Tab"); expect(tab.defaultPrevented).toBe(false); expect(onChange).toHaveBeenCalledWith("signage"); expect(trigger().getAttribute("aria-expanded")).toBe("false"); }); it("works with a pointer: press to open, press an option to choose, press outside to close", () => { const onChange = vi.fn(); render(); act(() => trigger().click()); expect(trigger().getAttribute("aria-expanded")).toBe("true"); // Pressed, it takes focus (Safari would not), so the keyboard and Escape work from here. expect(document.activeElement).toBe(trigger()); const signage = [...list().querySelectorAll('[role="option"]')][1] as HTMLElement; act(() => signage.click()); expect(onChange).toHaveBeenCalledWith("signage"); expect(trigger().getAttribute("aria-expanded")).toBe("false"); act(() => trigger().click()); act(() => { (container.querySelector('[data-testid="elsewhere"]') as HTMLElement).dispatchEvent( new Event("pointerdown", { bubbles: true, composed: true }), ); }); expect(trigger().getAttribute("aria-expanded")).toBe("false"); expect(onChange).toHaveBeenCalledTimes(1); }); it("does nothing while disabled", () => { const onChange = vi.fn(); render(); expect(trigger().disabled).toBe(true); press("ArrowDown"); expect(trigger().getAttribute("aria-expanded")).toBe("false"); }); it("is drawn from tokens: a themed list, and a chevron inside the control's own padding", () => { expect(EKKLESIA_UI_STYLESHEET).toContain(".ek-select-menu__chevron {"); expect(EKKLESIA_UI_STYLESHEET).toMatch( /\.ek-select-menu__list \{[^}]*background: var\(--ekklesia-background-raised\)/u, ); expect(EKKLESIA_UI_STYLESHEET).toMatch( /\.ek-select-menu__option\[aria-selected="true"\] \{[^}]*var\(--ekklesia-state-selected-background\)/u, ); // The chevron's turn is a motion token, so reduced motion stills it. expect(EKKLESIA_UI_STYLESHEET).toMatch( /\.ek-select-menu__chevron \{[^}]*transition: transform var\(--ekklesia-motion-duration-short\)/u, ); }); });