feat(popover): migrate the dialog surfaces onto the platform (R8 S4c)
The column filter panel, the nav's account panel and the header's sync read-out are mixed content with their own tab order rather than lists of choices, so they migrate as `dialog` popovers — trapping and returning focus, dismissing through the shared layer stack instead of their own document listeners. The column filter needed a structural change: its open state lived in the header row while the panel rendered per column, which cannot work when the platform anchors a panel to its own trigger. Funnel and panel are now one module-level component (module-level still matters — declaring it inline once remounted the popover on every keystroke, which broke the caret and IME composition). That retires three hand-rolled focus chores at once: capturing the trigger element at open time because a per-column ref nulled out before focus could return, the effect that stopped Escape stranding focus on <body>, and the useDismiss registration that had to include the trigger so its own mousedown did not close-then-reopen the panel. The filter also stops being one undifferentiated panel: a text filter is a dialog whose input takes initial focus, while the select and multi filters are real menus with roving focus — select as radios, multi as checkboxes. `menuitemcheckbox` is new to the platform, and the two existing multi-select toggle lists (channel tags, add-to-playlist) move onto it as well: several tags or playlists can be on at once, so they were never one exclusive choice. Role-only change, no interaction difference. Verified in-browser: all three anchor exactly where their hand-rolled placement did, Escape closes and returns focus to the trigger, the text filter keeps focus and caret position while typing narrows the table, and the multi filter opens as a checkbox menu with roving focus.
This commit is contained in:
@@ -1,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