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