Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions lib/rules/template-no-nested-interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,50 @@ function isMenuItemNode(node) {
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;
Expand Down Expand Up @@ -222,7 +266,7 @@ module.exports = {
context.report({
node,
messageId: 'nested',
data: { parent: parentEntry.tag, child: node.tag },
data: { parent: parentEntry.describe, child: describeInteractive(node) },
});
}
parentEntry.interactiveChildCount++;
Expand All @@ -244,15 +288,20 @@ module.exports = {
context.report({
node,
messageId: 'nested',
data: { parent: parentEntry.tag, child: node.tag },
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, interactiveChildCount: 0 });
interactiveStack.push({
tag: node.tag,
node,
describe: describeInteractive(node),
interactiveChildCount: 0,
});
}
},

Expand Down
62 changes: 61 additions & 1 deletion tests/lib/rules/template-no-nested-interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,17 @@ ruleTester.run('template-no-nested-interactive', rule, {
output: null,
errors: [{ messageId: 'nested' }],
},
// Error message surfaces the attribute that makes a <div> parent interactive
// (regression test for the original report against `<div role="menu">`).
{
code: '<template><div role="menu"><input type="search"></div></template>',
output: null,
errors: [
{
message: 'Do not nest interactive element <input> inside <div role="menu">.',
},
],
},
],
});

Expand Down Expand Up @@ -444,7 +455,7 @@ hbsRuleTester.run('template-no-nested-interactive', rule, {
{
code: '<button><div tabindex="1"></div></button>',
output: null,
errors: [{ message: 'Do not nest interactive element <div> inside <button>.' }],
errors: [{ message: 'Do not nest interactive element <div tabindex="1"> inside <button>.' }],
},
{
code: '<button><img usemap=""></button>',
Expand Down Expand Up @@ -488,5 +499,54 @@ hbsRuleTester.run('template-no-nested-interactive', rule, {
output: null,
errors: [{ message: 'Do not nest interactive element <button> inside <canvas>.' }],
},
// Error message surfaces the attribute that makes a <div> parent interactive,
// so authors can see *why* the rule fired without inspecting the rule source.
{
code: '<div role="menu"><input type="search"></div>',
output: null,
errors: [
{
message: 'Do not nest interactive element <input> inside <div role="menu">.',
},
],
},
{
code: '<div role="menu"><button type="button">Delete</button></div>',
output: null,
errors: [
{
message: 'Do not nest interactive element <button> inside <div role="menu">.',
},
],
},
{
code: '<div contenteditable><button>Edit</button></div>',
output: null,
errors: [
{
message: 'Do not nest interactive element <button> inside <div contenteditable>.',
},
],
},
{
code: '<div contenteditable="plaintext-only"><button>Edit</button></div>',
output: null,
errors: [
{
message:
'Do not nest interactive element <button> inside <div contenteditable="plaintext-only">.',
},
],
},
// Child-side enrichment: a <div role="button"> child surfaces its role too.
{
code: '<button><div role="button">Inner</div></button>',
output: null,
errors: [
{
message: 'Do not nest interactive element <div role="button"> inside <button>.',
},
],
},
],
});
Loading