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) => (
  1. 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; }