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:
@@ -108,7 +108,8 @@ export default function AddToPlaylist({
|
||||
{lists.map((pl) => (
|
||||
<PopoverItem
|
||||
key={pl.id}
|
||||
role="menuitemradio"
|
||||
// A video can sit in several playlists at once — checkboxes, not one choice.
|
||||
role="menuitemcheckbox"
|
||||
checked={pl.has_video}
|
||||
onSelect={() => toggle(pl)}
|
||||
disabled={busy}
|
||||
|
||||
@@ -197,10 +197,11 @@ interface PopoverListContextValue {
|
||||
}
|
||||
const PopoverListContext = createContext<PopoverListContextValue | null>(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 }
|
||||
|
||||
@@ -68,7 +68,8 @@ function TagEditorMenu({
|
||||
return (
|
||||
<PopoverItem
|
||||
key={tg.id}
|
||||
role="menuitemradio"
|
||||
// Several tags can be on at once — a checkbox set, not one exclusive choice.
|
||||
role="menuitemcheckbox"
|
||||
checked={on}
|
||||
onSelect={() => onToggle(tg.id)}
|
||||
label={tg.name}
|
||||
|
||||
@@ -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<HTMLButtonElement | null>(null);
|
||||
const acctPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
// Popover position (fixed, viewport coords). It's portaled to <body> 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 && <span className="truncate">{t("header.account.settings")}</span>}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<div>
|
||||
<button
|
||||
ref={acctBtnRef}
|
||||
onClick={toggleAccount}
|
||||
ref={acctPop.refs.setReference}
|
||||
{...acctPop.getReferenceProps()}
|
||||
title={slim ? `${name} · ${roleLabel}` : undefined}
|
||||
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
|
||||
>
|
||||
@@ -359,79 +338,75 @@ export default function NavSidebar({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{acctOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={acctPanelRef}
|
||||
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
|
||||
className="glass w-60 rounded-xl p-3 z-overlay animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<AvatarImg
|
||||
src={me.avatar_url}
|
||||
fallback={name}
|
||||
className="w-10 h-10 rounded-full text-sm shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{name}</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
{me.role === "admin" && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{t("header.account.admin")}
|
||||
</div>
|
||||
)}
|
||||
{otherAccounts.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
|
||||
{otherAccounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => switchTo(a.id)}
|
||||
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
|
||||
>
|
||||
<AvatarImg
|
||||
src={a.avatar_url}
|
||||
fallback={a.display_name ?? a.email}
|
||||
className="w-6 h-6 rounded-full text-[10px] shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 text-left">
|
||||
<span className="block text-[13px] text-fg truncate">
|
||||
{a.display_name ?? a.email.split("@")[0]}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted truncate">{a.email}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Adding an account switches THIS tab to it: drop the tab's pin so that on
|
||||
// return from Google it adopts the freshly-added account (the new default)
|
||||
// and pins that. Other tabs keep their own pinned account.
|
||||
clearActiveAccount();
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("header.account.addAccount")}
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("header.account.signOut")}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
<PopoverSurface
|
||||
popover={acctPop}
|
||||
ariaLabel={name}
|
||||
className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<AvatarImg
|
||||
src={me.avatar_url}
|
||||
fallback={name}
|
||||
className="w-10 h-10 rounded-full text-sm shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{name}</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
{me.role === "admin" && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{t("header.account.admin")}
|
||||
</div>
|
||||
)}
|
||||
{otherAccounts.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
|
||||
{otherAccounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => switchTo(a.id)}
|
||||
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
|
||||
>
|
||||
<AvatarImg
|
||||
src={a.avatar_url}
|
||||
fallback={a.display_name ?? a.email}
|
||||
className="w-6 h-6 rounded-full text-[10px] shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 text-left">
|
||||
<span className="block text-[13px] text-fg truncate">
|
||||
{a.display_name ?? a.email.split("@")[0]}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted truncate">{a.email}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Adding an account switches THIS tab to it: drop the tab's pin so that on
|
||||
// return from Google it adopts the freshly-added account (the new default)
|
||||
// and pins that. Other tabs keep their own pinned account.
|
||||
clearActiveAccount();
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("header.account.addAccount")}
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("header.account.signOut")}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<T>({
|
||||
//
|
||||
// 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 <body>, and the useDismiss
|
||||
// registration that had to include the trigger so its own mousedown didn't close-then-reopen.
|
||||
function FilterFunnel<T>({
|
||||
col,
|
||||
value,
|
||||
onApply,
|
||||
panelRef,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
col: Column<T>;
|
||||
value: string | string[] | undefined;
|
||||
onApply: (key: string, value: string | string[]) => void;
|
||||
panelRef: RefObject<HTMLDivElement>;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const f = col.filter!;
|
||||
const inputRef = useRef<HTMLInputElement | null>(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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{f.kind === "text" && (
|
||||
<input
|
||||
autoFocus
|
||||
value={(value as string) ?? ""}
|
||||
onChange={(e) => 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" && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => onApply(col.key, "")}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
!isActive(value) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
{t("datatable.all")}
|
||||
</button>
|
||||
{f.options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => onApply(col.key, o.value)}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
<>
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("datatable.filter")}
|
||||
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
|
||||
className={`-m-1.5 p-1.5 transition ${
|
||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<ListFilter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={col.header}
|
||||
initialFocus={isText ? inputRef : undefined}
|
||||
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{f.kind === "text" && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={(value as string) ?? ""}
|
||||
onChange={(e) => 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" && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<PopoverItem
|
||||
role="menuitemradio"
|
||||
checked={!filterOn}
|
||||
onSelect={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{f.kind === "multi" && (
|
||||
<FilterOptionList>
|
||||
{f.options.length === 0 && (
|
||||
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
||||
)}
|
||||
{f.options.map((o) => {
|
||||
const arr = (value as string[]) ?? [];
|
||||
const on = arr.includes(o.value);
|
||||
return (
|
||||
<button
|
||||
{t("datatable.all")}
|
||||
</PopoverItem>
|
||||
{f.options.map((o) => (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
onClick={() =>
|
||||
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
|
||||
}
|
||||
className={`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"
|
||||
role="menuitemradio"
|
||||
checked={value === o.value}
|
||||
onSelect={() => onApply(col.key, o.value)}
|
||||
label={o.label}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${
|
||||
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
{o.label}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{f.kind === "multi" && (
|
||||
<FilterOptionList>
|
||||
{f.options.length === 0 && (
|
||||
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
||||
)}
|
||||
{f.options.map((o) => {
|
||||
const arr = (value as string[]) ?? [];
|
||||
const on = arr.includes(o.value);
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role="menuitemcheckbox"
|
||||
checked={on}
|
||||
onSelect={() =>
|
||||
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 && <X className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</FilterOptionList>
|
||||
)}
|
||||
{isActive(value) && (
|
||||
<button
|
||||
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
||||
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <X className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{o.label}
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</FilterOptionList>
|
||||
)}
|
||||
{filterOn && (
|
||||
<button
|
||||
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
||||
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,33 +248,14 @@ export function ColumnHead<T>({
|
||||
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<string | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(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<HTMLButtonElement | null>(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 <body>,
|
||||
// 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 (
|
||||
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
|
||||
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
|
||||
{columns.map((col) => {
|
||||
const isSorted = sort?.key === col.key;
|
||||
const filterOn = isActive(filters[col.key]);
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
@@ -267,33 +293,15 @@ export function ColumnHead<T>({
|
||||
<span>{col.header}</span>
|
||||
)}
|
||||
{col.filter && (
|
||||
<button
|
||||
// Captured at open time rather than bound with `ref`, so it survives the close
|
||||
// (a `ref` scoped to the open column nulls out before focus can go back).
|
||||
onClick={(e) => {
|
||||
triggerRef.current = e.currentTarget;
|
||||
setOpenFilter((o) => (o === col.key ? null : col.key));
|
||||
}}
|
||||
aria-label={t("datatable.filter")}
|
||||
aria-expanded={openFilter === col.key}
|
||||
aria-haspopup="dialog"
|
||||
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
|
||||
className={`-m-1.5 p-1.5 transition ${
|
||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<ListFilter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<FilterFunnel
|
||||
col={col}
|
||||
value={filters[col.key]}
|
||||
onApply={onFilter}
|
||||
open={openFilter === col.key}
|
||||
onOpenChange={(o) => setOpenFilter(o ? col.key : null)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{openFilter === col.key && col.filter && (
|
||||
<FilterPopover
|
||||
col={col}
|
||||
value={filters[col.key]}
|
||||
onApply={onFilter}
|
||||
panelRef={panelRef}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user