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