feat(popover): add the Floating UI anchored-popover platform + migrate TagsCell pilot (R8 S1)
Introduce useAnchoredPopover + PopoverSurface over @floating-ui/react — one primitive replacing the hand-rolled measured engines: flip() (two-way flip), shift() (width clamp), hide() (hide-and-follow when the trigger leaves its scroller), autoUpdate (scroll follow), FloatingPortal (escape ancestor stacking contexts). Supports menu + hover modes; focus management lands in S2. Wrap the app and the public watch page in <FloatingTree> so Escape / outside-press dispatch is topmost-only across nested layers (S3 folds in the Modal + players). Migrate the richest engine, TagsCell, as the pilot — ~140 lines of bespoke positioning (coords state, flip layout-effect, scroll rAF loop, useDismiss, Overlay) collapse into one hook call. Verified in-app: click opens, portals to <body>, positions below the trigger, role=menu + aria-expanded/haspopup, Escape and outside-click close, no console errors.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
@@ -30,7 +29,7 @@ import Tooltip from "./Tooltip";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import Overlay from "./Overlay";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import Pager from "./Pager";
|
||||
@@ -1037,114 +1036,26 @@ function TagsCell({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Anchor for the portalled menu. It renders through <Overlay> (i.e. at <body>) rather than
|
||||
// `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a
|
||||
// `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew
|
||||
// UNDER the next card. Same hazard, same blessed fix as the players in E3.
|
||||
const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(
|
||||
null,
|
||||
);
|
||||
const fade = useScrollFade();
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
// The menu is no longer a DOM descendant of `ref`, so dismiss has to watch both.
|
||||
useDismiss(open, () => setOpen(false), [ref, popRef]);
|
||||
// Keep the scroll-fade hook's callback ref working alongside our own.
|
||||
const setPopRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
popRef.current = node;
|
||||
fade.ref(node);
|
||||
},
|
||||
[fade.ref],
|
||||
);
|
||||
// R8 anchored-popover platform: positioning (measured flip via flip(), horizontal clamp via shift(),
|
||||
// scroll-follow via autoUpdate, hide-on-leave-scroller via hide()), body portal, dismiss and
|
||||
// topmost Escape all come from the shared hook — replacing this cell's former hand-rolled engine.
|
||||
// The portal is what lets the CARD layout's per-row `transform` (a stacking context) NOT trap the
|
||||
// menu under the next card. Focus management (roving nav / return-to-trigger) arrives in S2.
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
// ONE placement rule, so the position the menu opens at and the one it keeps while scrolling can't
|
||||
// drift apart. `flip` keeps it anchored above the trigger once it has been flipped; the horizontal
|
||||
// clamp uses the MEASURED width (`w-44` is rem — it grows with the text-size slider, so a px guess
|
||||
// reads short at 1.3). Before first paint the menu isn't measurable yet; the layout effect below
|
||||
// re-runs this once it is.
|
||||
const coordsFrom = useCallback((r: DOMRect, flip: boolean) => {
|
||||
const w = popRef.current?.offsetWidth ?? 0;
|
||||
const left =
|
||||
w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left;
|
||||
return flip ? { left, bottom: window.innerHeight - r.top + 4 } : { left, top: r.bottom + 4 };
|
||||
}, []);
|
||||
|
||||
const place = useCallback(() => {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!r) return false;
|
||||
setCoords(coordsFrom(r, false));
|
||||
return true;
|
||||
}, [coordsFrom]);
|
||||
const toggle = () => {
|
||||
if (open) setOpen(false);
|
||||
else if (place()) setOpen(true);
|
||||
};
|
||||
|
||||
// Correct the placement against the MEASURED box: flip above when it would run off the bottom (and
|
||||
// there is room above), and apply the width clamp now that the menu can actually be measured.
|
||||
useLayoutEffect(() => {
|
||||
const pop = popRef.current;
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!open || !pop || !coords || !r) return;
|
||||
const h = pop.offsetHeight; // the width is read inside coordsFrom, off the same node
|
||||
const fitsBelow = r.bottom + 4 + h <= window.innerHeight - 8;
|
||||
const fitsAbove = r.top - 4 - h >= 8;
|
||||
// Decide BOTH ways, every time. Prefer the side it is already on (no flapping while scrolling),
|
||||
// but leave it as soon as that side stops fitting and the other one does — a one-way flip would
|
||||
// keep an above-anchored menu above a row scrolling toward the top and push it off the screen.
|
||||
const above = coords.bottom !== undefined;
|
||||
const flip = above ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove;
|
||||
const next = coordsFrom(r, flip);
|
||||
if (next.left !== coords.left || next.top !== coords.top || next.bottom !== coords.bottom) {
|
||||
setCoords(next);
|
||||
}
|
||||
}, [open, coords, coordsFrom]);
|
||||
|
||||
// A fixed menu doesn't travel with its row, so FOLLOW the trigger on scroll rather than closing:
|
||||
// in the table the menu used to scroll with the row, and closing on every wheel tick would make
|
||||
// attaching several tags a reopen-per-tag chore. Closes only once the trigger leaves the SCROLLER
|
||||
// (not the window — the page scroller starts below ~230px of fixed chrome, so a row hidden behind
|
||||
// the toolbar is still "on screen" by window coords and the menu would float over the chrome).
|
||||
// Scrolling INSIDE the menu must not move it.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let raf = 0;
|
||||
const reposition = () => {
|
||||
raf = 0;
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
const scroller = document.querySelector("main")?.getBoundingClientRect();
|
||||
const topEdge = scroller ? scroller.top : 0;
|
||||
const bottomEdge = scroller ? scroller.bottom : window.innerHeight;
|
||||
if (!r || r.bottom < topEdge || r.top > bottomEdge) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Keep whichever side it is anchored to, so the layout effect has nothing left to correct —
|
||||
// resetting to "below" every frame would make it flip back and forth, two renders per frame.
|
||||
setCoords((cur) => (cur ? coordsFrom(r, cur.bottom !== undefined) : cur));
|
||||
};
|
||||
const onScroll = (e: Event) => {
|
||||
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||||
if (!raf) raf = requestAnimationFrame(reposition); // coalesce a scroll burst into one move
|
||||
};
|
||||
const onResize = () => setOpen(false);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [open, coordsFrom]);
|
||||
if (userTags.length === 0) return null;
|
||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
||||
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
||||
// on every row). Tag create/delete still lives in the top "YOUR TAGS" row.
|
||||
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
|
||||
return (
|
||||
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
|
||||
<div className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
|
||||
{attached.map((tg) => (
|
||||
<button
|
||||
key={tg.id}
|
||||
@@ -1156,51 +1067,42 @@ function TagsCell({
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={toggle}
|
||||
ref={pop.refs.setReference}
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
{open && coords && (
|
||||
<Overlay>
|
||||
<div
|
||||
ref={setPopRef}
|
||||
style={{
|
||||
...fade.style,
|
||||
position: "fixed",
|
||||
left: coords.left,
|
||||
top: coords.top,
|
||||
bottom: coords.bottom,
|
||||
}}
|
||||
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{userTags.map((tg) => {
|
||||
const on = c.tag_ids.includes(tg.id);
|
||||
return (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => onToggleTag(tg.id)}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
on ? "text-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{tg.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
floatingRef={fade.ref}
|
||||
style={fade.style}
|
||||
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{userTags.map((tg) => {
|
||||
const on = c.tag_ids.includes(tg.id);
|
||||
return (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => onToggleTag(tg.id)}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
on ? "text-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{tg.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user