build(eslint): focus-safe-disabled folds into button-primitive

Two rules looked at the same <button>: button-primitive refused a raw one
outside the control primitives, focus-safe-disabled refused a raw
`disabled` one everywhere but FocusSafeButton and AnchoredPopover. They are
one rule now, with the same reach: outside the primitives a raw button is
refused (a disabled one with the focus message); inside a primitive and in
a test only a raw `<button disabled>` is, and the two native-disabled owners
are named in the rule rather than in a config override. One override block,
one rule entry and two register rows go; the snapshot loses a config group.
focusSafe.contract.test.ts pins every case, the primitive and test ones new.
This commit is contained in:
2026-10-05 00:19:39 +02:00
parent 2eb20ca227
commit ca707fd390
4 changed files with 45 additions and 102 deletions
+24 -33
View File
@@ -70,27 +70,6 @@ const jsxCommentText = {
};
},
};
// A raw `<button disabled>` drops a focused keyboard user to <body> the moment its caller disables it
// (a mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in
// this app are disabled by their own click. `ui/FocusSafeButton` (and `ui/Button` on top of it)
// keeps the focus; this is what stops a new raw one from reopening the hole.
const focusSafeDisabled = {
meta: {
type: "problem",
messages: {
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Build the primitive on ui/FocusSafeButton; anywhere else use ui/Button, IconButton or Pressable.",
},
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier" || node.name.name !== "button") return;
if (node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "disabled"))
context.report({ node, messageId: "raw" });
},
};
},
};
// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the
// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare
// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a
@@ -160,6 +139,12 @@ const localeFormat = {
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
// side panel, a toast) are built from the primitives like any feature component.
//
// The same rule keeps a raw `<button disabled>` out of the primitives themselves, and out of tests: a
// native disabled drops a focused keyboard user to <body> the moment the button turns disabled (a
// mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in this
// app are disabled by their own click. ui/FocusSafeButton (and ui/Button on top of it) keeps the
// focus. (It was its own rule, `focus-safe-disabled`, until E35.)
const BUTTON_PRIMITIVES = new Set([
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
"Button.tsx",
@@ -177,23 +162,39 @@ const BUTTON_PRIMITIVES = new Set([
"FilterSelect.tsx", // its option rows
"Combobox.tsx", // the field's chevron (A4)
]);
// The two that ARE the raw disabled button: the primitive itself, and the popover's menu row, whose
// native `disabled` is what floating-ui's roving focus reads to skip it (see ActionMenu).
const NATIVE_DISABLED = new Set(["FocusSafeButton.tsx", "AnchoredPopover.tsx"]);
const buttonPrimitive = {
meta: {
type: "problem",
messages: {
raw: "A raw <{{name}}> outside the control primitives: use ui/Button (a labelled action), IconButton (a glyph), Pressable (a card or row that is the control), Chip or DragHandle.",
role: 'A <{{name}} role="button"> is a hand-built button (its own Enter/Space, no focus-safe disabled, no touch target): use ui/Pressable for a surface that is the control.',
disabled:
"A raw <button disabled> throws a focused user to <body> when it turns disabled. Build the primitive on ui/FocusSafeButton; anywhere else use ui/Button, IconButton or Pressable.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && BUTTON_PRIMITIVES.has(file)) return {};
const ui = path.includes("/src/components/ui/");
// A test renders a raw button as a stand-in trigger or a focus target — not product UI, so only
// the disabled check applies to it.
const exempt = (ui && BUTTON_PRIMITIVES.has(file)) || /\.test\.tsx?$/.test(path);
const nativeDisabled = ui && NATIVE_DISABLED.has(file);
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (name === "button" || name === "FocusSafeButton")
if (
name === "button" &&
!nativeDisabled &&
node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "disabled")
)
context.report({ node, messageId: "disabled" });
else if (exempt) return;
else if (name === "button" || name === "FocusSafeButton")
context.report({ node, messageId: "raw", data: { name } });
// S3g: the same button spelled as a div with a role slipped past the rule above (my own
// S3h Plex picture). A component (capitalised) may take a role for its own reasons.
@@ -422,7 +423,6 @@ const localRules = {
"glyph-link": glyphLink,
"sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"locale-format": localeFormat,
"input-primitive": inputPrimitive,
@@ -559,7 +559,6 @@ export default tseslint.config(
// `useLatest`. It is on, at error, with no exception.
"react-hooks/refs": "error",
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/locale-format": "error",
"local/input-primitive": "error",
@@ -725,12 +724,6 @@ export default tseslint.config(
files: [owner],
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
})),
{
// The two that ARE the raw button: the primitive itself, and the popover's menu row, whose
// native `disabled` is what floating-ui's roving focus reads to skip it (see ActionMenu).
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
rules: { "local/focus-safe-disabled": "off" },
},
{
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
@@ -751,8 +744,6 @@ export default tseslint.config(
"no-restricted-globals": "off",
"no-restricted-properties": "off",
"local/literal-a11y-text": "off",
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
"local/button-primitive": "off",
"local/input-primitive": "off",
"local/surface-role": "off",
"local/glyph-link": "off",