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
+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)}