Skip to content

Commit 30a5f0b

Browse files
committed
refactor: adopt isComponentInvocation + isNativeInteractive utils
Adopts the shared utils from PRs #31 and #37. Bit-identical copies so both PRs merge cleanly regardless of order. Rule behavior unchanged.
1 parent 18846a3 commit 30a5f0b

5 files changed

Lines changed: 366 additions & 49 deletions

File tree

lib/rules/template-click-events-have-key-events.js

Lines changed: 18 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,26 @@
11
const { dom } = require('aria-query');
2-
3-
// Elements whose default HTML semantics make them interactive — a click handler
4-
// here doesn't need a keyboard fallback because keyboard focus/activation is
5-
// already built in. Derived from aria-query's `elementRoles` (tags whose
6-
// inherent ARIA role descends from `widget`); matches jsx-a11y's
7-
// `isInteractiveElement` treatment.
8-
const INHERENTLY_INTERACTIVE_TAGS = new Set([
9-
'button',
10-
'datalist',
11-
'details',
12-
'embed',
13-
'iframe',
14-
'input',
15-
'label',
16-
'option',
17-
'select',
18-
'summary',
19-
'textarea',
20-
]);
21-
22-
// Roles whose keyboard semantics are widget-like. When a non-interactive element
23-
// declares one of these, the user is opting in to a widget contract and the
24-
// click handler does need a keyboard equivalent — so we still check.
25-
// (Matches the jsx-a11y `isInteractiveRole` set.)
2+
const { isComponentInvocation } = require('../utils/is-component-invocation');
3+
const { isNativeInteractive } = require('../utils/native-interactive-elements');
264

275
const KEYBOARD_EVENT_NAMES = new Set(['keydown', 'keyup', 'keypress']);
286

297
function findAttr(node, name) {
308
return node.attributes?.find((a) => a.name === name);
319
}
3210

33-
function getTextAttrValue(attr) {
11+
function getAttrTextValue(attr) {
3412
if (attr?.value?.type === 'GlimmerTextNode') {
3513
return attr.value.chars;
3614
}
3715
return undefined;
3816
}
3917

18+
// Adapter matching the `isNativeInteractive` util's expected signature:
19+
// `(node, attrName) -> string | undefined` for static attribute text values.
20+
function getTextAttrValue(node, attrName) {
21+
return getAttrTextValue(findAttr(node, attrName));
22+
}
23+
4024
// True iff the attribute's mustache value is the literal boolean `true` —
4125
// e.g. `aria-hidden={{true}}`. Any other expression (path reference, helper
4226
// call, etc.) is left to runtime and not treated as a static escape hatch.
@@ -73,7 +57,7 @@ function isHiddenFromScreenReader(node) {
7357
}
7458

7559
function hasPresentationRole(node) {
76-
const role = getTextAttrValue(findAttr(node, 'role'));
60+
const role = getAttrTextValue(findAttr(node, 'role'));
7761
if (!role) {
7862
return false;
7963
}
@@ -84,27 +68,6 @@ function hasPresentationRole(node) {
8468
.some((token) => token === 'presentation' || token === 'none');
8569
}
8670

87-
function isInteractiveElement(node) {
88-
const tag = node.tag?.toLowerCase();
89-
if (!tag) {
90-
return false;
91-
}
92-
if (INHERENTLY_INTERACTIVE_TAGS.has(tag)) {
93-
// <input type="hidden"> is not interactive.
94-
if (tag === 'input') {
95-
const type = getTextAttrValue(findAttr(node, 'type'));
96-
if (type === 'hidden') {
97-
return false;
98-
}
99-
}
100-
return true;
101-
}
102-
if (tag === 'a' && findAttr(node, 'href')) {
103-
return true;
104-
}
105-
return false;
106-
}
107-
10871
function getOnModifierEventName(modifier) {
10972
if (modifier.type !== 'GlimmerElementModifierStatement') {
11073
return undefined;
@@ -156,7 +119,13 @@ module.exports = {
156119
return;
157120
}
158121

159-
// Skip components (not DOM elements).
122+
// Skip component invocations (PascalCase, named-arg, this-path, dot-path, named-block).
123+
if (isComponentInvocation(node)) {
124+
return;
125+
}
126+
127+
// Skip tags aria-query doesn't recognize as DOM elements (e.g. hyphenated
128+
// custom elements like `<my-widget>`).
160129
if (!dom.has(node.tag)) {
161130
return;
162131
}
@@ -170,7 +139,7 @@ module.exports = {
170139
return;
171140
}
172141

173-
if (isInteractiveElement(node)) {
142+
if (isNativeInteractive(node, getTextAttrValue)) {
174143
return;
175144
}
176145

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+
});

0 commit comments

Comments
 (0)