From 0f5d0e200f63fce7141111c58ed89acc9ba4c7b6 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:58:07 -0400 Subject: [PATCH] Glide the docs navigation highlights and animate disclosures Also add spacing above service list details and centre the copy tooltip on its button. --- src/components/Head.astro | 1 + src/scripts/motion.ts | 157 ++++++++++++++++++++++++++++++++++++++ src/styles/docs.css | 63 ++++++++++++++- 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/scripts/motion.ts diff --git a/src/components/Head.astro b/src/components/Head.astro index f237756..e319cc8 100644 --- a/src/components/Head.astro +++ b/src/components/Head.astro @@ -60,3 +60,4 @@ const breadcrumb = { observer.observe(box); } + diff --git a/src/scripts/motion.ts b/src/scripts/motion.ts new file mode 100644 index 0000000..ab56302 --- /dev/null +++ b/src/scripts/motion.ts @@ -0,0 +1,157 @@ +const FINE = '(hover: hover) and (pointer: fine)'; +const REDUCE = '(prefers-reduced-motion: reduce)'; +const EASE = 'cubic-bezier(.32,.72,0,1)'; +const REVEAL_MS = 200; + +const HOVER_LISTS = [ + { container: '.sidebar-content', item: 'a, summary' }, + { container: 'starlight-toc nav', item: 'a' }, +]; +const DISCLOSURES = '#starlight__sidebar details, details.cl-item, details.sy-disc'; + +const measurable = (el: Element) => { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; +}; + +const ensurePill = (container: Element, variant: string) => { + let pill = container.querySelector(`:scope > .sy-mp--${variant}`); + if (!pill) { + pill = document.createElement('div'); + pill.className = `sy-mp sy-mp--${variant}`; + pill.setAttribute('aria-hidden', 'true'); + container.prepend(pill); + } + return pill; +}; + +const placePill = (container: Element, pill: HTMLElement, item: Element) => { + if (!measurable(container) || !measurable(item)) { + pill.classList.remove('on'); + return; + } + const cr = container.getBoundingClientRect(); + const ir = item.getBoundingClientRect(); + const cs = getComputedStyle(item); + const rtl = cs.direction === 'rtl'; + const inline = (rtl ? cr.right - ir.right : ir.left - cr.left) - container.clientLeft; + const block = ir.top - cr.top - container.clientTop; + pill.style.borderRadius = cs.borderRadius; + pill.style.width = `${ir.width}px`; + pill.style.height = `${ir.height}px`; + pill.style.transform = `translate3d(${rtl ? -inline : inline}px,${block}px,0)`; + if (!pill.classList.contains('on')) { + pill.getBoundingClientRect(); + pill.classList.add('on'); + } +}; + +const hovered = new Map(); +const tocs = new Set(); + +const refresh = () => { + for (const [container, item] of hovered) { + const pill = ensurePill(container, 'hover'); + if (item?.isConnected) placePill(container, pill, item); + else pill.classList.remove('on'); + } + for (const nav of tocs) { + const current = nav.querySelector('a[aria-current="true"]'); + if (current) placePill(nav, ensurePill(nav, 'toc'), current); + } +}; + +if (matchMedia(FINE).matches) { + for (const cfg of HOVER_LISTS) { + for (const container of document.querySelectorAll(cfg.container)) { + container.addEventListener('pointerover', (e) => { + if ((e as PointerEvent).pointerType !== 'mouse') return; + const item = (e.target as Element).closest(cfg.item); + if (!item || !container.contains(item)) return; + hovered.set(container, item); + refresh(); + }); + container.addEventListener('pointerleave', () => { + hovered.set(container, null); + refresh(); + }); + } + } +} + +for (const nav of document.querySelectorAll('starlight-toc nav')) { + tocs.add(nav); + new MutationObserver(refresh).observe(nav, { + subtree: true, + attributes: true, + attributeFilter: ['aria-current'], + }); +} + +const resizeObserver = new ResizeObserver(refresh); +for (const cfg of HOVER_LISTS) { + for (const container of document.querySelectorAll(cfg.container)) resizeObserver.observe(container); +} +refresh(); + +const running = new WeakMap(); + +const heightWhen = (details: HTMLDetailsElement, open: boolean) => { + details.open = open; + return details.getBoundingClientRect().height; +}; + +const animateDisclosure = (details: HTMLDetailsElement, open: boolean) => { + const from = details.getBoundingClientRect().height; + for (const a of running.get(details) ?? []) a.cancel(); + const closed = heightWhen(details, false); + const expanded = heightWhen(details, true); + const duration = open ? REVEAL_MS : REVEAL_MS * 0.8; + details.style.overflow = 'hidden'; + + const size = details.animate( + { height: [`${from}px`, `${open ? expanded : closed}px`] }, + { duration, easing: EASE }, + ); + const content = [...details.children].filter((el) => el.tagName !== 'SUMMARY'); + const fades = content.map((el) => + el.animate( + { opacity: open ? [0, 1] : [1, 0] }, + open + ? { duration: REVEAL_MS * 0.55, delay: REVEAL_MS * 0.25, easing: 'linear', fill: 'backwards' } + : { duration: REVEAL_MS * 0.32, easing: 'linear', fill: 'forwards' }, + ), + ); + running.set(details, [size, ...fades]); + + let frame = requestAnimationFrame(function follow() { + refresh(); + frame = requestAnimationFrame(follow); + }); + size.onfinish = () => { + cancelAnimationFrame(frame); + if (!open) details.open = false; + for (const f of fades) f.cancel(); + details.style.overflow = ''; + running.delete(details); + refresh(); + }; + size.oncancel = () => cancelAnimationFrame(frame); +}; + +/* Bubble phase on the document, never capture. Starlight saves a sidebar group + as the inverse of its open state when the click reaches the sidebar. Changing + it before then saves the wrong state. */ +document.addEventListener('click', (e) => { + if (e.defaultPrevented || matchMedia(REDUCE).matches) return; + const target = e.target as Element; + const summary = target.closest?.('summary'); + if (!summary || target.closest('a')) return; + const details = summary.parentElement; + if (!(details instanceof HTMLDetailsElement) || !details.matches(DISCLOSURES)) return; + if (details.querySelector(':scope > summary') !== summary) return; + e.preventDefault(); + const opening = running.has(details) ? details.dataset.syClosing === 'true' : !details.open; + details.dataset.syClosing = String(!opening); + animateDisclosure(details, opening); +}); diff --git a/src/styles/docs.css b/src/styles/docs.css index 7b4240f..98240c6 100644 --- a/src/styles/docs.css +++ b/src/styles/docs.css @@ -187,6 +187,10 @@ border-radius: var(--sy-radius-lg); border: 1px solid var(--sl-color-hairline); } +.expressive-code .frame .copy > [aria-live] { + display: flex; + align-items: center; +} .sl-markdown-content table { border-collapse: collapse; @@ -636,7 +640,64 @@ button.sy-iconbtn:focus-visible { } } +.sidebar-content, +starlight-toc nav { + position: relative; +} +.sidebar-content :where(a, summary), +starlight-toc nav a { + position: relative; +} +#starlight__sidebar summary { + border-radius: 0.25rem; +} +/* pointer-events: none, or the pill swallows every hover and click in the list. */ +.sy-mp { + position: absolute; + top: 0; + inset-inline-start: 0; + pointer-events: none; + opacity: 0; + transition: opacity 0.12s ease; +} +.sy-mp.on { + opacity: 1; + transition: + opacity 0.12s ease, + transform 0.19s cubic-bezier(0.22, 1, 0.36, 1), + width 0.19s cubic-bezier(0.22, 1, 0.36, 1), + height 0.19s cubic-bezier(0.22, 1, 0.36, 1); +} +.sy-mp--hover { + background: var(--sy-overlay); +} +.sy-mp--toc { + background: color-mix(in srgb, var(--sl-color-text-accent) 14%, transparent); +} + +@media (prefers-reduced-motion: no-preference) { + @view-transition { + navigation: auto; + } + :root { + view-transition-name: none; + } +} +@media (prefers-reduced-motion: no-preference) and (min-width: 50em) { + #starlight__sidebar a[aria-current='page'] { + view-transition-name: sy-current-page; + } +} +::view-transition-group(sy-current-page) { + animation-duration: 0.32s; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} + @media (prefers-reduced-motion: reduce) { + .sy-mp, + .sy-mp.on { + transition: none; + } } .sy-shot { @@ -1106,7 +1167,7 @@ button.sy-iconbtn:focus-visible { } .sl-markdown-content .sy-disc__body { - padding: 0 16px 16px; + padding: 8px 16px 16px; } .sl-markdown-content .sy-disc__fields { display: grid;