diff --git a/.release-plan.json b/.release-plan.json index 83a94f63a8..a24cc968e3 100644 --- a/.release-plan.json +++ b/.release-plan.json @@ -1,22 +1,22 @@ { "solution": { "eslint-plugin-ember": { - "impact": "patch", - "oldVersion": "13.1.2", - "newVersion": "13.1.3", + "impact": "minor", + "oldVersion": "13.1.4", + "newVersion": "13.2.0", "tagName": "latest", "constraints": [ { - "impact": "patch", - "reason": "Appears in changelog section :bug: Bug Fix" + "impact": "minor", + "reason": "Appears in changelog section :rocket: Enhancement" }, { "impact": "patch", - "reason": "Appears in changelog section :house: Internal" + "reason": "Appears in changelog section :bug: Bug Fix" } ], "pkgJSONPath": "./package.json" } }, - "description": "## Release (2026-04-25)\n\n* eslint-plugin-ember 13.1.3 (patch)\n\n#### :bug: Bug Fix\n* `eslint-plugin-ember`\n * [#2730](https://github.com/ember-cli/eslint-plugin-ember/pull/2730) BUGFIX: template-require-valid-alt-text — reject empty-string aria-label/labelledby/alt on , , ([@johanrd](https://github.com/johanrd))\n * [#2729](https://github.com/ember-cli/eslint-plugin-ember/pull/2729) BUGFIX: template-no-invalid-role — support DPUB/Graphics-ARIA and role-fallback lists ([@johanrd](https://github.com/johanrd))\n * [#2726](https://github.com/ember-cli/eslint-plugin-ember/pull/2726) BUGFIX: template-no-unsupported-role-attributes — honor aria-query attribute constraints ([@johanrd](https://github.com/johanrd))\n * [#2727](https://github.com/ember-cli/eslint-plugin-ember/pull/2727) BUGFIX: template-no-redundant-role — case-insensitive match + →combobox ([@johanrd](https://github.com/johanrd))\n\n#### :house: Internal\n* `eslint-plugin-ember`\n * [#2746](https://github.com/ember-cli/eslint-plugin-ember/pull/2746) refactor: extract isNativeElement util (fix component-vs-HTML-tag misclassification) ([@johanrd](https://github.com/johanrd))\n\n#### Committers: 1\n- [@johanrd](https://github.com/johanrd)\n" + "description": "## Release (2026-04-28)\n\n* eslint-plugin-ember 13.2.0 (minor)\n\n#### :rocket: Enhancement\n* `eslint-plugin-ember`\n * [#2763](https://github.com/ember-cli/eslint-plugin-ember/pull/2763) feat: add template-require-input-type ([@johanrd](https://github.com/johanrd))\n\n#### :bug: Bug Fix\n* `eslint-plugin-ember`\n * [#2766](https://github.com/ember-cli/eslint-plugin-ember/pull/2766) Update parser ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### Committers: 2\n- [@NullVoxPopuli](https://github.com/NullVoxPopuli)\n- [@johanrd](https://github.com/johanrd)\n" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1126527ef6..7ff7def2ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## Release (2026-04-28) + +* eslint-plugin-ember 13.2.0 (minor) + +#### :rocket: Enhancement +* `eslint-plugin-ember` + * [#2763](https://github.com/ember-cli/eslint-plugin-ember/pull/2763) feat: add template-require-input-type ([@johanrd](https://github.com/johanrd)) + +#### :bug: Bug Fix +* `eslint-plugin-ember` + * [#2766](https://github.com/ember-cli/eslint-plugin-ember/pull/2766) Update parser ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) + +#### Committers: 2 +- [@NullVoxPopuli](https://github.com/NullVoxPopuli) +- [@johanrd](https://github.com/johanrd) + +## Release (2026-04-27) + +* eslint-plugin-ember 13.1.4 (patch) + +#### :bug: Bug Fix +* `eslint-plugin-ember` + * [#2752](https://github.com/ember-cli/eslint-plugin-ember/pull/2752) Update ember-eslint-parser to 0.11.2 ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) + * [#2728](https://github.com/ember-cli/eslint-plugin-ember/pull/2728) BUGFIX: template-require-mandatory-role-attributes — lowercase role + split whitespace role lists ([@johanrd](https://github.com/johanrd)) + +#### :house: Internal +* `eslint-plugin-ember` + * [#2748](https://github.com/ember-cli/eslint-plugin-ember/pull/2748) refactor: extract `html-interactive-content` util (HTML §3.2.5.2.7 authority) ([@johanrd](https://github.com/johanrd)) + +#### Committers: 2 +- [@NullVoxPopuli](https://github.com/NullVoxPopuli) +- [@johanrd](https://github.com/johanrd) + ## Release (2026-04-25) * eslint-plugin-ember 13.1.3 (patch) diff --git a/README.md b/README.md index 406ed89301..3c58a4fd69 100644 --- a/README.md +++ b/README.md @@ -256,40 +256,41 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le ### Accessibility -| Name | Description | 💼 | 🔧 | 💡 | -| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- | -| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | | -| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | | -| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | | -| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | 📋 | 🔧 | | -| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | 📋 | | | -| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | 📋 | 🔧 | | -| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | 📋 | | | -| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | 📋 | | | -| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | 📋 | | | -| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | 📋 | | | -| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | 📋 | | | -| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | 📋 | | | -| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | 📋 | | | -| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | 📋 | | | -| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | 📋 | | | -| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | 📋 | | | -| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | 📋 | | | -| [template-no-positive-tabindex](docs/rules/template-no-positive-tabindex.md) | disallow positive tabindex values | 📋 | | | -| [template-no-redundant-role](docs/rules/template-no-redundant-role.md) | disallow redundant role attributes | 📋 | 🔧 | | -| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | 📋 | 🔧 | | -| [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | 📋 | | | -| [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | 📋 | 🔧 | | -| [template-require-context-role](docs/rules/template-require-context-role.md) | require ARIA roles to be used in appropriate context | 📋 | | | -| [template-require-iframe-title](docs/rules/template-require-iframe-title.md) | require iframe elements to have a title attribute | 📋 | | | -| [template-require-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | 📋 | | | -| [template-require-lang-attribute](docs/rules/template-require-lang-attribute.md) | require lang attribute on html element | 📋 | | | -| [template-require-mandatory-role-attributes](docs/rules/template-require-mandatory-role-attributes.md) | require mandatory ARIA attributes for ARIA roles | 📋 | | | -| [template-require-media-caption](docs/rules/template-require-media-caption.md) | require captions for audio and video elements | 📋 | | | -| [template-require-presentational-children](docs/rules/template-require-presentational-children.md) | require presentational elements to only contain presentational children | 📋 | | | -| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | 📋 | | | -| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | | -| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | 📋 | | | +| Name | Description | 💼 | 🔧 | 💡 | +| :----------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- | +| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | | +| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | | +| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | | +| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | 📋 | 🔧 | | +| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | 📋 | | | +| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | 📋 | 🔧 | | +| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | 📋 | | | +| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | 📋 | | | +| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | 📋 | | | +| [template-no-interactive-element-to-noninteractive-role](docs/rules/template-no-interactive-element-to-noninteractive-role.md) | disallow native interactive elements being assigned non-interactive ARIA roles | | | | +| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | 📋 | | | +| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | 📋 | | | +| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | 📋 | | | +| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | 📋 | | | +| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | 📋 | | | +| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | 📋 | | | +| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | 📋 | | | +| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | 📋 | | | +| [template-no-positive-tabindex](docs/rules/template-no-positive-tabindex.md) | disallow positive tabindex values | 📋 | | | +| [template-no-redundant-role](docs/rules/template-no-redundant-role.md) | disallow redundant role attributes | 📋 | 🔧 | | +| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | 📋 | 🔧 | | +| [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | 📋 | | | +| [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | 📋 | 🔧 | | +| [template-require-context-role](docs/rules/template-require-context-role.md) | require ARIA roles to be used in appropriate context | 📋 | | | +| [template-require-iframe-title](docs/rules/template-require-iframe-title.md) | require iframe elements to have a title attribute | 📋 | | | +| [template-require-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | 📋 | | | +| [template-require-lang-attribute](docs/rules/template-require-lang-attribute.md) | require lang attribute on html element | 📋 | | | +| [template-require-mandatory-role-attributes](docs/rules/template-require-mandatory-role-attributes.md) | require mandatory ARIA attributes for ARIA roles | 📋 | | | +| [template-require-media-caption](docs/rules/template-require-media-caption.md) | require captions for audio and video elements | 📋 | | | +| [template-require-presentational-children](docs/rules/template-require-presentational-children.md) | require presentational elements to only contain presentational children | 📋 | | | +| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | 📋 | | | +| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | | +| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | 📋 | | | ### Best Practices @@ -358,6 +359,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le | [template-require-form-method](docs/rules/template-require-form-method.md) | require form method attribute | | 🔧 | | | [template-require-has-block-helper](docs/rules/template-require-has-block-helper.md) | require (has-block) helper usage instead of hasBlock property | 📋 | 🔧 | | | [template-require-iframe-src-attribute](docs/rules/template-require-iframe-src-attribute.md) | require iframe elements to have src attribute | | 🔧 | | +| [template-require-input-type](docs/rules/template-require-input-type.md) | require input elements to have a valid type attribute | | 🔧 | | | [template-require-splattributes](docs/rules/template-require-splattributes.md) | require splattributes usage in component templates | | | | | [template-require-strict-mode](docs/rules/template-require-strict-mode.md) | require templates to be in strict mode | | | | | [template-require-valid-named-block-naming-format](docs/rules/template-require-valid-named-block-naming-format.md) | require valid named block naming format | 📋 | 🔧 | | @@ -583,6 +585,8 @@ If you have any suggestions, ideas, or problems, feel free to [create an issue]( ### Creating a New Rule +If your rule inspects template attribute values (e.g. mustache forms like `attr={{X}}` or `attr="{{X}}"`), read [docs/glimmer-attribute-behavior.md](docs/glimmer-attribute-behavior.md) first — Glimmer's actual rendering behavior is non-obvious for several common forms, and the doc has the empirically-verified table. + - [Create an issue](https://github.com/ember-cli/eslint-plugin-ember/issues/new) with a description of the proposed rule - Create files for the [new rule](https://eslint.org/docs/developer-guide/working-with-rules): - `lib/rules/new-rule.js` (implementation, see [no-proxies](lib/rules/no-proxies.js) for an example) diff --git a/docs/glimmer-attribute-behavior.md b/docs/glimmer-attribute-behavior.md new file mode 100644 index 0000000000..f0b47761d0 --- /dev/null +++ b/docs/glimmer-attribute-behavior.md @@ -0,0 +1,330 @@ +# Glimmer attribute rendering behavior + +Reference for rule authors. Most template lint rules need to answer: "given an attribute written as `attr={{X}}` or `attr="{{X}}"` or `attr="value"`, what does it actually do at runtime?" The answer is non-obvious — Glimmer's bare-mustache and concat-mustache forms coerce values differently per attribute kind, HTML serialization can disagree with the live IDL property, and several intuitive mental models are wrong. + +This doc captures empirically-verified rendering behavior. Every cell in every table below comes from rendering the template under [§ To reproduce the reference table](#to-reproduce-the-reference-table) in an Ember dev app and inspecting `outerHTML` plus the relevant IDL property in devtools. **No extrapolation.** If a form isn't in a table, it hasn't been verified — extend the reproduction template and add the row. + +> Read this before writing or modifying any rule that inspects attribute values via `GlimmerBooleanLiteral`, `GlimmerStringLiteral`, `GlimmerConcatStatement`, or `GlimmerTextNode`. + +## Reference table + +Five per-attribute tables, one per representative attribute kind. IDs (`m1`–`m19`, `h1`–`h15`, `d1`–`d10`, `t1`–`t7`, `i1`–`i5`) cross-reference the reproduction template below. + +### `` + `muted` — boolean HTML attribute, non-reflecting + +`muted` is an HTML boolean attribute on ``. The IDL `videoEl.muted` is the **live** muted state (independent of the content attribute); `videoEl.defaultMuted` is what reflects. At media-load time the user agent sets `muted` from `defaultMuted`. The "IDL muted" column below is read **before media load**, so for cases where only the HTML attribute is set, IDL muted reads false at that snapshot but becomes true once the media loads. The "At play time" column derives the value the rule cares about: is the audio actually muted when autoplay starts? + +| ID | Source | outerHTML | IDL `muted` (preload) | hasAttr | At play time | +| --- | ------------------------------------------ | ------------------------------- | --------------------- | ------- | ------------------------------------------------------------- | +| m1 | `` | `` | `false` | `true` | **muted ON** (defaultMuted via attr) | +| m2 | `` | `` | `false` | `true` | **muted ON** | +| m3 | `` | `` | `false` | `true` | **muted ON** | +| m4 | `` | `` | `false` | `true` | **muted ON** (HTML boolean-attr presence) | +| m5 | `` | `` | `true` | `false` | **muted ON** (IDL set directly) | +| m6 | `` | `` | `false` | `false` | **muted OFF** | +| m7 | `` | `` | `false` | `true` | **muted ON** | +| m8 | `` | `` | `false` | `true` | **muted ON** (HTML boolean-attr presence) | +| m9 | `` | `` | `false` | `false` | **muted OFF** | +| m10 | `` | `` | `false` | `false` | **muted OFF** | +| m11 | `` | `` | `false` | `true` | **muted ON** | +| m12 | `` | `` | `false` | `false` | **muted OFF** | +| m13 | `` | `` | `true` | `false` | **muted ON** (concat sets IDL true) | +| m14 | `` | `` | `true` | `false` | **muted ON** (concat sets IDL true regardless of inner value) | +| m15 | `` | `` | `true` | `false` | **muted ON** | +| m16 | `` | `` | `true` | `false` | **muted ON** | +| m17 | `` | `` | `true` | `false` | **muted ON** | +| m18 | `` | `` | `true` | `false` | **muted ON** | +| m19 | `` | `` | `true` | `false` | **muted ON** | + +**Lint truth for `muted`:** OFF iff the source is bare `{{false}}` / `{{null}}` / `{{undefined}}` / `{{0}}`. Every other form is ON at play time — including the surprising ones (any concat regardless of literal value; bare string `"false"`). + +### `` + `disabled` — boolean HTML attribute, reflecting + +Same boolean-coercion behavior as `muted` for the bare-mustache form, but `disabled` reflects: when the IDL `disabled` is set, the HTML attribute appears as `disabled=""`. + +| ID | Source | outerHTML | IDL `disabled` | hasAttr | At runtime | +| --- | ---------------------------------- | -------------------------- | -------------- | ------- | -------------------------------------- | +| d1 | `` | `` | `true` | `true` | **disabled ON** | +| d2 | `` | `` | `true` | `true` | **disabled ON** | +| d3 | `` | `` | `false` | `false` | **disabled OFF** | +| d4 | `` | `` | `true` | `true` | **disabled ON** | +| d5 | `` | `` | `true` | `true` | **disabled ON** | +| d6 | `` | `` | `false` | `false` | **disabled OFF** | +| d7 | `` | `` | `true` | `true` | **disabled ON** (concat sets IDL true) | +| d8 | `` | `` | `true` | `true` | **disabled ON** | +| d9 | `` | `` | `true` | `true` | **disabled ON** | +| d10 | `` | `` | `true` | `true` | **disabled ON** | + +**Lint truth for `disabled`:** identical falsy-set as `muted` — bare `{{false}}` / `{{null}}` (and by inference `{{undefined}}` / `{{0}}`, not yet tested for this attribute). Every other form is ON. The HTML serialization differs from `muted` (because `disabled` reflects), but the boolean lint truth is the same. + +### `` + `aria-hidden` — ARIA string attribute + +ARIA attributes are string-valued, but Glimmer's bare-mustache form applies the same falsy-coercion as for boolean HTML attributes (omit on `false`/`null`/`undefined`). Bare-string and concat forms render the value literally — concat does **not** coerce to a boolean here. The "At runtime (per ARIA spec)" column derives whether the element is hidden from assistive tech: `aria-hidden="true"` is hidden; `aria-hidden="false"` is visible; `aria-hidden=""` (and the implied default) is contested. + +| ID | Source | outerHTML | IDL `ariaHidden` | hasAttr | At runtime (ARIA) | +| --- | --------------------------------------- | --------------------------------- | ---------------- | ------- | ---------------------------------------------- | +| h1 | `` | `` | `""` | `true` | **contested** (empty value) | +| h2 | `` | `` | `""` | `true` | **contested** | +| h3 | `` | `` | `"true"` | `true` | **hidden** | +| h4 | `` | `` | `"false"` | `true` | **visible** | +| h5 | `` | `` | `""` | `true` | **contested** (rendered empty, _not_ `"true"`) | +| h6 | `` | `` | `null` | `false` | **visible** (default) | +| h7 | `` | `` | `"true"` | `true` | **hidden** | +| h8 | `` | `` | `"false"` | `true` | **visible** | +| h9 | `` | `` | `null` | `false` | **visible** | +| h10 | `` | `` | `null` | `false` | **visible** | +| h11 | `` | `` | `""` | `true` | **contested** | +| h12 | `` | `` | `"true"` | `true` | **hidden** | +| h13 | `` | `` | `"false"` | `true` | **visible** | +| h14 | `` | `` | `"true"` | `true` | **hidden** | +| h15 | `` | `` | `"false"` | `true` | **visible** | + +**Lint truth for `aria-hidden`:** the rule depends on the value, not just presence. Notable differences from boolean attrs: bare `{{true}}` renders as `aria-hidden=""` (contested, not `"true"`); concat `="{{false}}"` renders as `aria-hidden="false"` (visible — _not_ IDL-coerced like boolean attrs). + +### `` + `tabindex` — numeric attribute + +Falsy-coercion (`false`/`null`) omits, like boolean attrs. Numeric and string-numeric values render the literal. IDL `tabIndex` returns `-1` when no attribute is set (the default for non-focusable elements), so `hasAttr` is the cleaner signal for "tabindex is set" than checking `tabIndex`. + +| ID | Source | outerHTML | IDL `tabIndex` | hasAttr | Effective | +| --- | -------------------------------- | --------------------------- | -------------- | ------- | ----------- | +| t1 | `` | `` | `0` | `true` | tabindex 0 | +| t2 | `` | `` | `-1` | `true` | tabindex -1 | +| t3 | `` | `` | `1` | `true` | tabindex 1 | +| t4 | `` | `` | `0` | `true` | tabindex 0 | +| t5 | `` | `` | `0` | `true` | tabindex 0 | +| t6 | `` | `` | `-1` (default) | `false` | not set | +| t7 | `` | `` | `-1` (default) | `false` | not set | + +**Lint truth for `tabindex`:** rules that care about the value should extract the literal from the AST (the value is preserved as-written across all bare and concat literal forms). Rules that care about presence should check that the source is not bare `{{false}}` / `{{null}}` (and by inference `{{undefined}}`, not tested). + +### `` + `autocomplete` — string attribute (not boolean-coerced) + +A regular string attribute. Glimmer's bare-mustache **does not** apply falsy-coercion here — `autocomplete={{false}}` renders as `autocomplete="false"` (kept). This is the key difference from boolean HTML attrs and from `aria-*`. The IDL `el.autocomplete` canonicalizes the attribute value (returns `""` for invalid tokens), so it differs from `getAttribute('autocomplete')` for non-spec values. + +| ID | Source | outerHTML | IDL `autocomplete` | hasAttr | attrValue | +| --- | ------------------------------------ | ------------------------------ | -------------------- | ------- | --------- | +| i1 | `` | `` | `"off"` | `true` | `"off"` | +| i2 | `` | `` | `"off"` | `true` | `"off"` | +| i3 | `` | `` | `"off"` | `true` | `"off"` | +| i4 | `` | `` | `""` (canonicalized) | `true` | `"false"` | +| i5 | `` | `` | `""` (canonicalized) | `true` | `"false"` | + +**Lint truth for `autocomplete`:** rules should check `getAttribute('autocomplete')` (or its AST equivalent) against the spec's valid token list, not the IDL property. The bare-mustache `{{false}}` form will give a literal `"false"` value — almost certainly an authoring bug worth flagging. + +### Cross-attribute observations + +- **Glimmer's bare-mustache "boolean coercion" list.** For `muted` (HTML boolean), `disabled` (HTML boolean), `aria-hidden` (ARIA string), and `tabindex` (numeric), bare `{{false}}` / `{{null}}` / `{{undefined}}` (and `{{0}}` for `muted`) cause the attribute to be **omitted**. For `autocomplete` (plain string), bare `{{false}}` renders as `autocomplete="false"`. So Glimmer applies boolean-coercion to a known set — at minimum HTML boolean attrs, ARIA attrs, and numeric attrs. Plain string attrs do not get coerced. +- **Bare-mustache string literals never coerce.** `attr={{"false"}}`, `attr={{"true"}}`, `attr={{""}}` always render as `attr=""` for every attribute kind tested. The literal `"false"` is JS-truthy and gets passed through. +- **Bare-mustache numeric `0` is in the falsy set for `muted`.** Verified for `muted` (`{{0}}` → omitted). Not yet tested for `disabled` / `aria-hidden` / `autocomplete`. +- **Concat-mustache forks by attribute kind.** For HTML boolean attrs (`muted`, `disabled`), any concat — including `"{{false}}"`, `"{{'false'}}"`, `"x{{false}}"` — sets the IDL property to `true`, regardless of the literal value inside. For ARIA / string attrs (`aria-hidden`, `autocomplete`), concat renders the stringified value as the attribute value (no boolean coercion); `aria-hidden="{{false}}"` becomes `aria-hidden="false"` (visible). +- **Concat is never falsy.** Across all attribute kinds tested, no concat form produces an absent attribute. Rules treating `attr="{{false}}"` as "off" are wrong for boolean attrs (it's IDL-true) and wrong for string attrs (the rendered value is `"false"`, attribute present). + +## Reading attribute values in rules + +Rule authors who classify attribute values must consume the reference table above through one of these AST shapes — each maps to a known rendering verdict: + +| AST shape | Source examples | Verdict | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `attr.value === null` (no value) | `` | Attribute is **present** with empty value (rendered as `attr=""`) — see d1, h1 | +| `attr.value.type === 'GlimmerTextNode'` | `attr="literal text"` | Attribute is **present** with the literal `chars` string — see m1–m4, h2–h4, d1, t-static, i1 | +| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerBooleanLiteral'` and `path.value === true` | `attr={{true}}` | **Reflecting boolean attrs**: present (e.g., `disabled=""`). **Non-reflecting boolean attrs** (`muted`, `autoplay`, etc.): IDL property set true, HTML attribute omitted. **ARIA string attrs**: present as `attr=""`. **Numeric attrs**: untested. — see m5, d2, h5 | +| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerBooleanLiteral'` and `path.value === false` (or `GlimmerNullLiteral` / `GlimmerUndefinedLiteral`) | `attr={{false}}` / `{{null}}` / `{{undefined}}` | Attribute is **omitted** at runtime — see m6, m9, m10, d3, d6, h6, h9, h10, t6, t7. **Exception:** plain string attrs (e.g., `autocomplete`) do _not_ falsy-coerce; bare `{{false}}` renders as `attr="false"` (i4). | +| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerStringLiteral'` | `attr={{"value"}}` | Attribute is **present** with the literal `path.value` string — see m7, m8, h7, h8, d4, d5, i2 | +| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerNumberLiteral'` (verified for `tabindex` only) | `attr={{0}}` | **Numeric attrs**: present with stringified number (t1, t2, t3). **`muted={{0}}` is in the falsy-omit set** (m12); not yet tested for other kinds. | +| `attr.value.type === 'GlimmerMustacheStatement'` with dynamic path | `attr={{this.x}}` | **Unknown** at lint time. | +| `attr.value.type === 'GlimmerConcatStatement'` with all-literal parts | `attr="{{X}}"` (single literal-mustache) / `attr="text{{X}}"` | **Boolean HTML attrs** (`muted`, `disabled`, etc.): IDL true regardless of inner literal — m13–m19, d7–d10. **ARIA / string attrs** (`aria-hidden`, `autocomplete`): renders the stringified value literally — h12–h15, i3, i5. | +| `attr.value.type === 'GlimmerConcatStatement'` with any dynamic part | `attr="{{this.x}}"` / `attr="x{{this.y}}"` | **Concat is never falsy** — present at runtime, but the value is generally **unknown** at lint time. | + +### Common mistakes to avoid + +1. **Don't use `findAttr(node, 'foo')` (AST-presence) as a proxy for "attribute set at runtime."** It's wrong for bare `{{false}}` / `{{null}}` / `{{undefined}}` on boolean-coerced attrs (m6/m9/m10/d3/d6/h6/h9/h10/t6/t7) — those forms still create an `AttrNode` in the AST but are omitted at runtime. +2. **Don't lump `BooleanLiteral(false)` with `StringLiteral("false")`.** Bare `{{"false"}}` is a JS-truthy string; it renders the literal `"false"` value (i4, h8, d4, m8). Treating them together as "off" is the most common audit footgun in this codebase. +3. **Don't treat single-mustache concat as the inner literal.** `attr="{{X}}"` is **never** falsy. For boolean HTML attrs the IDL property is set true regardless of `X`'s literal value (m14 verified: `` → `videoEl.muted === true`). For ARIA/string attrs the rendered HTML is the stringified value (h13: `aria-hidden="{{false}}"` → `aria-hidden="false"`, visible per ARIA spec). +4. **Don't apply boolean-coercion to plain string attrs.** `autocomplete`, `name`, `id`, `for`, `href`, `role`, `type`, `method`, `lang`, `title`, `alt` etc. do **not** falsy-coerce. Bare `{{false}}` on these renders the literal `"false"`. Plain-string attrs are documented under `i1`–`i5`; the falsy-coercion list (cross-attribute observations) covers HTML boolean attrs, ARIA attrs, and numeric attrs only. +5. **`role` is plain string, not ARIA-coerced.** Despite living in the ARIA family conceptually, `role` is a plain string DOM attribute — bare `role={{false}}` renders `role="false"` (analogous to i4), not omitted. +6. **`{{true}}` for `aria-hidden` (h5) renders `aria-hidden=""` — contested per ARIA spec, _not_ `aria-hidden="true"`.** Rules deciding "is this hidden?" should be explicit about which interpretation they take. Don't conflate h5 with h7 (`{{"true"}}` → renders the string `"true"`, hidden). + +### Recommended pattern + +The shared utility [`lib/utils/glimmer-attr-presence.js`](../lib/utils/glimmer-attr-presence.js) encodes the verdict table once. Rule authors should consume it rather than re-implementing the AST walk: + +```js +const { classifyAttribute } = require('../utils/glimmer-attr-presence'); + +const attr = node.attributes?.find((a) => a.name === 'aria-hidden'); +const { presence, value } = classifyAttribute(attr); +// presence: 'absent' | 'present' | 'unknown' +// value: string | null +// +// kind is inferred from attr.name (boolean / aria / numeric / plain-string). +// Override with options.kind when needed: classifyAttribute(attr, { kind: 'aria' }). + +if (presence === 'present' && value === 'true') { + // hidden — covers h3, h7, h12, h14 in one branch. +} +``` + +The utility maps every row in the reference table above to a single `(presence, value)` pair, including the surprising cases (`{{"false"}}` is JS-truthy, `aria-hidden={{true}}` renders empty per h5, concat is never falsy, etc.). Cite the doc row IDs from code comments where you call it so reviewers can confirm the lint truth without re-reading the AST. + +## To reproduce the reference table + +Every cell above was populated by rendering the template below in an Ember dev app and running the bundled JS console snippet to print each test case's `outerHTML` and IDL state. To re-verify (or extend with new attributes): + +1. Paste sections A–E into a template in your Ember dev app. +2. Render in the browser, open devtools. +3. Paste the JS snippet (after the template) into the console. +4. Compare the printed output against the tables above. +5. If anything diverges, update the changed cell, cite the new `ember-source` version in the commit, and note the change. + +```hbs +{{! A. + muted (boolean HTML, non-reflecting) }} + + + + + + + + + + + + + + + + + + + + +{{! B. + aria-hidden (ARIA string) }} + + + + + + + + + + + + + + + + +{{! C. + disabled (boolean HTML, reflecting) }} + + + + + + + + + + + +{{! D. + tabindex (numeric) }} + + + + + + + + +{{! E. + autocomplete (string) }} + + + + + +``` + +After rendering, paste this into the devtools console: + +```js +(() => { + const groups = { + 'A. video.muted': { + prefix: 'm', + count: 19, + idl: (el) => ({ + muted: el.muted, + hasAttr: el.hasAttribute('muted'), + attrValue: el.getAttribute('muted'), + }), + }, + 'B. div.ariaHidden': { + prefix: 'h', + count: 15, + idl: (el) => ({ + ariaHidden: el.ariaHidden, + hasAttr: el.hasAttribute('aria-hidden'), + attrValue: el.getAttribute('aria-hidden'), + }), + }, + 'C. input.disabled': { + prefix: 'd', + count: 10, + idl: (el) => ({ + disabled: el.disabled, + hasAttr: el.hasAttribute('disabled'), + attrValue: el.getAttribute('disabled'), + }), + }, + 'D. div.tabIndex': { + prefix: 't', + count: 7, + idl: (el) => ({ + tabIndex: el.tabIndex, + hasAttr: el.hasAttribute('tabindex'), + attrValue: el.getAttribute('tabindex'), + }), + }, + 'E. input.autocomplete': { + prefix: 'i', + count: 5, + idl: (el) => ({ + autocomplete: el.autocomplete, + hasAttr: el.hasAttribute('autocomplete'), + attrValue: el.getAttribute('autocomplete'), + }), + }, + }; + const lines = []; + for (const [label, { prefix, count, idl }] of Object.entries(groups)) { + lines.push(`\n==== ${label} ====`); + for (let i = 1; i <= count; i++) { + const id = prefix + i; + const el = document.querySelector(`#${id}`); + if (!el) { + lines.push(`${id}: (NOT FOUND)`); + continue; + } + const idlPairs = Object.entries(idl(el)) + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .join(' '); + lines.push(`${id.padEnd(4)} ${el.outerHTML.padEnd(75)} | ${idlPairs}`); + } + } + console.log(lines.join('\n')); +})(); +``` + +## Verification metadata + +- **Date verified:** 2026-04-28 +- **Ember version:** `ember-source` 6.12 (Glimmer VM is merged into the main Ember repo, no separate version) +- **Verified by:** [@johanrd](https://github.com/johanrd) (rendering app, devtools inspection) +- **Methodology:** rendered template fragments via the reproduction template above, captured `outerHTML` and IDL property state via the bundled JS console snippet + +## Related + +- HTML spec on boolean attributes and reflection: +- HTML spec on `muted` IDL vs. `defaultMuted`: +- ARIA `aria-hidden` semantics: +- ARIA reflection via `ARIAMixin`: diff --git a/docs/rules/template-no-interactive-element-to-noninteractive-role.md b/docs/rules/template-no-interactive-element-to-noninteractive-role.md new file mode 100644 index 0000000000..3c2f5a4d5a --- /dev/null +++ b/docs/rules/template-no-interactive-element-to-noninteractive-role.md @@ -0,0 +1,43 @@ +# ember/template-no-interactive-element-to-noninteractive-role + + + +Disallow native interactive elements from being assigned non-interactive ARIA roles. + +Assigning a non-interactive role to a native interactive element (e.g. ``) strips the element's built-in keyboard, focus, and activation semantics — leaving users with a broken widget. The [first rule of ARIA use](https://www.w3.org/TR/using-aria/#rule1) says don't use ARIA if native semantics already cover the job; this rule enforces the related corollary. + +The interactive-element set is derived in layers, mirroring [jsx-a11y's `isInteractiveElement`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isInteractiveElement.js): aria-query's `elementRoles` (with its attribute constraints, e.g. ``, ``, ``) is the primary signal; axobject-query's AX-tree mapping is consulted only as a fallback for tags that have no interactive `elementRoles` entry. + +Two deviations for real-world false-positive patterns: + +- `` is **not** treated as inherently interactive. Its AXObject is widget-typed but aria-query assigns it no inherent role; authors legitimately set `role="img"` or `role="presentation"` on canvases. +- `` and `` are only interactive when the `controls` attribute is present. Without it they render no user-operable UI (e.g. background / decorative media). + +## Examples + +This rule **forbids** the following: + +```gjs + + Click + Link + Click + +``` + +This rule **allows** the following: + +```gjs + + Item + Click + Link + Story + +``` + +## References + +- [WAI-ARIA 1.2 — Role taxonomy](https://www.w3.org/TR/wai-aria-1.2/#roles_categorization) +- [Using ARIA — Rule 1](https://www.w3.org/TR/using-aria/#rule1) +- [`no-interactive-element-to-noninteractive-role` — eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-interactive-element-to-noninteractive-role.md) 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-interactive-element-to-noninteractive-role.js b/lib/rules/template-no-interactive-element-to-noninteractive-role.js new file mode 100644 index 0000000000..e4f2aaebd6 --- /dev/null +++ b/lib/rules/template-no-interactive-element-to-noninteractive-role.js @@ -0,0 +1,289 @@ +'use strict'; + +const { roles, elementRoles } = require('aria-query'); +const { AXObjects, elementAXObjects } = require('axobject-query'); +const { INTERACTIVE_ROLES } = require('../utils/interactive-roles'); +const { isNativeElement } = require('../utils/is-native-element'); +const { classifyAttribute } = require('../utils/glimmer-attr-presence'); + +// Interactive-element derivation. Mirrors jsx-a11y's layered approach: +// 1. Primary signal — aria-query's `elementRoles`: an element is inherently +// interactive if one of its mapped roles is in INTERACTIVE_ROLES AND the +// schema's attribute constraints match the node. (Handles , , , , , , , +// , , .) +// 2. AX-tree fallback — axobject-query's `elementAXObjects`: consulted only +// for tag names that have NO interactive `elementRoles` entry. These are +// elements whose AXObject is a widget but aria-query lists no inherent +// ARIA role (e.g. , , ). +// +// Why we do NOT use the shared `isHtmlInteractiveContent` util here: +// Keep the rule's layered aria-query + axobject-query derivation — this +// rule's scope is different. We care about tags that have INHERENT +// interactive semantics per HTML-AAM / the ARIA widget role mappings (so +// that applying a non-interactive role is a demotion of AT semantics). +// `isHtmlInteractiveContent` answers a different question — "does HTML's +// content model forbid nesting this inside an interactive parent?" — and +// diverges on e.g. (HTML-interactive, ARIA structure), +// / (not HTML-interactive, but ARIA widgets), and +// (not HTML-interactive, AX widget). +// +// Deviations from jsx-a11y, driven by real-world false-positive patterns: +// - `` is NOT treated as inherently interactive. Its AXObject is +// `CanvasRole` (widget), but per aria-query `` has no inherent +// ARIA role — authors commonly set `role="img"` or `role="presentation"` +// as the accessibility surface, and that is legitimate. +// - `` and `` are only treated as interactive when the +// `controls` attribute is present. Without `controls` they render no +// user-operable UI (background / decorative media is a common real +// pattern). axobject-query does not encode this constraint; we add it +// here explicitly. + +const interactiveRoleSet = INTERACTIVE_ROLES; + +// Pre-index interactive element schemas by tag name at module load. aria-query's +// elementRoles is static data; bucketing by tag turns the per-node O(N) scan +// over all schemas into a direct O(1) Map lookup followed by checking only the +// 1–5 schemas for that specific tag. +const INTERACTIVE_SCHEMAS_BY_TAG = (() => { + const index = new Map(); + for (const [schema, rolesArr] of elementRoles) { + if ([...rolesArr].some((r) => interactiveRoleSet.has(r))) { + if (!index.has(schema.name)) { + index.set(schema.name, []); + } + index.get(schema.name).push(schema); + } + } + return index; +})(); + +const tagsWithInteractiveElementRoleEntry = new Set(INTERACTIVE_SCHEMAS_BY_TAG.keys()); + +// AX-fallback tag set — tags whose AXObject list is entirely widget AND which +// have no interactive `elementRoles` entry. Excludes `canvas` per rationale +// above. +const EXCLUDED_AX_FALLBACK_TAGS = new Set(['canvas']); + +// Tags where we require explicit attribute constraints before treating as +// interactive — overrides the unconstrained AX fallback. +const CONTROLS_GATED_TAGS = new Set(['audio', 'video']); + +const interactiveAXObjects = new Set( + [...AXObjects.keys()].filter((name) => AXObjects.get(name).type === 'widget') +); + +const AX_FALLBACK_TAGS = (() => { + const result = new Set(); + for (const [schema, axArr] of elementAXObjects) { + if (schema.attributes && schema.attributes.length > 0) { + continue; + } + const name = schema.name; + if (tagsWithInteractiveElementRoleEntry.has(name)) { + continue; + } + if (EXCLUDED_AX_FALLBACK_TAGS.has(name)) { + continue; + } + if (CONTROLS_GATED_TAGS.has(name)) { + continue; // handled via explicit controls check + } + if ([...axArr].every((o) => interactiveAXObjects.has(o))) { + result.add(name); + } + } + return result; +})(); + +function findAttr(node, name) { + return node.attributes?.find((a) => a.name === name); +} + +function hasAttr(node, name) { + return Boolean(findAttr(node, name)); +} + +function getTextAttrValue(attr) { + if (attr?.value?.type === 'GlimmerTextNode') { + return attr.value.chars; + } + return undefined; +} + +// Verify a single aria-query attribute-constraint entry matches the node. +function attrConstraintMatches(baseAttr, node) { + const attr = findAttr(node, baseAttr.name); + const constraints = baseAttr.constraints || []; + + if (constraints.includes('set')) { + return Boolean(attr); + } + if (constraints.includes('undefined')) { + if (!attr) { + return true; + } + // Attribute is present. 'undefined' means "this schema entry requires the + // attribute to be absent". Aria-query currently never pairs 'undefined' + // with a 'value' on the same attribute (verified against elementRoles), so + // attr-present is always a non-match here. Explicit early-return removes + // the previous ambiguous fall-through to `return Boolean(attr)`, which + // silently mis-matched schemas like `` against the + // combobox entry (aria-query says `multiple` promotes it to listbox). + return false; + } + + if (baseAttr.value !== undefined) { + const value = getTextAttrValue(attr); + if (value === undefined) { + return false; + } + // HTML attribute values for known enumerated attributes (type, autocomplete, + // contenteditable, …) are ASCII case-insensitive per the HTML spec, and + // may carry incidental whitespace. Normalize both sides so `type="TEXT"` + // and `type=" text "` match aria-query's `"text"` constraint. Aligns with + // the `type === 'hidden'` guard below that does the same normalization. + return value.trim().toLowerCase() === String(baseAttr.value).toLowerCase(); + } + + // Attribute named without a value or constraint — match means present. + return Boolean(attr); +} + +function attributesMatchSchema(schema, node) { + const baseAttrs = schema.attributes || []; + if (baseAttrs.length === 0) { + return true; + } + return baseAttrs.every((baseAttr) => attrConstraintMatches(baseAttr, node)); +} + +function isInteractiveElement(node) { + const tag = node.tag?.toLowerCase(); + if (!tag) { + return false; + } + + // Tag not in DOM per aria-query — treat as component (conservatively skip). + // aria-query's `dom` map covers HTML element names; unknown tag = not ours. + // (We don't import `dom` separately — `elementRoles` / AX maps cover the + // element-name surface for the rule's purpose, and unknown tags simply fail + // every schema match below.) + + // Primary signal: elementRoles with at least one interactive role + schema + // constraints match the node's attributes. + for (const schema of INTERACTIVE_SCHEMAS_BY_TAG.get(tag) ?? []) { + if (!attributesMatchSchema(schema, node)) { + continue; + } + // Special case: is never user-facing. aria-query's + // textbox entry would not match (it requires type=text/email/url/…), so + // normally we'd be fine — but keep the explicit guard for clarity. + // + // Use classifyAttribute so the guard catches every form that renders + // `type="hidden"` at runtime: GlimmerTextNode (i1), bare-mustache string + // literal `type={{"hidden"}}` (i2 analog), and concat-with-literal + // `type="{{'hidden'}}"` (i3 analog). Previous TextNode-only check was a + // false-positive source on the latter two forms. + if (tag === 'input') { + const { value: type } = classifyAttribute(findAttr(node, 'type')); + if (typeof type === 'string' && type.trim().toLowerCase() === 'hidden') { + return false; + } + } + return true; + } + + // AX-tree fallback for tags with no interactive elementRoles entry. + if (AX_FALLBACK_TAGS.has(tag)) { + return true; + } + + // Controls-gated fallback for /: only interactive when the + // `controls` attribute is rendered at runtime. Bare `controls={{false}}` / + // `{{null}}` / `{{undefined}}` cause Glimmer to omit the attribute (per + // cross-attribute observation in docs/glimmer-attribute-behavior.md), so + // AST-presence is wrong. classifyAttribute returns 'present' only when + // the attribute will actually render. + if ( + CONTROLS_GATED_TAGS.has(tag) && + classifyAttribute(findAttr(node, 'controls')).presence === 'present' + ) { + return true; + } + + return false; +} + +function getRoleTokens(node) { + const attr = findAttr(node, 'role'); + if (!attr || attr.value?.type !== 'GlimmerTextNode') { + return undefined; + } + const chars = attr.value.chars.trim(); + if (!chars) { + return undefined; + } + return chars.toLowerCase().split(/\s+/u); +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'disallow native interactive elements being assigned non-interactive ARIA roles', + category: 'Accessibility', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-interactive-element-to-noninteractive-role.md', + templateMode: 'both', + }, + fixable: null, + schema: [], + messages: { + mismatch: + 'Interactive element <{{tag}}> should not have a non-interactive role "{{role}}". Native interactive semantics are lost.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode || context.getSourceCode(); + return { + GlimmerElementNode(node) { + // Only run on native HTML elements — in strict GJS a lowercase tag can + // be shadowed by an in-scope local binding (e.g. `const button = Btn; + // `) and that's a component invocation, not a native element. + // `isNativeElement` combines the authoritative html/svg/mathml tag + // lists with scope-shadowing detection. + if (!isNativeElement(node, sourceCode)) { + return; + } + + if (!isInteractiveElement(node)) { + return; + } + + const tokens = getRoleTokens(node); + if (!tokens) { + return; + } + + // Pick the first token that's a known role (matching ARIA 1.2 §5.3 + // role-fallback behavior — UAs use the first recognised role). + for (const token of tokens) { + if (token === 'presentation' || token === 'none') { + context.report({ node, messageId: 'mismatch', data: { tag: node.tag, role: token } }); + return; + } + const def = roles.get(token); + if (!def || def.abstract) { + continue; + } + if (!INTERACTIVE_ROLES.has(token)) { + context.report({ node, messageId: 'mismatch', data: { tag: node.tag, role: token } }); + } + return; + } + }, + }; + }, +}; 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/html-interactive-content.js b/lib/utils/html-interactive-content.js index 8b6e4354a9..7d0872b031 100644 --- a/lib/utils/html-interactive-content.js +++ b/lib/utils/html-interactive-content.js @@ -1,5 +1,7 @@ 'use strict'; +const { classifyAttribute } = require('./glimmer-attr-presence'); + /** * HTML "interactive content" classification, authoritative per * [HTML Living Standard §3.2.5.2.7 Interactive content] @@ -79,9 +81,18 @@ function isHtmlInteractiveContent(node, getTextAttrValue, options = {}) { return hasAttribute(node, 'usemap'); } - // audio / video — interactive only when controls is present + // audio / video — interactive only when controls is present. + // + // `controls` is an HTML boolean attribute, so `controls={{false}}` / + // `{{null}}` / `{{undefined}}` cause Glimmer to omit the attribute at + // runtime (rows m6, m9, m10 by attribute-kind analogy + cross-attribute + // observation in docs/glimmer-attribute-behavior.md). AST-presence is + // wrong here — use classifyAttribute so the runtime presence drives the + // answer. `href` / `usemap` are plain string attrs that don't falsy-coerce + // (i4 analog), so AST-presence remains correct for them. if (tag === 'audio' || tag === 'video') { - return hasAttribute(node, 'controls'); + const controlsAttr = node.attributes?.find((a) => a.name === 'controls'); + return classifyAttribute(controlsAttr).presence === 'present'; } return false; diff --git a/lib/utils/is-component-invocation.js b/lib/utils/is-component-invocation.js new file mode 100644 index 0000000000..a0dfbcfe65 --- /dev/null +++ b/lib/utils/is-component-invocation.js @@ -0,0 +1,24 @@ +'use strict'; + +/** + * Returns true if the Glimmer element node is a component invocation + * rather than a native HTML element. Includes: + * - PascalCase tags (, ) + * - Named-arg invocations (<@heading>, <@tag.foo>) + * - This-path invocations (, ) + * - Dot-path invocations () + * - Named-block syntax () + */ +module.exports.isComponentInvocation = function isComponentInvocation(node) { + const tag = node?.tag; + if (typeof tag !== 'string') { + return false; + } + return ( + /^[A-Z]/.test(tag) || + tag.startsWith('@') || + tag.startsWith('this.') || + tag.includes('.') || + tag.includes('::') + ); +}; 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-interactive-element-to-noninteractive-role.js b/tests/lib/rules/template-no-interactive-element-to-noninteractive-role.js new file mode 100644 index 0000000000..bb5834f951 --- /dev/null +++ b/tests/lib/rules/template-no-interactive-element-to-noninteractive-role.js @@ -0,0 +1,207 @@ +'use strict'; + +const rule = require('../../../lib/rules/template-no-interactive-element-to-noninteractive-role'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-no-interactive-element-to-noninteractive-role', rule, { + valid: [ + // Interactive elements with interactive roles — fine. + 'Click', + 'Item', + 'Link', + '', + + // Non-interactive elements — not in scope of this rule. + 'Story', + 'Title', + + // No role → nothing to check. + 'Click', + 'Link', + + // Dynamic role → skipped. + 'Click', + + // Components — rule skips (not a DOM element). + '', + + // Scope-bound lowercase tag — `` here is a local binding, so it + // resolves to a component invocation (not a native interactive element) + // and the rule must skip it even with a non-interactive role attribute. + 'const button = ButtonComponent;\n', + + // Unknown role — rule skips. + 'Click', + + // is not interactive — role assignment allowed. + // HTML type values are ASCII case-insensitive and may carry incidental + // whitespace; these variants should behave the same as lowercase. + '', + '', + '', + // Mustache forms that resolve to `type="hidden"` at runtime (i2 / i3 + // analogs). Were previously false-positive flagged because the guard + // only matched GlimmerTextNode. + '', + '', + + // without href is not interactive. + 'Not a link', + + // is not treated as inherently interactive — authors commonly + // use it as an accessibility surface with role="img" or role="presentation". + 'Chart', + '', + '', + + // / without `controls` render no user-operable UI — treat + // as non-interactive. Decorative background media is a common pattern. + '', + '', + '', + '', + // Bare-mustache falsy on `controls` (cross-attribute observation: HTML + // boolean attrs follow rows m6/m9/m10) — Glimmer omits the attribute + // at runtime, so the media has no user-operable UI and the + // role="presentation" is allowed. Was a false positive before. + '', + '', + '', + ], + invalid: [ + { + code: 'Click', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: 'Click', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: 'Link', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + // role="presentation" / "none" on an interactive element — removes the + // interactive semantics. Flagged. + { + code: 'Click', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: 'Link', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + // Role-fallback list — picks the first recognised token. + { + code: 'Click', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + // HTML attribute values for known enumerated attributes are ASCII + // case-insensitive per spec and may carry incidental whitespace. A + // checkbox with `type="CHECKBOX"` / `type=" checkbox "` is still a + // checkbox and should be flagged the same as lowercase. + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + // / exposes user-operable playback UI — + // stripping interactive semantics with a non-interactive role is wrong. + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + // DIVERGENCE from jsx-a11y :recommended: is allowed + // there (allowedInvalidRoles config), but we have no options schema and always + // flag interactive-to-noninteractive regardless. + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + // DIVERGENCE from jsx-a11y: , , , are treated as + // static (no implicit role) by jsx-a11y, so is VALID + // there. Our rule derives interactivity from aria-query / axobject-query, + // which classifies these as widget-type AXObjects — so we flag. + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('template-no-interactive-element-to-noninteractive-role', rule, { + valid: [ + 'Click', + 'Story', + 'Click', + '', + 'Chart', + '', + ], + invalid: [ + { + code: 'Click', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: 'Link', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'mismatch' }], + }, + ], +}); 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: renders + expect(classifyAttribute(attr('muted', bareMustache(stringLit(''))))).toEqual({ + presence: 'present', + value: '', + }); + }); + }); + + describe('bare-mustache number literal {{0}} / {{N}}', () => { + it('{{0}} on boolean attr → absent (doc row m12)', () => { + expect(classifyAttribute(attr('muted', bareMustache(numberLit(0))))).toEqual({ + presence: 'absent', + value: null, + }); + }); + + it('{{0}} on numeric attr → present "0" (doc row t1, focusable)', () => { + expect(classifyAttribute(attr('tabindex', bareMustache(numberLit(0))))).toEqual({ + presence: 'present', + value: '0', + }); + }); + + it('{{-1}} / {{1}} render as stringified value', () => { + expect(classifyAttribute(attr('tabindex', bareMustache(numberLit(-1))))).toEqual({ + presence: 'present', + value: '-1', + }); + expect(classifyAttribute(attr('tabindex', bareMustache(numberLit(1))))).toEqual({ + presence: 'present', + value: '1', + }); + }); + }); + + describe('bare-mustache dynamic path {{this.x}}', () => { + it('on falsy-coerced kinds → unknown (could be falsy at runtime)', () => { + expect(classifyAttribute(attr('muted', bareMustache(pathExpr('this.x'))))).toEqual({ + presence: 'unknown', + value: null, + }); + expect(classifyAttribute(attr('aria-hidden', bareMustache(pathExpr('this.x'))))).toEqual({ + presence: 'unknown', + value: null, + }); + expect(classifyAttribute(attr('tabindex', bareMustache(pathExpr('this.x'))))).toEqual({ + presence: 'unknown', + value: null, + }); + }); + + it('on plain-string attr → present with unknown value', () => { + expect(classifyAttribute(attr('autocomplete', bareMustache(pathExpr('this.x'))))).toEqual({ + presence: 'present', + value: null, + }); + }); + }); + + describe('GlimmerConcatStatement (concat-mustache)', () => { + it('"{{false}}" on boolean attr → present "true" (concat sets IDL true regardless; doc row m14)', () => { + // Per doc m14, sets IDL muted=true. + // Surface canonical "true" rather than the inner literal so callers + // checking `value === 'false'` for "off" don't get the wrong answer. + expect(classifyAttribute(attr('muted', concat([bareMustache(boolLit(false))])))).toEqual({ + presence: 'present', + value: 'true', + }); + expect(classifyAttribute(attr('disabled', concat([bareMustache(boolLit(false))])))).toEqual({ + presence: 'present', + value: 'true', + }); + expect(classifyAttribute(attr('muted', concat([bareMustache(stringLit('false'))])))).toEqual({ + presence: 'present', + value: 'true', + }); + expect( + classifyAttribute(attr('muted', concat([textNode('x'), bareMustache(boolLit(false))]))) + ).toEqual({ presence: 'present', value: 'true' }); + }); + + it('"{{false}}" on aria attr → present "false" (doc row h13, visible)', () => { + expect( + classifyAttribute(attr('aria-hidden', concat([bareMustache(boolLit(false))]))) + ).toEqual({ presence: 'present', value: 'false' }); + }); + + it('"{{true}}" on aria attr → present "true" (doc row h12, hidden)', () => { + expect(classifyAttribute(attr('aria-hidden', concat([bareMustache(boolLit(true))])))).toEqual( + { presence: 'present', value: 'true' } + ); + }); + + it('"{{\'true\'}}" on aria → present "true" (doc row h14)', () => { + expect( + classifyAttribute(attr('aria-hidden', concat([bareMustache(stringLit('true'))]))) + ).toEqual({ presence: 'present', value: 'true' }); + }); + + it('multi-part static concat resolves the joined string', () => { + expect( + classifyAttribute( + attr( + 'aria-label', + concat([textNode('prefix-'), bareMustache(stringLit('mid')), textNode('-suffix')]) + ) + ) + ).toEqual({ presence: 'present', value: 'prefix-mid-suffix' }); + }); + + it('concat with dynamic part → present, value null', () => { + expect( + classifyAttribute( + attr('aria-label', concat([textNode('prefix-'), bareMustache(pathExpr('this.x'))])) + ) + ).toEqual({ presence: 'present', value: null }); + }); + }); + + describe('options.kind override', () => { + it('respects explicit kind over inferred', () => { + // Force aria semantics on a non-aria-prefixed attribute name + expect( + classifyAttribute(attr('myCustomAttr', bareMustache(boolLit(false))), { kind: 'aria' }) + ).toEqual({ presence: 'absent', value: null }); + + // Force plain-string on a known boolean attribute + expect( + classifyAttribute(attr('disabled', bareMustache(boolLit(false))), { + kind: 'plain-string', + }) + ).toEqual({ presence: 'present', value: 'false' }); + }); + }); + + describe('unknown AST node type', () => { + it('returns unknown for an unrecognized value type', () => { + expect(classifyAttribute(attr('foo', { type: 'GlimmerSubExpression' }))).toEqual({ + presence: 'unknown', + value: null, + }); + }); + + it('returns unknown when bare-mustache has no path', () => { + expect(classifyAttribute(attr('foo', bareMustache(null)))).toEqual({ + presence: 'unknown', + value: null, + }); + }); + }); +}); diff --git a/tests/lib/utils/html-interactive-content-test.js b/tests/lib/utils/html-interactive-content-test.js index da9b8cebc6..2bee36dfd8 100644 --- a/tests/lib/utils/html-interactive-content-test.js +++ b/tests/lib/utils/html-interactive-content-test.js @@ -111,6 +111,61 @@ describe('isHtmlInteractiveContent', () => { it(' without controls is NOT interactive', () => { expect(isHtmlInteractiveContent(makeNode('video'), getTextAttrValue)).toBe(false); }); + + // Bare-mustache falsy on `controls` (per cross-attribute observation in + // docs/glimmer-attribute-behavior.md) causes Glimmer to omit the attribute + // at runtime — so `` has NO controls and is not + // interactive. Was previously a false positive: AST-presence treated it + // as having controls. + it(' is NOT interactive (Glimmer omits the attribute)', () => { + const node = { + tag: 'video', + attributes: [ + { + name: 'controls', + value: { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerBooleanLiteral', value: false }, + }, + }, + ], + }; + expect(isHtmlInteractiveContent(node, getTextAttrValue)).toBe(false); + }); + + it(' is NOT interactive', () => { + const node = { + tag: 'audio', + attributes: [ + { + name: 'controls', + value: { type: 'GlimmerMustacheStatement', path: { type: 'GlimmerNullLiteral' } }, + }, + ], + }; + expect(isHtmlInteractiveContent(node, getTextAttrValue)).toBe(false); + }); + + it(' IS interactive (concat sets IDL true regardless)', () => { + const node = { + tag: 'video', + attributes: [ + { + name: 'controls', + value: { + type: 'GlimmerConcatStatement', + parts: [ + { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerBooleanLiteral', value: false }, + }, + ], + }, + }, + ], + }; + expect(isHtmlInteractiveContent(node, getTextAttrValue)).toBe(true); + }); }); describe('elements NOT in §3.2.5.2.7', () => { diff --git a/tests/lib/utils/is-component-invocation-test.js b/tests/lib/utils/is-component-invocation-test.js new file mode 100644 index 0000000000..d542a834dd --- /dev/null +++ b/tests/lib/utils/is-component-invocation-test.js @@ -0,0 +1,68 @@ +'use strict'; + +const { isComponentInvocation } = require('../../../lib/utils/is-component-invocation'); + +describe('isComponentInvocation', () => { + it('returns true for PascalCase tags', () => { + expect(isComponentInvocation({ tag: 'Button' })).toBe(true); + expect(isComponentInvocation({ tag: 'MyWidget' })).toBe(true); + // PascalCase tags that match a native HTML element name — the core bug case + expect(isComponentInvocation({ tag: 'Article' })).toBe(true); + expect(isComponentInvocation({ tag: 'Form' })).toBe(true); + expect(isComponentInvocation({ tag: 'Main' })).toBe(true); + expect(isComponentInvocation({ tag: 'Nav' })).toBe(true); + expect(isComponentInvocation({ tag: 'Ul' })).toBe(true); + expect(isComponentInvocation({ tag: 'Li' })).toBe(true); + expect(isComponentInvocation({ tag: 'Aside' })).toBe(true); + expect(isComponentInvocation({ tag: 'Section' })).toBe(true); + expect(isComponentInvocation({ tag: 'Table' })).toBe(true); + }); + + it('returns false for lowercase native HTML tags', () => { + expect(isComponentInvocation({ tag: 'div' })).toBe(false); + expect(isComponentInvocation({ tag: 'article' })).toBe(false); + expect(isComponentInvocation({ tag: 'form' })).toBe(false); + expect(isComponentInvocation({ tag: 'h1' })).toBe(false); + expect(isComponentInvocation({ tag: 'button' })).toBe(false); + }); + + it('returns true for named-arg invocations', () => { + expect(isComponentInvocation({ tag: '@heading' })).toBe(true); + expect(isComponentInvocation({ tag: '@tag.foo' })).toBe(true); + }); + + it('returns true for this-path invocations', () => { + expect(isComponentInvocation({ tag: 'this.myComponent' })).toBe(true); + expect(isComponentInvocation({ tag: 'this.comp.sub' })).toBe(true); + }); + + it('returns true for dot-path invocations', () => { + expect(isComponentInvocation({ tag: 'foo.bar' })).toBe(true); + expect(isComponentInvocation({ tag: 'ns.widget' })).toBe(true); + }); + + it('returns true for named-block / namespaced invocations', () => { + expect(isComponentInvocation({ tag: 'foo::bar' })).toBe(true); + expect(isComponentInvocation({ tag: 'Foo::Bar' })).toBe(true); + }); + + it('returns false for empty-string tag', () => { + expect(isComponentInvocation({ tag: '' })).toBe(false); + }); + + it('returns false for undefined node', () => { + expect(isComponentInvocation()).toBe(false); + expect(isComponentInvocation(undefined)).toBe(false); + expect(isComponentInvocation(null)).toBe(false); + }); + + it('returns false for node with undefined tag', () => { + expect(isComponentInvocation({})).toBe(false); + expect(isComponentInvocation({ tag: undefined })).toBe(false); + }); + + it('returns false for node with non-string tag', () => { + expect(isComponentInvocation({ tag: 123 })).toBe(false); + expect(isComponentInvocation({ tag: null })).toBe(false); + }); +});