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
98 changes: 98 additions & 0 deletions apps/docsite/src/components/docs/AnchorHeading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

/**
* A section heading that is linkable by anchor. Renders a `Heading` with a
* stable `id` plus a copy-link affordance that appears on hover/focus and
* copies the deep link (`<page-url>#<id>`) to the clipboard.
*
* Used for docs section headings so readers can link directly to a section
* (see issue: "Add section header link copy to docsite").
*/

import {useCallback, useState} from 'react';
import * as stylex from '@stylexjs/stylex';
import {Link2, Check} from 'lucide-react';
import {
Heading,
type HeadingLevel,
type HeadingType,
} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {IconButton} from '@astryxdesign/core/IconButton';
import {
spacingVars,
durationVars,
easeVars,
} from '@astryxdesign/core/theme/tokens.stylex';
import {anchorHeadingScope} from './anchorHeading.markers.stylex';

const styles = stylex.create({
row: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: spacingVars['--spacing-2'],
// Clear the sticky app header (and, on mobile, the sticky on-this-page
// selector via --docs-anchor-offset) when navigated to via the anchor.
scrollMarginTop:
'calc(var(--appshell-header-height, 0px) + var(--docs-anchor-offset, 0px) + 16px)',
},
reveal: {
display: 'inline-flex',
flexShrink: 0,
transitionProperty: 'opacity',
transitionDuration: durationVars['--duration-fast'],
transitionTimingFunction: easeVars['--ease-standard'],
opacity: {
default: 0,
// Coarse pointers can't hover; keep the affordance visible there.
'@media (hover: none)': 1,
[stylex.when.ancestor(':hover', anchorHeadingScope)]: 1,
[stylex.when.ancestor(':focus-within', anchorHeadingScope)]: 1,
},
},
});

export interface AnchorHeadingProps {
/** Anchor id assigned to the heading row and used to build the deep link. */
id: string;
level: HeadingLevel;
type?: HeadingType;
children: React.ReactNode;
}

export function AnchorHeading({id, level, type, children}: AnchorHeadingProps) {
const [copied, setCopied] = useState(false);

const handleCopy = useCallback(() => {
if (typeof window === 'undefined') {
return;
}
const url = `${window.location.origin}${window.location.pathname}#${id}`;
window.history.replaceState(null, '', `#${id}`);
void navigator.clipboard?.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}, [id]);

return (
<div id={id} {...stylex.props(styles.row, anchorHeadingScope)}>
<Heading level={level} type={type}>
{children}
</Heading>
<span {...stylex.props(styles.reveal)}>
<IconButton
size="sm"
variant="ghost"
label={`Copy link to "${typeof children === 'string' ? children : 'section'}"`}
tooltip={copied ? 'Copied' : 'Copy link'}
icon={<Icon icon={copied ? Check : Link2} size="sm" />}
onClick={handleCopy}
/>
</span>
</div>
);
}
160 changes: 154 additions & 6 deletions apps/docsite/src/components/docs/DocPageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,33 @@
* Owns the section padding, content container width, title and description
* treatment, and vertical spacing. Pages supply only their unique body via
* `children`.
*
* When `outline` items are provided, renders an on-this-page table of contents:
* a sticky `Outline` aside on desktop and a `Selector` jump menu on mobile.
* Outline anchors resolve to section ids that pages assign to their headings.
*/

import type {ReactNode} from 'react';
'use client';

import {useCallback, useMemo, useRef, useState, type ReactNode} from 'react';
import * as stylex from '@stylexjs/stylex';
import {Text, Heading} from '@astryxdesign/core/Text';
import {VStack} from '@astryxdesign/core/Layout';
import {Section} from '@astryxdesign/core/Section';
import {Divider} from '@astryxdesign/core/Divider';
import {typeScaleVars} from '@astryxdesign/core/theme/tokens.stylex';
import {Selector} from '@astryxdesign/core/Selector';
import {Outline, type OutlineItem} from '@astryxdesign/core/Outline';
import {useMediaQuery} from '@astryxdesign/core/hooks';
import {
colorVars,
spacingVars,
typeScaleVars,
} from '@astryxdesign/core/theme/tokens.stylex';
import {layout} from '../../layout.stylex';

