Skip to content

Commit 88ba3ca

Browse files
committed
refactor+fix: migrate utils, narrow focusability check, normalize aria-hidden (Copilot review)
1 parent e834954 commit 88ba3ca

8 files changed

Lines changed: 294 additions & 123 deletions

lib/rules/template-no-aria-hidden-on-focusable.js

Lines changed: 94 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use strict';
22

3-
const { isComponentInvocation } = require('../utils/is-component-invocation');
4-
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
3+
const { isNativeElement } = require('../utils/is-native-element');
54

65
function findAttr(node, name) {
76
return node.attributes?.find((a) => a.name === name);
@@ -26,51 +25,112 @@ function isAriaHiddenTrue(node) {
2625
return false;
2726
}
2827
if (value.type === 'GlimmerTextNode') {
29-
return value.chars.toLowerCase() === 'true';
28+
return value.chars.trim().toLowerCase() === 'true';
3029
}
3130
if (value.type === 'GlimmerMustacheStatement' && value.path) {
3231
if (value.path.type === 'GlimmerBooleanLiteral') {
3332
return value.path.value === true;
3433
}
3534
if (value.path.type === 'GlimmerStringLiteral') {
36-
return value.path.value.toLowerCase() === 'true';
35+
return value.path.value.trim().toLowerCase() === 'true';
3736
}
3837
}
3938
return false;
4039
}
4140

