// Copyright (c) Meta Platforms, Inc. and affiliates. "use client"; /** * @file Outline.tsx * @input Uses React, StyleX, Astryx link provider integration, Outline hooks/types, useListFocus * @output Exports Outline component and OutlineProps type * @position Core implementation; consumed by index.ts * * A table-of-contents navigation component with: * - Sliding indicator track (vertical divider + animated active bar) * - Density variant (default/compact) * - Scroll-spy active state when uncontrolled * - Roving-tabindex keyboard navigation (Arrow/Home/End, Enter/Space activate) * - onNavigateStart / onNavigateEnd callbacks around the smooth scroll * - Scroll scoping (offset, scrollContainerRef, hasScrollOnClick) * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Outline/Outline.doc.mjs * - /packages/core/src/Outline/index.ts * - /apps/storybook/stories/Outline.stories.tsx * - /packages/cli/assets/templates/blocks/components/Outline/ (showcase blocks) */ import { useRef } from "react"; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, durationVars, easeVars, radiusVars, spacingVars, typeScaleVars, fontWeightVars, } from "../theme/tokens.stylex.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import { useListFocus } from "../hooks/useListFocus.js"; import { useIsomorphicLayoutEffect } from "../hooks/useIsomorphicLayoutEffect.js"; import { mergeProps } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import { useScrollSpy } from "./useScrollSpy.js"; import type { OutlineItem } from "./types.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; export type { OutlineItem } from "./types.js"; export interface OutlineProps extends BaseProps { /** Ref forwarded to the root nav element. */ ref?: React.Ref; /** Ordered list of heading items to render. */ items: OutlineItem[]; /** ID of the currently active item. When provided, disables built-in scroll-spy. */ activeId?: string; /** Called when the active item changes from scroll-spy or click. */ onActiveIdChange?: (id: string) => void; /** Accessible label for the nav landmark. @default 'Table of contents' */ label?: string; /** * Density variant controlling item padding. * - 'default': Standard spacing (default) * - 'compact': Reduced spacing for dense UIs * @default 'default' */ density?: "default" | "compact"; /** Side of the outline's guide line. Place the nav beside content with your layout. */ side?: "left" | "right"; /** Show the guide line and active indicator. @default true */ hasLine?: boolean; /** * Called when navigation to an item begins, before the scroll starts. * Receives the item `id`. Pair with `onNavigateEnd` to drive an arrival * effect (flash, ring, pulse) on the target heading. */ onNavigateStart?: (id: string) => void; /** * Called once when navigation to an item resolves — when the smooth scroll * settles, or immediately-ish when reduced motion turns it into a jump. * * Fires exactly once for every `onNavigateStart`, including when the user * interrupts the scroll by scrolling manually, so a "navigating" state can * never leak. It does not fire if the Outline unmounts mid-scroll. */ onNavigateEnd?: (id: string) => void; /** * Height in px of a fixed header overlaying the top of the scroll root. * * Shifts both the activation line *and* the scroll landing by the same * amount, so a heading activates exactly where navigating to it puts it — * below the header rather than hidden underneath it. * * It composes with each heading's own `scroll-margin-top` (the header, then * the breathing room below it) rather than replacing it. When nothing * overlays the content, leave this at 0 and let `scroll-margin-top` do the * work — the browser already honors it. * * @default 0 */ offset?: number; /** * Scroll container to track, instead of auto-detecting the nearest scrollable * ancestor. Use this when the content scrolls inside a split pane, modal, or * dashboard panel rather than the viewport. */ scrollContainerRef?: React.RefObject; /** * Whether activating an item smooth-scrolls to it. Set to false to own the * scrolling yourself (virtualized content, a router) — the Outline still * updates the active item, the hash, and the navigate callbacks, but performs * no scroll and suppresses the anchor's default jump. * @default true */ hasScrollOnClick?: boolean; /** Test ID for testing frameworks. */ "data-testid"?: string; } // ============================================================================= // Styles // ============================================================================= const styles = { root: { k1xSpc: "x78zum5", kXwgrk: "x1q0g3np", kVAEAm: "x1n2onr6", kOIVth: "x1lsbc85", kzqmXN: "xh8yej3", $$css: true as const, }, activeAnchor: { k48fcG: "x7dpabl", $$css: true as const, }, link: { kGNEyG: "x6s0dn4", kaIpWk: "xh6dtrn", kB7OPa: "x9f619", kMwMTN: "xv1l7n4", kkrTdU: "x1ypdohk x16khyan", k1xSpc: "x78zum5", k63SB2: "x1sodnla", kI3sdo: "x1a2a7pz", kVAEAm: "x1n2onr6", k9WMMc: "x1yc453h", kybGjl: "x1hl2dhg", kIyJzY: "xuedmi6", k1ekBW: "xs2xxs2", kAMwcw: "xlr8y92", kzqmXN: "xh8yej3", kGuDYH: "xjm74w1", kLWn49: "xw6l6zx", kZC7kL: "xo5jvnm", $$css: true as const, }, activeLink: { kMwMTN: "x1tgivj0", k63SB2: "x2mo6ok", $$css: true as const, }, }; const densityStyles = { compact: { k8WAf4: "xu0wf1k", kwRFfy: "x1djylfy", $$css: true as const, }, default: { k8WAf4: "xce4md1", kwRFfy: "x1djylfy", $$css: true as const, }, }; const indentStyles = { level1: { kZCmMZ: "x126nfab", $$css: true as const, }, level2: { kZCmMZ: "xchaq28", $$css: true as const, }, level3: { kZCmMZ: "xc8afjc", $$css: true as const, }, level4: { kZCmMZ: "x19b7t93", $$css: true as const, }, }; function getIndentStyle(level: number) { // Map heading levels 1-6 to visual indent levels 1-4 // Level 1 (h1) = indent 1, Level 2 (h2) = indent 1, Level 3 (h3) = indent 2, etc. const indentLevel = Math.max(1, Math.min(4, level - 1 || 1)); switch (indentLevel) { case 1: return indentStyles.level1; case 2: return indentStyles.level2; case 3: return indentStyles.level3; default: return indentStyles.level4; } } // ============================================================================= // Component // ============================================================================= /** * A table-of-contents navigation component for document headings. * * Outline accepts a flat `items` array and renders anchor links with * indentation based on each heading level. Features a sliding indicator * track that animates to the active item. * * When `activeId` is omitted, it tracks scroll position and marks the last * heading whose top has passed its activation line — which is exactly where * navigating to that heading lands it: `offset` (a fixed header overlaying the * scroll root) plus the heading's own `scroll-margin-top`. It defaults to the * first item at the top and the last at the bottom. * * Keyboard: the list is a single tab stop (roving tabindex), seated on the * active heading. Arrow keys move between headings, Home/End jump to the ends, * and Enter/Space activate — so a long table of contents costs one Tab press, * not one per heading. * * @example * ``` * * ``` * * Scoped to a custom scroll container, under a fixed header: * * @example * ``` * const scrollContainerRef = useRef(null); *
...
* flashHeading(id)} * /> * ``` */ export function Outline({ items, activeId, onActiveIdChange, label: labelFromProps, density = "default", side = "left", hasLine = true, onNavigateStart, onNavigateEnd, offset = 0, scrollContainerRef, hasScrollOnClick = true, xstyle, className, style, ref, "data-testid": testId, ...props }: OutlineProps) { const t = useTranslator(); const label = labelFromProps ?? t("@astryx.outline.label"); const rootRef = useRef(null); const LinkComponent = useLinkComponent(); const { activeId: resolvedActiveId, scrollTo } = useScrollSpy({ activeId, items, onActiveIdChange, rootRef, offset, scrollContainerRef, hasScrollOnClick, onNavigateStart, onNavigateEnd, }); // Roving tabindex over the links: the whole outline is one tab stop, and // arrows move between headings. The hook owns `tabindex` on the items. const { listRef, handleKeyDown: handleListKeyDown, handleFocus, } = useListFocus({ itemSelector: "a[href]", orientation: "vertical", hasRovingTabIndex: true, }); // Seat the tab stop on the *active* heading. WAI-ARIA puts the single tab // stop on the current item, so tabbing into a table of contents while // reading section 7 lands on section 7 — not back at section 1, which is // where useListFocus would otherwise leave it (it promotes the first item on // mount and then keeps whichever item already holds the stop). // // Only while focus is outside the list: once the reader has arrowed to an // item, the stop is theirs and scroll-spy must not yank it away. Sets // `tabindex` only — it never steals focus. Registered after useListFocus so // it runs after the hook's own layout effect and wins; on later commits the // hook then finds this item already holding the stop and keeps it. useIsomorphicLayoutEffect(() => { const list = listRef.current; if (list == null || list.contains(document.activeElement)) { return; } const links = Array.from(list.querySelectorAll("a[href]")); const active = links.find( (link) => link.getAttribute("aria-current") === "location", ); if (active == null) { return; } for (const link of links) { const tabIndex = link === active ? "0" : "-1"; if (link.getAttribute("tabindex") !== tabIndex) { link.setAttribute("tabindex", tabIndex); } } }); /** * The single navigation path, shared by click and keyboard activation: * push the hash, then hand off to the scroll-spy's `scrollTo`, which fires * the navigate callbacks and moves the indicator when the scroll settles. */ const navigate = (id: string) => { if (scrollTo(id)) { window.history.pushState(null, "", `#${id}`); } }; /** Whether an event carries a modifier chord we must leave to the browser. */ const hasModifier = ( event: React.MouseEvent | React.KeyboardEvent, ) => event.metaKey || event.altKey || event.ctrlKey || event.shiftKey; const handleClick = (id: string) => (event: React.MouseEvent) => { // Let the browser handle modified clicks (open in new tab, etc.) without // touching the active state. if (event.defaultPrevented || hasModifier(event)) { return; } // With no target in the DOM — lazily-rendered or virtualized content — // there is nothing to scroll to and nothing to make active. Leave the // anchor to the browser's native fragment navigation (which still // updates the URL) rather than swallowing the click into a dead link. if (document.getElementById(id) == null) { return; } // Suppress the anchor's default jump: we own the scroll (or, with // `hasScrollOnClick={false}`, deliberately leave it to the consumer). event.preventDefault(); navigate(id); }; const handleKeyDown = (id: string) => (event: React.KeyboardEvent) => { // Enter is a link's native activation and already produces a click. // Space is not, so wire it up here to match the button-like affordance. if (event.key !== " " && event.key !== "Spacebar") { return; } if (event.defaultPrevented || hasModifier(event)) { return; } // preventDefault does double duty: it stops the page from scrolling, and // it tells useScrollSpy this Space is an activation, not a manual scroll. event.preventDefault(); navigate(id); }; return ( ); } Outline.displayName = "Outline";