Skip to content

Commit f8309da

Browse files
Merge pull request #564 from universal-ember/heading/add-article-and-aside
Heading: add more elements to auto-leveling detection, supporting usage in qunit testing, and in general just be more robust
2 parents 38c2f19 + 6ef7cc2 commit f8309da

File tree

4 files changed

+426
-24
lines changed

4 files changed

+426
-24
lines changed

docs-app/public/docs/3-ui/heading.md

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This enables distributed teams to correctly produce appropriate section heading
1010

1111
## Usage
1212

13-
In your app (and in this demo), you'll use `<section>` elements to denote when the [_Section Heading_][mdn-h] element should change its level.
13+
In your app, you can use any of `<section>`, `<article>`, and `<aside>` elements to denote when the [_Section Heading_][mdn-h] element should change its level.
1414
Note that this demo starts with `h3`, because this docs page already has an `h1`, and _this_ section (Usage) uses an `h2`.
1515

1616
<div class="featured-demo auto-height">
@@ -19,42 +19,105 @@ Note that this demo starts with `h3`, because this docs page already has an `h1`
1919
import { Heading } from 'ember-primitives/components/heading';
2020
2121
<template>
22-
<Heading>a heading</Heading>
23-
<Heading>a heading</Heading>
24-
25-
<section>
26-
<Heading>a heading</Heading>
22+
<main aria-label="heading-demo">
2723
<Heading>a heading</Heading>
28-
<section>
24+
25+
<nav>
26+
<Heading>a heading</Heading>
27+
</nav>
28+
29+
<article>
30+
<Heading>a heading</Heading>
31+
<a href="#">
32+
<Heading>a heading</Heading>
33+
</a>
2934
<Heading>a heading</Heading>
3035
<section>
3136
<Heading>a heading</Heading>
3237
<Heading>a heading</Heading>
38+
<a href="#">
39+
<Heading>a heading</Heading>
40+
</a>
3341
</section>
34-
<Heading>a heading</Heading>
35-
</section>
36-
</section>
42+
<footer>
43+
<Heading>a heading</Heading>
44+
45+
</footer>
46+
</article>
47+
</main>
3748
3849
<style>
39-
h3, h4, h5, h6 { margin: 0; }
50+
h1, h2, h3, h4, h5, h6 { margin: 0; }
4051
41-
h3::before, h4::before, h5::before, h6::before {
52+
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
4253
position: absolute;
4354
margin-left: -1.2em;
4455
font-size: 0.7em;
4556
text-align: right;
4657
opacity: 0.8;
4758
}
4859
60+
h1 { font-size: 2.5rem; }
61+
h2 { font-size: 2.25rem; }
4962
h3 { font-size: 2rem; }
5063
h4 { font-size: 1.5rem; }
5164
h5 { font-size: 1.25rem; }
5265
h6 { font-size: 1rem; }
66+
a { color: white; }
5367
68+
h1::before { content: 'h1'; }
69+
h2::before { content: 'h2'; }
5470
h3::before { content: 'h3'; }
5571
h4::before { content: 'h4'; }
5672
h5::before { content: 'h5'; }
5773
h6::before { content: 'h6'; }
74+
75+
article, section, aside, nav, main, footer {
76+
border: 1px dotted;
77+
padding: 0.25rem 1.5rem;
78+
padding-left: 2rem;
79+
padding-right: 0.5rem;
80+
position: relative;
81+
82+
&::before {
83+
position: absolute;
84+
right: 0.5rem;
85+
top: -1rem;
86+
font-size: 0.7rem;
87+
text-align: right;
88+
opacity: 0.8;
89+
}
90+
}
91+
92+
section, article {
93+
display: flex;
94+
flex-direction: column;
95+
gap: 0.75rem;
96+
}
97+
98+
article::before { content: '<article>'; }
99+
section::before { content: '<section>'; }
100+
aside::before { content: '<aside>'; }
101+
nav::before { content: '<nav>'; }
102+
main::before { content: '<main>'; }
103+
footer::before { content: '<footer>'; }
104+
105+
main {
106+
display: grid;
107+
gap: 2.5rem;
108+
grid-template-columns: max-content 1fr;
109+
grid-template-areas:
110+
"heading heading"
111+
"nav content"
112+
"nav content"
113+
"nav content";
114+
115+
}
116+
117+
>:first-child { grid-area: heading; }
118+
nav { grid-area: nav; }
119+
article { grid-area: content; }
120+
58121
</style>
59122
</template>
60123
```

docs-app/tests/application/pages-test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,28 @@ import { a11yAudit } from 'ember-a11y-testing/test-support';
99
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
1010
const pages: { path: string }[] = (window as any).__pages__;
1111

12+
/**
13+
* per-page settings
14+
*/
15+
const a11yChecks: {
16+
[url: string]: {
17+
[checkName: string]: Record<string, unknown>;
18+
};
19+
} = {
20+
'/3-ui/heading.md': {
21+
'landmark-main-is-top-level': {
22+
enabled: false,
23+
},
24+
'landmark-no-duplicate-main': {
25+
enabled: false,
26+
},
27+
},
28+
};
29+
1230
/**
1331
* a11yAudit halts tests, this gets around that
1432
*/
15-
async function checkA11y(assert: Assert, path: string, theme: string) {
33+
async function checkA11y(assert: Assert, path: string, theme: string, settings: object) {
1634
await settled();
1735

1836
try {
@@ -23,6 +41,7 @@ async function checkA11y(assert: Assert, path: string, theme: string) {
2341
'color-contrast': {
2442
enabled: false,
2543
},
44+
...settings,
2645
},
2746
});
2847
assert.ok(true, `no a11y errors found for ${path} using the ${theme} theme`);
@@ -52,18 +71,19 @@ module('Application | Pages', function (hooks) {
5271
for (const page of pages) {
5372
test(`${page.path}`, async function (assert) {
5473
const path = page.path.replace('.md', '');
74+
const settings: object = a11yChecks[page.path] ?? {};
5575

5676
await visit(path);
5777
await waitUntil(() => findAll('nav a').length !== 0);
58-
await checkA11y(assert, path, 'default');
78+
await checkA11y(assert, path, 'default', settings);
5979

6080
assert.dom('[data-page-error]').doesNotExist();
6181

6282
colorScheme.update('dark');
63-
await checkA11y(assert, path, 'dark');
83+
await checkA11y(assert, path, 'dark', settings);
6484

6585
colorScheme.update('light');
66-
await checkA11y(assert, path, 'light');
86+
await checkA11y(assert, path, 'light', settings);
6787
});
6888
}
6989
});

ember-primitives/src/components/heading.gts

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,56 @@
11
import Component from "@glimmer/component";
2+
import { assert } from "@ember/debug";
23

34
import { element } from "ember-element-helper";
45

56
import type Owner from "@ember/owner";
67

78
const LOOKUP = new WeakMap<Text, number>();
89

9-
function levelOf(node: Text): number {
10+
const BOUNDARY_ELEMENTS = new Set([
11+
"SECTION",
12+
"ARTICLE",
13+
"ASIDE",
14+
"HEADER",
15+
"FOOTER",
16+
"MAIN",
17+
"NAV",
18+
]);
19+
20+
/**
21+
* A set with both cases is more performant than calling toLowerCase
22+
*/
23+
const SECTION_HEADINGS = new Set([
24+
"h1",
25+
"h2",
26+
"h3",
27+
"h4",
28+
"h5",
29+
"h6",
30+
"H1",
31+
"H2",
32+
"H3",
33+
"H4",
34+
"H5",
35+
"H6",
36+
]);
37+
const TEST_BOUNDARY = "ember-testing";
38+
39+
function isRoot(element: Element) {
40+
return element === document.body || element.id === TEST_BOUNDARY;
41+
}
42+
43+
/**
44+
* The Platform native 'closest' function can't punch through shadow-boundaries
45+
*/
46+
function nearestAncestor(node: Text | Element, matcher: (el: Element) => boolean) {
1047
let parent: ParentNode | null = node.parentElement;
11-
let level = 0;
48+
49+
if (!parent) return;
1250

1351
while (parent) {
1452
if (parent instanceof Element) {
15-
if (parent.tagName.toLowerCase() === "section") {
16-
level++;
17-
}
53+
if (matcher(parent)) return parent;
1854
}
1955

2056
if (parent instanceof ShadowRoot) {
@@ -23,8 +59,69 @@ function levelOf(node: Text): number {
2359

2460
parent = parent.parentNode;
2561
}
62+
}
63+
64+
/**
65+
* The algorithm:
66+
*
67+
* section <- "our" level-changing boundary element
68+
* h# <- the element we want to figure out the level of
69+
*
70+
* We start assuming we'll emit an h1.
71+
* We adjust this based on what we find crawling up the tree.
72+
*
73+
* While traversing up, when we go from the h# to the section,
74+
* and ignore it. Because this alone has no bearing on if the h# should be an h2.
75+
* We need to continue traversing upwards, until we hit the next boundary element.
76+
*
77+
* IF we would change the level the heading, we will find another heading between
78+
* these two boundary elements.
79+
* We'll need to check the subtrees between these elements, stopping if we
80+
* encounter other boundary elements.
81+
*
82+
*/
83+
function levelOf(node: Text): number {
84+
const ourBoundary = nearestAncestor(node, (el) => BOUNDARY_ELEMENTS.has(el.tagName));
85+
86+
/**
87+
* We are the top-level
88+
*/
89+
if (!ourBoundary) {
90+
return 1;
91+
}
92+
93+
const stopAt = nearestAncestor(ourBoundary, (el) => {
94+
if (BOUNDARY_ELEMENTS.has(el.tagName)) return true;
95+
96+
return isRoot(el);
97+
});
98+
99+
assert(
100+
`[BUG]: Could not find a stopping boundary for automatic heading level detection. Checked for ${[...BOUNDARY_ELEMENTS, "body", "#ember-testing"].map((x) => x.toLowerCase()).join(", ")}`,
101+
stopAt,
102+
);
103+
104+
let current: ParentNode | null = ourBoundary.parentNode;
105+
106+
while (current) {
107+
for (const child of current.children) {
108+
if (!SECTION_HEADINGS.has(child.tagName)) continue;
109+
110+
const level = parseInt(child.tagName.replace("h", "").replace("H", ""));
111+
112+
return level + 1;
113+
}
114+
115+
if (current === stopAt) break;
116+
117+
if (current instanceof ShadowRoot) {
118+
current = current.host;
119+
}
120+
121+
current = current.parentNode;
122+
}
26123

27-
return level;
124+
return 1;
28125
}
29126

30127
export class Heading extends Component<{
@@ -44,7 +141,7 @@ export class Heading extends Component<{
44141
if (existing) return existing;
45142

46143
const parentLevel = levelOf(this.headingScopeAnchor);
47-
const myLevel = parentLevel + 1;
144+
const myLevel = parentLevel;
48145

49146
LOOKUP.set(this.headingScopeAnchor, myLevel);
50147

0 commit comments

Comments
 (0)