Skip to content

Commit 28bfda5

Browse files
committed
feat(template-no-nested-interactive): surface triggering attribute in error message
The error said "Do not nest interactive element <button> inside <div>." which hides why the rule fired — authors had to inspect the rule source to find that role/contenteditable/tabindex on the parent or child was the trigger. Now the message names the disambiguating attribute when the bare tag would be uninformative: Do not nest interactive element <button> inside <div role="menu">. Do not nest interactive element <input> inside <div contenteditable>. Do not nest interactive element <div tabindex="1"> inside <button>. Self-explanatory native interactive tags (button, input, a, etc.) keep their bare-tag form to avoid redundant noise. Applied symmetrically to both parent and child sides.
1 parent 133f2a9 commit 28bfda5

2 files changed

Lines changed: 120 additions & 4 deletions

File tree

lib/rules/template-no-nested-interactive.js

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,50 @@ function isMenuItemNode(node) {
4949
return MENUITEM_ROLES.has(getTextAttr(node, 'role'));
5050
}
5151

52+
// Build the element-description string used in error messages. Surfaces the
53+
// attribute that *makes* the element interactive when the bare tag would be
54+
// uninformative — e.g. `<div role="menu">`, `<div contenteditable>`, or
55+
// `<div tabindex="0">`. For self-explanatory native interactive tags
56+
// (button, input, a, etc.) the tag alone is returned, since adding the
57+
// triggering attribute would be redundant noise.
58+
function describeInteractive(node) {
59+
const tag = node.tag;
60+
61+
const role = getTextAttr(node, 'role');
62+
if (role && INTERACTIVE_ROLES.has(role)) {
63+
return `${tag} role="${role}"`;
64+
}
65+
66+
if (hasAttr(node, 'contenteditable')) {
67+
const ce = getTextAttr(node, 'contenteditable');
68+
const normalized = typeof ce === 'string' ? ce.trim().toLowerCase() : ce;
69+
if (normalized !== 'false') {
70+
// Surface 'plaintext-only' as a distinct spec keyword; collapse the
71+
// empty string, 'true', and the bare attribute to a uniform form.
72+
if (normalized === 'plaintext-only') {
73+
return `${tag} contenteditable="plaintext-only"`;
74+
}
75+
return `${tag} contenteditable`;
76+
}
77+
}
78+
79+
// Tabindex-only interactivity: the tag (typically <div>/<span>) carries no
80+
// signal on its own, so surface the tabindex value. Skip for elements that
81+
// are already interactive via tag/usemap/canvas — for those the tag itself
82+
// is the source of interactivity and the tabindex would be redundant.
83+
if (
84+
hasAttr(node, 'tabindex') &&
85+
!isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap: false }) &&
86+
tag !== 'canvas' &&
87+
!(tag === 'object' && hasAttr(node, 'usemap'))
88+
) {
89+
const tabindex = getTextAttr(node, 'tabindex');
90+
return tabindex === undefined ? `${tag} tabindex` : `${tag} tabindex="${tabindex}"`;
91+
}
92+
93+
return tag;
94+
}
95+
5296
function isAllowedDetailsChild(childNode, parentEntry) {
5397
if (parentEntry.tag !== 'details') {
5498
return false;
@@ -222,7 +266,7 @@ module.exports = {
222266
context.report({
223267
node,
224268
messageId: 'nested',
225-
data: { parent: parentEntry.tag, child: node.tag },
269+
data: { parent: parentEntry.describe, child: describeInteractive(node) },
226270
});
227271
}
228272
parentEntry.interactiveChildCount++;
@@ -244,15 +288,20 @@ module.exports = {
244288
context.report({
245289
node,
246290
messageId: 'nested',
247-
data: { parent: parentEntry.tag, child: node.tag },
291+
data: { parent: parentEntry.describe, child: describeInteractive(node) },
248292
});
249293
}
250294
}
251295

252296
// Push interactive elements to the stack, but tabindex-only elements
253297
// should not become parent interactive nodes
254298
if (currentIsInteractive && !isInteractiveOnlyFromTabindex(node)) {
255-
interactiveStack.push({ tag: node.tag, node, interactiveChildCount: 0 });
299+
interactiveStack.push({
300+
tag: node.tag,
301+
node,
302+
describe: describeInteractive(node),
303+
interactiveChildCount: 0,
304+
});
256305
}
257306
},
258307

tests/lib/rules/template-no-nested-interactive.js

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,18 @@ ruleTester.run('template-no-nested-interactive', rule, {
274274
output: null,
275275
errors: [{ messageId: 'nested' }],
276276
},
277+
// Error message surfaces the attribute that makes a <div> parent interactive
278+
// (regression test for the original report against `<div role="menu">`).
279+
{
280+
code: '<template><div role="menu"><input type="search"></div></template>',
281+
output: null,
282+
errors: [
283+
{
284+
message:
285+
'Do not nest interactive element <input> inside <div role="menu">.',
286+
},
287+
],
288+
},
277289
],
278290
});
279291

@@ -422,7 +434,9 @@ hbsRuleTester.run('template-no-nested-interactive', rule, {
422434
{
423435
code: '<button><div tabindex="1"></div></button>',
424436
output: null,
425-
errors: [{ message: 'Do not nest interactive element <div> inside <button>.' }],
437+
errors: [
438+
{ message: 'Do not nest interactive element <div tabindex="1"> inside <button>.' },
439+
],
426440
},
427441
{
428442
code: '<button><img usemap=""></button>',
@@ -466,5 +480,58 @@ hbsRuleTester.run('template-no-nested-interactive', rule, {
466480
output: null,
467481
errors: [{ message: 'Do not nest interactive element <button> inside <canvas>.' }],
468482
},
483+
// Error message surfaces the attribute that makes a <div> parent interactive,
484+
// so authors can see *why* the rule fired without inspecting the rule source.
485+
{
486+
code: '<div role="menu"><input type="search"></div>',
487+
output: null,
488+
errors: [
489+
{
490+
message:
491+
'Do not nest interactive element <input> inside <div role="menu">.',
492+
},
493+
],
494+
},
495+
{
496+
code: '<div role="menu"><button type="button">Delete</button></div>',
497+
output: null,
498+
errors: [
499+
{
500+
message:
501+
'Do not nest interactive element <button> inside <div role="menu">.',
502+
},
503+
],
504+
},
505+
{
506+
code: '<div contenteditable><button>Edit</button></div>',
507+
output: null,
508+
errors: [
509+
{
510+
message:
511+
'Do not nest interactive element <button> inside <div contenteditable>.',
512+
},
513+
],
514+
},
515+
{
516+
code: '<div contenteditable="plaintext-only"><button>Edit</button></div>',
517+
output: null,
518+
errors: [
519+
{
520+
message:
521+
'Do not nest interactive element <button> inside <div contenteditable="plaintext-only">.',
522+
},
523+
],
524+
},
525+
// Child-side enrichment: a <div role="button"> child surfaces its role too.
526+
{
527+
code: '<button><div role="button">Inner</div></button>',
528+
output: null,
529+
errors: [
530+
{
531+
message:
532+
'Do not nest interactive element <div role="button"> inside <button>.',
533+
},
534+
],
535+
},
469536
],
470537
});

0 commit comments

Comments
 (0)