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
10 changes: 8 additions & 2 deletions src/components/ArticleNav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { accelerator } from '@lib/accelerator';
import { SITE } from '@config';
import { Translations, Lang } from '@util/Languages';
import Separator from './Separator.astro';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be deleted now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah looks like it's unused except for the components demo page.

I think I'd prefer to leave it though, in case we need to put it back for some reason, and file a card to clean it up at the end, if that's OK?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, given that the spelling error caused me to need to re-spin the PR, I deleted the component.

Claude keeps writing comments in the CSS which say "colour" but our spelling requires AMERICAN :-(


const stats = new accelerator.statistics('octopus/components/ArticleNav.astro');
stats.start();
Expand Down Expand Up @@ -31,7 +30,6 @@ stats.stop();
<summary class="article-nav__title">
{_(Translations.toc.title)}
</summary>
<Separator />
<ol class="article-nav__list">
{headings.map((heading) => (
<li
Expand All @@ -47,6 +45,14 @@ stats.stop();
</li>
))}
</ol>
{/* Slid onto the current heading's link by scripts/modules/toc.js,
which measures the links against this element's offset parent —
the <details> above. */}
<span
class="article-nav__indicator"
data-article-nav-indicator
aria-hidden="true"
/>
</details>
</nav>
)
Expand Down
5 changes: 0 additions & 5 deletions src/components/Separator.astro

This file was deleted.

14 changes: 1 addition & 13 deletions src/pages/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import TopNav from 'src/components/TopNav.astro';

## Component Usage Guide
Expand All @@ -35,7 +34,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
Expand Down Expand Up @@ -401,17 +400,6 @@ The Link component is designed to provide a standardized way to display links wi
</div>
</div>

### Separator

<div class="docs-home simple-grid">
<Separator />
<div>
```
<Separator />
```
</div>
</div>

## Layout

### Grid
Expand Down
176 changes: 128 additions & 48 deletions src/scripts/modules/toc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,82 +2,162 @@

import { qsa } from './query.js';

let links = [];
let current = '';
const headings = [];
const highlightClass = 'highlight';
const indicatorReadyClass = 'article-nav__indicator--ready';

/**
* 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();
if (entries.length === 0) {
return;
}

// Optional: only the article nav draws a sliding indicator, and only when the
// component has rendered one.
const indicator = entries[0].link
.closest('[data-article-nav]')
?.querySelector('[data-article-nav-indicator]');

/** @type {{link: HTMLElement, heading: HTMLElement} | undefined} */
let current;
let queued = false;

const update = () => {
queued = false;

const entry = currentEntry(entries);

if (entry !== current) {
current = entry;
entries.forEach((candidate) => {
candidate.link.classList.toggle(highlightClass, candidate === entry);
});
}

// Measured every time rather than only on a change of section: a resize can
// rewrap the links under an unchanged one.
moveIndicator(indicator, entry.link);
};

// 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);

// A collapsed list has no geometry to measure, so the indicator has to be
// placed again once it reopens.
entries[0].link.closest('details')?.addEventListener('toggle', queue);
}

function getBookmarkLink(link) {
const linkParts = link.split('#');
if (linkParts.length === 2) {
return linkParts[1];
/**
* Puts the sliding indicator, where there is one, over a link.
*
* A hidden link — the list collapsed at the restack breakpoint — measures zero,
* so the indicator is taken back to its unplaced state instead: hidden, and
* without a transition, so that reopening the list does not animate it in from
* wherever it used to be.
*
* @param {Element | null | undefined} indicator
* @param {HTMLElement} link
*/
function moveIndicator(indicator, link) {
if (!(indicator instanceof HTMLElement)) {
return;
}

return '';
}
if (link.offsetHeight === 0) {
indicator.classList.remove(indicatorReadyClass);
return;
}

function highlight(id) {
links.forEach((link) => {
link.classList.remove(highlightClass);
indicator.style.transform = `translateY(${link.offsetTop}px)`;
indicator.style.height = `${link.offsetHeight}px`;

const bookmarkLink = getBookmarkLink(link.href);
if (bookmarkLink === id) {
link.classList.add(highlightClass);
}
});
// A frame's grace, so the browser has the placement above as the state to
// animate from rather than animating the placement itself.
if (!indicator.classList.contains(indicatorReadyClass)) {
window.requestAnimationFrame(() => {
indicator.classList.add(indicatorReadyClass);
});
}
}

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;
}

/**
* The fragment of a link's href, if it has one.
*
* @param {string} link
*/
function getBookmarkLink(link) {
const linkParts = link.split('#');

if (item && item.id !== current) {
current = item.id;
highlight(item.id);
if (linkParts.length === 2) {
return linkParts[1];
}

window.setTimeout(recheck, 1000);
return '';
}

export { highlightCurrentHeading };
Loading