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:
@@ -80,7 +80,7 @@ export function PlexPosterCard({
|
||||
onToggleWatched(card);
|
||||
}}
|
||||
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
|
||||
className={`w-4 h-4 ${card.status === "watched" ? "text-success" : "text-white"}`}
|
||||
@@ -95,14 +95,20 @@ export function PlexPosterCard({
|
||||
onInfo();
|
||||
}}
|
||||
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" />
|
||||
</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" && (
|
||||
<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")}
|
||||
</span>
|
||||
)}
|
||||
@@ -203,7 +209,7 @@ export function SeasonCard({
|
||||
title={
|
||||
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"}`} />
|
||||
</button>
|
||||
@@ -216,18 +222,26 @@ export function SeasonCard({
|
||||
onAdd();
|
||||
}}
|
||||
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" />
|
||||
</button>
|
||||
)}
|
||||
{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")}
|
||||
</span>
|
||||
)}
|
||||
{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")}
|
||||
</span>
|
||||
)}
|
||||
@@ -406,7 +420,7 @@ export function EpisodeCard({
|
||||
onToggleWatched();
|
||||
}}
|
||||
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
|
||||
className={`w-4 h-4 ${ep.status === "watched" ? "text-success" : "text-white"}`}
|
||||
@@ -416,7 +430,7 @@ export function EpisodeCard({
|
||||
{ep.status === "watched" && (
|
||||
<span
|
||||
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" />
|
||||
|
||||
Reference in New Issue
Block a user