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:
@@ -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")}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function LogLine({
|
||||
{...navItemAttrs(navIndex, `${event} ${msg}`)}
|
||||
>
|
||||
<Pressable
|
||||
selectable
|
||||
layer={!open}
|
||||
data-nav-open
|
||||
aria-expanded={open}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function PosterTile({
|
||||
const band = hueTileGradient(hueOf(title));
|
||||
return (
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user