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
+1 -3
View File
@@ -56,9 +56,7 @@ them itself (`no-undef`, `no-redeclare`, `no-dupe-keys`, …), nor the formattin
| lib/http.ts | `no-restricted-globals` — narrowed | A network owner (`NETWORK_OWNERS` in eslint.config.js): the one module that may call `fetch` (`send`, which `req` and the public pages go through). Every other network global stays restricted there. | 1 global |
| lib/api/core.ts | `no-restricted-globals` — narrowed | A network owner: `uploadReq` is the one `XMLHttpRequest` (upload progress). | 1 global |
| src tests · ui tests · lib tests | `no-restricted-globals`, `no-restricted-properties` — off | A test stands a double in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly. | — |
| src tests · ui tests · lib tests | `local/literal-a11y-text`, `local/button-primitive`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw button as a focus target, an untranslated label) that is not product UI. | — |
| FocusSafeButton.tsx + AnchoredPopover.tsx | `local/focus-safe-disabled` — off | FocusSafeButton: it IS the focus-safe primitive the rule points everyone else to. | — |
| FocusSafeButton.tsx + AnchoredPopover.tsx | `local/focus-safe-disabled` — off | AnchoredPopover: the popover's menu row: its native `disabled` is what floating-ui's roving focus reads to skip it. | — |
| src tests · ui tests · lib tests | `local/literal-a11y-text`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw input as a focus target, an untranslated label) that is not product UI. `local/button-primitive` stays on: in a test file (and in a control primitive) it only refuses a raw `<button disabled>`, which drops focus there as anywhere. | — |
| design-lab | `local/literal-a11y-text` — off | The dev-only design lab (never in the bundle — devOnlyGuard fails the build if it is) holds the owner's working copy, not product copy. | — |
| e2e | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | Playwright specs drive a browser from Node: no React, no product UI, and they print to the console on purpose. They get the base + typed rules + the playwright rules. | — |
| ts configs | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | The build configs (vite, vitest, playwright, reactPlugin, devOnlyGuard) run in Node at build time. | — |
+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",
+18 -13
View File
@@ -2,16 +2,19 @@ import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/focus-safe-disabled` (eslint.config.js): a raw `<button disabled>` throws a focused keyboard
// user to <body> the moment it turns disabled. ~60 were swept onto ui/FocusSafeButton; this pins that
// the rule keeping a new one out actually fires.
const RULE = "local/focus-safe-disabled";
// `local/button-primitive`'s disabled check (eslint.config.js): a raw `<button disabled>` throws a
// focused keyboard user to <body> the moment it turns disabled. ~60 were swept onto ui/FocusSafeButton;
// this pins that the check keeping a new one out fires — also inside a control primitive and a test,
// where the rest of the rule stands aside.
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
return (result?.messages ?? []).filter(
(m) => m.ruleId === "local/button-primitive" && m.messageId === "disabled",
).length;
}
const DISABLED = "export const D = (p: { d: boolean }) => <button disabled={p.d}>x</button>;\n";
describe("a raw button that can be disabled", () => {
it("is refused, statically or not", async () => {
expect(
@@ -20,13 +23,15 @@ describe("a raw button that can be disabled", () => {
expect(await hits("export const B = () => <button disabled>x</button>;\n")).toBe(1);
});
it("leaves a raw button with no disabled, and the primitive, alone", async () => {
it("is refused inside a control primitive and in a test too", async () => {
expect(await hits(DISABLED, "src/components/ui/Button.tsx")).toBe(1);
// an existing test file: the project service admits only lintProbe.testkit's probe names
expect(await hits(DISABLED, "src/components/Feed.countStale.dom.test.tsx")).toBe(1);
});
it("leaves a raw button with no disabled, and the two native-disabled owners, alone", async () => {
expect(await hits("export const C = () => <button onClick={() => {}}>x</button>;\n")).toBe(0);
expect(
await hits(
"export const D = (p: { d: boolean }) => <button disabled={p.d}>x</button>;\n",
"src/components/ui/FocusSafeButton.tsx",
),
).toBe(0);
expect(await hits(DISABLED, "src/components/ui/FocusSafeButton.tsx")).toBe(0);
expect(await hits(DISABLED, "src/components/ui/AnchoredPopover.tsx")).toBe(0);
});
});
+2 -53
View File
@@ -1,48 +1,6 @@
{
"eslint": {
"configs": {
"FocusSafeButton.tsx + AnchoredPopover.tsx": {
"languageOptions": {
"ecmaVersion": 2022,
"globals": "1169 names, sha1 aee621727a03",
"parser": "typescript-eslint/parser",
"parserOptions": { "projectService": true, "tsconfigRootDir": "." },
"sourceType": "module"
},
"linterOptions": { "reportUnusedDisableDirectives": 2 },
"notApplied": [],
"plugins": [
"@",
"@eslint-community/eslint-comments",
"@typescript-eslint",
"local",
"react-hooks",
"react-refresh"
],
"rules": {
"local/focus-safe-disabled": [0],
"no-restricted-imports": [
2,
{
"patterns": [
{
"message": "components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
"regex": "^\\.\\./(?!\\.\\./)"
},
{
"message": "components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
"regex": "^\\.\\./\\.\\./components/(?!ui/)"
},
{
"message": "components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
"regex": "^\\.\\./\\.\\./lib/api(/|$)"
}
]
}
]
},
"settings": null
},
"design-lab": {
"languageOptions": {
"ecmaVersion": 2022,
@@ -75,7 +33,6 @@
"linterOptions": { "reportUnusedDisableDirectives": 2 },
"notApplied": [
"local/button-primitive",
"local/focus-safe-disabled",
"local/glyph-link",
"local/input-primitive",
"local/jsx-comment-text",
@@ -158,7 +115,6 @@
"@typescript-eslint/prefer-namespace-keyword",
"@typescript-eslint/triple-slash-reference",
"local/button-primitive",
"local/focus-safe-disabled",
"local/glyph-link",
"local/input-primitive",
"local/jsx-comment-text",
@@ -248,7 +204,6 @@
"vitest"
],
"rules": {
"local/button-primitive": [0],
"local/glyph-link": [0],
"local/input-primitive": [0],
"local/literal-a11y-text": [0],
@@ -711,7 +666,6 @@
"linebreak-style": [0],
"lines-around-comment": [0],
"local/button-primitive": [2],
"local/focus-safe-disabled": [2],
"local/glyph-link": [2],
"local/input-primitive": [2],
"local/jsx-comment-text": [2],
@@ -1062,7 +1016,6 @@
"vitest"
],
"rules": {
"local/button-primitive": [0],
"local/glyph-link": [0],
"local/input-primitive": [0],
"local/literal-a11y-text": [0],
@@ -1153,7 +1106,6 @@
"linterOptions": { "reportUnusedDisableDirectives": 2 },
"notApplied": [
"local/button-primitive",
"local/focus-safe-disabled",
"local/glyph-link",
"local/input-primitive",
"local/jsx-comment-text",
@@ -1262,7 +1214,6 @@
"vitest"
],
"rules": {
"local/button-primitive": [0],
"local/glyph-link": [0],
"local/input-primitive": [0],
"local/literal-a11y-text": [0],
@@ -1331,10 +1282,6 @@
},
"files": {
"(not linted)": [],
"FocusSafeButton.tsx + AnchoredPopover.tsx": [
"src/components/ui/AnchoredPopover.tsx",
"src/components/ui/FocusSafeButton.tsx"
],
"design-lab": [
"src/design-lab/DesignLab.tsx",
"src/design-lab/batchA.tsx",
@@ -1959,6 +1906,7 @@
],
"ui": [
"src/components/ui/ActionMenu.tsx",
"src/components/ui/AnchoredPopover.tsx",
"src/components/ui/Avatar.tsx",
"src/components/ui/BackToTop.tsx",
"src/components/ui/Badge.tsx",
@@ -1978,6 +1926,7 @@
"src/components/ui/FilterChipGroup.tsx",
"src/components/ui/FilterFold.tsx",
"src/components/ui/FilterSelect.tsx",
"src/components/ui/FocusSafeButton.tsx",
"src/components/ui/Headings.tsx",
"src/components/ui/HiddenFilterCue.tsx",
"src/components/ui/IconButton.tsx",