feat(playlists): select rows with a box, Shift for a range, x and Esc

A box first in every row and one over the column for the rows shown, the
selected row tinted. A filter keeps what it hides selected; another list
starts over; a row that left the list drops out. Watch later has none.

A box no longer counts as a typing target: a click on one left the focus
there and the page's keys went dead.
This commit is contained in:
2026-11-01 20:49:59 +01:00
parent cd773e8f33
commit 8cf8c4139c
20 changed files with 710 additions and 29 deletions
+18 -6
View File
@@ -1,5 +1,5 @@
import clsx from "clsx";
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
import type { InputHTMLAttributes, ReactNode, RefObject } from "react";
// A6 (docs/DESIGN.md): checkbox, slider and colour well are the BROWSER's controls, tinted
// with the chosen colour (the accent; the media amber on the player chrome) — keyboard, screen
@@ -10,18 +10,30 @@ import type { InputHTMLAttributes, ReactNode, Ref } from "react";
type Native = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange">;
/** The bare checkbox: inside your own `<label>`, or with an `aria-label`. Prefer `CheckRow`. */
/** The bare checkbox: inside your own `<label>`, or with an `aria-label`. Prefer `CheckRow`.
* `onChange` also says whether Shift was held, for a range. `mixed` is the third state, some of what
* it covers: the DOM has no attribute for it, only the property. */
export function Checkbox({
onChange,
className,
ref,
mixed = false,
...rest
}: Native & { onChange?: (checked: boolean) => void; ref?: Ref<HTMLInputElement> }) {
}: Native & {
onChange?: (checked: boolean, shift: boolean) => void;
ref?: RefObject<HTMLInputElement | null>;
mixed?: boolean;
}) {
return (
<input
ref={ref}
ref={(el) => {
if (ref) ref.current = el;
if (el) el.indeterminate = mixed;
}}
type="checkbox"
onChange={(e) => onChange?.(e.target.checked)}
onChange={(e) =>
onChange?.(e.target.checked, e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey)
}
className={clsx(
"size-4 shrink-0 cursor-pointer accent-chosen disabled:cursor-not-allowed",
className,
@@ -54,7 +66,7 @@ export function CheckRow({
className,
)}
>
<Checkbox checked={checked} onChange={onChange} disabled={disabled} />
<Checkbox checked={checked} onChange={(on) => onChange(on)} disabled={disabled} />
{children}
</label>
);
+5 -2
View File
@@ -1,5 +1,5 @@
import { useRef, type ReactNode } from "react";
import { useListNav } from "../../lib/listNav";
import { useListNav, type NavSelection } from "../../lib/listNav";
// A plain list of cards or rows the keyboard can walk — `j`/`k`, the arrows, Enter (lib/listNav) — for
// the lists that mount every item (the Plex title, season and episode grids, the download lists, a
@@ -11,16 +11,19 @@ export default function NavGrid({
as: Tag = "div",
className,
onDelete,
selection,
children,
}: {
as?: "div" | "tbody";
className?: string;
/** The Delete key takes the focused item out (lib/listNav): its index among the items. */
onDelete?: (index: number) => void;
/** `x` selects the focused item, Esc clears (lib/listNav). */
selection?: NavSelection;
children: ReactNode;
}) {
const ref = useRef<HTMLDivElement & HTMLTableSectionElement>(null);
useListNav(ref, { enabled: true, onDelete });
useListNav(ref, { enabled: true, onDelete, selection });
return (
<Tag ref={ref} className={className}>
{children}
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c)
@@ -0,0 +1,36 @@
import { Checkbox } from "./NativeControls";
/** A table row's selecting box, and the same box over the column for all the rows. The whole cell is
* the target, 44 on touch: the label starts at the cell's edge, past its padding, so it ends where
* the next cell's control starts instead of lying under it. `onToggle` says whether Shift was held. */
export function RowSelectBox({
label,
checked,
mixed,
title,
onToggle,
}: {
label: string;
checked: boolean;
mixed?: boolean;
title?: string;
onToggle: (shift: boolean) => void;
}) {
return (
<label
title={title}
// Shift+press would otherwise extend the page's text selection over the rows between.
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault();
}}
className="-ml-2 grid min-h-6 w-9 cursor-pointer place-items-center touch-target"
>
<Checkbox
aria-label={label}
checked={checked}
mixed={mixed}
onChange={(_, shift) => onToggle(shift)}
/>
</label>
);
}
+3 -1
View File
@@ -232,7 +232,9 @@ export function ColumnHead<T>({
two of them per playlist table, whose grip and actions headers are empty. */}
{/* min-w-6 as well as min-h-6: a short header ("Ki", Hungarian "Who") was a 12px
target beside its funnel, under the 24px floor. */}
{col.sortable ? (
{col.headerCell ? (
col.headerCell
) : col.sortable ? (
<Pressable
onClick={() => onToggleSort(col.key)}
className="inline-flex min-h-6 min-w-6 items-center gap-1 hover:text-fg cursor-pointer touch-target"
+4 -2
View File
@@ -34,8 +34,10 @@
"search": "Jump to the search box",
"listStep": "Next / previous item",
"gridMove": "Move around the list",
"open": "Open the selected item",
"listRemove": "Remove the selected item from the list",
"open": "Open the current item",
"listRemove": "Remove the current item from the list",
"listSelect": "Select or deselect the current item",
"listClearSelection": "Clear the selection",
"cap": {
"space": "Space",
"menu": "Menu"
@@ -102,6 +102,8 @@
"removeItem": "Remove",
"removeItemOf": "Remove “{{title}}” from the playlist",
"dragHandleOf": "Reorder “{{title}}”",
"selectRow": "Select “{{title}}”",
"selectAllShown": "Select all the rows shown",
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
"clearView": "Reset view",
+4 -2
View File
@@ -34,8 +34,10 @@
"search": "Ugrás a keresőbe",
"listStep": "Következő / előző elem",
"gridMove": "Mozgás a listában",
"open": "A kijelölt elem megnyitása",
"listRemove": "A kijelölt elem eltávolítása a listából",
"open": "Az aktuális elem megnyitása",
"listRemove": "Az aktuális elem eltávolítása a listából",
"listSelect": "Az aktuális elem kijelölése vagy levétele",
"listClearSelection": "A kijelölés törlése",
"cap": {
"space": "Szóköz",
"menu": "Menü"
@@ -102,6 +102,8 @@
"removeItem": "Eltávolítás",
"removeItemOf": "„{{title}}” eltávolítása a listáról",
"dragHandleOf": "„{{title}}” átrendezése",
"selectRow": "„{{title}}” kijelölése",
"selectAllShown": "Az összes látható sor kijelölése",
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
"clearView": "Nézet visszaállítása",
+23
View File
@@ -93,6 +93,24 @@ describe("isTypingTarget", () => {
it("is false on ordinary content", () => {
expect(isTypingTarget(press({ key: "a" }))).toBe(false);
});
it("is false on a box, where no letter types: the page's keys go on working after a tick", () => {
for (const type of ["checkbox", "radio"]) {
const el = document.createElement("input");
el.type = type;
document.body.append(el);
expect(isTypingTarget(press({ key: "a" }, el))).toBe(false);
el.remove();
}
});
it("stays true on a slider, whose arrows are its own", () => {
const el = document.createElement("input");
el.type = "range";
document.body.append(el);
expect(isTypingTarget(press({ key: "ArrowLeft" }, el))).toBe(true);
el.remove();
});
});
describe("isActivationTarget", () => {
@@ -101,6 +119,11 @@ describe("isActivationTarget", () => {
document.body.append(btn);
expect(isActivationTarget(press({ key: " " }, btn))).toBe(true);
btn.remove();
const box = document.createElement("input");
box.type = "checkbox";
document.body.append(box);
expect(isActivationTarget(press({ key: " " }, box))).toBe(true);
box.remove();
expect(isActivationTarget(press({ key: " " }))).toBe(false);
});
});
+20 -2
View File
@@ -33,6 +33,7 @@ export type KeyScope =
| "search"
| "list"
| "listDelete"
| "listSelect"
| "undo"
| "contextMenu"
| "ytPlayer"
@@ -46,6 +47,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
"search",
"list",
"listDelete",
"listSelect",
]);
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
@@ -138,6 +140,15 @@ export const SHORTCUTS = [
// Its own scope beside `list`: only a list that can take an item out (a playlist's rows) registers
// it, so the sheet does not promise Delete on the feed or a Plex grid, where nothing answers it.
{ id: "remove", scope: "listDelete", keys: ["Delete"], labelKey: "keys.listRemove" },
// …and only a list whose rows can be selected registers these. Escape stays quiet in a field: the
// field's own Escape comes first, and a selection is not what the reader is in while typing.
{ id: "select", scope: "listSelect", keys: ["x"], labelKey: "keys.listSelect" },
{
id: "clearSelection",
scope: "listSelect",
keys: ["Escape"],
labelKey: "keys.listClearSelection",
},
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
@@ -264,15 +275,22 @@ export function isTypingTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
if (!el) return false;
const tag = (el.tagName || "").toLowerCase();
// A box takes no letters, and a click on a table row's box leaves the focus on it: `j` went dead
// there. A slider stays a field, as its arrows are its own.
if (isBox(el)) return false;
return tag === "input" || tag === "textarea" || tag === "select" || !!el.isContentEditable;
}
function isBox(el: HTMLElement | null): boolean {
return el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio");
}
/** True while focus is on something Space/Enter activates natively. Both players defer Space to a
* focused button ("Skip intro") rather than toggling playback; this is that shared rule. */
export function isActivationTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
const tag = (el?.tagName || "").toLowerCase();
return tag === "button" || tag === "a";
return tag === "button" || tag === "a" || isBox(el);
}
/** One printable character that is neither a letter nor a digit (`[`, `]`, `,`). */
@@ -462,7 +480,7 @@ export function useActiveScopes(): KeyScope[] {
// registers `page` in its own effect, which React runs after every child's — so by mount order
// the module jumps would head the sheet, above the list keys of the page you are looking at.
const surfaces = live.filter((s) => !isPageLevelScope(s));
const pageLevel = (["list", "listDelete", "search", "page"] as const).filter((s) =>
const pageLevel = (["list", "listSelect", "listDelete", "search", "page"] as const).filter((s) =>
live.includes(s),
);
return [...surfaces, ...pageLevel, "global"];
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
//
// `x` selects the walked item and Esc clears, in a list that takes a selection (a playlist's rows).
// Its own file: listNav keeps module state.
import { useRef } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { navItemAttrs, useListNav, type NavSelection } from "./listNav";
import { useActiveScopes } from "./keymap";
afterEach(cleanup);
function Rows({ selection }: { selection?: NavSelection }) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true, selection });
return (
<div ref={ref}>
{[0, 1, 2].map((i) => (
<div key={i} {...navItemAttrs(i)} data-testid={`r${i}`}>
<button data-testid={`r${i}-action`}>action</button>
<input data-testid={`r${i}-input`} />
<input type="checkbox" data-testid={`r${i}-box`} />
</div>
))}
</div>
);
}
function Scopes() {
return <output data-testid="scopes">{useActiveScopes().join(" ")}</output>;
}
function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
(document.activeElement ?? document.body).dispatchEvent(e);
return e;
}
const selection = (cleared = true) => ({
toggle: vi.fn<(index: number) => void>(),
clear: vi.fn(() => cleared),
});
describe("useListNav · selecting", () => {
it("toggles the walked item on x", () => {
const s = selection();
render(<Rows selection={s} />);
press("j");
press("j");
const e = press("x");
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(1);
expect(e.defaultPrevented).toBe(true);
});
it("toggles the item a control inside it holds the focus for", () => {
const s = selection();
render(<Rows selection={s} />);
screen.getByTestId("r2-action").focus();
press("x");
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(2);
});
it("goes on answering with the focus on the row's box, where a click leaves it", () => {
const s = selection();
render(<Rows selection={s} />);
screen.getByTestId("r1-box").focus();
press("x");
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(1);
press("j");
expect(document.activeElement).toBe(screen.getByTestId("r2"));
});
it("does nothing on x with no item under the keyboard", () => {
const s = selection();
render(<Rows selection={s} />);
const e = press("x");
expect(s.toggle).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(false);
});
it("types an x in a field instead of selecting", () => {
const s = selection();
render(<Rows selection={s} />);
screen.getByTestId("r0-input").focus();
press("x");
expect(s.toggle).not.toHaveBeenCalled();
});
it("does not flip back and forth while x is held down", () => {
const s = selection();
render(<Rows selection={s} />);
press("j");
press("x");
press("x", { repeat: true });
expect(s.toggle).toHaveBeenCalledTimes(1);
});
it("clears on Esc wherever the focus is, and spends the key only when it cleared", () => {
const s = selection();
render(<Rows selection={s} />);
expect(press("Escape").defaultPrevented).toBe(true);
expect(s.clear).toHaveBeenCalledTimes(1);
cleanup();
const idle = selection(false);
render(<Rows selection={idle} />);
expect(press("Escape").defaultPrevented).toBe(false);
expect(idle.clear).toHaveBeenCalledTimes(1);
});
it("leaves both keys alone in a list that takes no selection", () => {
render(<Rows />);
press("j");
expect(press("x").defaultPrevented).toBe(false);
expect(press("Escape").defaultPrevented).toBe(false);
});
it("lists the keys only over a list that takes a selection and has items", async () => {
render(
<>
<Scopes />
<Rows selection={selection()} />
</>,
);
await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("listSelect"));
cleanup();
render(
<>
<Scopes />
<Rows />
</>,
);
await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("list"));
expect(screen.getByTestId("scopes").textContent).not.toContain("listSelect");
});
});
+34
View File
@@ -36,6 +36,15 @@ interface NavList {
readonly reveal: (index: number) => void;
/** Takes the item at `index` out of the list (Delete), or null for a list that cannot. */
readonly remove: () => ((index: number) => void) | null;
/** The list's selection (`x`, Esc), or null for a list whose items cannot be selected. */
readonly selection: () => NavSelection | null;
}
/** What `x` and Esc do in a list whose items can be selected. `clear` says whether it cleared
* anything: an Esc that did not is left for whoever else answers it. */
export interface NavSelection {
readonly toggle: (index: number) => void;
readonly clear: () => boolean;
}
const lists = new Map<HTMLElement, NavList>();
@@ -432,6 +441,27 @@ function onKey(e: KeyboardEvent): void {
},
{ inTopLayer },
);
dispatch(
e,
"listSelect",
{
// Anywhere in the item, unlike Delete: a tick is undone by the same key, and after a click on
// the box the focus sits inside the row. Held down, it must not flip back and forth.
select: (ev) => {
const cur = current();
const selection = cur?.list.selection();
if (!cur || !selection || ev.repeat) return;
ev.preventDefault();
selection.toggle(cur.index);
},
clearSelection: (ev) => {
let cleared = false;
for (const list of ordered()) if (list.selection()?.clear()) cleared = true;
if (cleared) ev.preventDefault();
},
},
{ inTopLayer },
);
}
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
@@ -599,6 +629,8 @@ export function useListNav(
reveal?: (index: number) => void;
/** A list whose items the Delete key takes out (a playlist's rows): the item's index. */
onDelete?: (index: number) => void;
/** A list whose items can be selected, by `x` and Esc. */
selection?: NavSelection;
},
): void {
// Read when a key is pressed, so the registration below never re-runs for a new `opts` object.
@@ -609,6 +641,7 @@ export function useListNav(
const [hasItems, setHasItems] = useState(false);
useKeyScope("list", opts.enabled && hasItems);
useKeyScope("listDelete", opts.enabled && hasItems && !!opts.onDelete);
useKeyScope("listSelect", opts.enabled && hasItems && !!opts.selection);
useEffect(() => {
const el = ref.current;
if (!opts.enabled || !el) return;
@@ -618,6 +651,7 @@ export function useListNav(
cols: () => latestOpts().cols ?? measuredCols(el),
reveal: (i) => latestOpts().reveal?.(i),
remove: () => latestOpts().onDelete ?? null,
selection: () => latestOpts().selection ?? null,
});
el.setAttribute(LIST, "");
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
+2
View File
@@ -24,6 +24,8 @@ type ColumnFilter<T> =
export interface Column<T> {
key: string;
header: string;
// A header that is a control of its own (the box that selects every row), in place of `header`.
headerCell?: ReactNode;
render: (row: T) => ReactNode;
align?: "left" | "right" | "center";
width?: string;
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useRowSelection } from "./useRowSelection";
const ABCDE = ["a", "b", "c", "d", "e"];
function setup(shown = ABCDE, all = ABCDE, scope: unknown = 1) {
return renderHook(
(p: { all: string[]; shown: string[]; scope: unknown }) =>
useRowSelection(p.all, p.shown, p.scope),
{ initialProps: { all, shown, scope } },
);
}
describe("useRowSelection", () => {
it("toggles one row at a time, and says the selected rows in the order shown", () => {
const { result } = setup();
act(() => result.current.toggle("d"));
act(() => result.current.toggle("b"));
expect(result.current.shownSelected).toEqual(["b", "d"]);
act(() => result.current.toggle("d"));
expect(result.current.shownSelected).toEqual(["b"]);
expect(result.current.isSelected("b")).toBe(true);
expect(result.current.isSelected("d")).toBe(false);
});
it("selects the range from the last row toggled, in either direction", () => {
const { result } = setup();
act(() => result.current.toggle("b"));
act(() => result.current.toggle("d", true));
expect(result.current.shownSelected).toEqual(["b", "c", "d"]);
act(() => {
result.current.clear();
});
act(() => result.current.toggle("d"));
act(() => result.current.toggle("b", true));
expect(result.current.shownSelected).toEqual(["b", "c", "d"]);
});
it("takes a range's state from the row the range ends on: it can clear rows too", () => {
const { result } = setup();
act(() => result.current.toggle("a"));
act(() => result.current.toggle("e", true));
expect(result.current.shownSelected).toEqual(ABCDE);
// `e` is selected, so a range ending on it clears; the anchor moved to `e` with the last click.
act(() => result.current.toggle("b", true));
expect(result.current.shownSelected).toEqual(["a"]);
});
it("leaves rows outside the range alone", () => {
const { result } = setup();
act(() => result.current.toggle("a"));
act(() => result.current.toggle("c"));
act(() => result.current.toggle("e", true));
expect(result.current.shownSelected).toEqual(["a", "c", "d", "e"]);
});
it("follows the order on screen, not the stored one", () => {
const { result } = setup(["e", "c", "a", "b", "d"]);
act(() => result.current.toggle("c"));
act(() => result.current.toggle("b", true));
expect(result.current.shownSelected).toEqual(["c", "a", "b"]);
});
it("makes a range with no row to start from a plain toggle", () => {
const { result } = setup();
act(() => result.current.toggle("c", true));
expect(result.current.shownSelected).toEqual(["c"]);
});
it("makes a range whose first row a filter hid a plain toggle", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
rerender({ all: ABCDE, shown: ["c", "d", "e"], scope: 1 });
act(() => result.current.toggle("e", true));
expect(result.current.shownSelected).toEqual(["e"]);
});
it("has the header box cover the rows shown, and keep what a filter hides", () => {
const { result, rerender } = setup();
expect(result.current.headerState).toBe("none");
act(() => result.current.toggle("a"));
expect(result.current.headerState).toBe("some");
rerender({ all: ABCDE, shown: ["c", "d"], scope: 1 });
// The one selected row is hidden: nothing SHOWN is selected, and acting counts what is shown.
expect(result.current.headerState).toBe("none");
expect(result.current.shownSelected).toEqual([]);
act(() => result.current.toggleAllShown());
expect(result.current.headerState).toBe("all");
expect(result.current.shownSelected).toEqual(["c", "d"]);
act(() => result.current.toggleAllShown());
expect(result.current.headerState).toBe("none");
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
expect(result.current.shownSelected).toEqual(["a"]);
});
it("selects every row shown from a partial selection", () => {
const { result } = setup();
act(() => result.current.toggle("b"));
act(() => result.current.toggleAllShown());
expect(result.current.shownSelected).toEqual(ABCDE);
});
it("has no header state to show over no rows", () => {
const { result } = setup([], []);
expect(result.current.headerState).toBe("none");
act(() => result.current.toggleAllShown());
expect(result.current.headerState).toBe("none");
});
it("drops a row that left the list, so it does not come back selected", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("b"));
act(() => result.current.toggle("c"));
rerender({ all: ["a", "c", "d", "e"], shown: ["a", "c", "d", "e"], scope: 1 });
expect(result.current.shownSelected).toEqual(["c"]);
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
expect(result.current.shownSelected).toEqual(["c"]);
});
it("clears on a change of scope, in the render that changes it", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
// The same videos in another list are not selected there.
rerender({ all: ABCDE, shown: ABCDE, scope: 2 });
expect(result.current.shownSelected).toEqual([]);
act(() => result.current.toggle("c", true));
expect(result.current.shownSelected).toEqual(["c"]);
});
it("says whether clearing did anything", () => {
const { result } = setup();
let cleared = true;
act(() => {
cleared = result.current.clear();
});
expect(cleared).toBe(false);
act(() => result.current.toggle("a"));
act(() => {
cleared = result.current.clear();
});
expect(cleared).toBe(true);
expect(result.current.shownSelected).toEqual([]);
});
it("counts a selected row a filter hides as something to clear", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
rerender({ all: ABCDE, shown: ["c"], scope: 1 });
let cleared = false;
act(() => {
cleared = result.current.clear();
});
expect(cleared).toBe(true);
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
expect(result.current.shownSelected).toEqual([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { useState } from "react";
const NONE: ReadonlySet<string> = new Set();
/** Which rows of a table are ticked, for the bulk actions over them. `all` is every row the list
* holds and `shown` the ones on screen, in screen order. A row a filter hides stays selected and
* comes back ticked, but `shownSelected`, what an action covers, leaves it out: nothing happens to a
* row the reader cannot see. A row that left the list drops out for good, or an Undo would bring it
* back ticked. A change of `scope` starts over: the same video in another list is another row. */
export function useRowSelection(all: readonly string[], shown: readonly string[], scope: unknown) {
const [selected, setSelected] = useState(NONE);
// The last row toggled: where a Shift range starts.
const [anchor, setAnchor] = useState<string | null>(null);
const [selectedFor, setSelectedFor] = useState(scope);
if (selectedFor !== scope) {
setSelectedFor(scope);
setSelected(NONE);
setAnchor(null);
}
const held = new Set(all);
const kept = [...selected].filter((id) => held.has(id));
if (kept.length !== selected.size) setSelected(new Set(kept));
const shownSelected = shown.filter((id) => selected.has(id));
function toggle(id: string, range = false) {
const on = !selected.has(id);
const from = range && anchor != null ? shown.indexOf(anchor) : -1;
const to = shown.indexOf(id);
// Taken from where the range ends, the way a mail list does: it can clear a run of rows too.
const rows =
from >= 0 && to >= 0 ? shown.slice(Math.min(from, to), Math.max(from, to) + 1) : [id];
const next = new Set(selected);
for (const row of rows) {
if (on) next.add(row);
else next.delete(row);
}
setSelected(next);
setAnchor(id);
}
function toggleAllShown() {
const next = new Set(selected);
const on = shownSelected.length < shown.length;
for (const row of shown) {
if (on) next.add(row);
else next.delete(row);
}
setSelected(next);
setAnchor(null);
}
/** Says whether there was anything to clear: an Esc that cleared nothing is not spent. */
function clear(): boolean {
const had = selected.size > 0;
if (had) setSelected(NONE);
setAnchor(null);
return had;
}
return {
shownSelected,
isSelected: (id: string) => selected.has(id),
headerState:
shownSelected.length === 0
? ("none" as const)
: shownSelected.length === shown.length
? ("all" as const)
: ("some" as const),
toggle,
toggleAllShown,
clear,
};
}
@@ -863,9 +863,9 @@ describe("Playlists · a narrow table", () => {
el.classList.contains("hidden") && el.classList.contains("@2xl:table-cell");
const heads = [...document.querySelectorAll("thead th")];
const cells = [...document.querySelectorAll("tbody tr:first-child td")];
// Columns: grip, #, title, channel, length, "…". Length went too (the owner's pick).
expect(heads.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
expect(cells.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
// Columns: box, grip, #, title, channel, length, "…". Length went too (the owner's pick).
expect(heads.map(narrowHidden)).toEqual([false, false, true, false, true, true, false]);
expect(cells.map(narrowHidden)).toEqual([false, false, true, false, true, true, false]);
});
it("carries the hidden length on the picture and the channel under a two-line title", async () => {
@@ -1036,3 +1036,94 @@ describe("Playlists · the view in the address", () => {
expect(param("plgroup")).toBeNull();
});
});
describe("Playlists — selecting rows", () => {
const box = (title: string) =>
screen.findByRole("checkbox", { name: `playlists.selectRow:${title}` });
const rowOf = (el: HTMLElement) => el.closest("tr")!;
const header = () => screen.findByRole("checkbox", { name: "playlists.selectAllShown" });
const three = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
const press = (key: string) =>
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
it("gives every row a box, first in the row, and marks a selected row", async () => {
mount(three);
const a = (await box("Video A")) as HTMLInputElement;
expect(rowOf(a).cells[0]!.contains(a)).toBe(true);
expect(a.checked).toBe(false);
expect(rowOf(a).getAttribute("data-selected")).toBeNull();
fireEvent.click(a);
expect(a.checked).toBe(true);
expect(rowOf(a).getAttribute("data-selected")).toBe("");
expect(((await box("Video B")) as HTMLInputElement).checked).toBe(false);
});
it("selects a range on Shift+click", async () => {
mount(three);
fireEvent.click(await box("Video A"));
fireEvent.click(await box("Video C"), { shiftKey: true });
expect(((await box("Video B")) as HTMLInputElement).checked).toBe(true);
expect(((await box("Video C")) as HTMLInputElement).checked).toBe(true);
});
it("has the header box say all, some or none, and cover the rows", async () => {
mount(three);
const all = (await header()) as HTMLInputElement;
expect([all.checked, all.indeterminate]).toEqual([false, false]);
fireEvent.click(await box("Video B"));
expect([all.checked, all.indeterminate]).toEqual([false, true]);
fireEvent.click(all);
expect([all.checked, all.indeterminate]).toEqual([true, false]);
expect(((await box("Video C")) as HTMLInputElement).checked).toBe(true);
fireEvent.click(all);
expect([all.checked, all.indeterminate]).toEqual([false, false]);
expect(((await box("Video C")) as HTMLInputElement).checked).toBe(false);
});
it("toggles the walked row on x and clears on Esc", async () => {
mount(three);
const b = (await box("Video B")) as HTMLInputElement;
press("j");
press("j");
await act(async () => press("x"));
expect(b.checked).toBe(true);
await act(async () => press("Escape"));
expect(b.checked).toBe(false);
});
it("clears the selection on a switch to another list holding the same video", async () => {
const { rerender } = mount({ 1: [vid("A", 0)], 2: [vid("A", 0)] });
fireEvent.click(await box("Video A"));
h.selectedId = 2;
rerender();
await waitFor(() => expect(api.playlist).toHaveBeenCalledWith(2));
await waitFor(() => expect(screen.queryByText("List 1")).toBeNull());
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(false);
});
it("drops a row removed from the list out of the selection", async () => {
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
mount(three);
fireEvent.click(await box("Video A"));
fireEvent.click(await box("Video B"));
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() =>
expect(screen.queryByRole("checkbox", { name: "playlists.selectRow:Video A" })).toBeNull(),
);
const all = (await header()) as HTMLInputElement;
expect(all.indeterminate).toBe(true);
expect(((await box("Video B")) as HTMLInputElement).checked).toBe(true);
});
it("offers no selecting on Watch later", async () => {
h.selectedId = WATCH_LATER;
mount({ [WATCH_LATER]: [vid("A", 0)] });
await screen.findByRole("button", { name: "card.play — Video A" });
expect(screen.queryAllByRole("checkbox", { name: /playlists\.select/ })).toEqual([]);
press("j");
press("x");
expect(screen.queryAllByRole("checkbox", { name: /playlists\.select/ })).toEqual([]);
});
});
+39 -4
View File
@@ -41,6 +41,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { playlistName, playlistNameText } from "../../lib/playlistName";
import { reinsertAt } from "../../lib/reinsert";
import { useRowSelection } from "../../lib/useRowSelection";
import { useUndoable } from "../../lib/useUndoable";
import { cycleSort, type SortState } from "../../lib/columnSort";
import { useUrlMirror } from "../../lib/useUrlMirror";
@@ -79,7 +80,7 @@ import {
type Column,
type FilterMap,
} from "../../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { GRIP_KEY, playlistColumns, selectColumn } from "./playlistColumns";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
import { DragHandle } from "../../components/ui/DragHandle";
import { useOrderItems } from "../../components/ui/useOrderItems";
@@ -119,6 +120,7 @@ function Row({
video,
index,
columns,
selected,
dragDisabled,
dragHint,
menuProps,
@@ -127,6 +129,7 @@ function Row({
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
index: number;
columns: Column<Video>[];
selected: boolean;
dragDisabled: boolean;
dragHint: string;
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
@@ -152,7 +155,8 @@ function Row({
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
// the same lag by another route (150ms behind the pointer, on every row).
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
className="group border-b border-border/50 state-layer transition-colors"
className={`group border-b border-border/50 transition-colors ${selected ? "bg-accent/12 hover:bg-accent/16" : "state-layer"}`}
data-selected={selected ? "" : undefined}
{...navItemAttrs(index)}
{...menuProps}
>
@@ -445,6 +449,25 @@ function PlaylistDetail() {
[items, columns, filters, viewSort, groupBy, lang],
);
// The rows the bulk actions cover. Watch later offers no edit, so it has nothing to select them for.
const selection = useRowSelection(
items.map((v) => v.id),
shown.map((v) => v.id),
selectedId,
);
const tableColumns = canEdit
? [
selectColumn({
t,
state: selection.headerState,
onToggleAll: selection.toggleAllShown,
isSelected: selection.isSelected,
onToggle: selection.toggle,
}),
...columns,
]
: columns;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
// offers itself when the view is a pure reordering of the whole list.
@@ -1106,7 +1129,7 @@ function PlaylistDetail() {
min-content, and a nowrap title cell's min-content is the entire title. */}
<table className="w-full table-fixed border-collapse text-sm">
<ColumnHead
columns={columns}
columns={tableColumns}
sort={viewSort}
onToggleSort={toggleSort}
filters={filters}
@@ -1130,13 +1153,25 @@ function PlaylistDetail() {
}
: undefined
}
selection={
canEdit
? {
toggle: (i) => {
const v = shownRef.current[i];
if (v) selection.toggle(v.id);
},
clear: selection.clear,
}
: undefined
}
>
{shown.map((v, i) => (
<Row
key={v.id}
index={i}
video={v}
columns={columns}
columns={tableColumns}
selected={selection.isSelected(v.id)}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
menuProps={rowMenu.triggerProps(v)}
@@ -6,6 +6,7 @@ import { formatDuration } from "../../lib/format";
import type { Column } from "../../lib/tableColumns";
import { Pressable } from "../../components/ui/Pressable";
import RemoteImage from "../../components/ui/RemoteImage";
import { RowSelectBox } from "../../components/ui/RowSelectBox";
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
//
@@ -145,6 +146,46 @@ export function playlistColumns({
return cols;
}
/** The box column, first in the row, for a list whose rows can be selected. A separate column from
* `playlistColumns`, whose array the view's filter and sort read: a tick must not re-run them. */
export function selectColumn({
t,
state,
onToggleAll,
isSelected,
onToggle,
}: {
t: TFunction;
state: "none" | "some" | "all";
onToggleAll: () => void;
isSelected: (id: string) => boolean;
onToggle: (id: string, range: boolean) => void;
}): Column<Video> {
const allLabel = t("playlists.selectAllShown");
return {
key: "select",
header: allLabel,
headerCell: (
<RowSelectBox
label={allLabel}
title={allLabel}
checked={state === "all"}
mixed={state === "some"}
onToggle={onToggleAll}
/>
),
width: "2.25rem",
hideInCard: true,
render: (v) => (
<RowSelectBox
label={t("playlists.selectRow", { title: v.title })}
checked={isSelected(v.id)}
onToggle={(shift) => onToggle(v.id, shift)}
/>
),
};
}
// A video's length, or its live / upcoming state when it has none. `onPicture`: the dark badge on the
// thumbnail (the narrow row), readable on any frame, like the media card's length badge.
function lengthCell(v: Video, t: TFunction, onPicture = false): ReactNode {