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