fix(a11y): touch/keyboard reveal tail + single-Escape layer coordination (UX-7/UX-8)
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.
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -262,9 +262,12 @@ function Thumb({
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
|
||||
{inProgress ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user