Skip to content

Commit 6020fa7

Browse files
committed
feat: gate template-no-noninteractive-element-to-interactive-role on isNativeElement
Add is-native-element util and guard the rule against PascalCase components whose lowercased tag name collides with a NON_INTERACTIVE_TAGS entry (e.g. `<Article>` was misclassified as native `<article>` because the existing tag normalization used `node.tag?.toLowerCase()`). Regression tests added for `<Article role="button">` and `<Section role="tab">`.
1 parent e76da71 commit 6020fa7

3 files changed

Lines changed: 99 additions & 0 deletions

File tree

lib/rules/template-no-noninteractive-element-to-interactive-role.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const { elementRoles, roles } = require('aria-query');
22
const { AXObjects, elementAXObjects } = require('axobject-query');
33
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
4+
const { isNativeElement } = require('../utils/is-native-element');
45

56
// Elements with inherent non-interactive accessibility-tree semantics. We
67
// union two derivations to match jsx-a11y's `isNonInteractiveElement`
@@ -118,8 +119,16 @@ module.exports = {
118119
},
119120

120121
create(context) {
122+
const sourceCode = context.sourceCode || context.getSourceCode();
121123
return {
122124
GlimmerElementNode(node) {
125+
// PascalCase tags like `<Article>` collide with lowercase native tags
126+
// when naively lowercased for the NON_INTERACTIVE_TAGS lookup. Gate
127+
// on `isNativeElement` so components and scope-shadowed bindings are
128+
// filtered out before we classify the element.
129+
if (!isNativeElement(node, sourceCode)) {
130+
return;
131+
}
123132
const tag = node.tag?.toLowerCase();
124133
if (!tag || !NON_INTERACTIVE_TAGS.has(tag)) {
125134
return;

lib/utils/is-native-element.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use strict';
2+
3+
const htmlTags = require('html-tags');
4+
const svgTags = require('svg-tags');
5+
const { mathmlTagNames } = require('mathml-tag-names');
6+
7+
// Authoritative set of native element tag names. Mirrors the approach
8+
// established by #2689 (template-no-block-params-for-html-elements), which
9+
// the maintainer requires for component-vs-element discrimination in this
10+
// plugin. Heuristic approaches (PascalCase detection, etc.) were explicitly
11+
// rejected there because a lowercase tag CAN be a component in GJS/GTS when
12+
// the name is bound in scope (e.g. `const div = MyComponent; <div />`).
13+
const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
14+
15+
/**
16+
* Returns true if the Glimmer element node is a native HTML / SVG / MathML
17+
* element — i.e. the tag name is in the authoritative list AND is not
18+
* shadowed by an in-scope binding.
19+
*
20+
* "Native" here means **spec-registered tag name** (in the HTML, SVG, or
21+
* MathML spec registries, reached via the `html-tags` / `svg-tags` /
22+
* `mathml-tag-names` packages). It is NOT the same as:
23+
*
24+
* - "native accessibility" / "widget-ness" — see `interactive-roles.js`
25+
* (aria-query widget taxonomy; an ARIA-tree-semantics question)
26+
* - "native interactive content" / "focus behavior" — see
27+
* `html-interactive-content.js` (HTML §3.2.5.2.7; an HTML-content-model
28+
* question about which tags can be nested inside what)
29+
* - "natively focusable" / sequential-focus — see HTML §6.6.3
30+
*
31+
* This util answers only: "is this tag a first-class built-in element of one
32+
* of the three markup-language standards, rather than a component invocation
33+
* or a shadowed local binding?" Callers compose it with the other utils
34+
* above when they need a more specific question (see e.g. `template-no-
35+
* noninteractive-tabindex`, which consults both this and
36+
* `html-interactive-content`).
37+
*
38+
* Returns false for:
39+
* - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced —
40+
* none of these tag names appear in the HTML/SVG/MathML lists)
41+
* - custom elements (`<my-widget>`) — accepted false negative; the web-
42+
* components namespace is open and can't be enumerated
43+
* - scope-bound identifiers (`<div>` when `div` is a local `let` / `const` /
44+
* import / block-param in the enclosing scope)
45+
*
46+
* @param {object} node - GlimmerElementNode
47+
* @param {object} [sourceCode] - ESLint SourceCode, for scope lookup. When
48+
* omitted, the scope check is skipped (the result is then list-based only —
49+
* suitable for unit tests).
50+
*/
51+
function isNativeElement(node, sourceCode) {
52+
if (!node || typeof node.tag !== 'string') {
53+
return false;
54+
}
55+
if (!ELEMENT_TAGS.has(node.tag)) {
56+
return false;
57+
}
58+
if (!sourceCode || !node.parent) {
59+
return true;
60+
}
61+
const scope = sourceCode.getScope(node.parent);
62+
const firstPart = node.parts && node.parts[0];
63+
// Compare by identifier name rather than AST node object identity — object
64+
// identity isn't guaranteed across parser versions (ember-eslint-parser can
65+
// produce distinct node objects for the same token depending on how the
66+
// scope manager walks the tree), but the resolved `.name` is stable.
67+
if (firstPart && scope.references.some((ref) => ref.identifier?.name === firstPart?.name)) {
68+
return false;
69+
}
70+
return true;
71+
}
72+
73+
/**
74+
* Inverse of {@link isNativeElement}. Returns true when the node should NOT
75+
* be treated as a native HTML element — either because it's a component
76+
* invocation (PascalCase, dotted, @-prefixed, this.-prefixed, custom element)
77+
* OR a tag name that's shadowed by a scope binding.
78+
*/
79+
function isComponentInvocation(node, sourceCode) {
80+
return !isNativeElement(node, sourceCode);
81+
}
82+
83+
module.exports = { isNativeElement, isComponentInvocation, ELEMENT_TAGS };

tests/lib/rules/template-no-noninteractive-element-to-interactive-role.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ ruleTester.run('template-no-noninteractive-element-to-interactive-role', rule, {
3232
// Components — rule skips.
3333
'<template><CustomHeading role="button" /></template>',
3434

35+
// PascalCase component whose lowercased tag name collides with a
36+
// NON_INTERACTIVE_TAGS entry. Naive tag?.toLowerCase() would have
37+
// misclassified <Article> as native <article>; isNativeElement filters
38+
// it out as a component invocation.
39+
'<template><Article role="button" /></template>',
40+
'<template><Section role="tab" /></template>',
41+
3542
// Unknown role — rule skips.
3643
'<template><h1 role="fakerole">Title</h1></template>',
3744

0 commit comments

Comments
 (0)