42-
function isFocusable(node) {
43-
const tag = node.tag?.toLowerCase();
44-
if (!tag) {
41+
// Tags with an unconditional default focusable UI (sequentially focusable per
42+
// HTML §6.6.3 "focusable area" + widget roles per HTML-AAM).
43+
// NOTE: <label> is HTML-interactive-content (§3.2.5.2.7) but NOT keyboard-
44+
// focusable by default — clicks on a label forward to its associated control,
45+
// but the label itself isn't in the tab order. So it's excluded here even
46+
// though `isHtmlInteractiveContent` would return true for it.
47+
const UNCONDITIONAL_FOCUSABLE_TAGS = new Set([
48+
'button',
49+
'select',
50+
'textarea',
51+
'iframe',
52+
'embed',
53+
'summary',
54+
'details',
55+
'option',
56+
'datalist',
57+
]);
58+
59+
// Form-control tags whose `disabled` attribute removes them from the tab order
60+
// (HTML §4.10.18.5 "disabled" + HTML §6.6.3 "focusable area").
61+
const DISABLEABLE_TAGS = new Set(['button', 'input', 'select', 'textarea', 'fieldset']);
62+
63+
function isDisabledFormControl(node, tag) {
64+
if (!DISABLEABLE_TAGS.has(tag)) {
65+
return false;
66+
}
67+
return Boolean(findAttr(node, 'disabled'));
68+
}
69+
70+
// Narrow rule-local "keyboard-focusable" check. Intentionally distinct from
71+
// `isHtmlInteractiveContent` (HTML content-model) — we want the sequential-
72+
// focus + programmatic-focus axis only. See WAI-ARIA "focusable" definition
73+
// and HTML §6.6.3.
74+
function isKeyboardFocusable(node, getTextAttrValueFn) {
75+
const rawTag = node?.tag;
76+
if (typeof rawTag !== 'string' || rawTag.length === 0) {
77+
return false;
78+
}
79+
const tag = rawTag.toLowerCase();
80+
81+
// Disabled form controls are not focusable.
82+
if (isDisabledFormControl(node, tag)) {
4583
return false;
4684
}
4785

48-
// Opt-out via tabindex="-1" makes the element programmatically focusable
49-
// (still reachable via .focus()) but removes it from the tab order.
50-
// `aria-hidden` on such an element is still problematic — if it can receive
51-
// focus, assistive tech should be able to see it. Match jsx-a11y: flag any
52-
// tabindex that's not "undefined" (i.e. any tabindex attribute at all).
53-
const tabindex = findAttr(node, 'tabindex');
54-
if (tabindex) {
86+
// Any tabindex (including "-1") makes the element at least programmatically
87+
// focusable — still a keyboard-trap risk under aria-hidden.
88+
if (findAttr(node, 'tabindex')) {
5589
return true;
5690
}
5791

58-
// Delegate interactive-content classification to the shared util (HTML
59-
// §3.2.5.2.7 + summary): button/details/embed/iframe/label/select/summary/
60-
// textarea, input (non-hidden), a[href], img[usemap], and
61-
// audio[controls]/video[controls].
62-
return isHtmlInteractiveContent(node, getTextAttrValue);
92+
// contenteditable (truthy) makes the element focusable.
93+
const contentEditable = getTextAttrValueFn(node, 'contenteditable');
94+
if (contentEditable !== undefined && contentEditable !== null) {
95+
const normalized = contentEditable.trim().toLowerCase();
96+
// per HTML spec, "", "true", and "plaintext-only" all enable editing.
97+
if (normalized === '' || normalized === 'true' || normalized === 'plaintext-only') {
98+
return true;
99+
}
100+
}
101+
102+
if (UNCONDITIONAL_FOCUSABLE_TAGS.has(tag)) {
103+
return true;
104+
}
105+
106+
if (tag === 'input') {
107+
const type = getTextAttrValueFn(node, 'type');
108+
return type === undefined || type === null || type.trim().toLowerCase() !== 'hidden';
109+
}
110+
111+
if (tag === 'a' || tag === 'area') {
112+
return Boolean(findAttr(node, 'href'));
113+
}
114+
115+
if (tag === 'img') {
116+
return Boolean(findAttr(node, 'usemap'));
117+
}
118+
119+
if (tag === 'audio' || tag === 'video') {
120+
return Boolean(findAttr(node, 'controls'));
121+
}
122+
123+
return false;
63124
}
64125

65126
// A focusable descendant of an aria-hidden="true" ancestor can still receive
66127
// focus (aria-hidden does not remove elements from the tab order), so the
67128
// ancestor hides AT-visible content that remains keyboard-reachable — a
68129
// keyboard trap. This rule targets the anti-pattern flagged by axe's
69-
// `aria-hidden-focus` check and by jsx-a11y's `no-aria-hidden-on-focusable`;
70-
// the WAI-ARIA 1.2 spec itself only says authors MAY "with caution" use
71-
// aria-hidden, so the rule rests on community a11y guidance, not a
72-
// normative WAI-ARIA MUST-NOT.
73-
function hasFocusableDescendant(node) {
130+
// `aria-hidden-focus` check and by jsx-a11y's `no-aria-hidden-on-focusable`.
131+
// WAI-ARIA 1.2 says authors SHOULD NOT put aria-hidden on focusable content
132+
// (the spec normatively warns against this in the aria-hidden authoring note).
133+
function hasFocusableDescendant(node, sourceCode) {
74134
const children = node.children;
75135
if (!children || children.length === 0) {
76136
return false;
@@ -81,14 +141,14 @@ function hasFocusableDescendant(node) {
81141
// expressions, and anything else whose rendered element we can't inspect.
82142
continue;
83143
}
84-
if (isComponentInvocation(child)) {
85-
// Component / dynamic tag — opaque. Don't recurse.
144+
if (!isNativeElement(child, sourceCode)) {
145+
// Component / dynamic / shadowed tag — opaque. Don't recurse.
86146
continue;
87147
}
88-
if (isFocusable(child)) {
148+
if (isKeyboardFocusable(child, getTextAttrValue)) {
89149
return true;
90150
}
91-
if (hasFocusableDescendant(child)) {
151+
if (hasFocusableDescendant(child, sourceCode)) {
92152
return true;
93153
}
94154
}
@@ -116,16 +176,20 @@ module.exports = {
116176
},
117177

118178
create(context) {
179+
const sourceCode = context.sourceCode ?? context.getSourceCode();
119180
return {
120181
GlimmerElementNode(node) {
121182
if (!isAriaHiddenTrue(node)) {
122183
return;
123184
}
124-
if (isFocusable(node)) {
185+
if (!isNativeElement(node, sourceCode)) {
186+
return;
187+
}
188+
if (isKeyboardFocusable(node, getTextAttrValue)) {
125189
context.report({ node, messageId: 'noAriaHiddenOnFocusable' });
126190
return;
127191
}
128-
if (hasFocusableDescendant(node)) {
192+
if (hasFocusableDescendant(node, sourceCode)) {
129193
context.report({ node, messageId: 'noAriaHiddenOnAncestorOfFocusable' });
130194
}
131195
},

lib/utils/html-interactive-content.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function isHtmlInteractiveContent(node, getTextAttrValue, options = {}) {
6363
// input — interactive unless type="hidden"
6464
if (tag === 'input') {
6565
const type = getTextAttrValue(node, 'type');
66-
return type !== 'hidden';
66+
return type === undefined || type === null || type.trim().toLowerCase() !== 'hidden';
6767
}
6868

6969
// a — interactive only when href is present

lib/utils/is-component-invocation.js

Lines changed: 0 additions & 24 deletions
This file was deleted.

lib/utils/is-native-element.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
if (firstPart && scope.references.some((ref) => ref.identifier === firstPart)) {
64+
return false;
65+
}
66+
return true;
67+
}
68+
69+
/**
70+
* Inverse of {@link isNativeElement}. Returns true when the node should NOT
71+
* be treated as a native HTML element — either because it's a component
72+
* invocation (PascalCase, dotted, @-prefixed, this.-prefixed, custom element)
73+
* OR a tag name that's shadowed by a scope binding.
74+
*/
75+
function isComponentInvocation(node, sourceCode) {
76+
return !isNativeElement(node, sourceCode);
77+
}
78+
79+
module.exports = { isNativeElement, isComponentInvocation, ELEMENT_TAGS };

tests/lib/rules/template-no-aria-hidden-on-focusable.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ ruleTester.run('template-no-aria-hidden-on-focusable', rule, {
3535
// <a> without href isn't focusable by default.
3636
'<template><a aria-hidden="true">Not a link</a></template>',
3737

38+
// <label> is HTML interactive content but NOT keyboard-focusable by default
39+
// (clicks forward to the associated control; the label itself isn't in the
40+
// tab order). So aria-hidden on it is fine.
41+
'<template><label aria-hidden="true">Name</label></template>',
42+
43+
// Disabled form controls are removed from the tab order (HTML §4.10.18.5),
44+
// so they're not keyboard-focusable and aria-hidden on them isn't a trap.
45+
'<template><button disabled aria-hidden="true">Click me</button></template>',
46+
'<template><input disabled aria-hidden="true" /></template>',
47+
3848
// Components — we don't know if they render a focusable element.
3949
'<template><CustomBtn aria-hidden="true" /></template>',
4050

@@ -112,6 +122,13 @@ ruleTester.run('template-no-aria-hidden-on-focusable', rule, {
112122
output: null,
113123
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
114124
},
125+
{
126+
// Whitespace-padded "true" is still a truthy aria-hidden per enumerated-
127+
// attribute normalization (trim + case-insensitive).
128+
code: '<template><button aria-hidden=" true "></button></template>',
129+
output: null,
130+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
131+
},
115132

116133
// Descendant-focusable check. Per WAI-ARIA 1.2 §aria-hidden
117134
// "may receive focus" — focusable descendants are keyboard-reachable

tests/lib/utils/html-interactive-content-test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ describe('isHtmlInteractiveContent', () => {
5353
isHtmlInteractiveContent(makeNode('input', { type: 'hidden' }), getTextAttrValue)
5454
).toBe(false);
5555
});
56+
57+
it('is NOT interactive when type="HIDDEN" (case-insensitive)', () => {
58+
expect(
59+
isHtmlInteractiveContent(makeNode('input', { type: 'HIDDEN' }), getTextAttrValue)
60+
).toBe(false);
61+
});
62+
63+
it('is NOT interactive when type=" hidden " (whitespace-trimmed)', () => {
64+
expect(
65+
isHtmlInteractiveContent(makeNode('input', { type: ' hidden ' }), getTextAttrValue)
66+
).toBe(false);
67+
});
5668
});
5769

5870
describe('<a>', () => {

0 commit comments

Comments
 (0)