feat(design): titles in click surfaces stay selectable (E28 S3e4b)
A button whose text is a title or a name is now `selectable` (FocusSafeButton, under Pressable and Button): Chrome selects inside a button once it is user-select: text (measured), and the click that ends a selection, or a press dragged past the slop, opens nothing. 23 surfaces carry it (cards, rows, people, channels, playlists, users, views, messages, log lines, description links); the Plex caption div gets the same guard. A contract test pins every plain Pressable with its reason, so a new one has to choose.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// 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 = import.meta.glob(["../**/*.tsx", "!../**/*.test.*", "!../design-lab/**"], {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
|
||||
/** file → [how many plain Pressables, why they are not titles]. */
|
||||
const PLAIN: Record<string, [number, string]> = {
|
||||
"Home.tsx": [1, "the hero's picture (named by aria-label; its title sits outside)"],
|
||||
"LanguageSwitcher.tsx": [1, "an option row"],
|
||||
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
|
||||
"PaletteLauncher.tsx": [2, "launcher controls"],
|
||||
"PlayerModal.tsx": [2, "previous / next arrows"],
|
||||
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||
"PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||
"PlexPlayer.tsx": [3, "track picker rows"],
|
||||
"PlexPlayerSettings.tsx": [2, "setting options"],
|
||||
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
|
||||
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
||||
"PlexSearchBox.tsx": [1, "a listbox option"],
|
||||
"Scheduler.tsx": [1, "the interval editor trigger"],
|
||||
"SettingsPanel.tsx": [1, "a colour swatch"],
|
||||
"SyncStatus.tsx": [1, "the status pill trigger"],
|
||||
"TagManager.tsx": [1, "a suggestion in a picker"],
|
||||
"UploadDialog.tsx": [1, "the drop zone"],
|
||||
"VideoEditor.tsx": [2, "timeline glyphs"],
|
||||
"Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||
"downloadRow.tsx": [1, "the error-details link"],
|
||||
"playlistColumns.tsx": [1, "the thumbnail"],
|
||||
"plexDetailUi.tsx": [2, "a switch row and the poster"],
|
||||
"plexViews.tsx": [1, "the rating filter (a number)"],
|
||||
"ui/ActionMenu.tsx": [1, "a menu row"],
|
||||
"ui/BackToTop.tsx": [1, "a control"],
|
||||
"ui/CommandPalette.tsx": [1, "a command row"],
|
||||
"ui/NavRail.tsx": [2, "nav items"],
|
||||
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
|
||||
"ui/SidePanel.tsx": [2, "panel controls"],
|
||||
"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/, `../x.tsx` is components/.
|
||||
const rel = path.startsWith("./") ? `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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user