A related video played in place is the player's item now: its title and its own watched / playlist / download controls, its progress checkpointed and resumed, marked watched at its end — and its end does not step the queue. Back returns to the opened video, where it was left. The related list: - its marks announce through the shared notice (the Notification Center finds them), join the host's player session (the feed refetches on close), and stay per write, so an earlier answer no longer undoes a later mark still on its way; - every host's state write patches the cached related lists, which are never refetched; - kept for the session (gcTime), a retry asks for the page that failed, a list read so far with nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names itself on hover. Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons are left out. Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the top layer; the help sheet lists the list keys over a layer that holds a list.
713 lines
24 KiB
TypeScript
713 lines
24 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// `lib/listNav` against real DOM: `j`/`k` from item to item and across lists in reading order, the
|
|
// arrows around a grid, Enter as a click on the item's own "open" element — and the places where the
|
|
// keys must stay out of the way (a field, an overlay, a control inside the card).
|
|
import { useRef, useState } from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { focusNavItem, navItemAttrs, useListNav } from "./listNav";
|
|
import { pushLayer, removeLayer } from "./layerStack";
|
|
|
|
afterEach(cleanup);
|
|
|
|
function List({
|
|
id,
|
|
count,
|
|
cols = 1,
|
|
mounted,
|
|
reveal,
|
|
measure,
|
|
onOpen = () => {},
|
|
}: {
|
|
id: string;
|
|
count: number;
|
|
cols?: number;
|
|
/** Mount only the first N items, the way a virtualized grid leaves the rest out of the DOM. */
|
|
mounted?: number;
|
|
reveal?: (index: number) => void;
|
|
/** Register without a count or columns — a rail or plain grid, measured from the DOM. */
|
|
measure?: boolean;
|
|
onOpen?: (id: string) => void;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useListNav(ref, measure ? { enabled: true } : { enabled: true, count, cols, reveal });
|
|
return (
|
|
<div ref={ref}>
|
|
{Array.from({ length: mounted ?? count }, (_, i) => (
|
|
<div key={i} {...navItemAttrs(i)} data-testid={`${id}${i}`}>
|
|
<a
|
|
href="#"
|
|
data-nav-open=""
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onOpen(`${id}${i}`);
|
|
}}
|
|
>
|
|
title
|
|
</a>
|
|
<button data-testid={`${id}${i}-action`}>action</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A keydown where a real one starts: on whatever holds focus, bubbling to the window listener. */
|
|
function press(key: string): KeyboardEvent {
|
|
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
|
act(() => {
|
|
(document.activeElement ?? document.body).dispatchEvent(e);
|
|
});
|
|
return e;
|
|
}
|
|
|
|
const focused = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
|
|
|
|
describe("useListNav", () => {
|
|
it("lands the first j on an item, then steps one item at a time", () => {
|
|
render(<List id="a" count={3} />);
|
|
press("j");
|
|
expect(focused()).toBe("a0");
|
|
press("j");
|
|
expect(focused()).toBe("a1");
|
|
press("k");
|
|
expect(focused()).toBe("a0");
|
|
});
|
|
|
|
// The Tab order is the reader's, not ours: only the item the keyboard is on is focusable, and the
|
|
// previous one gives its tabindex back.
|
|
it("roves a single tabindex=-1 and never adds a Tab stop", () => {
|
|
render(<List id="a" count={3} />);
|
|
expect(screen.getByTestId("a0").hasAttribute("tabindex")).toBe(false);
|
|
press("j");
|
|
expect(screen.getByTestId("a0").getAttribute("tabindex")).toBe("-1");
|
|
press("j");
|
|
expect(screen.getByTestId("a0").hasAttribute("tabindex")).toBe(false);
|
|
expect(screen.getByTestId("a1").getAttribute("tabindex")).toBe("-1");
|
|
});
|
|
|
|
// The player restores focus to whatever held it when it opened. If the item gave its
|
|
// tabindex back on blur, that restore would silently fail and focus would fall to <body>.
|
|
it("keeps the item focusable after focus leaves it, so a dialog can restore onto it", () => {
|
|
render(
|
|
<>
|
|
<List id="a" count={2} />
|
|
<button data-testid="outside">elsewhere</button>
|
|
</>,
|
|
);
|
|
press("j");
|
|
act(() => screen.getByTestId("outside").focus());
|
|
act(() => screen.getByTestId("a0").focus());
|
|
expect(focused()).toBe("a0");
|
|
});
|
|
|
|
it("stops at both ends of the page instead of wrapping", () => {
|
|
render(<List id="a" count={2} />);
|
|
press("j");
|
|
press("k");
|
|
expect(focused()).toBe("a0");
|
|
press("j");
|
|
press("j");
|
|
expect(focused()).toBe("a1");
|
|
});
|
|
|
|
// Owner decision: j/k walk every collection on the page in reading order.
|
|
it("continues into the next list, and back, in document order", () => {
|
|
render(
|
|
<>
|
|
<List id="a" count={2} />
|
|
<List id="b" count={2} />
|
|
</>,
|
|
);
|
|
press("j");
|
|
press("j");
|
|
press("j");
|
|
expect(focused()).toBe("b0");
|
|
press("k");
|
|
expect(focused()).toBe("a1");
|
|
});
|
|
|
|
it("moves around a grid with the arrows, row by row", () => {
|
|
// 3 columns, 7 items: rows [0 1 2] [3 4 5] [6]
|
|
render(<List id="a" count={7} cols={3} />);
|
|
press("j");
|
|
press("ArrowRight");
|
|
expect(focused()).toBe("a1");
|
|
press("ArrowDown");
|
|
expect(focused()).toBe("a4");
|
|
press("ArrowDown"); // the row below is short: land on its last item
|
|
expect(focused()).toBe("a6");
|
|
press("ArrowDown"); // already on the last row
|
|
expect(focused()).toBe("a6");
|
|
press("ArrowUp");
|
|
expect(focused()).toBe("a3");
|
|
press("ArrowLeft"); // left wraps to the end of the row above — reading order
|
|
expect(focused()).toBe("a2");
|
|
});
|
|
|
|
it("works the arrows from a control inside the card too", () => {
|
|
render(<List id="a" count={4} cols={2} />);
|
|
act(() => screen.getByTestId("a0-action").focus());
|
|
press("ArrowDown");
|
|
expect(focused()).toBe("a2");
|
|
});
|
|
|
|
// Outside a list an arrow scrolls the page; that native meaning must survive.
|
|
it("leaves the arrows alone when focus is not in a list", () => {
|
|
render(<List id="a" count={4} cols={2} />);
|
|
expect(press("ArrowDown").defaultPrevented).toBe(false);
|
|
expect(focused()).toBeUndefined();
|
|
});
|
|
|
|
it("opens the item on Enter by clicking its own open element", () => {
|
|
const onOpen = vi.fn();
|
|
render(<List id="a" count={2} onOpen={onOpen} />);
|
|
press("j");
|
|
press("j");
|
|
expect(press("Enter").defaultPrevented).toBe(true);
|
|
expect(onOpen).toHaveBeenCalledWith("a1");
|
|
});
|
|
|
|
it("leaves Enter to a control inside the card", () => {
|
|
const onOpen = vi.fn();
|
|
render(<List id="a" count={2} onOpen={onOpen} />);
|
|
act(() => screen.getByTestId("a0-action").focus());
|
|
expect(press("Enter").defaultPrevented).toBe(false);
|
|
expect(onOpen).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stays out of a text field", () => {
|
|
render(
|
|
<>
|
|
<input data-testid="field" />
|
|
<List id="a" count={2} />
|
|
</>,
|
|
);
|
|
act(() => screen.getByTestId("field").focus());
|
|
press("j");
|
|
expect(focused()).toBe("field");
|
|
});
|
|
|
|
it("is quiet under a layer that owns the keyboard, but not under a tooltip", () => {
|
|
render(<List id="a" count={2} />);
|
|
const modal = pushLayer();
|
|
press("j");
|
|
expect(focused()).toBeUndefined();
|
|
removeLayer(modal);
|
|
|
|
const tooltip = pushLayer({ passive: true });
|
|
press("j");
|
|
expect(focused()).toBe("a0");
|
|
removeLayer(tooltip);
|
|
});
|
|
|
|
// A list INSIDE the topmost layer (the player's related videos) is the one the user is in.
|
|
it("walks a list inside the topmost layer, and only inside it", () => {
|
|
render(
|
|
<>
|
|
<List id="a" count={2} />
|
|
<div data-testid="layer">
|
|
<List id="b" count={2} />
|
|
</div>
|
|
</>,
|
|
);
|
|
const dialog = pushLayer({ root: () => screen.getByTestId("layer") });
|
|
act(() => screen.getByTestId("b0-action").focus());
|
|
press("j");
|
|
expect(focused()).toBe("b1");
|
|
press("k");
|
|
press("k"); // its first row: the walk stops there, not on the page's list behind
|
|
expect(focused()).toBe("b0");
|
|
// A menu opened over it moves with its own keys: the list stays still.
|
|
const menu = pushLayer();
|
|
press("j");
|
|
expect(focused()).toBe("b0");
|
|
removeLayer(menu);
|
|
// Focus left behind on the page's list does not walk it while the layer is up.
|
|
act(() => screen.getByTestId("a0-action").focus());
|
|
press("j");
|
|
expect(focused()).toBe("a0-action");
|
|
removeLayer(dialog);
|
|
});
|
|
|
|
// A virtualized grid only mounts a screen of rows: the next item may not exist yet.
|
|
it("asks the list to reveal an unmounted item, then focuses it once it renders", async () => {
|
|
const reveal = vi.fn();
|
|
const { rerender } = render(<List id="a" count={5} mounted={2} reveal={reveal} />);
|
|
press("j");
|
|
press("j");
|
|
press("j");
|
|
expect(reveal).toHaveBeenCalledWith(2);
|
|
expect(focused()).toBe("a1");
|
|
rerender(<List id="a" count={5} reveal={reveal} />);
|
|
await waitFor(() => expect(focused()).toBe("a2"));
|
|
});
|
|
|
|
it("unbinds when the last list unmounts", () => {
|
|
const { unmount } = render(<List id="a" count={2} />);
|
|
unmount();
|
|
expect(press("j").defaultPrevented).toBe(false);
|
|
});
|
|
});
|
|
|
|
// jsdom lays nothing out, so the tests below give each item a box: `boxes` maps an item's test id
|
|
// (`a0`, `b3`) to where it sits. Everything else — the list containers — is a zero box at the origin.
|
|
function withLayout(
|
|
boxes: (id: string, n: number) => { left: number; top: number } | null,
|
|
run: () => void,
|
|
) {
|
|
const spy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
|
|
this: Element,
|
|
) {
|
|
const m = /^([a-z])(\d+)$/.exec((this as HTMLElement).dataset?.testid ?? "");
|
|
const at = m ? boxes(m[1]!, Number(m[2])) : null;
|
|
const { left, top } = at ?? { left: 0, top: 0 };
|
|
const size = at ? 90 : 0;
|
|
return {
|
|
left,
|
|
top,
|
|
right: left + size,
|
|
bottom: top + size,
|
|
width: size,
|
|
height: size,
|
|
x: left,
|
|
y: top,
|
|
} as DOMRect;
|
|
});
|
|
// Restored in `finally`: a spy left behind by a failing assertion would fail every later test.
|
|
try {
|
|
run();
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
|
|
describe("useListNav · measured lists (rails and plain grids)", () => {
|
|
it("reads the item count and the columns off the DOM when the list does not give them", () => {
|
|
// Three to a row: [0 1 2] [3 4 5] [6]
|
|
withLayout(
|
|
(_, n) => ({ left: (n % 3) * 100, top: Math.floor(n / 3) * 100 }),
|
|
() => {
|
|
render(<List id="a" count={7} measure />);
|
|
press("j");
|
|
press("ArrowDown");
|
|
expect(focused()).toBe("a3");
|
|
press("ArrowDown");
|
|
expect(focused()).toBe("a6");
|
|
press("ArrowRight"); // the last item: nothing past it
|
|
expect(focused()).toBe("a6");
|
|
},
|
|
);
|
|
});
|
|
|
|
// Owner decision: ↑↓ from a rail go to the rail above or below, not nowhere.
|
|
it("goes on from a list's bottom row to the next list's nearest item, and back up", () => {
|
|
withLayout(
|
|
// Two rails of four; the lower one sits 30px further right.
|
|
(list, n) => (list === "a" ? { left: n * 100, top: 0 } : { left: n * 100 + 30, top: 200 }),
|
|
() => {
|
|
render(
|
|
<>
|
|
<List id="a" count={4} measure />
|
|
<List id="b" count={4} measure />
|
|
</>,
|
|
);
|
|
press("j");
|
|
press("ArrowRight");
|
|
press("ArrowRight");
|
|
expect(focused()).toBe("a2");
|
|
press("ArrowDown");
|
|
expect(focused()).toBe("b2"); // centre 275 is nearest a2's 45+200
|
|
press("ArrowDown"); // the page's last list: stays
|
|
expect(focused()).toBe("b2");
|
|
press("ArrowUp");
|
|
expect(focused()).toBe("a2");
|
|
press("ArrowRight");
|
|
press("ArrowRight"); // ←→ never leave the rail
|
|
expect(focused()).toBe("a3");
|
|
},
|
|
);
|
|
});
|
|
|
|
it("lands the first j on the first card a rail shows, not one scrolled out to the left", () => {
|
|
withLayout(
|
|
(_, n) => ({ left: (n - 2) * 100, top: 0 }), // a0 and a1 sit left of the rail's edge
|
|
() => {
|
|
render(<List id="a" count={4} measure />);
|
|
press("j");
|
|
expect(focused()).toBe("a2");
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("useListNav · keys and lists it must leave alone", () => {
|
|
// DataTable renders its rows twice — a table and a card list, one of them display:none at any width.
|
|
it("skips a list that is not on screen", () => {
|
|
render(
|
|
<>
|
|
<List id="a" count={2} />
|
|
<List id="b" count={2} />
|
|
</>,
|
|
);
|
|
Object.defineProperty(screen.getByTestId("a0").parentElement!, "checkVisibility", {
|
|
value: () => false,
|
|
});
|
|
press("j");
|
|
expect(focused()).toBe("b0");
|
|
press("k");
|
|
expect(focused()).toBe("b0");
|
|
});
|
|
|
|
// A playlist row's drag handle moves the row with the arrows during a keyboard drag; dnd-kit handles
|
|
// the key on the document first and prevents its default. Moving focus as well would drop the drag.
|
|
it("leaves a key that something before it already handled", () => {
|
|
render(<List id="a" count={3} />);
|
|
press("j");
|
|
const handled = (e: KeyboardEvent) => e.preventDefault();
|
|
document.addEventListener("keydown", handled);
|
|
try {
|
|
press("ArrowDown");
|
|
press("j");
|
|
expect(focused()).toBe("a0");
|
|
} finally {
|
|
document.removeEventListener("keydown", handled);
|
|
}
|
|
press("ArrowDown");
|
|
expect(focused()).toBe("a1");
|
|
});
|
|
});
|
|
|
|
describe("useListNav · the roving tabindex and a removed item", () => {
|
|
// A tile that is a native <button> is a Tab stop already. A tabindex="-1" on it took it out of the
|
|
// Tab order until the next `j` moved elsewhere: Shift+Tab back to a genre tile skipped it.
|
|
it("leaves an item that is a Tab stop in its own right out of the tabindex it roves", () => {
|
|
function Tiles() {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useListNav(ref, { enabled: true });
|
|
return (
|
|
<div ref={ref}>
|
|
{[0, 1].map((i) => (
|
|
<button key={i} type="button" {...navItemAttrs(i)} data-testid={`t${i}`}>
|
|
tile
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
render(<Tiles />);
|
|
press("j");
|
|
expect(focused()).toBe("t0");
|
|
expect(screen.getByTestId("t0").hasAttribute("tabindex")).toBe(false);
|
|
press("j");
|
|
expect(focused()).toBe("t1");
|
|
expect(screen.getByTestId("t1").hasAttribute("tabindex")).toBe(false);
|
|
});
|
|
|
|
// Landing again on the item that already holds the roved tabindex (focus went to <body> in between)
|
|
// forgot it, so it kept tabindex="-1" for good — and a click on its blank area focused the card.
|
|
it("gives the tabindex back after landing twice on the same item", () => {
|
|
render(<List id="a" count={3} />);
|
|
press("j");
|
|
expect(focused()).toBe("a0");
|
|
act(() => (document.activeElement as HTMLElement).blur());
|
|
press("j");
|
|
expect(focused()).toBe("a0");
|
|
press("j");
|
|
expect(focused()).toBe("a1");
|
|
expect(screen.getByTestId("a0").hasAttribute("tabindex")).toBe(false);
|
|
});
|
|
|
|
// Hiding the focused feed card unmounts it and drops focus to <body>; the next `j` started over at
|
|
// the first visible card instead of going on from the hidden card's slot.
|
|
it("goes on from the slot of an item that was removed under the focus", () => {
|
|
function Removable() {
|
|
const [ids, setIds] = useState([0, 1, 2, 3]);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useListNav(ref, { enabled: true });
|
|
return (
|
|
<div ref={ref}>
|
|
{ids.map((id, i) => (
|
|
<div key={id} {...navItemAttrs(i)} data-testid={`r${id}`}>
|
|
<button
|
|
data-testid={`r${id}-hide`}
|
|
onClick={() => setIds((xs) => xs.filter((x) => x !== id))}
|
|
>
|
|
hide
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
withLayout(
|
|
(_, n) => ({ left: 0, top: n * 100 }),
|
|
() => {
|
|
render(<Removable />);
|
|
press("j");
|
|
press("j");
|
|
press("j");
|
|
expect(focused()).toBe("r2");
|
|
act(() => screen.getByTestId("r2-hide").focus());
|
|
fireEvent.click(screen.getByTestId("r2-hide"));
|
|
expect(document.activeElement).toBe(document.body);
|
|
press("j");
|
|
expect(focused()).toBe("r3");
|
|
},
|
|
);
|
|
});
|
|
|
|
// The same inside a layer (the player's related videos): a row marked from its menu leaves and the
|
|
// focus falls to <body>, where the layer's quiet rule left `j` doing nothing.
|
|
it("goes on from the slot of a removed item inside the topmost layer too", () => {
|
|
function Removable() {
|
|
const [ids, setIds] = useState([0, 1, 2, 3]);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useListNav(ref, { enabled: true });
|
|
return (
|
|
<div data-testid="layer">
|
|
<div ref={ref}>
|
|
{ids.map((id, i) => (
|
|
<div key={id} {...navItemAttrs(i)} data-testid={`r${id}`}>
|
|
<button
|
|
data-testid={`r${id}-hide`}
|
|
onClick={() => setIds((xs) => xs.filter((x) => x !== id))}
|
|
>
|
|
hide
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
withLayout(
|
|
(_, n) => ({ left: 0, top: n * 100 }),
|
|
() => {
|
|
render(<Removable />);
|
|
const layer = pushLayer({ root: () => screen.getByTestId("layer") });
|
|
try {
|
|
act(() => screen.getByTestId("r1-hide").focus());
|
|
press("j");
|
|
expect(focused()).toBe("r2");
|
|
act(() => screen.getByTestId("r2-hide").focus());
|
|
fireEvent.click(screen.getByTestId("r2-hide"));
|
|
expect(document.activeElement).toBe(document.body);
|
|
press("j");
|
|
expect(focused()).toBe("r3");
|
|
} finally {
|
|
removeLayer(layer);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("useListNav · leaving the list, and naming the item", () => {
|
|
function Swappable({ ids }: { ids: number[] }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useListNav(ref, { enabled: true });
|
|
return (
|
|
<div ref={ref}>
|
|
{ids.map((id, i) => (
|
|
<div key={id} {...navItemAttrs(i)} data-testid={`r${id}`}>
|
|
<button>act</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// `s`, a query, Escape: the feed stays mounted with new results and the old card is gone. The slot
|
|
// kept for a REMOVED card sent the next `j` to the third new result instead of the first.
|
|
it("starts from the first visible item after focus went on to a field and the items changed", () => {
|
|
withLayout(
|
|
(_, n) => ({ left: 0, top: n * 100 }),
|
|
() => {
|
|
const page = (ids: number[]) => (
|
|
<>
|
|
<input data-testid="field" />
|
|
<Swappable ids={ids} />
|
|
</>
|
|
);
|
|
const { rerender } = render(page([0, 1, 2, 3]));
|
|
press("j");
|
|
press("j");
|
|
press("j");
|
|
expect(focused()).toBe("r2");
|
|
act(() => screen.getByTestId("field").focus());
|
|
rerender(page([4, 5, 6, 7]));
|
|
act(() => screen.getByTestId("field").blur());
|
|
press("j");
|
|
expect(focused()).toBe("r4");
|
|
},
|
|
);
|
|
});
|
|
|
|
// A confirm (or the item's own menu) takes the focus before the delete it confirms: back from it, the
|
|
// next `j` still goes on from the deleted row's slot.
|
|
it("keeps the slot when the removal was confirmed in a dialog", () => {
|
|
withLayout(
|
|
(_, n) => ({ left: 0, top: n * 100 }),
|
|
() => {
|
|
const page = (ids: number[]) => (
|
|
<>
|
|
<div role="dialog">
|
|
<button data-testid="ok">ok</button>
|
|
</div>
|
|
<Swappable ids={ids} />
|
|
</>
|
|
);
|
|
const { rerender } = render(page([0, 1, 2, 3]));
|
|
press("j");
|
|
press("j");
|
|
press("j");
|
|
act(() => screen.getByTestId("ok").focus());
|
|
rerender(page([0, 1, 3]));
|
|
act(() => screen.getByTestId("ok").blur());
|
|
press("j");
|
|
expect(focused()).toBe("r3");
|
|
},
|
|
);
|
|
});
|
|
|
|
// A card root is a plain <div>: focused by `j` it had no role and no name, so a screen reader read
|
|
// an unlabelled run of text.
|
|
it("names a focused card after what it opens while it holds the focus, then gives the name back", () => {
|
|
render(<List id="a" count={2} />);
|
|
press("j");
|
|
const a0 = screen.getByTestId("a0");
|
|
expect(a0.getAttribute("role")).toBe("group");
|
|
expect(a0.getAttribute("aria-label")).toBe("title");
|
|
press("j");
|
|
expect(a0.hasAttribute("role")).toBe(false);
|
|
expect(a0.hasAttribute("aria-label")).toBe(false);
|
|
expect(screen.getByTestId("a1").getAttribute("aria-label")).toBe("title");
|
|
});
|
|
|
|
// A Plex poster's open element is the whole card — badges, title, year, rating and summary — and a
|
|
// season card's holds only the hover caption "Open show": named from that text, every season was
|
|
// "Open show". The item's own name wins.
|
|
it("names a card by the name its item gives, over the text of what it opens", () => {
|
|
function Posters() {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useListNav(ref, { enabled: true });
|
|
return (
|
|
<div ref={ref}>
|
|
<div {...navItemAttrs(0, "Season 1")} data-testid="p0">
|
|
<div role="button" tabIndex={0} data-nav-open="">
|
|
Open show
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
render(<Posters />);
|
|
press("j");
|
|
expect(screen.getByTestId("p0").getAttribute("aria-label")).toBe("Season 1");
|
|
});
|
|
|
|
// Hide a card, then Undo in the toast that put up: focus on the toast's button is not the reader
|
|
// leaving the list, and `j` goes on from the card put back.
|
|
it("keeps the slot when focus went to the Undo of a toast", () => {
|
|
withLayout(
|
|
(_, n) => ({ left: 0, top: n * 100 }),
|
|
() => {
|
|
const page = (ids: number[]) => (
|
|
<>
|
|
<div data-toast-region="">
|
|
<button data-testid="undo">undo</button>
|
|
</div>
|
|
<Swappable ids={ids} />
|
|
</>
|
|
);
|
|
const { rerender } = render(page([0, 1, 2, 3]));
|
|
press("j");
|
|
press("j");
|
|
press("j");
|
|
rerender(page([0, 1, 3]));
|
|
act(() => screen.getByTestId("undo").focus());
|
|
rerender(page([0, 1, 2, 3]));
|
|
act(() => screen.getByTestId("undo").blur());
|
|
press("j");
|
|
expect(focused()).toBe("r2");
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("useListNav · Delete (a playlist row's X became the Delete key)", () => {
|
|
function Deletable({ onDelete }: { onDelete?: boolean }) {
|
|
const [ids, setIds] = useState([0, 1, 2]);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useListNav(ref, {
|
|
enabled: true,
|
|
onDelete:
|
|
onDelete === false ? undefined : (i) => setIds((xs) => xs.filter((_, j) => j !== i)),
|
|
});
|
|
return (
|
|
<div ref={ref}>
|
|
{ids.map((id, i) => (
|
|
<div key={id} {...navItemAttrs(i)} data-testid={`d${id}`}>
|
|
<button data-testid={`d${id}-action`}>action</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
const gone = (id: string) => screen.queryByTestId(id) === null;
|
|
|
|
it("takes the focused item out and moves the focus to the one now in its slot", async () => {
|
|
render(<Deletable />);
|
|
press("j");
|
|
press("j");
|
|
expect(focused()).toBe("d1");
|
|
const e = press("Delete");
|
|
expect(e.defaultPrevented).toBe(true);
|
|
expect(gone("d1")).toBe(true);
|
|
await waitFor(() => expect(focused()).toBe("d2"));
|
|
// …so a second Delete takes the next row.
|
|
press("Delete");
|
|
expect(gone("d2")).toBe(true);
|
|
await waitFor(() => expect(focused()).toBe("d0"));
|
|
});
|
|
|
|
it("leaves Delete to a control inside the item", () => {
|
|
render(<Deletable />);
|
|
act(() => screen.getByTestId("d1-action").focus());
|
|
const e = press("Delete");
|
|
expect(e.defaultPrevented).toBe(false);
|
|
expect(gone("d1")).toBe(false);
|
|
});
|
|
|
|
it("does nothing in a list that cannot remove", () => {
|
|
render(<Deletable onDelete={false} />);
|
|
press("j");
|
|
const e = press("Delete");
|
|
expect(e.defaultPrevented).toBe(false);
|
|
expect(gone("d0")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("focusNavItem", () => {
|
|
it("hands the focus to the control `pick` finds in the item, else to the item", () => {
|
|
Element.prototype.scrollIntoView ??= () => {};
|
|
const { container } = render(<List id="a" count={3} />);
|
|
act(() => focusNavItem(container, 2, (item) => item.querySelector<HTMLElement>("button")));
|
|
expect(focused()).toBe("a2-action");
|
|
act(() => focusNavItem(container, 1));
|
|
expect(focused()).toBe("a1");
|
|
});
|
|
|
|
it("brings a virtualized item in first, then focuses its control", async () => {
|
|
Element.prototype.scrollIntoView ??= () => {};
|
|
const reveal = vi.fn();
|
|
const { container, rerender } = render(<List id="a" count={5} mounted={2} reveal={reveal} />);
|
|
act(() => focusNavItem(container, 4, (item) => item.querySelector<HTMLElement>("button")));
|
|
expect(reveal).toHaveBeenCalledWith(4);
|
|
rerender(<List id="a" count={5} reveal={reveal} />);
|
|
await waitFor(() => expect(focused()).toBe("a4-action"));
|
|
});
|
|
});
|