Third and last slice of the E11 keyboard layer (S4c). S4a/S4b covered every card grid and rail; the table-shaped pages — the channel manager, a playlist, the download center — were still Tab-stop country: a channel row carries a priority stepper, four action buttons and a tag picker before the next row's name. - `ui/DataTable keyboardNav`: the rows are the items, Enter clicks the element a column marks `data-nav-open`. DataTable renders its rows twice (a table, and a card list below `md`); both register and `lib/listNav` now walks only lists on screen (`checkVisibility`), so `j` past the last row never carries on into the hidden copy. - `lib/listNav` leaves a key something before it already handled (`defaultPrevented`): during a keyboard drag a playlist row's grip moves the row with the arrows, and dnd-kit takes the key on the document before listNav's window listener. Without the guard ↓ moved the focus off the grip and dropped the drag. - `ui/NavGrid as="tbody"` for a table whose body is the list (the playlist, under dnd-kit's SortableContext). - Wired: Subscriptions and Discovery tables, the card layout (`ChannelLayoutGrid` → VirtualGrid `keyboardNav`), Hidden/Blocked cards (the ChannelLink name is a channel's open element); a playlist's rows (the title plays; the grip keeps Space/Enter); the download queue, library and shared lists (the play button opens, `IconBtn open`; a row without one opens nothing). - `listNav.wiring.contract.test`: `VirtualGrid` and `DataTable` must say `keyboardNav` like `Carousel`, `DownloadRow` is a card; the audit log's table is the one exemption (its rows open nothing), and an exemption that no longer matches a tag fails the test. Measured in Chrome: Channels table j → row, ring round the whole row; a row walked down keeps 12px above the viewport edge, walked up clears the sticky header by 35px; Enter opens the channel. Cards 3 columns: → 0→1, ↓ 1→4. Hidden and Discovery: j + Enter open the channel. Playlist: j + Enter plays, Escape returns focus to the row; a keyboard drag (Enter on the grip, ↓) moves the row with focus kept on the grip, and Escape cancels with the order unchanged. Downloads library: Enter plays a video row, does nothing on a plain file row.
146 lines
5.3 KiB
TypeScript
146 lines
5.3 KiB
TypeScript
import { Play, X } from "lucide-react";
|
|
import type { TFunction } from "i18next";
|
|
import type { Video } from "../lib/api";
|
|
import { formatDuration } from "../lib/format";
|
|
import type { Column } from "../lib/tableColumns";
|
|
|
|
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
|
|
//
|
|
// The `grip` column carries no renderer: the drag handle needs the `useSortable` attributes that
|
|
// only exist inside the row component, which a `render(row)` closure can't reach. It stays in the
|
|
// list anyway so ONE column array drives both the header and the body — the row renderer fills
|
|
// that cell itself. Everything else is an ordinary cell.
|
|
export const GRIP_KEY = "grip";
|
|
|
|
export function playlistColumns({
|
|
t,
|
|
indexOf,
|
|
onPlay,
|
|
onRemove,
|
|
canEdit,
|
|
}: {
|
|
t: TFunction;
|
|
/** Position in the STORED order (1-based on screen) — not the row's position in the current view,
|
|
* so a view-sort never renumbers the playlist under the reader. */
|
|
indexOf: (v: Video) => number;
|
|
onPlay: (v: Video) => void;
|
|
onRemove: (v: Video) => void;
|
|
canEdit: boolean;
|
|
}): Column<Video>[] {
|
|
const cols: Column<Video>[] = [
|
|
{ key: GRIP_KEY, header: "", width: "2.25rem", render: () => null, hideInCard: true },
|
|
{
|
|
key: "pos",
|
|
header: "#",
|
|
align: "right",
|
|
width: "4.5rem",
|
|
nowrap: true,
|
|
hideInCard: true,
|
|
// Sorting by "#" ascending is the stored order itself — that is what makes it a truthful
|
|
// default for the header arrow, and a one-click way back from any other sort.
|
|
sortable: true,
|
|
sortValue: indexOf,
|
|
render: (v) => <span className="text-xs text-muted tabular-nums">{indexOf(v) + 1}</span>,
|
|
},
|
|
{
|
|
key: "title",
|
|
header: t("playlists.colTitle"),
|
|
// The flexible column: under `table-fixed` (see Playlists.tsx) width:100% hands it every
|
|
// pixel the fixed columns don't claim. Auto layout could not do this — a `truncate` cell is
|
|
// `white-space: nowrap`, so its MIN-content width is the whole title, and the column grew
|
|
// until Length and the remove button hung off the page scroller.
|
|
width: "100%",
|
|
sortable: true,
|
|
sortValue: (v) => v.title ?? "",
|
|
filter: { kind: "text", get: (v) => v.title ?? "" },
|
|
cardPrimary: true,
|
|
render: (v) => (
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<button
|
|
onClick={() => onPlay(v)}
|
|
aria-label={`${t("card.play")} — ${v.title}`}
|
|
className="relative w-[62px] h-[35px] rounded-sm overflow-hidden bg-surface shrink-0 group"
|
|
>
|
|
{v.thumbnail_url && (
|
|
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
|
)}
|
|
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition">
|
|
<Play className="w-4 h-4 text-white fill-current" />
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => onPlay(v)}
|
|
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
|
|
data-nav-open=""
|
|
className="min-w-0 flex-1 text-left"
|
|
>
|
|
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "channel",
|
|
header: t("playlists.colChannel"),
|
|
width: "12rem",
|
|
nowrap: true,
|
|
sortable: true,
|
|
sortValue: (v) => v.channel_title ?? "",
|
|
filter: { kind: "text", get: (v) => v.channel_title ?? "" },
|
|
render: (v) => (
|
|
<span className="text-[0.8125rem] text-muted truncate block">{v.channel_title}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "duration",
|
|
header: t("playlists.colDuration"),
|
|
align: "right",
|
|
width: "5rem",
|
|
nowrap: true,
|
|
sortable: true,
|
|
// Nulls (live/upcoming, or not yet enriched) sort last in BOTH directions in the old
|
|
// comparator; sortRows is a plain numeric compare, so park them at the end of an ascending
|
|
// sort with a large sentinel rather than letting them read as zero-length.
|
|
sortValue: (v) => v.duration_seconds ?? Number.MAX_SAFE_INTEGER,
|
|
render: (v) =>
|
|
v.duration_seconds != null ? (
|
|
<span className="text-2xs text-muted tabular-nums">
|
|
{formatDuration(v.duration_seconds)}
|
|
</span>
|
|
) : v.live_status === "live" || v.live_status === "upcoming" ? (
|
|
<span
|
|
className={`text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
|
v.live_status === "live"
|
|
? "bg-red-500/90 text-white" /* fixed live-red, not the status token */
|
|
: "bg-card border border-border text-muted"
|
|
}`}
|
|
>
|
|
{t(`card.${v.live_status}`)}
|
|
</span>
|
|
) : null,
|
|
},
|
|
];
|
|
|
|
if (canEdit) {
|
|
cols.push({
|
|
key: "actions",
|
|
header: "",
|
|
align: "right",
|
|
width: "2.5rem",
|
|
nowrap: true,
|
|
render: (v) => (
|
|
<button
|
|
onClick={() => onRemove(v)}
|
|
title={t("playlists.removeItem")}
|
|
aria-label={t("playlists.removeItemOf", { title: v.title })}
|
|
className="text-muted hover:text-fg p-1"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
),
|
|
});
|
|
}
|
|
|
|
return cols;
|
|
}
|