Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ This package contains an eslint plugin that enforces some default rules for usin

`<EuiButton />` should either be a button or a link, for a11y purposes. When given an `href` the button behaves as a link, otherwise an `onClick` handler is expected and it will behave as a button.

### `@elastic/eui/href-or-on-click`

`<EuiButton />` should either be a button or a link, for a11y purposes. When given an `href` the button behaves as a link, otherwise an `onClick` handler is expected and it will behave as a button.

In some cases it makes sense to disable this rule locally, such as when <kbd>cmd</kbd> + click should open the link in a new tab, but a standard click should use the `history.pushState()` API to change the URL without triggering a full page load.

**Exception**: `EuiLink` has to be provided with both `onClick` and `href` so that it renders as an anchor and support Ctrl/Cmd+Click to open in a new tab, and other standard link interactions. See `@elastic/eui/require-href-for-link`.

### `@elastic/eui/no-restricted-eui-imports`

At times, we deprecate features that may need more highlighting and/or that are not possible to annotate with JSDoc `@deprecated`, e.g. JSON token imports: `@elastic/eui/dist/eui_theme_*.json` (for context: https://github.com/elastic/kibana/issues/199715#json-tokens).
Expand Down Expand Up @@ -175,6 +181,10 @@ Ensure the `EuiBadge` includes appropriate accessibility attributes.
- `iconOnClickAriaLabel` is only valid when `iconOnClick` is present. The rule autofixes by removing `iconOnClickAriaLabel`.
- `onClickAriaLabel` is only valid when `onClick` is present. The rule autofixes by removing `onClickAriaLabel`.

### `@elastic/eui/require-href-for-link`

Ensure `EuiLink` components that have an `onClick` handler also include an `href` prop. Without `href`, the component does not render as a true link, which means users cannot Ctrl/Cmd+Click to open in a new tab or use other standard link interactions. The rule bails out when spread attributes are present, since `href` may be provided via the spread.

### `@elastic/eui/icon-accessibility-rules`

Ensure the `EuiIcon` includes appropriate accessibility attributes.
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/changelogs/upcoming/9615.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added new `require-href-for-link` rule
3 changes: 3 additions & 0 deletions packages/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AccessibleInteractiveElements } from './rules/a11y/accessible_interacti
import { CallOutAnnounceOnMount } from './rules/a11y/callout_announce_on_mount';
import { ConsistentIsInvalidProps } from './rules/a11y/consistent_is_invalid_props';
import { HrefOnClick } from './rules/href_or_on_click';
import { RequireHrefForLink } from './rules/require_href_for_link';
import { NoCssColor } from './rules/no_css_color';
import { NoRestrictedEuiImports } from './rules/no_restricted_eui_imports';
import { NoStaticZIndex } from './rules/no_static_z_index';
Expand Down Expand Up @@ -44,6 +45,7 @@ const config = {
PreferTooltipTriggerFocusTestUtility,
'badge-accessibility-rules': EuiBadgeAccessibilityRules,
'icon-accessibility-rules': EuiIconAccessibilityRules,
'require-href-for-link': RequireHrefForLink,
},
configs: {
recommended: {
Expand All @@ -66,6 +68,7 @@ const config = {
'@elastic/eui/prefer-tooltip-trigger-focus-test-utility': 'warn',
'@elastic/eui/badge-accessibility-rules': 'warn',
'@elastic/eui/icon-accessibility-rules': 'warn',
'@elastic/eui/require-href-for-link': 'warn',
},
},
},
Expand Down
13 changes: 0 additions & 13 deletions packages/eslint-plugin/src/rules/href_or_on_click.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,5 @@ ruleTester.run('href-or-on-click', HrefOnClick, {
},
],
},
{
code: dedent`
module.export = () => (
<EuiLink href="/" onClick={fooBar} />
)
`,
languageOptions,
errors: [
{
messageId: 'hrefOrOnClick',
},
],
},
],
});
2 changes: 1 addition & 1 deletion packages/eslint-plugin/src/rules/href_or_on_click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { TSESTree, ESLintUtils } from '@typescript-eslint/utils';

const componentNames = ['EuiButton', 'EuiButtonEmpty', 'EuiLink', 'EuiBadge'];
const componentNames = ['EuiButton', 'EuiButtonEmpty', 'EuiBadge'];

export const HrefOnClick = ESLintUtils.RuleCreator.withoutDocs({
create(context) {
Expand Down
83 changes: 83 additions & 0 deletions packages/eslint-plugin/src/rules/require_href_for_link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* 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 { RequireHrefForLink } from './require_href_for_link';

const languageOptions = {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
};

const ruleTester = new RuleTester();

ruleTester.run('require-href-for-link', RequireHrefForLink, {
valid: [
{
code: dedent`
module.export = () => (
<EuiLink />
)
`,
languageOptions,
},
{
code: dedent`
module.export = () => (
<EuiLink href="/" />
)
`,
languageOptions,
},
{
code: dedent`
module.export = () => (
<EuiLink href="/" onClick={handleClick} />
)
`,
languageOptions,
},
{
code: dedent`
module.export = () => (
<EuiLink {...linkProps} onClick={handleClick} />
)
`,
languageOptions,
},
{
code: dedent`
module.export = () => (
<EuiButton onClick={handleClick} />
)
`,
languageOptions,
},
],

invalid: [
{
code: dedent`
module.export = () => (
<EuiLink onClick={handleClick} />
)
`,
languageOptions,
errors: [
{
messageId: 'requireHrefForLink',
},
],
},
],
});
64 changes: 64 additions & 0 deletions packages/eslint-plugin/src/rules/require_href_for_link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* 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 { TSESTree, ESLintUtils } from '@typescript-eslint/utils';

import { hasSpread } from '../utils/has_spread';

export const RequireHrefForLink = ESLintUtils.RuleCreator.withoutDocs({
create(context) {
return {
JSXOpeningElement(node: TSESTree.JSXOpeningElement): void {
if (
node.name.type !== 'JSXIdentifier' ||
node.name.name !== 'EuiLink'
) {
return;
}

// Bail out if props are spread — we can't statically determine
// whether `href` is provided via the spread
if (hasSpread(node.attributes)) {
return;
}

// Check if the node has `href` and `onClick` attributes
const hasHref = node.attributes.some(
(attr) => attr.type === 'JSXAttribute' && attr.name.name === 'href'
);
const hasOnClick = node.attributes.some(
(attr) => attr.type === 'JSXAttribute' && attr.name.name === 'onClick'
);

// Report an issue if `onClick` is present without `href`
if (hasOnClick && !hasHref) {
context.report({
node,
messageId: 'requireHrefForLink',
data: {
name: node.name.name,
},
});
}
},
};
},
meta: {
type: 'suggestion',
docs: {
description:
'Recommend including `href` alongside `onClick` on EuiLink so that Ctrl/Cmd+Click (open link in new tab) works.',
},
schema: [],
messages: {
requireHrefForLink:
'<{{name}}> has `onClick` but no `href`. Consider adding `href` so that the component renders as a link and supports Ctrl/Cmd+Click / "Open link in new tab".',
},
},
defaultOptions: [],
});