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,
);
});
});