Skip to content

Commit 04e74fb

Browse files
committed
fix: use classifyAttribute for aria-label + tabindex (rows h6, h9, h10, t6, t7)
1 parent c8ab616 commit 04e74fb

2 files changed

Lines changed: 38 additions & 19 deletions

File tree

lib/rules/template-no-aria-label-misuse.js

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
const { roles, elementRoles } = require('aria-query');
99
const { isNativeElement } = require('../utils/is-native-element');
10+
const { classifyAttribute } = require('../utils/glimmer-attr-presence');
1011

1112
function findAttr(node, name) {
1213
return node.attributes?.find((attr) => attr.name === name);
@@ -142,29 +143,23 @@ function getRole(node) {
142143
}
143144

144145
function hasNonEmptyLabelAttr(node, name) {
146+
// Per docs/glimmer-attribute-behavior.md, bare-mustache falsy literals on
147+
// aria-* attributes (rows h6, h9, h10) cause Glimmer to OMIT the attribute
148+
// at runtime — there is no aria-label on the rendered element, so it can't
149+
// be a misuse. Use classifyAttribute so the runtime-presence drives the
150+
// answer rather than AST-presence.
145151
const attr = findAttr(node, name);
146-
if (!attr) {
152+
const { presence, value } = classifyAttribute(attr);
153+
if (presence === 'absent') {
147154
return false;
148155
}
149-
// A valueless attribute (e.g. `<div aria-label>`) carries no accessible
150-
// name. Treat it as empty — not as non-empty — so downstream checks don't
151-
// mistake it for an author-declared label.
152-
if (attr.value === null || attr.value === undefined) {
156+
// 'present' with statically-known value: empty / whitespace renders no
157+
// accessible name, so treat as empty (not a misuse).
158+
if (value !== null && value.trim() === '') {
153159
return false;
154160
}
155-
if (attr.value.type === 'GlimmerTextNode') {
156-
return attr.value.chars.trim() !== '';
157-
}
158-
// Mustache with a static string literal path: `aria-label={{""}}` is still
159-
// empty, so treat it the same as an empty text node.
160-
if (
161-
attr.value.type === 'GlimmerMustacheStatement' &&
162-
attr.value.path?.type === 'GlimmerStringLiteral'
163-
) {
164-
return attr.value.path.value.trim() !== '';
165-
}
166-
// All other mustache / concat forms — treat as non-empty (author has
167-
// declared intent).
161+
// Otherwise the attribute renders a non-empty (or dynamic) value — author
162+
// has declared intent.
168163
return true;
169164
}
170165

@@ -190,8 +185,13 @@ function isExplicitlyDecorative(node) {
190185
// high false-positive cost (the author wants the label read on focus)
191186
// relative to the true-positive it would catch. Disable via
192187
// `strictTabindex: true` to get strict spec-role enforcement.
188+
//
189+
// Per docs/glimmer-attribute-behavior.md (rows t6, t7), bare-mustache
190+
// `tabindex={{false}}` / `{{null}}` / `{{undefined}}` cause Glimmer to omit
191+
// the attribute at runtime — the element has no tabindex and the escape
192+
// hatch should NOT fire.
193193
function hasTabindex(node) {
194-
return Boolean(findAttr(node, 'tabindex'));
194+
return classifyAttribute(findAttr(node, 'tabindex')).presence === 'present';
195195
}
196196

197197
/** @type {import('eslint').Rule.RuleModule} */

tests/lib/rules/template-no-aria-label-misuse.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ const validHbs = [
6262
'<div aria-label>x</div>',
6363
// Static string-literal mustache — empty string is treated as no label.
6464
'<div aria-label={{""}}>x</div>',
65+
// Bare-mustache falsy on aria-label (rows h6, h9, h10) — Glimmer omits the
66+
// attribute at runtime, so there is NO aria-label and no misuse to flag.
67+
'<div aria-label={{false}}>x</div>',
68+
'<div aria-label={{null}}>x</div>',
69+
'<div aria-label={{undefined}}>x</div>',
70+
// Bare-mustache falsy on tabindex (rows t6, t7) — escape hatch should NOT
71+
// fire because tabindex isn't actually rendered. The element is back to
72+
// having a non-interactive implicit role and aria-label IS a misuse.
73+
// (paired with an aria-label to ensure it gets flagged for the right reason)
6574
];
6675

6776
const invalidHbs = [
@@ -85,6 +94,16 @@ const invalidHbs = [
8594
code: '<div aria-label={{this.label}}>x</div>',
8695
errors: [{ message: err('aria-label', 'div', 'generic') }],
8796
},
97+
// Bare-mustache falsy on tabindex (rows t6, t7) — escape hatch shouldn't
98+
// fire (tabindex omitted at runtime). aria-label is misuse on a generic.
99+
{
100+
code: '<div tabindex={{false}} aria-label="Custom">x</div>',
101+
errors: [{ message: err('aria-label', 'div', 'generic') }],
102+
},
103+
{
104+
code: '<div tabindex={{null}} aria-label="Custom">x</div>',
105+
errors: [{ message: err('aria-label', 'div', 'generic') }],
106+
},
88107
// <img alt=""> is role=presentation per ARIA; aria-label contradicts the
89108
// "decorative" hint and is prohibited.
90109
{

0 commit comments

Comments
 (0)