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:
+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",
|
||||
|
||||
Reference in New Issue
Block a user