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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user