Skip to content

Commit 0605a25

Browse files
committed
refactor: adopt isComponentInvocation util; keep rule-specific interactive-tag derivation
Adopt the shared `isComponentInvocation` util for the component-skip in `template-no-interactive-element-to-noninteractive-role`. The rule-specific layered derivation (aria-query `elementRoles` primary, axobject-query fallback, with canvas and audio/video-without-controls carve-outs) is retained — it is narrower than `isNativeInteractive` because this rule targets inherent-interactive tags per HTML-AAM (demotion via non-interactive role) rather than conditionally-interactive tags. Also carry `native-interactive-elements.js` (+ tests) into this branch for Z-1 merge cleanliness, even though this rule does not adopt it.
1 parent 014a9f0 commit 0605a25

5 files changed

Lines changed: 363 additions & 0 deletions

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

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

56
// Interactive-element derivation. Mirrors jsx-a11y's layered approach:
67
// 1. Primary signal — aria-query's `elementRoles`: an element is inherently
@@ -13,6 +14,15 @@ const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
1314
// elements whose AXObject is a widget but aria-query lists no inherent
1415
// ARIA role (e.g. <summary>, <menuitem>, <embed>).
1516
//
17+
// Why we do NOT use the shared `isNativeInteractive` util here:
18+
// Keep the rule's layered aria-query + axobject-query derivation — this
19+
// rule's scope is narrower than `isNativeInteractive`. We care about tags
20+
// that have INHERENT interactive semantics per HTML-AAM (so that applying
21+
// a non-interactive role is a demotion). `isNativeInteractive` also treats
22+
// tags with conditional interactivity (a[href], audio[controls]) as
23+
// interactive in a way that wouldn't make sense here without the
24+
// conditional context.
25+
//
1626
// Deviations from jsx-a11y, driven by real-world false-positive patterns:
1727
// - `<canvas>` is NOT treated as inherently interactive. Its AXObject is
1828
// `CanvasRole` (widget), but per aria-query `<canvas>` has no inherent
@@ -205,6 +215,11 @@ module.exports = {
205215
create(context) {
206216
return {
207217
GlimmerElementNode(node) {
218+
// Skip component invocations — the rule targets native HTML elements.
219+
if (isComponentInvocation(node)) {
220+
return;
221+
}
222+
208223
if (!isInteractiveElement(node)) {
209224
return;
210225
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict';
2+
3+
/**
4+
* Returns true if the Glimmer element node is a component invocation
5+
* rather than a native HTML element. Excludes:
6+
* - PascalCase tags (<Button>, <MyWidget>)
7+
* - Named-arg invocations (<@heading>, <@tag.foo>)
8+
* - This-path invocations (<this.myComponent>, <this.comp.sub>)
9+
* - Dot-path invocations (<foo.bar>)
10+
* - Named-block syntax (<foo::bar>)
11+
*/
12+
module.exports.isComponentInvocation = function isComponentInvocation(node) {
13+
const tag = node?.tag;
14+
if (typeof tag !== 'string') {
15+
return false;
16+
}
17+
return (
18+
/^[A-Z]/.test(tag) ||
19+
tag.startsWith('@') ||
20+
tag.startsWith('this.') ||
21+
tag.includes('.') ||
22+
tag.includes('::')
23+
);
24+
};
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
'use strict';
2+
3+
/**
4+
* Native-interactive HTML element classification, shared across rules that need to
5+
* ask "does this HTML tag natively expose interactive UI to keyboard / AT users?".
6+
*
7+
* The set is hand-curated rather than derived from a single authority because
8+
* aria-query, axobject-query, HTML-AAM, WAI-ARIA, and browser reality disagree on
9+
* several rows. Decision rationale is documented per-tag:
10+
*
11+
* | Element | Behavior | Rationale |
12+
* |-------------------------------------------------|----------------------|-----------|
13+
* | button, select, textarea, iframe, embed, | Interactive | aria-query/axobject-query widget + universally-accepted |
14+
* | summary, details | | |
15+
* | input (except type=hidden) | Interactive | Same as above, minus hidden |
16+
* | option, datalist | Interactive | aria-query roles option/listbox; axobject widget; HTML-AAM |
17+
* | a[href], area[href] | Interactive (cond.) | HTML-AAM: anchor interactivity requires href |
18+
* | audio[controls], video[controls] | Interactive | Browsers only render focusable UI with `controls` |
19+
* | audio, video (no controls) | NOT interactive | No keyboard semantics without controls; browsers agree |
20+
* | object | Interactive | axobject-query EmbeddedObjectRole |
21+
* | canvas | Interactive | axobject-query CanvasRole widget; bias toward no-FP |
22+
* | input[type=hidden] | NOT interactive | HTML spec: no UI, no focus, no AT exposure |
23+
* | menuitem | NOT interactive | Deprecated; no longer rendered in Chrome/Edge/Safari/FF |
24+
* | label | NOT interactive | axobject-query LabelRole is structure, not widget |
25+
*/
26+
27+
// Unconditionally-interactive HTML tags (no attribute dependencies).
28+
const UNCONDITIONAL_INTERACTIVE_TAGS = new Set([
29+
'button',
30+
'select',
31+
'textarea',
32+
'iframe',
33+
'embed',
34+
'summary',
35+
'details',
36+
'option',
37+
'datalist',
38+
'object',
39+
'canvas',
40+
]);
41+
42+
/**
43+
* Determine whether a Glimmer element node represents a natively-interactive
44+
* HTML element.
45+
*
46+
* @param {object} node Glimmer `ElementNode` (has a string `tag`).
47+
* @param {Function} getTextAttrValue Helper (node, attrName) -> string | undefined
48+
* that returns the text value of a static
49+
* attribute, or undefined for dynamic / missing.
50+
* @returns {boolean} True if the element is natively interactive.
51+
*/
52+
function isNativeInteractive(node, getTextAttrValue) {
53+
const rawTag = node && node.tag;
54+
if (typeof rawTag !== 'string' || rawTag.length === 0) {
55+
return false;
56+
}
57+
const tag = rawTag.toLowerCase();
58+
59+
// Unconditional interactive tags.
60+
if (UNCONDITIONAL_INTERACTIVE_TAGS.has(tag)) {
61+
return true;
62+
}
63+
64+
// <input> is interactive unless type="hidden" (HTML spec: no UI/focus/AT exposure).
65+
if (tag === 'input') {
66+
const type = getTextAttrValue(node, 'type');
67+
return type !== 'hidden';
68+
}
69+
70+
// <a> and <area> are interactive only when an href is present (HTML-AAM).
71+
if (tag === 'a' || tag === 'area') {
72+
return hasAttribute(node, 'href');
73+
}
74+
75+
// <audio>/<video> are only interactive when `controls` is present.
76+
if (tag === 'audio' || tag === 'video') {
77+
return hasAttribute(node, 'controls');
78+
}
79+
80+
return false;
81+
}
82+
83+
function hasAttribute(node, name) {
84+
return Boolean(node.attributes && node.attributes.some((a) => a.name === name));
85+
}
86+
87+
module.exports = {
88+
isNativeInteractive,
89+
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use strict';
2+
3+
const { isComponentInvocation } = require('../../../lib/utils/is-component-invocation');
4+
5+
describe('isComponentInvocation', () => {
6+
it('returns true for PascalCase tags', () => {
7+
expect(isComponentInvocation({ tag: 'Button' })).toBe(true);
8+
expect(isComponentInvocation({ tag: 'MyWidget' })).toBe(true);
9+
// PascalCase tags that match a native HTML element name — the core bug case
10+
expect(isComponentInvocation({ tag: 'Article' })).toBe(true);
11+
expect(isComponentInvocation({ tag: 'Form' })).toBe(true);
12+
expect(isComponentInvocation({ tag: 'Main' })).toBe(true);
13+
expect(isComponentInvocation({ tag: 'Nav' })).toBe(true);
14+
expect(isComponentInvocation({ tag: 'Ul' })).toBe(true);
15+
expect(isComponentInvocation({ tag: 'Li' })).toBe(true);
16+
expect(isComponentInvocation({ tag: 'Aside' })).toBe(true);
17+
expect(isComponentInvocation({ tag: 'Section' })).toBe(true);
18+
expect(isComponentInvocation({ tag: 'Table' })).toBe(true);
19+
});
20+
21+
it('returns false for lowercase native HTML tags', () => {
22+
expect(isComponentInvocation({ tag: 'div' })).toBe(false);
23+
expect(isComponentInvocation({ tag: 'article' })).toBe(false);
24+
expect(isComponentInvocation({ tag: 'form' })).toBe(false);
25+
expect(isComponentInvocation({ tag: 'h1' })).toBe(false);
26+
expect(isComponentInvocation({ tag: 'button' })).toBe(false);
27+
});
28+
29+
it('returns true for named-arg invocations', () => {
30+
expect(isComponentInvocation({ tag: '@heading' })).toBe(true);
31+
expect(isComponentInvocation({ tag: '@tag.foo' })).toBe(true);
32+
});
33+
34+
it('returns true for this-path invocations', () => {
35+
expect(isComponentInvocation({ tag: 'this.myComponent' })).toBe(true);
36+
expect(isComponentInvocation({ tag: 'this.comp.sub' })).toBe(true);
37+
});
38+
39+
it('returns true for dot-path invocations', () => {
40+
expect(isComponentInvocation({ tag: 'foo.bar' })).toBe(true);
41+
expect(isComponentInvocation({ tag: 'ns.widget' })).toBe(true);
42+
});
43+
44+
it('returns true for named-block / namespaced invocations', () => {
45+
expect(isComponentInvocation({ tag: 'foo::bar' })).toBe(true);
46+
expect(isComponentInvocation({ tag: 'Foo::Bar' })).toBe(true);
47+
});
48+
49+
it('returns false for empty-string tag', () => {
50+
expect(isComponentInvocation({ tag: '' })).toBe(false);
51+
});
52+
53+
it('returns false for undefined node', () => {
54+
expect(isComponentInvocation()).toBe(false);
55+
expect(isComponentInvocation(undefined)).toBe(false);
56+
expect(isComponentInvocation(null)).toBe(false);
57+
});
58+
59+
it('returns false for node with undefined tag', () => {
60+
expect(isComponentInvocation({})).toBe(false);
61+
expect(isComponentInvocation({ tag: undefined })).toBe(false);
62+
});
63+
64+
it('returns false for node with non-string tag', () => {
65+
expect(isComponentInvocation({ tag: 123 })).toBe(false);
66+
expect(isComponentInvocation({ tag: null })).toBe(false);
67+
});
68+
});
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
'use strict';
2+
3+
const { isNativeInteractive } = require('../../../lib/utils/native-interactive-elements');
4+
5+
function makeNode(tag, attrs = {}) {
6+
return {
7+
tag,
8+
attributes: Object.entries(attrs).map(([name, value]) => {
9+
if (value === true) {
10+
// boolean-style attribute, no value
11+
return { name, value: { type: 'GlimmerTextNode', chars: '' } };
12+
}
13+
return { name, value: { type: 'GlimmerTextNode', chars: String(value) } };
14+
}),
15+
};
16+
}
17+
18+
function getTextAttrValue(node, name) {
19+
const attr = node.attributes && node.attributes.find((a) => a.name === name);
20+
if (attr && attr.value && attr.value.type === 'GlimmerTextNode') {
21+
return attr.value.chars;
22+
}
23+
return undefined;
24+
}
25+
26+
describe('isNativeInteractive', () => {
27+
describe('unconditionally interactive widgets', () => {
28+
for (const tag of ['button', 'select', 'textarea', 'iframe', 'embed', 'summary', 'details']) {
29+
it(`<${tag}> is interactive`, () => {
30+
expect(isNativeInteractive(makeNode(tag), getTextAttrValue)).toBe(true);
31+
});
32+
}
33+
});
34+
35+
describe('<input>', () => {
36+
it('is interactive without type attribute', () => {
37+
expect(isNativeInteractive(makeNode('input'), getTextAttrValue)).toBe(true);
38+
});
39+
40+
it('is interactive when type="text"', () => {
41+
expect(isNativeInteractive(makeNode('input', { type: 'text' }), getTextAttrValue)).toBe(true);
42+
});
43+
44+
it('is NOT interactive when type="hidden"', () => {
45+
expect(isNativeInteractive(makeNode('input', { type: 'hidden' }), getTextAttrValue)).toBe(
46+
false
47+
);
48+
});
49+
});
50+
51+
describe('<option> and <datalist>', () => {
52+
it('<option> is interactive (aria option role)', () => {
53+
expect(isNativeInteractive(makeNode('option'), getTextAttrValue)).toBe(true);
54+
});
55+
56+
it('<datalist> is interactive (aria listbox role)', () => {
57+
expect(isNativeInteractive(makeNode('datalist'), getTextAttrValue)).toBe(true);
58+
});
59+
});
60+
61+
describe('<a>', () => {
62+
it('is interactive when href is present', () => {
63+
expect(isNativeInteractive(makeNode('a', { href: '/about' }), getTextAttrValue)).toBe(true);
64+
});
65+
66+
it('is NOT interactive without href', () => {
67+
expect(isNativeInteractive(makeNode('a'), getTextAttrValue)).toBe(false);
68+
});
69+
});
70+
71+
describe('<area>', () => {
72+
it('is interactive when href is present', () => {
73+
expect(isNativeInteractive(makeNode('area', { href: '/map' }), getTextAttrValue)).toBe(true);
74+
});
75+
76+
it('is NOT interactive without href', () => {
77+
expect(isNativeInteractive(makeNode('area'), getTextAttrValue)).toBe(false);
78+
});
79+
});
80+
81+
describe('<audio> / <video>', () => {
82+
it('<audio controls> is interactive', () => {
83+
expect(isNativeInteractive(makeNode('audio', { controls: true }), getTextAttrValue)).toBe(
84+
true
85+
);
86+
});
87+
88+
it('<video controls> is interactive', () => {
89+
expect(isNativeInteractive(makeNode('video', { controls: true }), getTextAttrValue)).toBe(
90+
true
91+
);
92+
});
93+
94+
it('<audio> without controls is NOT interactive', () => {
95+
expect(isNativeInteractive(makeNode('audio'), getTextAttrValue)).toBe(false);
96+
});
97+
98+
it('<video> without controls is NOT interactive', () => {
99+
expect(isNativeInteractive(makeNode('video'), getTextAttrValue)).toBe(false);
100+
});
101+
});
102+
103+
describe('<object>', () => {
104+
it('is interactive (axobject EmbeddedObjectRole)', () => {
105+
expect(isNativeInteractive(makeNode('object'), getTextAttrValue)).toBe(true);
106+
});
107+
});
108+
109+
describe('<canvas>', () => {
110+
it('is interactive (axobject CanvasRole widget; no-FP bias)', () => {
111+
expect(isNativeInteractive(makeNode('canvas'), getTextAttrValue)).toBe(true);
112+
});
113+
});
114+
115+
describe('excluded elements (documented NOT-interactive)', () => {
116+
it('<label> is NOT interactive (structure role, not widget)', () => {
117+
expect(isNativeInteractive(makeNode('label'), getTextAttrValue)).toBe(false);
118+
});
119+
120+
it('<menuitem> is NOT interactive (deprecated, dropped across browsers)', () => {
121+
expect(isNativeInteractive(makeNode('menuitem'), getTextAttrValue)).toBe(false);
122+
});
123+
});
124+
125+
describe('non-interactive tags', () => {
126+
for (const tag of ['div', 'span', 'p', 'section', 'article', 'header', 'footer', 'img']) {
127+
it(`<${tag}> is not interactive`, () => {
128+
expect(isNativeInteractive(makeNode(tag), getTextAttrValue)).toBe(false);
129+
});
130+
}
131+
});
132+
133+
describe('tag normalization', () => {
134+
it('lowercases tag names before classification', () => {
135+
expect(isNativeInteractive(makeNode('BUTTON'), getTextAttrValue)).toBe(true);
136+
expect(isNativeInteractive(makeNode('Input', { type: 'hidden' }), getTextAttrValue)).toBe(
137+
false
138+
);
139+
expect(isNativeInteractive(makeNode('A', { href: '/x' }), getTextAttrValue)).toBe(true);
140+
});
141+
});
142+
143+
describe('edge cases', () => {
144+
it('returns false for missing tag', () => {
145+
expect(isNativeInteractive({}, getTextAttrValue)).toBe(false);
146+
});
147+
148+
it('returns false for non-string tag', () => {
149+
expect(isNativeInteractive({ tag: null }, getTextAttrValue)).toBe(false);
150+
expect(isNativeInteractive({ tag: 123 }, getTextAttrValue)).toBe(false);
151+
});
152+
153+
it('returns false for empty-string tag', () => {
154+
expect(isNativeInteractive({ tag: '' }, getTextAttrValue)).toBe(false);
155+
});
156+
157+
it('handles nodes without attributes array (a/area without href)', () => {
158+
expect(isNativeInteractive({ tag: 'a' }, getTextAttrValue)).toBe(false);
159+
expect(isNativeInteractive({ tag: 'area' }, getTextAttrValue)).toBe(false);
160+
});
161+
162+
it('handles nodes without attributes array (audio/video without controls)', () => {
163+
expect(isNativeInteractive({ tag: 'audio' }, getTextAttrValue)).toBe(false);
164+
expect(isNativeInteractive({ tag: 'video' }, getTextAttrValue)).toBe(false);
165+
});
166+
});
167+
});

0 commit comments

Comments
 (0)