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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user