// @vitest-environment jsdom // // The rail's in-row rename, from the keyboard. Enter and Escape unmount the focused field and render // the row as a NEW button; without a refocus the keyboard user was dropped on and lost their // place (E10 review round 1, measured again in Chrome in E11 S2). Everything around the rail that is // not the rename path is stubbed. import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) }; }); vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ search: "", selectedId: 1 }), usePlaylistsActions: () => ({ setSelectedId: vi.fn() }), })); vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) })); vi.mock("./comfortPin", () => ({ useComfortPin: () => ({ item: () => [] }), pinPlaylist: (id: number) => id, })); const rename = vi.fn(async () => {}); vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ rename, remove: vi.fn() }), })); // The SidePanel/PanelGroups chrome is not what is under test: render the rail's bodies directly. vi.mock("./FilterRail", () => ({ default: ({ items }: { items: { id: string; body: ReactNode }[] }) => ( <> {items.map((i) => (
{i.body}
))} ), })); import PlaylistsRail from "./PlaylistsRail"; import { api, type Playlist } from "../lib/api"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; Element.prototype.scrollIntoView ??= () => {}; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); rename.mockClear(); }); const alpha = { id: 1, name: "alpha", kind: "custom", source: "local", item_count: 3, total_duration_seconds: 0, cover_thumbnail: null, dirty: false, } as unknown as Playlist; async function openRename() { vi.spyOn(api, "playlists").mockResolvedValue([alpha]); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( {}} collapsed={false} onToggleCollapse={() => {}} /> , ); const row = (await screen.findByText("alpha")).closest("button")!; act(() => row.focus()); fireEvent.keyDown(row, { key: "F10", shiftKey: true }); fireEvent.click(await screen.findByText("playlists.rename")); // The menu runs its action a task after it closes (see ui/ActionMenu). const field = await screen.findByRole("textbox", { name: "playlists.rename" }); await vi.waitFor(() => expect(document.activeElement).toBe(field)); return field as HTMLInputElement; } const rowButton = () => screen.getByText("alpha").closest("button"); describe("PlaylistsRail ยท rename from the keyboard", () => { it("Escape cancels and puts the focus back on the row", async () => { const field = await openRename(); fireEvent.change(field, { target: { value: "alpha2" } }); fireEvent.keyDown(field, { key: "Escape" }); await vi.waitFor(() => expect(document.activeElement).toBe(rowButton())); expect(rename).not.toHaveBeenCalled(); }); it("Enter commits and puts the focus back on the row", async () => { const field = await openRename(); fireEvent.change(field, { target: { value: "beta" } }); fireEvent.keyDown(field, { key: "Enter" }); expect(rename).toHaveBeenCalledWith(1, "beta"); await vi.waitFor(() => expect(document.activeElement).toBe(rowButton())); }); });