mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat: website (#45)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import { isValidFramework, isValidStyleForFramework } from '@/types/docs';
|
||||
import { resolveDocsLinkUrl } from '@/utils/docs/routing';
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<'a'>, 'href'> {
|
||||
slug: string;
|
||||
}
|
||||
const { slug, ...rest } = Astro.props;
|
||||
|
||||
const { framework: paramFramework, style: paramStyle } = Astro.params;
|
||||
if (!paramFramework || !isValidFramework(paramFramework)) {
|
||||
throw new Error(`Invalid or missing framework param "${paramFramework ?? 'undefined'}".`);
|
||||
}
|
||||
if (!paramStyle || !isValidStyleForFramework(paramFramework, paramStyle)) {
|
||||
throw new Error(`Invalid style param "${paramStyle}" for framework "${paramFramework}".`);
|
||||
}
|
||||
|
||||
const { url: href } = resolveDocsLinkUrl({
|
||||
targetSlug: slug,
|
||||
contextFramework: paramFramework,
|
||||
contextStyle: paramStyle,
|
||||
});
|
||||
---
|
||||
|
||||
<a href={href} {...rest}>
|
||||
<slot />
|
||||
</a>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import type { Guide, SupportedFramework, SupportedStyle } from '@/types/docs';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
type Props<F extends SupportedFramework = SupportedFramework> = {
|
||||
prev: Guide | null;
|
||||
next: Guide | null;
|
||||
framework: F;
|
||||
style: SupportedStyle<F>;
|
||||
docTitles: Map<string, string>;
|
||||
};
|
||||
|
||||
const { prev, next, framework, style, docTitles } = Astro.props;
|
||||
|
||||
function getGuideUrl<F extends SupportedFramework>(framework: F, style: SupportedStyle<F>, slug: string): string {
|
||||
return `/docs/framework/${framework}/style/${style}/${slug}`;
|
||||
}
|
||||
---
|
||||
|
||||
<nav class="w-full max-w-3xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{
|
||||
prev ? (
|
||||
<a
|
||||
href={getGuideUrl(framework, style, prev.slug)}
|
||||
class="grid items-center gap-x-2 p-4 border border-light-40 rounded-lg intent:border-dark-40"
|
||||
style="grid-template-areas: 'empty direction' 'icon title'; grid-template-columns: auto minmax(0,1fr); grid-template-rows: auto minmax(0,1fr);"
|
||||
>
|
||||
<div class="text-sm text-dark-80" style="grid-area: direction;">
|
||||
Previous
|
||||
</div>
|
||||
<div class="text-base font-medium text-balance" style="grid-area: title">
|
||||
{prev.sidebarLabel || docTitles.get(prev.slug)}
|
||||
</div>
|
||||
<div style="grid-area: icon">
|
||||
<ChevronDown size={12} className="rotate-90" />
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div />
|
||||
)
|
||||
}
|
||||
{
|
||||
next ? (
|
||||
<a
|
||||
href={getGuideUrl(framework, style, next.slug)}
|
||||
class="grid items-center gap-x-2 p-4 border border-light-40 rounded-lg intent:border-dark-40 text-right"
|
||||
style="grid-template-areas: 'direction empty' 'title icon';grid-template-columns: minmax(0,1fr) auto; grid-template-rows: auto minmax(0,1fr);"
|
||||
>
|
||||
<div class="text-sm text-dark-80" style="grid-area: direction;">
|
||||
Next
|
||||
</div>
|
||||
<div class="text-base font-medium text-balance" style="grid-area: title">
|
||||
{next.sidebarLabel || docTitles.get(next.slug)}
|
||||
</div>
|
||||
<div style="grid-area: icon">
|
||||
<ChevronDown size={12} className="-rotate-90" />
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div />
|
||||
)
|
||||
}
|
||||
</nav>
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import type { SupportedFramework, SupportedStyle } from '@/types/docs';
|
||||
|
||||
import { PreferenceUpdater } from '@/components/docs/PreferenceUpdater';
|
||||
import { Selectors } from '@/components/docs/Selectors';
|
||||
import SidebarItem from '@/components/docs/SidebarItem.astro';
|
||||
import { filterSidebar } from '@/utils/docs/sidebar';
|
||||
|
||||
type Props<F extends SupportedFramework = SupportedFramework> = {
|
||||
framework: F;
|
||||
style: SupportedStyle<F>;
|
||||
docTitles: Map<string, string>;
|
||||
};
|
||||
|
||||
const { framework, style, docTitles } = Astro.props;
|
||||
const filteredSidebar = filterSidebar(framework, style);
|
||||
---
|
||||
|
||||
<slot />
|
||||
<PreferenceUpdater client:idle currentFramework={framework} currentStyle={style} />
|
||||
<nav class="p-6 text-dark-80">
|
||||
{filteredSidebar.map((item) => <SidebarItem item={item} framework={framework} style={style} docTitles={docTitles} />)}
|
||||
</nav>
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import { GITHUB_REPO_URL } from '@/consts';
|
||||
|
||||
type Props = {
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
const { filePath } = Astro.props;
|
||||
const editUrl = `${GITHUB_REPO_URL}edit/main/${filePath}`;
|
||||
---
|
||||
|
||||
<div class="w-full max-w-3xl mx-auto">
|
||||
<a href={editUrl} target="_blank" rel="noopener noreferrer" class="underline intent:no-underline text-sm">
|
||||
Edit page
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
import { type SupportedFramework } from '@/types/docs';
|
||||
import type { SupportedFramework } from '@/types/docs';
|
||||
|
||||
interface Props {
|
||||
frameworks?: SupportedFramework[];
|
||||
frameworks?: SupportedFramework[];
|
||||
}
|
||||
|
||||
const { frameworks } = Astro.props;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SupportedFramework, SupportedStyle } from '@/types/docs';
|
||||
import { useEffect } from 'react';
|
||||
import { setPreferenceClient } from '@/utils/docs/preferences';
|
||||
|
||||
interface PreferenceUpdaterProps<F extends SupportedFramework = SupportedFramework> {
|
||||
currentFramework: F;
|
||||
currentStyle: SupportedStyle<F>;
|
||||
}
|
||||
|
||||
/**
|
||||
* PreferenceUpdater component updates user preferences in cookies.
|
||||
* This component is loaded with client:idle directive, making it non-blocking.
|
||||
* It renders nothing but updates cookies whenever framework or style changes.
|
||||
*/
|
||||
export function PreferenceUpdater<F extends SupportedFramework = SupportedFramework>({ currentFramework, currentStyle }: PreferenceUpdaterProps<F>) {
|
||||
useEffect(() => {
|
||||
setPreferenceClient(currentFramework, currentStyle);
|
||||
}, [currentFramework, currentStyle]);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
@@ -1,142 +1,92 @@
|
||||
import type { AnySupportedStyle, SupportedFramework } from '@/types/docs';
|
||||
import type { AnySupportedStyle, SupportedFramework, SupportedStyle } from '@/types/docs';
|
||||
|
||||
import { navigate } from 'astro:transitions/client';
|
||||
import { Select } from '@/components/Select';
|
||||
import { FRAMEWORK_STYLES, isValidFramework, isValidStyleForFramework, SUPPORTED_FRAMEWORKS } from '@/types/docs';
|
||||
import { resolveFrameworkChange, resolveStyleChange } from '@/utils/docs/routing';
|
||||
|
||||
import { getAvailableStyles, getDefaultStyle, SUPPORTED_FRAMEWORKS } from '@/types/docs';
|
||||
import { findFirstGuide, findGuideBySlug, getValidStylesForGuide } from '@/utils/docs/sidebar';
|
||||
|
||||
interface SelectorProps {
|
||||
currentFramework: SupportedFramework;
|
||||
currentStyle: AnySupportedStyle;
|
||||
interface SelectorProps<T extends SupportedFramework> {
|
||||
currentFramework: T;
|
||||
currentStyle: SupportedStyle<T>;
|
||||
currentSlug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the current guide slug from the docs URL.
|
||||
* URL format: /docs/framework/{framework}/style/{style}/{slug}/
|
||||
* @returns The guide slug (everything after the style parameter)
|
||||
*/
|
||||
function getCurrentGuideSlug(): string {
|
||||
const pathParts = window.location.pathname.split('/').filter(Boolean);
|
||||
const styleIndex = pathParts.indexOf('style');
|
||||
const slugParts = pathParts.slice(styleIndex + 2); // Everything after the style value
|
||||
return slugParts.join('/');
|
||||
}
|
||||
export function Selectors({
|
||||
currentFramework,
|
||||
currentStyle,
|
||||
currentSlug,
|
||||
}: SelectorProps<SupportedFramework>) {
|
||||
// TODO: use astro view transitions to preserve scroll position when switching from the same slug to the same slug
|
||||
const handleFrameworkChange = (newFramework: SupportedFramework | null) => {
|
||||
if (newFramework === null) return;
|
||||
if (!isValidFramework(newFramework)) return;
|
||||
|
||||
export function Selectors({ currentFramework, currentStyle }: SelectorProps) {
|
||||
const handleFrameworkChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newFramework = event.target.value as SupportedFramework;
|
||||
const { url, shouldReplace } = resolveFrameworkChange({
|
||||
currentFramework,
|
||||
currentStyle,
|
||||
currentSlug,
|
||||
newFramework,
|
||||
});
|
||||
|
||||
// Get current guide slug from URL
|
||||
const currentGuideSlug = getCurrentGuideSlug();
|
||||
|
||||
// Find the current guide in the sidebar
|
||||
const currentGuide = findGuideBySlug(currentGuideSlug);
|
||||
|
||||
if (!currentGuide) {
|
||||
// No current guide found, redirect to first guide of new framework
|
||||
const firstGuide = findFirstGuide(newFramework, getDefaultStyle(newFramework));
|
||||
if (firstGuide) {
|
||||
navigate(`/docs/framework/${newFramework}/style/${getDefaultStyle(newFramework)}/${firstGuide}/`);
|
||||
} else {
|
||||
navigate('/docs/');
|
||||
}
|
||||
return;
|
||||
if (shouldReplace) {
|
||||
// Maintaining the current slug, navigate without pushing onto the history stack
|
||||
window.location.replace(url);
|
||||
} else {
|
||||
// Changing slug, use normal navigation
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Check if guide is available for the new framework
|
||||
if (currentGuide.frameworks && !currentGuide.frameworks.includes(newFramework)) {
|
||||
// Guide not available in new framework, redirect to first guide
|
||||
const firstGuide = findFirstGuide(newFramework, getDefaultStyle(newFramework));
|
||||
if (firstGuide) {
|
||||
navigate(`/docs/framework/${newFramework}/style/${getDefaultStyle(newFramework)}/${firstGuide}/`);
|
||||
} else {
|
||||
navigate('/docs/');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get valid styles for this guide in the new framework
|
||||
const validStyles = getValidStylesForGuide(currentGuide, newFramework);
|
||||
|
||||
if (validStyles.length === 0) {
|
||||
// Guide not available in new framework, go to first guide
|
||||
const firstGuide = findFirstGuide(newFramework, getDefaultStyle(newFramework));
|
||||
if (firstGuide) {
|
||||
navigate(`/docs/framework/${newFramework}/style/${getDefaultStyle(newFramework)}/${firstGuide}/`);
|
||||
} else {
|
||||
navigate('/docs/');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick best style: current if still valid, otherwise first valid
|
||||
const newStyle = validStyles.includes(currentStyle) ? currentStyle : validStyles[0];
|
||||
|
||||
// Navigate to same guide with adjusted framework/style
|
||||
navigate(`/docs/framework/${newFramework}/style/${newStyle}/${currentGuideSlug}/`);
|
||||
};
|
||||
|
||||
const handleStyleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newStyle = event.target.value as AnySupportedStyle;
|
||||
const handleStyleChange = (newStyle: AnySupportedStyle | null) => {
|
||||
if (newStyle === null) return;
|
||||
if (!isValidStyleForFramework(currentFramework, newStyle)) return;
|
||||
|
||||
// Get current guide slug from URL
|
||||
const currentGuideSlug = getCurrentGuideSlug();
|
||||
const { url, shouldReplace } = resolveStyleChange({
|
||||
currentFramework,
|
||||
currentStyle,
|
||||
currentSlug,
|
||||
newStyle,
|
||||
});
|
||||
|
||||
// Find the current guide in the sidebar
|
||||
const currentGuide = findGuideBySlug(currentGuideSlug);
|
||||
|
||||
if (!currentGuide) {
|
||||
// No current guide found, redirect to first guide of new style
|
||||
const firstGuide = findFirstGuide(currentFramework, newStyle);
|
||||
if (firstGuide) {
|
||||
navigate(`/docs/framework/${currentFramework}/style/${newStyle}/${firstGuide}/`);
|
||||
} else {
|
||||
navigate('/docs/');
|
||||
}
|
||||
return;
|
||||
if (shouldReplace) {
|
||||
// Maintaining the current slug, navigate without pushing onto the history stack
|
||||
window.location.replace(url);
|
||||
} else {
|
||||
// Changing slug, use normal navigation
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Check if guide is valid for current framework and new style
|
||||
const validStyles = getValidStylesForGuide(currentGuide, currentFramework);
|
||||
|
||||
if (!validStyles.includes(newStyle)) {
|
||||
// Guide not available for new style, go to first guide
|
||||
const firstGuide = findFirstGuide(currentFramework, newStyle);
|
||||
if (firstGuide) {
|
||||
navigate(`/docs/framework/${currentFramework}/style/${newStyle}/${firstGuide}/`);
|
||||
} else {
|
||||
navigate('/docs/');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Guide supports the new style, navigate to it
|
||||
navigate(`/docs/framework/${currentFramework}/style/${newStyle}/${currentGuideSlug}/`);
|
||||
};
|
||||
|
||||
const availableStyles = getAvailableStyles(currentFramework);
|
||||
const availableStyles = FRAMEWORK_STYLES[currentFramework];
|
||||
|
||||
const frameworkOptions = SUPPORTED_FRAMEWORKS.map(fw => ({
|
||||
value: fw,
|
||||
label: fw,
|
||||
}));
|
||||
|
||||
const styleOptions = availableStyles.map(st => ({
|
||||
value: st,
|
||||
label: st,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div>
|
||||
<label htmlFor="framework-select">Framework:</label>
|
||||
<select id="framework-select" value={currentFramework} onChange={handleFrameworkChange}>
|
||||
{SUPPORTED_FRAMEWORKS.map(fw => (
|
||||
<option key={fw} value={fw}>
|
||||
{fw}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="style-select">Style:</label>
|
||||
<select id="style-select" value={currentStyle} onChange={handleStyleChange}>
|
||||
{availableStyles.map(st => (
|
||||
<option key={st} value={st}>
|
||||
{st}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="p-6 lg:py-2.5 xl:p-6 border-b border-light-40">
|
||||
<div className="max-w-3xl mx-auto w-full grid gap-x-6 gap-y-2 items-center" style={{ gridTemplateColumns: 'auto minmax(0, 1fr)' }}>
|
||||
<span>Framework</span>
|
||||
<Select
|
||||
value={currentFramework}
|
||||
onChange={handleFrameworkChange}
|
||||
options={frameworkOptions}
|
||||
aria-label="Select framework"
|
||||
data-testid="select-framework"
|
||||
/>
|
||||
<span>Style</span>
|
||||
<Select
|
||||
value={currentStyle}
|
||||
onChange={handleStyleChange}
|
||||
options={styleOptions}
|
||||
aria-label="Select style"
|
||||
data-testid="select-style"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,76 @@
|
||||
---
|
||||
import {
|
||||
type SupportedFramework,
|
||||
type AnySupportedStyle,
|
||||
type Guide,
|
||||
type Section,
|
||||
isSection,
|
||||
} from '@/types/docs';
|
||||
import type { Guide, Section, SupportedFramework, SupportedStyle } from '@/types/docs';
|
||||
import { isSection } from '@/types/docs';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
type Props = {
|
||||
item: Guide | Section;
|
||||
framework: SupportedFramework;
|
||||
style: AnySupportedStyle;
|
||||
docTitles: Map<string, string>;
|
||||
type Props<F extends SupportedFramework = SupportedFramework> = {
|
||||
item: Guide | Section;
|
||||
framework: F;
|
||||
style: SupportedStyle<F>;
|
||||
docTitles: Map<string, string>;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
const { item, framework, style, docTitles } = Astro.props;
|
||||
const { item, framework, style, docTitles, depth = 0 } = Astro.props;
|
||||
const currentPath = Astro.url.pathname;
|
||||
|
||||
// Helper to check if this item or any of its children contains the active path
|
||||
function containsActivePath(item: Guide | Section): boolean {
|
||||
if (isSection(item)) {
|
||||
return item.contents.some((child) => containsActivePath(child));
|
||||
} else {
|
||||
const itemPath = `/docs/framework/${framework}/style/${style}/${item.slug}`;
|
||||
return itemPath === currentPath;
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = containsActivePath(item);
|
||||
---
|
||||
|
||||
{
|
||||
isSection(item) ? (
|
||||
<details open>
|
||||
<summary>{item.sidebarLabel}</summary>
|
||||
{item.contents.map((contentItem) => (
|
||||
<Astro.self
|
||||
item={contentItem}
|
||||
framework={framework}
|
||||
style={style}
|
||||
docTitles={docTitles}
|
||||
/>
|
||||
))}
|
||||
<details open class="group" style={`padding-left: calc(var(--spacing) * ${depth * 4})`}>
|
||||
<summary
|
||||
class:list={[
|
||||
'py-2 flex items-center gap-4 ',
|
||||
'intent:text-dark-100 cursor-pointer',
|
||||
depth === 0 && 'font-medium',
|
||||
depth > 0 && 'border-l',
|
||||
isActive ? 'text-dark-100 border-dark-100 ' : 'border-light-40',
|
||||
]}
|
||||
>
|
||||
<span class="flex-1">{item.sidebarLabel}</span>
|
||||
<ChevronDown size={12} className="group-open:rotate-180" />
|
||||
</summary>
|
||||
<div>
|
||||
{item.contents.map((contentItem) => (
|
||||
<Astro.self item={contentItem} framework={framework} style={style} docTitles={docTitles} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div class="sidebar-guide">
|
||||
<a href={`/docs/framework/${framework}/style/${style}/${item.slug}/`}>
|
||||
{item.sidebarLabel || docTitles.get(item.slug) || item.slug}
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
class:list={[
|
||||
'py-2 flex items-center gap-4 ',
|
||||
'intent:text-dark-100 cursor-pointer',
|
||||
depth === 0 && 'font-medium',
|
||||
depth > 0 && 'border-l',
|
||||
isActive ? 'text-dark-100 border-dark-100 ' : 'border-light-40',
|
||||
]}
|
||||
style={`padding-left: calc(var(--spacing) * ${depth * 4})`}
|
||||
href={`/docs/framework/${framework}/style/${style}/${item.slug}`}
|
||||
>
|
||||
{item.devOnly ? '[Dev only] ' : ''}
|
||||
{item.sidebarLabel || docTitles.get(item.slug) || item.slug}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
summary {
|
||||
list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { AnySupportedStyle } from '@/types/docs';
|
||||
|
||||
interface Props {
|
||||
styles?: AnySupportedStyle[];
|
||||
styles?: AnySupportedStyle[];
|
||||
}
|
||||
|
||||
const { styles } = Astro.props;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user