diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 608991c2173..476155c4931 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -160,8 +160,13 @@ Ensure that appropriate aria-attributes are set for `EuiBetaBadge`, `EuiButtonIc Ensure `EuiTooltip` components are anchored to elements that can receive keyboard focus, making them accessible to all users. When using non-interactive elements (like `span`or `EuiText`) as tooltip anchors, they must include `tabIndex={0}` to be keyboard-focusable. For better accessibility, prefer using semantic interactive components (like `EuiButton` or `EuiLink`) which are focusable by default. ### `@elastic/eui/accessible-interactive-element` + Ensure interactive EUI components (like e.g. `EuiLink`, `EuiButton`, `EuiRadio`) remain accessible by prohibiting `tabIndex={-1}`, which removes them from keyboard navigation. +### `@elastic/eui/require-table-caption` + +Ensure `EuiInMemoryTable`, `EuiBasicTable` have a `tableCaption` property for accessibility. + ## Testing ### Running unit tests diff --git a/packages/eslint-plugin/changelogs/upcoming/9168.md b/packages/eslint-plugin/changelogs/upcoming/9168.md new file mode 100644 index 00000000000..ea476b2c67f --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/9168.md @@ -0,0 +1 @@ +- Added new `require-table-caption` rule. diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 34ecddb5451..168704d760d 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -20,6 +20,7 @@ import { NoUnnamedInteractiveElement } from './rules/a11y/no_unnamed_interactive import { TooltipFocusableAnchor } from './rules/a11y/tooltip_focusable_anchor'; import { CallOutAnnounceOnMount } from './rules/a11y/callout_announce_on_mount'; import { AccessibleInteractiveElements } from './rules/a11y/accessible_interactive_element'; +import { RequireTableCaption } from './rules/a11y/require_table_caption'; const config = { rules: { @@ -35,6 +36,7 @@ const config = { 'no-unnamed-interactive-element': NoUnnamedInteractiveElement, 'tooltip-focusable-anchor': TooltipFocusableAnchor, 'accessible-interactive-element': AccessibleInteractiveElements, + 'require-table-caption': RequireTableCaption, }, configs: { recommended: { @@ -52,6 +54,7 @@ const config = { '@elastic/eui/no-unnamed-interactive-element': 'warn', '@elastic/eui/tooltip-focusable-anchor': 'warn', '@elastic/eui/accessible-interactive-element': 'warn', + '@elastic/eui/require-table-caption': 'warn', }, }, }, diff --git a/packages/eslint-plugin/src/rules/a11y/require_table_caption.test.ts b/packages/eslint-plugin/src/rules/a11y/require_table_caption.test.ts new file mode 100644 index 00000000000..7c3236f71d5 --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/require_table_caption.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import dedent from 'dedent'; +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { RequireTableCaption } from './require_table_caption'; + +const languageOptions = { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}; + +const ruleTester = new RuleTester(); + +ruleTester.run('require-table-caption', RequireTableCaption, { + valid: [ + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + }, + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + }, + { + code: dedent` + const MyComponent = () => ( + Should not affect other components
+ ) + `, + languageOptions, + }, + ], + invalid: [ + { + code: dedent` + const MyComponent = () => ( + + ); + `, + languageOptions, + errors: [ + { + messageId: 'missingTableCaption', + data: { component: 'EuiBasicTable' }, + }, + ], + }, + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + errors: [ + { + messageId: 'missingTableCaption', + data: { component: 'EuiInMemoryTable' }, + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/src/rules/a11y/require_table_caption.ts b/packages/eslint-plugin/src/rules/a11y/require_table_caption.ts new file mode 100644 index 00000000000..564153df75c --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/require_table_caption.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils'; + +const TABLE_COMPONENTS = ['EuiInMemoryTable', 'EuiBasicTable']; +const TABLE_CAPTION_PROP = 'tableCaption'; + +export const RequireTableCaption = ESLintUtils.RuleCreator.withoutDocs({ + create(context) { + return { + JSXOpeningElement(node: TSESTree.JSXOpeningElement) { + if (node.name.type !== 'JSXIdentifier') { + return; + } + + const component = node.name.name; + + if (!TABLE_COMPONENTS.includes(component)) { + return; + } + + const hasTableCaption = node.attributes.some( + (attr) => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === TABLE_CAPTION_PROP + ); + + if (!hasTableCaption) { + context.report({ + node, + messageId: 'missingTableCaption', + data: { component } + }); + } + }, + }; + }, + defaultOptions: [], + meta: { + type: 'problem', + docs: { + description: `Ensure ${TABLE_COMPONENTS.join(', ')} have a \`${TABLE_CAPTION_PROP}\` prop for accessibility.`, + }, + schema: [], + messages: { + missingTableCaption: [ + '{{component}} must include a `tableCaption` prop for accessibility.', + '', + 'Example:', + ' <{{component}} tableCaption="Descriptive caption for the table" ... />', + ].join('\n'), + }, + }, +});