Files
siftlode/frontend/src/components/ui/FocusSafeButton.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

116 lines
4.7 KiB
TypeScript

import {
forwardRef,
useCallback,
useRef,
useState,
useSyncExternalStore,
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.
//
// A focused button that turns natively disabled loses the focus — Chrome moves it to <body>
// (measured) — and the app disables the very button that was just pressed on ~60 sites (a
// mutation pending, a form gone clean after its save), so a keyboard user was dropped out of the page
// on every submit. While THIS button holds the focus, `disabled` (and `busy`) is expressed as
// `aria-disabled` plus a click guard instead: still focusable, announced as unavailable, and not
// activatable — its preventDefault also cancels a form's implicit submission, which is a click on the
// default button. The native attribute applies again once the focus is elsewhere (at the blur; in
// a window without focus, which fires no blur, at the next render).
//
// Unstyled on purpose: `ui/Button` is built on it, and a hand-rolled `<button>` swaps its tag for
// 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.
const noSubscription = () => () => {};
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,
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;
if (typeof ref === "function") ref(el);
else if (ref) ref.current = el;
},
[ref],
);
// Focused by the event OR by the document's own answer at render time: when the window itself is
// not focused (another app in front) `.focus()` moves `activeElement` but fires no focus event
// (measured in Chrome), and the button disabled in that state lost the focus all the same. Read
// through useSyncExternalStore, which React asks on EVERY render, never from a memo: a bare
// render-time DOM read made the React Compiler skip the component, and compiled it would be
// cached. Nothing to subscribe to — the focus events above already re-render it.
const activeNow = useSyncExternalStore(
noSubscription,
() => node.current !== null && node.current.ownerDocument.activeElement === node.current,
);
const focused = focusEvent || activeNow;
const soft = busy || (!!disabled && focused);
// A caller's own aria-disabled is the "unavailable, and here is why" state (DESIGN.md: the tooltip or
// label carries the reason, so the button stays focusable): it is kept, never overwritten by ours, and
// it is inert to a click like ours, so no caller has to guard its own handler.
const callerSoft = rest["aria-disabled"] === true || rest["aria-disabled"] === "true";
return (
<button
ref={setNode}
{...rest}
className={clsx(selectable && "select-text", className) || undefined}
data-focus-safe=""
disabled={!!disabled && !focused}
aria-disabled={soft || callerSoft || undefined}
aria-busy={busy || undefined}
onClick={(e) => {
if (soft || callerSoft) {
e.preventDefault();
return;
}
if (selectable && (endsTextSelection(e) || 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);
}}
onBlur={(e) => {
setFocusEvent(false);
onBlur?.(e);
}}
/>
);
});