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