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
+2 -1
View File
@@ -108,7 +108,8 @@ export default function AddToPlaylist({
{lists.map((pl) => ( {lists.map((pl) => (
<PopoverItem <PopoverItem
key={pl.id} key={pl.id}
role="menuitemradio" // A video can sit in several playlists at once — checkboxes, not one choice.
role="menuitemcheckbox"
checked={pl.has_video} checked={pl.has_video}
onSelect={() => toggle(pl)} onSelect={() => toggle(pl)}
disabled={busy} disabled={busy}
+4 -3
View File
@@ -197,10 +197,11 @@ interface PopoverListContextValue {
} }
const PopoverListContext = createContext<PopoverListContextValue | null>(null); 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 /** 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. * `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`: * 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 active = ctx?.activeIndex === index;
const ariaProps = const ariaProps =
role === "menuitemradio" role === "menuitemradio" || role === "menuitemcheckbox"
? { role, "aria-checked": !!checked } ? { role, "aria-checked": !!checked }
: role === "option" : role === "option"
? { role, "aria-selected": !!selected } ? { role, "aria-selected": !!selected }
+2 -1
View File
@@ -68,7 +68,8 @@ function TagEditorMenu({
return ( return (
<PopoverItem <PopoverItem
key={tg.id} key={tg.id}
role="menuitemradio" // Several tags can be on at once — a checkbox set, not one exclusive choice.
role="menuitemcheckbox"
checked={on} checked={on}
onSelect={() => onToggle(tg.id)} onSelect={() => onToggle(tg.id)}
label={tg.name} label={tg.name}
+24 -49
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useState, useSyncExternalStore } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./NavigationProvider";
@@ -25,6 +24,7 @@ import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar"; import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // 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 // 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 expanded = pinned || peek.active;
const slim = !expanded; // visual: icon-only rail const slim = !expanded; // visual: icon-only rail
const [acctOpen, setAcctOpen] = useState(false); const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null); // The account panel holds mixed content with its own tab order (profile, account switches, two
const acctPanelRef = useRef<HTMLDivElement | null>(null); // actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
// Popover position (fixed, viewport coords). It's portaled to <body> so it escapes the // backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
// nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let // to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
// clicks fall through to the controls behind it. // hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); // comes from the platform instead of a pair of document listeners.
const acctPop = useAnchoredPopover({
function toggleAccount() { open: acctOpen,
if (!acctOpen) { onOpenChange: setAcctOpen,
const r = acctBtnRef.current?.getBoundingClientRect(); mode: "dialog",
if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); role: "dialog",
} placement: "right-end",
setAcctOpen((o) => !o); offset: 8,
} });
// 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]);
async function logout() { async function logout() {
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then // 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>} {!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button> </button>
<div className="relative"> <div>
<button <button
ref={acctBtnRef} ref={acctPop.refs.setReference}
onClick={toggleAccount} {...acctPop.getReferenceProps()}
title={slim ? `${name} · ${roleLabel}` : undefined} title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`} className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
> >
@@ -359,12 +338,10 @@ export default function NavSidebar({
)} )}
</button> </button>
{acctOpen && <PopoverSurface
createPortal( popover={acctPop}
<div ariaLabel={name}
ref={acctPanelRef} className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
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"> <div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg <AvatarImg
@@ -429,9 +406,7 @@ export default function NavSidebar({
{t("header.account.signOut")} {t("header.account.signOut")}
</button> </button>
</div> </div>
</div>, </PopoverSurface>
document.body,
)}
</div> </div>
</div> </div>
</nav> </nav>
+20 -25
View File
@@ -1,12 +1,11 @@
import { useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api"; import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format"; 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* // 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 // 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 { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const chipRef = useRef<HTMLButtonElement | null>(null); // A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
const popRef = useRef<HTMLDivElement | null>(null); // popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
// Fixed viewport coords: the popover is portaled out of the header, whose glass backdrop-filter // stacking and positioning.
// would otherwise trap its stacking and positioning. const pop = useAnchoredPopover({
const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 }); open,
useDismiss(open, () => setOpen(false), [chipRef, popRef]); onOpenChange: setOpen,
mode: "dialog",
role: "dialog",
placement: "bottom-end",
offset: 8,
});
const { data } = useQuery({ const { data } = useQuery({
queryKey: qk.myStatus(), queryKey: qk.myStatus(),
queryFn: api.myStatus, queryFn: api.myStatus,
@@ -106,16 +110,11 @@ export default function SyncStatus({
return ( return (
<div className="pointer-events-auto shrink-0"> <div className="pointer-events-auto shrink-0">
<button <button
ref={chipRef} ref={pop.refs.setReference}
onClick={() => { {...pop.getReferenceProps()}
const r = chipRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 8, right: window.innerWidth - r.right });
setOpen((o) => !o);
}}
// Short label only: the popover already explains the numbers, and the long one overflowed // Short label only: the popover already explains the numbers, and the long one overflowed
// the viewport edge from here. // the viewport edge from here.
title={countsText} 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" className="glass glass-hover relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs text-muted"
> >
{active ? ( {active ? (
@@ -129,12 +128,10 @@ export default function SyncStatus({
)} )}
</button> </button>
{open && <PopoverSurface
createPortal( popover={pop}
<div ariaLabel={countsText}
ref={popRef} className="glass-menu w-64 rounded-xl p-3 text-xs text-muted animate-[popIn_0.16s_ease]"
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 <div
title={t("header.sync.countTooltip")} title={t("header.sync.countTooltip")}
@@ -170,9 +167,7 @@ export default function SyncStatus({
{!showMain && pauseBtn} {!showMain && pauseBtn}
</div> </div>
)} )}
</div>, </PopoverSurface>
document.body,
)}
</div> </div>
); );
} }
+77 -69
View File
@@ -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 { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react"; 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 { useScrollFade } from "../lib/useScrollFade";
import { type SortState } from "../lib/columnSort"; import { type SortState } from "../lib/columnSort";
@@ -85,31 +85,66 @@ function FilterOptionList({ children }: { children: ReactNode }) {
); );
} }
// MODULE LEVEL, deliberately. This used to be declared inside DataTable's body, which made it a NEW // MODULE LEVEL, deliberately. The panel used to be declared inside DataTable's body, which made it a
// component type on every render: React unmounted and remounted the popover on each keystroke, so // NEW component type on every render: React unmounted and remounted the popover on each keystroke,
// the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The // 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". // `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, col,
value, value,
onApply, onApply,
panelRef, open,
onOpenChange,
}: { }: {
col: Column<T>; col: Column<T>;
value: string | string[] | undefined; value: string | string[] | undefined;
onApply: (key: string, value: string | string[]) => void; onApply: (key: string, value: string | string[]) => void;
panelRef: RefObject<HTMLDivElement>; open: boolean;
onOpenChange: (open: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const f = col.filter!; 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 ( return (
<div <>
ref={panelRef} <button
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]" 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" && ( {f.kind === "text" && (
<input <input
autoFocus ref={inputRef}
value={(value as string) ?? ""} value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)} onChange={(e) => onApply(col.key, e.target.value)}
placeholder={col.header} placeholder={col.header}
@@ -118,24 +153,30 @@ function FilterPopover<T>({
)} )}
{f.kind === "select" && ( {f.kind === "select" && (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<button <PopoverItem
onClick={() => onApply(col.key, "")} role="menuitemradio"
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${ checked={!filterOn}
!isActive(value) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card" 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"
}`} }`}
> >
{t("datatable.all")} {t("datatable.all")}
</button> </PopoverItem>
{f.options.map((o) => ( {f.options.map((o) => (
<button <PopoverItem
key={o.value} key={o.value}
onClick={() => onApply(col.key, o.value)} role="menuitemradio"
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${ 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" value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`} }`}
> >
{o.label} {o.label}
</button> </PopoverItem>
))} ))}
</div> </div>
)} )}
@@ -148,12 +189,15 @@ function FilterPopover<T>({
const arr = (value as string[]) ?? []; const arr = (value as string[]) ?? [];
const on = arr.includes(o.value); const on = arr.includes(o.value);
return ( return (
<button <PopoverItem
key={o.value} key={o.value}
onClick={() => role="menuitemcheckbox"
checked={on}
onSelect={() =>
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value]) 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 ${ 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 ? "text-fg" : "text-muted hover:bg-card"
}`} }`}
> >
@@ -165,12 +209,12 @@ function FilterPopover<T>({
{on && <X className="w-2.5 h-2.5" />} {on && <X className="w-2.5 h-2.5" />}
</span> </span>
{o.label} {o.label}
</button> </PopoverItem>
); );
})} })}
</FilterOptionList> </FilterOptionList>
)} )}
{isActive(value) && ( {filterOn && (
<button <button
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")} onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition" className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
@@ -178,7 +222,8 @@ function FilterPopover<T>({
{t("datatable.clear")} {t("datatable.clear")}
</button> </button>
)} )}
</div> </PopoverSurface>
</>
); );
} }
@@ -203,33 +248,14 @@ export function ColumnHead<T>({
filters: FilterMap; filters: FilterMap;
onFilter: (key: string, value: string | string[]) => void; 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 [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 ( 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-['']"> <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)]"> <tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
{columns.map((col) => { {columns.map((col) => {
const isSorted = sort?.key === col.key; const isSorted = sort?.key === col.key;
const filterOn = isActive(filters[col.key]);
return ( return (
<th <th
key={col.key} key={col.key}
@@ -267,33 +293,15 @@ export function ColumnHead<T>({
<span>{col.header}</span> <span>{col.header}</span>
)} )}
{col.filter && ( {col.filter && (
<button <FilterFunnel
// 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>
)}
</span>
{openFilter === col.key && col.filter && (
<FilterPopover
col={col} col={col}
value={filters[col.key]} value={filters[col.key]}
onApply={onFilter} onApply={onFilter}
panelRef={panelRef} open={openFilter === col.key}
onOpenChange={(o) => setOpenFilter(o ? col.key : null)}
/> />
)} )}
</span>
</th> </th>
); );
})} })}