fix(popover): resolve the R8 platform code-review findings
Correctness:
- Escape is now strictly topmost-only for EVERY mode. Hover popovers register in
the shared layerStack too (and floating-ui's own Escape is off for all modes), so
an open tooltip/hover-card no longer either swallows the Escape from a Modal/player
underneath (its document-level stopPropagation vs their window listeners) or
double-closes both. A player's own overlay hover-card now takes Escape before the
player, keeping it alive.
- An invisible (referenceHidden, scrolled-out) surface is no longer a dismissable
layer, so it can't silently eat an Escape aimed at what's actually on screen, nor
make a player defer.
- PopoverSurface splits into a wrapper that returns null before any hook and an inner
that holds them, so a mounted-but-closed popover (≈10 per table row) subscribes no
fullscreen listeners — was ~1800 document listeners on a 100-row table.
- The fullscreen portal no longer remounts on a key: one persistent container is
re-parented body↔fullscreen-element with appendChild (atomic move), so the open
panel's focus/caret/scroll/animation survive a transition and an element SWAP is
handled by the element-identity dependency.
- AddToPlaylist drops `disabled={busy}` on its items (it blurred the focused item to
<body> and permanently killed roving focus); re-entrancy is already guarded in
toggle(), and aria-busy conveys the pending state.
- New `hostsInput` option turns off BOTH typeahead and focus-item-on-hover for the two
menus with an inline text field, so a pointer passing over a list item no longer
steals the field's caret and mis-routes the next Enter. Replaces their `typeahead:false`.
- ChannelTags' single-pass fit loop gains a whole-set fast path, so it stops collapsing
chips into "+N" when they would all have fitted.
- `initialFocus={-1}` is applied only when a `selectedIndex` is set (the sync-focus race
it works around); a plain nav menu now lets the focus manager focus normally, so an
item-less menu (first-run tag editor) still has a keyboard entry point.
a11y:
- `ariaLabel` now wins on menu-role panels — the surface strips useRole's competing
`aria-labelledby` when an explicit label is given (verified: the filter/View menus
are named by their column/label, not the trigger).
- PopoverItem routes caller props THROUGH getItemProps and applies its own
tabIndex/disabled/data-active/className last, so a caller handler or tabIndex can no
longer be silently dropped or clobber the roving model.
Regressions / consistency:
- Tooltip shows/hides on contact again (explicit 0/0 hover delay) instead of inheriting
the platform's 120/140 ms intent delay.
- ChannelTags' editor list regains its useScrollFade overflow mask.
- ChannelTagsControl takes a `bounded` prop; the channel detail page passes false so
tags show inline in the wide header instead of collapsing to "+N".
- ChannelPage's tag-catalog query is gated on subscribed && !demo (matches the only
consumer's render condition); the manager's tag toggle now also invalidates
qk.channel(id) so the detail page's cached tags stay in step.
- DetailCustomizeMenu + PrefToggle revert off the platform to the pre-R8 inline
`absolute` menu: in the player's overlay it renders inside a transform:scale(1.25)
subtree, and portalling out to <body> painted it at 1×. Same reason the player's own
menus stay hand-rolled; this also restores PrefToggle to plain buttons (the rows are
independent toggles, not a radio group). The stale PlayerModal fullscreen comment is
corrected too.
Gate green (tsc/eslint/76 vitest). Self-UAT in-browser: hover-over-item no longer
steals the tag input's caret; text filter keeps caret while narrowing; menu aria-label
is the label not the trigger; selectedIndex lands focus on the checked item; roving +
Escape-close+refocus intact; add-to-playlist items never disabled. NOT verified here
(needs a device / playing media): the fullscreen portal reparent and the player-overlay
paths — owed device UAT, as they already were.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { qk } from "./queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { useDismiss } from "./useDismiss";
|
||||
import { api } from "./api";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "../components/AnchoredPopover";
|
||||
|
||||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||
@@ -131,41 +131,51 @@ export function DetailCustomizeMenu({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
|
||||
// Deliberately NOT on the R8 popover platform. In the player's OVERLAY variant this menu renders
|
||||
// inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel
|
||||
// out to <body> — which would paint it at 1× while the surrounding player UI is at 1.25×. A plain
|
||||
// `absolute` child stays in the scaled subtree and matches, in both this variant and the unscaled
|
||||
// detail-page variant. Same reason the player's own gear/tracks menus stay hand-rolled; migrating
|
||||
// any of them needs the platform to portal INTO a caller-named (scaled) container.
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
ref={btnRef}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title={t("plex.info.customize")}
|
||||
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass-menu w-52 rounded-xl p-1.5 text-sm"
|
||||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||||
>
|
||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||
{hasCast && (
|
||||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||||
)}
|
||||
{hasRelated && onToggleRelated && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefRelated")}
|
||||
on={showRelated ?? true}
|
||||
onClick={onToggleRelated}
|
||||
/>
|
||||
)}
|
||||
{extra}
|
||||
</PopoverSurface>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="glass-menu absolute right-0 top-full z-menu mt-1 w-52 rounded-xl p-1.5 text-sm"
|
||||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||||
>
|
||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||
{hasCast && (
|
||||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||||
)}
|
||||
{hasRelated && onToggleRelated && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefRelated")}
|
||||
on={showRelated ?? true}
|
||||
onClick={onToggleRelated}
|
||||
/>
|
||||
)}
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One switch row inside `DetailCustomizeMenu` — a `PopoverItem`, so the rows callers pass through
|
||||
* `extra` join the same roving-focus list as the built-in ones. */
|
||||
/** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
|
||||
* so a plain button, not a menuitemradio. */
|
||||
export function PrefToggle({
|
||||
label,
|
||||
on,
|
||||
@@ -176,12 +186,9 @@ export function PrefToggle({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PopoverItem
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={onClick}
|
||||
label={label}
|
||||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left outline-none hover:bg-surface data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
@@ -191,6 +198,6 @@ export function PrefToggle({
|
||||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
|
||||
/>
|
||||
</span>
|
||||
</PopoverItem>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user