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
251 changes: 251 additions & 0 deletions app/components/page-toc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
"use client";

import { useEffect, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { usePathname } from "next/navigation";
import { tocId } from "./toc-section";

function useIsClient() {
return useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
}

type TocEntry = {
id: string;
label: string;
level: number;
};

const EXCLUDED_PATHS = new Set(["/", "/playlists/map"]);
const SCROLL_OFFSET = 112;
/** Minimum clear pixels between page content and viewport right edge. */
const TOC_MIN_RIGHT_GUTTER = 28;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Gutter threshold is far smaller than the expanded panel's width.

TOC_MIN_RIGHT_GUTTER is 28px, but the expanded panel (min-width: 11rem / max-width: 13.5rem, see app/globals.css lines 264-265) needs ~176-216px of clearance. Near the 28px threshold, the rail will show but the panel will overlap page content (or overflow the viewport) on hover/focus, since hasRoomForToc() never accounts for panel width.

Raise the threshold to reflect the panel's actual footprint (e.g. panel max-width + rail offset + margin), not just the rail's.

Also applies to: 31-36, 200-200

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/page-toc.tsx` at line 25, The TOC visibility threshold in
page-toc.tsx is too low for the expanded panel footprint, so the rail can appear
even when the panel will overlap content or overflow. Update hasRoomForToc() and
the TOC_MIN_RIGHT_GUTTER constant to account for the full expanded panel width
(use the panel’s max-width plus the rail offset/margin), and keep the rail
hidden unless there is enough space for the complete TOC panel.


function findPageContentColumn(): HTMLElement | null {
return document.querySelector<HTMLElement>('main [class*="max-w-"]');
}

function hasRoomForToc(): boolean {
const content = findPageContentColumn();
if (!content) return false;
const gutter = window.innerWidth - content.getBoundingClientRect().right;
return gutter >= TOC_MIN_RIGHT_GUTTER;
}

function getLabelText(el: HTMLElement): string {
return el.textContent?.trim().replace(/\s+/g, " ") ?? "";
}

function resolveScrollTarget(labelEl: HTMLElement): HTMLElement | null {
const inset = labelEl.closest<HTMLElement>(".mag-card-inset");
if (inset) return inset;

const card = labelEl.closest<HTMLElement>(".mag-card");
if (card) return card;

const section = labelEl.closest<HTMLElement>("section");
if (section) return section;

const fadeUp = labelEl.closest<HTMLElement>(".fade-up");
if (fadeUp) return fadeUp;

return labelEl.parentElement;
}

function ensureScrollTarget(el: HTMLElement, label: string): string {
if (el.id) {
el.style.scrollMarginTop = "5rem";
return el.id;
}

let candidate = tocId(label);
let suffix = 2;
while (document.getElementById(candidate) && document.getElementById(candidate) !== el) {
candidate = `${tocId(label)}-${suffix++}`;
}

el.id = candidate;
el.style.scrollMarginTop = "5rem";
return candidate;
}

function isInsideExplicitToc(el: HTMLElement): boolean {
return Boolean(el.closest("[data-toc-item]"));
}

function compareDocumentOrder(a: HTMLElement, b: HTMLElement): number {
if (a === b) return 0;
const position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
}

function scanTocItems(): TocEntry[] {
const entries: { el: HTMLElement; label: string; level: number }[] = [];
const claimed = new Set<HTMLElement>();

const claim = (el: HTMLElement, label: string, level: number) => {
if (!label || claimed.has(el)) return;
claimed.add(el);
entries.push({ el, label, level });
};

document.querySelectorAll<HTMLElement>("[data-toc-item]").forEach((el) => {
const label = el.getAttribute("data-toc-label") ?? "";
const level = Number(el.getAttribute("data-toc-level") ?? 0);
if (label) claim(el, label, level);
});

document.querySelectorAll<HTMLElement>(".mag-label").forEach((labelEl) => {
const target = resolveScrollTarget(labelEl);
if (!target || isInsideExplicitToc(target)) return;

const label = getLabelText(labelEl);
const level = labelEl.closest(".mag-card-inset") ? 1 : 0;
claim(target, label, level);
});

document.querySelectorAll<HTMLElement>(".mag-card h2, .mag-card h1").forEach((heading) => {
const target = heading.closest<HTMLElement>(".fade-up") ?? heading.closest<HTMLElement>(".mag-card");
if (!target || isInsideExplicitToc(target) || claimed.has(target)) return;

const label = getLabelText(heading);
claim(target, label, 0);
});

return entries
.sort((a, b) => compareDocumentOrder(a.el, b.el))
.map(({ el, label, level }) => ({
id: ensureScrollTarget(el, label),
label,
level,
}));
}

export default function PageToc() {
const pathname = usePathname();
const isClient = useIsClient();
const [items, setItems] = useState<TocEntry[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
const [hasRoom, setHasRoom] = useState(false);

useEffect(() => {
const refresh = () => {
const next = scanTocItems();
setItems(next);
setActiveId((prev) => {
if (prev && next.some((item) => item.id === prev)) return prev;
return next[0]?.id ?? null;
});
};

refresh();
const timer = window.setTimeout(refresh, 150);
return () => window.clearTimeout(timer);
}, [pathname]);

useEffect(() => {
const updateRoom = () => {
setHasRoom(hasRoomForToc());
};

updateRoom();
const timer = window.setTimeout(updateRoom, 150);
window.addEventListener("resize", updateRoom);

const content = findPageContentColumn();
const observer = content ? new ResizeObserver(updateRoom) : null;
if (content) observer?.observe(content);

return () => {
window.clearTimeout(timer);
window.removeEventListener("resize", updateRoom);
observer?.disconnect();
};
}, [pathname]);

useEffect(() => {
if (items.length === 0) return;

const onScroll = () => {
let current: string | null = items[0].id;
for (const { id } of items) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top <= SCROLL_OFFSET) {
current = id;
}
}
setActiveId(current);
};

const frame = requestAnimationFrame(onScroll);
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [items]);

const scrollTo = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
};

if (EXCLUDED_PATHS.has(pathname) || items.length < 2 || !isClient || !hasRoom) {
return null;
}

return createPortal(
<nav
aria-label="On this page"
className="page-toc page-toc--visible"
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
onFocus={() => setExpanded(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setExpanded(false);
}
}}
>
<div className="page-toc-rail" aria-hidden={expanded}>
{items.map(({ id, level }) => (
<span
key={id}
className={`page-toc-line page-toc-line--l${level}${activeId === id ? " page-toc-line--active" : ""}`}
/>
))}
</div>
Comment on lines +204 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keyboard users cannot open the TOC panel.

When collapsed, the rail's <span> elements are not focusable, and the panel is visibility: hidden (see app/globals.css lines 273-275) — visibility:hidden removes descendants from the tab order in modern browsers. That means there is no focusable element inside <nav> while collapsed, so onFocus (which is supposed to trigger setExpanded(true)) can never fire via keyboard Tab navigation. The floating TOC is effectively mouse-only.

Add a focusable trigger (e.g. a visually-hidden button, or tabIndex={0} with a visible focus style on the rail container) so keyboard users can expand the panel.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/page-toc.tsx` around lines 204 - 224, The page TOC is
keyboard-inaccessible when collapsed because `PageToc` renders only
non-focusable rail `<span>` elements and `visibility: hidden` prevents any
descendants from receiving Tab focus. Update the `createPortal` markup in
`page-toc.tsx` to include a focusable trigger inside the `<nav>` (for example a
button or a focusable rail container) that can receive keyboard focus and call
`setExpanded(true)`, and ensure it has an appropriate visible focus state while
keeping the existing mouse and blur behavior.


<div
className={`page-toc-panel${expanded ? " page-toc-panel--open" : ""}`}
aria-hidden={!expanded}
>
<ul className="page-toc-list">
{items.map(({ id, label, level }) => {
const isActive = activeId === id;
return (
<li key={id} className={`page-toc-item page-toc-item--l${level}`}>
<button
type="button"
className={`page-toc-link${isActive ? " page-toc-link--active" : ""}`}
onClick={() => scrollTo(id)}
aria-current={isActive ? "location" : undefined}
>
{label}
</button>
</li>
);
})}
</ul>
</div>
</nav>,
document.body,
);
}
2 changes: 2 additions & 0 deletions app/components/subpage-enter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { usePathname } from "next/navigation";
import PageToc from "./page-toc";

export default function SubpageEnter({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
Expand All @@ -13,6 +14,7 @@ export default function SubpageEnter({ children }: { children: React.ReactNode }
return (
<div className="subpage-enter" key={pathname}>
<div className="subpage-enter-inner relative z-[1]">{children}</div>
<PageToc />
</div>
);
}
43 changes: 43 additions & 0 deletions app/components/toc-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { HTMLAttributes, ReactNode } from "react";

export function tocId(label: string): string {
return label
.toLowerCase()
.replace(/['']/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}

export type TocSectionProps = {
label: string;
level?: 0 | 1;
id?: string;
children: ReactNode;
} & Omit<HTMLAttributes<HTMLDivElement>, "id">;

/** Optional explicit scroll target — PageToc also auto-discovers `.mag-label` sections. */
export function TocSection({
label,
level = 0,
id,
className,
children,
style,
...rest
}: TocSectionProps) {
const sectionId = id ?? tocId(label);

return (
<div
id={sectionId}
data-toc-item
data-toc-label={label}
data-toc-level={String(level)}
className={className}
style={{ scrollMarginTop: "5rem", ...style }}
{...rest}
>
{children}
</div>
);
}
Loading