fix(design): the keyboard ring of a flush row stays visible (E28)

A list item's ring sat 2 px outside it. On a table row it ran under the
sticky head and past the table's sides; on a Plex playlist show header, flush
in its clipping card, it was cut away and the focus was invisible. A table row
(by its tag), a log line and the Plex show header and episode rows now ring
inside themselves, the show header with its card's corners.

The sticky header's fade-below was drawn at rest, over the first row's top
16 px, hiding the ring's top edge: it now fades in with the first 1rem of
scroll (a scroll-driven animation; no fade where unsupported).
This commit is contained in:
2026-09-26 20:01:04 +02:00
parent 47c12f9405
commit 3e7c79fa69
7 changed files with 116 additions and 6 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ export default function LogLine({
return ( return (
<div <div
className="border-b border-border/50 last:border-b-0" className="border-b border-border/50 last:border-b-0"
{...navItemAttrs(navIndex, `${event} ${msg}`)} {...navItemAttrs(navIndex, `${event} ${msg}`, { inset: true })}
> >
<Pressable <Pressable
selectable selectable
@@ -265,6 +265,9 @@ describe("PlexPlaylistView · the keyboard", () => {
expect(document.activeElement).toBe(rowOf("The Show")); expect(document.activeElement).toBe(rowOf("The Show"));
pressKey("j"); pressKey("j");
expect(document.activeElement).toBe(rowOf("Ep e1")); expect(document.activeElement).toBe(rowOf("Ep e1"));
// Both sit flush in the show's clipping card: an outside ring was cut away (index.css).
expect(rowOf("The Show").hasAttribute("data-nav-inset")).toBe(true);
expect(rowOf("Ep e1").hasAttribute("data-nav-inset")).toBe(true);
pressKey("k"); pressKey("k");
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never); const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
pressKey("Delete"); pressKey("Delete");
+7 -3
View File
@@ -178,7 +178,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
{...ctx.trigger(target)} {...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(ep.id), ep.title)} {...navItemAttrs(ctx.navIndex(ep.id), ep.title, { inset: true })}
className={`group flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`} className={`group flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`}
> >
<DragHandle <DragHandle
@@ -292,8 +292,12 @@ function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
{/* Group header — drag moves the whole show; the chevron collapses; its "…" holds the rest. */} {/* Group header — drag moves the whole show; the chevron collapses; its "…" holds the rest. */}
<div <div
{...ctx.trigger(target)} {...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(block.id), block.title)} {...navItemAttrs(ctx.navIndex(block.id), block.title, { inset: true })}
className="group flex items-center gap-2.5 p-2" // The card's corners minus its 1 px edge, so the inside keyboard ring follows the card's
// curve instead of cutting across it; square at the bottom while the seasons hang below.
className={`group flex items-center gap-2.5 p-2 rounded-t-[calc(var(--radius-xl)-1px)] ${
open ? "" : "rounded-b-[calc(var(--radius-xl)-1px)]"
}`}
> >
<DragHandle <DragHandle
label={t("plex.playlist.reorderItem", { title: block.title })} label={t("plex.playlist.reorderItem", { title: block.title })}
+30
View File
@@ -144,6 +144,19 @@
/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content /* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content
dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */ dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */
/* A STICKY header's fade (a table head, the server log's toolbar), inside the scroller it sticks in.
It shows only once content has slid under it: at rest it lay over the first row's top 16 px,
dimming it and hiding the top edge of its keyboard ring (E28, seen live). It fades in over the
first 1rem of scroll by a scroll-driven animation; an engine without one draws no fade rather than
a fade over the resting first row. */
@keyframes fade-edge-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@utility fade-below { @utility fade-below {
&::after { &::after {
content: ""; content: "";
@@ -153,6 +166,14 @@
top: 100%; top: 100%;
height: 1rem; height: 1rem;
background: linear-gradient(to bottom, var(--bg), transparent); background: linear-gradient(to bottom, var(--bg), transparent);
opacity: 0;
}
@supports (animation-timeline: scroll()) {
&::after {
animation: fade-edge-in linear both;
animation-timeline: scroll(nearest block);
animation-range: 0 1rem;
}
} }
} }
@utility fade-below-panel { @utility fade-below-panel {
@@ -595,6 +616,15 @@ body {
[data-nav-index] [data-nav-open]:focus-visible { [data-nav-index] [data-nav-open]:focus-visible {
outline-color: transparent; outline-color: transparent;
} }
/* An item with no room round it rings inside its own box — outside, the ring ran under a sticky
table head and past the table's sides, or a clipping card cut it away and the focus vanished.
Every table row is such an item, by its tag; any other says so (`navItemAttrs(…, { inset: true })`:
a log line, a row flush in a clipping card). */
:is(tr, [data-nav-inset])[data-nav-index],
:is(tr, [data-nav-inset])[data-nav-index]:focus-visible,
:is(tr, [data-nav-inset])[data-nav-index]:has([data-nav-open]:focus-visible) {
outline-offset: -2px;
}
/* ===== R10 S4 · Selection-paint recipes ===== /* ===== R10 S4 · Selection-paint recipes =====
The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME
@@ -28,3 +28,24 @@ describe("the fade edge", () => {
expect(copies).toEqual([]); expect(copies).toEqual([]);
}); });
}); });
// A sticky header's fade shows only once content has slid under it. Drawn at rest it lay over the
// first row's top 16 px, dimming it and hiding the top edge of the row's keyboard ring (E28, seen
// live). jsdom runs no scroll timeline, so what is pinned is the recipe: resting opacity 0, and the
// scroll-driven fade-in over the first 1rem, only where the engine has scroll timelines.
describe("the sticky header's fade", () => {
const css = readFileSync(new URL("../index.css", import.meta.url), "utf-8");
const at = css.indexOf("@utility fade-below {");
const body = css.slice(at, css.indexOf("\n}\n", at));
it("rests invisible and fades in with the first 1rem of scroll", () => {
expect(at, "the utility").toBeGreaterThan(0);
expect(body).toMatch(/opacity:\s*0;/);
expect(body).toMatch(/@supports \(animation-timeline: scroll\(\)\)/);
expect(body).toMatch(/animation-timeline:\s*scroll\(nearest block\)/);
expect(body).toMatch(/animation-range:\s*0 1rem/);
expect(css).toMatch(
/@keyframes fade-edge-in \{\s*from \{\s*opacity: 0;\s*\}\s*to \{\s*opacity: 1;/,
);
});
});
+9 -2
View File
@@ -50,13 +50,20 @@ const REVEAL_FRAMES = 10;
/** Spread onto an item's ROOT element. `name` is what a screen reader calls the item while the keyboard /** Spread onto an item's ROOT element. `name` is what a screen reader calls the item while the keyboard
* is on it (its title) — pass it where the element Enter opens wraps the whole card (a Plex poster: * is on it (its title) — pass it where the element Enter opens wraps the whole card (a Plex poster:
* badges, year, rating and summary run together), shows a caption that is not the item's name, or * badges, year, rating and summary run together), shows a caption that is not the item's name, or
* holds no text at all (deep-review E11 round 3). */ * holds no text at all (deep-review E11 round 3).
*
* `inset` draws the keyboard ring INSIDE the item. The ring sits 2 px outside by default, which a
* card with a gap round it has room for; an item flush against its neighbours (a log line) or against
* the edge of a clipping card (a Plex playlist show header) does not: the ring ran past the list, or
* was clipped away entirely. A table row needs no flag: index.css insets every `tr` by its tag. */
export const navItemAttrs = ( export const navItemAttrs = (
index: number, index: number,
name?: string | null, name?: string | null,
): { "data-nav-index": number; "data-nav-name"?: string } => ({ { inset = false }: { inset?: boolean } = {},
): { "data-nav-index": number; "data-nav-name"?: string; "data-nav-inset"?: "" } => ({
[INDEX]: index, [INDEX]: index,
...(name ? { "data-nav-name": name } : null), ...(name ? { "data-nav-name": name } : null),
...(inset ? { "data-nav-inset": "" } : null),
}); });
const indexOf = (item: Element): number => Number(item.getAttribute(INDEX)); const indexOf = (item: Element): number => Number(item.getAttribute(INDEX));
+45
View File
@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { navItemAttrs } from "./listNav";
// The keyboard ring of a list item (`[data-nav-index]`) sits 2 px OUTSIDE the item: a card with a gap
// round it has room. A table row, a log line or a row flush in a clipping card has none — the ring ran
// under a sticky table head and past the table's sides, and in a Plex playlist show's card it was
// clipped away so the focus was invisible (E28, seen live). A `tr` is inset by its tag (a table
// hand-built outside DataTable was the one first reported); the others ask for `inset`. jsdom does no
// layout, so what is pinned is the attribute and the rule that draws it inside, with every selector the
// outside ring uses (a `:has()` one outranks a plain attribute pair).
const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
it("an inset item carries the marker, a plain one does not", () => {
expect(navItemAttrs(3, "x", { inset: true })).toEqual({
"data-nav-index": 3,
"data-nav-name": "x",
"data-nav-inset": "",
});
expect(navItemAttrs(3)).toEqual({ "data-nav-index": 3 });
});
it("index.css draws an inset item's ring inside, after the outside rule", () => {
const outside = css.indexOf("[data-nav-index]:has([data-nav-open]:focus-visible)");
const at = css.indexOf(":is(tr, [data-nav-inset])[data-nav-index],");
expect(outside, "the outside ring").toBeGreaterThan(0);
expect(at, "the inset rule comes later").toBeGreaterThan(outside);
const rule = css.slice(at, css.indexOf("}", at));
expect(rule).toContain(":is(tr, [data-nav-inset])[data-nav-index]:focus-visible");
expect(rule).toContain(
":is(tr, [data-nav-inset])[data-nav-index]:has([data-nav-open]:focus-visible)",
);
expect(rule).toMatch(/outline-offset:\s*-2px/);
});
// The items that sit flush, by their source: a new one of the kind should be added here.
const src = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8");
it.each([["../components/LogLine.tsx", /navItemAttrs\(navIndex, [^)]*\{ inset: true \}\)/]])(
"%s asks for the inside ring",
(file, re) => {
expect(src(file)).toMatch(re);
},
);