Skip to content

Commit e110ed9

Browse files
committed
refactor: align with #37's HTML-content-model authority split
Replace lib/utils/native-interactive-elements.js with lib/utils/html-interactive-content.js to match the canonical util introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7 Interactive Content as its sole authority, resolving the previous mixed-authority approach that cited axobject-query's widget taxonomy for some rows and HTML spec for others. Byte-identical copy of #37's util + test across worktrees so the two PRs can land in either order without conflict.
1 parent 6a0a1c0 commit e110ed9

5 files changed

Lines changed: 288 additions & 258 deletions

lib/rules/template-no-role-presentation-on-focusable.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
'use strict';
1717

1818
const { isComponentInvocation } = require('../utils/is-component-invocation');
19-
const { isNativeInteractive } = require('../utils/native-interactive-elements');
19+
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
2020

2121
function findAttr(node, name) {
2222
return node.attributes?.find((a) => a.name === name);
@@ -49,7 +49,13 @@ function isFocusable(node) {
4949
if (findAttr(node, 'tabindex')) {
5050
return true;
5151
}
52-
return isNativeInteractive(node, getTextAttrValue);
52+
// <area href> is focusable (part of an image map's sequential focus order
53+
// per HTML §6.6.3) but is not HTML §3.2.5.2.7 interactive content, so the
54+
// shared util doesn't classify it. Rule-level special case.
55+
if (typeof node.tag === 'string' && node.tag.toLowerCase() === 'area') {
56+
return Boolean(findAttr(node, 'href'));
57+
}
58+
return isHtmlInteractiveContent(node, getTextAttrValue);
5359
}
5460

5561
/** @type {import('eslint').Rule.RuleModule} */
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use strict';
2+
3+
/**
4+
* HTML "interactive content" classification, authoritative per
5+
* [HTML Living Standard §3.2.5.2.7 Interactive content]
6+
* (https://html.spec.whatwg.org/multipage/dom.html#interactive-content):
7+
*
8+
* a (if the href attribute is present), audio (if the controls attribute
9+
* is present), button, details, embed, iframe, img (if the usemap
10+
* attribute is present), input (if the type attribute is not in the
11+
* Hidden state), label, select, textarea, video (if the controls
12+
* attribute is present).
13+
*
14+
* Plus <summary>, which is not in §3.2.5.2.7 but is keyboard-activatable per
15+
* [§4.11.2 The summary element](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-summary-element).
16+
*
17+
* This is the HTML-content-model authority — it answers "does the HTML spec
18+
* prohibit nesting this inside an interactive parent?" It does NOT answer
19+
* "is this an ARIA widget for AT semantics?" (see `interactive-roles.js`
20+
* for that). The two questions diverge on rows like <label> (HTML: yes;
21+
* ARIA: structure role), <canvas> (HTML: no; ARIA: widget per axobject),
22+
* and <option>/<datalist> (HTML: no; ARIA: widgets). Rules that need
23+
* "interactive for any reason" should compose both authorities.
24+
*/
25+
26+
const UNCONDITIONAL_INTERACTIVE_TAGS = new Set([
27+
'button',
28+
'details',
29+
'embed',
30+
'iframe',
31+
'label',
32+
'select',
33+
'summary',
34+
'textarea',
35+
]);
36+
37+
/**
38+
* Determine whether a Glimmer element node is HTML-interactive content per
39+
* §3.2.5.2.7 (+ summary).
40+
*
41+
* @param {object} node Glimmer ElementNode (has a string `tag`).
42+
* @param {Function} getTextAttrValue Helper (node, attrName) -> string | undefined
43+
* returning the static text value of an
44+
* attribute, or undefined for dynamic / missing.
45+
* @param {object} [options]
46+
* @param {boolean} [options.ignoreUsemap=false] Treat `<img usemap>` as NOT interactive.
47+
* Consumed by rules with an `ignoreUsemap`
48+
* config option that lets authors opt out
49+
* of image-map-based interactivity.
50+
* @returns {boolean}
51+
*/
52+
function isHtmlInteractiveContent(node, getTextAttrValue, options = {}) {
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+
if (UNCONDITIONAL_INTERACTIVE_TAGS.has(tag)) {
60+
return true;
61+
}
62+
63+
// input — interactive unless type="hidden"
64+
if (tag === 'input') {
65+
const type = getTextAttrValue(node, 'type');
66+
return type !== 'hidden';
67+
}
68+
69+
// a — interactive only when href is present
70+
if (tag === 'a') {
71+
return hasAttribute(node, 'href');
72+
}
73+
74+
// img — interactive only when usemap is present (image map)
75+
if (tag === 'img') {
76+
if (options.ignoreUsemap) {
77+
return false;
78+
}
79+
return hasAttribute(node, 'usemap');
80+
}
81+
82+
// audio / video — interactive only when controls is present
83+
if (tag === 'audio' || tag === 'video') {
84+
return hasAttribute(node, 'controls');
85+
}
86+
87+
return false;
88+
}
89+
90+
function hasAttribute(node, name) {
91+
return Boolean(node.attributes && node.attributes.some((a) => a.name === name));
92+
}
93+
94+
module.exports = { isHtmlInteractiveContent };

lib/utils/native-interactive-elements.js

Lines changed: 0 additions & 89 deletions
This file was deleted.
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
'use strict';
2+
3+
const { isHtmlInteractiveContent } = require('../../../lib/utils/html-interactive-content');
4+
5+
function makeNode(tag, attrs = {}) {
6+
return {
7+
tag,
8+
attributes: Object.entries(attrs).map(([name, value]) => {
9+
if (value === true) {
10+
return { name, value: { type: 'GlimmerTextNode', chars: '' } };
11+
}
12+
return { name, value: { type: 'GlimmerTextNode', chars: String(value) } };
13+
}),
14+
};
15+
}
16+
17+
function getTextAttrValue(node, name) {
18+
const attr = node.attributes && node.attributes.find((a) => a.name === name);
19+
if (attr && attr.value && attr.value.type === 'GlimmerTextNode') {
20+
return attr.value.chars;
21+
}
22+
return undefined;
23+
}
24+
25+
describe('isHtmlInteractiveContent', () => {
26+
describe('§3.2.5.2.7 unconditional interactive content', () => {
27+
for (const tag of ['button', 'details', 'embed', 'iframe', 'label', 'select', 'textarea']) {
28+
it(`<${tag}> is interactive`, () => {
29+
expect(isHtmlInteractiveContent(makeNode(tag), getTextAttrValue)).toBe(true);
30+
});
31+
}
32+
});
33+
34+
describe('<summary> (§4.11.2 — keyboard-activatable)', () => {
35+
it('<summary> is interactive', () => {
36+
expect(isHtmlInteractiveContent(makeNode('summary'), getTextAttrValue)).toBe(true);
37+
});
38+
});
39+
40+
describe('<input>', () => {
41+
it('is interactive without type attribute (defaults to text)', () => {
42+
expect(isHtmlInteractiveContent(makeNode('input'), getTextAttrValue)).toBe(true);
43+
});
44+
45+
it('is interactive when type="text"', () => {
46+
expect(isHtmlInteractiveContent(makeNode('input', { type: 'text' }), getTextAttrValue)).toBe(
47+
true
48+
);
49+
});
50+
51+
it('is NOT interactive when type="hidden"', () => {
52+
expect(
53+
isHtmlInteractiveContent(makeNode('input', { type: 'hidden' }), getTextAttrValue)
54+
).toBe(false);
55+
});
56+
});
57+
58+
describe('<a>', () => {
59+
it('is interactive when href is present', () => {
60+
expect(isHtmlInteractiveContent(makeNode('a', { href: '/about' }), getTextAttrValue)).toBe(
61+
true
62+
);
63+
});
64+
65+
it('is NOT interactive without href', () => {
66+
expect(isHtmlInteractiveContent(makeNode('a'), getTextAttrValue)).toBe(false);
67+
});
68+
});
69+
70+
describe('<img>', () => {
71+
it('is interactive when usemap is present', () => {
72+
expect(isHtmlInteractiveContent(makeNode('img', { usemap: '#m' }), getTextAttrValue)).toBe(
73+
true
74+
);
75+
});
76+
77+
it('is NOT interactive without usemap', () => {
78+
expect(isHtmlInteractiveContent(makeNode('img'), getTextAttrValue)).toBe(false);
79+
});
80+
});
81+
82+
describe('<audio> / <video>', () => {
83+
it('<audio controls> is interactive', () => {
84+
expect(
85+
isHtmlInteractiveContent(makeNode('audio', { controls: true }), getTextAttrValue)
86+
).toBe(true);
87+
});
88+
89+
it('<video controls> is interactive', () => {
90+
expect(
91+
isHtmlInteractiveContent(makeNode('video', { controls: true }), getTextAttrValue)
92+
).toBe(true);
93+
});
94+
95+
it('<audio> without controls is NOT interactive', () => {
96+
expect(isHtmlInteractiveContent(makeNode('audio'), getTextAttrValue)).toBe(false);
97+
});
98+
99+
it('<video> without controls is NOT interactive', () => {
100+
expect(isHtmlInteractiveContent(makeNode('video'), getTextAttrValue)).toBe(false);
101+
});
102+
});
103+
104+
describe('elements NOT in §3.2.5.2.7', () => {
105+
// <object> is notably absent from §3.2.5.2.7 — rules that want to flag
106+
// <object usemap> nesting (e.g. for upstream ember-template-lint parity)
107+
// must do so as a rule-level special case, not via this util.
108+
it('<object> is NOT interactive (not in §3.2.5.2.7, even with usemap)', () => {
109+
expect(isHtmlInteractiveContent(makeNode('object'), getTextAttrValue)).toBe(false);
110+
expect(isHtmlInteractiveContent(makeNode('object', { usemap: '#m' }), getTextAttrValue)).toBe(
111+
false
112+
);
113+
});
114+
115+
// <area> is not in §3.2.5.2.7 either — rules caring about area[href]
116+
// should check via the ARIA widget-role authority (area[href] has
117+
// implicit role=link per HTML-AAM).
118+
it('<area> is NOT interactive (not in §3.2.5.2.7)', () => {
119+
expect(isHtmlInteractiveContent(makeNode('area', { href: '/map' }), getTextAttrValue)).toBe(
120+
false
121+
);
122+
});
123+
124+
// <canvas>, <option>, <datalist> — ARIA widgets per axobject-query, but
125+
// not HTML interactive content. Rules wanting these should consult the
126+
// ARIA widget-role authority, not this util.
127+
for (const tag of ['canvas', 'option', 'datalist']) {
128+
it(`<${tag}> is NOT interactive per HTML §3.2.5.2.7`, () => {
129+
expect(isHtmlInteractiveContent(makeNode(tag), getTextAttrValue)).toBe(false);
130+
});
131+
}
132+
133+
// Deprecated HTML elements.
134+
for (const tag of ['menuitem', 'keygen']) {
135+
it(`<${tag}> is NOT interactive (deprecated)`, () => {
136+
expect(isHtmlInteractiveContent(makeNode(tag), getTextAttrValue)).toBe(false);
137+
});
138+
}
139+
});
140+
141+
describe('non-interactive tags', () => {
142+
for (const tag of ['div', 'span', 'p', 'section', 'article', 'header', 'footer', 'img']) {
143+
it(`<${tag}> is not interactive (no relevant attribute)`, () => {
144+
expect(isHtmlInteractiveContent(makeNode(tag), getTextAttrValue)).toBe(false);
145+
});
146+
}
147+
});
148+
149+
describe('tag normalization', () => {
150+
it('lowercases tag names before classification', () => {
151+
expect(isHtmlInteractiveContent(makeNode('BUTTON'), getTextAttrValue)).toBe(true);
152+
expect(
153+
isHtmlInteractiveContent(makeNode('Input', { type: 'hidden' }), getTextAttrValue)
154+
).toBe(false);
155+
expect(isHtmlInteractiveContent(makeNode('A', { href: '/x' }), getTextAttrValue)).toBe(true);
156+
});
157+
});
158+
159+
describe('edge cases', () => {
160+
it('returns false for missing tag', () => {
161+
expect(isHtmlInteractiveContent({}, getTextAttrValue)).toBe(false);
162+
});
163+
164+
it('returns false for non-string tag', () => {
165+
expect(isHtmlInteractiveContent({ tag: null }, getTextAttrValue)).toBe(false);
166+
expect(isHtmlInteractiveContent({ tag: 123 }, getTextAttrValue)).toBe(false);
167+
});
168+
169+
it('returns false for empty-string tag', () => {
170+
expect(isHtmlInteractiveContent({ tag: '' }, getTextAttrValue)).toBe(false);
171+
});
172+
173+
it('handles nodes without attributes array (a without href)', () => {
174+
expect(isHtmlInteractiveContent({ tag: 'a' }, getTextAttrValue)).toBe(false);
175+
});
176+
177+
it('handles nodes without attributes array (audio/video without controls)', () => {
178+
expect(isHtmlInteractiveContent({ tag: 'audio' }, getTextAttrValue)).toBe(false);
179+
expect(isHtmlInteractiveContent({ tag: 'video' }, getTextAttrValue)).toBe(false);
180+
});
181+
182+
it('handles nodes without attributes array (img without usemap)', () => {
183+
expect(isHtmlInteractiveContent({ tag: 'img' }, getTextAttrValue)).toBe(false);
184+
});
185+
});
186+
});

0 commit comments

Comments
 (0)