From 50518099404090566914913c23484a39b72cc577 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 23 Aug 2026 21:59:58 +0200 Subject: [PATCH] fix(a11y): touch/keyboard reveal tail + single-Escape layer coordination (UX-7/UX-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX-8: one Escape must close only the topmost layer, never both an open popover AND the player beneath it. The popover's Escape listener is on `document` and fires before the player's on `window`; closing the popover flushes its layer off the shared stack synchronously, so the player's `anyLayerAbove()` lookup read "nothing above" and closed too. Add a consumed-event flag to layerStack (`markEscapeHandled`/`escapeHandled`): the acting topmost layer stamps the shared KeyboardEvent, and both players check the stamp before the (now-mutated) stack. Immune to listener order and mid-dispatch stack mutation. Cover with layerStack.dom.test.ts. UX-7: the E4 `.reveal` sweep already covered the card action rows; two residual hover-only traps remained — invisible-but-tappable/Tab-focusable on touch. Add a group-agnostic `.reveal-hover` recipe (display:none under `(hover: none)`) paired with each site's own hover/focus-within reveal: - VideoCard play/Continue/Restart overlay: also reveal on group-focus-within (keyboard), and drop it on touch — the card tap already plays/resumes, so no permanent scrim over every mobile thumbnail. - Carousel prev/next arrows: gone on touch, where the strip swipes. --- frontend/src/components/Carousel.tsx | 4 +- frontend/src/components/PlayerModal.tsx | 7 ++- frontend/src/components/PlexPlayer.tsx | 7 ++- frontend/src/components/VideoCard.tsx | 7 ++- frontend/src/index.css | 10 ++++ frontend/src/lib/layerStack.dom.test.ts | 79 +++++++++++++++++++++++++ frontend/src/lib/layerStack.ts | 24 +++++++- 7 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/layerStack.dom.test.ts diff --git a/frontend/src/components/Carousel.tsx b/frontend/src/components/Carousel.tsx index 31834026..46f4affa 100644 --- a/frontend/src/components/Carousel.tsx +++ b/frontend/src/components/Carousel.tsx @@ -77,8 +77,10 @@ export function Carousel({ const label = ariaLabel ?? (typeof title === "string" ? title : undefined); + // Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover` + // removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap. const arrowBtn = - "absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + + "reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + "bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " + "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75"; diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 77adbcab..67ca30ad 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -31,7 +31,7 @@ import { import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; -import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack"; +import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack"; import DownloadButton from "./DownloadButton"; import { api, type Video, type VideoStatus } from "../lib/api"; import type { YTPlayer } from "../lib/youtube"; @@ -337,7 +337,10 @@ export default function PlayerModal({ if (e.key === "Escape") { // Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an // anchored popover): it takes the Escape and we do nothing — so we don't also step out of - // maximise or close the player underneath it. + // maximise or close the player underneath it. The consumed-event flag is the reliable signal + // (UX-8): the layer's `document` listener runs before ours and its close can pop it off the + // stack synchronously, so `anyLayerAbove` alone would miss it and close the player too. + if (escapeHandled(e)) return; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. if (fullscreenElement()) return; diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 1fe3eef3..76a7d8de 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -53,7 +53,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; import { useBackToClose } from "../lib/history"; -import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack"; +import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack"; // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. const PlexInfo = lazy(() => import("./PlexInfo")); @@ -602,7 +602,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { case "Escape": // Defer to any layer opened above the player (Modal or anchored popover) — let it take the // Escape, not us. A player MENU registers as a layer too, so it closes first and the next - // Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). + // Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the + // consumed-event flag first (UX-8): a layer's `document` listener runs before this `window` + // one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it. + if (escapeHandled(e)) break; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break; if (skipProgressRef.current != null) cancelAutoSkipRef.current(); else if (menuOpenRef.current) { diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index f4b40dab..e502722c 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -262,9 +262,12 @@ function Thumb({ )} - {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */} + {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only: + reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and + `reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a + permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */} {onOpen && ( -
+
{inProgress ? ( <>