diff --git a/frontend/src/components/PlexSearchBox.dom.test.tsx b/frontend/src/components/PlexSearchBox.dom.test.tsx index 82c0df6f..4751a63f 100644 --- a/frontend/src/components/PlexSearchBox.dom.test.tsx +++ b/frontend/src/components/PlexSearchBox.dom.test.tsx @@ -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(); }); }); diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx index 9529993f..69311b42 100644 --- a/frontend/src/components/PlexSearchBox.tsx +++ b/frontend/src/components/PlexSearchBox.tsx @@ -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 (