diff --git a/.eslintrc.js b/.eslintrc.js
index 490c542f9d4565..53ae4eadd7433e 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -249,6 +249,24 @@ module.exports = {
],
},
},
+ {
+ files: [
+ 'packages/*/src/**/*.[tj]s?(x)',
+ 'storybook/stories/**/*.[tj]s?(x)',
+ ],
+ excludedFiles: [ '**/*.native.js' ],
+ rules: {
+ 'no-restricted-syntax': [
+ 'error',
+ {
+ selector:
+ 'JSXOpeningElement[name.name="Button"]:not(:has(JSXAttribute[name.name="__experimentalIsFocusable"])) JSXAttribute[name.name="disabled"]',
+ message:
+ '`disabled` used without the `__experimentalIsFocusable` 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.)',
+ },
+ ],
+ },
+ },
{
files: [
// Components package.
diff --git a/packages/block-directory/src/plugins/get-install-missing/install-button.js b/packages/block-directory/src/plugins/get-install-missing/install-button.js
index 2dc01184bdeb4a..075fed360c14c8 100644
--- a/packages/block-directory/src/plugins/get-install-missing/install-button.js
+++ b/packages/block-directory/src/plugins/get-install-missing/install-button.js
@@ -42,6 +42,7 @@ export default function InstallButton( { attributes, block, clientId } ) {
}
} )
}
+ __experimentalIsFocusable
disabled={ isInstallingBlock }
isBusy={ isInstallingBlock }
variant="primary"
diff --git a/packages/block-editor/src/components/button-block-appender/index.js b/packages/block-editor/src/components/button-block-appender/index.js
index 974f48e61bc287..cd1289c897824c 100644
--- a/packages/block-editor/src/components/button-block-appender/index.js
+++ b/packages/block-editor/src/components/button-block-appender/index.js
@@ -60,6 +60,8 @@ function ButtonBlockAppender(
onClick={ onToggle }
aria-haspopup={ isToggleButton ? 'true' : undefined }
aria-expanded={ isToggleButton ? isOpen : undefined }
+ // Disable reason: There shouldn't be a case where this button is disabled but not visually hidden.
+ // eslint-disable-next-line no-restricted-syntax
disabled={ disabled }
label={ label }
>
diff --git a/packages/block-editor/src/components/link-control/link-preview.js b/packages/block-editor/src/components/link-control/link-preview.js
index 867b69356eb9d9..fb4b3658e2a4f1 100644
--- a/packages/block-editor/src/components/link-control/link-preview.js
+++ b/packages/block-editor/src/components/link-control/link-preview.js
@@ -149,6 +149,7 @@ export default function LinkPreview( {
isEmptyURL || showIconLabels ? '' : ': ' + value.url
) }
ref={ ref }
+ __experimentalIsFocusable
disabled={ isEmptyURL }
size="compact"
/>
diff --git a/packages/block-library/src/gallery/v1/gallery-image.js b/packages/block-library/src/gallery/v1/gallery-image.js
index 368d5da55c4ac9..5384944b2335d9 100644
--- a/packages/block-library/src/gallery/v1/gallery-image.js
+++ b/packages/block-library/src/gallery/v1/gallery-image.js
@@ -222,6 +222,8 @@ class GalleryImage extends Component {
onClick={ isFirstItem ? undefined : onMoveBackward }
label={ __( 'Move image backward' ) }
aria-disabled={ isFirstItem }
+ // Disable reason: Truly disable when image is not selected.
+ // eslint-disable-next-line no-restricted-syntax
disabled={ ! isSelected }
/>
@@ -237,12 +241,16 @@ class GalleryImage extends Component {
icon={ edit }
onClick={ this.onEdit }
label={ __( 'Replace image' ) }
+ // Disable reason: Truly disable when image is not selected.
+ // eslint-disable-next-line no-restricted-syntax
disabled={ ! isSelected }
/>
diff --git a/packages/block-library/src/page-list/convert-to-links-modal.js b/packages/block-library/src/page-list/convert-to-links-modal.js
index 8f8c75f6dea1c1..a717349557de50 100644
--- a/packages/block-library/src/page-list/convert-to-links-modal.js
+++ b/packages/block-library/src/page-list/convert-to-links-modal.js
@@ -27,6 +27,7 @@ export function ConvertToLinksModal( { onClick, onClose, disabled } ) {
) : (
changePage( 1 ) }
+ // Disable reason: Would not cause confusion, and allows quicker access to a relevant nav button.
+ // eslint-disable-next-line no-restricted-syntax
disabled={ disabled || currentPage === 1 }
aria-label={ __( 'First page' ) }
>
@@ -54,6 +56,8 @@ export default function Pagination( {
changePage( currentPage - 1 ) }
+ // Disable reason: Would not cause confusion, and allows quicker access to a relevant nav button.
+ // eslint-disable-next-line no-restricted-syntax
disabled={ disabled || currentPage === 1 }
aria-label={ __( 'Previous page' ) }
>
@@ -72,6 +76,8 @@ export default function Pagination( {
changePage( currentPage + 1 ) }
+ // Disable reason: Would not cause confusion, and allows quicker access to a relevant nav button.
+ // eslint-disable-next-line no-restricted-syntax
disabled={ disabled || currentPage === numPages }
aria-label={ __( 'Next page' ) }
>
@@ -80,6 +86,8 @@ export default function Pagination( {
changePage( numPages ) }
+ // Disable reason: Would not cause confusion, and allows quicker access to a relevant nav button.
+ // eslint-disable-next-line no-restricted-syntax
disabled={ disabled || currentPage === numPages }
aria-label={ __( 'Last page' ) }
>
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js
index a2281804bcb728..d290a40516bf0b 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js
@@ -43,6 +43,7 @@ export default function RenameModal( { menuTitle, onClose, onSave } ) {
diff --git a/packages/editor/src/components/post-preview-button/index.js b/packages/editor/src/components/post-preview-button/index.js
index 578cd78351d23a..d256169dc3301f 100644
--- a/packages/editor/src/components/post-preview-button/index.js
+++ b/packages/editor/src/components/post-preview-button/index.js
@@ -168,6 +168,7 @@ export default function PostPreviewButton( {
className={ className || 'editor-post-preview' }
href={ href }
target={ targetId }
+ __experimentalIsFocusable
disabled={ ! isSaveable }
onClick={ openPreviewWindow }
role={ role }
diff --git a/packages/editor/src/components/post-preview-button/test/index.js b/packages/editor/src/components/post-preview-button/test/index.js
index e34c05caa178bd..bb51f302edf50e 100644
--- a/packages/editor/src/components/post-preview-button/test/index.js
+++ b/packages/editor/src/components/post-preview-button/test/index.js
@@ -139,12 +139,16 @@ describe( 'PostPreviewButton', () => {
).toBeInTheDocument();
} );
- it( 'should be disabled if post is not saveable.', () => {
+ it( 'should be accessibly disabled if post is not saveable.', () => {
mockUseSelect( { isEditedPostSaveable: () => false } );
render( );
- expect( screen.getByRole( 'button' ) ).toBeDisabled();
+ expect( screen.getByRole( 'button' ) ).toBeEnabled();
+ expect( screen.getByRole( 'button' ) ).toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
} );
it( 'should not be disabled if post is saveable.', () => {
@@ -153,6 +157,10 @@ describe( 'PostPreviewButton', () => {
render( );
expect( screen.getByRole( 'button' ) ).toBeEnabled();
+ expect( screen.getByRole( 'button' ) ).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
} );
it( 'should set `href` to edited post preview link if specified.', () => {
diff --git a/packages/editor/src/components/post-publish-panel/index.js b/packages/editor/src/components/post-publish-panel/index.js
index d9c158ab429a9f..079ed5b0e42155 100644
--- a/packages/editor/src/components/post-publish-panel/index.js
+++ b/packages/editor/src/components/post-publish-panel/index.js
@@ -93,6 +93,7 @@ export class PostPublishPanel extends Component {