`
+ is flagged as strictly as any other generic element. Enable this for
+ strict spec-role enforcement.
+
+```js
+module.exports = {
+ rules: {
+ 'ember/template-no-aria-label-misuse': ['error', { strictTabindex: true }],
+ },
+};
+```
+
+## Examples
+
+Forbids:
+
+```hbs
+
...
+
...
+
Note text
+
...
+

+```
+
+Allows:
+
+```hbs
+
+
...
+
+{{! becomes role=region }}
+
+{{! becomes role=form }}
+
...
+
...
+```
+
+## References
+
+- [WAI-ARIA 1.2: Accessible Name Calculation](https://www.w3.org/TR/wai-aria-1.2/#namecalculation)
+- [WAI-ARIA 1.2: `aria-label` property definition](https://www.w3.org/TR/wai-aria-1.2/#aria-label)
+- [HTML-AAM: ARIA role mappings](https://www.w3.org/TR/html-aam-1.1/#html-element-role-mappings)
+- [`aria-query`](https://www.npmjs.com/package/aria-query) (authoritative ARIA data, already a dep of this plugin)
+- Rule inspired by [`html-validate`'s `aria-label-misuse`](https://gitlab.com/html-validate/html-validate/-/blob/v10.13.1/src/rules/aria-label-misuse.ts) (MIT).
diff --git a/docs/rules/template-require-input-type.md b/docs/rules/template-require-input-type.md
new file mode 100644
index 0000000000..7fc59e28b0
--- /dev/null
+++ b/docs/rules/template-require-input-type.md
@@ -0,0 +1,66 @@
+# ember/template-require-input-type
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+
+
+This rule rejects `
` values that are not one of the input
+types defined by the HTML spec, and (optionally) requires every `
` to
+declare a `type` attribute.
+
+An invalid value like `
` silently falls back to the Text
+state — the browser reports no error, but the author's intent (validation,
+inputmode hint, platform keyboard) is lost. That's a genuine silent-failure
+class, which this rule always flags and auto-fixes to `type="text"`.
+
+A missing `type` attribute (`
`) is _spec-compliant_ — the
+missing-value default is the Text state — so flagging it is a style /
+consistency choice, not a correctness one. Opt in with `requireExplicit: true`
+if your team wants parity with `template-require-button-type`.
+
+## Examples
+
+This rule **forbids** the following (always):
+
+```hbs
+
+
+
+```
+
+With `requireExplicit: true` the rule **also forbids**:
+
+```hbs
+
+
+```
+
+This rule **allows** the following:
+
+```hbs
+
+
+
+
+```
+
+Dynamic values such as `type={{this.inputType}}` are not flagged at lint time.
+
+## Configuration
+
+- `requireExplicit` (`boolean`, default `false`): when true, also flag
+ `
` elements that have no `type` attribute. Auto-fix inserts
+ `type="text"`.
+
+```js
+module.exports = {
+ rules: {
+ 'ember/template-require-input-type': ['error', { requireExplicit: true }],
+ },
+};
+```
+
+## References
+
+- [HTML spec — the input element](https://html.spec.whatwg.org/multipage/input.html#the-input-element)
+- Adapted from [`html-validate`'s `no-implicit-input-type`](https://html-validate.org/rules/no-implicit-input-type.html) (MIT).
diff --git a/lib/rules/template-block-indentation.js b/lib/rules/template-block-indentation.js
index 660fd1a94f..e42831516f 100644
--- a/lib/rules/template-block-indentation.js
+++ b/lib/rules/template-block-indentation.js
@@ -1,25 +1,9 @@
'use strict';
+const { htmlVoidElements } = require('html-void-elements');
const editorConfigUtil = require('../utils/editorconfig');
-const VOID_TAGS = new Set([
- 'area',
- 'base',
- 'br',
- 'col',
- 'command',
- 'embed',
- 'hr',
- 'img',
- 'input',
- 'keygen',
- 'link',
- 'meta',
- 'param',
- 'source',
- 'track',
- 'wbr',
-]);
+const VOID_TAGS = new Set(htmlVoidElements);
const IGNORED_ELEMENTS = new Set(['pre', 'script', 'style', 'textarea']);
function isControlChar(char) {
diff --git a/lib/rules/template-no-aria-label-misuse.js b/lib/rules/template-no-aria-label-misuse.js
new file mode 100644
index 0000000000..d7df3c7a3d
--- /dev/null
+++ b/lib/rules/template-no-aria-label-misuse.js
@@ -0,0 +1,272 @@
+'use strict';
+
+// See html-validate (https://html-validate.org/rules/aria-label-misuse.html) for the peer rule concept.
+// Role resolution delegates to `aria-query` — the authoritative WAI-ARIA
+// data package (already a dependency of this plugin). The
+// `roles.get(r).prohibitedProps` list drives the flag/allow decision.
+
+const { roles, elementRoles } = require('aria-query');
+const { isNativeElement } = require('../utils/is-native-element');
+const { classifyAttribute } = require('../utils/glimmer-attr-presence');
+
+function findAttr(node, name) {
+ return node.attributes?.find((attr) => attr.name === name);
+}
+
+function getStaticAttrString(node, name) {
+ const attr = findAttr(node, name);
+ if (!attr) {
+ return null;
+ }
+ // Valueless attribute (e.g. `
![]()
`) — treat as empty string.
+ if (attr.value === null || attr.value === undefined) {
+ return '';
+ }
+ if (attr.value.type === 'GlimmerTextNode') {
+ return attr.value.chars;
+ }
+ // Mustache with a static string literal path (e.g. `alt={{""}}`).
+ if (
+ attr.value.type === 'GlimmerMustacheStatement' &&
+ attr.value.path?.type === 'GlimmerStringLiteral'
+ ) {
+ return attr.value.path.value;
+ }
+ return null;
+}
+
+// Score how well an elementRoles entry matches the given node. Returns `null`
+// if any constraint fails; otherwise the number of satisfied conditions
+// (higher = more specific, used to pick the best match).
+function scoreMatch(entry, node) {
+ const attrs = entry.attributes || [];
+ let score = 0;
+ for (const spec of attrs) {
+ const nodeAttr = findAttr(node, spec.name);
+ const isPresent = Boolean(nodeAttr);
+ const staticValue = getStaticAttrString(node, spec.name);
+
+ // Specificity tiers: exact value match is strictest (3), "set" presence
+ // is stricter than "undefined" absence (2 vs 1). Previously `set` and
+ // `undefined` both scored 1, so a constraints-match on either tied; the
+ // tier split ensures a more specific "set" entry beats a looser
+ // "undefined" entry when both match — and an exact-value entry (e.g.
+ //
![]()
→ presentation) still beats a plain "set" entry.
+ if (spec.value !== undefined) {
+ if (staticValue === null || staticValue.toLowerCase() !== spec.value) {
+ return null;
+ }
+ score += 3;
+ continue;
+ }
+ if (spec.constraints?.includes('set')) {
+ if (!isPresent) {
+ return null;
+ }
+ score += 2;
+ continue;
+ }
+ if (spec.constraints?.includes('undefined')) {
+ if (isPresent) {
+ return null;
+ }
+ score += 1;
+ continue;
+ }
+ return null;
+ }
+ return score;
+}
+
+// Pre-index elementRoles by tag name at module load. aria-query's Map is
+// static data; bucketing by tag turns the per-call scan (~80 keys) into a
+// 1–5 key lookup per tag. Mirrors the optimization landed on PR #52's
+// template-no-unsupported-role-attributes rule.
+const ELEMENT_ROLES_KEYS_BY_TAG = buildElementRolesIndex();
+
+function buildElementRolesIndex() {
+ const index = new Map();
+ for (const key of elementRoles.keys()) {
+ if (!index.has(key.name)) {
+ index.set(key.name, []);
+ }
+ index.get(key.name).push(key);
+ }
+ return index;
+}
+
+function getImplicitRole(node) {
+ const keys = ELEMENT_ROLES_KEYS_BY_TAG.get(node.tag);
+ if (!keys) {
+ return null;
+ }
+ let best = null;
+ let bestScore = -1;
+ for (const key of keys) {
+ const score = scoreMatch(key, node);
+ if (score === null) {
+ continue;
+ }
+ if (score > bestScore) {
+ bestScore = score;
+ best = elementRoles.get(key)[0];
+ }
+ }
+ return best;
+}
+
+function getRole(node) {
+ const roleAttr = findAttr(node, 'role');
+ if (roleAttr) {
+ // Present but dynamic (mustache / concat) — the runtime role is unknown,
+ // and if it differs from the element's implicit role we'd false-positive
+ // against the implicit. Skip rather than guess.
+ const explicit = getStaticAttrString(node, 'role');
+ if (explicit === null) {
+ return null;
+ }
+ // Walk the whitespace-separated token list for the first RECOGNISED
+ // role, matching WAI-ARIA §4.1 role-fallback semantics — UAs skip
+ // unknown tokens and pick the first they implement. `role="xxyxyz
+ // button"` resolves to `button`; later tokens are graceful-degradation
+ // fallbacks. Unknown-only lists fall through to the implicit role.
+ const tokens = explicit.trim().toLowerCase().split(/\s+/u);
+ for (const token of tokens) {
+ if (token && roles.has(token)) {
+ return token;
+ }
+ }
+ // `role` present but no recognised token (e.g. `role="bogus"`) — per
+ // ARIA, invalid values are ignored and the implicit role applies.
+ }
+ return getImplicitRole(node);
+}
+
+function hasNonEmptyLabelAttr(node, name) {
+ // Per docs/glimmer-attribute-behavior.md, bare-mustache falsy literals on
+ // aria-* attributes (rows h6, h9, h10) cause Glimmer to OMIT the attribute
+ // at runtime — there is no aria-label on the rendered element, so it can't
+ // be a misuse. Use classifyAttribute so the runtime-presence drives the
+ // answer rather than AST-presence.
+ const attr = findAttr(node, name);
+ const { presence, value } = classifyAttribute(attr);
+ if (presence === 'absent') {
+ return false;
+ }
+ // 'present' with statically-known value: empty / whitespace renders no
+ // accessible name, so treat as empty (not a misuse).
+ if (value !== null && value.trim() === '') {
+ return false;
+ }
+ // Otherwise the attribute renders a non-empty (or dynamic) value — author
+ // has declared intent.
+ return true;
+}
+
+function isExplicitlyDecorative(node) {
+ const role = getStaticAttrString(node, 'role');
+ if (!role) {
+ return false;
+ }
+ // Walk the token list for the first *recognised* role, mirroring WAI-ARIA
+ // §4.1 fallback semantics (UAs skip unknown tokens). `role="foo none"` is
+ // decorative because the first recognised token is "none".
+ const tokens = role.trim().toLowerCase().split(/\s+/u);
+ for (const token of tokens) {
+ if (token && roles.get(token)) {
+ return token === 'presentation' || token === 'none';
+ }
+ }
+ return false;
+}
+
+// Escape hatch: any `tabindex` value signals author-intent-to-interact,
+// even when the computed ARIA role is still generic. Flagging here has a
+// high false-positive cost (the author wants the label read on focus)
+// relative to the true-positive it would catch. Disable via
+// `strictTabindex: true` to get strict spec-role enforcement.
+//
+// Per docs/glimmer-attribute-behavior.md (rows t6, t7), bare-mustache
+// `tabindex={{false}}` / `{{null}}` / `{{undefined}}` cause Glimmer to omit
+// the attribute at runtime — the element has no tabindex and the escape
+// hatch should NOT fire.
+function hasTabindex(node) {
+ return classifyAttribute(findAttr(node, 'tabindex')).presence === 'present';
+}
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'disallow aria-label and aria-labelledby on elements whose role prohibits an accessible name',
+ category: 'Accessibility',
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-aria-label-misuse.md',
+ templateMode: 'both',
+ },
+ fixable: null,
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ strictTabindex: {
+ type: 'boolean',
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ misuse:
+ '`{{attr}}` is prohibited on `<{{tag}}>` (role `{{role}}`). Elements with this role are not named from author; the attribute is ignored by assistive tech.',
+ },
+ },
+
+ create(context) {
+ const strictTabindex = Boolean(context.options[0]?.strictTabindex);
+ const sourceCode = context.sourceCode || context.getSourceCode();
+
+ return {
+ GlimmerElementNode(node) {
+ // Gate on `isNativeElement` to correctly exclude custom elements
+ // (
), colon-namespaced tags (), named blocks
+ // (<:slot>), PascalCase components, dotted/at-prefixed path tags,
+ // and scope-shadowed bindings. The previous first-char regex was
+ // permissive and misclassified custom elements as native HTML.
+ if (!isNativeElement(node, sourceCode)) {
+ return;
+ }
+ if (isExplicitlyDecorative(node)) {
+ return;
+ }
+ if (!strictTabindex && hasTabindex(node)) {
+ return;
+ }
+
+ const role = getRole(node);
+ if (!role) {
+ return;
+ }
+ const def = roles.get(role);
+ if (!def) {
+ return;
+ }
+
+ for (const key of ['aria-label', 'aria-labelledby']) {
+ if (!hasNonEmptyLabelAttr(node, key)) {
+ continue;
+ }
+ if (def.prohibitedProps?.includes(key)) {
+ const attr = findAttr(node, key);
+ context.report({
+ node: attr,
+ messageId: 'misuse',
+ data: { attr: key, tag: node.tag, role },
+ });
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/lib/rules/template-require-input-type.js b/lib/rules/template-require-input-type.js
new file mode 100644
index 0000000000..270d5433fe
--- /dev/null
+++ b/lib/rules/template-require-input-type.js
@@ -0,0 +1,145 @@
+'use strict';
+
+// See html-validate (https://html-validate.org) for the peer rule concept.
+
+const { isNativeElement } = require('../utils/is-native-element');
+
+const VALID_TYPES = new Set([
+ 'button',
+ 'checkbox',
+ 'color',
+ 'date',
+ 'datetime-local',
+ 'email',
+ 'file',
+ 'hidden',
+ 'image',
+ 'month',
+ 'number',
+ 'password',
+ 'radio',
+ 'range',
+ 'reset',
+ 'search',
+ 'submit',
+ 'tel',
+ 'text',
+ 'time',
+ 'url',
+ 'week',
+]);
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'require input elements to have a valid type attribute',
+ category: 'Best Practices',
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-input-type.md',
+ templateMode: 'both',
+ },
+ fixable: 'code',
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ requireExplicit: {
+ type: 'boolean',
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ missing: 'All `` elements should have a `type` attribute',
+ invalid: '`` is not a valid input type',
+ },
+ },
+
+ create(context) {
+ // Flagging a missing `type` is a style/consistency check, not a correctness
+ // one: `` without `type` is spec-compliant (defaults to the Text
+ // state). Opt-in so teams that want parity with template-require-button-
+ // type can enable it without imposing it on others.
+ const requireExplicit = Boolean(context.options[0]?.requireExplicit);
+ const sourceCode = context.sourceCode || context.getSourceCode();
+
+ return {
+ GlimmerElementNode(node) {
+ if (node.tag !== 'input') {
+ return;
+ }
+ // In strict GJS, a lowercase local binding can shadow the native
+ // `` element. `isNativeElement` consults html/svg/mathml tag
+ // lists and checks bindings in the scope chain to filter out
+ // scope-shadowed cases.
+ if (!isNativeElement(node, sourceCode)) {
+ return;
+ }
+
+ const typeAttr = node.attributes?.find((attr) => attr.name === 'type');
+
+ if (!typeAttr) {
+ if (!requireExplicit) {
+ return;
+ }
+ context.report({
+ node,
+ messageId: 'missing',
+ fix(fixer) {
+ // Insert right after ``) — per HTML spec, a
+ // present-but-empty type attribute resolves to the missing-value
+ // default ("Text state"). That's the same runtime result as
+ // `type=""`, which we already flag. Treat them consistently:
+ // flag as invalid('') and autofix to `type="text"`.
+ if (!value) {
+ context.report({
+ node: typeAttr,
+ messageId: 'invalid',
+ data: { value: '' },
+ fix(fixer) {
+ return fixer.replaceText(typeAttr, 'type="text"');
+ },
+ });
+ return;
+ }
+
+ if (value.type === 'GlimmerTextNode') {
+ const typeValue = value.chars.toLowerCase();
+ if (typeValue === '') {
+ context.report({
+ node: typeAttr,
+ messageId: 'invalid',
+ data: { value: '' },
+ fix(fixer) {
+ return fixer.replaceText(typeAttr, 'type="text"');
+ },
+ });
+ } else if (!VALID_TYPES.has(typeValue)) {
+ context.report({
+ node: typeAttr,
+ messageId: 'invalid',
+ data: { value: value.chars },
+ fix(fixer) {
+ return fixer.replaceText(typeAttr, 'type="text"');
+ },
+ });
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/lib/rules/template-self-closing-void-elements.js b/lib/rules/template-self-closing-void-elements.js
index 6b194979c7..2cc40f813a 100644
--- a/lib/rules/template-self-closing-void-elements.js
+++ b/lib/rules/template-self-closing-void-elements.js
@@ -1,3 +1,9 @@
+'use strict';
+
+const { htmlVoidElements } = require('html-void-elements');
+
+const VOID_ELEMENTS = new Set(htmlVoidElements);
+
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
@@ -27,25 +33,6 @@ module.exports = {
},
create(context) {
- const VOID_ELEMENTS = new Set([
- 'area',
- 'base',
- 'br',
- 'col',
- 'command',
- 'embed',
- 'hr',
- 'img',
- 'input',
- 'keygen',
- 'link',
- 'meta',
- 'param',
- 'source',
- 'track',
- 'wbr',
- ]);
-
const sourceCode = context.sourceCode;
const config = context.options[0] ?? true;
diff --git a/lib/utils/glimmer-attr-presence.js b/lib/utils/glimmer-attr-presence.js
new file mode 100644
index 0000000000..1d8479a28e
--- /dev/null
+++ b/lib/utils/glimmer-attr-presence.js
@@ -0,0 +1,202 @@
+'use strict';
+
+const { find, html } = require('property-information');
+const { getStaticAttrValue } = require('./static-attr-value');
+
+// `colspan` is a positive-integer attribute per WHATWG, but property-information
+// 7.1.0 doesn't mark it as `number: true` (likely upstream gap — `rowspan`,
+// `cols`, etc. do have it). Override locally; remove if upstream fixes.
+const NUMERIC_OVERRIDES = new Set(['colspan']);
+
+/**
+ * Infer the attribute kind from its name. Used when the caller doesn't pass
+ * `options.kind` explicitly.
+ *
+ * Returns one of: 'boolean' | 'aria' | 'numeric' | 'plain-string'.
+ *
+ * Classification flows from the `property-information` package, which encodes
+ * attribute type info per WHATWG HTML / WAI-ARIA. ARIA prefix is checked first
+ * because Glimmer's rendering for `aria-*` attrs diverges from HTML booleans
+ * (e.g., `aria-hidden={{true}}` renders empty per h5, but `disabled={{true}}`
+ * renders `disabled=""` per d2). `role` falls through to plain-string because
+ * Glimmer does not falsy-coerce it (the doc's cross-attribute observations
+ * confirm this — `role={{false}}` renders `role="false"`).
+ */
+function inferAttrKind(name) {
+ // HTML attribute names are case-insensitive; normalize before lookup so
+ // `Disabled`, `ARIA-Hidden`, etc. classify the same as the lowercase form.
+ const lower = name.toLowerCase();
+ if (lower.startsWith('aria-')) {
+ return 'aria';
+ }
+ const info = find(html, lower);
+ // boolean: standard HTML boolean attrs (disabled, muted, …).
+ // overloadedBoolean: hidden, download — boolean-like with extra string values,
+ // but Glimmer's falsy-omit coercion still applies (verified for `hidden`-style).
+ if (info.boolean || info.overloadedBoolean) {
+ return 'boolean';
+ }
+ if (info.number || NUMERIC_OVERRIDES.has(lower)) {
+ return 'numeric';
+ }
+ // Everything else (plain strings, booleanish HTML attrs like contenteditable
+ // and draggable whose Glimmer behavior isn't verified in the doc) routes to
+ // plain-string. Conservative: no falsy-omit coercion, render the literal.
+ return 'plain-string';
+}
+
+/**
+ * Classify a Glimmer attribute against the verified rendering model in
+ * docs/glimmer-attribute-behavior.md.
+ *
+ * Result shape: { presence, value }
+ *
+ * presence: 'absent' | 'present' | 'unknown'
+ * - 'absent' — attribute will not be on the rendered element.
+ * Either attrNode is null/undefined, OR the source is
+ * bare {{false}}/{{null}}/{{undefined}} (or {{0}} for
+ * `boolean` kind) on a falsy-coerced attribute kind
+ * (boolean / aria / numeric). Doc rows: m6, m9, m10, m12,
+ * d3, d6, h6, h9, h10, t6, t7.
+ * - 'present' — attribute will be present at runtime. `value` is the
+ * resolved static string when known, or null when the
+ * value is dynamic (e.g., bare {{this.x}} on a plain-string
+ * attribute).
+ * - 'unknown' — cannot determine statically (dynamic mustache / dynamic
+ * concat part on a falsy-coerced kind, since the runtime
+ * value could be falsy and thus omit the attribute).
+ *
+ * value: string | null
+ * The resolved HTML attribute value when statically known. null when:
+ * - presence is 'absent' or 'unknown'
+ * - presence is 'present' but the value is dynamic
+ *
+ * @param {object|null|undefined} attrNode - The AttrNode, or null/undefined when not found.
+ * @param {object} [options]
+ * @param {'boolean'|'aria'|'numeric'|'plain-string'} [options.kind] - Override inferred kind.
+ * @returns {{presence: 'absent'|'present'|'unknown', value: string|null}}
+ */
+function classifyAttribute(attrNode, options = {}) {
+ if (!attrNode) {
+ return { presence: 'absent', value: null };
+ }
+
+ const kind = options.kind || inferAttrKind(attrNode.name);
+ const isFalsyCoerced = kind === 'boolean' || kind === 'aria' || kind === 'numeric';
+ const value = attrNode.value;
+
+ // Valueless attribute: ,
+ // Renders as `attr=""`. Doc rows: d1, h1.
+ if (value === null || value === undefined) {
+ return { presence: 'present', value: '' };
+ }
+
+ // Static text: attr="anything". Renders the literal chars.
+ // Doc rows: m1-m4, h2-h4, d1, t-static, i1.
+ if (value.type === 'GlimmerTextNode') {
+ return { presence: 'present', value: value.chars };
+ }
+
+ // Bare-mustache: attr={{X}}
+ if (value.type === 'GlimmerMustacheStatement') {
+ return classifyBareMustache(value, kind, isFalsyCoerced);
+ }
+
+ // Concat-mustache: attr="...{{X}}..." — never falsy.
+ // Doc cross-attribute observation: "Concat is never falsy."
+ if (value.type === 'GlimmerConcatStatement') {
+ // For boolean attrs, the IDL property is set true regardless of inner
+ // literal (rows m13–m19, d7–d10). Report the canonical "on" value so
+ // callers comparing `value === 'false'` to detect "off" don't get a
+ // wrong answer from the inner literal of `attr="{{false}}"`.
+ if (kind === 'boolean') {
+ return { presence: 'present', value: 'true' };
+ }
+ // For aria/numeric/plain-string, the rendered HTML value is the
+ // stringified concatenation of parts (h12–h15, i3, i5). If any part
+ // is dynamic, the resolved value is unknown but presence is still 'present'.
+ const resolved = getStaticAttrValue(value);
+ return { presence: 'present', value: resolved === undefined ? null : resolved };
+ }
+
+ // Unknown AST shape (e.g., a future Glimmer node type) — be conservative.
+ return { presence: 'unknown', value: null };
+}
+
+function classifyBareMustache(value, kind, isFalsyCoerced) {
+ const path = value.path;
+ if (!path) {
+ return { presence: 'unknown', value: null };
+ }
+
+ // {{true}} / {{false}}
+ if (path.type === 'GlimmerBooleanLiteral') {
+ if (path.value === false) {
+ // {{false}} on falsy-coerced kind → omitted (m6, d3, h6, t6 verified).
+ // {{false}} on plain-string → renders "false" (i4 verified for autocomplete).
+ if (isFalsyCoerced) {
+ return { presence: 'absent', value: null };
+ }
+ return { presence: 'present', value: 'false' };
+ }
+ // {{true}}: behavior diverges by kind.
+ // - boolean: verified (m5, d2). HTML may be empty (d2) or omitted (m5),
+ // but the attribute is conceptually "on". Surface 'true' so callers
+ // can string-compare like for {{"true"}}.
+ // - aria: verified (h5). Renders aria-hidden="" — empty, NOT "true".
+ // Callers comparing aria-hidden to "true" must not match this row.
+ // - numeric / plain-string: untested in the verification doc. Be
+ // conservative — return 'unknown' rather than guess.
+ if (kind === 'boolean') {
+ return { presence: 'present', value: 'true' };
+ }
+ if (kind === 'aria') {
+ return { presence: 'present', value: '' };
+ }
+ return { presence: 'unknown', value: null };
+ }
+
+ // {{null}} / {{undefined}}
+ if (path.type === 'GlimmerNullLiteral' || path.type === 'GlimmerUndefinedLiteral') {
+ // Verified for falsy-coerced kinds via cross-attribute observation
+ // (rows m9, m10, h9, h10, d6, t7).
+ // For plain-string, behavior is not yet verified — return 'unknown' to
+ // avoid claiming behavior the doc doesn't guarantee.
+ if (isFalsyCoerced) {
+ return { presence: 'absent', value: null };
+ }
+ return { presence: 'unknown', value: null };
+ }
+
+ // {{"string"}}
+ // Bare-mustache string literals never coerce — render literal value.
+ // Doc rows: m7, m8, h7, h8, d4, d5, i2.
+ if (path.type === 'GlimmerStringLiteral') {
+ return { presence: 'present', value: path.value };
+ }
+
+ // {{0}}, {{1}}, {{-1}}, etc.
+ if (path.type === 'GlimmerNumberLiteral') {
+ // {{0}} for boolean kind → omitted (m12 verified for muted).
+ // For numeric kind, t1 verifies {{0}} renders "0" (focusable).
+ // For plain-string, untested.
+ if (path.value === 0 && kind === 'boolean') {
+ return { presence: 'absent', value: null };
+ }
+ return { presence: 'present', value: String(path.value) };
+ }
+
+ // Dynamic path: {{this.x}}, {{x}}, {{(some-helper)}}, etc.
+ // For falsy-coerced kinds, runtime value could be falsy → attribute omitted.
+ // For plain-string, the attribute renders something (even null/undefined coerce
+ // via stringification), but the value isn't statically known.
+ if (isFalsyCoerced) {
+ return { presence: 'unknown', value: null };
+ }
+ return { presence: 'present', value: null };
+}
+
+module.exports = {
+ classifyAttribute,
+ inferAttrKind,
+};
diff --git a/lib/utils/is-native-element.js b/lib/utils/is-native-element.js
index 190374d9bb..ebdad3b9c4 100644
--- a/lib/utils/is-native-element.js
+++ b/lib/utils/is-native-element.js
@@ -21,19 +21,17 @@ const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
* MathML spec registries, reached via the `html-tags` / `svg-tags` /
* `mathml-tag-names` packages). It is NOT the same as:
*
- * - "native accessibility" / "widget-ness" — see `interactive-roles.js`
- * (aria-query widget taxonomy; an ARIA-tree-semantics question)
- * - "native interactive content" / "focus behavior" — see
- * `html-interactive-content.js` (HTML §3.2.5.2.7; an HTML-content-model
- * question about which tags can be nested inside what)
+ * - "native accessibility" / "widget-ness" — an ARIA-tree-semantics
+ * question (for example, whether something maps to a widget role)
+ * - "native interactive content" / "focus behavior" — an HTML content-model
+ * question about which elements are considered interactive in the spec
* - "natively focusable" / sequential-focus — see HTML §6.6.3
*
* This util answers only: "is this tag a first-class built-in element of one
* of the three markup-language standards, rather than a component invocation
- * or a shadowed local binding?" Callers compose it with the other utils
- * above when they need a more specific question (see e.g. `template-no-
- * noninteractive-tabindex`, which consults both this and
- * `html-interactive-content`).
+ * or a shadowed local binding?" Callers should combine it with whatever
+ * accessibility, interactivity, or focusability checks they need for more
+ * specific questions.
*
* Returns false for:
* - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced —
diff --git a/package.json b/package.json
index ea76c590a3..b75bb22bed 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "eslint-plugin-ember",
- "version": "13.1.3",
+ "version": "13.2.0",
"description": "ESLint plugin for Ember.js apps",
"keywords": [
"eslint",
@@ -67,15 +67,17 @@
"axobject-query": "^4.1.0",
"css-tree": "^3.0.1",
"editorconfig": "^3.0.2",
- "ember-eslint-parser": "^0.11.2",
+ "ember-eslint-parser": "^0.11.3",
"ember-rfc176-data": "^0.3.18",
"eslint-utils": "^3.0.0",
"estraverse": "^5.3.0",
"html-tags": "^3.3.1",
+ "html-void-elements": "^3.0.0",
"language-tags": "^1.0.9",
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"mathml-tag-names": "^4.0.0",
+ "property-information": "^7.1.0",
"requireindex": "^1.2.0",
"snake-case": "^3.0.3",
"svg-tags": "^1.0.0"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3df7119c27..6140b319e9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -24,8 +24,8 @@ importers:
specifier: ^3.0.2
version: 3.0.2
ember-eslint-parser:
- specifier: ^0.11.2
- version: 0.11.2(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3)
+ specifier: ^0.11.3
+ version: 0.11.3(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3)
ember-rfc176-data:
specifier: ^0.3.18
version: 0.3.18
@@ -38,6 +38,9 @@ importers:
html-tags:
specifier: ^3.3.1
version: 3.3.1
+ html-void-elements:
+ specifier: ^3.0.0
+ version: 3.0.0
language-tags:
specifier: ^1.0.9
version: 1.0.9
@@ -50,6 +53,9 @@ importers:
mathml-tag-names:
specifier: ^4.0.0
version: 4.0.0
+ property-information:
+ specifier: ^7.1.0
+ version: 7.1.0
requireindex:
specifier: ^1.2.0
version: 1.2.0
@@ -1755,8 +1761,8 @@ packages:
electron-to-chromium@1.5.344:
resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
- ember-eslint-parser@0.11.2:
- resolution: {integrity: sha512-q38xuVA6OAYJU9zEyUazW9snG7igRWp62KfvJYoF191DHwzLnYnZzVd8u4iFS86s5RHPH1AAhVHJ+oqVTM3oOQ==}
+ ember-eslint-parser@0.11.3:
+ resolution: {integrity: sha512-tGLDVyemseglpXyt3MMnggWL2ROYglRGoL0lKVy+GySFcrD4YQbt5iyvrY9hcEnN62gmkFLIFRinIq5niqYKXg==}
engines: {node: '>=16.0.0'}
peerDependencies:
'@babel/eslint-parser': ^7.28.6
@@ -1767,8 +1773,8 @@ packages:
'@typescript-eslint/parser':
optional: true
- ember-estree@0.6.2:
- resolution: {integrity: sha512-CeKa6FZ95jp6de+vjhmv2XmOGBB2V95iiH7RqI2av1nU0m1XhFZEG8npoEciklQqPEzFO5OXS41b0mGvXBsJCA==}
+ ember-estree@0.6.3:
+ resolution: {integrity: sha512-76NgApjUCvGHd6eC5BueOSwDdXtyO/+zSrZuKaWdRm3EyIbtE9ysvCOsm1cft8zl6dxdqeJpNpTJ5a1ljVIFMg==}
ember-rfc176-data@0.3.18:
resolution: {integrity: sha512-JtuLoYGSjay1W3MQAxt3eINWXNYYQliK90tLwtb8aeCuQK8zKGCRbBodVIrkcTqshULMnRuTOS6t1P7oQk3g6Q==}
@@ -2313,6 +2319,9 @@ packages:
resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==}
engines: {node: '>=20.10'}
+ html-void-elements@3.0.0:
+ resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+
http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@@ -3394,6 +3403,9 @@ packages:
resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
engines: {node: '>=10'}
+ property-information@7.1.0:
+ resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
@@ -5707,12 +5719,12 @@ snapshots:
electron-to-chromium@1.5.344: {}
- ember-eslint-parser@0.11.2(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3):
+ ember-eslint-parser@0.11.3(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3):
dependencies:
'@glimmer/syntax': 0.95.0
'@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3)
content-tag: 4.1.1
- ember-estree: 0.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ ember-estree: 0.6.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
eslint-scope: 9.1.2
html-tags: 5.1.0
mathml-tag-names: 4.0.0
@@ -5725,7 +5737,7 @@ snapshots:
- '@emnapi/runtime'
- typescript
- ember-estree@0.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
+ ember-estree@0.6.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
'@glimmer/env': 0.1.7
'@glimmer/syntax': 0.95.0
@@ -6447,6 +6459,8 @@ snapshots:
html-tags@5.1.0: {}
+ html-void-elements@3.0.0: {}
+
http-cache-semantics@4.2.0: {}
http-proxy-agent@4.0.1:
@@ -7693,6 +7707,8 @@ snapshots:
err-code: 2.0.3
retry: 0.12.0
+ property-information@7.1.0: {}
+
proto-list@1.2.4: {}
psl@1.15.0:
diff --git a/tests/lib/rules/template-no-aria-label-misuse.js b/tests/lib/rules/template-no-aria-label-misuse.js
new file mode 100644
index 0000000000..9be7b60c06
--- /dev/null
+++ b/tests/lib/rules/template-no-aria-label-misuse.js
@@ -0,0 +1,151 @@
+const rule = require('../../../lib/rules/template-no-aria-label-misuse');
+const RuleTester = require('eslint').RuleTester;
+
+const err = (attr, tag, role) =>
+ `\`${attr}\` is prohibited on \`<${tag}>\` (role \`${role}\`). Elements with this role are not named from author; the attribute is ignored by assistive tech.`;
+
+const validHbs = [
+ '',
+ '',
+ '',
+ '',
+ 'link',
+ '',
+ '',
+ '',
+ '',
+ 'x
',
+ 'x
',
+ '',
+ // role="presentation" / "none" — author opted out; nothing to lint.
+ 'x
',
+ 'x',
+ // First *recognised* token is "none"/"presentation" — still decorative.
+ 'x
',
+ 'x
',
+ // Tabindex escape hatch: real screen readers read aria-label on a
+ // tabindexed element even when the implicit role is generic.
+ 'x',
+ 'x
',
+ // No aria-label/labelledby, or only empty values.
+ 'plain
',
+ 'text',
+ '',
+ // Mustache with a static empty string — treated as empty, not as a label.
+ '',
+ '',
+ // Ember component — skipped (role unknowable).
+ '',
+ // Custom elements — not in HTML/SVG/MathML tag lists, skipped.
+ '',
+ '',
+ // Colon-namespaced tags (SVG DOM prefix form) — not enumerable, skipped.
+ '',
+ // Named blocks (Ember component API) — skipped.
+ '<:slot aria-label="x" />',
+ // Elements with no aria-query entry — skipped ("when in doubt, don't flag").
+ '',
+ '',
+ // transitions to role=region when aria-label is present.
+ '',
+ // ',
+ // Dynamic role — runtime role may differ from implicit; skip rather than
+ // false-flag against the element's implicit role.
+ 'x
',
+ 'x',
+ // Role-fallback: UAs walk unknown leading tokens to the first recognised
+ // role per WAI-ARIA §4.1. `role="xxyxyz button"` resolves to `button`,
+ // which accepts aria-label — should not flag.
+ 'x
',
+ // Valueless aria-label carries no name — treated as empty, not flagged as misuse.
+ 'x
',
+ // Static string-literal mustache — empty string is treated as no label.
+ 'x
',
+ // Bare-mustache falsy on aria-label (rows h6, h9, h10) — Glimmer omits the
+ // attribute at runtime, so there is NO aria-label and no misuse to flag.
+ 'x
',
+ 'x
',
+ 'x
',
+ // Bare-mustache falsy on tabindex (rows t6, t7) — escape hatch should NOT
+ // fire because tabindex isn't actually rendered. The element is back to
+ // having a non-interactive implicit role and aria-label IS a misuse.
+ // (paired with an aria-label to ensure it gets flagged for the right reason)
+];
+
+const invalidHbs = [
+ {
+ code: 'x
',
+ errors: [{ message: err('aria-label', 'div', 'generic') }],
+ },
+ {
+ code: 'x',
+ errors: [{ message: err('aria-labelledby', 'span', 'generic') }],
+ },
+ {
+ code: 'text
',
+ errors: [{ message: err('aria-label', 'p', 'paragraph') }],
+ },
+ {
+ code: 'x',
+ errors: [{ message: err('aria-label', 'a', 'generic') }],
+ },
+ {
+ code: 'x
',
+ errors: [{ message: err('aria-label', 'div', 'generic') }],
+ },
+ // Bare-mustache falsy on tabindex (rows t6, t7) — escape hatch shouldn't
+ // fire (tabindex omitted at runtime). aria-label is misuse on a generic.
+ {
+ code: 'x
',
+ errors: [{ message: err('aria-label', 'div', 'generic') }],
+ },
+ {
+ code: 'x
',
+ errors: [{ message: err('aria-label', 'div', 'generic') }],
+ },
+ //
is role=presentation per ARIA; aria-label contradicts the
+ // "decorative" hint and is prohibited.
+ {
+ code: '
',
+ errors: [{ message: err('aria-label', 'img', 'presentation') }],
+ },
+ // With strictTabindex: true, tabindex no longer exempts a generic element.
+ {
+ code: 'x',
+ options: [{ strictTabindex: true }],
+ errors: [{ message: err('aria-label', 'span', 'generic') }],
+ },
+ {
+ code: 'x
',
+ options: [{ strictTabindex: true }],
+ errors: [{ message: err('aria-label', 'div', 'generic') }],
+ },
+];
+
+const gjsValid = validHbs.map((code) => `${code}`);
+const gjsInvalid = invalidHbs.map(({ code, errors, options }) => ({
+ code: `${code}`,
+ errors,
+ ...(options ? { options } : {}),
+}));
+
+const gjsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+gjsRuleTester.run('template-no-aria-label-misuse', rule, {
+ valid: gjsValid,
+ invalid: gjsInvalid,
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+hbsRuleTester.run('template-no-aria-label-misuse', rule, {
+ valid: validHbs,
+ invalid: invalidHbs,
+});
diff --git a/tests/lib/rules/template-require-input-type.js b/tests/lib/rules/template-require-input-type.js
new file mode 100644
index 0000000000..5f8fa3e3e5
--- /dev/null
+++ b/tests/lib/rules/template-require-input-type.js
@@ -0,0 +1,129 @@
+const rule = require('../../../lib/rules/template-require-input-type');
+const RuleTester = require('eslint').RuleTester;
+
+const ERROR_MISSING = 'All `` elements should have a `type` attribute';
+const errInvalid = (value) => `\`\` is not a valid input type`;
+
+const validHbs = [
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ // Default (requireExplicit=false): missing `type` is allowed.
+ '',
+ '',
+];
+
+const invalidHbs = [
+ {
+ code: '',
+ output: '',
+ errors: [{ message: errInvalid('') }],
+ },
+ {
+ code: '',
+ output: '',
+ errors: [{ message: errInvalid('foo') }],
+ },
+ {
+ code: '',
+ output: '',
+ errors: [{ message: errInvalid('TEXTY') }],
+ },
+ // Valueless type attribute — per HTML spec resolves to the missing-value
+ // default (Text state), same runtime result as `type=""`. Flag and autofix
+ // to `type="text"`. (Output loses the pre-slash space because the
+ // valueless attr range ends at `type`; prettier will re-insert if needed.)
+ {
+ code: '',
+ output: '',
+ errors: [{ message: errInvalid('') }],
+ },
+];
+
+const requireExplicitInvalid = [
+ {
+ code: '',
+ options: [{ requireExplicit: true }],
+ output: '',
+ errors: [{ message: ERROR_MISSING }],
+ },
+ {
+ code: '',
+ options: [{ requireExplicit: true }],
+ output: '',
+ errors: [{ message: ERROR_MISSING }],
+ },
+ {
+ code: '',
+ options: [{ requireExplicit: true }],
+ output: '',
+ errors: [{ message: ERROR_MISSING }],
+ },
+];
+
+const requireExplicitValid = [
+ // With requireExplicit: an explicit known type satisfies the rule.
+ { code: '', options: [{ requireExplicit: true }] },
+ // Dynamic type also satisfies — we can't know the runtime value.
+ { code: '', options: [{ requireExplicit: true }] },
+];
+
+const gjsValid = validHbs.map((code) => `${code}`);
+const gjsInvalid = invalidHbs.map(({ code, output, errors }) => ({
+ code: `${code}`,
+ output: `${output}`,
+ errors,
+}));
+
+const gjsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+gjsRuleTester.run('template-require-input-type', rule, {
+ valid: [
+ ...gjsValid,
+ ...requireExplicitValid.map(({ code, options }) => ({
+ code: `${code}`,
+ options,
+ })),
+ // Scope-shadowed `input` — the template's `` refers to the local
+ // const binding (a component), not the native HTML element. The rule
+ // skips it via `isNativeElement`'s scope check.
+ `const input = 'foo';
+`,
+ `const input = 'foo';
+`,
+ // Block-param shadowing — `` binds `input` inside the
+ // yield block. The inner `` should resolve to the block-param,
+ // not the native tag.
+ `import Foo from 'whatever';
+`,
+ ],
+ invalid: [
+ ...gjsInvalid,
+ ...requireExplicitInvalid.map(({ code, options, output, errors }) => ({
+ code: `${code}`,
+ options,
+ output: `${output}`,
+ errors,
+ })),
+ ],
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+hbsRuleTester.run('template-require-input-type', rule, {
+ valid: [...validHbs, ...requireExplicitValid],
+ invalid: [...invalidHbs, ...requireExplicitInvalid],
+});
diff --git a/tests/lib/utils/glimmer-attr-presence-test.js b/tests/lib/utils/glimmer-attr-presence-test.js
new file mode 100644
index 0000000000..ba8560cd01
--- /dev/null
+++ b/tests/lib/utils/glimmer-attr-presence-test.js
@@ -0,0 +1,389 @@
+'use strict';
+
+const { classifyAttribute, inferAttrKind } = require('../../../lib/utils/glimmer-attr-presence');
+
+// Helpers to build minimal AttrNode-shaped objects for tests.
+function attr(name, value) {
+ return { name, value };
+}
+function textNode(chars) {
+ return { type: 'GlimmerTextNode', chars };
+}
+function bareMustache(path) {
+ return { type: 'GlimmerMustacheStatement', path };
+}
+function concat(parts) {
+ return { type: 'GlimmerConcatStatement', parts };
+}
+const boolLit = (v) => ({ type: 'GlimmerBooleanLiteral', value: v });
+const stringLit = (v) => ({ type: 'GlimmerStringLiteral', value: v });
+const numberLit = (v) => ({ type: 'GlimmerNumberLiteral', value: v });
+const nullLit = () => ({ type: 'GlimmerNullLiteral' });
+const undefinedLit = () => ({ type: 'GlimmerUndefinedLiteral' });
+const pathExpr = (original) => ({ type: 'GlimmerPathExpression', original });
+
+describe('inferAttrKind', () => {
+ it('classifies known HTML boolean attrs', () => {
+ expect(inferAttrKind('disabled')).toBe('boolean');
+ expect(inferAttrKind('muted')).toBe('boolean');
+ expect(inferAttrKind('autoplay')).toBe('boolean');
+ expect(inferAttrKind('hidden')).toBe('boolean');
+ expect(inferAttrKind('controls')).toBe('boolean');
+ });
+
+ it('classifies aria-* attributes', () => {
+ expect(inferAttrKind('aria-hidden')).toBe('aria');
+ expect(inferAttrKind('aria-label')).toBe('aria');
+ expect(inferAttrKind('aria-checked')).toBe('aria');
+ });
+
+ it('classifies known numeric attrs', () => {
+ expect(inferAttrKind('tabindex')).toBe('numeric');
+ expect(inferAttrKind('colspan')).toBe('numeric');
+ });
+
+ it('treats role as plain-string (not aria-coerced)', () => {
+ // Despite living conceptually with ARIA, role is a plain DOM string
+ // attribute and does NOT participate in falsy-coercion (per the
+ // cross-attribute observations in docs/glimmer-attribute-behavior.md).
+ expect(inferAttrKind('role')).toBe('plain-string');
+ });
+
+ it('classifies unknown attrs as plain-string', () => {
+ expect(inferAttrKind('autocomplete')).toBe('plain-string');
+ expect(inferAttrKind('href')).toBe('plain-string');
+ expect(inferAttrKind('id')).toBe('plain-string');
+ expect(inferAttrKind('for')).toBe('plain-string');
+ expect(inferAttrKind('type')).toBe('plain-string');
+ });
+
+ it('is case-insensitive (HTML attribute names are case-insensitive)', () => {
+ expect(inferAttrKind('Disabled')).toBe('boolean');
+ expect(inferAttrKind('MUTED')).toBe('boolean');
+ expect(inferAttrKind('TabIndex')).toBe('numeric');
+ expect(inferAttrKind('ARIA-Hidden')).toBe('aria');
+ expect(inferAttrKind('Aria-Label')).toBe('aria');
+ });
+});
+
+describe('classifyAttribute', () => {
+ describe('absent attribute', () => {
+ it('returns absent for null/undefined attrNode', () => {
+ expect(classifyAttribute(null)).toEqual({ presence: 'absent', value: null });
+ expect(classifyAttribute(undefined)).toEqual({ presence: 'absent', value: null });
+ });
+ });
+
+ describe('valueless attribute', () => {
+ it('returns present with empty string (doc rows d1, h1)', () => {
+ expect(classifyAttribute(attr('disabled', null))).toEqual({
+ presence: 'present',
+ value: '',
+ });
+ expect(classifyAttribute(attr('aria-hidden', undefined))).toEqual({
+ presence: 'present',
+ value: '',
+ });
+ });
+ });
+
+ describe('GlimmerTextNode (static text)', () => {
+ it('returns present with literal chars (doc rows m1-m4, h2-h4, d1, i1)', () => {
+ expect(classifyAttribute(attr('aria-hidden', textNode('true')))).toEqual({
+ presence: 'present',
+ value: 'true',
+ });
+ expect(classifyAttribute(attr('autocomplete', textNode('off')))).toEqual({
+ presence: 'present',
+ value: 'off',
+ });
+ expect(classifyAttribute(attr('muted', textNode('false')))).toEqual({
+ presence: 'present',
+ value: 'false',
+ });
+ });
+ });
+
+ describe('bare-mustache {{true}} / {{false}}', () => {
+ it('{{false}} on boolean attr → absent (doc rows m6, d3)', () => {
+ expect(classifyAttribute(attr('muted', bareMustache(boolLit(false))))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ expect(classifyAttribute(attr('disabled', bareMustache(boolLit(false))))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ });
+
+ it('{{false}} on aria attr → absent (doc row h6)', () => {
+ expect(classifyAttribute(attr('aria-hidden', bareMustache(boolLit(false))))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ });
+
+ it('{{false}} on numeric attr → absent (doc row t6)', () => {
+ expect(classifyAttribute(attr('tabindex', bareMustache(boolLit(false))))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ });
+
+ it('{{false}} on plain-string attr → present "false" (doc row i4)', () => {
+ expect(classifyAttribute(attr('autocomplete', bareMustache(boolLit(false))))).toEqual({
+ presence: 'present',
+ value: 'false',
+ });
+ // role is plain-string (not aria-coerced) — bare {{false}} renders literal
+ expect(classifyAttribute(attr('role', bareMustache(boolLit(false))))).toEqual({
+ presence: 'present',
+ value: 'false',
+ });
+ });
+
+ it('{{true}} on aria attr → present "" (doc row h5: renders aria-hidden="")', () => {
+ expect(classifyAttribute(attr('aria-hidden', bareMustache(boolLit(true))))).toEqual({
+ presence: 'present',
+ value: '',
+ });
+ });
+
+ it('{{true}} on boolean → present "true" (verified m5, d2)', () => {
+ expect(classifyAttribute(attr('disabled', bareMustache(boolLit(true))))).toEqual({
+ presence: 'present',
+ value: 'true',
+ });
+ expect(classifyAttribute(attr('muted', bareMustache(boolLit(true))))).toEqual({
+ presence: 'present',
+ value: 'true',
+ });
+ });
+
+ it('{{true}} on numeric / plain-string → unknown (untested in doc)', () => {
+ expect(classifyAttribute(attr('tabindex', bareMustache(boolLit(true))))).toEqual({
+ presence: 'unknown',
+ value: null,
+ });
+ expect(classifyAttribute(attr('autocomplete', bareMustache(boolLit(true))))).toEqual({
+ presence: 'unknown',
+ value: null,
+ });
+ });
+ });
+
+ describe('bare-mustache {{null}} / {{undefined}}', () => {
+ it('{{null}} on falsy-coerced kinds → absent (doc rows m9, d6, h9, t7)', () => {
+ expect(classifyAttribute(attr('muted', bareMustache(nullLit())))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ expect(classifyAttribute(attr('disabled', bareMustache(nullLit())))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ expect(classifyAttribute(attr('aria-hidden', bareMustache(nullLit())))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ expect(classifyAttribute(attr('tabindex', bareMustache(nullLit())))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ });
+
+ it('{{undefined}} on falsy-coerced kinds → absent (doc rows m10, h10)', () => {
+ expect(classifyAttribute(attr('muted', bareMustache(undefinedLit())))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ expect(classifyAttribute(attr('aria-hidden', bareMustache(undefinedLit())))).toEqual({
+ presence: 'absent',
+ value: null,
+ });
+ });
+
+ it('{{null}} / {{undefined}} on plain-string → unknown (untested in doc)', () => {
+ expect(classifyAttribute(attr('autocomplete', bareMustache(nullLit())))).toEqual({
+ presence: 'unknown',
+ value: null,
+ });
+ expect(classifyAttribute(attr('autocomplete', bareMustache(undefinedLit())))).toEqual({
+ presence: 'unknown',
+ value: null,
+ });
+ });
+ });
+
+ describe('bare-mustache string literal {{"x"}}', () => {
+ it('renders the literal value across all kinds (doc rows m7, m8, h7, h8, d4, d5, i2)', () => {
+ expect(classifyAttribute(attr('muted', bareMustache(stringLit('false'))))).toEqual({
+ presence: 'present',
+ value: 'false',
+ });
+ expect(classifyAttribute(attr('aria-hidden', bareMustache(stringLit('true'))))).toEqual({
+ presence: 'present',
+ value: 'true',
+ });
+ expect(classifyAttribute(attr('disabled', bareMustache(stringLit('false'))))).toEqual({
+ presence: 'present',
+ value: 'false',
+ });
+ expect(classifyAttribute(attr('autocomplete', bareMustache(stringLit('off'))))).toEqual({
+ presence: 'present',
+ value: 'off',
+ });
+ });
+
+ it('handles empty string literal {{""}} as present empty', () => {
+ // Doc row m11: