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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user