diff --git a/src/modules/markdown/components/callout.tsx b/src/modules/markdown/components/callout.tsx index 6907d85..1a96e0d 100644 --- a/src/modules/markdown/components/callout.tsx +++ b/src/modules/markdown/components/callout.tsx @@ -5,7 +5,7 @@ import { OctagonAlertIcon, TriangleAlertIcon, } from 'lucide-react'; -import type { ReactNode } from 'react'; +import * as React from 'react'; /** * The five GitHub alert types, each mapped to its icon and visible label. @@ -31,7 +31,7 @@ type AlertType = keyof typeof ALERTS; * @param props.type - One of the five alert types; supplied by `remark-alert`. * @param props.children - The alert body, already stripped of its `[!TYPE]` marker. */ -export function Callout({ type, children }: { type: AlertType; children: ReactNode }) { +export function Callout({ type, children }: { type: AlertType; children: React.ReactNode }) { const { icon: Icon, label } = ALERTS[type]; return (
diff --git a/src/modules/markdown/components/code-copy-button.tsx b/src/modules/markdown/components/code-copy-button.tsx index 407588b..9bf3dea 100644 --- a/src/modules/markdown/components/code-copy-button.tsx +++ b/src/modules/markdown/components/code-copy-button.tsx @@ -1,8 +1,7 @@ 'use client'; import { CheckIcon, CopyIcon } from 'lucide-react'; -import type { MouseEvent } from 'react'; -import { useRef, useState } from 'react'; +import * as React from 'react'; import { Button } from '#/ui/components/core/button'; @@ -17,10 +16,10 @@ const COPY_FEEDBACK_DURATION_MS = 2000; * lines are skipped here. */ export function CodeCopyButton() { - const [copied, setCopied] = useState(false); - const timeoutRef = useRef>(undefined); + const [copied, setCopied] = React.useState(false); + const timeoutRef = React.useRef>(undefined); - function handleCopy(event: MouseEvent) { + function handleCopy(event: React.MouseEvent) { const figure = event.currentTarget.closest('figure'); if (!figure) return; diff --git a/src/modules/markdown/components/tabs.tsx b/src/modules/markdown/components/tabs.tsx index e77e3fb..35b9e70 100644 --- a/src/modules/markdown/components/tabs.tsx +++ b/src/modules/markdown/components/tabs.tsx @@ -1,7 +1,4 @@ -'use client'; - -import type { ReactNode } from 'react'; -import { Children, isValidElement } from 'react'; +import * as React from 'react'; import { Tabs as TabsRoot, TabsContent, TabsList, TabsTrigger } from '#/ui/components/core/tabs'; @@ -11,9 +8,9 @@ import { Tabs as TabsRoot, TabsContent, TabsList, TabsTrigger } from '#/ui/compo * remark plugin are plain strings; children hold one wrapper div per * `::tab[Label]` section, in label order. */ -export function Tabs({ labels, children }: { labels: string; children: ReactNode }) { +export function Tabs({ labels, children }: { labels: string; children: React.ReactNode }) { const tabLabels = JSON.parse(labels) as string[]; - const panels = Children.toArray(children).filter((child) => isValidElement(child)); + const panels = React.Children.toArray(children).filter((child) => React.isValidElement(child)); return ( diff --git a/src/modules/toc/components/article-toc.tsx b/src/modules/toc/components/article-toc.tsx new file mode 100644 index 0000000..85b5f9a --- /dev/null +++ b/src/modules/toc/components/article-toc.tsx @@ -0,0 +1,160 @@ +import { ListIcon } from 'lucide-react'; +import * as React from 'react'; + +import type { TableOfContents } from '#/modules/markdown/markdown.types'; +import { TocLink, TocList, TocRail, TocRoot, useToc } from '#/modules/toc/components/toc'; +import { parseTocEntries } from '#/modules/toc/toc.entries'; +import { Button } from '#/ui/components/core/button'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '#/ui/components/core/drawer'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '#/ui/components/core/sheet'; +import { cn } from '#/ui/utils'; + +/** + * The article's "On this page" navigation, placed once per article and adapting + * to three widths: + * + * - **Desktop** (`xl` and up): fixed in the gutter just right of the centered + * article, always visible ({@link GutterToc}). + * - **Tablet** (`md`–`xl`, where that gutter collapses): a floating button + * opening the list in a right-side sheet ({@link SheetToc}). + * - **Mobile** (below `md`): a full-width bar pinned to the very bottom like a + * bottom navigation bar, opening the list in a drawer ({@link DrawerToc}). + * + * All three tiers share one {@link TocRoot}, so the reading position is + * tracked once no matter which tier is visible. Renders nothing when the + * article has no h2/h3 headings. + */ +export function ArticleToc({ headings }: { headings: TableOfContents }) { + const entries = React.useMemo(() => parseTocEntries(headings), [headings]); + if (entries.length === 0) return null; + + return ( + + +
+ +
+
+ +
+
+ ); +} + +const TOC_TITLE = 'On this page'; + +/** The icon-and-text title shared by every tier's heading. */ +function TocTitle() { + return ( + <> + + {TOC_TITLE} + + ); +} + +/** + * The scrollable list every tier shows: rail, links, and the fading + * `.toc-scroll` viewport (see app.css) that hides its scrollbar and fades the + * edges while there's more to scroll. + */ +function TocPanel({ className, onNavigate }: { className?: string; onNavigate?: () => void }) { + const { entries } = useToc(); + + return ( +
+ + + {entries.map((entry) => ( + + ))} + +
+ ); +} + +/** Desktop tier: always visible in the gutter right of the centered article. */ +function GutterToc() { + return ( + + ); +} + +/** + * Tablet tier: a floating outline button in the bottom-right corner opening + * the TOC in a sheet that slides in from the right, echoing the desktop + * gutter's position. Closes as soon as a link scrolls the page. + */ +function SheetToc() { + const [open, setOpen] = React.useState(false); + + return ( + + + {TOC_TITLE} + + } + /> + + + + + + + setOpen(false)} /> + + + ); +} + +/** + * Mobile tier: a full-width bar fixed to the very bottom — styled like a + * mobile bottom navigation bar, with a safe-area inset so it clears the home + * indicator — opening the TOC in a bottom drawer the reader can swipe down to + * dismiss. Closes as soon as a link scrolls the page. + */ +function DrawerToc() { + const [open, setOpen] = React.useState(false); + + return ( + + + + {TOC_TITLE} + + + + + + + + setOpen(false)} /> + + }>Close + + + + ); +} diff --git a/src/modules/toc/components/toc.tsx b/src/modules/toc/components/toc.tsx new file mode 100644 index 0000000..62c60e7 --- /dev/null +++ b/src/modules/toc/components/toc.tsx @@ -0,0 +1,240 @@ +import * as React from 'react'; + +import type { TocEntry } from '#/modules/toc/toc.entries'; +import type { RailGeometry } from '#/modules/toc/toc.rail'; +import { DOT_RADIUS, highlightSpan, linkIndent, useRailGeometry } from '#/modules/toc/toc.rail'; +import { scrollToHeading, useKeepActiveInView, useScrollSpy } from '#/modules/toc/toc.scroll'; +import { cn } from '#/ui/utils'; + +/** + * Composable "On this page" primitives. `TocRoot` owns the shared reading + * state (one scroll spy, however many lists render under it); each `TocList` + * renders one navigable list and measures its own layout for the `TocRail` + * highlight. Compose them per surface: + * + * ```tsx + * const entries = parseTocEntries(post.toc); + * + * + * + * + * {entries.map((entry) => ( + * + * ))} + * + * + * ``` + * + * `ArticleToc` assembles these into the standard responsive shell. + */ + +interface TocContextValue { + /** The entries this TOC navigates, in document order. */ + entries: TocEntry[]; + /** Ids of the headings whose section is on screen, in document order. */ + activeIds: string[]; + /** The reading position is still above the first heading. */ + atStart: boolean; + /** The end of the article's content has scrolled into view. */ + atEnd: boolean; +} + +const TocContext = React.createContext(null); + +/** The TOC's entries and live reading state, provided by {@link TocRoot}. */ +export function useToc(): TocContextValue { + const context = React.useContext(TocContext); + if (!context) throw new Error('useToc must be used within '); + return context; +} + +interface TocRootProps { + /** + * Parsed entries (see `parseTocEntries`), referentially stable across + * renders — memoize at the call site; they key the scroll spy. + */ + entries: TocEntry[]; + children: React.ReactNode; +} + +/** + * Provides the entries and their scroll-derived reading state to every TOC + * piece below it. Renders no DOM of its own, so one root can feed several + * lists (the responsive shell mounts three) from a single scroll listener. + */ +export function TocRoot({ entries, children }: TocRootProps) { + const spy = useScrollSpy(entries); + const value = React.useMemo(() => ({ entries, ...spy }), [entries, spy]); + return {children}; +} + +interface TocListContextValue { + /** Measured layout of this list, or `null` until it has one (see `useRailGeometry`). */ + geometry: RailGeometry | null; + /** Registers a link's `
  • ` under its entry id, for measurement and follow-scroll. */ + registerLink: (id: string, element: HTMLLIElement | null) => void; + /** Click handler shared by every link: smooth-scrolls and reports navigation. */ + onLinkClick: (event: React.MouseEvent, id: string) => void; +} + +const TocListContext = React.createContext(null); + +function useTocList(): TocListContextValue { + const context = React.useContext(TocListContext); + if (!context) throw new Error('TOC list pieces must be used within '); + return context; +} + +interface TocListProps { + className?: string; + /** Called after a link scrolls the page — lets a sheet or drawer close. */ + onNavigate?: () => void; + /** `TocLink`s (and optionally a `TocRail`). */ + children: React.ReactNode; +} + +/** + * One rendered TOC list: a `