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.
52 lines
1.8 KiB
TypeScript
52 lines
1.8 KiB
TypeScript
import { useState, useSyncExternalStore } from "react";
|
|
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
|
import { hintsEnabled, subscribeHints } from "../lib/hints";
|
|
|
|
type Side = "top" | "bottom";
|
|
|
|
/** Wrap any element to show a short glass hint caption on hover — but only while the
|
|
* app-wide hints toggle (Settings → Appearance) is on. Anchored by the R8 popover platform, so it
|
|
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
|
|
* and slides back inside the viewport near an edge. */
|
|
export default function Tooltip({
|
|
hint,
|
|
side = "top",
|
|
children,
|
|
}: {
|
|
hint: string;
|
|
side?: Side;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
|
const [open, setOpen] = useState(false);
|
|
const pop = useAnchoredPopover({
|
|
open: open && enabled && !!hint,
|
|
onOpenChange: setOpen,
|
|
mode: "hover",
|
|
placement: side,
|
|
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}</>;
|
|
|
|
return (
|
|
<>
|
|
<span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
|
|
{children}
|
|
</span>
|
|
{/* Passive caption: it must not swallow a click aimed at a neighbouring control that it
|
|
overlaps while the hover-close delay runs out. */}
|
|
<PopoverSurface
|
|
popover={pop}
|
|
passthrough
|
|
className="glass w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
|
|
>
|
|
{hint}
|
|
</PopoverSurface>
|
|
</>
|
|
);
|
|
}
|