Skip to content
Merged
8 changes: 6 additions & 2 deletions docs/rules/template-no-nested-interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ This rule disallows nesting interactive elements inside other interactive elemen
Interactive elements include:

- `<a>` (only when it has an `href` attribute)
- `<audio>` (only when it has a `controls` attribute)
- `<button>`
- `<canvas>` (drawing/game-UI convention; not in the HTML spec's interactive-content category)
- `<details>`
- `<embed>`
- `<iframe>`
Expand All @@ -24,6 +26,7 @@ Interactive elements include:
- `<select>`
- `<summary>`
- `<textarea>`
- `<video>` (only when it has a `controls` attribute)
- Elements with interactive ARIA roles (e.g., `role="button"`, `role="link"`)
- Elements with `tabindex` (unless `ignoreTabindex` is enabled)
- Elements with `contenteditable` (except `contenteditable="false"`)
Expand All @@ -32,8 +35,9 @@ Interactive elements include:
Special cases:

- `<label>` may contain **one** interactive child (e.g., `<label><input /></label>` is fine, but `<label><input /><button>x</button></label>` is not)
- `<summary>` as the first child of `<details>` is allowed
- Nested `role="menuitem"` elements are allowed (menu/sub-menu pattern)
- `<summary>` as the first child of `<details>` is allowed; other interactive content after `<summary>` (in the disclosed panel) is also allowed
- Canonical ARIA composite-widget hierarchies are allowed (e.g., `role="option"` inside `role="listbox"`, `role="tab"` inside `role="tablist"`, `role="row"` inside `role="grid"`, `role="radio"` inside `role="radiogroup"`). Derived from the ARIA `requiredOwnedElements` relationship.
- Nested `role="menuitem"` / `role="menuitemcheckbox"` / `role="menuitemradio"` elements are allowed (menu/sub-menu pattern)

## Examples

Expand Down
76 changes: 21 additions & 55 deletions lib/rules/template-no-invalid-interactive.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
'use strict';

const { isNativeElement } = require('../utils/is-native-element');
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');

function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
Expand Down Expand Up @@ -74,42 +78,6 @@ module.exports = {
const ignoreTabindex = options.ignoreTabindex || false;
const ignoreUsemap = options.ignoreUsemap || false;

const NATIVE_INTERACTIVE_ELEMENTS = new Set([
'button',
'canvas',
'details',
'embed',
'iframe',
'input',
'label',
'select',
'summary',
'textarea',
]);

const INTERACTIVE_ROLES = new Set([
'button',
'checkbox',
'link',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'radio',
'scrollbar',
'searchbox',
'slider',
'spinbutton',
'switch',
'tab',
'textbox',
'tooltip',
'treeitem',
'combobox',
'gridcell',
]);

// eslint-disable-next-line complexity
function isInteractive(node) {
const tag = node.tag?.toLowerCase();
if (!tag) {
Expand All @@ -120,24 +88,17 @@ module.exports = {
return true;
}

// <a> is only interactive when it has href
if (tag === 'a' && hasAttr(node, 'href')) {
return true;
}

// <audio>/<video> with controls attribute is interactive
if ((tag === 'audio' || tag === 'video') && hasAttr(node, 'controls')) {
// HTML §3.2.5.2.7 interactive content (authoritative for content-model;
// handles label/button/etc. + conditional a[href], input[!hidden],
// img[usemap], audio/video[controls]).
if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
return true;
}

if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
// Hidden input is not interactive
if (tag === 'input') {
const type = getTextAttr(node, 'type');
if (type === 'hidden') {
return false;
}
}
// <canvas> — not in §3.2.5.2.7 but upstream ember-template-lint treats
// it as interactive (canvas is commonly wired for drawing/game UI where
// event handlers are expected). Preserved for upstream parity.
if (tag === 'canvas') {
return true;
}

Expand All @@ -152,10 +113,15 @@ module.exports = {
return true;
}

// Check contenteditable
const ce = getTextAttr(node, 'contenteditable');
if (ce && ce !== 'false') {
return true;
// Check contenteditable. Per HTML spec, valid keywords are "true",
// "false", "plaintext-only", and the empty string (which is the default
// state, equivalent to "true"). So the attribute enables editing unless
// its value is "false".
if (hasAttr(node, 'contenteditable')) {
const ce = getTextAttr(node, 'contenteditable');
if (ce === undefined || ce === null || ce.trim().toLowerCase() !== 'false') {
return true;
}
}

// Check usemap (only on img/object)
Expand Down
128 changes: 76 additions & 52 deletions lib/rules/template-no-nested-interactive.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,7 @@
const NATIVE_INTERACTIVE_ELEMENTS = new Set([
'button',
'details',
'embed',
'iframe',
'input',
'label',
'select',
'summary',
'textarea',
]);
'use strict';

const INTERACTIVE_ROLES = new Set([
'button',
'checkbox',
'link',
'searchbox',
'spinbutton',
'switch',
'textbox',
'radio',
'slider',
'tab',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'combobox',
'gridcell',
]);
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
const { INTERACTIVE_ROLES, COMPOSITE_WIDGET_CHILDREN } = require('../utils/interactive-roles');

function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
Expand All @@ -41,8 +15,38 @@ function getTextAttr(node, name) {
return undefined;
}

function getRole(node) {
return getTextAttr(node, 'role');
}

// Menu submenu pattern — per WAI-ARIA APG, a `menuitem` with `aria-haspopup`
// may own a nested `menu`. aria-query's `requiredOwnedElements` does not
// express this "menu-inside-menuitem" direction, so it is handled explicitly.
const MENUITEM_ROLES = new Set(['menuitem', 'menuitemcheckbox', 'menuitemradio']);

function isCompositeWidgetPattern(parentRole, childRole) {
if (!parentRole || !childRole) {
return false;
}
const allowedChildren = COMPOSITE_WIDGET_CHILDREN.get(parentRole);
if (allowedChildren && allowedChildren.has(childRole)) {
return true;
}
// Submenu: <… role="menuitem"><… role="menu"> …
if (MENUITEM_ROLES.has(parentRole) && childRole === 'menu') {
return true;
}
return false;
}

function isMenuItemNode(node) {
return getTextAttr(node, 'role') === 'menuitem';
// Match all three menu-item role variants per ARIA taxonomy. `menuitem`,
// `menuitemcheckbox`, and `menuitemradio` are all "menu items" — they can
// carry submenus (via MENUITEM_ROLES in isCompositeWidgetPattern) and nest
// each other (via the nested-menuitem compat exception). Keeping both
// predicates symmetric avoids false positives on APG Menu Button /
// Menubar patterns that mix the variants.
return MENUITEM_ROLES.has(getTextAttr(node, 'role'));
}

function isAllowedDetailsChild(childNode, parentEntry) {
Expand Down Expand Up @@ -122,22 +126,23 @@ module.exports = {
return true;
}

if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
if (tag === 'input') {
const type = getTextAttr(node, 'type');
if (type === 'hidden') {
return false;
}
}
// HTML §3.2.5.2.7 interactive content (authoritative for content-model
// nesting — handles a[href], audio/video[controls], input[!hidden],
// img[usemap], plus label/button/select/textarea/iframe/etc. unconditional).
if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
return true;
}

// <a> with href is interactive (without href, <a> is not interactive)
if (tag === 'a' && hasAttr(node, 'href')) {
// <canvas> — not in §3.2.5.2.7 but upstream ember-template-lint treats
// it as interactive. Preserved for parity.
if (tag === 'canvas') {
return true;
}

// Check role
// ARIA widget roles (author-declared interactivity — separate authority
// from HTML content-model: `html-interactive-content.js` speaks to the
// HTML §3.2.5.2.7 content model, while `interactive-roles.js` speaks to
// the WAI-ARIA 1.2 widget taxonomy).
const role = getTextAttr(node, 'role');
if (role && INTERACTIVE_ROLES.has(role)) {
return true;
Expand All @@ -148,14 +153,21 @@ module.exports = {
return true;
}

// Check contenteditable
const ce = getTextAttr(node, 'contenteditable');
if (ce && ce !== 'false') {
return true;
// Check contenteditable. Per HTML spec, valid keywords are "true",
// "false", "plaintext-only", and the empty string (which is the default
// state, equivalent to "true"). So the attribute enables editing unless
// its value is "false".
if (hasAttr(node, 'contenteditable')) {
const ce = getTextAttr(node, 'contenteditable');
if (ce === undefined || ce === null || ce.trim().toLowerCase() !== 'false') {
return true;
}
}

// Check usemap (only on img and object elements)
if (!ignoreUsemap && (tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
// <object usemap> — not in HTML §3.2.5.2.7 but upstream ember-template-lint
// treats object+usemap as interactive (image-map behavior). Rule-level
// special case for upstream parity; revisit if/when HTML-AAM clarifies.
if (!ignoreUsemap && tag === 'object' && hasAttr(node, 'usemap')) {
return true;
}

Expand All @@ -175,19 +187,21 @@ module.exports = {
if (additionalInteractiveTags.has(tag)) {
return false;
}
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
if (tag === 'canvas') {
return false;
}
if (tag === 'a' && hasAttr(node, 'href')) {
if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
return false;
}
const role = getTextAttr(node, 'role');
if (role && INTERACTIVE_ROLES.has(role)) {
return false;
}
const ce = getTextAttr(node, 'contenteditable');
if (ce && ce !== 'false') {
return false;
if (hasAttr(node, 'contenteditable')) {
const ce = getTextAttr(node, 'contenteditable');
if (ce === undefined || ce === null || ce.trim().toLowerCase() !== 'false') {
return false;
}
}
if ((tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
return false;
Expand All @@ -214,8 +228,18 @@ module.exports = {
parentEntry.interactiveChildCount++;
} else if (isAllowedDetailsChild(node, parentEntry)) {
// flow content in the disclosed panel, or <summary> as first child
} else if (isCompositeWidgetPattern(getRole(parentEntry.node), getRole(node))) {
// Canonical ARIA composite-widget hierarchies — e.g. option inside
// listbox, tab inside tablist, treeitem inside tree, row inside
// grid/treegrid, gridcell/columnheader/rowheader inside row,
// radio inside radiogroup, menu inside menuitem (submenu).
// Derived from aria-query's requiredOwnedElements with superClass
// inheritance — see lib/utils/interactive-roles.js.
} else if (isMenuItemNode(parentEntry.node) && isMenuItemNode(node)) {
// Nested menuitem nodes are valid (menu/sub-menu pattern)
// Nested menu-item nodes (any combination of menuitem /
// menuitemcheckbox / menuitemradio) are valid — menu/sub-menu
// pattern per WAI-ARIA APG. Kept for historical compat since
// aria-query doesn't encode this via requiredOwnedElements.
} else {
context.report({
node,
Expand Down
Loading
Loading