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
1 change: 1 addition & 0 deletions src/components/Head.astro
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ const breadcrumb = {
observer.observe(box);
}
</script>
<script src="../scripts/motion.ts"></script>
157 changes: 157 additions & 0 deletions src/scripts/motion.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(`: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<Element, Element | null>();
const tocs = new Set<Element>();

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<HTMLDetailsElement, Animation[]>();

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);
});
63 changes: 62 additions & 1 deletion src/styles/docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down