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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 88 additions & 35 deletions app/components/Outline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
<div className={classNames('relative self-start', containerClassName)}>
<nav
Expand All @@ -88,27 +91,7 @@ export function Outline({
{headings.length > 0 && (
<>
<p className="qe-outline__title">On this page</p>
<ul className="qe-outline__list">
{tree.map((branch) => (
<li
key={`outline-li-${branch.id}`}
className={classNames({
'qe-outline__expanded': branch.children.length > 0 && branch.id === expandedId,
})}
>
<Entry heading={branch} currentId={currentId} Link={Link} />
{branch.children.length > 0 && (
<ul>
{branch.children.map((child) => (
<li key={`outline-li-${child.id}`} className="qe-outline__sub">
<Entry heading={child} currentId={currentId} Link={Link} />
</li>
))}
</ul>
)}
</li>
))}
</ul>
<Branches nodes={tree} depth={0} currentId={currentId} open={open} Link={Link} />
</>
)}
<div className="qe-outline__logo">
Expand All @@ -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<string> {
const open = new Set<string>();
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<string>;
Link: ReturnType<typeof useLinkProvider>;
}) {
return (
<ul className={depth === 0 ? 'qe-outline__list' : undefined}>
{nodes.map((node) => (
<li
key={`outline-li-${node.id}`}
className={classNames({
'qe-outline__sub': depth > 0,
'qe-outline__expanded': node.children.length > 0 && open.has(node.id),
})}
>
<Entry heading={node} currentId={currentId} Link={Link} />
{node.children.length > 0 && (
<Branches
nodes={node.children}
depth={depth + 1}
currentId={currentId}
open={open}
Link={Link}
/>
)}
</li>
))}
</ul>
);
}

function Entry({
heading,
currentId,
Expand Down
22 changes: 13 additions & 9 deletions docs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions styles/quantecon.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
11 changes: 10 additions & 1 deletion tests/visual/fixture-no-thebe/outline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
55 changes: 43 additions & 12 deletions tests/visual/theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand Down Expand Up @@ -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));
Expand Down
Loading