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:
@@ -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");
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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;/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user