feat(design): the channel menu's Tags and Priority submenus (C1, E28 S3e3b)

Tags A-Z as in-place checks (the menu stays open for the next); Priority
as the fixed steps 10 5 3 2 1 0, an off-scale current value ticked in its
place. A pick re-sorts the list at once, moving only that row
(placeByPriority, the server's order), and the table / card pager and
the focus follow it (DataTable and useCardPager take a reveal seq,
listNav.focusNavItem lands on the row's own "…", a virtualized card
revealed first). ActionMenu rows take check: many | one. The menu reads
the row fresh from the cache and keys its target by id.
This commit is contained in:
2026-09-26 17:34:47 +02:00
parent 2b3094ffc5
commit 5e5c8a42a9
15 changed files with 467 additions and 28 deletions
+70 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -25,6 +25,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions";
import { useChannelPriority } from "../lib/useChannelPriority";
import { placeByPriority } from "../lib/channelOrder";
import { focusNavItem } from "../lib/listNav";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ui/ColumnSortControl";
import FilterChipGroup from "./ui/FilterChipGroup";
@@ -129,9 +131,10 @@ export default function Channels() {
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't
// jump (the server list is priority-sorted; refetching would reorder rows and lose your scroll
// position). The new order takes effect next time the page loads. Press-and-hold is debounced +
// The row's stepper updates priority optimistically in place and does NOT re-sort/refetch, so the
// list doesn't jump under a press-and-hold (the server list is priority-sorted; refetching would
// reorder rows and lose your scroll position) — its new order shows next time the page loads. A
// pick from the row's menu DOES re-sort (`pickPriority` below). Press-and-hold is debounced +
// flushed on unmount by the shared hook (same flow the channel detail page uses).
const { setPriority, bumpPriority } = useChannelPriority({
read: (id) =>
@@ -143,6 +146,21 @@ export default function Channels() {
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
});
// A priority step picked from the row's menu (E28 S3e3b, owner): the one thing a pick is FOR is
// to move the channel up (or down) the list, so the list re-sorts at once — only that row moves
// (`placeByPriority`, the server's order) — and the page and the focus follow it (`follow`): the
// table/card pager turns to its page, and the effect below hands the focus to its "…" (its node
// may have been re-inserted, which drops the focus to <body>, or be a virtualized card not yet
// mounted). Picked again from there, the next step is one Enter away.
const [follow, setFollow] = useState<{ key: string; seq: number } | null>(null);
const moreRefs = useRef(new Map<string, HTMLButtonElement>());
const listRef = useRef<HTMLDivElement>(null);
const pickPriority = (c: ManagedChannel, next: number) => {
setPriority(c.id, next);
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) => old && placeByPriority(old, c.id));
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
};
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
// attach/detach + create-and-attach flow is shared with the channel detail page.
@@ -297,10 +315,22 @@ export default function Channels() {
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
const rowMenu = useContextMenu<ManagedChannel>();
// Keyed by id: a tag toggled in the menu, a priority picked, a poll — each rebuilds the row object
// under the open menu, and its "…" must still know the menu is its own.
const rowMenu = useContextMenu<ManagedChannel>({ keyOf: (c) => c.id });
const rowPin = useComfortPin(rowMenu.open);
const channelActions = useChannelActions({ onView, onOpenWizard });
const menuRow = rowMenu.target;
const channelActions = useChannelActions({
onView,
onOpenWizard,
tags: userTags,
onToggleTag: (c, tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId)),
onPickPriority: pickPriority,
});
// The row as the cache holds it NOW, not as it was when the menu opened: the Tags submenu stays
// open across toggles, and each tick (and the next toggle's attach-or-detach) reads this.
const menuRow =
rowMenu.target &&
((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target);
const menuItems = menuRow
? channelActions.items(menuRow, {
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
@@ -318,9 +348,13 @@ export default function Channels() {
bumpPriority,
setPriority,
more: (c) => {
const mine = rowMenu.open && rowMenu.target === c;
const mine = rowMenu.open && rowMenu.target?.id === c.id;
return (
<MoreButton
ref={(el) => {
if (el) moreRefs.current.set(c.id, el);
else moreRefs.current.delete(c.id);
}}
label={t("channels.row.menu")}
expanded={mine}
menuProps={rowMenu.buttonProps(c)}
@@ -422,7 +456,32 @@ export default function Channels() {
paged: pagedCards,
pagerProps,
resetPage,
} = useCardPager(sortedChannels, LS.channelCardSize, isCards);
} = useCardPager(
sortedChannels,
LS.channelCardSize,
isCards,
follow && { seq: follow.seq, match: (c) => c.id === follow.key },
);
// The focus half of `follow` (the pagers took the page half during render). A mounted row names
// its own place; a card the virtualized grid has not mounted yet is found by its place on the page.
const focusFollowed = useEffectEvent((key: string) => {
const wrap = listRef.current;
if (!wrap) return;
const button = () => moreRefs.current.get(key) ?? null;
const item = button()?.closest("[data-nav-index]");
const at = item
? Number(item.getAttribute("data-nav-index"))
: isCards
? sortedChannels.findIndex((c) => c.id === key) -
pagerProps.page * Math.max(0, pagerProps.size)
: -1;
// Filtered out by its new priority (a Prio column filter): nothing to follow — honest, not a
// jump to some other row.
if (at >= 0) focusNavItem(wrap, at, button);
});
useEffect(() => {
if (follow) focusFollowed(follow.key);
}, [follow]);
// Changing what you're looking at starts over at the first page, the way DataTable resets its own
// page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would
@@ -570,7 +629,7 @@ export default function Channels() {
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
`position: sticky` engages on the first scroll (the band's controls row above gives the
visual gap). */
<div className="px-4 pb-4 max-w-384 mx-auto">
<div ref={listRef} className="px-4 pb-4 max-w-384 mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
card layout swaps the table for a virtualized grid over the same rows (its pager and
@@ -601,6 +660,7 @@ export default function Channels() {
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
rowClassName={() => "group"}
keyboardNav
reveal={follow}
/>
) : (
<ChannelLayoutGrid
@@ -84,6 +84,37 @@ describe("ActionMenu", () => {
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
});
it("a 'many' check row toggles in place: the menu stays open and the focus stays on it", async () => {
const onSelect = vi.fn();
renderMenu([
{ id: "rust", label: "rust", check: { kind: "many", checked: true }, onSelect },
{ id: "go", label: "go", check: { kind: "many", checked: false }, onSelect: vi.fn() },
]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
const rows = screen.getAllByRole("menuitemcheckbox", { hidden: true });
expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]);
fireEvent.click(rows[0]!);
// Synchronously — no close, no deferred act — and nothing closed around it.
expect(onSelect).toHaveBeenCalledTimes(1);
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true })).toHaveLength(2);
await new Promise((r) => setTimeout(r, 0));
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true })).toHaveLength(2);
});
it("a 'one' check row is a radio that picks and closes like any row", async () => {
const onSelect = vi.fn();
renderMenu([
{ id: "10", label: "10", check: { kind: "one", checked: false }, onSelect },
{ id: "5", label: "5", check: { kind: "one", checked: true }, onSelect: vi.fn() },
]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["false", "true"]);
fireEvent.click(rows[0]!);
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
});
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
const onSelect = vi.fn();
renderMenu([{ id: "sync", label: "Sync", onSelect, disabled: true }]);
+20 -3
View File
@@ -3,13 +3,15 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
import { IconButton } from "./IconButton";
import { MenuCheck } from "./MenuCheck";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
// fire-and-close (`onSelect`) — all but a `check: { kind: "many" }` row, which toggles in place; a
// `disabled` item (e.g. a mutation already in flight) is inert.
//
// E10 S2 split the ITEM LIST out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click
// context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point
@@ -62,6 +64,11 @@ interface ActionItem extends MenuItemBase {
* user's gesture (a task later, as `closeThenAct` runs every other row, it is refused). Only for
* an action that opens nothing and moves no focus: that is what the deferral exists for. */
inGesture?: boolean;
/** A checkable row, its state shown in the icon slot. `many`: one of a set that can all be on (a
* channel's tags) — a menuitemcheckbox that toggles IN PLACE: the menu stays open, so the next
* tag is one click away and the check just set is seen. `one`: one choice of several (a priority
* step) — a menuitemradio that picks and closes, like any other row. */
check?: { kind: "many" | "one"; checked: boolean };
items?: never;
}
@@ -99,19 +106,29 @@ export function MenuItemList({
<SubMenu item={it} onClose={onClose} />
) : (
<PopoverItem
role="menuitem"
role={
it.check
? it.check.kind === "many"
? "menuitemcheckbox"
: "menuitemradio"
: "menuitem"
}
checked={it.check?.checked}
disabled={it.disabled}
label={it.label}
title={it.title}
// Any row may open a dialog (a confirm), so every row closes first — see closeThenAct.
// A `many` row opens nothing and stays: the focus is still on it, nothing to hand back.
onSelect={() => {
if (it.check?.kind === "many") return it.onSelect();
if (!it.inGesture) return closeThenAct(onClose, it.onSelect);
it.onSelect();
onClose();
}}
className={`${ROW}${it.danger ? " text-danger" : ""}`}
>
{it.icon && (
{it.check && <MenuCheck kind={it.check.kind} on={it.check.checked} />}
{!it.check && it.icon && (
<span
className={`shrink-0 ${it.danger ? "text-danger" : "text-muted"}${dim(it.disabled)}`}
>
@@ -136,3 +136,27 @@ describe("DataTable · keyboardNav", () => {
expect(document.activeElement).toBe(document.body);
});
});
describe("DataTable · reveal", () => {
const many: Row[] = Array.from({ length: 25 }, (_, i) => ({ id: `r${i}` }));
const cols: Column<Row>[] = [
{ key: "name", header: "Name", cardPrimary: true, render: (r) => r.id },
];
const shown = () => [...document.querySelectorAll("tbody tr")].map((tr) => tr.textContent);
it("turns to the page that now holds a moved row on each new seq", () => {
viewport(true);
const view = (list: Row[], reveal: { key: string; seq: number } | null) => (
<DataTable rows={list} columns={cols} rowKey={(r) => r.id} reveal={reveal} />
);
const { rerender } = render(view(many, { key: "r22", seq: 0 }));
// The mount's seq is not a request: the first page stays.
expect(shown()[0]).toBe("r0");
rerender(view(many, { key: "r22", seq: 1 }));
expect(shown()).toContain("r22");
expect(shown()[0]).toBe("r20");
// r22 bubbled up to the top: the next seq follows it back to page one.
rerender(view([many[22]!, ...many.filter((r) => r.id !== "r22")], { key: "r22", seq: 2 }));
expect(shown()[0]).toBe("r22");
});
});
+14
View File
@@ -69,6 +69,7 @@ export default function DataTable<T>({
onFiltersChange,
fixedLayout = false,
keyboardNav = false,
reveal,
}: {
rows: T[];
columns: Column<T>[];
@@ -121,6 +122,10 @@ export default function DataTable<T>({
// Enter clicks the element a column's render marks `data-nav-open` (the channel name). Both row
// shapes register; listNav walks only the one on screen, so the hidden copy is never a stop.
keyboardNav?: boolean;
// Follow a row the caller just MOVED (a channel re-sorted by the priority its menu set): each new
// `seq` turns to the page that now holds the row `key`. Focus is the caller's (it knows which of
// the row's controls it came from); the page is the table's, so it is decided here.
reveal?: { key: string; seq: number } | null;
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
@@ -208,6 +213,15 @@ export default function DataTable<T>({
const allRows = size <= 0;
const totalPages = allRows ? 1 : Math.max(1, Math.ceil(sorted.length / size));
const safePage = Math.min(page, totalPages - 1);
// Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame
// that re-sorted the row already shows its new page. Seeded with the mount's seq: arriving with a
// stale reveal must not move a page nobody asked to move.
const [revealSeen, setRevealSeen] = useState(reveal?.seq);
if (reveal && reveal.seq !== revealSeen) {
setRevealSeen(reveal.seq);
const at = sorted.findIndex((row) => rowKey(row) === reveal.key);
if (at >= 0 && !allRows) setPage(Math.floor(at / size));
}
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
function toggleSort(key: string) {
@@ -2,7 +2,8 @@
//
// A subscribed channel's ONE action list (E28 C1): what the manager row's "…" (and its right-click,
// long-press and Shift+F10) offers — and the row buttons it replaced, now rows here: full history,
// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed).
// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed) — and the Tags and
// Priority submenus (E28 S3e3b).
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, act, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -27,7 +28,8 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.canWrite }) }));
import { useChannelActions } from "./useChannelActions";
import { api, type ManagedChannel } from "../lib/api";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import type { ActionMenuItem } from "./ui/ActionMenu";
afterEach(() => {
cleanup();
@@ -37,6 +39,9 @@ afterEach(() => {
h.confirm.mockImplementation(async () => true);
h.role = "user";
h.canWrite = true;
tags = [];
onToggleTag.mockReset();
onPickPriority.mockReset();
});
const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
@@ -62,12 +67,19 @@ const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
});
const onView = vi.fn();
const onToggleTag = vi.fn();
const onPickPriority = vi.fn();
let tags: Tag[] = [];
const tag = (id: number, name: string) => ({ id, name }) as Tag;
function mount() {
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
return renderHook(() => useChannelActions({ onView, onOpenWizard: () => {} }), { wrapper });
return renderHook(
() => useChannelActions({ onView, onOpenWizard: () => {}, tags, onToggleTag, onPickPriority }),
{ wrapper },
);
}
const pin = [{ id: "pin", label: "pin", onSelect: () => {} }];
const ids = (items: { id: string }[]) => items.map((i) => i.id);
@@ -75,11 +87,13 @@ const rowsFor = (c: ManagedChannel) => mount().result.current.items(c, { pin });
const row = (c: ManagedChannel, id: string) => rowsFor(c).find((i) => i.id === id)!;
describe("useChannelActions — the menu", () => {
it("lists open, pin, the YouTube + copy group, full history, hide and unsubscribe", () => {
it("lists open, pin, tags + priority, the YouTube + copy group, full history, hide and unsubscribe", () => {
const items = rowsFor(ch());
expect(ids(items)).toEqual([
"open",
"pin",
"tags",
"priority",
"open-youtube",
"copy-text",
"copy-link",
@@ -87,8 +101,9 @@ describe("useChannelActions — the menu", () => {
"hide",
"unsubscribe",
]);
// Groups: YouTube/copy, the channel's own settings, then the destructive row alone.
// Groups: tuning, YouTube/copy, the channel's own settings, then the destructive row alone.
expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([
"tags",
"open-youtube",
"backfill",
"unsubscribe",
@@ -188,3 +203,45 @@ describe("useChannelActions — hide and unsubscribe", () => {
await waitFor(() => expect(unsub).toHaveBeenCalledWith("UC1"));
});
});
const sub = (it: ActionMenuItem) => (it.items ?? []) as ActionMenuItem[];
describe("useChannelActions — the Tags submenu", () => {
it("lists the reader's tags A–Z, the channel's own ticked, each a toggle that stays open", () => {
tags = [tag(3, "rust"), tag(1, "Go"), tag(2, "music")];
const items = sub(row(ch({ tag_ids: [2] }), "tags"));
expect(items.map((i) => i.label)).toEqual(["Go", "music", "rust"]);
expect(items.map((i) => ("check" in i ? i.check : null))).toEqual([
{ kind: "many", checked: false },
{ kind: "many", checked: true },
{ kind: "many", checked: false },
]);
items[1]!.onSelect!();
expect(onToggleTag).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 2);
});
it("stays in place, off, saying where tags are made, with no tag yet", () => {
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "channels.row.noTagsYet" });
expect(row(ch(), "tags").items).toBeUndefined();
});
});
describe("useChannelActions — the Priority submenu", () => {
it("offers the fixed steps with the current one ticked, and picks a new one", () => {
const items = sub(row(ch({ priority: 3 }), "priority"));
expect(items.map((i) => i.label)).toEqual(["10", "5", "3", "2", "1", "0"]);
expect(items.filter((i) => "check" in i && i.check?.checked).map((i) => i.label)).toEqual([
"3",
]);
expect(items.every((i) => "check" in i && i.check?.kind === "one")).toBe(true);
items[0]!.onSelect!();
expect(onPickPriority).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 10);
});
it("shows an off-scale priority ticked in its place, and re-picking the current one is a no-op", () => {
const items = sub(row(ch({ priority: 7 }), "priority"));
expect(items.map((i) => i.label)).toEqual(["10", "7", "5", "3", "2", "1", "0"]);
items[1]!.onSelect!();
expect(onPickPriority).not.toHaveBeenCalled();
});
});
+57 -3
View File
@@ -1,8 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { EyeOff, ExternalLink, History, Tv, UserMinus } from "lucide-react";
import { ArrowUpDown, EyeOff, ExternalLink, History, Tags, Tv, UserMinus } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type ManagedChannel } from "../lib/api";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { priorityChoices } from "../lib/channelOrder";
import { channelYouTubeUrl } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -14,14 +15,24 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
// A subscribed channel's ONE action list (E28 C1): the manager row's "…", its right-click, long-press
// and Shift+F10, in the table and in the card layout alike. The row keeps what the reader looks at
// or tunes all the time (priority, tags, the stored count's ✓); everything that was a button on the
// row — full history, hide, unsubscribe — is here, beside open, the pin and the copies.
// row — full history, hide, unsubscribe — is here, beside open, the pin and the copies. Tags and
// priority are here TOO, as submenus (owner, 2026-09-26): tagging a run of channels, or bubbling one
// up the list, from the menu the hand is already in.
export function useChannelActions({
onView,
onOpenWizard,
tags,
onToggleTag,
onPickPriority,
}: {
onView: (c: ManagedChannel) => void;
onOpenWizard: () => void;
/** The reader's own tags (no system tags) — the Tags submenu lists them A–Z. */
tags: readonly Tag[];
onToggleTag: (c: ManagedChannel, tagId: number) => void;
/** A priority step picked: the caller re-sorts its list and follows the row. */
onPickPriority: (c: ManagedChannel, priority: number) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
@@ -130,6 +141,47 @@ export function useChannelActions({
};
}
/** Tags A–Z, one check each, toggled in place (the menu stays open for the next one). With no tag
* yet the row stays, off, and says where tags are made. */
function tagsItem(c: ManagedChannel): ActionMenuItem {
const base = {
id: "tags",
label: t("channels.tags.label"),
icon: <Tags className="w-4 h-4" />,
};
if (tags.length === 0)
return { ...base, disabled: true, note: t("channels.row.noTagsYet"), onSelect: () => {} };
const on = new Set(c.tag_ids);
return {
...base,
items: [...tags]
.sort((a, b) => a.name.localeCompare(b.name))
.map((tg) => ({
id: `tag-${tg.id}`,
label: tg.name,
check: { kind: "many" as const, checked: on.has(tg.id) },
onSelect: () => onToggleTag(c, tg.id),
})),
};
}
/** The fixed priority steps, the current one ticked; a pick re-sorts the list (the caller). */
function priorityItem(c: ManagedChannel): ActionMenuItem {
return {
id: "priority",
label: t("channels.row.priority"),
icon: <ArrowUpDown className="w-4 h-4" />,
items: priorityChoices(c.priority).map((p) => ({
id: `priority-${p}`,
label: String(p),
check: { kind: "one" as const, checked: p === c.priority },
onSelect: () => {
if (p !== c.priority) onPickPriority(c, p);
},
})),
};
}
/** The channel's menu rows; `pin` is the caller's comfort-shelf row (its "is it pinned?" read
* follows the caller's own menu). */
function items(c: ManagedChannel, { pin }: { pin: ActionMenuItem[] }): ActionMenuItem[] {
@@ -142,6 +194,8 @@ export function useChannelActions({
onSelect: () => onView(c),
},
...pin,
{ ...tagsItem(c), separatorBefore: true },
priorityItem(c),
...[
{
id: "open-youtube",
+3 -1
View File
@@ -118,7 +118,9 @@
"unsubscribeHint": "Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.",
"unsubscribeOnYoutube": "Unsubscribe on YouTube",
"thisChannel": "This channel",
"menu": "Channel actions"
"menu": "Channel actions",
"priority": "Priority",
"noTagsYet": "No tags yet — make one with a row’s tag “+”"
},
"notify": {
"synced": "Synced {{count}} subscriptions",
+3 -1
View File
@@ -118,7 +118,9 @@
"unsubscribeHint": "Leiratkozás erről a csatornáról a YouTube-on (megváltoztatja a valódi fiókodat). Csak olvasható módban ez rejtve van — használd helyette az Elrejtést.",
"unsubscribeOnYoutube": "Leiratkozás a YouTube-on",
"thisChannel": "Ez a csatorna",
"menu": "Csatorna műveletei"
"menu": "Csatorna műveletei",
"priority": "Prioritás",
"noTagsYet": "Még nincs címkéd — egy sor címke-„+” gombjával hozhatsz létre"
},
"notify": {
"synced": "{{count}} feliratkozás szinkronizálva",
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { placeByPriority, priorityChoices } from "./channelOrder";
const row = (id: string, priority: number, title: string | null = id) => ({ id, priority, title });
const ids = (rows: { id: string }[]) => rows.map((r) => r.id);
describe("placeByPriority", () => {
const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)];
it("bubbles a raised channel up to the first row it now outranks", () => {
expect(ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e"))).toEqual([
"a",
"b",
"c",
"e",
"d",
]);
});
it("sinks a lowered channel below every row it no longer outranks", () => {
expect(ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a"))).toEqual([
"b",
"c",
"a",
"d",
"e",
]);
});
it("orders equal priorities by title, case-folded, a missing title last", () => {
const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")];
expect(ids(placeByPriority(rows, "z"))).toEqual(["z", "x", "y"]);
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y"))).toEqual(["x", "y"]);
});
it("leaves every other row where it was, even ones out of the server's order", () => {
const rows = [row("p", 0), row("q", 9), row("r", 2)];
// r lands before the first row it outranks; p and q keep their (unsorted) order.
expect(ids(placeByPriority(rows, "r"))).toEqual(["r", "p", "q"]);
});
it("returns an unknown id's list unchanged", () => {
expect(ids(placeByPriority(list, "nope"))).toEqual(ids(list));
});
});
describe("priorityChoices", () => {
it("offers the fixed steps, and an off-scale current value in its place", () => {
expect(priorityChoices(3)).toEqual([10, 5, 3, 2, 1, 0]);
expect(priorityChoices(7)).toEqual([10, 7, 5, 3, 2, 1, 0]);
expect(priorityChoices(-2)).toEqual([10, 5, 3, 2, 1, 0, -2]);
});
});
+41
View File
@@ -0,0 +1,41 @@
// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b).
/** The steps a channel's menu offers (owner, 2026-09-26): fixed levels, so bubbling a channel up
* the list is one pick, not a run of +1 presses, and not straight to the top either. The row's
* stepper still sets any value, negatives included. */
const PRIORITY_STEPS: readonly number[] = [10, 5, 3, 2, 1, 0];
/** The steps to offer for a channel at `current`: the fixed ones, plus `current` itself in its
* place when it is off the scale (a 7, a -2) — the menu always shows, ticked, where it is now. */
export function priorityChoices(current: number): number[] {
return PRIORITY_STEPS.includes(current)
? [...PRIORITY_STEPS]
: [...PRIORITY_STEPS, current].sort((a, b) => b - a);
}
interface Ordered {
id: string;
priority: number;
title?: string | null;
}
/** The server's order (`routes/channels.py`: priority desc, then the title case-folded, a missing
* title last). */
function before(a: Ordered, b: Ordered): boolean {
if (a.priority !== b.priority) return a.priority > b.priority;
if (a.title == null) return false;
if (b.title == null) return true;
return a.title.toLowerCase() < b.title.toLowerCase();
}
/** `rows` with the channel `id` taken out and put back where its priority now places it, every
* other row left exactly where it was — the server's order, without a refetch that would reorder
* anything the reader did not touch. */
export function placeByPriority<T extends Ordered>(rows: readonly T[], id: string): T[] {
const moved = rows.find((r) => r.id === id);
if (!moved) return [...rows];
const rest = rows.filter((r) => r !== moved);
const at = rest.findIndex((r) => before(moved, r));
rest.splice(at < 0 ? rest.length : at, 0, moved);
return rest;
}
+22 -1
View File
@@ -6,7 +6,7 @@
import { useRef, useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { navItemAttrs, useListNav } from "./listNav";
import { focusNavItem, navItemAttrs, useListNav } from "./listNav";
import { pushLayer, removeLayer } from "./layerStack";
afterEach(cleanup);
@@ -615,3 +615,24 @@ describe("useListNav · Delete (E28 S3e2: a playlist row's X became the Delete k
expect(gone("d0")).toBe(false);
});
});
describe("focusNavItem", () => {
it("hands the focus to the control `pick` finds in the item, else to the item", () => {
Element.prototype.scrollIntoView ??= () => {};
const { container } = render(<List id="a" count={3} />);
act(() => focusNavItem(container, 2, (item) => item.querySelector<HTMLElement>("button")));
expect(focused()).toBe("a2-action");
act(() => focusNavItem(container, 1));
expect(focused()).toBe("a1");
});
it("brings a virtualized item in first, then focuses its control", async () => {
Element.prototype.scrollIntoView ??= () => {};
const reveal = vi.fn();
const { container, rerender } = render(<List id="a" count={5} mounted={2} reveal={reveal} />);
act(() => focusNavItem(container, 4, (item) => item.querySelector<HTMLElement>("button")));
expect(reveal).toHaveBeenCalledWith(4);
rerender(<List id="a" count={5} reveal={reveal} />);
await waitFor(() => expect(focused()).toBe("a4-action"));
});
});
+27 -3
View File
@@ -213,20 +213,44 @@ function focusItem(el: HTMLElement, reveal = true): void {
if (reveal) el.scrollIntoView?.({ block: "nearest" });
}
function focusIndex(list: NavList, index: number): void {
function focusIndex(
list: NavList,
index: number,
land: (el: HTMLElement) => void = (el) => focusItem(el),
): void {
const el = itemAt(list, index);
if (el) return focusItem(el);
if (el) return land(el);
// Virtualized and not mounted: have the list scroll it in, then take it as soon as it renders.
list.reveal(index);
let frames = 0;
const wait = () => {
const got = itemAt(list, index);
if (got) focusItem(got);
if (got) land(got);
else if (++frames < REVEAL_FRAMES) requestAnimationFrame(wait);
};
requestAnimationFrame(wait);
}
/** Hand the focus to item `index` of the list registered inside `within` — or, with `pick`, to the
* control `pick` finds in it (the row's own "…") — bringing a virtualized item into the DOM first
* and scrolling it just far enough to be seen. For a caller that MOVED an item and follows it: a
* channel re-sorted by the priority its menu set, whose node may have been re-inserted (the focus
* then fell to <body>) or scrolled off. */
export function focusNavItem(
within: HTMLElement,
index: number,
pick?: (item: HTMLElement) => HTMLElement | null,
): void {
const list = [...lists.values()].find((l) => within.contains(l.el));
if (!list) return;
focusIndex(list, index, (item) => {
const to = pick?.(item);
if (!to) return focusItem(item);
to.focus({ preventScroll: true });
item.scrollIntoView?.({ block: "nearest" });
});
}
function step(delta: 1 | -1): void {
const cur = current();
if (!cur) {
@@ -84,3 +84,27 @@ describe("useCardPager — an out-of-range page", () => {
expect(result.current.paged).toEqual(rows(30)); // and the rows are never sliced in table mode
});
});
describe("useCardPager — following a moved row", () => {
it("turns to the page that holds the row on each new seq, and not on mount", () => {
const { result, rerender } = renderHook(
({ list, seq }) =>
useCardPager(list, KEY, true, seq ? { seq, match: (r: number) => r === 3 } : null),
{ initialProps: { list: rows(30), seq: 0 } },
);
act(() => result.current.pagerProps.onSizeChange(10));
act(() => result.current.pagerProps.onPageChange(2));
// The row 3 sank to place 25: seq 1 follows it to the third page…
const sunk = [...rows(30).filter((r) => r !== 3)];
sunk.splice(25, 0, 3);
rerender({ list: sunk, seq: 1 });
expect(result.current.pagerProps.page).toBe(2);
// …and bubbled up to place 4: seq 2 follows it back to the first.
rerender({ list: rows(30), seq: 2 });
expect(result.current.pagerProps.page).toBe(0);
// A re-render with the same seq leaves the reader's own paging alone.
act(() => result.current.pagerProps.onPageChange(1));
rerender({ list: rows(30), seq: 2 });
expect(result.current.pagerProps.page).toBe(1);
});
});
+16 -1
View File
@@ -10,11 +10,26 @@ import { readAccount, writeAccount } from "./storage";
//
// The page size persists per account; the page itself is transient. `enabled` is the card layout
// flag — in table mode DataTable does its own paging, so this does no work and holds still.
export function useCardPager<T>(rows: T[], sizeKey: string, enabled: boolean) {
export function useCardPager<T>(
rows: T[],
sizeKey: string,
enabled: boolean,
/** Follow a row the caller just moved: each new `seq` turns to the page holding the row `match`
* finds — the same contract as `DataTable`'s `reveal`, for the card paint of the same list. */
reveal?: { seq: number; match: (row: T) => boolean } | null,
) {
const { t } = useTranslation();
const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null)));
const [page, setPage] = useState(0);
// During render against the last seq seen, as in DataTable (seeded so a mount never jumps).
const [revealSeen, setRevealSeen] = useState(reveal?.seq);
if (reveal && reveal.seq !== revealSeen) {
setRevealSeen(reveal.seq);
const at = rows.findIndex(reveal.match);
if (enabled && at >= 0 && size > 0) setPage(Math.floor(at / size));
}
const totalPages = size <= 0 ? 1 : Math.max(1, Math.ceil(rows.length / size));
const safePage = Math.min(page, totalPages - 1);