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
+1
View File
@@ -232,6 +232,7 @@ function UsersRoles({ me }: { me: Me }) {
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
>
<Pressable
selectable
data-row-open=""
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
+1
View File
@@ -30,6 +30,7 @@ export default function ChannelLink({
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
{onView ? (
<Pressable
selectable
onClick={onView}
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
// card clicks this.
+1
View File
@@ -88,6 +88,7 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
)}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<Pressable
selectable
onClick={() => toggleMinimize(chat.partnerId)}
className="flex items-center gap-2 min-w-0 flex-1"
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
+1
View File
@@ -283,6 +283,7 @@ function ShelfTile({
: t("home.shelf.itemCount", { count: count ?? 0 });
return (
<Pressable
selectable
onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
+1
View File
@@ -41,6 +41,7 @@ export default function LogLine({
{...navItemAttrs(navIndex, `${event} ${msg}`)}
>
<Pressable
selectable
layer={!open}
data-nav-open
aria-expanded={open}
+2
View File
@@ -160,6 +160,7 @@ function ConversationList({
className="group flex items-center gap-1 rounded-xl state-layer transition"
>
<Pressable
selectable
data-nav-open=""
onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 min-w-0 flex-1"
@@ -255,6 +256,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
<div className="flex flex-col gap-1">
{users.map((u) => (
<Pressable
selectable
key={u.id}
layer
onClick={() => onPick(u)}
@@ -64,6 +64,7 @@ function ViewRow({
>
<DragHandle label={t("views.reorder")} {...attributes} {...listeners} />
<Pressable
selectable
onClick={onSelect}
className={`flex-1 min-w-0 truncate py-1.5 text-sm ${selected ? "text-accent" : "text-fg"}`}
>
@@ -329,6 +329,7 @@ export default function PlaylistsRail({
// A sibling of the row button, not inside it — a button cannot hold a button.
<div key={pl.id} className="group relative" {...railMenu.triggerProps(pl)}>
<Pressable
selectable
ref={(el) => {
if (pl.id === selectedId) selectedRef.current = el;
// Back from the rename field by Enter/Escape — see refocusRowRef.
+1
View File
@@ -583,6 +583,7 @@ export default function PlexBrowse() {
>
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
<Pressable
selectable
onClick={() => onOpenPerson(p.name)}
title={t("plex.info.viewPerson", { name: p.name })}
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg"
+3
View File
@@ -298,6 +298,7 @@ function CrewPanel({
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
<Pressable
selectable
onClick={() => onOpenPerson(c.name)}
title={t("plex.info.viewPerson", { name: c.name })}
className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)"
@@ -739,6 +740,7 @@ export default function PlexInfo({
);
return clickable ? (
<Pressable
selectable
key={i}
align="center"
{...navItemAttrs(i)}
@@ -794,6 +796,7 @@ export default function PlexInfo({
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
{col.items.map((m, i) => (
<Pressable
selectable
key={m.id}
{...navItemAttrs(i)}
onClick={() => onPlayItem?.(m.id)}
@@ -190,6 +190,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
{ep.episode_number}
</span>
<Pressable
selectable
onClick={() => ctx.onPlay(ep.id)}
data-nav-open
className="group/thumb flex min-w-0 flex-1 items-center gap-2.5"
@@ -242,6 +243,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
{...listeners}
/>
<Pressable
selectable
onClick={() => ctx.onPlay(item.id)}
data-nav-open
className="group/thumb flex min-w-0 flex-1 items-center gap-3"
@@ -327,6 +329,7 @@ function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
</Pressable>
)}
<Pressable
selectable
onClick={() => ctx.toggleShow(block.showKey)}
data-nav-open
className="min-w-0 flex-1"
+1
View File
@@ -28,6 +28,7 @@ export default function PosterTile({
const band = hueTileGradient(hueOf(title));
return (
<Pressable
selectable
onClick={onClick}
title={title}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
+1
View File
@@ -336,6 +336,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
return (
<div className={`${SURFACE.card} p-3`}>
<Pressable
selectable
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="w-full flex items-center gap-3"
@@ -80,6 +80,7 @@ export function renderDescription(
const sameVideo = yt.id === opts.currentId;
out.push(
<Pressable
selectable
key={key++}
onClick={() =>
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
@@ -135,6 +136,7 @@ export function renderDescription(
const ts = m[3]!;
out.push(
<Pressable
selectable
key={key++}
onClick={() => opts.onSeek(tsToSeconds(ts))}
className="text-accent hover:underline"
@@ -74,6 +74,7 @@ export function playlistColumns({
</span>
</Pressable>
<Pressable
selectable
onClick={() => onPlay(v)}
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
data-nav-open=""
@@ -146,3 +146,34 @@ describe("Plex cards in a keyboard-walkable list", () => {
expect(container.querySelector("[data-nav-index]")).toBeNull();
});
});
describe("a Plex card's title stays selectable", () => {
it("opens from its caption, but not on the click that ends selecting the title", () => {
const onOpen = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
const title = screen.getByText("M");
const range = document.createRange();
range.selectNodeContents(title);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
fireEvent.click(title);
expect(onOpen).not.toHaveBeenCalled();
window.getSelection()!.removeAllRanges();
fireEvent.click(title);
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
describe("a Plex card's caption and a drag", () => {
it("does not open on a press dragged past the slop", () => {
const onOpen = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
const title = screen.getByText("M");
fireEvent.mouseDown(title, { clientX: 10, clientY: 10 });
fireEvent.click(title, { detail: 1, clientX: 60, clientY: 10 });
expect(onOpen).not.toHaveBeenCalled();
fireEvent.mouseDown(title, { clientX: 10, clientY: 10 });
fireEvent.click(title, { detail: 1, clientX: 11, clientY: 10 });
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
+17 -2
View File
@@ -1,10 +1,11 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Film, ListPlus, Play, Star, Tv2 } from "lucide-react";
import { type CSSProperties, type ReactNode } from "react";
import { type CSSProperties, type ReactNode, useRef } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { plexAddLabelKey } from "./usePlexCardActions";
import { IconButton } from "./ui/IconButton";
@@ -97,9 +98,22 @@ function Caption({
children: ReactNode;
}) {
const { t } = useTranslation();
const pressedAt = useRef<{ x: number; y: number } | null>(null);
return (
<div
onClick={onClick}
// The click that ends selecting the title's words, or a press dragged past the slop, does not
// open it (DESIGN "Text stays selectable"); a div's text selects on its own, so only the click
// needs the guard.
onClick={
onClick &&
((e) => {
if (!endsTextSelection(e.currentTarget) && !movedPastSlop(pressedAt.current, e))
onClick();
})
}
onMouseDown={(e) => {
pressedAt.current = { x: e.clientX, y: e.clientY };
}}
className={`mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5 ${onClick ? "cursor-pointer" : ""} ${className}`}
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
@@ -443,6 +457,7 @@ export function CastStrip({
);
return clickable ? (
<Pressable
selectable
key={i}
align="center"
{...navItemAttrs(i)}
+1
View File
@@ -59,6 +59,7 @@ export function Filterable({
if (!onClick) return <span className={className}>{children}</span>;
return (
<Pressable
selectable
onClick={onClick}
title={hint}
// min-h-6 + touch-target: a value in the meta line is still a 24 / 44 target (B1, A3).
+1 -1
View File
@@ -738,7 +738,7 @@ export function PlexSeasonView({
/>
}
>
<Button variant="link-muted" onClick={onBack} className="text-sm">
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
{d.show.title}
</Button>
<PageHeading className="text-2xl font-semibold leading-tight">{se.title}</PageHeading>
+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 (
+2 -2
View File
@@ -37,9 +37,9 @@ 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 |
| 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);
});
});
+16
View File
@@ -13,3 +13,19 @@ export function endsTextSelection(el: Element): boolean {
(!!sel.focusNode && el.contains(sel.focusNode))
);
}
// The rule's other half: a press that travelled before its release was a drag (across a row's empty
// padding, say, where nothing selects), not a click, so it opens nothing either.
/** How far, in px, the pointer may move between press and release and still count as a click. */
const CLICK_SLOP = 6;
/** True when a click's pointer moved past the slop since its press at `from`. A keyboard click
* (`detail` 0) carries no pointer, so it never did. */
export function movedPastSlop(
from: { x: number; y: number } | null,
e: { detail: number; clientX: number; clientY: number },
): boolean {
if (!from || e.detail === 0) return false;
return Math.abs(e.clientX - from.x) > CLICK_SLOP || Math.abs(e.clientY - from.y) > CLICK_SLOP;
}