fix(plex): address S2 round-2 review nits (typeahead highlight + tests)

- Reset `hi` on Escape and gate `activeOptionId` on `open`, so a dismissed
  panel never exposes an aria-activedescendant pointing at an unmounted option,
  and no stale highlight flashes on reopen (live-verified: aria-activedescendant
  is null when nothing is highlighted / the panel is closed).
- Strengthen the reopen test: setQ now writes back to the mock provider's q, so
  the test asserts a FRESH suggest query fires on retype (q="matrixx"), not just
  that a cached listbox re-appears.
- Add a combobox-ARIA test: role=combobox, aria-expanded toggles with the panel,
  and aria-controls associates the input with the listbox it opens.

Suite 171 green; typecheck + eslint clean.
This commit is contained in:
2026-08-16 08:34:56 +02:00
parent c51df4e0b9
commit 5134ba5433
2 changed files with 38 additions and 5 deletions
@@ -20,7 +20,12 @@ vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
// Controllable provider — PlexSearchBox reads q/scope/field and calls setQ/setField/setJumpTo.
const plexState = { scope: "both", field: "all", q: "" } as { scope: string; field: string; q: string };
const setQ = vi.fn();
// setQ writes back to plexState.q so a typed value flows through to `dq` on the next render (the real
// provider persists it) — lets the reopen test assert a fresh query fires on retype, not just that
// the panel re-appears from cache.
const setQ = vi.fn((v: string) => {
plexState.q = v;
});
const setField = vi.fn();
const setJumpTo = vi.fn();
vi.mock("./PlexProvider", () => ({
@@ -69,7 +74,7 @@ beforeEach(() => {
plexState.scope = "both";
plexState.field = "all";
plexState.q = "";
setQ.mockReset();
setQ.mockClear(); // clear calls but KEEP the write-back implementation
setField.mockReset();
setJumpTo.mockReset();
plexLibrary.mockReset();
@@ -161,9 +166,34 @@ describe("PlexSearchBox — typeahead", () => {
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
// Typing again must re-open it without a blur+refocus round-trip.
// Typing again must re-open it without a blur+refocus round-trip AND fire a fresh suggest query
// for the new term (not just re-show the cached list).
plexLibrary.mockClear();
fireEvent.change(input, { target: { value: "matrixx" } });
await screen.findByRole("listbox");
expect(screen.getByRole("listbox")).toBeTruthy();
await waitFor(() =>
expect(plexLibrary).toHaveBeenCalledWith(expect.objectContaining({ q: "matrixx" })),
);
});
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
renderBox();
const input = screen.getByRole("combobox");
// Collapsed until focused.
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(input.getAttribute("aria-controls")).toBeNull();
fireEvent.focus(input);
const list = await screen.findByRole("listbox");
// Expanded, and the input points at the listbox it controls.
expect(input.getAttribute("aria-expanded")).toBe("true");
expect(input.getAttribute("aria-controls")).toBe(list.id);
// Escape collapses it again and drops the association.
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(input.getAttribute("aria-expanded")).toBe("false"));
expect(input.getAttribute("aria-controls")).toBeNull();
});
});
+4 -1
View File
@@ -73,7 +73,9 @@ export default function PlexSearchBox() {
const fields = availablePlexFields(scope);
const pillLabel = t(plexFieldLabelKey(activeField));
const activeOptionId = hi >= 0 ? `${listId}-opt-${hi}` : undefined;
// Only reference an option while the listbox is actually open — a dangling aria-activedescendant
// on a collapsed combobox is an inconsistent a11y state.
const activeOptionId = open && hi >= 0 ? `${listId}-opt-${hi}` : undefined;
return (
<SearchBar
@@ -102,6 +104,7 @@ export default function PlexSearchBox() {
pick(hi);
} else if (e.key === "Escape") {
setDismissed(true);
setHi(-1); // don't leave a stale highlight to flash on the next reopen
}
}}
inputProps={{