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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user