diff --git a/src/components/ArticleNav.astro b/src/components/ArticleNav.astro
index 40deaa609c..aef1e174d4 100644
--- a/src/components/ArticleNav.astro
+++ b/src/components/ArticleNav.astro
@@ -2,7 +2,6 @@
import { accelerator } from '@lib/accelerator';
import { SITE } from '@config';
import { Translations, Lang } from '@util/Languages';
-import Separator from './Separator.astro';
const stats = new accelerator.statistics('octopus/components/ArticleNav.astro');
stats.start();
@@ -31,7 +30,6 @@ stats.stop();
{_(Translations.toc.title)}
-
{headings.map((heading) => (
diff --git a/src/pages/components.mdx b/src/pages/components.mdx
index 91b2693b1f..9c5d6abd80 100644
--- a/src/pages/components.mdx
+++ b/src/pages/components.mdx
@@ -18,7 +18,6 @@ import Card from 'src/components/Card.astro';
import IconTile from 'src/components/IconTile.astro';
import Image from 'src/components/Image.astro';
import Link from 'src/components/Link.astro';
-import Separator from 'src/components/Separator.astro';
## Component Usage Guide
@@ -34,7 +33,7 @@ To use Astro components like `Card` and `Separator` in your articles, you will n
```javascript
import Card from 'src/components/Card.astro';
-import Separator from 'src/components/Separator.astro';
+import Link from 'src/components/Link.astro';
```
## Components usage
diff --git a/src/scripts/modules/toc.js b/src/scripts/modules/toc.js
index 3af857d873..42f3fc0124 100644
--- a/src/scripts/modules/toc.js
+++ b/src/scripts/modules/toc.js
@@ -2,82 +2,115 @@
import { qsa } from './query.js';
-let links = [];
-let current = '';
-const headings = [];
const highlightClass = 'highlight';
/**
- * Makes an entire block clickable based on a data-attribute, usually "data-destination"
+ * Marks the link in a table of contents whose section the reader is in, and
+ * keeps it in step as the page scrolls.
*
- * Example: You have a list of blog posts, including featured images. If you make the title
- * clickable, clicks on the image won't open the blog. Adding links to the images means
- * keyboard users have to tab twice as much to get through the list.
- *
- * Use clickable blocks to allow keyboard users to tab through the real links, but still
- * capture clicks elsewhere on the block.
+ * Every table of contents on the page gets its own call, and each keeps its
+ * own state — a page can show more than one at a time.
*
+ * @param {string} tocSelector selector for the links of one table of contents
*/
function highlightCurrentHeading(tocSelector) {
- links = qsa(tocSelector);
+ /** @type {{link: HTMLElement, heading: HTMLElement}[]} */
+ const entries = [];
+
+ qsa(tocSelector).forEach((link) => {
+ const id = getBookmarkLink(link.href);
+ const heading = id ? document.getElementById(id) : null;
- links.forEach((link) => {
- const bookmarkLink = getBookmarkLink(link.href);
- if (bookmarkLink) {
- headings.push(document.getElementById(bookmarkLink));
+ // A link can outlive its heading — a stale anchor, or a heading rendered
+ // conditionally. Those links just never highlight.
+ if (heading) {
+ entries.push({ link, heading });
}
});
- recheck();
-}
-
-function getBookmarkLink(link) {
- const linkParts = link.split('#');
- if (linkParts.length === 2) {
- return linkParts[1];
+ if (entries.length === 0) {
+ return;
}
- return '';
-}
+ /** @type {{link: HTMLElement, heading: HTMLElement} | undefined} */
+ let current;
+ let queued = false;
-function highlight(id) {
- links.forEach((link) => {
- link.classList.remove(highlightClass);
+ const update = () => {
+ queued = false;
- const bookmarkLink = getBookmarkLink(link.href);
- if (bookmarkLink === id) {
- link.classList.add(highlightClass);
+ const entry = currentEntry(entries);
+ if (entry === current) {
+ return;
}
- });
+
+ current = entry;
+ entries.forEach((candidate) => {
+ candidate.link.classList.toggle(highlightClass, candidate === entry);
+ });
+ };
+
+ // Scroll fires far more often than the page can paint, so the reading is
+ // taken once per frame at most.
+ const queue = () => {
+ if (!queued) {
+ queued = true;
+ window.requestAnimationFrame(update);
+ }
+ };
+
+ update();
+ window.addEventListener('scroll', queue, { passive: true });
+ window.addEventListener('resize', queue);
}
-function recheck() {
- const docTop = Math.floor(document.documentElement.scrollTop);
- const vh = Math.max(
- document.documentElement.clientHeight || 0,
- window.innerHeight || 0
- );
+/**
+ * The section the reader is in: the last heading to have passed the line that
+ * `scroll-padding-block-start` parks a clicked anchor on. Clicking a link in
+ * the table of contents therefore always highlights the link that was clicked.
+ *
+ * Above the first heading the first section is used, and at the bottom of the
+ * page the last one is, so a final section too short to scroll to the line
+ * still gets its turn.
+ *
+ * @param {{link: HTMLElement, heading: HTMLElement}[]} entries
+ */
+function currentEntry(entries) {
+ const doc = document.documentElement;
+
+ if (Math.ceil(window.scrollY + window.innerHeight) >= doc.scrollHeight) {
+ return entries[entries.length - 1];
+ }
- const validItems = [];
+ // `scrollPaddingTop` is `auto` when the page sets no scroll padding.
+ const line = parseFloat(getComputedStyle(doc).scrollPaddingTop) || 0;
- headings.forEach((elem) => {
- const hasPassed = elem.offsetTop < docTop;
- const inView = elem.offsetTop > docTop && elem.offsetTop < docTop + vh;
- const isValid = docTop + vh - elem.offsetTop > vh / 1.5;
+ let current = entries[0];
- if (isValid) {
- validItems.push(elem);
+ entries.forEach((entry) => {
+ // A clicked heading lands exactly on the line, so allow a pixel for the
+ // browser's rounding of the scroll position.
+ if (entry.heading.getBoundingClientRect().top <= line + 1) {
+ current = entry;
}
});
- const item = validItems.pop();
+ return current;
+}
- if (item && item.id !== current) {
- current = item.id;
- highlight(item.id);
+/**
+ * The fragment of a link's href, if it has one.
+ *
+ * @param {string} link
+ */
+function getBookmarkLink(link) {
+ const linkParts = link.split('#');
+
+ if (linkParts.length === 2) {
+ return linkParts[1];
}
- window.setTimeout(recheck, 1000);
+ return '';
}
export { highlightCurrentHeading };
diff --git a/src/styles/main.css b/src/styles/main.css
index 6c992e528b..c4b762ff5a 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -845,124 +845,125 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
text-align: left;
}
-/* Article Nav */
-.article-nav {
- margin-inline-end: 1rem;
-}
-
-@media (max-width: 1130px) {
- .article-nav {
- margin-bottom: 2rem;
- }
-}
-
-.article-nav .article-nav__details .article-nav__title::marker {
- content: '';
- display: none;
-}
+/* Article nav ("On this page") */
-.article-nav .article-nav__details .article-nav__title::after {
- /* fa-chevron-down */
- content: '\f078';
- font-family: fa-solid;
- float: right;
- transition: transform var(--duration-default) ease-in-out;
- display: none;
+/* The only ever collapses at the restack breakpoint, where this
+ column sits above the article. On desktop the component's script forces it
+ open and the summary is inert. */
+.article-nav__details {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space16);
}
-.article-nav .article-nav__details[open] .article-nav__title::after {
- transform: rotate(180deg);
+/* Two classes throughout, so these beat the global `summary:hover,
+ summary:focus` rule — which would otherwise leave the title in a link color
+ after it has been clicked open at the restack breakpoint. */
+.article-nav .article-nav__title,
+.article-nav .article-nav__title:hover,
+.article-nav .article-nav__title:focus {
+ color: var(--colorTextPrimary);
+ text-decoration: none;
}
.article-nav__title {
+ /* Laying the summary out as a flex box drops the disclosure triangle
+ everywhere except Safari, which needs the -webkit rule below. */
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space8);
+ padding-block-end: var(--space6);
+ padding-inline-start: var(--space16);
+ font: var(--textBodyBoldMedium);
pointer-events: none;
}
-@media (max-width: 1130px) {
- .article-nav__title {
- pointer-events: auto;
- }
-
- .article-nav .article-nav__details .article-nav__title::after {
- /* fa-chevron-down */
- content: '\f078';
- font-family: fa-solid;
- float: right;
- transition: transform var(--duration-default) ease-in-out;
- display: block;
- }
-}
-
-.article-nav__title {
- color: var(--color-heading);
- font-family: var(--fontFamilySystem-ui);
- font-size: var(--fontSizeBase);
- font-weight: var(--fontWeight700);
- text-transform: uppercase;
- text-decoration: none;
- transition: color var(--duration-default) ease-in-out;
-
- @media (prefers-reduced-motion: reduce) {
- transition: none;
- }
-}
-
-.article-nav .separator {
- margin: 1rem 0 0.69rem;
+.article-nav__title::-webkit-details-marker {
+ display: none;
}
+/* The rail runs the full height of the list. The current heading paints its own
+ bold segment over the top of it. */
.article-nav__list {
display: flex;
flex-direction: column;
- justify-content: center;
+ gap: var(--space4);
list-style: none;
- gap: 1rem;
+ border-inline-start: var(--borderWidth2) solid var(--colorBorderPrimary);
}
-.article-nav__item {
- cursor: pointer;
-}
-
-.article-nav__link {
- display: flex;
- color: var(--color-subtitle);
- font-size: var(--fontSizeBase);
- line-height: 1.5rem;
- font-weight: var(--fontWeight400);
+/* Two classes throughout, so these beat the global `a:hover, a:focus` rule —
+ including after a click, which leaves the link focused but not
+ focus-visible. */
+.article-nav .article-nav__link {
+ display: block;
+ position: relative;
+ padding-block: var(--space6);
+ padding-inline-start: var(--space16);
+ color: var(--colorTextSecondary);
+ font: var(--textBodyRegularMedium);
text-decoration: none;
- transition-property: font-weight, color, transform;
- transition-timing-function: ease-in-out;
- transition-duration: var(--duration-default);
+ overflow-wrap: break-word;
+ transition: color var(--duration-default) ease-in-out;
@media (prefers-reduced-motion: reduce) {
- transition-property: none;
+ transition: none;
}
}
-.article-nav__link.highlight,
-.article-nav__item:hover .article-nav__link {
- font-weight: var(--fontWeight600);
- color: var(--badge-color);
+/* `highlight` is applied by scripts/modules/toc.js to whichever heading is in
+ view. */
+.article-nav .article-nav__link.highlight {
+ color: var(--colorTextPrimary);
}
-.article-nav__item:hover .article-nav__link {
- transform: translateX(0.5rem);
+/* The segment sits one border-width to the left of the link's box, which is
+ where the rail is regardless of how far the heading level indents. */
+.article-nav__link.highlight::before {
+ content: '';
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: calc(-1 * var(--borderWidth2));
+ width: var(--borderWidth2);
+ background-color: var(--colorBorderBold);
}
-.article-nav .article-nav__item--level-3 {
- padding-inline-start: 1em;
+.article-nav .article-nav__link:hover,
+.article-nav .article-nav__link:focus-visible {
+ color: var(--colorTextLinkDefault);
}
-.article-nav .article-nav__item--level-4 {
- padding-inline-start: 2em;
+/* h2 takes the base indent. The design steps two levels in from there and
+ stops, so anything deeper lines up with h4. */
+.article-nav__item--level-3 .article-nav__link {
+ padding-inline-start: var(--space32);
}
-.article-nav .article-nav__item--level-5 {
- padding-inline-start: 3em;
+.article-nav__item--level-4 .article-nav__link,
+.article-nav__item--level-5 .article-nav__link,
+.article-nav__item--level-6 .article-nav__link {
+ padding-inline-start: var(--space48);
}
-.article-nav .article-nav__item--level-6 {
- padding-inline-start: 4em;
+@media (max-width: 1130px) {
+ .article-nav__title {
+ pointer-events: auto;
+ }
+
+ /* Masked pseudo-element rather than inline SVG, matching the site nav. */
+ .article-nav__title::after {
+ content: '';
+ flex: none;
+ width: 1.25rem;
+ height: 1.25rem;
+ background-color: var(--colorIconTertiary);
+ mask: url('../assets/icons/chevron-right.svg') center / contain no-repeat;
+ }
+
+ .article-nav__details[open] .article-nav__title::after {
+ mask-image: url('../assets/icons/chevron-down.svg');
+ }
}
/* Card */
@@ -1278,22 +1279,24 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
.side-nav {
align-self: start;
- overflow: overlay;
position: sticky;
- font-size: var(--fontSizeMedium);
- font-weight: var(--fontWeight400);
- margin-block-start: 10vh;
- height: calc(100vh - 82px - 10.625rem);
- top: 10.625rem;
-}
-
-.side-nav > ul {
- padding-inline-end: 1rem;
- padding-block-end: 3rem;
-}
-
-.side-nav li {
- list-style: none;
+ /* The global reset does not inherit html's border-box, and this column is
+ sized by the grid with padding on top of it. */
+ box-sizing: border-box;
+ /* The last of the three column start heights below the header — 16px for the
+ nav, 56px for the content, 100px here. */
+ margin-block-start: 6.25rem;
+ top: calc(82px + 6.25rem);
+ /* Everything from the sticky top to the bottom of the window, so a long list
+ scrolls inside the column instead of running off the end of it. */
+ height: calc(100vh - 82px - 6.25rem);
+ /* Keeps the toc off the right edge of the window. The grid column is widened
+ to match, so the nav itself keeps its designed width. */
+ padding-inline-end: var(--space16);
+ padding-block-end: var(--space48);
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--scrollbar-color) transparent;
}
@media (max-width: 1130px) {
@@ -1313,22 +1316,16 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
height: calc(100vh - 7.625rem - var(--space16));
}
+ /* Stacked above the article rather than beside it, so none of the sticky
+ column geometry applies. */
.side-nav {
+ position: static;
+ top: auto;
height: auto;
margin: 0;
- padding: 0;
- position: unset;
- top: unset;
- }
-
- .side-nav details {
- margin-block: 1rem;
- width: auto;
- margin-inline: auto;
- }
-
- .side-nav summary {
- text-align: center;
+ /* The grid's own left and right areas provide the gutters here. */
+ padding: var(--space16) 0 var(--space32);
+ overflow: visible;
}
}
@@ -1413,7 +1410,10 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
grid-template-columns:
var(--navigation-width)
minmax(300px, var(--content-width-article))
- var(--toc-width);
+ /* space-between pins this column to the right edge of the window, so the
+ column carries a gutter on top of the width the toc itself is designed
+ to. .side-nav spends it as padding. */
+ calc(var(--toc-width) + var(--space16));
display: grid;
grid-template-rows: auto;
gap: var(--grid-gap-main);
@@ -1473,9 +1473,9 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
/* Drops the breadcrumb 56px below the header. The design gives each column
its own start height below the header — 16px for the nav, 56px here,
100px for the toc — so there is no shared value to put on .content-group,
- whose margin-top is clearance for the fixed header. Only this column is
- built to the design so far: the nav still starts at 0 and .side-nav still
- uses a viewport-relative 10vh. Reset in the restack breakpoint below. */
+ whose margin-top is clearance for the fixed header. The nav is the one
+ column not built to the design yet; it still starts at 0. Reset in the
+ restack breakpoint below. */
margin: var(--space56) 0 0;
}