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}
+87 -112
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,79 +338,75 @@ 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">
> <AvatarImg
<div className="flex items-center gap-3 pb-3 border-b border-border"> src={me.avatar_url}
<AvatarImg fallback={name}
src={me.avatar_url} className="w-10 h-10 rounded-full text-sm shrink-0"
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="min-w-0"> <div className="text-xs text-muted truncate">{me.email}</div>
<div className="text-sm font-semibold truncate">{name}</div> </div>
<div className="text-xs text-muted truncate">{me.email}</div> </div>
</div> {me.role === "admin" && (
</div> <div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
{me.role === "admin" && ( <Shield className="w-3.5 h-3.5" />
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent"> {t("header.account.admin")}
<Shield className="w-3.5 h-3.5" /> </div>
{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,
)} )}
{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>
</div> </div>
</nav> </nav>
+54 -59
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,50 +128,46 @@ 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
> title={t("header.sync.countTooltip")}
<div className="flex items-center gap-1.5 pb-2 border-b border-border/60"
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>
<Database className="w-3.5 h-3.5 shrink-0" /> <span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
<span> {t("header.sync.yours")}
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "} <span className="opacity-40"> / </span>
{t("header.sync.yours")} {formatViews(data.total_videos)} {t("header.sync.total")}
<span className="opacity-40"> / </span> </span>
{formatViews(data.total_videos)} {t("header.sync.total")} </div>
</span> {showMain && (
</div> <div className="flex items-center justify-between gap-1.5 pt-2">
{showMain && ( <span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
<div className="flex items-center justify-between gap-1.5 pt-2"> {pauseBtn}
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span> </div>
{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,
)} )}
{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> </div>
); );
} }
+130 -122
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,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 // 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()}
{f.kind === "text" && ( aria-label={t("datatable.filter")}
<input // Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
autoFocus className={`-m-1.5 p-1.5 transition ${
value={(value as string) ?? ""} filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
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" <ListFilter className="w-3.5 h-3.5" />
/> </button>
)} <PopoverSurface
{f.kind === "select" && ( popover={pop}
<div className="flex flex-col gap-0.5"> ariaLabel={col.header}
<button initialFocus={isText ? inputRef : undefined}
onClick={() => onApply(col.key, "")} className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
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" {f.kind === "text" && (
}`} <input
> ref={inputRef}
{t("datatable.all")} value={(value as string) ?? ""}
</button> onChange={(e) => onApply(col.key, e.target.value)}
{f.options.map((o) => ( placeholder={col.header}
<button className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
key={o.value} />
onClick={() => onApply(col.key, o.value)} )}
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${ {f.kind === "select" && (
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card" <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} {t("datatable.all")}
</button> </PopoverItem>
))} {f.options.map((o) => (
</div> <PopoverItem
)}
{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
key={o.value} key={o.value}
onClick={() => role="menuitemradio"
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value]) checked={value === o.value}
} onSelect={() => onApply(col.key, o.value)}
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${ label={o.label}
on ? "text-fg" : "text-muted hover:bg-card" 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 {o.label}
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${ </PopoverItem>
on ? "bg-accent border-accent text-accent-fg" : "border-border" ))}
</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
</span> className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
{o.label} on ? "bg-accent border-accent text-accent-fg" : "border-border"
</button> }`}
); >
})} {on && <X className="w-2.5 h-2.5" />}
</FilterOptionList> </span>
)} {o.label}
{isActive(value) && ( </PopoverItem>
<button );
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")} })}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition" </FilterOptionList>
> )}
{t("datatable.clear")} {filterOn && (
</button> <button
)} onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
</div> 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; 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 col={col}
// (a `ref` scoped to the open column nulls out before focus can go back). value={filters[col.key]}
onClick={(e) => { onApply={onFilter}
triggerRef.current = e.currentTarget; open={openFilter === col.key}
setOpenFilter((o) => (o === col.key ? null : col.key)); onOpenChange={(o) => setOpenFilter(o ? col.key : null)}
}} />
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> </span>
{openFilter === col.key && col.filter && (
<FilterPopover
col={col}
value={filters[col.key]}
onApply={onFilter}
panelRef={panelRef}
/>
)}
</th> </th>
); );
})} })}