Skip to content

Commit a51c130

Browse files
committed
fix(template-click-events-have-key-events): drop misleading 'Visible' message word; add aria-hidden mustache-string and concat tests; clarify option/datalist comment
1 parent 0e728eb commit a51c130

2 files changed

Lines changed: 25 additions & 27 deletions

File tree

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

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const { dom } = require('aria-query');
22
const { isNativeElement } = require('../utils/is-native-element');
33
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
4+
const { getStaticAttrValue } = require('../utils/static-attr-value');
45

56
const KEYBOARD_EVENT_NAMES = new Set(['keydown', 'keyup', 'keypress']);
67

@@ -21,33 +22,20 @@ function getTextAttrValue(node, attrName) {
2122
return getAttrTextValue(findAttr(node, attrName));
2223
}
2324

24-
// True iff the attribute's mustache value is the literal boolean `true` —
25-
// e.g. `aria-hidden={{true}}`. Any other expression (path reference, helper
26-
// call, etc.) is left to runtime and not treated as a static escape hatch.
27-
function isMustacheLiteralTrue(attr) {
28-
if (attr?.value?.type !== 'GlimmerMustacheStatement') {
29-
return false;
30-
}
31-
const path = attr.value.path;
32-
return path?.type === 'GlimmerBooleanLiteral' && path.value === true;
33-
}
34-
3525
function isHiddenFromScreenReader(node) {
3626
const ariaHidden = findAttr(node, 'aria-hidden');
3727
if (ariaHidden) {
3828
// WAI-ARIA 1.2: aria-hidden is NOT a boolean HTML attribute. Only the
3929
// string value "true" hides the element. A valueless attribute or an
4030
// empty string is invalid and must NOT be treated as hiding the element.
41-
if (ariaHidden.value?.type === 'GlimmerTextNode') {
42-
const chars = ariaHidden.value.chars;
43-
if (chars.trim().toLowerCase() === 'true') {
44-
return true;
45-
}
46-
}
47-
// Mustache-literal `{{true}}` — unambiguous static escape hatch. Any
48-
// other mustache shape (path reference, helper invocation) is dynamic
49-
// and intentionally NOT treated as hidden.
50-
if (isMustacheLiteralTrue(ariaHidden)) {
31+
//
32+
// getStaticAttrValue resolves GlimmerTextNode, GlimmerMustacheStatement
33+
// with a literal path (boolean/string), and GlimmerConcatStatement whose
34+
// parts are all static — covering aria-hidden="TRUE", aria-hidden={{true}},
35+
// aria-hidden={{"true"}}, and aria-hidden="{{true}}". Dynamic expressions
36+
// return undefined and are intentionally not treated as hidden.
37+
const resolved = getStaticAttrValue(ariaHidden.value);
38+
if (resolved !== undefined && resolved.trim().toLowerCase() === 'true') {
5139
return true;
5240
}
5341
}
@@ -109,7 +97,7 @@ module.exports = {
10997
schema: [],
11098
messages: {
11199
needsKeyEvent:
112-
'Visible, non-interactive elements with click handlers must have at least one keyboard listener (keydown/keyup/keypress).',
100+
'Non-interactive elements with click handlers must have at least one keyboard listener (keydown/keyup/keypress).',
113101
},
114102
},
115103

@@ -149,12 +137,14 @@ module.exports = {
149137
// Elements outside HTML §3.2.5.2.7 that are nonetheless ARIA widgets
150138
// or conventionally interactive surfaces — click-without-key on them
151139
// isn't what this rule targets. The HTML-content-model util covers
152-
// the spec-normative list; these are the ARIA-widget / convention
153-
// additions (see `html-interactive-content.js` docstring for why the
154-
// two authorities diverge).
140+
// the spec-normative list; these are explicit exemptions for elements
141+
// that do not qualify as interactive content under the HTML spec but
142+
// are carved out here due to their ARIA roles or browser-native
143+
// behavior (see `html-interactive-content.js` docstring for context).
155144
// - <canvas>: drawing/game surface (axobject-query: CanvasRole).
156-
// - <option>: ARIA role="option" (widget).
157-
// - <datalist>: ARIA role="listbox" (widget).
145+
// - <option>: ARIA role="option" (widget), but not keyboard-activatable
146+
// as a standalone element — exempted as a special case.
147+
// - <datalist>: ARIA role="listbox" (widget), same rationale as <option>.
158148
const lowerTag = node.tag.toLowerCase();
159149
if (lowerTag === 'canvas' || lowerTag === 'option' || lowerTag === 'datalist') {
160150
return;

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,21 @@ ruleTester.run('template-click-events-have-key-events', rule, {
2323
'<template><select {{on "click" this.onClick}}></select></template>',
2424
'<template><textarea {{on "click" this.onClick}}></textarea></template>',
2525
'<template><summary {{on "click" this.noop}}>More</summary></template>',
26+
27+
// <option>/<datalist> are widget descendants — keyboard activation lives on
28+
// their host (<select>/<input list>), not on the descendant itself, so the
29+
// rule explicitly skips them rather than treating them as "keyboard built in".
2630
'<template><option {{on "click" this.h}}>Foo</option></template>',
2731
'<template><datalist {{on "click" this.h}}></datalist></template>',
2832

2933
// Hidden from AT.
3034
'<template><div aria-hidden="true" {{on "click" this.noop}}></div></template>',
3135
// Mustache-literal boolean `true` — explicit static opt-out.
3236
'<template><div aria-hidden={{true}} {{on "click" this.noop}}></div></template>',
37+
// Mustache string-literal "TRUE" (case-insensitive) — also static opt-out.
38+
'<template><div aria-hidden={{"TRUE"}} {{on "click" this.noop}}></div></template>',
39+
// GlimmerConcatStatement form (quoted-mustache with single boolean-literal part).
40+
'<template><div aria-hidden="{{true}}" {{on "click" this.noop}}></div></template>',
3341
'<template><div hidden {{on "click" this.noop}}></div></template>',
3442

3543
// Presentation role — content has no semantics for AT.

0 commit comments

Comments
 (0)