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:
2026-07-31 02:10:14 +02:00
parent 2727b59fa5
commit 2984aa3240
8 changed files with 195 additions and 98 deletions
+14 -4
View File
@@ -30,7 +30,9 @@ export default function AddToPlaylist({
open, open,
onOpenChange: setOpen, onOpenChange: setOpen,
offset: 6, offset: 6,
typeahead: false, // The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
// roving list so they don't steal the caret.
hostsInput: true,
}); });
const membership = useQuery({ const membership = useQuery({
@@ -101,7 +103,12 @@ export default function AddToPlaylist({
would otherwise reach the feed card that hosts the trigger and start playback. */} would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div> <div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div ref={fade.ref} style={fade.style} className="max-h-56 overflow-y-auto no-scrollbar"> <div
ref={fade.ref}
style={fade.style}
aria-busy={busy}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{lists.length === 0 && !membership.isLoading && ( {lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div> <div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)} )}
@@ -112,9 +119,12 @@ export default function AddToPlaylist({
role="menuitemcheckbox" role="menuitemcheckbox"
checked={pl.has_video} checked={pl.has_video}
onSelect={() => toggle(pl)} onSelect={() => toggle(pl)}
disabled={busy} // NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
label={playlistName(pl, t)} label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-none transition hover:bg-card/60 disabled:opacity-50 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]" className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-none transition hover:bg-card/60 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
> >
<span <span
className={`grid place-items-center w-4 h-4 rounded border ${ className={`grid place-items-center w-4 h-4 rounded border ${
+94 -50
View File
@@ -2,6 +2,7 @@ import {
createContext, createContext,
useContext, useContext,
useEffect, useEffect,
useLayoutEffect,
useRef, useRef,
useState, useState,
type ButtonHTMLAttributes, type ButtonHTMLAttributes,
@@ -82,6 +83,10 @@ export interface AnchoredPopoverOptions {
/** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that /** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that
* contain a free-text input, where character keys belong to the text field. */ * contain a free-text input, where character keys belong to the text field. */
typeahead?: boolean; typeahead?: boolean;
/** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
hostsInput?: boolean;
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then /** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these * lands the roving focus on it instead of the first item — the behaviour a "pick one of these
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */ * modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
@@ -142,11 +147,12 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
handleClose: safePolygon(), handleClose: safePolygon(),
}); });
const focus = useFocus(context, { enabled: isHover }); const focus = useFocus(context, { enabled: isHover });
// Escape for an interactive (non-hover) popover is dispatched through the shared `layerStack` // Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
// (below) so it coordinates with Modals and players, not just other floating nodes — so turn // is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
// floating-ui's own Escape off there. Outside-press stays with floating-ui (tree-aware). Hover // off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
// tooltips keep floating-ui's Escape and are NOT layers (they mustn't make a player defer). // stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
const dismiss = useDismiss(context, { escapeKey: isHover }); // press stays with floating-ui (tree-aware).
const dismiss = useDismiss(context, { escapeKey: false });
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") }); const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
const listNav = useListNavigation(context, { const listNav = useListNavigation(context, {
enabled: isNav, enabled: isNav,
@@ -158,9 +164,13 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens, // Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
// which strands focus on the menu container — arrows then can't enter the roving list at all). // which strands focus on the menu container — arrows then can't enter the roving list at all).
focusItemOnOpen: true, focusItemOnOpen: true,
// A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely
// passes over it (the library default) — that blurs the input mid-typing and mis-routes the next
// Enter to the hovered item. Turned off together with typeahead for such menus.
focusItemOnHover: !opts.hostsInput,
}); });
const typeahead = useTypeahead(context, { const typeahead = useTypeahead(context, {
enabled: isNav && (opts.typeahead ?? true), enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
listRef: labelsRef, listRef: labelsRef,
activeIndex, activeIndex,
onMatch: setActiveIndex, onMatch: setActiveIndex,
@@ -168,20 +178,28 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]); const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
// Register the open interactive popover as a dismissable layer (topmost-only Escape, and so a // `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
// player/modal underneath defers to it). Hover tooltips are excluded on purpose. // then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
useLayer(opts.open && !isHover, () => opts.onOpenChange(false)); const referenceHidden = middlewareData.hide?.referenceHidden ?? false;
// Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
// an Escape the user aimed at whatever is actually on screen.
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false));
return { return {
nodeId, nodeId,
mode, mode,
isNav, isNav,
open: opts.open, open: opts.open,
selectedIndex: opts.selectedIndex ?? null,
refs, refs,
floatingStyles, floatingStyles,
context, context,
isPositioned, isPositioned,
referenceHidden: middlewareData.hide?.referenceHidden ?? false, referenceHidden,
elementsRef, elementsRef,
labelsRef, labelsRef,
activeIndex, activeIndex,
@@ -244,43 +262,37 @@ export function PopoverItem({
? { role, "aria-selected": !!selected } ? { role, "aria-selected": !!selected }
: { role }; : { role };
// Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which
// composes handlers rather than dropping them — spreading `{...rest}` separately would let the
// getter's own onClick/onFocus/onMouseMove silently clobber the caller's. `onClick` here runs the
// caller's first (e.g. a stopPropagation), then the select.
const mergedClick = (e: React.MouseEvent<HTMLButtonElement>) => {
rest.onClick?.(e);
if (!disabled) onSelect?.();
};
const handlers = ctx const handlers = ctx
? ctx.getItemProps({ onClick: () => !disabled && onSelect?.() }) ? ctx.getItemProps({ ...rest, onClick: mergedClick })
: { onClick: () => !disabled && onSelect?.() }; : { ...rest, onClick: mergedClick };
return ( return (
<button <button
ref={ref} ref={ref}
type="button" type="button"
// Roving tabindex: only the active item is in the Tab order; arrows move focus between them. {...ariaProps}
{...handlers}
// These win over anything in `handlers` — the roving tabindex must not be a caller override,
// and disabled/className/data-active are the platform's to set.
tabIndex={active ? 0 : -1} tabIndex={active ? 0 : -1}
// Highlight hook: reliable across click- vs key-open (see the note above), unlike :focus-visible.
data-active={active ? "true" : undefined} data-active={active ? "true" : undefined}
disabled={disabled} disabled={disabled}
className={className} className={className}
{...ariaProps}
{...rest}
{...handlers}
> >
{children} {children}
</button> </button>
); );
} }
/** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the interface PopoverSurfaceProps {
* library's floating ref/props with the caller's className/style. Menu/listbox surfaces trap+return
* focus and host the list-navigation registry by default; pass `focusManaged={false}` to opt a
* surface out (e.g. a hover card). */
export function PopoverSurface({
popover,
className,
style,
children,
focusManaged,
initialFocus,
passthrough,
ariaLabel,
}: {
popover: AnchoredPopover; popover: AnchoredPopover;
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
@@ -294,7 +306,30 @@ export function PopoverSurface({
* aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable * aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable
* content, links) must NOT set this. */ * content, links) must NOT set this. */
passthrough?: boolean; passthrough?: boolean;
}) { }
/** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the
* library's floating ref/props with the caller's className/style. Menu/listbox surfaces trap+return
* focus and host the list-navigation registry by default; pass `focusManaged={false}` to opt a
* surface out (e.g. a hover card).
*
* A thin wrapper so the closed case runs NO hooks: a mounted-but-closed popover (there can be ~10
* per table row) must not subscribe fullscreen listeners or build a portal container. */
export function PopoverSurface(props: PopoverSurfaceProps) {
if (!props.popover.open) return null;
return <PopoverSurfaceInner {...props} />;
}
function PopoverSurfaceInner({
popover,
className,
style,
children,
focusManaged,
initialFocus,
passthrough,
ariaLabel,
}: PopoverSurfaceProps) {
const { const {
context, context,
refs, refs,
@@ -304,23 +339,36 @@ export function PopoverSurface({
nodeId, nodeId,
referenceHidden, referenceHidden,
isPositioned, isPositioned,
open,
mode, mode,
isNav, isNav,
selectedIndex,
activeIndex, activeIndex,
elementsRef, elementsRef,
labelsRef, labelsRef,
} = popover; } = popover;
// A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover // A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
// opened from a player's control bar in fullscreen would be invisible. Portal into the fullscreen // opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
// element instead while one exists — this is what let the player's in-bar menus stop being either // fullscreen element. We own one persistent container div and re-parent it (body ↔ fullscreen
// non-portaled (and clipped) or outright disabled in fullscreen. // element) with appendChild, which MOVES the node atomically — no detach, so the open panel's
// focus, caret, scroll offset and animation state survive a fullscreen transition, and an element
// SWAP (A→B without exiting) is handled by the element-identity dependency. (Keying the portal to
// remount instead would rebuild the subtree and lose all of that.)
const fsRoot = useFullscreenElement(); const fsRoot = useFullscreenElement();
const [container] = useState<HTMLDivElement | null>(() =>
typeof document === "undefined" ? null : document.createElement("div"),
);
useLayoutEffect(() => {
if (container) (fsRoot ?? document.body).appendChild(container);
}, [container, fsRoot]);
useEffect(() => () => container?.remove(), [container]);
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as { const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
style?: CSSProperties; style?: CSSProperties;
} & Record<string, unknown>; } & Record<string, unknown>;
// useRole stamps `aria-labelledby` (the trigger) on a menu-role panel, and it WINS over aria-label
if (!open) return null; // in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
if (ariaLabel) delete floatingProps["aria-labelledby"];
const zClass = mode === "hover" ? "z-tooltip" : "z-popover"; const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
// Everything but a passive hover card manages focus by default (trap into the surface, return to // Everything but a passive hover card manages focus by default (trap into the surface, return to
@@ -368,22 +416,18 @@ export function PopoverSurface({
return ( return (
<FloatingNode id={nodeId}> <FloatingNode id={nodeId}>
{/* `root` must be undefined (NOT null) to mean "default to <body>" — floating-ui reads a null <FloatingPortal root={container ?? undefined}>
root as "the container doesn't exist yet" and creates no portal node at all. And it only
builds that node once, so a root that changes mid-open is ignored; the key remounts the
portal on a fullscreen transition, which is exactly when the popover must move. */}
<FloatingPortal key={fsRoot ? "fullscreen" : "body"} root={fsRoot ?? undefined}>
{managed ? ( {managed ? (
<FloatingFocusManager <FloatingFocusManager
context={context} context={context}
modal={false} modal={false}
// On a roving list, useListNavigation owns the initial focus — so tell the focus manager // Only when a `selectedIndex` is set does useListNavigation focus its item SYNCHRONOUSLY
// NOT to also focus the panel itself (-1). Without this, a `selectedIndex` menu ends up // (in a layout effect) and race the focus manager's own initial focus — so ONLY then do we
// with focus stranded on the container: that path focuses its item SYNCHRONOUSLY in a // suppress the manager's initial focus with -1. For a plain nav menu we let the manager
// layout effect, and the manager's own initial focus then runs after it and wins. The // focus normally: it lands on the first tabbable child, which is the first roving item, or
// no-selectedIndex path only escapes the same fate because it defers its focus to a // — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
// microtask/rAF while it waits for the item list to populate. // input, so such a menu still has a keyboard entry point instead of stranding focus.
initialFocus={initialFocus ?? (isNav ? -1 : undefined)} initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
returnFocus returnFocus
> >
{positioned} {positioned}
+9 -1
View File
@@ -171,7 +171,13 @@ export default function ChannelPage({
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic // Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success // on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
// invalidates the manager list so a tag added here shows there too. // invalidates the manager list so a tag added here shows there too.
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); // Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an
// unsubscribed/demo channel page never shows the control, so the request would be thrown away.
const tagsQuery = useQuery({
queryKey: qk.tags(),
queryFn: api.tags,
enabled: !!ch?.subscribed && !me.is_demo,
});
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const { toggleTag, createAndAttachTag } = useChannelTagActions({ const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) => optimisticToggle: (id, tagId, attach) =>
@@ -441,6 +447,8 @@ export default function ChannelPage({
attachedIds={ch.tag_ids} attachedIds={ch.tag_ids}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))} onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)} onCreate={(name) => createAndAttachTag(channelId, name)}
// The header row has free width — show tags inline instead of collapsing to "+N".
bounded={false}
/> />
)} )}
</div> </div>
+29 -8
View File
@@ -3,6 +3,7 @@ import { Check, Plus } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api"; import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useScrollFade } from "../lib/useScrollFade";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail // The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell: // page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -53,6 +54,7 @@ function TagEditorMenu({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const fade = useScrollFade();
const attached = new Set(attachedIds); const attached = new Set(attachedIds);
const submit = () => { const submit = () => {
const name = draft.trim(); const name = draft.trim();
@@ -62,7 +64,7 @@ function TagEditorMenu({
}; };
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="max-h-[240px] overflow-y-auto no-scrollbar"> <div ref={fade.ref} style={fade.style} className="max-h-[240px] overflow-y-auto no-scrollbar">
{userTags.map((tg) => { {userTags.map((tg) => {
const on = attached.has(tg.id); const on = attached.has(tg.id);
return ( return (
@@ -135,6 +137,7 @@ export function ChannelTagsControl({
onToggle, onToggle,
onCreate, onCreate,
onChipClick, onChipClick,
bounded = true,
}: { }: {
userTags: Tag[]; userTags: Tag[];
attachedIds: number[]; attachedIds: number[];
@@ -142,6 +145,10 @@ export function ChannelTagsControl({
onCreate: (name: string) => void; onCreate: (name: string) => void;
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */ /** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
onChipClick?: (tagId: number, name: string) => void; onChipClick?: (tagId: number, name: string) => void;
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
* table (the manager). Set false where there is free width (the channel detail header), so tags
* fit inline instead of needlessly collapsing into "+N". */
bounded?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const attached = userTags.filter((tg) => attachedIds.includes(tg.id)); const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
@@ -174,6 +181,16 @@ export function ChannelTagsControl({
? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth)) ? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth))
: 0; : 0;
const avail = row.clientWidth; const avail = row.clientWidth;
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and no "+N"
// pill is needed. The reservation loop below reserves the pill's width for every chip except
// the last and breaks at the first overflow, so on its own it never reaches the only "does the
// whole set fit?" test — it would collapse chips that would have fitted (e.g. a short trailing
// chip narrower than the pill), leaving "+N" beside empty space.
const lastRight = lastChip ? lastChip.offsetLeft + lastChip.offsetWidth : 0;
if (lastRight <= avail) {
setVisible(chips.length);
return;
}
let count = chips.length; let count = chips.length;
for (let i = 0; i < chips.length; i++) { for (let i = 0; i < chips.length; i++) {
const chip = chips[i]; const chip = chips[i];
@@ -202,8 +219,9 @@ export function ChannelTagsControl({
onOpenChange: setEditorOpen, onOpenChange: setEditorOpen,
mode: "menu", mode: "menu",
placement: "bottom-start", placement: "bottom-start",
// The menu contains a free-text "create tag" input — type-to-focus would fight the text field. // The menu contains a free-text "create tag" input — keep both typed chars and pointer-hover out
typeahead: false, // of the roving list so neither steals the field's caret.
hostsInput: true,
}); });
const [overflowOpen, setOverflowOpen] = useState(false); const [overflowOpen, setOverflowOpen] = useState(false);
@@ -223,11 +241,14 @@ export function ChannelTagsControl({
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-card text-muted border border-border"; "shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
return ( return (
// FIXED width so the cell's footprint is constant under `table-layout:auto`: a content-driven cell // When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
// re-solves EVERY column's width as chips are added, visibly shifting the whole table. A fixed // content-driven cell re-solves EVERY column's width as chips are added, visibly shifting the whole
// width keeps the table stable and centered; the chip row fits what it can and collapses the rest // table. The fixed width keeps the table stable and centered; the chip row fits what it can and
// into "+N". (max-w-full lets a narrower card shrink it.) // collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
<div className="relative flex items-center gap-1 w-[12rem] max-w-full min-w-0"> // takes its container's width, so a wide header shows the tags inline instead of collapsing them.
<div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-[12rem] max-w-full" : "w-full"}`}
>
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */} {/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
<div <div
ref={measureRef} ref={measureRef}
+3
View File
@@ -210,6 +210,9 @@ export default function Channels() {
: ch, : ch,
), ),
), ),
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }), onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
}); });
const syncSubs = useMutation({ const syncSubs = useMutation({
+3 -2
View File
@@ -867,8 +867,9 @@ export default function PlayerModal({
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback; seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
the exit button also re-arms our shortcuts. Add-to-playlist / download are hidden in true the exit button also re-arms our shortcuts. Add-to-playlist works in true fullscreen (its
fullscreen — their popovers portal to <body>, outside the fullscreen element. */} popover portals INTO the fullscreen element); download stays hidden there because it opens
a Modal, which still portals to <body> — outside the fullscreen element. */}
{(maximized || isFullscreen) && ( {(maximized || isFullscreen) && (
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
+3
View File
@@ -25,6 +25,9 @@ export default function Tooltip({
mode: "hover", mode: "hover",
placement: side, placement: side,
offset: 8, offset: 8,
// Show/hide on contact, as the pre-platform tooltip did — the platform's 120/140 ms hover-intent
// default suits interactive cards but makes a fast sweep across an icon row surface no hints.
hoverDelay: { open: 0, close: 0 },
}); });
if (!enabled || !hint) return <>{children}</>; if (!enabled || !hint) return <>{children}</>;
+40 -33
View File
@@ -1,10 +1,10 @@
import { useLayoutEffect, useState, type ReactNode } from "react"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react"; import { SlidersHorizontal } from "lucide-react";
import { useDismiss } from "./useDismiss";
import { api } from "./api"; 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, // 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 // 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 { t } = useTranslation();
const [open, setOpen] = useState(false); 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 ( return (
<div className="shrink-0"> <div className="relative shrink-0">
<button <button
ref={pop.refs.setReference} ref={btnRef}
{...pop.getReferenceProps()} onClick={() => setOpen((v) => !v)}
title={t("plex.info.customize")} 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"}`} 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" /> <SlidersHorizontal className="w-4 h-4" />
</button> </button>
<PopoverSurface {open && (
popover={pop} <div
className="glass-menu w-52 rounded-xl p-1.5 text-sm" ref={menuRef}
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }} 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.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} /> {hasCast && (
)} <PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
{hasRelated && onToggleRelated && ( )}
<PrefToggle {hasRelated && onToggleRelated && (
label={t("plex.info.prefRelated")} <PrefToggle
on={showRelated ?? true} label={t("plex.info.prefRelated")}
onClick={onToggleRelated} on={showRelated ?? true}
/> onClick={onToggleRelated}
)} />
{extra} )}
</PopoverSurface> {extra}
</div>
)}
</div> </div>
); );
} }
/** One switch row inside `DetailCustomizeMenu` — a `PopoverItem`, so the rows callers pass through /** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
* `extra` join the same roving-focus list as the built-in ones. */ * so a plain button, not a menuitemradio. */
export function PrefToggle({ export function PrefToggle({
label, label,
on, on,
@@ -176,12 +186,9 @@ export function PrefToggle({
onClick: () => void; onClick: () => void;
}) { }) {
return ( return (
<PopoverItem <button
role="menuitemradio" onClick={onClick}
checked={on} className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
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)]"
> >
<span>{label}</span> <span>{label}</span>
<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"}`} className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
/> />
</span> </span>
</PopoverItem> </button>
); );
} }