diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 0a0b629b..64559ff2 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -189,7 +189,7 @@ function ConversationList({ onClick={() => openChat(c.partner)} title={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" > diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 041230a3..e2a00d3e 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -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 { useTranslation } from "react-i18next"; import { X } from "lucide-react"; @@ -6,6 +6,14 @@ import { useBackToClose } from "../lib/history"; import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack"; 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(FOCUSABLE)].filter((el) => el.offsetParent !== null); + // 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 // 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. useBackToClose(onClose); const fsRoot = useFullscreenElement(); + const panelRef = useRef(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 . 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 // 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(() => { const id = pushLayer(); const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && isTopmost(id)) { + if (!isTopmost(id)) return; + if (e.key === "Escape") { e.stopPropagation(); 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); const prev = document.body.style.overflow; 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 () => { window.removeEventListener("keydown", onKey); removeLayer(id); 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(
-
+
-

{title}

+

+ {title} +

diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 86fc3116..d0c2bc6d 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -198,7 +198,7 @@ function JobRow({ className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition" > · {t("scheduler.everyMin", { count: job.interval_minutes })} - + )} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index b2a1f77b..24bf28a8 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -62,7 +62,7 @@ function Actions({ // rather than silently rendering a half-clipped control outside the card.
diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 1e2b7b84..a9a465c9 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -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" > )} - {/* 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" && ( - + {t("plex.watched")} )} @@ -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" > @@ -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" > )} {watched && ( - + {t("plex.watched")} )} {se.status === "in_progress" && ( - + {t("plex.inProgress")} )} @@ -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" > diff --git a/frontend/src/index.css b/frontend/src/index.css index ce4b33c9..3e9d8e4b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -536,6 +536,38 @@ body { 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 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.