@elastic/eslint-plugin-eui 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +64 -1
  2. package/lib/cjs/index.js +3 -0
  3. package/lib/cjs/rules/a11y/no_nested_interactive_element.d.ts +3 -0
  4. package/lib/cjs/rules/a11y/no_nested_interactive_element.d.ts.map +1 -0
  5. package/lib/cjs/rules/a11y/no_nested_interactive_element.js +202 -0
  6. package/lib/cjs/rules/a11y/tooltip_no_interactive_content.d.ts.map +1 -1
  7. package/lib/cjs/rules/a11y/tooltip_no_interactive_content.js +32 -13
  8. package/lib/cjs/utils/constants.d.ts +42 -5
  9. package/lib/cjs/utils/constants.d.ts.map +1 -1
  10. package/lib/cjs/utils/constants.js +60 -7
  11. package/lib/cjs/utils/is_interactive_element.d.ts +23 -0
  12. package/lib/cjs/utils/is_interactive_element.d.ts.map +1 -0
  13. package/lib/cjs/utils/is_interactive_element.js +71 -0
  14. package/lib/cjs/utils/walk_jsx_children.d.ts +5 -0
  15. package/lib/cjs/utils/walk_jsx_children.d.ts.map +1 -1
  16. package/lib/cjs/utils/walk_jsx_children.js +8 -4
  17. package/lib/esm/index.js +3 -0
  18. package/lib/esm/index.js.map +1 -1
  19. package/lib/esm/rules/a11y/no_nested_interactive_element.d.ts +2 -0
  20. package/lib/esm/rules/a11y/no_nested_interactive_element.js +225 -0
  21. package/lib/esm/rules/a11y/no_nested_interactive_element.js.map +1 -0
  22. package/lib/esm/rules/a11y/tooltip_no_interactive_content.js +33 -22
  23. package/lib/esm/rules/a11y/tooltip_no_interactive_content.js.map +1 -1
  24. package/lib/esm/utils/constants.d.ts +42 -5
  25. package/lib/esm/utils/constants.js +75 -10
  26. package/lib/esm/utils/constants.js.map +1 -1
  27. package/lib/esm/utils/is_interactive_element.d.ts +22 -0
  28. package/lib/esm/utils/is_interactive_element.js +71 -0
  29. package/lib/esm/utils/is_interactive_element.js.map +1 -0
  30. package/lib/esm/utils/walk_jsx_children.d.ts +5 -0
  31. package/lib/esm/utils/walk_jsx_children.js +8 -4
  32. package/lib/esm/utils/walk_jsx_children.js.map +1 -1
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -549,6 +549,69 @@ The rule only fires when `variant="selection"` is set as a static string. Dynami
549
549
  </EuiButtonGroup>
