diff options
author | Chris Swithinbank | 2023-05-15 18:30:52 +0200 |
---|---|---|
committer | GitHub | 2023-05-15 18:30:52 +0200 |
commit | e96d9a7628c5c04fe34dbc65ddd6fabdc0667a6d (patch) | |
tree | 454cb26034b1b78603bd0de1a391431ddd970806 | |
parent | 7ee1b5db9b6ec290ac7678d08b1f8d6a116d6d02 (diff) | |
download | IT.starlight-e96d9a7628c5c04fe34dbc65ddd6fabdc0667a6d.tar.gz IT.starlight-e96d9a7628c5c04fe34dbc65ddd6fabdc0667a6d.tar.bz2 IT.starlight-e96d9a7628c5c04fe34dbc65ddd6fabdc0667a6d.zip |
Add highlighting of current section to table of contents (#47)
-rw-r--r-- | .changeset/beige-pillows-tan.md | 5 | ||||
-rw-r--r-- | .changeset/clever-parrots-chew.md | 5 | ||||
-rw-r--r-- | .changeset/slow-lies-attack.md | 5 | ||||
-rw-r--r-- | packages/starlight/404.astro | 22 | ||||
-rw-r--r-- | packages/starlight/components/RightSidebarPanel.astro | 4 | ||||
-rw-r--r-- | packages/starlight/components/SidebarSublist.astro | 8 | ||||
-rw-r--r-- | packages/starlight/components/TableOfContents.astro | 15 | ||||
-rw-r--r-- | packages/starlight/components/TableOfContents/MobileTableOfContents.astro | 104 | ||||
-rw-r--r-- | packages/starlight/components/TableOfContents/TableOfContentsList.astro | 39 | ||||
-rw-r--r-- | packages/starlight/components/TableOfContents/generateToC.ts | 18 | ||||
-rw-r--r-- | packages/starlight/components/TableOfContents/starlight-toc.ts | 85 | ||||
-rw-r--r-- | packages/starlight/index.astro | 8 |
12 files changed, 238 insertions, 80 deletions
diff --git a/.changeset/beige-pillows-tan.md b/.changeset/beige-pillows-tan.md new file mode 100644 index 00000000..519296d2 --- /dev/null +++ b/.changeset/beige-pillows-tan.md @@ -0,0 +1,5 @@ +--- +"@astrojs/starlight": patch +--- + +Fix CSS ordering issue caused by imports in 404 route. diff --git a/.changeset/clever-parrots-chew.md b/.changeset/clever-parrots-chew.md new file mode 100644 index 00000000..89149956 --- /dev/null +++ b/.changeset/clever-parrots-chew.md @@ -0,0 +1,5 @@ +--- +"@astrojs/starlight": patch +--- + +Highlight current page section in table of contents. diff --git a/.changeset/slow-lies-attack.md b/.changeset/slow-lies-attack.md new file mode 100644 index 00000000..ce2a62e3 --- /dev/null +++ b/.changeset/slow-lies-attack.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': patch +--- + +Fix usage of `aria-current` in navigation sidebar to use `page` value. diff --git a/packages/starlight/404.astro b/packages/starlight/404.astro index a0f8bb4c..eee55f8b 100644 --- a/packages/starlight/404.astro +++ b/packages/starlight/404.astro @@ -1,15 +1,21 @@ --- -import Header from './components/Header.astro'; -import MarkdownContent from './components/MarkdownContent.astro'; -import ThemeProvider from './components/ThemeProvider.astro'; -import PageFrame from './layout/PageFrame.astro'; - // Built-in CSS styles. import './style/props.css'; import './style/reset.css'; import './style/shiki.css'; import './style/util.css'; +// Layout +import PageFrame from './layout/PageFrame.astro'; + +// Components +import Header from './components/Header.astro'; +import MarkdownContent from './components/MarkdownContent.astro'; +import ThemeProvider from './components/ThemeProvider.astro'; + +// Important that this is the last import so it can override built-in styles. +import 'virtual:starlight/user-css'; + // TODO: replace with proper values — requires support for a “default” locale const lang = 'en'; const dir = 'ltr'; @@ -26,9 +32,9 @@ const locale = undefined; <ThemeProvider /> <PageFrame> <Header slot="header" locale={locale} /> - <main id="starlight__overview"> + <main> <MarkdownContent> - <h1>404</h1> + <h1 id="starlight__overview">404</h1> <p>Houston, we have a problem.</p> <p> We couldn’t find that link. Check the address or <a @@ -40,7 +46,7 @@ const locale = undefined; </PageFrame> <style> - #starlight__overview { + main { margin: auto; padding: clamp(2rem, 10vmin, 6rem) var(--sl-nav-pad-x); max-width: var(--sl-content-width); diff --git a/packages/starlight/components/RightSidebarPanel.astro b/packages/starlight/components/RightSidebarPanel.astro index 81ce2e4d..c96b7a9e 100644 --- a/packages/starlight/components/RightSidebarPanel.astro +++ b/packages/starlight/components/RightSidebarPanel.astro @@ -16,8 +16,10 @@ } .right-sidebar-panel :global(h2) { color: var(--sl-color-white); - font-size: var(--sl-text-base); + font-size: var(--sl-text-h5); font-weight: 600; + line-height: var(--sl-line-height-headings); + margin-bottom: 0.5rem; } .right-sidebar-panel :global(a) { display: block; diff --git a/packages/starlight/components/SidebarSublist.astro b/packages/starlight/components/SidebarSublist.astro index 2065c889..344df808 100644 --- a/packages/starlight/components/SidebarSublist.astro +++ b/packages/starlight/components/SidebarSublist.astro @@ -11,7 +11,7 @@ interface Props { Astro.props.sublist.map((entry) => ( <li class:list={{ 'sidebar-group': entry.type === 'group' }}> {entry.type === 'link' ? ( - <a href={entry.href} aria-current={entry.isCurrent && 'true'}> + <a href={entry.href} aria-current={entry.isCurrent && 'page'}> {entry.label} </a> ) : ( @@ -56,9 +56,9 @@ interface Props { color: var(--sl-color-white); } - [aria-current='true'], - [aria-current='true']:hover, - [aria-current='true']:focus { + [aria-current='page'], + [aria-current='page']:hover, + [aria-current='page']:focus { font-weight: 600; color: var(--sl-color-text-invert); background-color: var(--sl-color-text-accent); diff --git a/packages/starlight/components/TableOfContents.astro b/packages/starlight/components/TableOfContents.astro index 3732bce5..eff17903 100644 --- a/packages/starlight/components/TableOfContents.astro +++ b/packages/starlight/components/TableOfContents.astro @@ -11,7 +11,14 @@ interface Props { const toc = generateToC(Astro.props.headings, config.tableOfContents); --- -<nav aria-labelledby="starlight__on-this-page"> - <h2 id="starlight__on-this-page">On this page</h2> - <TableOfContentsList toc={toc} /> -</nav> +<starlight-toc + data-min-h={config.tableOfContents.minHeadingLevel} + data-max-h={config.tableOfContents.maxHeadingLevel} +> + <nav aria-labelledby="starlight__on-this-page"> + <h2 id="starlight__on-this-page">On this page</h2> + <TableOfContentsList toc={toc} /> + </nav> +</starlight-toc> + +<script src="./TableOfContents/starlight-toc"></script> diff --git a/packages/starlight/components/TableOfContents/MobileTableOfContents.astro b/packages/starlight/components/TableOfContents/MobileTableOfContents.astro index 494b1d00..9cb36c6e 100644 --- a/packages/starlight/components/TableOfContents/MobileTableOfContents.astro +++ b/packages/starlight/components/TableOfContents/MobileTableOfContents.astro @@ -12,19 +12,25 @@ interface Props { const toc = generateToC(Astro.props.headings, config.tableOfContents); --- -<nav aria-labelledby="starlight__on-this-page--mobile" class="lg:hidden"> - <details id="starlight__mobile-toc"> - <summary id="starlight__on-this-page--mobile" class="flex"> - <div class="toggle flex"> - On this page - <Icon name={'right-caret'} class="caret" size="1rem" /> +<mobile-starlight-toc + data-min-h={config.tableOfContents.minHeadingLevel} + data-max-h={config.tableOfContents.maxHeadingLevel} +> + <nav aria-labelledby="starlight__on-this-page--mobile" class="lg:hidden"> + <details id="starlight__mobile-toc"> + <summary id="starlight__on-this-page--mobile" class="flex"> + <div class="toggle flex"> + On this page + <Icon name={'right-caret'} class="caret" size="1rem" /> + </div> + <span class="display-current">{toc[0]?.text}</span> + </summary> + <div class="dropdown"> + <TableOfContentsList toc={toc} isMobile /> </div> - </summary> - <div class="dropdown"> - <TableOfContentsList toc={toc} isMobile /> - </div> - </details> -</nav> + </details> + </nav> +</mobile-starlight-toc> <style> nav { @@ -41,9 +47,12 @@ const toc = generateToC(Astro.props.headings, config.tableOfContents); } summary { + gap: 0.5rem; + align-items: center; height: var(--sl-mobile-toc-height); border-bottom: 1px solid var(--sl-color-hairline-shade); padding: 0.5rem 1rem; + font-size: var(--sl-text-xs); outline-offset: var(--sl-outline-offset-inside); } summary::marker, @@ -52,6 +61,7 @@ const toc = generateToC(Astro.props.headings, config.tableOfContents); } .toggle { + flex-shrink: 0; gap: 1rem; align-items: center; justify-content: space-between; @@ -60,7 +70,6 @@ const toc = generateToC(Astro.props.headings, config.tableOfContents); padding-block: 0.5rem; padding-inline-start: 0.75rem; padding-inline-end: 0.5rem; - font-size: var(--sl-text-xs); line-height: 1; background-color: var(--sl-color-black); user-select: none; @@ -82,6 +91,13 @@ const toc = generateToC(Astro.props.headings, config.tableOfContents); transform: rotateZ(90deg); } + .display-current { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + color: var(--sl-color-white); + } + .dropdown { --border-top: 1px; margin-top: calc(-1 * var(--border-top)); @@ -95,31 +111,43 @@ const toc = generateToC(Astro.props.headings, config.tableOfContents); </style> <script> - const details = document.querySelector<HTMLDetailsElement>( - '#starlight__mobile-toc' - ); - if (details) { - const closeToC = () => { - details.open = false; - }; - // Close the table of contents whenever a link is clicked. - details.querySelectorAll('a').forEach((a) => { - a.addEventListener('click', closeToC); - }); - // Close the table of contents when a user clicks outside of it. - window.addEventListener('click', (e) => { - if (!details.contains(e.target as Node)) closeToC(); - }); - // Or when they press the escape key. - window.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && details.open) { - const hasFocus = details.contains(document.activeElement); - closeToC(); - if (hasFocus) { - const summary = details.querySelector('summary'); - if (summary) summary.focus(); + import { StarlightTOC } from './starlight-toc'; + + class MobileStarlightTOC extends StarlightTOC { + override set current(link: HTMLAnchorElement) { + super.current = link; + const display = this.querySelector('.display-current') as HTMLSpanElement; + if (display) display.textContent = link.textContent; + } + + constructor() { + super(); + const details = this.querySelector('details'); + if (!details) return; + const closeToC = () => { + details.open = false; + }; + // Close the table of contents whenever a link is clicked. + details.querySelectorAll('a').forEach((a) => { + a.addEventListener('click', closeToC); + }); + // Close the table of contents when a user clicks outside of it. + window.addEventListener('click', (e) => { + if (!details.contains(e.target as Node)) closeToC(); + }); + // Or when they press the escape key. + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && details.open) { + const hasFocus = details.contains(document.activeElement); + closeToC(); + if (hasFocus) { + const summary = details.querySelector('summary'); + if (summary) summary.focus(); + } } - } - }); + }); + } } + + customElements.define('mobile-starlight-toc', MobileStarlightTOC); </script> diff --git a/packages/starlight/components/TableOfContents/TableOfContentsList.astro b/packages/starlight/components/TableOfContents/TableOfContentsList.astro index f19c91bd..83adf903 100644 --- a/packages/starlight/components/TableOfContents/TableOfContentsList.astro +++ b/packages/starlight/components/TableOfContents/TableOfContentsList.astro @@ -14,7 +14,9 @@ const { toc, isMobile = false, depth = 0 } = Astro.props; { toc.map((heading) => ( <li> - <a href={'#' + heading.slug}>{heading.text}</a> + <a href={'#' + heading.slug} aria-current={heading.current && 'true'}> + <span>{heading.text}</span> + </a> {heading.children.length > 0 && ( <Astro.self toc={heading.children} @@ -30,27 +32,52 @@ const { toc, isMobile = false, depth = 0 } = Astro.props; <style define:vars={{ depth }}> ul { padding: 0; - } - ul :global(::marker) { - color: transparent; + list-style: none; } a { - --pad-inline: 0rem; + --pad-inline: 0.5rem; display: block; + border-radius: 0.25rem; + padding-block: 0.25rem; padding-inline: calc(1rem * var(--depth) + var(--pad-inline)) var(--pad-inline); + line-height: 1.25; + } + a[aria-current='true'], + a[aria-current='true']:hover, + a[aria-current='true']:focus { + font-weight: 600; + color: var(--sl-color-text-invert); + background-color: var(--sl-color-text-accent); } .isMobile a { --pad-inline: 1rem; + display: flex; + justify-content: space-between; + gap: var(--pad-inline); border-top: 1px solid var(--sl-color-gray-6); + border-radius: 0; padding-block: 0.5rem; color: var(--sl-color-text); font-size: var(--sl-text-sm); - line-height: 1.25; text-decoration: none; outline-offset: var(--sl-outline-offset-inside); } .isMobile:first-child > li:first-child > a { border-top: 0; } + .isMobile a[aria-current='true'], + .isMobile a[aria-current='true']:hover, + .isMobile a[aria-current='true']:focus { + color: var(--sl-color-white); + background-color: unset; + } + .isMobile a[aria-current='true']::after { + content: ''; + width: 1rem; + background-color: var(--sl-color-text-accent); + /* Check mark SVG icon */ + -webkit-mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxNCAxNCc+PHBhdGggZD0nTTEwLjkxNCA0LjIwNmEuNTgzLjU4MyAwIDAgMC0uODI4IDBMNS43NCA4LjU1NyAzLjkxNCA2LjcyNmEuNTk2LjU5NiAwIDAgMC0uODI4Ljg1N2wyLjI0IDIuMjRhLjU4My41ODMgMCAwIDAgLjgyOCAwbDQuNzYtNC43NmEuNTgzLjU4MyAwIDAgMCAwLS44NTdaJy8+PC9zdmc+Cg=='); + mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxNCAxNCc+PHBhdGggZD0nTTEwLjkxNCA0LjIwNmEuNTgzLjU4MyAwIDAgMC0uODI4IDBMNS43NCA4LjU1NyAzLjkxNCA2LjcyNmEuNTk2LjU5NiAwIDAgMC0uODI4Ljg1N2wyLjI0IDIuMjRhLjU4My41ODMgMCAwIDAgLjgyOCAwbDQuNzYtNC43NmEuNTgzLjU4MyAwIDAgMCAwLS44NTdaJy8+PC9zdmc+Cg=='); + } </style> diff --git a/packages/starlight/components/TableOfContents/generateToC.ts b/packages/starlight/components/TableOfContents/generateToC.ts index 155eb285..a6c54765 100644 --- a/packages/starlight/components/TableOfContents/generateToC.ts +++ b/packages/starlight/components/TableOfContents/generateToC.ts @@ -2,6 +2,7 @@ import type { MarkdownHeading } from 'astro'; export interface TocItem extends MarkdownHeading { children: TocItem[]; + current?: boolean; } function diveChildren(item: TocItem, depth: number): TocItem[] { @@ -35,10 +36,7 @@ export function generateToC( for (const heading of headings) { if (toc.length === 0) { - toc.push({ - ...heading, - children: [], - }); + toc.push({ ...heading, children: [], current: true }); } else { const lastItemInToc = toc.at(-1)!; if (heading.depth < lastItemInToc.depth) { @@ -46,19 +44,13 @@ export function generateToC( } if (heading.depth === lastItemInToc.depth) { // same depth - toc.push({ - ...heading, - children: [], - }); + toc.push({ ...heading, children: [] }); } else { // higher depth - // push into children, or children' children alike + // push into children, or children's children alike const gap = heading.depth - lastItemInToc.depth; const target = diveChildren(lastItemInToc, gap); - target.push({ - ...heading, - children: [], - }); + target.push({ ...heading, children: [] }); } } } diff --git a/packages/starlight/components/TableOfContents/starlight-toc.ts b/packages/starlight/components/TableOfContents/starlight-toc.ts new file mode 100644 index 00000000..450fad08 --- /dev/null +++ b/packages/starlight/components/TableOfContents/starlight-toc.ts @@ -0,0 +1,85 @@ +export class StarlightTOC extends HTMLElement { + private _current = this.querySelector( + 'a[aria-current="true"]' + ) as HTMLAnchorElement | null; + private minH = parseInt(this.dataset.minH || '2', 10); + private maxH = parseInt(this.dataset.maxH || '3', 10); + + protected set current(link: HTMLAnchorElement) { + if (link === this._current) return; + if (this._current) this._current.removeAttribute('aria-current'); + link.setAttribute('aria-current', 'true'); + this._current = link; + } + + constructor() { + super(); + + /** All the links in the table of contents. */ + const links = [...this.querySelectorAll('a')]; + + /** Test if an element is a table-of-contents heading. */ + const isHeading = (el: Element): el is HTMLHeadingElement => { + if (el instanceof HTMLHeadingElement) { + // Special case for page title h1 + if (el.id === 'starlight__overview') return true; + // Check the heading level is within the user-configured limits for the ToC + const level = el.tagName[1]; + if (level) { + const int = parseInt(level, 10); + if (int >= this.minH && int <= this.maxH) return true; + } + } + return false; + }; + + /** Walk up the DOM to find the nearest heading. */ + const getElementHeading = ( + el: Element | null + ): HTMLHeadingElement | null => { + if (!el) return null; + const origin = el; + while (el) { + if (isHeading(el)) return el; + // Assign the previous sibling’s last, most deeply nested child to el. + el = el.previousElementSibling; + while (el?.lastElementChild) { + el = el.lastElementChild; + } + // Look for headings amongst siblings. + const h = getElementHeading(el); + if (h) return h; + } + // Walk back up the parent. + return getElementHeading(origin.parentElement); + }; + + /** Handle intersections and set the current link to the heading for the current intersection. */ + const setCurrent: IntersectionObserverCallback = (entries) => { + for (const { isIntersecting, target } of entries) { + if (!isIntersecting) continue; + const heading = getElementHeading(target); + if (!heading) continue; + const link = links.find((link) => link.hash === '#' + heading.id); + if (link) { + this.current = link; + break; + } + } + }; + + const headingsObserver = new IntersectionObserver(setCurrent, { + rootMargin: '5% 0% -85%', + }); + + // Observe elements with an `id` (most likely headings) and their siblings. + // Also observe direct children of `.content` to include elements before + // the first heading. + const toObserve = document.querySelectorAll( + 'main [id], main [id] ~ *, main .content > *' + ); + toObserve.forEach((h) => headingsObserver.observe(h)); + } +} + +customElements.define('starlight-toc', StarlightTOC); diff --git a/packages/starlight/index.astro b/packages/starlight/index.astro index 102677e7..cf2ce4db 100644 --- a/packages/starlight/index.astro +++ b/packages/starlight/index.astro @@ -57,14 +57,10 @@ const prevNextLinks = getPrevNextLinks(sidebar); <MobileTableOfContents headings={headings} /> <TwoColumnContent> <RightSidebar slot="right-sidebar" entry={entry} headings={headings} /> - <main - id="starlight__overview" - data-pagefind-body - lang={entryMeta.lang} - dir={entryMeta.dir} - > + <main data-pagefind-body lang={entryMeta.lang} dir={entryMeta.dir}> <ContentPanel> <h1 + id="starlight__overview" style="font-size: var(--sl-text-h1); line-height: var(--sl-line-height-headings); font-weight: 600; color: var(--sl-color-white); margin-top: 1rem;" > {entry.data.title} |