fix(playlists): address the review round — 11 findings

Header (shared, so every table gets these):
- The filter funnel could not close itself: useDismiss only knew the panel, so
  the trigger's mousedown closed it and the click reopened it. It now knows the
  trigger, captured at open time so it survives the close.
- Escape stranded focus on <body>; it goes back to the funnel, which also gained
  aria-expanded/aria-haspopup.
- A non-sortable header rendered a focusable no-op button — two unlabeled dead
  stops per playlist table, whose grip and actions headers are empty. Plain span
  unless the column sorts.

Playlist:
- The disabled drag grip explained itself to nobody: `disabled` drops a button
  from the tab order and suppresses its tooltip. It is aria-disabled now, stays
  focusable, and the reason is its accessible name.
- That reason also lied on a built-in list, where the block has nothing to do
  with sorting or filtering. Third branch, own string.
- The open player followed a position in a derived list, so a refetch or a view
  change could slide a different video under it or unmount it mid-playback. It
  follows the video id, like PlayerModal's own queue already does.
- A failed reorder could not resync: the id set is unchanged by a reorder, so
  the arriving detail was judged "same list" and the rejected order stayed on
  screen as the base for the next drag.
- The same guard is now keyed by playlist. Two lists holding the same videos
  looked identical to it, so the second rendered in the first's order and
  inherited its undo stack — where Undo would write it back to the wrong list.
- A one-item list filtered to nothing had no Reset view: the whole control strip
  was gated on the item count while the funnels rendered regardless.
- Both delete outcomes now read as actions ("Delete here only" / "Delete here
  and on YouTube"); "Here only" scanned as the safe choice while also deleting.
- Keyboard dragging shipped with dnd-kit's hardcoded English screen-reader
  instructions and announcements. Localized, with live positions.
This commit is contained in:
2026-07-20 23:45:31 +02:00
parent 83e673718f
commit efd46a01c8
4 changed files with 131 additions and 47 deletions
+46 -18
View File
@@ -1,4 +1,4 @@
import { useRef, useState, type ReactNode, type RefObject } from "react";
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
@@ -198,7 +198,23 @@ export function ColumnHead<T>({
const { t } = useTranslation();
const [openFilter, setOpenFilter] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
useDismiss(!!openFilter, () => setOpenFilter(null), panelRef);
// 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-['']">
@@ -222,25 +238,37 @@ export function ColumnHead<T>({
}
>
<span className="inline-flex items-center gap-1">
<button
onClick={() => col.sortable && onToggleSort(col.key)}
className={`inline-flex items-center gap-1 ${
col.sortable ? "hover:text-fg cursor-pointer" : "cursor-default"
}`}
>
{col.header}
{col.sortable && isSorted && sort && (
sort.dir === "asc" ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)
)}
</button>
{/* Only a sortable header is a control. A non-sortable one used to render a
focusable no-op button, which put unlabeled dead stops in the tab order —
two of them per playlist table, whose grip and actions headers are empty. */}
{col.sortable ? (
<button
onClick={() => onToggleSort(col.key)}
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
>
{col.header}
{isSorted && sort && (
sort.dir === "asc" ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)
)}
</button>
) : (
<span>{col.header}</span>
)}
{col.filter && (
<button
onClick={() => setOpenFilter((o) => (o === col.key ? null : col.key))}
// 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"