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:
@@ -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 ${
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user