fix(player): the shut panel keeps the dialog's name; Tab onto a cut link opens the text
With the side panel shut the title heading was gone, and the dialog it named went unnamed: the strip carries an sr-only copy. A link below a clamp's cut stayed tabbable out of sight; reaching it now opens the text, measured from the text's top since focus scrolls the clamped box. DESIGN describes the modal as built, with log rows for its chrome and the wheel rule.
This commit is contained in:
@@ -629,8 +629,11 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 2.75rem)");
|
||||
// The strip keeps the way out and the stepping.
|
||||
expect(within(strip).getByTestId("player-close")).toBeTruthy();
|
||||
// The dialog keeps its name with the title out of sight: it is named by that heading's id.
|
||||
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
|
||||
fireEvent.click(within(strip).getByRole("button", { name: "player.next" }));
|
||||
expect(screen.getByTestId("player-strip")).toBeTruthy(); // still shut on the next video
|
||||
expect(screen.getByRole("dialog", { name: "Video B" })).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("player-strip")).getByRole("button", {
|
||||
name: "player.expandPanel",
|
||||
|
||||
@@ -324,6 +324,8 @@ export default function PlayerModal({
|
||||
const navigated = currentVideoId !== active.id;
|
||||
// Title/author of a navigated-to video, read from the player (free, no API call).
|
||||
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
|
||||
// What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout.
|
||||
const heading = navigated ? (liveData?.title ?? t("player.loading")) : active.title;
|
||||
// IFrame Player API error code, when the current video can't play in the embed (e.g. an
|
||||
// auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
|
||||
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
|
||||
@@ -919,6 +921,10 @@ export default function PlayerModal({
|
||||
data-testid="player-strip"
|
||||
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
|
||||
>
|
||||
{/* The dialog is named by this heading's id: the panel's own one is not rendered. */}
|
||||
<h2 id={titleId} className="sr-only">
|
||||
{heading}
|
||||
</h2>
|
||||
{closeButton}
|
||||
<IconButton
|
||||
ref={panelToggleRef}
|
||||
@@ -995,7 +1001,7 @@ export default function PlayerModal({
|
||||
right (wide, it heads the panel). */}
|
||||
<div className="flex items-start gap-3">
|
||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
||||
{heading}
|
||||
</h2>
|
||||
{!wide && (
|
||||
<IconButton
|
||||
|
||||
@@ -45,6 +45,34 @@ describe("ClampedText", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("opens when focus reaches a link the cut hides, and not for one in sight", () => {
|
||||
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
||||
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
|
||||
try {
|
||||
render(
|
||||
<ClampedText text="t" lines={3} moreLabel="More" lessLabel="Less">
|
||||
<a href="#a">seen</a>
|
||||
<a href="#b">cut</a>
|
||||
</ClampedText>,
|
||||
);
|
||||
const clamp = screen.getByText("seen").parentElement!;
|
||||
vi.spyOn(clamp, "getBoundingClientRect").mockReturnValue(rect(100, 160));
|
||||
vi.spyOn(screen.getByText("seen"), "getBoundingClientRect").mockReturnValue(rect(100, 120));
|
||||
act(() => screen.getByText("seen").focus());
|
||||
expect(screen.getByText("More")).toBeTruthy();
|
||||
// A Tab onto a link below the cut. The browser scrolls the clamped box to show it (measured in
|
||||
// Chrome: 45 px), so on screen the link sits inside the box: only the text's own top tells.
|
||||
clamp.scrollTop = 240;
|
||||
vi.spyOn(screen.getByText("cut"), "getBoundingClientRect").mockReturnValue(rect(130, 150));
|
||||
act(() => screen.getByText("cut").focus());
|
||||
expect(screen.getByText("Less").getAttribute("aria-expanded")).toBe("true");
|
||||
} finally {
|
||||
tall.mockRestore();
|
||||
box.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts another text collapsed: a reused view opening the next item", () => {
|
||||
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type FocusEvent, type ReactNode } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// Static class names, so Tailwind sees them.
|
||||
@@ -9,7 +9,9 @@ const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as con
|
||||
* resize: the window and the text-size slider both move where the cut falls. Another text starts
|
||||
* collapsed: a view reused for the next item (a show page opening another show) must not open its
|
||||
* text already expanded. `children` draws the text in a richer form (a description's links and
|
||||
* timestamps); `text` still names it, so another text is still another text. */
|
||||
* timestamps); `text` still names it, so another text is still another text. A link the cut hides
|
||||
* stays in the tab order (it is still text), so reaching it opens the text: focus never sits on
|
||||
* something out of sight. */
|
||||
export function ClampedText({
|
||||
text,
|
||||
lines,
|
||||
@@ -45,9 +47,21 @@ export function ClampedText({
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [text, expanded]);
|
||||
// Measured from the text's top, not against the box on screen: focus scrolls the clamped box itself
|
||||
// to bring the link into view (overflow:hidden still scrolls), so on screen it looked "in sight".
|
||||
const revealFocused = (e: FocusEvent<HTMLDivElement>) => {
|
||||
const box = e.currentTarget;
|
||||
if (expanded || e.target === box) return;
|
||||
const top = box.getBoundingClientRect().top - box.scrollTop;
|
||||
if (e.target.getBoundingClientRect().bottom - top > box.clientHeight + 1) setExpanded(true);
|
||||
};
|
||||
return (
|
||||
<div className={className}>
|
||||
<div ref={ref} className={`${textClassName} ${expanded ? "" : CLAMP[lines]}`}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${textClassName} ${expanded ? "" : CLAMP[lines]}`}
|
||||
onFocus={revealFocused}
|
||||
>
|
||||
{children ?? text}
|
||||
</div>
|
||||
{(clipped || expanded) && (
|
||||
|
||||
Reference in New Issue
Block a user