const styles = stylex.create({
section: {
// Centered article when there is no outline aside (original behavior).
sectionCentered: {
marginInline: 'auto',
// Article body text reads larger and airier than the app default
// (body = 14px / 1.43). Re-assigning the body size/leading tokens here
Expand All @@ -32,22 +46,111 @@
[typeScaleVars['--text-body-size']]: '1rem', // 16px
[typeScaleVars['--text-body-leading']]: '1.75', // 28px line box
},
// Article that shares the row with a sticky outline aside. Mirrors the
// larger/airier article body typography from sectionCentered so pages with
// an outline keep the same readable prose.
sectionInRow: {
marginInline: 0,
flexShrink: 1,
minWidth: 0,
[typeScaleVars['--text-body-size']]: '1rem', // 16px
[typeScaleVars['--text-body-leading']]: '1.75', // 28px line box
},
row: {
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'center',
gap: 32,
width: '100%',
},
aside: {
position: 'sticky',
top: 'calc(var(--appshell-header-height, 0px) + 24px)',
alignSelf: 'flex-start',
flexShrink: 0,
width: 232,
},
// Mobile on-this-page selector: pinned below the app header while scrolling.
// Lives directly in the article column so its sticky range spans the article;
// an opaque background + bottom border keep content readable as it scrolls
// underneath.
mobileOutline: {
position: 'sticky',
top: 'var(--appshell-header-height, 0px)',
zIndex: 1,
backgroundColor: colorVars['--color-background-surface'],
paddingBlock: spacingVars['--spacing-3'],
borderBottomWidth: 1,
borderBottomStyle: 'solid',
borderBottomColor: colorVars['--color-border'],
},
});

export function DocPageLayout({
title,
description,
children,
outline,
}: {
title: string;
description?: string;
children: ReactNode;
/**
* Optional on-this-page navigation items. When non-empty, an Outline aside
* (desktop) or Selector jump menu (mobile) is rendered. Each item id must
* match a heading/section element id in `children`.
*/
outline?: OutlineItem[];
}) {
return (
const hasOutline = outline != null && outline.length > 0;
const isNarrow = useMediaQuery('(max-width: 1024px)');
const showAside = hasOutline && !isNarrow;
const showSelector = hasOutline && isNarrow;

const [activeId, setActiveId] = useState<string | undefined>(
outline?.[0]?.id,
);

// Track the sticky mobile selector height so anchored sections can offset
// their scroll position by both the app header and the selector — otherwise
// the section title lands hidden behind the pinned selector.
const [selectorHeight, setSelectorHeight] = useState(0);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const selectorRef = useCallback((node: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect();
if (node == null) {
setSelectorHeight(0);
return;
}
const measure = () => setSelectorHeight(node.offsetHeight);
measure();
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(measure);
observer.observe(node);
resizeObserverRef.current = observer;
}
}, []);

const selectorOptions = useMemo(
() => (outline ?? []).map(item => ({value: item.id, label: item.label})),
[outline],
);

const scrollToId = useCallback((id: string) => {
setActiveId(id);
const target = document.getElementById(id);
if (target != null) {
target.scrollIntoView({behavior: 'smooth', block: 'start'});
window.history.pushState(null, '', `#${id}`);
}
}, []);

const article = (
<Section
maxWidth={layout.proseMaxWidth}
padding={6}
xstyle={styles.section}>
xstyle={showAside ? styles.sectionInRow : styles.sectionCentered}>
<VStack gap={10}>
<VStack gap={4}>
<Heading level={1} type="display-1">
Expand All @@ -58,10 +161,55 @@
{description}
</Text>
) : null}
<Divider />
{/* When the mobile selector is shown it carries its own bottom border,
so the title divider is dropped to avoid a doubled separator. */}
{showSelector ? null : <Divider />}
</VStack>
{showSelector ? (
<div ref={selectorRef} {...stylex.props(styles.mobileOutline)}>
<Selector
label="On this page"
isLabelHidden
options={selectorOptions}
value={activeId}
onChange={scrollToId}
width="100%"
/>
</div>
) : null}
{children}
</VStack>
</Section>
);

if (!hasOutline) {
return article;
}

const rowProps = stylex.props(styles.row);

return (
<div
className={rowProps.className}
style={
{

Check warning on line 195 in apps/docsite/src/components/docs/DocPageLayout.tsx

View workflow job for this annotation

GitHub Actions / lint

Always prefer const x: T = { ... }
...rowProps.style,
// Consumed by anchored section headings (AnchorHeading and Markdown
// headings) to clear the sticky mobile selector when scrolled to.
'--docs-anchor-offset': `${selectorHeight}px`,
} as React.CSSProperties
}>
{article}
{showAside ? (
<aside {...stylex.props(styles.aside)}>
<Outline
items={outline}
label="On this page"
density="compact"
onActiveIdChange={setActiveId}
/>
</aside>
) : null}
</div>
);
}
Loading
Loading