fix(menus): one tick for every choice; a listbox holds its options only

- The saved views, the view switcher, the year filter and the table's single-choice filter mark the
  chosen row with MenuCheck, as DESIGN asks, not a trailing tick, a dot or a filled row; a contract
  test holds every checkable menu row to it. keepSelectedPaint had no caller left and is gone.
- A combobox's panel carries its listbox role on an inner PopoverList (listInside), so the failed
  load, Loading, No matches and the 'n more' line sit beside the options, not inside the listbox. The
  Plex search box's Loading line likewise.
- The Plex sidebar's playlist picker offers Create only once the lists have loaded: over a failed load
  it pushed the error and its Retry out at the first keystroke.
This commit is contained in:
2026-09-27 19:43:04 +02:00
parent cda626be10
commit e31a588f19
12 changed files with 266 additions and 116 deletions
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
//
// The sidebar's playlist picker (E28 review round 2, P7): typing a name offers "Create …" — but only
// once the lists have loaded. Over a failed load the create row pushed the error and its Retry out
// of the list at the first keystroke, and whether the name already existed was unknown anyway.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FloatingTree } from "@floating-ui/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: { name?: string }) => (o?.name != null ? `${key}:${o.name}` : key),
i18n: { language: "en" },
}),
};
});
vi.mock("./plexContext", () => ({
usePlex: () => ({}),
usePlexActions: () => ({ setPlaylistOpen: vi.fn() }),
}));
import { PlaylistPicker } from "./PlexSidebar";
import { api } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<PlaylistPicker />
</FloatingTree>
</QueryClientProvider>,
);
return screen.getByRole("combobox");
}
describe("the sidebar playlist picker", () => {
it("keeps the load error and its Retry while a name is typed, with no Create row", async () => {
vi.spyOn(api, "plexPlaylists").mockRejectedValue(new TypeError("Failed to fetch"));
const input = mount();
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "Road trip" } });
await waitFor(() => expect(screen.getByRole("alert", { hidden: true })).toBeTruthy());
expect(screen.getByText("common.retry")).toBeTruthy();
expect(screen.queryByText("plex.playlist.createNamed:Road trip")).toBeNull();
});
it("offers Create for a new name once the lists are in", async () => {
vi.spyOn(api, "plexPlaylists").mockResolvedValue({ playlists: [] } as never);
const input = mount();
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "Road trip" } });
expect(await screen.findByText("plex.playlist.createNamed:Road trip")).toBeTruthy();
});
});