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:
2026-09-26 18:46:24 +02:00
parent e1ef56cdbc
commit edf2f22ef9
26 changed files with 309 additions and 8 deletions
+2
View File
@@ -36,6 +36,8 @@ interface ButtonOwnProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "
* `media` variant. */
round?: boolean;
fullWidth?: boolean;
/** Its text is a title or a name (a link back to a show): it stays selectable (FocusSafeButton). */
selectable?: boolean;
}
// A button of this primitive always renders text, or it is an `IconButton`. `<Button icon={x} />`
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
//
// docs/DESIGN.md "Text stays selectable": a button whose text is a title or a name is `selectable` —
// its text selects (Chrome selects none inside a button without `user-select: text`), and the click
// that ends selecting it, or a press dragged past the slop, does nothing. Held here, on the primitive every button family is built on.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { Pressable } from "./Pressable";
import { Button } from "./Button";
afterEach(() => {
window.getSelection()!.removeAllRanges();
cleanup();
});
function selectText(el: Element) {
const range = document.createRange();
range.selectNodeContents(el);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
}
describe("a selectable button", () => {
it("lets its text select, and ignores the click that ends a selection", () => {
const onClick = vi.fn();
render(
<Pressable selectable onClick={onClick}>
<span>Andor</span>
</Pressable>,
);
const btn = screen.getByRole("button");
expect(btn.classList.contains("select-text")).toBe(true);
selectText(screen.getByText("Andor"));
btn.click();
expect(onClick).not.toHaveBeenCalled();
window.getSelection()!.removeAllRanges();
btn.click();
expect(onClick).toHaveBeenCalledTimes(1);
});
it("ignores a press dragged past the slop, keeps a steady one and a keyboard click", () => {
const onClick = vi.fn();
render(
<Pressable selectable onClick={onClick}>
Andor
</Pressable>,
);
const btn = screen.getByRole("button");
fireEvent.mouseDown(btn, { clientX: 10, clientY: 10 });
fireEvent.click(btn, { detail: 1, clientX: 40, clientY: 10 });
expect(onClick).not.toHaveBeenCalled();
fireEvent.mouseDown(btn, { clientX: 10, clientY: 10 });
fireEvent.click(btn, { detail: 1, clientX: 13, clientY: 12 });
expect(onClick).toHaveBeenCalledTimes(1);
// Enter / Space: a click with no pointer, wherever the last press was.
fireEvent.mouseDown(btn, { clientX: 10, clientY: 10 });
fireEvent.click(btn, { detail: 0, clientX: 0, clientY: 0 });
expect(onClick).toHaveBeenCalledTimes(2);
});
it("still clicks when the selection is somewhere else on the page", () => {
const onClick = vi.fn();
render(
<>
<p>elsewhere</p>
<Button variant="link-muted" selectable onClick={onClick}>
Andor
</Button>
</>,
);
selectText(screen.getByText("elsewhere"));
screen.getByRole("button").click();
expect(onClick).toHaveBeenCalledTimes(1);
});
it("is off unless asked: a plain button neither selects nor guards", () => {
const onClick = vi.fn();
render(<Pressable onClick={onClick}>Save</Pressable>);
const btn = screen.getByRole("button");
expect(btn.classList.contains("select-text")).toBe(false);
selectText(btn);
btn.click();
expect(onClick).toHaveBeenCalledTimes(1);
});
});
+35 -1
View File
@@ -1,4 +1,6 @@
import { forwardRef, useCallback, useRef, useState, type ButtonHTMLAttributes } from "react";
import clsx from "clsx";
import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
// A `<button>` whose `disabled` never throws the focus out of the page.
//
@@ -15,15 +17,37 @@ import { forwardRef, useCallback, useRef, useState, type ButtonHTMLAttributes }
// this one keeping its classes. The dimmed look of the soft state is one rule in index.css
// (`[data-focus-safe][aria-disabled="true"]`), since a caller's `disabled:*` utilities match only the
// native attribute.
//
// `selectable` is for a button whose text is a title or a name (docs/DESIGN.md "Text stays
// selectable"): a row, a card, a name that opens its object. Chrome selects no text inside a
// <button> unless it is `user-select: text`, and then the mouseup that ends the drag still clicks the
// button (both measured, 2026-09-26), so the flag sets both halves: the text selects, and the click
// that ends a selection inside the button, or a press dragged past the slop, does nothing.
export const FocusSafeButton = forwardRef<
HTMLButtonElement,
ButtonHTMLAttributes<HTMLButtonElement> & {
/** Working: unavailable and announced as busy, whether focused or not. */
busy?: boolean;
/** Its text is a title or a name: selectable, and the click that ends a selection is ignored. */
selectable?: boolean;
}
>(function FocusSafeButton({ disabled, busy = false, onClick, onFocus, onBlur, ...rest }, ref) {
>(function FocusSafeButton(
{
disabled,
busy = false,
selectable = false,
className,
onClick,
onMouseDown,
onFocus,
onBlur,
...rest
},
ref,
) {
const [focusEvent, setFocusEvent] = useState(false);
const node = useRef<HTMLButtonElement | null>(null);
const pressedAt = useRef<{ x: number; y: number } | null>(null);
const setNode = useCallback(
(el: HTMLButtonElement | null) => {
node.current = el;
@@ -46,6 +70,7 @@ export const FocusSafeButton = forwardRef<
<button
ref={setNode}
{...rest}
className={clsx(selectable && "select-text", className) || undefined}
data-focus-safe=""
disabled={!!disabled && !focused}
aria-disabled={soft || callerSoft || undefined}
@@ -55,8 +80,17 @@ export const FocusSafeButton = forwardRef<
e.preventDefault();
return;
}
if (
selectable &&
(endsTextSelection(e.currentTarget) || movedPastSlop(pressedAt.current, e))
)
return;
onClick?.(e);
}}
onMouseDown={(e) => {
pressedAt.current = { x: e.clientX, y: e.clientY };
onMouseDown?.(e);
}}
onFocus={(e) => {
setFocusEvent(true);
onFocus?.(e);
+2
View File
@@ -18,6 +18,8 @@ export const Pressable = forwardRef<
align?: "left" | "center";
/** Working: unavailable and announced as busy. */
busy?: boolean;
/** Its text is a title or a name: it stays selectable (see FocusSafeButton). */
selectable?: boolean;
}
>(function Pressable({ type, layer = false, align = "left", className, ...rest }, ref) {
return (
+4 -4
View File
@@ -36,10 +36,10 @@ domain-free.
## What lives here
| area | components |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `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), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
| area | components |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `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), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| 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>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `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), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
@@ -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);
});
});