diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0a70869f..2c110b24b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
so no canonical names a redirect. `og:url` is built by the same function, so
the two cannot disagree. Nothing is emitted without `site_url`
([#207](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/207)) ([#227](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/227)).
+- The "On this page" outline lists h4 subsections, which the Sphinx panel lists
+ and this one left out: a reader inside an h4 saw its h3 marked with nothing
+ below it. The panel is now a tree of any depth, and the Sphinx expansion rule
+ applies at every level of it — the current entry's own sub-list and those of
+ all its ancestors are open, while only the current entry itself is marked.
+ h4 entries are indented one step further than h3s. h5 and deeper are still
+ not listed
+ ([#208](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/208)) ([#228](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/228)).
### Changed
- **Breaking: the Launch control is now opt-in and explicitly configured.** It
diff --git a/app/components/Outline.tsx b/app/components/Outline.tsx
index 0f3cce9fd..adcdb4070 100644
--- a/app/components/Outline.tsx
+++ b/app/components/Outline.tsx
@@ -52,10 +52,12 @@ export function BackToTop() {
* `sticky`: the wrapper is `self-start`, so a sticky child would have zero
* travel, and the grid declares no rows for it to span. `left`/`right` stay
* `auto`, so the panel keeps its static position in the margin track.
- * - h3 entries nest under their h2 and collapse to the active branch: at the
- * top of the page only the sections show; scrolling into a section expands
- * its subsections, the current one (section or subsection) is marked, and
- * the parent of a current subsection is expanded but not marked. Past
+ * - h2 to h4 nest into a tree of any depth, which collapses to the active
+ * branch: at the top of the page only the sections show, and a branch opens
+ * when the current entry is inside it. The rule runs at every level -- the
+ * current entry's own sub-list and those of all its ancestors are open --
+ * so a current h3 shows its h4s. Only the current entry is marked, at
+ * whatever depth it sits; its ancestors are open but unmarked. Past
* `max-height` the panel scrolls internally.
* - Enumerators come from the heading itself (`span.select-none`, "3.1"),
* plus the period the h1 uses -- never a number computed from the list
@@ -71,14 +73,15 @@ export function Outline({
}) {
const Link = useLinkProvider();
const baseurl = useBaseurl();
- const { headings } = useHeaders('main h2, main h3', 3);
+ // h2 to h4, the depths the Sphinx panel lists. `maxdepth` stays 3 because
+ // upstream renumbers levels from the shallowest heading on the page and keeps
+ // `level < maxdepth + 1`: with an h2 present, an h4 is level 3.
+ const { headings } = useHeaders('main h2, main h3, main h4', 3);
const currentId = useActiveHeading(headings);
const tree = nest(headings);
- // The current item's own sub-list, and every ancestor of the current item,
- // are expanded; nothing else is.
- const expandedId = tree.find(
- (branch) => branch.id === currentId || branch.children.some((c) => c.id === currentId)
- )?.id;
+ // The current entry's own sub-list and those of all its ancestors are open;
+ // nothing else is.
+ const open = openBranches(tree, currentId);
return (
0 && (
<>
On this page
-
- {tree.map((branch) => (
- 0 && branch.id === expandedId,
- })}
- >
-
- {branch.children.length > 0 && (
-
- {branch.children.map((child) => (
-
-
-
- ))}
-
- )}
-
- ))}
-
+
>
)}
@@ -134,18 +117,88 @@ export function Outline({
}
type OutlineHeading = { id: string; title: string; level: number; element: HTMLElement };
-type Branch = OutlineHeading & { children: OutlineHeading[] };
+type Branch = OutlineHeading & { children: Branch[] };
-/** h3s under the h2 before them; a leading h3 with no h2 starts its own branch. */
+/**
+ * Nests each heading under the nearest preceding shallower one, to any depth.
+ * A heading with nothing shallower before it starts its own branch, so a page
+ * whose first heading is an h3 still renders.
+ */
function nest(headings: OutlineHeading[]): Branch[] {
const tree: Branch[] = [];
+ const ancestors: Branch[] = [];
for (const h of headings) {
- if (h.level > 1 && tree.length > 0) tree[tree.length - 1].children.push(h);
- else tree.push({ ...h, children: [] });
+ const branch: Branch = { ...h, children: [] };
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1].level >= h.level) {
+ ancestors.pop();
+ }
+ if (ancestors.length > 0) ancestors[ancestors.length - 1].children.push(branch);
+ else tree.push(branch);
+ ancestors.push(branch);
}
return tree;
}
+/**
+ * The ids whose sub-lists the panel opens: the current entry's and every
+ * ancestor's, which is the rule the Sphinx panel's scrollspy applies at any
+ * depth. Marking is separate -- only the current entry itself is marked.
+ */
+function openBranches(tree: Branch[], currentId?: string): Set
{
+ const open = new Set();
+ if (!currentId) return open;
+ const walk = (nodes: Branch[], ancestors: string[]): boolean =>
+ nodes.some((node) => {
+ if (node.id === currentId) {
+ for (const id of [...ancestors, node.id]) open.add(id);
+ return true;
+ }
+ return walk(node.children, [...ancestors, node.id]);
+ });
+ walk(tree, []);
+ return open;
+}
+
+/** One level of the outline; renders its children recursively. */
+function Branches({
+ nodes,
+ depth,
+ currentId,
+ open,
+ Link,
+}: {
+ nodes: Branch[];
+ depth: number;
+ currentId?: string;
+ open: Set;
+ Link: ReturnType;
+}) {
+ return (
+
+ {nodes.map((node) => (
+ 0,
+ 'qe-outline__expanded': node.children.length > 0 && open.has(node.id),
+ })}
+ >
+
+ {node.children.length > 0 && (
+
+ )}
+
+ ))}
+
+ );
+}
+
function Entry({
heading,
currentId,
diff --git a/docs/layout.md b/docs/layout.md
index e8c0f80e1..298f0b560 100644
--- a/docs/layout.md
+++ b/docs/layout.md
@@ -21,15 +21,19 @@ it and its toggle.
## "On this page"
The right-hand outline is pinned (`position: fixed` in the margin column),
-lists h2 and h3 headings with their section numbers when numbering is on, and
-marks the section being read in QuantEcon blue, bold, with an inset rule
-(`aria-current="location"`). The rule is the Sphinx scrollspy's: a section is
-current once its heading has passed 120px from the top, and the last section is
-current at the bottom of the page. Past the viewport height the panel scrolls
-internally behind a fade. Subsections collapse to the current branch as the
-Sphinx panel does under `contents_autoexpand`: only the sections show until
-you scroll into one, its subsections then expand, and the parent of a current
-subsection is expanded but not marked.
+lists **h2, h3 and h4** headings with their section numbers when numbering is
+on, and marks the section being read in QuantEcon blue, bold, with an inset
+rule (`aria-current="location"`). Each level is indented one step further than
+the one above it. h5 and deeper are not listed. The rule is the Sphinx
+scrollspy's: a section is current once its heading has passed 120px from the
+top, and the last section is current at the bottom of the page. Past the
+viewport height the panel scrolls internally behind a fade.
+
+Sub-lists collapse to the current branch as the Sphinx panel does under
+`contents_autoexpand`, at every depth: only the sections show until you scroll
+into one, its subsections then expand, and an h3's h4s expand once that h3 or
+one of those h4s is current. Ancestors of the current entry are expanded but
+not marked — exactly one entry is marked at a time.
## Back to top
diff --git a/styles/quantecon.css b/styles/quantecon.css
index 1369f8128..b49ebe873 100644
--- a/styles/quantecon.css
+++ b/styles/quantecon.css
@@ -582,6 +582,13 @@
.qe-outline__sub a {
padding-inline-start: 1.75rem; /* 0.75rem entry padding + 1rem indent */
}
+ /* One more step for the level below (h4 under an h3). Keyed on nesting
+ rather than a depth class, so the step applies wherever the tree goes;
+ at (0,2,1) it outranks the single-level rule above. Logical padding, so
+ a right-to-left edition indents from the right. */
+ .qe-outline__sub .qe-outline__sub a {
+ padding-inline-start: 2.75rem; /* a second 1rem step */
+ }
.qe-outline__list a:hover {
color: var(--qe-link-color);
}
diff --git a/tests/visual/fixture-no-thebe/outline.md b/tests/visual/fixture-no-thebe/outline.md
index 463d676e7..d9bb88a57 100644
--- a/tests/visual/fixture-no-thebe/outline.md
+++ b/tests/visual/fixture-no-thebe/outline.md
@@ -4,7 +4,7 @@ title: Outline page
# Outline page
-This page exercises the "On this page" panel: two levels of headings under
+This page exercises the "On this page" panel: three levels of headings under
project numbering, so entries carry the heading's own enumerator.
## First section
@@ -15,6 +15,15 @@ Short section; scrolling here should mark it.
A level-three entry, indented in the panel.
+#### A level-four subsection
+
+A level-four entry: listed under its h3, indented one step further, and shown
+only while that h3 or one of its own h4s is current.
+
+#### Another level-four subsection
+
+A second one, so the sub-list is a list.
+
### Second subsection
Another one.
diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts
index 7e759792d..5186d6bdf 100644
--- a/tests/visual/theme.spec.ts
+++ b/tests/visual/theme.spec.ts
@@ -369,18 +369,23 @@ test.describe("On this page outline", () => {
await page.goto(`${noThebeBase}/outline`, { waitUntil: "domcontentloaded" });
await settle(page);
const entries = nav(page).locator("ul a");
- // Two h2 + two h3 + one h2 = five entries, with the heading's own
+ // Three h2, two h3 and two h4 = seven entries, each with the heading's own
// enumerator, never a list-index number.
- await expect(entries).toHaveCount(5);
+ await expect(entries).toHaveCount(7);
// (`titles: true` numbers nothing on this fixture's pages, so the
// enumerators are section-only; the lecture repos get "3.1." from the
// same span.)
await expect(entries.nth(0)).toHaveText(/^1\. First section$/);
await expect(entries.nth(1)).toHaveText(/^1\.1\. First subsection$/);
- await expect(entries.nth(4)).toHaveText(/^3\. Last section$/);
- // Autoexpand: at the top of the page only the sections show.
- const subs = nav(page).locator("li.qe-outline__sub a");
- await expect(subs).toHaveCount(2);
+ await expect(entries.nth(2)).toHaveText(/^1\.1\.1\. A level-four subsection$/);
+ await expect(entries.nth(6)).toHaveText(/^3\. Last section$/);
+ // Autoexpand: at the top of the page only the sections show. Every entry
+ // below the top level carries the sub class -- two h3s and two h4s. The
+ // child combinator matters: `li.qe-outline__sub a` would match an h4's
+ // anchor through its h3 ancestor's li, so the count would hold even if the
+ // h4's own li lost the class, and the indent rule keys on that class.
+ const subs = nav(page).locator("li.qe-outline__sub > a");
+ await expect(subs).toHaveCount(4);
await expect(subs.first()).toBeHidden();
// Scrolling into the first section expands its subsections, indented:
// the anchors are block-level, so compare their start padding.
@@ -391,8 +396,14 @@ test.describe("On this page outline", () => {
await expect(subs.first()).toBeVisible();
const pad = async (i: number) =>
parseFloat(await entries.nth(i).evaluate((a) => getComputedStyle(a).paddingInlineStart));
+ // One indent step per level: h2 < h3 < h4.
expect(await pad(1)).toBeGreaterThan(await pad(0));
- expect(await pad(3)).toBe(await pad(0));
+ expect(await pad(2)).toBeGreaterThan(await pad(1));
+ // ...and a later h2 is back at the top level's indent.
+ expect(await pad(5)).toBe(await pad(0));
+ // The h4s stay closed while only their h2 is current: an h3's sub-list
+ // opens when that h3, or one of its own h4s, is current.
+ await expect(entries.nth(2)).toBeHidden();
// Pinned: the panel's top is the same before and after a long scroll.
const top = async () => Math.round((await nav(page).boundingBox())!.y);
const before = await top();
@@ -420,17 +431,37 @@ test.describe("On this page outline", () => {
await scrollTo("second-section", 100);
await expect(current(page)).toHaveAttribute("href", /#second-section$/);
await expect(current(page)).toHaveCSS("font-weight", "600");
- // ...and the first section's sub-list has collapsed again.
+ // ...and the first section's sub-list, h4s included, has collapsed again.
await expect(nav(page).locator("li.qe-outline__sub a").first()).toBeHidden();
+ await expect(nav(page).getByRole("link", { name: /A level-four subsection/ })).toBeHidden();
// 20px short of the line: the previous section (a subsection) still holds.
await scrollTo("second-section", 140);
await expect(current(page)).toHaveAttribute("href", /#second-subsection$/);
await scrollTo("first-subsection", 100);
- // A current subsection is marked itself; its parent is expanded, not marked.
+ // A current subsection is marked itself; its ancestor is expanded, not
+ // marked. The subsection's own sub-list opens too, so both it and its
+ // parent carry the expanded class -- the rule is "the current entry and
+ // every ancestor of it".
await expect(current(page)).toHaveAttribute("href", /#first-subsection$/);
- const parent = nav(page).locator("li.qe-outline__expanded > a");
- await expect(parent).toHaveAttribute("href", /#first-section$/);
- await expect(parent).not.toHaveAttribute("aria-current", "location");
+ const expanded = nav(page).locator("li.qe-outline__expanded > a");
+ await expect(expanded).toHaveCount(2);
+ await expect(expanded.nth(0)).toHaveAttribute("href", /#first-section$/);
+ await expect(expanded.nth(1)).toHaveAttribute("href", /#first-subsection$/);
+ await expect(expanded.nth(0)).not.toHaveAttribute("aria-current", "location");
+ // Its h4s are now visible.
+ await expect(nav(page).getByRole("link", { name: /A level-four subsection/ })).toBeVisible();
+
+ // A current h4 is marked itself; its h3 and h2 are expanded, not marked.
+ await scrollTo("a-level-four-subsection", 100);
+ await expect(current(page)).toHaveAttribute("href", /#a-level-four-subsection$/);
+ await expect(current(page)).toHaveCount(1);
+ const openNow = nav(page).locator("li.qe-outline__expanded > a");
+ await expect(openNow).toHaveCount(2);
+ await expect(openNow.nth(0)).toHaveAttribute("href", /#first-section$/);
+ await expect(openNow.nth(1)).toHaveAttribute("href", /#first-subsection$/);
+ for (const i of [0, 1]) {
+ await expect(openNow.nth(i)).not.toHaveAttribute("aria-current", "location");
+ }
// The last section is too short to reach the activation window; the
// bottom-of-page rule marks it.
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));