550
550
  ```
551
551
 
552
+ ### `@elastic/eui/no-nested-interactive-element`
553
+
554
+ Disallow interactive elements nested inside other interactive elements.
555
+
556
+ Nesting a control inside another control produces invalid HTML (`<button>` inside `<button>`, `<a>` inside `<a>`) and leaves the inner control unreachable or ambiguous for keyboard and screen-reader users.
557
+
558
+ The rule checks three groups of outer elements:
559
+
560
+ - **Native leaf controls** — `button` and `a` with `href`.
561
+ - **Leaf controls** — components that render their content inside a single focusable element: `EuiButton`, `EuiButtonEmpty`, `EuiButtonIcon`, `EuiContextMenuItem`, `EuiFacetButton`, `EuiFilterButton`, `EuiHeaderLink`, `EuiHeaderSectionItemButton`, `EuiKeyPadMenuItem`, `EuiLink`, `EuiListGroupItem`, `EuiStepHorizontal`, and `EuiTab`. Some of these only render a control when given the right props — `EuiListGroupItem` is an `<li>` without `onClick`/`href`, `EuiContextMenuItem` a `<div>` without `onClick`/`href`/`toolTipContent` — and are checked accordingly. Composite components whose purpose is to host controls (`EuiBasicTable`, `EuiSelectable`, `EuiSideNav`, `EuiButtonGroup`, …) are intentionally not checked.
562
+ - **Clickable `EuiCard`** — a card with `onClick` or `href` forwards clicks anywhere in the card to its title link, so a control in `title`, `description`, `footer`, `image`, or the card's children fires both its own action and the card's. Statically disabled cards (`isDisabled`) attach no handler and are skipped, as are `selectable` cards: pairing the select button with a footer action is a pattern EUI itself ships.
563
+
564
+ Beyond children, the rule also checks props whose value renders inside the focusable element: `label` and `icon` on `EuiListGroupItem`, `label` on `EuiKeyPadMenuItem`, `title` on `EuiStepHorizontal`, `prepend`/`append` on `EuiTab`, `icon` on `EuiFacetButton` and `EuiContextMenuItem`, and `notification` on `EuiHeaderSectionItemButton`. Props that render a **sibling** control (`EuiListGroupItem`'s `extraAction`) or render into a portal (`EuiContextMenuItem`'s `toolTipContent`) are not reported.
565
+
566
+ What counts as nested content:
567
+
568
+ - Non-interactive wrappers (`EuiFlexGroup`, `EuiText`, `EuiToolTip`, …) are traversed, so a control behind them is still found.
569
+ - Controls referenced through a local variable or a local arrow-function component are resolved and checked.
570
+ - Conditionally-interactive elements only count as interactive content when the relevant prop is present:
571
+ - `EuiBadge`: `onClick`, `href`, or `iconOnClick`
572
+ - `EuiBetaBadge`: `onClick`, `href`, or `tooltipContent`
573
+ - `EuiCard`: `onClick`, `href`, or `selectable`
574
+ - `EuiContextMenuItem`: `onClick`, `href`, or `toolTipContent`
575
+ - `EuiListGroupItem`: `onClick` or `href` (`extraAction` is checked separately as a sibling `EuiButtonIcon`)
576
+ - Native `<a>` and `EuiHeaderLogo`: `href` (including `href=""`)
577
+ - Note the exact prop casing: `EuiBetaBadge` uses `tooltipContent`, while `EuiContextMenuItem` uses `toolTipContent`.
578
+ - Dynamic content (e.g. `{renderLabel()}`) cannot be statically analyzed and is skipped.
579
+
580
+ #### Examples
581
+
582
+ ```tsx
583
+ // ✗ Bad - link inside a button
584
+ <EuiButton onClick={onClick}>
585
+ Read the <EuiLink href="/docs">docs</EuiLink>
586
+ </EuiButton>
587
+
588
+ // ✓ Render the controls as siblings
589
+ <EuiFlexGroup>
590
+ <EuiButton onClick={onClick}>Save</EuiButton>
591
+ <EuiLink href="/docs">Read the docs</EuiLink>
592
+ </EuiFlexGroup>
593
+ ```
594
+
595
+ ```tsx
596
+ // ✗ Bad - the footer button also triggers the card's `onClick`
597
+ <EuiCard
598
+ title="Dashboard"
599
+ description="View metrics"
600
+ onClick={onClick}
601
+ footer={<EuiButton>Open</EuiButton>}
602
+ />
603
+
604
+ // ✓ One action per card - either a clickable card…
605
+ <EuiCard title="Dashboard" description="View metrics" onClick={onClick} />
606
+
607
+ // ✓ …or a footer action
608
+ <EuiCard
609
+ title="Dashboard"
610
+ description="View metrics"
611
+ footer={<EuiButton>Open</EuiButton>}
612
+ />
613
+ ```
614
+
552
615
  ## Testing
553
616
 
554
617
  ### Running unit tests
@@ -570,7 +633,7 @@ To test the local changes to the plugin, you must:
570
633
  3. Build the package: `yarn build`
571
634
  4. Run `yalc publish` in the plugin's directory to publish it locally.
572
635
  5. In your project's directory, run `yalc add @elastic/eslint-plugin-eui` to link the locally published package.
573
- 6. Install dependencies: `yarn` (if you're a Kibana contributor, run `yarn kbn bootstrap --no-validate`).
636
+ 6. Install dependencies using your package manager.
574
637
  7. After making further changes to the plugin, repeat the steps from 3.
575
638
 
576
639
  ## Publishing
package/lib/cjs/index.js CHANGED
@@ -8,6 +8,7 @@ var _href_or_on_click = require("./rules/href_or_on_click");
8
8
  var _require_href_for_link = require("./rules/require_href_for_link");
9
9
  var _no_css_color = require("./rules/no_css_color");
10
10
  var _no_nested_copy_tooltip = require("./rules/a11y/no_nested_copy_tooltip");
11
+ var _no_nested_interactive_element = require("./rules/a11y/no_nested_interactive_element");
11
12
  var _no_restricted_eui_imports = require("./rules/no_restricted_eui_imports");
12
13
  var _no_static_z_index = require("./rules/no_static_z_index");
13
14
  var _no_unnamed_interactive_element = require("./rules/a11y/no_unnamed_interactive_element");
@@ -43,6 +44,7 @@ const config = {
43
44
  'no-css-color': _no_css_color.NoCssColor,
44
45
  'no-deprecated-icon-aliases': _no_deprecated_icon_aliases.NoDeprecatedIconAliases,
45
46
  'no-nested-copy-tooltip': _no_nested_copy_tooltip.NoNestedCopyTooltip,
47
+ 'no-nested-interactive-element': _no_nested_interactive_element.NoNestedInteractiveElement,
46
48
  'no-restricted-eui-imports': _no_restricted_eui_imports.NoRestrictedEuiImports,
47
49
  'no-static-z-index': _no_static_z_index.NoStaticZIndex,
48
50
  'no-unnamed-interactive-element': _no_unnamed_interactive_element.NoUnnamedInteractiveElement,
@@ -75,6 +77,7 @@ const config = {
75
77
  '@elastic/eui/no-css-color': 'warn',
76
78
  '@elastic/eui/no-deprecated-icon-aliases': 'warn',
77
79
  '@elastic/eui/no-nested-copy-tooltip': 'warn',
80
+ '@elastic/eui/no-nested-interactive-element': 'warn',
78
81
  '@elastic/eui/no-restricted-eui-imports': 'warn',
79
82
  '@elastic/eui/no-static-z-index': 'warn',
80
83
  '@elastic/eui/no-unnamed-interactive-element': 'warn',
@@ -0,0 +1,3 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ export declare const NoNestedInteractiveElement: ESLintUtils.RuleModule<"nestedInteractive" | "clickableCardContent", [], unknown, ESLintUtils.RuleListener>;
3
+ //# sourceMappingURL=no_nested_interactive_element.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"no_nested_interactive_element.d.ts","sourceRoot":"","sources":["../../../../src/rules/a11y/no_nested_interactive_element.ts"],"names":[],"mappings":"AAQA,OAAO,EAAiB,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAqNtE,eAAO,MAAM,0BAA0B,6GAgErC,CAAC"}
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.NoNestedInteractiveElement = void 0;
7
+ var _utils = require("@typescript-eslint/utils");
8
+ var _constants = require("../../utils/constants");
9
+ var _get_element_name = require("../../utils/get_element_name");
10
+ var _has_meaningful_attr = require("../../utils/has_meaningful_attr");
11
+ var _is_interactive_element = require("../../utils/is_interactive_element");
12
+ var _walk_jsx_children = require("../../utils/walk_jsx_children");
13
+ /*
14
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
15
+ * or more contributor license agreements. Licensed under the Elastic License
16
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
17
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
18
+ * Side Public License, v 1.
19
+ */
20
+
21
+ /** Describes why, and where, an element must not contain other controls. */
22
+
23
+ const CARD = 'EuiCard';
24
+ const LIST_GROUP_ITEM = 'EuiListGroupItem';
25
+ const LIST_GROUP_ITEM_EXTRA_ACTION = 'EuiButtonIcon';
26
+
27
+ /**
28
+ * Leaf-control props whose value renders *inside* the focusable element, in
29
+ * addition to children. Props that render a **sibling** control (e.g.
30
+ * `EuiListGroupItem` `extraAction`) are intentionally absent, as are props
31
+ * rendered into a portal (e.g. `EuiContextMenuItem` `toolTipContent`).
32
+ */
33
+ const LEAF_CONTENT_PROPS = {
34
+ EuiContextMenuItem: ['icon'],
35
+ EuiFacetButton: ['icon'],
36
+ EuiHeaderSectionItemButton: ['notification'],
37
+ EuiKeyPadMenuItem: ['label'],
38
+ EuiListGroupItem: ['icon', 'label'],
39
+ EuiStepHorizontal: ['title'],
40
+ EuiTab: ['append', 'prepend']
41
+ };
42
+
43
+ /**
44
+ * `EuiCard` props rendered inside the card wrapper, which is what carries the
45
+ * forwarding click handler. `betaBadgeProps` can also produce a control, but it
46
+ * is configured through an object rather than JSX and is not analysed here.
47
+ */
48
+ const CARD_CONTENT_PROPS = ['description', 'footer', 'image', 'title'];
49
+ const LEAF_COMPONENTS = new Set(_constants.LEAF_INTERACTIVE_EUI_COMPONENTS);
50
+ const LEAF_HTML_ELEMENTS = new Set(['a', 'button']);
51
+ const COMPONENTS_WITH_CHILDREN_PROP = new Set(['a', CARD, 'button', 'EuiBadge', 'EuiBetaBadge', 'EuiButton', 'EuiButtonEmpty', 'EuiContextMenuItem', 'EuiFacetButton', 'EuiFilterButton', 'EuiHeaderLink', 'EuiHeaderSectionItemButton', 'EuiKeyPadMenuItem', 'EuiLink', 'EuiTab']);
52
+
53
+ /**
54
+ * Whether a boolean prop is set to a statically-known `true` (`<El prop />` or
55
+ * `<El prop={true} />`). Dynamic values are treated as *not* set, so that a
56
+ * card gated on e.g. `isDisabled={isLoading}` is still checked.
57
+ */
58
+ function isStaticallyTrue(openingElement, propName) {
59
+ const attr = openingElement.attributes.find(a => a.type === 'JSXAttribute' && a.name.type === 'JSXIdentifier' && a.name.name === propName);
60
+ if (!attr) return false;
61
+ if (attr.value == null) return true;
62
+ return attr.value.type === 'JSXExpressionContainer' && attr.value.expression.type === 'Literal' && attr.value.expression.value === true;
63
+ }
64
+ function getTarget(componentName, node) {
65
+ const {
66
+ openingElement
67
+ } = node;
68
+ if (componentName === CARD) {
69
+ // A card with `onClick`/`href` attaches a wrapper click handler that
70
+ // forwards to the title link, so a control anywhere in its content fires
71
+ // both its own action and the card's. A disabled card attaches no handler.
72
+ //
73
+ // `selectable` cards are deliberately not a target: pairing the select
74
+ // button with a footer action is a pattern EUI itself ships. It is only
75
+ // safe when the footer control stops propagation, which this rule cannot
76
+ // verify statically — see the PR discussion.
77
+ if ((0, _has_meaningful_attr.hasMeaningfulAttr)(openingElement, 'selectable')) {
78
+ return null;
79
+ }
80
+ return (0, _is_interactive_element.hasInteractivityProp)(openingElement) && !isStaticallyTrue(openingElement, 'isDisabled') ? {
81
+ messageId: 'clickableCardContent',
82
+ contentProps: getComponentContentProps(componentName)
83
+ } : null;
84
+ }
85
+ if (LEAF_HTML_ELEMENTS.has(componentName) && (0, _is_interactive_element.isInteractiveElement)(node)) {
86
+ return {
87
+ messageId: 'nestedInteractive',
88
+ contentProps: []
89
+ };
90
+ }
91
+
92
+ // Several leaf components only render a control when given the right props
93
+ // (`EuiListGroupItem` is an `<li>` without an action, `EuiContextMenuItem` a
94
+ // `<div>`), so the name alone is not enough to make it a target.
95
+ if (LEAF_COMPONENTS.has(componentName) && (0, _is_interactive_element.isInteractiveElement)(node)) {
96
+ return {
97
+ messageId: 'nestedInteractive',
98
+ contentProps: getComponentContentProps(componentName)
99
+ };
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /** The element's children plus the expression value of each content prop. */
105
+ function getContentRoots(node, componentName, contentProps) {
106
+ const roots = [...node.children];
107
+ for (const attr of node.openingElement.attributes) {
108
+ if (attr.type !== 'JSXAttribute' || attr.name.type !== 'JSXIdentifier') {
109
+ continue;
110
+ }
111
+ const isChildrenProp = attr.name.name === 'children' && componentName != null && COMPONENTS_WITH_CHILDREN_PROP.has(componentName);
112
+ if (!isChildrenProp && !contentProps.includes(attr.name.name)) continue;
113
+ // Only `prop={<JSX />}` can hold an element; a string literal cannot.
114
+ if (attr.value?.type !== 'JSXExpressionContainer') continue;
115
+ if (attr.value.expression.type === 'JSXEmptyExpression') continue;
116
+ roots.push(attr.value.expression);
117
+ }
118
+ return roots;
119
+ }
120
+ function getComponentContentProps(componentName) {
121
+ if (componentName === CARD) {
122
+ return CARD_CONTENT_PROPS;
123
+ }
124
+ return LEAF_CONTENT_PROPS[componentName ?? ''] ?? [];
125
+ }
126
+ function getSyntheticInteractiveElementName(element) {
127
+ const componentName = (0, _get_element_name.getElementName)(element.openingElement);
128
+ if (componentName === LIST_GROUP_ITEM && (0, _has_meaningful_attr.hasMeaningfulAttr)(element.openingElement, 'extraAction')) {
129
+ return LIST_GROUP_ITEM_EXTRA_ACTION;
130
+ }
131
+ return null;
132
+ }
133
+ function getDescendantRoots(node) {
134
+ const componentName = (0, _get_element_name.getElementName)(node.openingElement);
135
+
136
+ // `getContentRoots` also adds supported `children={...}` content based on
137
+ // the component name, so descendants are traversed the same way as targets.
138
+ return getContentRoots(node, componentName, getComponentContentProps(componentName));
139
+ }
140
+ function shouldSkipNestedScanElement(element) {
141
+ const elementName = (0, _get_element_name.getElementName)(element.openingElement);
142
+ if (!elementName) return true;
143
+ if (getSyntheticInteractiveElementName(element)) return false;
144
+
145
+ // Custom components stay traversable here; local ones are resolved by
146
+ // `walkJsxChildren` before this guard runs, and unresolved ones remain opaque.
147
+ if (/^[A-Z]/.test(elementName) && !elementName.startsWith('Eui')) {
148
+ return true;
149
+ }
150
+ return !(0, _is_interactive_element.isInteractiveElement)(element);
151
+ }
152
+ const NoNestedInteractiveElement = exports.NoNestedInteractiveElement = _utils.ESLintUtils.RuleCreator.withoutDocs({
153
+ create(context) {
154
+ return {
155
+ JSXElement(node) {
156
+ const componentName = (0, _get_element_name.getElementName)(node.openingElement);
157
+ if (!componentName) return;
158
+ const target = getTarget(componentName, node);
159
+ if (!target) return;
160
+
161
+ // Non-interactive elements (layout wrappers, `EuiToolTip`, text, …) are
162
+ // walked through; interactive ones are reported and not descended into,
163
+ // so this scan flags only the outermost control of a subtree. Anything
164
+ // deeper is reported against its own nearest target, when that target
165
+ // is itself scanned.
166
+ const reported = new Set();
167
+ for (const root of getContentRoots(node, componentName, target.contentProps)) {
168
+ (0, _walk_jsx_children.walkJsxChildren)(root, leaf => {
169
+ if (leaf.type !== 'JSXElement' || reported.has(leaf)) return;
170
+ const elementName = getSyntheticInteractiveElementName(leaf) ?? (0, _get_element_name.getElementName)(leaf.openingElement);
171
+ if (!elementName) return;
172
+ reported.add(leaf);
173
+ context.report({
174
+ node: leaf.openingElement,
175
+ messageId: target.messageId,
176
+ data: {
177
+ componentName,
178
+ elementName
179
+ }
180
+ });
181
+ }, {
182
+ sourceCode: context.sourceCode,
183
+ shouldSkip: shouldSkipNestedScanElement,
184
+ getChildren: getDescendantRoots
185
+ });
186
+ }
187
+ }
188
+ };
189
+ },
190
+ meta: {
191
+ type: 'problem',
192
+ docs: {
193
+ description: 'Disallow interactive elements inside other interactive elements'
194
+ },
195
+ schema: [],
196
+ messages: {
197
+ nestedInteractive: '{{ elementName }} is nested inside {{ componentName }}, which renders a single focusable element. Nested controls are invalid HTML and not reliably reachable by keyboard or screen reader. Render {{ elementName }} as a sibling instead.',
198
+ clickableCardContent: '{{ elementName }} inside a clickable {{ componentName }} also triggers the card `onClick`/`href` when activated. Use one action per card: remove the card `onClick`/`href`, or move {{ elementName }} outside the card.'
199
+ }
200
+ },
201
+ defaultOptions: []
202
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"tooltip_no_interactive_content.d.ts","sourceRoot":"","sources":["../../../../src/rules/a11y/tooltip_no_interactive_content.ts"],"names":[],"mappings":"AAQA,OAAO,EAAiB,WAAW,EAAE,MAAM,0BAA0B,CAAC;AA4BtE,eAAO,MAAM,2BAA2B,uFAgFtC,CAAC"}
1
+ {"version":3,"file":"tooltip_no_interactive_content.d.ts","sourceRoot":"","sources":["../../../../src/rules/a11y/tooltip_no_interactive_content.ts"],"names":[],"mappings":"AAQA,OAAO,EAAiB,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAyCtE,eAAO,MAAM,2BAA2B,uFA8EtC,CAAC"}
@@ -5,9 +5,10 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.TooltipNoInteractiveContent = void 0;
7
7
  var _utils = require("@typescript-eslint/utils");
8
- var _constants = require("../../utils/constants");
9
8
  var _walk_jsx_children = require("../../utils/walk_jsx_children");
10
9
  var _get_element_name = require("../../utils/get_element_name");
10
+ var _has_meaningful_attr = require("../../utils/has_meaningful_attr");
11
+ var _is_interactive_element = require("../../utils/is_interactive_element");
11
12
  /*
12
13
  * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
13
14
  * or more contributor license agreements. Licensed under the Elastic License
@@ -18,9 +19,27 @@ var _get_element_name = require("../../utils/get_element_name");
18
19
 
19
20
  const TOOLTIP_COMPONENTS = ['EuiToolTip', 'EuiIconTip'];
20
21
  const TOOLTIP_CONTENT_PROPS = ['content', 'title'];
21
- const INTERACTIVE_HTML_ELEMENTS = ['a', 'button', 'input', 'select', 'textarea'];
22
- const CONDITIONALLY_INTERACTIVE_SET = new Set(_constants.CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS);
23
- const INTERACTIVE_ELEMENTS = new Set([...INTERACTIVE_HTML_ELEMENTS, ..._constants.INTERACTIVE_EUI_COMPONENTS.filter(c => !CONDITIONALLY_INTERACTIVE_SET.has(c))]);
22
+ const LIST_GROUP_ITEM = 'EuiListGroupItem';
23
+ const LIST_GROUP_ITEM_EXTRA_ACTION = 'EuiButtonIcon';
24
+ function getSyntheticInteractiveElementName(element) {
25
+ const componentName = (0, _get_element_name.getElementName)(element.openingElement);
26
+ if (componentName === LIST_GROUP_ITEM && (0, _has_meaningful_attr.hasMeaningfulAttr)(element.openingElement, 'extraAction')) {
27
+ return LIST_GROUP_ITEM_EXTRA_ACTION;
28
+ }
29
+ return null;
30
+ }
31
+ function shouldSkipTooltipContentElement(element) {
32
+ const elementName = (0, _get_element_name.getElementName)(element.openingElement);
33
+ if (!elementName) return true;
34
+ if (getSyntheticInteractiveElementName(element)) return false;
35
+
36
+ // Custom components stay traversable here; local ones are resolved by
37
+ // `walkJsxChildren` before this guard runs, and unresolved ones remain opaque.
38
+ if (/^[A-Z]/.test(elementName) && !elementName.startsWith('Eui')) {
39
+ return true;
40
+ }
41
+ return !(0, _is_interactive_element.isInteractiveElement)(element);
42
+ }
24
43
  const TooltipNoInteractiveContent = exports.TooltipNoInteractiveContent = _utils.ESLintUtils.RuleCreator.withoutDocs({
25
44
  create(context) {
26
45
  return {
@@ -48,24 +67,24 @@ const TooltipNoInteractiveContent = exports.TooltipNoInteractiveContent = _utils
48
67
  let found = false;
49
68
  (0, _walk_jsx_children.walkJsxChildren)(expression, leaf => {
50
69
  if (found || leaf.type !== 'JSXElement') return;
51
- const el = leaf;
52
- const name = (0, _get_element_name.getElementName)(el.openingElement);
53
- if (!name || !INTERACTIVE_ELEMENTS.has(name)) return;
70
+ const syntheticElementName = getSyntheticInteractiveElementName(leaf);
71
+ const elementName = syntheticElementName ?? (0, _get_element_name.getElementName)(leaf.openingElement);
72
+ if (!elementName || !syntheticElementName && !(0, _is_interactive_element.isInteractiveElement)(leaf)) {
73
+ return;
74
+ }
54
75
  context.report({
55
- node: el.openingElement,
76
+ node: leaf.openingElement,
56
77
  messageId: 'noInteractiveContent',
57
78
  data: {
58
79
  propName: attr.name.name,
59
80
  componentName,
60
- elementName: name
81
+ elementName
61
82
  }
62
83
  });
63
84
  found = true;
64
85
  }, {
65
- shouldSkip: el => {
66
- const name = (0, _get_element_name.getElementName)(el.openingElement);
67
- return !name || !INTERACTIVE_ELEMENTS.has(name);
68
- }
86
+ sourceCode: context.sourceCode,
87
+ shouldSkip: shouldSkipTooltipContentElement
69
88
  });
70
89
  }
71
90
  }
@@ -25,15 +25,39 @@ export declare const NON_INTERACTIVE_HTML_TAGS: string[];
25
25
  */
26
26
  export declare const INTERACTIVE_EUI_COMPONENTS: string[];
27
27
  /**
28
- * EUI components that are only interactive when `onClick` or `href` is provided.
29
- * Without those props they render as a plain non-focusable element (span/div),
30
- * so rules that need to distinguish unconditionally-interactive components should
31
- * exclude these.
28
+ * EUI components that render a focusable element only when given one of the
29
+ * listed props. Without them they render plain, non-focusable markup — a
30
+ * `<span>`, `<div>`, or `<li>` — so rules that need an unconditionally
31
+ * interactive element must check the props rather than the name alone.
32
+ *
33
+ * - `EuiBadge` / `EuiBetaBadge` render a `<span>` unless clickable.
34
+ * - `EuiCard` renders a plain panel; `selectable` also makes it clickable by
35
+ * rendering an `EuiCardSelect` button.
36
+ * - `EuiContextMenuItem` renders a `<div>` unless it has an action, and is a
37
+ * `<button>` when given `toolTipContent`.
38
+ * - `EuiHeaderLogo` renders an `<a>` without an `href` attribute, which is not
39
+ * focusable.
40
+ * - `EuiListGroupItem` renders an `<li>` unless it has an action.
41
+ */
42
+ export declare const CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS: Record<string, string[]>;
43
+ /**
44
+ * Native HTML elements that are focusable only when given one of the listed
45
+ * attributes. An `<a>` without `href` is not a link and is not focusable.
46
+ */
47
+ export declare const CONDITIONALLY_INTERACTIVE_HTML_ELEMENTS: Record<string, string[]>;
48
+ /**
49
+ * Conditional interactive props whose presence alone is enough to make the
50
+ * rendered element focusable, even when the statically-known value is `""`.
51
+ *
52
+ * Native anchors and `EuiHeaderLogo` both forward `href=""` to the DOM, which
53
+ * still creates a focusable same-document link.
32
54
  */
33
- export declare const CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS: string[];
55
+ export declare const PRESENCE_INTERACTIVE_PROPS: Record<string, string[]>;
34
56
  export declare const HTML_TEXT_ELEMENTS: Set<string>;
35
57
  export declare const EUI_TEXT_COMPONENTS: Set<string>;
36
58
  export declare const HTML_ACTION_ELEMENTS: Set<string>;
59
+ /** Native HTML elements that are focusable and respond to user input. */
60
+ export declare const INTERACTIVE_HTML_ELEMENTS: string[];
37
61
  /**
38
62
  * Transparent layout wrappers inside `EuiCallOut` children that the rule should
39
63
  * traverse rather than treat as opaque custom components.
@@ -45,4 +69,17 @@ export declare const CALLOUT_LAYOUT_CONTAINERS: Set<string>;
45
69
  * Rules treat these the same as `HTML_TEXT_ELEMENTS` / `EUI_TEXT_COMPONENTS`.
46
70
  */
47
71
  export declare const I18N_TEXT_COMPONENTS: Set<string>;
72
+ /**
73
+ * EUI components that render their content **inside** a single focusable
74
+ * `<button>` or `<a>`.
75
+ *
76
+ * Nesting another interactive element in their content produces invalid HTML
77
+ * (e.g. `<button>` inside `<button>`) and leaves the inner control unreachable
78
+ * or ambiguous for keyboard and screen-reader users.
79
+ *
80
+ * This is deliberately narrower than `INTERACTIVE_EUI_COMPONENTS`, which also
81
+ * contains composite components (`EuiBasicTable`, `EuiSelectable`, `EuiSideNav`,
82
+ * …) whose entire purpose is to host controls.
83
+ */
84
+ export declare const LEAF_INTERACTIVE_EUI_COMPONENTS: string[];
48
85
  //# sourceMappingURL=constants.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/utils/constants.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,yBAAyB,UAyCrC,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,UA8CtC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,UAIpD,CAAC;AAEF,eAAO,MAAM,kBAAkB,aAS7B,CAAC;AAEH,eAAO,MAAM,mBAAmB,aAO9B,CAAC;AAEH,eAAO,MAAM,oBAAoB,aAA2B,CAAC;AAE7D;;;GAGG;AACH,eAAO,MAAM,yBAAyB,aAMpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,aAAgC,CAAC"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/utils/constants.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,yBAAyB,UAyCrC,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,UA8CtC,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,wCAAwC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAQ3E,CAAC;AAEJ;;;GAGG;AACH,eAAO,MAAM,uCAAuC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAG1E,CAAC;AAEJ;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAG/D,CAAC;AAEF,eAAO,MAAM,kBAAkB,aAS7B,CAAC;AAEH,eAAO,MAAM,mBAAmB,aAO9B,CAAC;AAEH,eAAO,MAAM,oBAAoB,aAA2B,CAAC;AAE7D,yEAAyE;AACzE,eAAO,MAAM,yBAAyB,UAMrC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,aAMpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,aAAgC,CAAC;AAElE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,+BAA+B,UAc3C,CAAC"}
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.NON_INTERACTIVE_HTML_TAGS = exports.INTERACTIVE_EUI_COMPONENTS = exports.I18N_TEXT_COMPONENTS = exports.HTML_TEXT_ELEMENTS = exports.HTML_ACTION_ELEMENTS = exports.EUI_TEXT_COMPONENTS = exports.CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS = exports.CALLOUT_LAYOUT_CONTAINERS = void 0;
6
+ exports.PRESENCE_INTERACTIVE_PROPS = exports.NON_INTERACTIVE_HTML_TAGS = exports.LEAF_INTERACTIVE_EUI_COMPONENTS = exports.INTERACTIVE_HTML_ELEMENTS = exports.INTERACTIVE_EUI_COMPONENTS = exports.I18N_TEXT_COMPONENTS = exports.HTML_TEXT_ELEMENTS = exports.HTML_ACTION_ELEMENTS = exports.EUI_TEXT_COMPONENTS = exports.CONDITIONALLY_INTERACTIVE_HTML_ELEMENTS = exports.CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS = exports.CALLOUT_LAYOUT_CONTAINERS = void 0;
7
7
  /*
8
8
  * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
9
9
  * or more contributor license agreements. Licensed under the Elastic License
@@ -41,16 +41,55 @@ const NON_INTERACTIVE_HTML_TAGS = exports.NON_INTERACTIVE_HTML_TAGS = ['div', 's
41
41
  const INTERACTIVE_EUI_COMPONENTS = exports.INTERACTIVE_EUI_COMPONENTS = ['EuiBadge', 'EuiBasicTable', 'EuiBetaBadge', 'EuiBreadcrumbs', 'EuiButton', 'EuiButtonEmpty', 'EuiButtonGroup', 'EuiButtonIcon', 'EuiCard', 'EuiCheckableCard', 'EuiCheckbox', 'EuiColorPicker', 'EuiComboBox', 'EuiContextMenuItem', 'EuiDatePicker', 'EuiDualRange', 'EuiFacetButton', 'EuiFieldNumber', 'EuiFieldPassword', 'EuiFieldSearch', 'EuiFieldText', 'EuiFilterButton', 'EuiFilterSelectItem', 'EuiFilterSelectable', 'EuiHeaderLink', 'EuiHeaderLogo', 'EuiHeaderSectionItemButton', 'EuiInMemoryTable', 'EuiKeyPadMenuItem', 'EuiLink', 'EuiListGroupItem', 'EuiPagination', 'EuiPinnableListGroup', 'EuiRadio', 'EuiRange', 'EuiSelect', 'EuiSelectable', 'EuiSideNav', 'EuiStepHorizontal', 'EuiSuperDatePicker', 'EuiSuperSelect', 'EuiSwitch', 'EuiTab', 'EuiTextArea', 'EuiTreeView'];
42
42
 
43
43
  /**
44
- * EUI components that are only interactive when `onClick` or `href` is provided.
45
- * Without those props they render as a plain non-focusable element (span/div),
46
- * so rules that need to distinguish unconditionally-interactive components should
47
- * exclude these.
44
+ * EUI components that render a focusable element only when given one of the
45
+ * listed props. Without them they render plain, non-focusable markup — a
46
+ * `<span>`, `<div>`, or `<li>` — so rules that need an unconditionally
47
+ * interactive element must check the props rather than the name alone.
48
+ *
49
+ * - `EuiBadge` / `EuiBetaBadge` render a `<span>` unless clickable.
50
+ * - `EuiCard` renders a plain panel; `selectable` also makes it clickable by
51
+ * rendering an `EuiCardSelect` button.
52
+ * - `EuiContextMenuItem` renders a `<div>` unless it has an action, and is a
53
+ * `<button>` when given `toolTipContent`.
54
+ * - `EuiHeaderLogo` renders an `<a>` without an `href` attribute, which is not
55
+ * focusable.
56
+ * - `EuiListGroupItem` renders an `<li>` unless it has an action.
57
+ */
58
+ const CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS = exports.CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS = {
59
+ EuiBadge: ['iconOnClick', 'onClick', 'href'],
60
+ EuiBetaBadge: ['tooltipContent', 'onClick', 'href'],
61
+ EuiCard: ['onClick', 'href', 'selectable'],
62
+ EuiContextMenuItem: ['onClick', 'href', 'toolTipContent'],
63
+ EuiHeaderLogo: ['href'],
64
+ EuiListGroupItem: ['onClick', 'href']
65
+ };
66
+
67
+ /**
68
+ * Native HTML elements that are focusable only when given one of the listed
69
+ * attributes. An `<a>` without `href` is not a link and is not focusable.
70
+ */
71
+ const CONDITIONALLY_INTERACTIVE_HTML_ELEMENTS = exports.CONDITIONALLY_INTERACTIVE_HTML_ELEMENTS = {
72
+ a: ['href']
73
+ };
74
+
75
+ /**
76
+ * Conditional interactive props whose presence alone is enough to make the
77
+ * rendered element focusable, even when the statically-known value is `""`.
78
+ *
79
+ * Native anchors and `EuiHeaderLogo` both forward `href=""` to the DOM, which
80
+ * still creates a focusable same-document link.
48
81
  */
49
- const CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS = exports.CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS = ['EuiBadge', 'EuiBetaBadge', 'EuiCard'];
82
+ const PRESENCE_INTERACTIVE_PROPS = exports.PRESENCE_INTERACTIVE_PROPS = {
83
+ a: ['href'],
84
+ EuiHeaderLogo: ['href']
85
+ };
50
86
  const HTML_TEXT_ELEMENTS = exports.HTML_TEXT_ELEMENTS = new Set(['p', 'span', 'strong', 'em', 'b', 'i', 'small', 'code']);
51
87
  const EUI_TEXT_COMPONENTS = exports.EUI_TEXT_COMPONENTS = new Set(['EuiText', 'EuiTextColor', 'EuiTextAlign', 'EuiCode', 'EuiMark', 'EuiHighlight']);
52
88
  const HTML_ACTION_ELEMENTS = exports.HTML_ACTION_ELEMENTS = new Set(['button', 'a']);
53
89
 
90
+ /** Native HTML elements that are focusable and respond to user input. */
91
+ const INTERACTIVE_HTML_ELEMENTS = exports.INTERACTIVE_HTML_ELEMENTS = ['a', 'button', 'input', 'select', 'textarea'];
92
+
54
93
  /**
55
94
  * Transparent layout wrappers inside `EuiCallOut` children that the rule should
56
95
  * traverse rather than treat as opaque custom components.
@@ -62,4 +101,18 @@ const CALLOUT_LAYOUT_CONTAINERS = exports.CALLOUT_LAYOUT_CONTAINERS = new Set(['
62
101
  * (e.g. `FormattedMessage` from react-intl used extensively in Kibana).
63
102
  * Rules treat these the same as `HTML_TEXT_ELEMENTS` / `EUI_TEXT_COMPONENTS`.
64
103
  */
65
- const I18N_TEXT_COMPONENTS = exports.I18N_TEXT_COMPONENTS = new Set(['FormattedMessage']);
104
+ const I18N_TEXT_COMPONENTS = exports.I18N_TEXT_COMPONENTS = new Set(['FormattedMessage']);
105
+
106
+ /**
107
+ * EUI components that render their content **inside** a single focusable
108
+ * `<button>` or `<a>`.
109
+ *
110
+ * Nesting another interactive element in their content produces invalid HTML
111
+ * (e.g. `<button>` inside `<button>`) and leaves the inner control unreachable
112
+ * or ambiguous for keyboard and screen-reader users.
113
+ *
114
+ * This is deliberately narrower than `INTERACTIVE_EUI_COMPONENTS`, which also
115
+ * contains composite components (`EuiBasicTable`, `EuiSelectable`, `EuiSideNav`,
116
+ * …) whose entire purpose is to host controls.
117
+ */
118
+ const LEAF_INTERACTIVE_EUI_COMPONENTS = exports.LEAF_INTERACTIVE_EUI_COMPONENTS = ['EuiButton', 'EuiButtonEmpty', 'EuiButtonIcon', 'EuiContextMenuItem', 'EuiFacetButton', 'EuiFilterButton', 'EuiHeaderLink', 'EuiHeaderSectionItemButton', 'EuiKeyPadMenuItem', 'EuiLink', 'EuiListGroupItem', 'EuiStepHorizontal', 'EuiTab'];
@@ -0,0 +1,23 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ /**
3
+ * Whether any of `propNames` is present on the element with a statically
4
+ * non-empty value. Dynamic values (`prop={maybe}`) count as present, since
5
+ * their runtime value is unknown.
6
+ */
7
+ export declare function hasAnyProp(openingElement: TSESTree.JSXOpeningElement, propNames: string[], presenceOnlyProps?: Set<string>): boolean;
8
+ /**
9
+ * Whether an element has a prop that makes it respond to user input
10
+ * (`onClick` or `href`).
11
+ */
12
+ export declare function hasInteractivityProp(openingElement: TSESTree.JSXOpeningElement): boolean;
13
+ /**
14
+ * Whether a JSX element renders a focusable control.
15
+ *
16
+ * Native interactive HTML elements and unconditionally interactive EUI
17
+ * components always qualify. Everything in
18
+ * `CONDITIONALLY_INTERACTIVE_EUI_COMPONENTS` and
19
+ * `CONDITIONALLY_INTERACTIVE_HTML_ELEMENTS` — `EuiBadge`, `EuiListGroupItem`,
20
+ * a bare `<a>`, … — qualifies only when given a prop that makes it focusable.
21
+ */
22
+ export declare function isInteractiveElement(element: TSESTree.JSXElement): boolean;
23
+ //# sourceMappingURL=is_interactive_element.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"is_interactive_element.d.ts","sourceRoot":"","sources":["../../../src/utils/is_interactive_element.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AA2BzD;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,cAAc,EAAE,QAAQ,CAAC,iBAAiB,EAC1C,SAAS,EAAE,MAAM,EAAE,EACnB,iBAAiB,cAAoB,GACpC,OAAO,CAmBT;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,cAAc,EAAE,QAAQ,CAAC,iBAAiB,GACzC,OAAO,CAET;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,QAAQ,CAAC,UAAU,GAAG,OAAO,CAgB1E"}