From fa8cbd846aab6f19028f10fbb13b0018e5377c92 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 13 Jan 2026 18:50:48 +0100 Subject: [PATCH 01/21] Replace inline rule with custom `@wordpress/no-unsafe-button-disabled` rule The new rule is able to lint only imports from the @wordpress/components package --- .eslintrc.js | 25 +-- packages/eslint-plugin/README.md | 1 + .../docs/rules/no-unsafe-button-disabled.md | 49 +++++ .../__tests__/no-unsafe-button-disabled.js | 203 ++++++++++++++++++ .../rules/no-unsafe-button-disabled.js | 131 +++++++++++ 5 files changed, 392 insertions(+), 17 deletions(-) create mode 100644 packages/eslint-plugin/docs/rules/no-unsafe-button-disabled.md create mode 100644 packages/eslint-plugin/rules/__tests__/no-unsafe-button-disabled.js create mode 100644 packages/eslint-plugin/rules/no-unsafe-button-disabled.js diff --git a/.eslintrc.js b/.eslintrc.js index e709db6c0d156b..b0f415b0490283 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -108,16 +108,6 @@ const restrictedSyntax = [ }, ]; -/** `no-restricted-syntax` rules for components. */ -const restrictedSyntaxComponents = [ - { - selector: - 'JSXOpeningElement[name.name="Button"]:not(:has(JSXAttribute[name.name="accessibleWhenDisabled"])) JSXAttribute[name.name="disabled"]', - message: - '`disabled` used without the `accessibleWhenDisabled` prop. Disabling a control without maintaining focusability can cause accessibility issues, by hiding their presence from screen reader users, or preventing focus from returning to a trigger element. (Ignore this error if you truly mean to disable.)', - }, -]; - module.exports = { root: true, extends: [ @@ -257,11 +247,8 @@ module.exports = { ], excludedFiles: [ '**/*.native.js' ], rules: { - 'no-restricted-syntax': [ - 'error', - ...restrictedSyntax, - ...restrictedSyntaxComponents, - ], + 'no-restricted-syntax': [ 'error', ...restrictedSyntax ], + '@wordpress/no-unsafe-button-disabled': 'error', }, }, { @@ -274,7 +261,6 @@ module.exports = { 'no-restricted-syntax': [ 'error', ...restrictedSyntax, - ...restrictedSyntaxComponents, // Temporary rules until we're ready to officially default to the new size. ...[ 'BorderBoxControl', @@ -312,6 +298,8 @@ module.exports = { 'FormFileUpload should have the `__next40pxDefaultSize` prop to opt-in to the new default size.', }, ], + '@wordpress/no-unsafe-button-disabled': 'error', + }, }, { @@ -440,10 +428,13 @@ module.exports = { 'packages/components/src/theme/**', ], rules: { + '@wordpress/no-unsafe-button-disabled': [ + 'error', + { checkLocalImports: true }, + ], 'no-restricted-syntax': [ 'error', ...restrictedSyntax, - ...restrictedSyntaxComponents, { selector: ':matches(Literal[value=/--wp-admin-theme-/],TemplateElement[value.cooked=/--wp-admin-theme-/])', diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 8dc2df8cbf9b69..8c66d9d2522190 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -83,6 +83,7 @@ The granular rulesets will not define any environment globals. As such, if they | [i18n-translator-comments](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/i18n-translator-comments.md) | Enforce adding translator comments. | ✓ | | [no-base-control-with-label-without-id](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md) | Disallow the usage of BaseControl component with a label prop set but omitting the id property. | ✓ | | [no-unguarded-get-range-at](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md) | Disallow the usage of unguarded `getRangeAt` calls. | ✓ | +| [no-unsafe-button-disabled](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-button-disabled.md) | Disallow using `disabled` on Button without `accessibleWhenDisabled`. | | | [no-unsafe-wp-apis](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-wp-apis.md) | Disallow the usage of unsafe APIs from `@wordpress/*` packagesl | ✓ | | [no-unused-vars-before-return](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) | Disallow assigning variable values if unused before a return. | ✓ | | [no-wp-process-env](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-wp-process-env.md) | Disallow legacy usage of WordPress variables via `process.env` like `process.env.SCRIPT_DEBUG`. | ✓ | diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-button-disabled.md b/packages/eslint-plugin/docs/rules/no-unsafe-button-disabled.md new file mode 100644 index 00000000000000..67c0b4c1009300 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-unsafe-button-disabled.md @@ -0,0 +1,49 @@ +# Disallow using disabled on Button without accessibleWhenDisabled (no-unsafe-button-disabled) + +Disabling a `Button` component without maintaining focusability can cause accessibility issues by hiding its presence from screen reader users, or preventing focus from returning to a trigger element. + +This rule enforces that `Button` components from `@wordpress/components` include the `accessibleWhenDisabled` prop when the `disabled` prop is set. + +## Rule details + +Examples of **incorrect** code for this rule: + +```jsx +import { Button } from '@wordpress/components'; + + + +``` + +Examples of **correct** code for this rule: + +```jsx +import { Button } from '@wordpress/components'; + + + + + +``` + +## Options + +### checkLocalImports + +When set to `true`, the rule also checks `Button` components imported from relative paths. This is useful inside the `@wordpress/components` package itself, where components are imported via relative paths instead of `@wordpress/components`. + +```json +{ + "@wordpress/no-unsafe-button-disabled": [ + "error", + { "checkLocalImports": true } + ] +} +``` + +## Important notes + +- By default, this rule only applies to `Button` components imported from `@wordpress/components`. +- `Button` components from other packages (like `@wordpress/ui`) or locally defined components with the same name are not affected. +- Aliased imports (e.g., `import { Button as WPButton }`) are correctly tracked. +- Use the `checkLocalImports` option when linting inside the `@wordpress/components` package. diff --git a/packages/eslint-plugin/rules/__tests__/no-unsafe-button-disabled.js b/packages/eslint-plugin/rules/__tests__/no-unsafe-button-disabled.js new file mode 100644 index 00000000000000..50b9e0059ded9a --- /dev/null +++ b/packages/eslint-plugin/rules/__tests__/no-unsafe-button-disabled.js @@ -0,0 +1,203 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../no-unsafe-button-disabled'; + +const ruleTester = new RuleTester( { + parserOptions: { + sourceType: 'module', + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, +} ); + +ruleTester.run( 'no-unsafe-button-disabled', rule, { + valid: [ + // Button with both disabled and accessibleWhenDisabled + { + code: ` + import { Button } from '@wordpress/components'; + + + + +``` + +Examples of **correct** code for this rule: + +```jsx +import { Button, InputControl } from '@wordpress/components'; + + + + + + +``` + +## FormFileUpload special case + +`FormFileUpload` can use either the `__next40pxDefaultSize` prop or the `render` prop to be considered valid: + +```jsx +import { FormFileUpload } from '@wordpress/components'; + +// Both are valid: + + } /> +``` + +## Options + +### checkLocalImports + +When set to `true`, the rule also checks components imported from relative paths. This is useful inside the `@wordpress/components` package itself, where components are imported via relative paths instead of `@wordpress/components`. + +```json +{ + "@wordpress/no-missing-40px-size-prop": [ + "error", + { "checkLocalImports": true } + ] +} +``` + +## Important notes + +- By default, this rule only applies to components imported from `@wordpress/components`. +- Components from other packages (like `@wordpress/ui`) or locally defined components with the same name are not affected. +- Aliased imports (e.g., `import { Button as WPButton }`) are correctly tracked. +- Components with a non-default `size` prop (e.g., `size="small"`, `size="compact"`) are exempt from this rule. +- Use the `checkLocalImports` option when linting inside the `@wordpress/components` package. diff --git a/packages/eslint-plugin/rules/__tests__/no-missing-40px-size-prop.js b/packages/eslint-plugin/rules/__tests__/no-missing-40px-size-prop.js new file mode 100644 index 00000000000000..d374d135d16f82 --- /dev/null +++ b/packages/eslint-plugin/rules/__tests__/no-missing-40px-size-prop.js @@ -0,0 +1,349 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../no-missing-40px-size-prop'; + +const ruleTester = new RuleTester( { + parserOptions: { + sourceType: 'module', + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, +} ); + +ruleTester.run( 'no-missing-40px-size-prop', rule, { + valid: [ + // Component with __next40pxDefaultSize (boolean attribute) + { + code: ` + import { Button } from '@wordpress/components'; + } /> + `, + }, + // FormFileUpload with __next40pxDefaultSize + { + code: ` + import { FormFileUpload } from '@wordpress/components'; + + `, + }, + // Component with dynamic size prop (assumes it could be non-default) + { + code: ` + import { Button } from '@wordpress/components'; + } /> + `, + options: [ { checkLocalImports: true } ], + }, + ], + invalid: [], +} ); diff --git a/packages/eslint-plugin/rules/no-missing-40px-size-prop.js b/packages/eslint-plugin/rules/no-missing-40px-size-prop.js new file mode 100644 index 00000000000000..dabde8d2e335ea --- /dev/null +++ b/packages/eslint-plugin/rules/no-missing-40px-size-prop.js @@ -0,0 +1,315 @@ +/** + * Enforces that specific components from @wordpress/components include the + * `__next40pxDefaultSize` prop. + * + * @type {import('eslint').Rule.RuleModule} + */ + +/** + * Components that require the __next40pxDefaultSize prop. + * These can be exempted if they have a non-default `size` prop. + */ +const COMPONENTS_REQUIRING_40PX = new Set( [ + 'BorderBoxControl', + 'BorderControl', + 'BoxControl', + 'Button', + 'ComboboxControl', + 'CustomSelectControl', + 'FontAppearanceControl', + 'FontFamilyControl', + 'FontSizePicker', + 'FormTokenField', + 'InputControl', + 'LetterSpacingControl', + 'LineHeightControl', + 'NumberControl', + 'RangeControl', + 'SelectControl', + 'TextControl', + 'ToggleGroupControl', + 'UnitControl', +] ); + +/** + * Components that can use the `render` prop as an alternative to __next40pxDefaultSize. + */ +const COMPONENTS_WITH_RENDER_EXEMPTION = new Set( [ 'FormFileUpload' ] ); + +/** + * All tracked component names for path-based detection. + */ +const ALL_TRACKED_COMPONENTS = new Set( [ + ...COMPONENTS_REQUIRING_40PX, + ...COMPONENTS_WITH_RENDER_EXEMPTION, +] ); + +module.exports = { + meta: { + type: 'problem', + schema: [ + { + type: 'object', + properties: { + checkLocalImports: { + type: 'boolean', + description: + 'When true, also checks components imported from relative paths (for use inside @wordpress/components package).', + }, + }, + additionalProperties: false, + }, + ], + messages: { + missingProp: + '{{ component }} should have the `__next40pxDefaultSize` prop when using the default size.', + missingPropFormFileUpload: + 'FormFileUpload should have the `__next40pxDefaultSize` prop to opt-in to the new default size.', + }, + }, + create( context ) { + const checkLocalImports = + context.options[ 0 ]?.checkLocalImports ?? false; + + // Track local names of components imported from @wordpress/components + // Map: localName -> importedName + const trackedImports = new Map(); + + /** + * Check if the import source should be tracked. + * + * @param {string} source - The import source path + * @return {boolean} Whether to track imports from this source + */ + function shouldTrackImportSource( source ) { + if ( source === '@wordpress/components' ) { + return true; + } + + // When checkLocalImports is enabled, also track relative imports + if ( checkLocalImports ) { + return source.startsWith( '.' ) || source.startsWith( '/' ); + } + + return false; + } + + /** + * Try to infer component name from import path. + * e.g., '../button' -> 'Button', '../input-control' -> 'InputControl' + * + * @param {string} source - The import source path + * @return {string|null} The inferred component name or null + */ + function inferComponentNameFromPath( source ) { + // Get the last segment of the path + const lastSegment = source.split( '/' ).pop(); + if ( ! lastSegment ) { + return null; + } + + // Convert kebab-case to PascalCase + const pascalCase = lastSegment + .split( '-' ) + .map( + ( part ) => + part.charAt( 0 ).toUpperCase() + part.slice( 1 ) + ) + .join( '' ); + + // Check if it's one of our tracked components + if ( ALL_TRACKED_COMPONENTS.has( pascalCase ) ) { + return pascalCase; + } + + return null; + } + + /** + * Check if an attribute exists and has a truthy value. + * Returns true if the attribute exists with a truthy value. + * + * @param {Array} attributes - JSX attributes array + * @param {string} attrName - Attribute name to check + * @return {boolean} Whether the attribute has a truthy value + */ + function hasTruthyAttribute( attributes, attrName ) { + const attr = attributes.find( + ( a ) => + a.type === 'JSXAttribute' && + a.name && + a.name.name === attrName + ); + + if ( ! attr ) { + return false; + } + + // Boolean attribute without value (e.g., `__next40pxDefaultSize`) + if ( attr.value === null ) { + return true; + } + + // Expression like `__next40pxDefaultSize={true}` or `__next40pxDefaultSize={false}` + if ( + attr.value.type === 'JSXExpressionContainer' && + attr.value.expression.type === 'Literal' + ) { + return attr.value.expression.value !== false; + } + + // String value - truthy if not empty + if ( attr.value.type === 'Literal' ) { + return Boolean( attr.value.value ); + } + + // For any other expression (variables, etc.), assume it could be truthy + return true; + } + + /** + * Check if the `size` prop has a non-default value. + * + * @param {Array} attributes - JSX attributes array + * @return {boolean} Whether size has a non-default value + */ + function hasNonDefaultSize( attributes ) { + const sizeAttr = attributes.find( + ( a ) => + a.type === 'JSXAttribute' && + a.name && + a.name.name === 'size' + ); + + if ( ! sizeAttr ) { + return false; + } + + // String value like `size="small"` or `size="compact"` + if ( + sizeAttr.value && + sizeAttr.value.type === 'Literal' && + typeof sizeAttr.value.value === 'string' + ) { + return sizeAttr.value.value !== 'default'; + } + + // Expression - could be non-default, so don't report + if ( + sizeAttr.value && + sizeAttr.value.type === 'JSXExpressionContainer' + ) { + return true; + } + + return false; + } + + /** + * Check if the `render` prop exists. + * + * @param {Array} attributes - JSX attributes array + * @return {boolean} Whether render prop exists + */ + function hasRenderProp( attributes ) { + return attributes.some( + ( a ) => + a.type === 'JSXAttribute' && + a.name && + a.name.name === 'render' + ); + } + + return { + ImportDeclaration( node ) { + const source = node.source.value; + + if ( ! shouldTrackImportSource( source ) ) { + return; + } + + // Handle named imports + node.specifiers.forEach( ( specifier ) => { + if ( specifier.type !== 'ImportSpecifier' ) { + return; + } + + const importedName = specifier.imported.name; + const localName = specifier.local.name; + + // Track components that require the prop + if ( + COMPONENTS_REQUIRING_40PX.has( importedName ) || + COMPONENTS_WITH_RENDER_EXEMPTION.has( importedName ) + ) { + trackedImports.set( localName, importedName ); + } + } ); + + // Handle default imports when checking local imports + // e.g., import InputControl from '../input-control' + if ( checkLocalImports ) { + node.specifiers.forEach( ( specifier ) => { + if ( specifier.type === 'ImportDefaultSpecifier' ) { + const localName = specifier.local.name; + const inferredName = + inferComponentNameFromPath( source ); + if ( inferredName ) { + trackedImports.set( localName, inferredName ); + } + } + } ); + } + }, + + JSXOpeningElement( node ) { + // Only check simple JSX element names (not member expressions) + if ( node.name.type !== 'JSXIdentifier' ) { + return; + } + + const elementName = node.name.name; + const importedName = trackedImports.get( elementName ); + + // Only check if this is a tracked component from @wordpress/components + if ( ! importedName ) { + return; + } + + const attributes = node.attributes; + + // Check if __next40pxDefaultSize has a truthy value + if ( hasTruthyAttribute( attributes, '__next40pxDefaultSize' ) ) { + return; + } + + // Handle FormFileUpload special case + if ( COMPONENTS_WITH_RENDER_EXEMPTION.has( importedName ) ) { + // FormFileUpload is valid if it has a `render` prop + if ( hasRenderProp( attributes ) ) { + return; + } + + context.report( { + node, + messageId: 'missingPropFormFileUpload', + } ); + return; + } + + // For other components, check if size prop has a non-default value + if ( hasNonDefaultSize( attributes ) ) { + return; + } + + context.report( { + node, + messageId: 'missingProp', + data: { + component: importedName, + }, + } ); + }, + }; + }, +}; From 9ad09dea3f5112b94337690ff4296ac7d802a821 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 13 Jan 2026 19:18:56 +0100 Subject: [PATCH 03/21] Rename rules --- packages/eslint-plugin/README.md | 4 ++-- ...size-prop.md => components-no-missing-40px-size-prop.md} | 4 ++-- ...-disabled.md => components-no-unsafe-button-disabled.md} | 4 ++-- ...size-prop.js => components-no-missing-40px-size-prop.js} | 6 +++--- ...-disabled.js => components-no-unsafe-button-disabled.js} | 6 +++--- ...size-prop.js => components-no-missing-40px-size-prop.js} | 0 ...-disabled.js => components-no-unsafe-button-disabled.js} | 0 7 files changed, 12 insertions(+), 12 deletions(-) rename packages/eslint-plugin/docs/rules/{no-missing-40px-size-prop.md => components-no-missing-40px-size-prop.md} (94%) rename packages/eslint-plugin/docs/rules/{no-unsafe-button-disabled.md => components-no-unsafe-button-disabled.md} (94%) rename packages/eslint-plugin/rules/__tests__/{no-missing-40px-size-prop.js => components-no-missing-40px-size-prop.js} (97%) rename packages/eslint-plugin/rules/__tests__/{no-unsafe-button-disabled.js => components-no-unsafe-button-disabled.js} (95%) rename packages/eslint-plugin/rules/{no-missing-40px-size-prop.js => components-no-missing-40px-size-prop.js} (100%) rename packages/eslint-plugin/rules/{no-unsafe-button-disabled.js => components-no-unsafe-button-disabled.js} (100%) diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index cc7d32da1743b5..88016a72579067 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -82,9 +82,9 @@ The granular rulesets will not define any environment globals. As such, if they | [i18n-text-domain](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/i18n-text-domain.md) | Enforce passing valid text domains. | ✓ | | [i18n-translator-comments](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/i18n-translator-comments.md) | Enforce adding translator comments. | ✓ | | [no-base-control-with-label-without-id](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md) | Disallow the usage of BaseControl component with a label prop set but omitting the id property. | ✓ | -| [no-missing-40px-size-prop](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-missing-40px-size-prop.md) | Disallow missing `__next40pxDefaultSize` prop on `@wordpress/components` components. | | +| [components-no-missing-40px-size-prop](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md) | Disallow missing `__next40pxDefaultSize` prop on `@wordpress/components` components. | | +| [components-no-unsafe-button-disabled](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md) | Disallow using `disabled` on Button without `accessibleWhenDisabled`. | | | [no-unguarded-get-range-at](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md) | Disallow the usage of unguarded `getRangeAt` calls. | ✓ | -| [no-unsafe-button-disabled](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-button-disabled.md) | Disallow using `disabled` on Button without `accessibleWhenDisabled`. | | | [no-unsafe-wp-apis](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-wp-apis.md) | Disallow the usage of unsafe APIs from `@wordpress/*` packagesl | ✓ | | [no-unused-vars-before-return](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) | Disallow assigning variable values if unused before a return. | ✓ | | [no-wp-process-env](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-wp-process-env.md) | Disallow legacy usage of WordPress variables via `process.env` like `process.env.SCRIPT_DEBUG`. | ✓ | diff --git a/packages/eslint-plugin/docs/rules/no-missing-40px-size-prop.md b/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md similarity index 94% rename from packages/eslint-plugin/docs/rules/no-missing-40px-size-prop.md rename to packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md index 45064c67b9034d..43a677e40a2c6f 100644 --- a/packages/eslint-plugin/docs/rules/no-missing-40px-size-prop.md +++ b/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md @@ -1,4 +1,4 @@ -# Disallow missing \_\_next40pxDefaultSize prop (no-missing-40px-size-prop) +# Disallow missing \_\_next40pxDefaultSize prop (components-no-missing-40px-size-prop) Enforces that specific components from `@wordpress/components` include the `__next40pxDefaultSize` prop to opt-in to the new 40px default size. @@ -72,7 +72,7 @@ When set to `true`, the rule also checks components imported from relative paths ```json { - "@wordpress/no-missing-40px-size-prop": [ + "@wordpress/components-no-missing-40px-size-prop": [ "error", { "checkLocalImports": true } ] diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-button-disabled.md b/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md similarity index 94% rename from packages/eslint-plugin/docs/rules/no-unsafe-button-disabled.md rename to packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md index 67c0b4c1009300..87b999bac26884 100644 --- a/packages/eslint-plugin/docs/rules/no-unsafe-button-disabled.md +++ b/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md @@ -1,4 +1,4 @@ -# Disallow using disabled on Button without accessibleWhenDisabled (no-unsafe-button-disabled) +# Disallow using disabled on Button without accessibleWhenDisabled (components-no-unsafe-button-disabled) Disabling a `Button` component without maintaining focusability can cause accessibility issues by hiding its presence from screen reader users, or preventing focus from returning to a trigger element. @@ -34,7 +34,7 @@ When set to `true`, the rule also checks `Button` components imported from relat ```json { - "@wordpress/no-unsafe-button-disabled": [ + "@wordpress/components-no-unsafe-button-disabled": [ "error", { "checkLocalImports": true } ] diff --git a/packages/eslint-plugin/rules/__tests__/no-missing-40px-size-prop.js b/packages/eslint-plugin/rules/__tests__/components-no-missing-40px-size-prop.js similarity index 97% rename from packages/eslint-plugin/rules/__tests__/no-missing-40px-size-prop.js rename to packages/eslint-plugin/rules/__tests__/components-no-missing-40px-size-prop.js index d374d135d16f82..87441d3b1c572d 100644 --- a/packages/eslint-plugin/rules/__tests__/no-missing-40px-size-prop.js +++ b/packages/eslint-plugin/rules/__tests__/components-no-missing-40px-size-prop.js @@ -6,7 +6,7 @@ import { RuleTester } from 'eslint'; /** * Internal dependencies */ -import rule from '../no-missing-40px-size-prop'; +import rule from '../components-no-missing-40px-size-prop'; const ruleTester = new RuleTester( { parserOptions: { @@ -18,7 +18,7 @@ const ruleTester = new RuleTester( { }, } ); -ruleTester.run( 'no-missing-40px-size-prop', rule, { +ruleTester.run( 'components-no-missing-40px-size-prop', rule, { valid: [ // Component with __next40pxDefaultSize (boolean attribute) { @@ -296,7 +296,7 @@ ruleTester.run( 'no-missing-40px-size-prop', rule, { } ); // Additional tests for checkLocalImports option -ruleTester.run( 'no-missing-40px-size-prop (checkLocalImports)', rule, { +ruleTester.run( 'components-no-missing-40px-size-prop (checkLocalImports)', rule, { valid: [ // Relative import with correct props { diff --git a/packages/eslint-plugin/rules/__tests__/no-unsafe-button-disabled.js b/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js similarity index 95% rename from packages/eslint-plugin/rules/__tests__/no-unsafe-button-disabled.js rename to packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js index 50b9e0059ded9a..bc37e3c64694b5 100644 --- a/packages/eslint-plugin/rules/__tests__/no-unsafe-button-disabled.js +++ b/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js @@ -6,7 +6,7 @@ import { RuleTester } from 'eslint'; /** * Internal dependencies */ -import rule from '../no-unsafe-button-disabled'; +import rule from '../components-no-unsafe-button-disabled'; const ruleTester = new RuleTester( { parserOptions: { @@ -18,7 +18,7 @@ const ruleTester = new RuleTester( { }, } ); -ruleTester.run( 'no-unsafe-button-disabled', rule, { +ruleTester.run( 'components-no-unsafe-button-disabled', rule, { valid: [ // Button with both disabled and accessibleWhenDisabled { @@ -166,7 +166,7 @@ ruleTester.run( 'no-unsafe-button-disabled', rule, { } ); // Additional tests for checkLocalImports option -ruleTester.run( 'no-unsafe-button-disabled (checkLocalImports)', rule, { +ruleTester.run( 'components-no-unsafe-button-disabled (checkLocalImports)', rule, { valid: [ // Relative import with correct props { diff --git a/packages/eslint-plugin/rules/no-missing-40px-size-prop.js b/packages/eslint-plugin/rules/components-no-missing-40px-size-prop.js similarity index 100% rename from packages/eslint-plugin/rules/no-missing-40px-size-prop.js rename to packages/eslint-plugin/rules/components-no-missing-40px-size-prop.js diff --git a/packages/eslint-plugin/rules/no-unsafe-button-disabled.js b/packages/eslint-plugin/rules/components-no-unsafe-button-disabled.js similarity index 100% rename from packages/eslint-plugin/rules/no-unsafe-button-disabled.js rename to packages/eslint-plugin/rules/components-no-unsafe-button-disabled.js From bb26f3a5a915924f8b4ac6e04c575650ab7d5e09 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 14 Jan 2026 10:26:15 +0100 Subject: [PATCH 04/21] Format --- .eslintrc.js | 1 - .../components-no-missing-40px-size-prop.js | 70 ++++++++++--------- .../components-no-unsafe-button-disabled.js | 50 +++++++------ .../components-no-missing-40px-size-prop.js | 7 +- 4 files changed, 68 insertions(+), 60 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 3e7a4def6278fd..135c7339233a9f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -261,7 +261,6 @@ module.exports = { 'no-restricted-syntax': [ 'error', ...restrictedSyntax ], '@wordpress/no-unsafe-button-disabled': 'error', '@wordpress/no-missing-40px-size-prop': 'error', - }, }, { diff --git a/packages/eslint-plugin/rules/__tests__/components-no-missing-40px-size-prop.js b/packages/eslint-plugin/rules/__tests__/components-no-missing-40px-size-prop.js index 87441d3b1c572d..eda7143017b867 100644 --- a/packages/eslint-plugin/rules/__tests__/components-no-missing-40px-size-prop.js +++ b/packages/eslint-plugin/rules/__tests__/components-no-missing-40px-size-prop.js @@ -296,54 +296,58 @@ ruleTester.run( 'components-no-missing-40px-size-prop', rule, { } ); // Additional tests for checkLocalImports option -ruleTester.run( 'components-no-missing-40px-size-prop (checkLocalImports)', rule, { - valid: [ - // Relative import with correct props - { - code: ` +ruleTester.run( + 'components-no-missing-40px-size-prop (checkLocalImports)', + rule, + { + valid: [ + // Relative import with correct props + { + code: ` import { Button } from '../button'; } /> `, - options: [ { checkLocalImports: true } ], - }, - ], - invalid: [], -} ); + options: [ { checkLocalImports: true } ], + }, + ], + invalid: [], + } +); diff --git a/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js b/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js index bc37e3c64694b5..5a0af35b12861f 100644 --- a/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js +++ b/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js @@ -166,38 +166,42 @@ ruleTester.run( 'components-no-unsafe-button-disabled', rule, { } ); // Additional tests for checkLocalImports option -ruleTester.run( 'components-no-unsafe-button-disabled (checkLocalImports)', rule, { - valid: [ - // Relative import with correct props - { - code: ` +ruleTester.run( + 'components-no-unsafe-button-disabled (checkLocalImports)', + rule, + { + valid: [ + // Relative import with correct props + { + code: ` import { Button } from '../button'; @@ -57,12 +56,7 @@ describe( 'Button', () => { it( 'can be enabled explicitly when loading', () => { render( - // Disabling because this lint rule was meant for the - // `@wordpress/components` Button, but is being applied here. - // TODO: rework the lint rule so that it checks the package - // where the Button comes from. // TODO: Additional improvement in the original lint rule: only error if disabled=true? - // eslint-disable-next-line no-restricted-syntax @@ -76,11 +70,7 @@ describe( 'Button', () => { it( 'supports custom render prop while retaining the default focusable when disabled behavior', () => { render( - // Disabling because this lint rule was meant for the - // `@wordpress/components` Button, but is being applied here. - // TODO: rework the lint rule so that it checks the package - // where the Button comes from. - // eslint-disable-next-line jsx-a11y/anchor-has-content, no-restricted-syntax + // eslint-disable-next-line jsx-a11y/anchor-has-content From 613b531d8796890dc90123707785d50dfd9a90e1 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 10:12:54 +0100 Subject: [PATCH 12/21] Cleanup comments --- packages/ui/src/button/test/button.test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/ui/src/button/test/button.test.tsx b/packages/ui/src/button/test/button.test.tsx index 1023d40c69c70d..1ecc053e6bbcbf 100644 --- a/packages/ui/src/button/test/button.test.tsx +++ b/packages/ui/src/button/test/button.test.tsx @@ -25,10 +25,6 @@ describe( 'Button', () => { const onClickMock = jest.fn(); render( - // Disabling because this lint rule was meant for the - // `@wordpress/components` Button, but is being applied here. - // TODO: rework the lint rule so that it checks the package - // where the Button comes from. @@ -56,7 +52,6 @@ describe( 'Button', () => { it( 'can be enabled explicitly when loading', () => { render( - // TODO: Additional improvement in the original lint rule: only error if disabled=true? From fb8e4c4ed5babb0911cfa762e369704f23b9f818 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 10:51:32 +0100 Subject: [PATCH 13/21] Fix typo --- packages/eslint-plugin/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 88016a72579067..a5d42e207cac2f 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -82,10 +82,10 @@ The granular rulesets will not define any environment globals. As such, if they | [i18n-text-domain](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/i18n-text-domain.md) | Enforce passing valid text domains. | ✓ | | [i18n-translator-comments](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/i18n-translator-comments.md) | Enforce adding translator comments. | ✓ | | [no-base-control-with-label-without-id](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md) | Disallow the usage of BaseControl component with a label prop set but omitting the id property. | ✓ | -| [components-no-missing-40px-size-prop](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md) | Disallow missing `__next40pxDefaultSize` prop on `@wordpress/components` components. | | -| [components-no-unsafe-button-disabled](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md) | Disallow using `disabled` on Button without `accessibleWhenDisabled`. | | +| [components-no-missing-40px-size-prop](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md) | Disallow missing `__next40pxDefaultSize` prop on `@wordpress/components` components. | ✓ | +| [components-no-unsafe-button-disabled](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md) | Disallow using `disabled` on Button without `accessibleWhenDisabled`. | ✓ | | [no-unguarded-get-range-at](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md) | Disallow the usage of unguarded `getRangeAt` calls. | ✓ | -| [no-unsafe-wp-apis](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-wp-apis.md) | Disallow the usage of unsafe APIs from `@wordpress/*` packagesl | ✓ | +| [no-unsafe-wp-apis](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-wp-apis.md) | Disallow the usage of unsafe APIs from `@wordpress/*` packages | ✓ | | [no-unused-vars-before-return](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) | Disallow assigning variable values if unused before a return. | ✓ | | [no-wp-process-env](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-wp-process-env.md) | Disallow legacy usage of WordPress variables via `process.env` like `process.env.SCRIPT_DEBUG`. | ✓ | | [react-no-unsafe-timeout](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/react-no-unsafe-timeout.md) | Disallow unsafe `setTimeout` in component. | | From 706dbf3dc8f03574973e7081fa2ed7c6e81f8dc8 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 10:52:42 +0100 Subject: [PATCH 14/21] Remove unnecessary char escape --- .../docs/rules/components-no-missing-40px-size-prop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md b/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md index 43a677e40a2c6f..c5cfd3ece27fb4 100644 --- a/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md +++ b/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md @@ -1,4 +1,4 @@ -# Disallow missing \_\_next40pxDefaultSize prop (components-no-missing-40px-size-prop) +# Disallow missing `__next40pxDefaultSize` prop (components-no-missing-40px-size-prop) Enforces that specific components from `@wordpress/components` include the `__next40pxDefaultSize` prop to opt-in to the new 40px default size. From 49fe55816b653e763980fad7a5b144d6b612786e Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 11:26:07 +0100 Subject: [PATCH 15/21] Do not error if disabled={false} --- .../components-no-unsafe-button-disabled.md | 4 +- .../components-no-unsafe-button-disabled.js | 98 +++++++++++++------ .../components-no-unsafe-button-disabled.js | 50 ++++++++-- 3 files changed, 114 insertions(+), 38 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md b/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md index 87b999bac26884..78159812f5b7ad 100644 --- a/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md +++ b/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md @@ -13,6 +13,8 @@ import { Button } from '@wordpress/components'; + + ``` Examples of **correct** code for this rule: @@ -22,7 +24,7 @@ import { Button } from '@wordpress/components'; - + ``` diff --git a/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js b/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js index 5a0af35b12861f..674b4a1c02ecc4 100644 --- a/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js +++ b/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js @@ -34,13 +34,40 @@ ruleTester.run( 'components-no-unsafe-button-disabled', rule, { - - + ``` Examples of **correct** code for this rule: @@ -24,6 +23,8 @@ import { Button } from '@wordpress/components'; + + ``` From 7c6c699b09e252ff3b57475f525b2c061869512d Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 21:23:09 +0100 Subject: [PATCH 17/21] Remove leftover "errors" prop --- .../rules/__tests__/components-no-unsafe-button-disabled.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js b/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js index 674b4a1c02ecc4..9121c2f844b811 100644 --- a/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js +++ b/packages/eslint-plugin/rules/__tests__/components-no-unsafe-button-disabled.js @@ -40,11 +40,6 @@ ruleTester.run( 'components-no-unsafe-button-disabled', rule, { import { Button } from '@wordpress/components';