Files
siftlode/frontend/src/components/ui/pressableSelectable.contract.test.ts
T
peter 3553eac8c4 feat(settings): upload a Google Takeout export in Settings › Account
A "YouTube history" section under YouTube access: the watch history and
the Watch later csv are dropped or picked (one request each, each file's
outcome said on its own), and the section then says what the import did
- only the counts it produced - or that it is running, failed or was cut
off by a restart. While it runs it asks again until it ends; a failed
re-read shows the failure, not the previous answer.

The drop zone the Files upload had inline is now ui/DropZone, used by
both. The e2e seed clears the account's kept Takeout videos.
2026-10-23 18:51:05 +02:00

118 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/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);
});
});