Skip to content

Commit 4e2406e

Browse files
committed
Add dialog/popover scoping, dynamic role guard, and widen block scoping
1 parent 131cd12 commit 4e2406e

2 files changed

Lines changed: 40 additions & 35 deletions

File tree

lib/rules/template-no-duplicate-landmark-elements.js

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,25 @@ module.exports = {
131131
}
132132
}
133133

134+
// Track elements that create new landmark scopes (dialog, popover)
135+
const newScopeElements = [];
136+
134137
return {
135138
GlimmerElementNode(node) {
136139
elementStack.push(node.tag);
137140

141+
// <dialog> and elements with popover attribute create isolated landmark scopes
142+
if (isNewScopeElement(node)) {
143+
landmarksStack.push(new Map());
144+
newScopeElements.push(node);
145+
}
146+
147+
// Dynamic role values — skip (can't determine role statically)
148+
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
149+
if (roleAttr && roleAttr.value?.type !== 'GlimmerTextNode') {
150+
return;
151+
}
152+
138153
const landmarkRole = getLandmarkRole(
139154
node,
140155
LANDMARK_ROLES,
@@ -150,41 +165,22 @@ module.exports = {
150165
}
151166
},
152167

153-
'GlimmerElementNode:exit'() {
168+
'GlimmerElementNode:exit'(node) {
154169
elementStack.pop();
155-
},
156-
157-
GlimmerBlock(node) {
158-
const parent = node.parent;
159-
if (parent && isIfUnless(parent)) {
160-
// Entering a branch of an if/unless block.
161-
// Push a copy of the scope from BEFORE the conditional
162-
// (which is the second-to-last on the stack, since the
163-
// conditional's own scope was pushed on enter).
164-
const parentScope = landmarksStack.at(-2) || landmarksStack.at(-1);
165-
landmarksStack.push(cloneLandmarks(parentScope));
166-
}
167-
},
168-
169-
'GlimmerBlock:exit'(node) {
170-
const parent = node.parent;
171-
if (parent && isIfUnless(parent)) {
170+
if (newScopeElements.at(-1) === node) {
171+
newScopeElements.pop();
172172
landmarksStack.pop();
173173
}
174174
},
175175

176-
GlimmerBlockStatement(node) {
177-
if (isIfUnless(node)) {
178-
// Push a scope for the conditional block itself.
179-
// Each branch (GlimmerBlock) will push its own scope on top.
180-
landmarksStack.push(cloneLandmarks(currentLandmarks()));
181-
}
176+
// Every Block gets its own scope (matching the original's Block visitor).
177+
// This handles each, let, with, etc. — not just if/unless.
178+
GlimmerBlock() {
179+
landmarksStack.push(cloneLandmarks(currentLandmarks()));
182180
},
183181

184-
'GlimmerBlockStatement:exit'(node) {
185-
if (isIfUnless(node)) {
186-
landmarksStack.pop();
187-
}
182+
'GlimmerBlock:exit'() {
183+
landmarksStack.pop();
188184
},
189185

190186
'Program:exit'() {
@@ -194,13 +190,6 @@ module.exports = {
194190
},
195191
};
196192

197-
function isIfUnless(node) {
198-
if (node.type === 'GlimmerBlockStatement' && node.path?.type === 'GlimmerPathExpression') {
199-
return ['if', 'unless'].includes(node.path.original);
200-
}
201-
return false;
202-
}
203-
204193
function getLabel(node) {
205194
// Check aria-label
206195
const ariaLabel = node.attributes?.find((attr) => attr.name === 'aria-label');
@@ -232,6 +221,10 @@ function getRoleValue(node) {
232221
return null;
233222
}
234223

224+
function isNewScopeElement(node) {
225+
return node.tag === 'dialog' || node.attributes?.some((attr) => attr.name === 'popover');
226+
}
227+
235228
function getLandmarkRole(node, LANDMARK_ROLES, ELEMENT_TO_ROLE, insideSectioning) {
236229
const role = getRoleValue(node);
237230
if (role && LANDMARK_ROLES.has(role)) {

tests/lib/rules/template-no-duplicate-landmark-elements.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ ruleTester.run('template-no-duplicate-landmark-elements', rule, {
1818
'<template>{{#if this.isCreateProjectFromSavedSearchEnabled}}<form></form>{{else}}<form></form>{{/if}}</template>',
1919
// header inside sectioning element loses landmark role
2020
"<template><main><header><h1>Main Page Header</h1></header></main><dialog id='my-dialog'><header><h1>Dialog Header</h1></header></dialog></template>",
21+
// Landmarks inside dialog are in a separate scope
22+
'<template><nav></nav><dialog><nav></nav></dialog></template>',
23+
// Dynamic role values — can't determine role statically
24+
'<template><div role={{this.role}}></div><div role={{this.role}}></div></template>',
2125
],
2226

2327
invalid: [
@@ -81,6 +85,14 @@ hbsRuleTester.run('template-no-duplicate-landmark-elements (hbs)', rule, {
8185
"<main><header><h1>Main Page Header</h1></header><button commandfor='my-dialog'>Open Dialog</button></main><div popover id='my-dialog'><header><h1>Dialog Header</h1></header></div>",
8286
// Conditional branches: elements in if/else are mutually exclusive
8387
'{{#if this.isCreateProjectFromSavedSearchEnabled}}<form></form>{{else}}<form></form>{{/if}}',
88+
// Landmarks inside dialog are in a separate scope
89+
'<nav></nav><dialog><nav></nav></dialog>',
90+
// Landmarks inside popover element are in a separate scope
91+
'<nav></nav><div popover><nav></nav></div>',
92+
// Dynamic role values — can't determine role statically
93+
'<div role={{this.role}}></div><div role={{this.role}}></div>',
94+
// Each blocks: landmarks in each body are scoped
95+
'{{#each this.items as |item|}}<nav aria-label={{item.label}}></nav>{{/each}}',
8496
],
8597
invalid: [
8698
{

0 commit comments

Comments
 (0)