diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 9236233a..e289f300 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -108,7 +108,8 @@ export default function AddToPlaylist({ {lists.map((pl) => ( toggle(pl)} disabled={busy} diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx index 2eeefef7..bcbbc4ed 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/AnchoredPopover.tsx @@ -197,10 +197,11 @@ interface PopoverListContextValue { } const PopoverListContext = createContext(null); -type ItemRole = "menuitem" | "menuitemradio" | "option"; +type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option"; /** One keyboard-navigable option inside a menu/listbox `PopoverSurface`. Self-registers for roving - * focus + typeahead and applies the correct ARIA. Use `menuitemradio`+`checked` for a toggle, + * focus + typeahead and applies the correct ARIA. Use `menuitemradio`+`checked` when the items are + * one exclusive choice, `menuitemcheckbox`+`checked` when several can be on at once, * `option`+`selected` for a listbox choice, plain `menuitem` for an action. * * The active (roving) item carries `data-active` — style the highlight off THAT, not `:focus-visible`: @@ -237,7 +238,7 @@ export function PopoverItem({ const active = ctx?.activeIndex === index; const ariaProps = - role === "menuitemradio" + role === "menuitemradio" || role === "menuitemcheckbox" ? { role, "aria-checked": !!checked } : role === "option" ? { role, "aria-selected": !!selected } diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 100dbaa4..e138bb3f 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -68,7 +68,8 @@ function TagEditorMenu({ return ( onToggle(tg.id)} label={tg.name} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index d0ce865a..d9cac4c0 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -1,6 +1,5 @@ -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useState, useSyncExternalStore } from "react"; import { qk } from "../lib/queryKeys"; -import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; @@ -25,6 +24,7 @@ import { useHoverFocusWithin } from "../lib/useHoverFocusWithin"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; +import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; // Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes @@ -57,41 +57,20 @@ export default function NavSidebar({ const expanded = pinned || peek.active; const slim = !expanded; // visual: icon-only rail const [acctOpen, setAcctOpen] = useState(false); - const acctBtnRef = useRef(null); - const acctPanelRef = useRef(null); - // Popover position (fixed, viewport coords). It's portaled to so it escapes the - // nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let - // clicks fall through to the controls behind it. - const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); - - function toggleAccount() { - if (!acctOpen) { - const r = acctBtnRef.current?.getBoundingClientRect(); - if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); - } - setAcctOpen((o) => !o); - } - - // Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop - // div, which (sitting between the popover and the page) would break the popover's - // backdrop-filter and make it look solid. - useEffect(() => { - if (!acctOpen) return; - function onDoc(e: MouseEvent) { - const t = e.target as Node; - if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return; - setAcctOpen(false); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setAcctOpen(false); - } - document.addEventListener("mousedown", onDoc); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDoc); - document.removeEventListener("keydown", onKey); - }; - }, [acctOpen]); + // The account panel holds mixed content with its own tab order (profile, account switches, two + // actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose + // backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through + // to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its + // hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now + // comes from the platform instead of a pair of document listeners. + const acctPop = useAnchoredPopover({ + open: acctOpen, + onOpenChange: setAcctOpen, + mode: "dialog", + role: "dialog", + placement: "right-end", + offset: 8, + }); async function logout() { // Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then @@ -338,10 +317,10 @@ export default function NavSidebar({ {!slim && {t("header.account.settings")}} -
+
- {acctOpen && - createPortal( -
-
- -
-
{name}
-
{me.email}
-
-
- {me.role === "admin" && ( -
- - {t("header.account.admin")} -
- )} - {otherAccounts.length > 0 && ( -
- {otherAccounts.map((a) => ( - - ))} -
- )} -
- - -
-
, - document.body, + +
+ +
+
{name}
+
{me.email}
+
+
+ {me.role === "admin" && ( +
+ + {t("header.account.admin")} +
)} + {otherAccounts.length > 0 && ( +
+ {otherAccounts.map((a) => ( + + ))} +
+ )} +
+ + +
+
diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 19c0d28f..5e5095d5 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -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(null); - const popRef = useRef(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 (
- {open && - createPortal( -
-
- - - {formatViews(data.my_videos)}{" "} - {t("header.sync.yours")} - / - {formatViews(data.total_videos)} {t("header.sync.total")} - -
- {showMain && ( -
- {stateNode} - {pauseBtn} -
- )} - {notFull > 0 && ( -
- - {!showMain && pauseBtn} -
- )} -
, - document.body, + +
+ + + {formatViews(data.my_videos)}{" "} + {t("header.sync.yours")} + / + {formatViews(data.total_videos)} {t("header.sync.total")} + +
+ {showMain && ( +
+ {stateNode} + {pauseBtn} +
)} + {notFull > 0 && ( +
+ + {!showMain && pauseBtn} +
+ )} +
); } diff --git a/frontend/src/components/tableHeader.tsx b/frontend/src/components/tableHeader.tsx index 3b92bcd5..55507138 100644 --- a/frontend/src/components/tableHeader.tsx +++ b/frontend/src/components/tableHeader.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react"; -import { useDismiss } from "../lib/useDismiss"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; import { useScrollFade } from "../lib/useScrollFade"; import { type SortState } from "../lib/columnSort"; @@ -85,100 +85,145 @@ function FilterOptionList({ children }: { children: ReactNode }) { ); } -// MODULE LEVEL, deliberately. This used to be declared inside DataTable's body, which made it a NEW -// component type on every render: React unmounted and remounted the popover on each keystroke, so -// the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The +// MODULE LEVEL, deliberately. The panel used to be declared inside DataTable's body, which made it a +// NEW component type on every render: React unmounted and remounted the popover on each keystroke, +// so the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The // `autoFocus` masked it well enough that it read as "the filter is just twitchy". -function FilterPopover({ +// +// The funnel and its panel live in ONE component because the R8 popover platform anchors the panel +// to its trigger. That also retires this file's three hand-rolled focus chores: capturing the +// trigger element at open time (a `ref` scoped to the open column nulled out before focus could go +// back), the restore-focus effect that stopped Escape stranding focus on , and the useDismiss +// registration that had to include the trigger so its own mousedown didn't close-then-reopen. +function FilterFunnel({ col, value, onApply, - panelRef, + open, + onOpenChange, }: { col: Column; value: string | string[] | undefined; onApply: (key: string, value: string | string[]) => void; - panelRef: RefObject; + open: boolean; + onOpenChange: (open: boolean) => void; }) { const { t } = useTranslation(); const f = col.filter!; + const inputRef = useRef(null); + // A text filter is a free-text field, so it is a dialog whose input takes focus; the option lists + // are real menus with roving focus. `select` is one exclusive choice (radio), `multi` is several + // independent ones (checkbox). + const isText = f.kind === "text"; + const pop = useAnchoredPopover({ + open, + onOpenChange, + mode: isText ? "dialog" : "menu", + role: isText ? "dialog" : "menu", + }); + const filterOn = isActive(value); + return ( -
- {f.kind === "text" && ( - onApply(col.key, e.target.value)} - placeholder={col.header} - className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" - /> - )} - {f.kind === "select" && ( -
- - {f.options.map((o) => ( - + + {f.kind === "text" && ( + onApply(col.key, e.target.value)} + placeholder={col.header} + className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" + /> + )} + {f.kind === "select" && ( +
+ onApply(col.key, "")} + label={t("datatable.all")} + className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${ + !filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card" }`} > - {o.label} - - ))} -
- )} - {f.kind === "multi" && ( - - {f.options.length === 0 && ( - {t("datatable.noOptions")} - )} - {f.options.map((o) => { - const arr = (value as string[]) ?? []; - const on = arr.includes(o.value); - return ( -
+ )} + {f.kind === "multi" && ( + + {f.options.length === 0 && ( + {t("datatable.noOptions")} + )} + {f.options.map((o) => { + const arr = (value as string[]) ?? []; + const on = arr.includes(o.value); + return ( + + onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value]) + } + label={o.label} + className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${ + on ? "text-fg" : "text-muted hover:bg-card" }`} > - {on && } - - {o.label} - - ); - })} - - )} - {isActive(value) && ( - - )} -
+ + {on && } + + {o.label} +
+ ); + })} + + )} + {filterOn && ( + + )} + + ); } @@ -203,33 +248,14 @@ export function ColumnHead({ filters: FilterMap; onFilter: (key: string, value: string | string[]) => void; }) { - const { t } = useTranslation(); + // Only one column's filter panel is open at a time; each funnel owns its own anchoring. const [openFilter, setOpenFilter] = useState(null); - const panelRef = useRef(null); - // The funnel that opened the panel. `useDismiss` must know about it too, or the trigger's own - // mousedown closes the panel and the click that follows immediately reopens it — clicking the - // funnel a second time then looks like it does nothing. Also the thing to hand focus back to. - const triggerRef = useRef(null); - const restoreFocus = useRef(false); - useDismiss(!!openFilter, () => setOpenFilter(null), [panelRef, triggerRef]); - - // Escape (or an outside click) used to unmount the focused input and strand focus on , - // so the next Tab restarted from the top of the page. - useEffect(() => { - if (openFilter) { - restoreFocus.current = true; - } else if (restoreFocus.current) { - restoreFocus.current = false; - triggerRef.current?.focus(); - } - }, [openFilter]); return ( {columns.map((col) => { const isSorted = sort?.key === col.key; - const filterOn = isActive(filters[col.key]); return ( ({ {col.header} )} {col.filter && ( - + setOpenFilter(o ? col.key : null)} + /> )} - {openFilter === col.key && col.filter && ( - - )} ); })}