Skip to content
Draft
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
71 changes: 68 additions & 3 deletions src/components/TableOfContentsClient.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,57 @@
import { memo } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import type { MarkdownHeading } from '@/lib/markdown-headings';

interface Props {
headings: MarkdownHeading[];
activeGuideId?: string;
}

const TableOfContentsClient = memo<Props>(({ headings }) => {
const TableOfContentsClient = memo<Props>(({ headings, activeGuideId }) => {
const [activeHeadingSlug, setActiveHeadingSlug] = useState<string | null>(headings[0]?.slug ?? null);

const headingSlugs = useMemo(() => headings.map((heading) => heading.slug), [headings]);

useEffect(() => {
setActiveHeadingSlug(headings[0]?.slug ?? null);
}, [activeGuideId, headings]);

useEffect(() => {
if (!headingSlugs.length) return;
if (typeof CSS === 'undefined' || typeof CSS.escape !== 'function') return;

const safeGuideId = activeGuideId ? CSS.escape(activeGuideId) : null;

const activeGuideArticle = safeGuideId
? document.querySelector<HTMLElement>(`[data-guide-id="${safeGuideId}"]`)
: null;

const headingElements = headingSlugs
.map((slug) => {
const safeSlug = CSS.escape(slug);
return activeGuideArticle?.querySelector<HTMLElement>(`[id="${safeSlug}"]`) ?? null;
})
.filter((element): element is HTMLElement => Boolean(element));

if (!headingElements.length) return;

const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);

if (visible.length) {
const headingId = visible[0].target.id;
if (headingId) setActiveHeadingSlug(headingId);
}
},
{ rootMargin: '-88px 0px -50% 0px', threshold: [0.1, 0.45, 1] }
);

headingElements.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, [headingSlugs, activeGuideId]);

if (!headings.length) return null;

return (
Expand All @@ -14,7 +60,26 @@ const TableOfContentsClient = memo<Props>(({ headings }) => {
<ul className="toc-list">
{headings.map((heading) => (
<li key={`${heading.slug}-${heading.depth}`} className={`toc-item depth-${heading.depth}`}>
<a href={`#${heading.slug}`} data-toc-link={heading.slug}>
<a
href={`#${heading.slug}`}
data-toc-link={heading.slug}
data-active={activeHeadingSlug === heading.slug}
onClick={(event) => {
if (!activeGuideId) return;
if (typeof CSS === 'undefined' || typeof CSS.escape !== 'function') return;
event.preventDefault();
const safeGuideId = CSS.escape(activeGuideId);
const safeSlug = CSS.escape(heading.slug);
const activeGuideArticle = document.querySelector<HTMLElement>(
`[data-guide-id="${safeGuideId}"]`
);
const target = activeGuideArticle?.querySelector<HTMLElement>(`[id="${safeSlug}"]`);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveHeadingSlug(heading.slug);
}}
aria-current={activeHeadingSlug === heading.slug ? 'location' : undefined}
>
{heading.text}
</a>
</li>
Expand Down
124 changes: 124 additions & 0 deletions src/components/installation/InstallationSidebarClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { memo, useEffect, useMemo, useState } from 'react';
import TableOfContentsClient from '@/components/TableOfContentsClient';
import type { MarkdownHeading } from '@/lib/markdown-headings';

interface InstallationGuideMeta {
id: string;
label: string;
href: string;
headings: MarkdownHeading[];
}

interface InstallationSidebarClientProps {
guides: InstallationGuideMeta[];
initialGuideId: string;
}

const InstallationSidebarClient = memo<InstallationSidebarClientProps>(({ guides, initialGuideId }) => {
const guideIds = useMemo(() => guides.map((guide) => guide.id), [guides]);
const [activeGuideId, setActiveGuideId] = useState(initialGuideId);

const activeGuide = useMemo(
() => guides.find((guide) => guide.id === activeGuideId) ?? guides[0],
[activeGuideId, guides]
);

useEffect(() => {
const hashGuideId = window.location.hash.slice(1);
if (hashGuideId && guideIds.includes(hashGuideId)) {
setActiveGuideId(hashGuideId);
}
}, [guideIds]);

useEffect(() => {
const updateVisibleGuide = () => {
const guideArticles = document.querySelectorAll<HTMLElement>('[data-guide-id]');
guideArticles.forEach((article) => {
const isActive = article.dataset.guideId === activeGuideId;
article.classList.toggle('hidden', !isActive);
article.classList.toggle('flex', isActive);
article.setAttribute('aria-hidden', isActive ? 'false' : 'true');
});
};

updateVisibleGuide();
}, [activeGuideId]);

useEffect(() => {
const onHashChange = () => {
const hashGuideId = window.location.hash.slice(1);
if (hashGuideId && guideIds.includes(hashGuideId)) {
setActiveGuideId(hashGuideId);
}
};

window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [guideIds]);

const onGuideChange = (guideId: string) => {
setActiveGuideId(guideId);
window.history.pushState(null, '', `#${guideId}`);
};

if (!guides.length || !activeGuide) return null;

return (
<div className="flex flex-col gap-3.5">
<div className="syne-font mb-0.5 text-[11px] tracking-[0.12em] text-[var(--film-text-dim)] uppercase">
Apps
</div>

<div className="md:hidden">
<label htmlFor="installation-app-select" className="sr-only">
Select installation app
</label>
<select
id="installation-app-select"
value={activeGuideId}
onChange={(event) => onGuideChange(event.target.value)}
className="syne-font w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs tracking-[0.06em] text-[var(--film-cream)] uppercase"
>
{guides.map((guide) => (
<option key={`option-${guide.id}`} value={guide.id} className="bg-[var(--film-black)]">
{guide.label}
</option>
))}
</select>
</div>

<nav id="app-nav" aria-label="Installation apps" className="hidden flex-col gap-1.5 md:flex">
{guides.map((guide) => {
const isActive = guide.id === activeGuideId;
return (
<a
key={guide.id}
href={guide.href}
data-app-link={guide.id}
aria-current={isActive ? 'page' : undefined}
onClick={(event) => {
event.preventDefault();
onGuideChange(guide.id);
}}
className={`syne-font rounded-md border px-2 py-1.5 text-xs tracking-[0.05em] uppercase transition-colors ${
isActive
? 'border-amber-300/50 bg-amber-800/35 text-[var(--film-cream)]'
: 'border-white/10 bg-white/[0.02] text-[var(--film-text-label)] hover:text-[var(--film-cream)]'
}`}
>
{guide.label}
</a>
);
})}
</nav>

<div id="toc-container" className="mt-1">
<TableOfContentsClient headings={activeGuide.headings} activeGuideId={activeGuideId} />
</div>
</div>
);
});

InstallationSidebarClient.displayName = 'InstallationSidebarClient';

export default InstallationSidebarClient;
2 changes: 2 additions & 0 deletions src/components/installation/ShowcaseMediaFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const ShowcaseMediaFrame = memo<ShowcaseMediaFrameProps>(({ src, alt, ratio, mob
alt={alt}
style={{ width: '100%', height: '100%', display: 'block', objectFit: 'cover' }}
loading="lazy"
decoding="async"
/>
</div>
);
Expand Down Expand Up @@ -82,6 +83,7 @@ const ShowcaseMediaFrame = memo<ShowcaseMediaFrameProps>(({ src, alt, ratio, mob
objectPosition: MOBILE_IMAGE_OBJECT_POSITION,
}}
loading="lazy"
decoding="async"
/>
</div>
</div>
Expand Down
Loading