feat(popover): migrate the dialog surfaces onto the platform (R8 S4c)

The column filter panel, the nav's account panel and the header's sync read-out are
mixed content with their own tab order rather than lists of choices, so they migrate as
`dialog` popovers — trapping and returning focus, dismissing through the shared layer
stack instead of their own document listeners.

The column filter needed a structural change: its open state lived in the header row
while the panel rendered per column, which cannot work when the platform anchors a panel
to its own trigger. Funnel and panel are now one module-level component (module-level
still matters — declaring it inline once remounted the popover on every keystroke, which
broke the caret and IME composition). That retires three hand-rolled focus chores at
once: capturing the trigger element at open time because a per-column ref nulled out
before focus could return, the effect that stopped Escape stranding focus on <body>, and
the useDismiss registration that had to include the trigger so its own mousedown did not
close-then-reopen the panel.

The filter also stops being one undifferentiated panel: a text filter is a dialog whose
input takes initial focus, while the select and multi filters are real menus with roving
focus — select as radios, multi as checkboxes.

`menuitemcheckbox` is new to the platform, and the two existing multi-select toggle lists
(channel tags, add-to-playlist) move onto it as well: several tags or playlists can be on
at once, so they were never one exclusive choice. Role-only change, no interaction
difference.

Verified in-browser: all three anchor exactly where their hand-rolled placement did,
Escape closes and returns focus to the trigger, the text filter keeps focus and caret
position while typing narrows the table, and the multi filter opens as a checkbox menu
with roving focus.
This commit is contained in:
2026-07-31 00:11:26 +02:00
parent 280de2e592
commit e35c4be561
6 changed files with 279 additions and 298 deletions
+54 -59
View File
@@ -1,12 +1,11 @@
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format";
import { useDismiss } from "../lib/useDismiss";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
@@ -21,12 +20,17 @@ export default function SyncStatus({
const { t } = useTranslation();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const chipRef = useRef<HTMLButtonElement | null>(null);
const popRef = useRef<HTMLDivElement | null>(null);
// Fixed viewport coords: the popover is portaled out of the header, whose glass backdrop-filter
// would otherwise trap its stacking and positioning.
const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
useDismiss(open, () => setOpen(false), [chipRef, popRef]);
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
// popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
// stacking and positioning.
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "dialog",
role: "dialog",
placement: "bottom-end",
offset: 8,
});
const { data } = useQuery({
queryKey: qk.myStatus(),
queryFn: api.myStatus,
@@ -106,16 +110,11 @@ export default function SyncStatus({
return (
<div className="pointer-events-auto shrink-0">
<button
ref={chipRef}
onClick={() => {
const r = chipRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 8, right: window.innerWidth - r.right });
setOpen((o) => !o);
}}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// Short label only: the popover already explains the numbers, and the long one overflowed
// the viewport edge from here.
title={countsText}
aria-expanded={open}
className="glass glass-hover relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs text-muted"
>
{active ? (
@@ -129,50 +128,46 @@ export default function SyncStatus({
)}
</button>
{open &&
createPortal(
<div
ref={popRef}
style={{ position: "fixed", top: pos.top, right: pos.right }}
className="glass-menu w-64 rounded-xl p-3 z-overlay text-xs text-muted animate-[popIn_0.16s_ease]"
>
<div
title={t("header.sync.countTooltip")}
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
>
<Database className="w-3.5 h-3.5 shrink-0" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</div>
{showMain && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
{pauseBtn}
</div>
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<button
onClick={() => {
setOpen(false);
onGoToFullHistory();
}}
title={t("header.sync.fullHistoryTooltip")}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
{!showMain && pauseBtn}
</div>
)}
</div>,
document.body,
<PopoverSurface
popover={pop}
ariaLabel={countsText}
className="glass-menu w-64 rounded-xl p-3 text-xs text-muted animate-[popIn_0.16s_ease]"
>
<div
title={t("header.sync.countTooltip")}
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
>
<Database className="w-3.5 h-3.5 shrink-0" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</div>
{showMain && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
{pauseBtn}
</div>
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<button
onClick={() => {
setOpen(false);
onGoToFullHistory();
}}
title={t("header.sync.fullHistoryTooltip")}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
{!showMain && pauseBtn}
</div>
)}
</PopoverSurface>
</div>
);
}