Skip to content
Closed
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');

Comment thread
johanrd marked this conversation as resolved.
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;
}
Comment thread
johanrd marked this conversation as resolved.

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
129 changes: 75 additions & 54 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');
Comment thread
johanrd marked this conversation as resolved.

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,18 @@ module.exports = {
if (additionalInteractiveTags.has(tag)) {
return false;
}
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
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 +225,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
94 changes: 94 additions & 0 deletions lib/utils/html-interactive-content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use strict';

/**
* HTML "interactive content" classification, authoritative per
* [HTML Living Standard §3.2.5.2.7 Interactive content]
* (https://html.spec.whatwg.org/multipage/dom.html#interactive-content):
*
* a (if the href attribute is present), audio (if the controls attribute
* is present), button, details, embed, iframe, img (if the usemap
* attribute is present), input (if the type attribute is not in the
* Hidden state), label, select, textarea, video (if the controls
* attribute is present).
*
* Plus <summary>, which is not in §3.2.5.2.7 but is keyboard-activatable per
* [§4.11.2 The summary element](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-summary-element).
*
* This is the HTML-content-model authority — it answers "does the HTML spec
* prohibit nesting this inside an interactive parent?" It does NOT answer
* "is this an ARIA widget for AT semantics?" (see `interactive-roles.js`
* for that). The two questions diverge on rows like <label> (HTML: yes;
* ARIA: structure role), <canvas> (HTML: no; ARIA: widget per axobject),
* and <option>/<datalist> (HTML: no; ARIA: widgets). Rules that need
* "interactive for any reason" should compose both authorities.
*/

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

/**
* Determine whether a Glimmer element node is HTML-interactive content per
* §3.2.5.2.7 (+ summary).
*
* @param {object} node Glimmer ElementNode (has a string `tag`).
* @param {Function} getTextAttrValue Helper (node, attrName) -> string | undefined
* returning the static text value of an
* attribute, or undefined for dynamic / missing.
* @param {object} [options]
* @param {boolean} [options.ignoreUsemap=false] Treat `<img usemap>` as NOT interactive.
* Consumed by rules with an `ignoreUsemap`
* config option that lets authors opt out
* of image-map-based interactivity.
* @returns {boolean}
*/
function isHtmlInteractiveContent(node, getTextAttrValue, options = {}) {
const rawTag = node && node.tag;
if (typeof rawTag !== 'string' || rawTag.length === 0) {
return false;
}
const tag = rawTag.toLowerCase();

if (UNCONDITIONAL_INTERACTIVE_TAGS.has(tag)) {
return true;
}

// input — interactive unless type="hidden"
if (tag === 'input') {
const type = getTextAttrValue(node, 'type');
return type === undefined || type === null || type.trim().toLowerCase() !== 'hidden';
}

// a — interactive only when href is present
if (tag === 'a') {
return hasAttribute(node, 'href');
}

// img — interactive only when usemap is present (image map)
if (tag === 'img') {
if (options.ignoreUsemap) {
return false;
}
return hasAttribute(node, 'usemap');
}

// audio / video — interactive only when controls is present
if (tag === 'audio' || tag === 'video') {
return hasAttribute(node, 'controls');
}

return false;
}

function hasAttribute(node, name) {
return Boolean(node.attributes && node.attributes.some((a) => a.name === name));
}

module.exports = { isHtmlInteractiveContent };
Loading
Loading