eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
337 lines (304 loc) • 12.6 kB
JavaScript
'use strict';
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);
}
function getTextAttr(node, name) {
const attr = node.attributes?.find((a) => a.name === name);
if (attr?.value?.type === 'GlimmerTextNode') {
return attr.value.chars;
}
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) {
// 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'));
}
// Build the element-description string used in error messages. Surfaces the
// attribute that *makes* the element interactive when the bare tag would be
// uninformative — e.g. `<div role="menu">`, `<div contenteditable>`, or
// `<div tabindex="0">`. For self-explanatory native interactive tags
// (button, input, a, etc.) the tag alone is returned, since adding the
// triggering attribute would be redundant noise.
function describeInteractive(node) {
const tag = node.tag;
const role = getTextAttr(node, 'role');
if (role && INTERACTIVE_ROLES.has(role)) {
return `${tag} role="${role}"`;
}
if (hasAttr(node, 'contenteditable')) {
const ce = getTextAttr(node, 'contenteditable');
const normalized = typeof ce === 'string' ? ce.trim().toLowerCase() : ce;
if (normalized !== 'false') {
// Surface 'plaintext-only' as a distinct spec keyword; collapse the
// empty string, 'true', and the bare attribute to a uniform form.
if (normalized === 'plaintext-only') {
return `${tag} contenteditable="plaintext-only"`;
}
return `${tag} contenteditable`;
}
}
// Tabindex-only interactivity: the tag (typically <div>/<span>) carries no
// signal on its own, so surface the tabindex value. Skip for elements that
// are already interactive via tag/usemap/canvas — for those the tag itself
// is the source of interactivity and the tabindex would be redundant.
if (
hasAttr(node, 'tabindex') &&
!isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap: false }) &&
tag !== 'canvas' &&
!(tag === 'object' && hasAttr(node, 'usemap'))
) {
const tabindex = getTextAttr(node, 'tabindex');
return tabindex === undefined ? `${tag} tabindex` : `${tag} tabindex="${tabindex}"`;
}
return tag;
}
function isAllowedDetailsChild(childNode, parentEntry) {
if (parentEntry.tag !== 'details') {
return false;
}
// Non-<summary> children are flow content in the disclosed panel — allowed.
// <summary> is only allowed as the first non-whitespace child of <details>.
if (childNode.tag !== 'summary') {
return true;
}
const children = parentEntry.node.children || [];
const firstNonWhitespace = children.find((child) => {
if (child.type === 'GlimmerTextNode') {
return child.chars.trim().length > 0;
}
return true;
});
return firstNonWhitespace === childNode;
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow nested interactive elements',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-nested-interactive.md',
templateMode: 'both',
},
fixable: null,
schema: [
{
type: 'object',
properties: {
additionalInteractiveTags: { type: 'array', items: { type: 'string' } },
ignoredTags: { type: 'array', items: { type: 'string' } },
ignoreTabindex: { type: 'boolean' },
ignoreUsemap: { type: 'boolean' },
ignoreUsemapAttribute: { type: 'boolean' },
},
additionalProperties: false,
},
],
messages: {
nested: 'Do not nest interactive element <{{child}}> inside <{{parent}}>.',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-nested-interactive.js',
docs: 'docs/rule/no-nested-interactive.md',
tests: 'test/unit/rules/no-nested-interactive-test.js',
},
},
create(context) {
const options = context.options[0] || {};
const additionalInteractiveTags = new Set(options.additionalInteractiveTags || []);
const ignoredTags = new Set(options.ignoredTags || []);
const ignoreTabindex = options.ignoreTabindex || false;
const ignoreUsemap = options.ignoreUsemap || options.ignoreUsemapAttribute || false;
const interactiveStack = [];
// Stack for saving/restoring label interactiveChildCount across GlimmerBlock boundaries
const blockCountStack = [];
function isInteractive(node) {
const tag = node.tag?.toLowerCase();
if (!tag) {
return false;
}
if (ignoredTags.has(tag)) {
return false;
}
if (additionalInteractiveTags.has(tag)) {
return true;
}
// 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;
}
// <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;
}
// 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;
}
// Check tabindex
if (!ignoreTabindex && hasAttr(node, 'tabindex')) {
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;
}
}
// <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;
}
return false;
}
/**
* Returns true if the element is interactive ONLY because of tabindex
* (not because of tag name, role, contenteditable, usemap, etc.)
* Called only after isInteractive() already returned true.
*/
function isInteractiveOnlyFromTabindex(node) {
const tag = node.tag?.toLowerCase();
if (!tag) {
return false;
}
if (additionalInteractiveTags.has(tag)) {
return false;
}
if (tag === 'canvas') {
return false;
}
if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
return false;
}
const role = getTextAttr(node, 'role');
if (role && INTERACTIVE_ROLES.has(role)) {
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;
}
return hasAttr(node, 'tabindex');
}
return {
GlimmerElementNode(node) {
const currentIsInteractive = isInteractive(node);
if (currentIsInteractive && interactiveStack.length > 0) {
const parentEntry = interactiveStack.at(-1);
if (parentEntry.tag === 'label') {
// Label can contain ONE interactive child — track and flag additional ones
if (parentEntry.interactiveChildCount >= 1) {
context.report({
node,
messageId: 'nested',
data: { parent: parentEntry.describe, child: describeInteractive(node) },
});
}
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 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,
messageId: 'nested',
data: { parent: parentEntry.describe, child: describeInteractive(node) },
});
}
}
// Push interactive elements to the stack, but tabindex-only elements
// should not become parent interactive nodes
if (currentIsInteractive && !isInteractiveOnlyFromTabindex(node)) {
interactiveStack.push({
tag: node.tag,
node,
describe: describeInteractive(node),
interactiveChildCount: 0,
});
}
},
'GlimmerElementNode:exit'(node) {
if (interactiveStack.length > 0 && interactiveStack.at(-1).node === node) {
interactiveStack.pop();
}
},
// Save/restore label interactive child count at block boundaries
// so that conditional branches ({{#if}}/{{else}}) are tracked independently
GlimmerBlock() {
const labelEntry = interactiveStack.length > 0 ? interactiveStack.at(-1) : null;
if (labelEntry && labelEntry.tag === 'label') {
blockCountStack.push(labelEntry.interactiveChildCount);
} else {
blockCountStack.push(null);
}
},
'GlimmerBlock:exit'() {
const saved = blockCountStack.pop();
if (saved !== null && saved !== undefined) {
const labelEntry = interactiveStack.length > 0 ? interactiveStack.at(-1) : null;
if (labelEntry && labelEntry.tag === 'label') {
labelEntry.interactiveChildCount = saved;
}
}
},
};
},
};