feat(a11y): touch-visible card controls + modal focus trap/restore (E4 S6)

UX-7 — hover-only card actions were `opacity-0 group-hover:opacity-100`: fine on
a mouse, but invisible AND still tappable on a touch device (no hover). New
`.reveal` primitive (index.css) gates the hide on `@media (hover: hover)`, so a
touch device keeps the control visible; its partner `.reveal-idle` is the idle
badge a control replaces (watched/in-progress pill under the watched-toggle),
faded on hover and dropped on touch so the two don't collide at the same corner.
Migrated the true controls — feed card action toolbar (VideoCard), Plex poster
card watched-toggle/info/add across all card variants + their swap badges
(plexCards), message pop-out (Messages), edit-interval pencil (Scheduler), and
saved-view star/rename/delete (SavedViewsWidget). Pure play-scrim overlays (the
whole card is the tap target) stay hover-only by design.

FE-9 — Modal.tsx had aria-modal but no focus management. Adds: a Tab/Shift+Tab
focus trap (topmost layer only, wraps at the edges and pulls escaped focus back);
initial focus into the dialog unless an autoFocus child already placed it; focus
restore to the trigger on close (captured in a useState initializer during render,
before commit runs autoFocus and moves activeElement inside); and aria-labelledby
wiring the dialog to its title.

Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): FE-9 trap
both directions + pull-back + Escape-restore-to-trigger + aria-labelledby verified;
UX-7 feed toolbar opacity 0 on desktop / 1 under mobile (hover:none) emulation,
Plex reveal buttons opacity 1 + reveal-idle badges display:none on touch.
This commit is contained in:
2026-08-19 21:35:52 +02:00
parent aea99cc71a
commit 94a608053a
7 changed files with 124 additions and 21 deletions
+1 -1
View File
@@ -189,7 +189,7 @@ function ConversationList({
onClick={() => openChat(c.partner)} onClick={() => openChat(c.partner)}
title={t("messages.popOut")} title={t("messages.popOut")}
aria-label={t("messages.popOut")} aria-label={t("messages.popOut")}
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition" className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</button> </button>
+62 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { X } from "lucide-react"; import { X } from "lucide-react";
@@ -6,6 +6,14 @@ import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack"; import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen"; import { useFullscreenElement } from "../lib/fullscreen";
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
// collapsed disclosure) so the trap never lands focus on something the user can't see.
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the // Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor // shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
// dismisses just the error and returns to the editor underneath, and a player below defers to it // dismisses just the error and returns to the editor underneath, and a player below defers to it
@@ -27,6 +35,13 @@ export default function Modal({
// Browser/mouse Back closes the topmost modal instead of navigating away. // Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose); useBackToClose(onClose);
const fsRoot = useFullscreenElement(); const fsRoot = useFullscreenElement();
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
// dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
// it later (in the effect) would capture a now-inside element that unmounts on close, stranding
// focus on <body>. useState initializer runs exactly once, on first render.
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a // Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on // drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
@@ -39,20 +54,55 @@ export default function Modal({
useEffect(() => { useEffect(() => {
const id = pushLayer(); const id = pushLayer();
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isTopmost(id)) { if (!isTopmost(id)) return;
if (e.key === "Escape") {
e.stopPropagation(); e.stopPropagation();
onCloseRef.current(); onCloseRef.current();
return;
}
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
// page behind the backdrop. Only the topmost modal traps (a nested one defers to its child).
if (e.key === "Tab") {
const panel = panelRef.current;
if (!panel) return;
const items = focusablesIn(panel);
const first = items[0];
const last = items[items.length - 1];
if (!first || !last) {
e.preventDefault();
return;
}
const active = document.activeElement;
if (!panel.contains(active)) {
// Focus escaped the dialog (or sits on the panel itself) — pull it back to an edge.
e.preventDefault();
(e.shiftKey ? last : first).focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
} }
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow; const prev = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
// focus to the first control so the dialog opens focused, not stranded on the background.
const panel = panelRef.current;
if (panel && !panel.contains(document.activeElement)) {
(focusablesIn(panel)[0] ?? panel).focus();
}
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
removeLayer(id); removeLayer(id);
document.body.style.overflow = prev; document.body.style.overflow = prev;
// Restore focus to the trigger, if it's still in the document and focusable.
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
}; };
}, []); }, [restoreTo]);
return createPortal( return createPortal(
<div <div
@@ -65,10 +115,17 @@ export default function Modal({
}} }}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId}
> >
<div className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl`}> <div
ref={panelRef}
tabIndex={-1}
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3"> <div className="flex items-start justify-between gap-3 p-5 pb-3">
<h2 className="text-lg font-semibold leading-snug">{title}</h2> <h2 id={titleId} className="text-lg font-semibold leading-snug">
{title}
</h2>
<button <button
onClick={onClose} onClick={onClose}
title={t("common.close")} title={t("common.close")}
+3 -3
View File
@@ -321,7 +321,7 @@ function ViewRow({
className={`p-1 rounded transition ${ className={`p-1 rounded transition ${
view.is_default view.is_default
? "text-accent" ? "text-accent"
: "text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-accent" : "text-muted reveal hover:text-accent"
}`} }`}
> >
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} /> <Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
@@ -329,14 +329,14 @@ function ViewRow({
<button <button
onClick={onStartRename} onClick={onStartRename}
title={t("views.rename")} title={t("views.rename")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition" className="p-1 rounded-sm text-muted reveal hover:text-fg transition"
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={onDelete} onClick={onDelete}
title={t("views.delete")} title={t("views.delete")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-danger transition" className="p-1 rounded-sm text-muted reveal hover:text-danger transition"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
+1 -1
View File
@@ -198,7 +198,7 @@ function JobRow({
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition" className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
> >
· {t("scheduler.everyMin", { count: job.interval_minutes })} · {t("scheduler.everyMin", { count: job.interval_minutes })}
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 transition" /> <Pencil className="w-3 h-3 reveal transition" />
</button> </button>
</Tooltip> </Tooltip>
)} )}
+1 -1
View File
@@ -62,7 +62,7 @@ function Actions({
// rather than silently rendering a half-clipped control outside the card. // rather than silently rendering a half-clipped control outside the card.
<div <div
className={clsx( className={clsx(
"flex flex-wrap opacity-0 group-hover:opacity-100 transition", "flex flex-wrap reveal transition",
dense ? "gap-0.5" : "gap-1", dense ? "gap-0.5" : "gap-1",
)} )}
> >
+24 -10
View File
@@ -80,7 +80,7 @@ export function PlexPosterCard({
onToggleWatched(card); onToggleWatched(card);
}} }}
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")} title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition" className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
> >
<CheckCircle2 <CheckCircle2
className={`w-4 h-4 ${card.status === "watched" ? "text-success" : "text-white"}`} className={`w-4 h-4 ${card.status === "watched" ? "text-success" : "text-white"}`}
@@ -95,14 +95,20 @@ export function PlexPosterCard({
onInfo(); onInfo();
}} }}
title={t("plex.info.title")} title={t("plex.info.title")}
className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition" className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
> >
<Info className="w-4 h-4 text-white" /> <Info className="w-4 h-4 text-white" />
</button> </button>
)} )}
{/* Watched badge when not hovering (the toggle replaces it on hover). */} {/* Watched badge when not hovering (the toggle replaces it on hover). Only a playable card
has that toggle, so only there does the badge yield to it (reveal-idle: gone on touch,
where the toggle is always shown); a watched show keeps the badge as its sole marker. */}
{card.status === "watched" && ( {card.status === "watched" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0"> <span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${
isPlayable ? "reveal-idle" : "group-hover:opacity-0"
}`}
>
{t("plex.watched")} {t("plex.watched")}
</span> </span>
)} )}
@@ -203,7 +209,7 @@ export function SeasonCard({
title={ title={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched") watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
} }
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition" className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
> >
<CheckCircle2 className={`w-4 h-4 ${watched ? "text-success" : "text-white"}`} /> <CheckCircle2 className={`w-4 h-4 ${watched ? "text-success" : "text-white"}`} />
</button> </button>
@@ -216,18 +222,26 @@ export function SeasonCard({
onAdd(); onAdd();
}} }}
title={t("plex.playlist.addSeason")} title={t("plex.playlist.addSeason")}
className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition" className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
> >
<ListPlus className="w-4 h-4 text-white" /> <ListPlus className="w-4 h-4 text-white" />
</button> </button>
)} )}
{watched && ( {watched && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0"> <span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${
onToggleWatched ? "reveal-idle" : "group-hover:opacity-0"
}`}
>
{t("plex.watched")} {t("plex.watched")}
</span> </span>
)} )}
{se.status === "in_progress" && ( {se.status === "in_progress" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0"> <span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm ${
onToggleWatched ? "reveal-idle" : "group-hover:opacity-0"
}`}
>
{t("plex.inProgress")} {t("plex.inProgress")}
</span> </span>
)} )}
@@ -406,7 +420,7 @@ export function EpisodeCard({
onToggleWatched(); onToggleWatched();
}} }}
title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")} title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition" className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
> >
<CheckCircle2 <CheckCircle2
className={`w-4 h-4 ${ep.status === "watched" ? "text-success" : "text-white"}`} className={`w-4 h-4 ${ep.status === "watched" ? "text-success" : "text-white"}`}
@@ -416,7 +430,7 @@ export function EpisodeCard({
{ep.status === "watched" && ( {ep.status === "watched" && (
<span <span
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${ className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
onToggleWatched ? "group-hover:opacity-0" : "" onToggleWatched ? "reveal-idle" : ""
}`} }`}
> >
<CheckCircle2 className="w-4 h-4 text-success" /> <CheckCircle2 className="w-4 h-4 text-success" />
+32
View File
@@ -536,6 +536,38 @@ body {
0 14px 30px -14px rgba(0, 0, 0, 0.5); 0 14px 30px -14px rgba(0, 0, 0, 0.5);
} }
/* UX-7 — touch-visible reveal for hover-only card controls. A control tagged `.reveal` used to be
`opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover),
but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is
gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered
or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the
control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the
"watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and
dropped entirely on touch — where the toggle is always present, so the two would otherwise collide
at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */
.reveal-idle {
transition: opacity 150ms ease;
}
@media (hover: hover) {
.reveal {
opacity: 0;
transition: opacity 150ms ease;
}
.group:hover .reveal,
.group:focus-within .reveal,
.reveal:focus-visible {
opacity: 1;
}
.group:hover .reveal-idle {
opacity: 0;
}
}
@media (hover: none) {
.reveal-idle {
display: none;
}
}
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted /* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs
which sit over the feed. Strengthen the pools and let a touch more bleed through the glass. which sit over the feed. Strengthen the pools and let a touch more bleed through the glass.