Files
siftlode/frontend/src/components/ui/pressableSelectable.contract.test.ts
T
peter b5f9cf14db feat(channels): a "Discover from your history" tab for the channels watched but not followed
The Channel manager lists the channels the Takeout history shows watched
often but not followed, the most watched first: twenty at a time, the
channel's About on hover, its name to its page, Follow, and Not interested
with an Undo. A row's count opens the videos it counts, ready to play one
or all. Before a history is uploaded it points to the upload; while the
import still asks YouTube it says more channels may join.
2026-10-24 00:00:50 +02:00

119 lines
5.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { readSources } from "../../lib/rawSources.testkit";
// docs/DESIGN.md "Text stays selectable": a click surface whose text is a title or a name (a card, a
// row, a person, a channel, a playlist, a log line) is `<Pressable selectable`, so its words select and
// the click that ends selecting them does nothing. A Pressable that is NOT selectable is a control whose
// text is its own label or that has no text at all (a menu row, an option, a tab, a nav item, a
// picture, a switch). Which one a new surface is cannot be read from the code, so every plain one is
// pinned here with the reason: a new Pressable, or one that lost its flag, fails this test until
// someone decides.
const files = readSources(__dirname, [
"../../**/*.tsx",
"!../../**/*.test.*",
"!../../design-lab/**",
]) as Record<string, string>;
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = {
"modules/settings/ApiTokens.tsx": [
1,
"a token kind's tile in the new-token dialog (an option: its text is its label)",
],
"features/downloads/DownloadTray.tsx": [
2,
"the folded pill (its summary is its label); a failure's reason, which opens its details",
],
"modules/files/FileCard.tsx": [
1,
"a card's picture (named by aria-label; its title is the caption)",
],
"modules/files/FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
"modules/home/Home.tsx": [
2,
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
],
"components/ui/LanguageSwitcher.tsx": [1, "an option row"],
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
"modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
"features/plexCards/PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
"modules/plex/PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
"modules/plex/PlexSearchBox.tsx": [1, "a listbox option"],
"features/plexCards/plexCards.tsx": [
1,
"a card's picture (named by aria-label; its title is the caption)",
],
"modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"],
"modules/settings/SettingsPanel.tsx": [1, "a colour swatch"],
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
"features/channel/TagManager.tsx": [1, "a suggestion in a picker"],
"components/ui/DropZone.tsx": [1, "the drop zone (its text is its own prompt)"],
"modules/files/VideoEditor.tsx": [2, "timeline glyphs"],
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
"modules/channels/WatchedVideosDialog.tsx": [1, "the thumbnail (its title sits beside it)"],
"modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"],
"modules/plex/plexViews.tsx": [1, "the rating filter (a number)"],
"components/ui/ActionMenu.tsx": [1, "a menu row"],
"components/ui/BackToTop.tsx": [1, "a control"],
"components/ui/CommandPalette.tsx": [1, "a command row"],
"components/ui/NavRail.tsx": [
2,
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link",
],
"components/ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
"components/ui/SidePanel.tsx": [2, "panel controls"],
"components/ui/tableHeader.tsx": [1, "a column's sort header"],
};
function scan(): { plain: Record<string, number>; selectable: number } {
const plain: Record<string, number> = {};
let selectable = 0;
for (const [path, code] of Object.entries(files)) {
// Glob keys are relative to this folder (`./x.tsx` is ui/); the table keys them from src/.
const rel = path.startsWith("./")
? `components/ui/${path.slice(2)}`
: path.replace(/^(\.\.\/)+/, "");
for (const m of code.matchAll(/<Pressable\b/g)) {
if (/\sselectable\b(?!=)/.test(openingTag(code, m.index))) selectable++;
else plain[rel] = (plain[rel] ?? 0) + 1;
}
}
return { plain, selectable };
}
/** The opening tag starting at `from`: up to its `>` outside any `{…}` expression, quoted string or
* line comment (an arrow's `=>` sits inside braces, a class like `[&>svg]` inside quotes). */
function openingTag(code: string, from: number): string {
let depth = 0;
let quote: string | null = null;
for (let i = from + 1; i < code.length; i++) {
const c = code[i];
if (quote) {
if (c === quote) quote = null;
} else if (c === "/" && code[i + 1] === "/") {
i = code.indexOf("\n", i); // a comment between the props: its apostrophes are not quotes
if (i < 0) break;
} else if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) return code.slice(from, i + 1);
}
return code.slice(from);
}
describe("every Pressable decides whether its text is a title", () => {
it("finds the selectable ones, and no plain one outside the pinned list", () => {
const { plain, selectable } = scan();
// A scan that stopped matching would be green over nothing.
expect(selectable).toBeGreaterThan(15);
const pinned = Object.fromEntries(Object.entries(PLAIN).map(([f, [n]]) => [f, n]));
expect(plain).toEqual(pinned);
});
});