feat: website (#45)

This commit is contained in:
Darius Cepulis
2025-10-20 18:14:37 -05:00
committed by GitHub
parent 6d7325cedb
commit 7d2536a766
129 changed files with 7368 additions and 1054 deletions
@@ -0,0 +1,48 @@
import type { MarkdownHeading } from 'astro';
import clsx from 'clsx';
import { useRef } from 'react';
import { useAutoScroll } from './utils';
interface TableOfContentsDesktopProps {
headings: MarkdownHeading[];
activeId: string;
onNavigate: (slug: string) => void;
className?: string;
}
export function TableOfContentsDesktop({ headings, activeId, onNavigate, className }: TableOfContentsDesktopProps) {
const navRef = useRef<HTMLElement>(null);
useAutoScroll({ activeId, containerRef: navRef });
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
e.preventDefault();
onNavigate(slug);
};
return (
<nav
ref={navRef}
className={clsx('', className)}
>
<div className="py-8 pr-6">
<h2 className="text-sm mb-3 font-semibold">On this page</h2>
<ul className="space-y-3">
{headings.map(heading => (
<li key={heading.slug}>
<a
href={`#${heading.slug}`}
onClick={e => handleClick(e, heading.slug)}
className={clsx('text-sm block', activeId === heading.slug ? 'text-dark-100' : 'text-dark-40 intent:text-dark-100',
)}
style={{ paddingLeft: `calc(${(heading.depth - 2)} * var(--spacing) * 4)` }}
>
{heading.text}
</a>
</li>
))}
</ul>
</div>
</nav>
);
}
@@ -0,0 +1,41 @@
import type { MarkdownHeading } from 'astro';
import clsx from 'clsx';
import { Select } from '@/components/Select';
interface TableOfContentsMobileProps {
headings: MarkdownHeading[];
activeId: string;
onNavigate: (slug: string) => void;
className?: string;
}
export function TableOfContentsMobile({ headings, activeId, onNavigate, className }: TableOfContentsMobileProps) {
const handleChange = (slug: string | null) => {
if (slug) onNavigate(slug);
};
const options = [
{ value: null, label: 'On this page…' },
...headings.map(heading => ({
value: heading.slug,
label: `${'\u00A0'.repeat((heading.depth - 2) * 2)}${heading.text}`,
})),
];
return (
<div
className={clsx('border-b border-light-40 bg-light-80 px-6 lg:px-12 h-(--h) flex items-center', className)}
style={{ '--h': 'var(--mobile-toc-h)' } as React.CSSProperties}
>
<div className="w-full max-w-3xl mx-auto">
<Select
value={activeId || null}
onChange={handleChange}
options={options}
aria-label="Table of contents"
className="w-full"
/>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import type { MarkdownHeading } from 'astro';
import { TableOfContentsDesktop } from './TableOfContents.desktop';
import { TableOfContentsMobile } from './TableOfContents.mobile';
import { filterHeadingsByDepth, navigateToHeading, useActiveHeading } from './utils';
interface TableOfContentsProps {
headings: MarkdownHeading[];
}
export function TableOfContents({ headings }: TableOfContentsProps) {
const filteredHeadings = filterHeadingsByDepth(headings, 2, 3);
const activeId = useActiveHeading(filteredHeadings);
if (filteredHeadings.length === 0) return <></>;
return (
<>
<TableOfContentsMobile
headings={filteredHeadings}
activeId={activeId}
onNavigate={navigateToHeading}
className="xl:hidden"
/>
<TableOfContentsDesktop
headings={filteredHeadings}
activeId={activeId}
onNavigate={navigateToHeading}
className="hidden xl:block"
/>
</>
);
}
@@ -0,0 +1,156 @@
import type { MarkdownHeading } from 'astro';
import type { RefObject } from 'react';
import debounce from 'just-debounce-it';
import throttle from 'just-throttle';
import { useEffect, useState } from 'react';
/**
* Find the first scrollable ancestor of an element
*/
export function getScrollParent(element: HTMLElement): HTMLElement {
let current: HTMLElement | null = element;
while (current && current !== document.body) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
const overflow = style.overflow;
if (/auto|scroll/.test(overflow + overflowY)) {
if (current.scrollHeight > current.clientHeight) {
return current;
}
}
current = current.parentElement;
}
return document.body;
}
/**
* Check if an element is outside the visible bounds of its scroll container
*/
export function isElementOffscreen(
element: HTMLElement,
container: HTMLElement,
): boolean {
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const isAboveView = elementRect.top < containerRect.top;
const isBelowView = elementRect.bottom > containerRect.bottom;
return isAboveView || isBelowView;
}
/**
* Filter headings by depth range
*/
export function filterHeadingsByDepth(
headings: MarkdownHeading[],
minDepth: number,
maxDepth: number,
): MarkdownHeading[] {
return headings.filter(h => h.depth >= minDepth && h.depth <= maxDepth);
}
/**
* Navigate to a heading by scrolling it into view and updating the URL
*/
export function navigateToHeading(slug: string): void {
const element = document.getElementById(slug);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
window.history.pushState({}, '', `#${slug}`);
}
}
interface UseAutoScrollOptions {
activeId: string;
containerRef: RefObject<HTMLElement | null>;
}
/**
* Auto-scrolls the active link into view when it becomes active and is offscreen
*/
export function useAutoScroll({ activeId, containerRef }: UseAutoScrollOptions) {
useEffect(() => {
if (!activeId || !containerRef.current) return;
const activeLink = containerRef.current.querySelector<HTMLAnchorElement>(
`a[href="#${activeId}"]`,
);
if (!activeLink) return;
const scrollParent = getScrollParent(containerRef.current);
if (!scrollParent) return;
if (isElementOffscreen(activeLink, scrollParent)) {
activeLink.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [activeId, containerRef]);
}
/**
* Tracks which heading is currently active based on scroll position
*/
export function useActiveHeading(headings: MarkdownHeading[]): string {
const [activeId, setActiveId] = useState<string>('');
useEffect(() => {
const handleScroll = () => {
// globals.css SHOULD define a scroll-margin-top for headings
// let's get the value of that, here
let scrollOffset = 125; // idk, a sensible default
const idElement = document.querySelector('main [id]');
if (idElement) {
const computedStyle = getComputedStyle(idElement);
const scrollMarginTop = computedStyle.scrollMarginTop;
if (scrollMarginTop) {
const parsed = Number.parseFloat(scrollMarginTop);
if (!Number.isNaN(parsed)) scrollOffset = parsed;
}
}
scrollOffset = scrollOffset + 1;
const scrollPosition = window.scrollY + scrollOffset;
// Find the last heading that's above the scroll position
let currentActiveId = '';
for (const heading of headings) {
const element = document.getElementById(heading.slug);
if (element) {
const elementTop = element.offsetTop;
if (elementTop <= scrollPosition) {
currentActiveId = heading.slug;
} else {
break;
}
}
}
// since this function is mostly only called in events...
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setActiveId(currentActiveId);
};
// Throttle to limit how often it runs during scrolling
const throttledHandleScroll = throttle(handleScroll, 100);
// Debounce to ensure it runs after scrolling stops
const debouncedHandleScroll = debounce(throttledHandleScroll, 50);
// Set initial active heading
handleScroll();
// Add scroll listeners
window.addEventListener('scroll', throttledHandleScroll);
window.addEventListener('scroll', debouncedHandleScroll);
return () => {
window.removeEventListener('scroll', throttledHandleScroll);
window.removeEventListener('scroll', debouncedHandleScroll);
};
}, [headings]);
return activeId;
}