diff --git a/src/components/stepConnector/index.tsx b/src/components/stepConnector/index.tsx new file mode 100644 index 00000000000000..795b4430ed45fd --- /dev/null +++ b/src/components/stepConnector/index.tsx @@ -0,0 +1,195 @@ +'use client'; + +/** + * Component: StepConnector / StepComponent + * + * Visually connects sequential headings with a vertical rail and circles. + * Supports optional numbering and a user‑checkable “completed” state. + * + * Props overview + * - startAt: number — first step number (default 1) + * - selector: string — heading selector (default 'h2') + * - showNumbers: boolean — show numbers inside circles (default true) + * - checkable: boolean — allow toggling completion (default false) + * - persistence: 'session' | 'none' — completion storage (default 'session') + * - showReset: boolean — show a small “Reset steps” action (default true) + * + * Usage + * + * + * + * Accessibility + * - Each circle becomes a button when `checkable` is enabled (keyboard + aria‑pressed). + * - When `showNumbers` is false, a subtle dot is shown; numbering remains implicit + * via the DOM order, and buttons include descriptive aria‑labels. + * + * Theming / CSS variables (in style.module.scss) + * - --rail-x, --circle, --gap control rail position and circle size/spacing. + */ + +import {useEffect, useMemo, useRef, useState} from 'react'; + +import styles from './style.module.scss'; + +type Persistence = 'session' | 'none'; + +type Props = { + children: React.ReactNode; + /** Allow users to check off steps (circle becomes a button). @defaultValue false */ + checkable?: boolean; + /** Completion storage: 'session' | 'none'. @defaultValue 'session' */ + persistence?: Persistence; + /** Which heading level to connect (CSS selector). @defaultValue 'h2' */ + selector?: string; + /** Show numeric labels inside circles. Set false for blank circles. @defaultValue true */ + showNumbers?: boolean; + /** Show a small "Reset steps" action when checkable. @defaultValue true */ + showReset?: boolean; + /** Start numbering from this value. @defaultValue 1 */ + startAt?: number; +}; + +export function StepComponent({ + children, + startAt = 1, + selector = 'h2', + showNumbers = true, + checkable = false, + persistence = 'session', + showReset = true, +}: Props) { + const containerRef = useRef(null); + const [completed, setCompleted] = useState>(new Set()); + + const storageKey = useMemo(() => { + if (typeof window === 'undefined' || persistence !== 'session') return null; + try { + const path = window.location?.pathname ?? ''; + return `stepConnector:${path}:${selector}:${startAt}`; + } catch { + return null; + } + }, [persistence, selector, startAt]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + // Return empty cleanup function for consistent return + return () => {}; + } + + const headings = Array.from( + container.querySelectorAll(`:scope ${selector}`) + ); + + headings.forEach(h => { + h.classList.remove(styles.stepHeading); + h.removeAttribute('data-step'); + h.removeAttribute('data-completed'); + const existingToggle = h.querySelector(`.${styles.stepToggle}`); + if (existingToggle) existingToggle.remove(); + }); + + headings.forEach((h, idx) => { + const stepNumber = startAt + idx; + h.setAttribute('data-step', String(stepNumber)); + h.classList.add(styles.stepHeading); + + if (checkable) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = styles.stepToggle; + btn.setAttribute('aria-label', `Toggle completion for step ${stepNumber}`); + btn.setAttribute('aria-pressed', completed.has(h.id) ? 'true' : 'false'); + btn.addEventListener('click', () => { + setCompleted(prev => { + const next = new Set(prev); + if (next.has(h.id)) next.delete(h.id); + else next.add(h.id); + return next; + }); + }); + h.insertBefore(btn, h.firstChild); + } + }); + + // Cleanup function + return () => { + headings.forEach(h => { + h.classList.remove(styles.stepHeading); + h.removeAttribute('data-step'); + h.removeAttribute('data-completed'); + const existingToggle = h.querySelector(`.${styles.stepToggle}`); + if (existingToggle) existingToggle.remove(); + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [startAt, selector, checkable]); + + useEffect(() => { + if (!storageKey || !checkable) return; + try { + const raw = sessionStorage.getItem(storageKey); + if (raw) setCompleted(new Set(JSON.parse(raw) as string[])); + } catch { + // Ignore storage errors + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey, checkable]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const headings = Array.from( + container.querySelectorAll(`:scope ${selector}`) + ); + headings.forEach(h => { + const isDone = completed.has(h.id); + if (isDone) h.setAttribute('data-completed', 'true'); + else h.removeAttribute('data-completed'); + const btn = h.querySelector(`.${styles.stepToggle}`) as HTMLButtonElement | null; + if (btn) btn.setAttribute('aria-pressed', isDone ? 'true' : 'false'); + }); + + if (storageKey && checkable) { + try { + sessionStorage.setItem(storageKey, JSON.stringify(Array.from(completed))); + } catch { + // Ignore storage errors + } + } + }, [completed, selector, storageKey, checkable]); + + const handleReset = () => { + setCompleted(new Set()); + if (storageKey) { + try { + sessionStorage.removeItem(storageKey); + } catch { + // Ignore storage errors + } + } + }; + + return ( + + {checkable && showReset && ( + + + Reset steps + + + )} + {children} + + ); +} + +// Alias to match usage ... +export function StepConnector(props: Props) { + return ; +} diff --git a/src/components/stepConnector/style.module.scss b/src/components/stepConnector/style.module.scss new file mode 100644 index 00000000000000..ab2f05dc171b53 --- /dev/null +++ b/src/components/stepConnector/style.module.scss @@ -0,0 +1,93 @@ +.stepContainer { + position: relative; + --rail-x: 18px; + --circle: 36px; + --gap: 8px; + --pad-left: calc(var(--rail-x) + var(--circle) + var(--gap)); + padding-left: var(--pad-left); +} + +.stepContainer::before { + content: ''; + position: absolute; + left: var(--rail-x); + top: 0; + bottom: 0; + width: 2px; + background: var(--gray-a4); +} + +.stepHeading { + position: relative; + scroll-margin-top: var(--header-height, 80px); +} + +.stepHeading::before { + content: attr(data-step); + position: absolute; + left: calc(var(--rail-x) - (var(--circle) / 2) - var(--pad-left)); + top: 0.05em; + width: var(--circle); + height: var(--circle); + border-radius: 9999px; + display: grid; + place-items: center; + font-size: 1.18rem; + font-weight: 600; + line-height: 1; + z-index: 1; + background: var(--gray-1); + color: var(--gray-12); + border: 1px solid var(--gray-a6); + box-shadow: 0 1px 2px var(--gray-a3); +} + +.stepContainer[data-shownumbers='false'] .stepHeading::before { content: ''; } +.stepContainer[data-shownumbers='false'] .stepHeading:not([data-completed='true'])::after { + content: ''; + position: absolute; + left: calc(var(--rail-x) - 3px - var(--pad-left)); + top: calc(0.05em + (var(--circle) / 2) - 3px); + width: 6px; + height: 6px; + border-radius: 9999px; + background: var(--gray-a8); + z-index: 2; +} + +.stepHeading[data-completed='true']::before { + content: '✓'; + background: var(--accent-11); + color: white; + border-color: var(--accent-11); +} + +.stepToggle { + position: absolute; + left: calc(var(--rail-x) - (var(--circle) / 2) - var(--pad-left)); + top: 0.05em; + width: var(--circle); + height: var(--circle); + border: 0; + padding: 0; + background: transparent; + cursor: pointer; + z-index: 3; +} +.stepToggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 9999px; +} + +.resetRow { display: flex; justify-content: flex-end; margin-bottom: 0.5rem; } +.resetBtn { + font-size: 0.8rem; + color: var(--gray-11); + background: transparent; + border: 1px solid var(--gray-a5); + border-radius: 9999px; + padding: 2px 8px; +} +.resetBtn:hover { background: var(--gray-a3); } + diff --git a/src/mdxComponents.ts b/src/mdxComponents.ts index 5525e3539813f3..7a423c04144492 100644 --- a/src/mdxComponents.ts +++ b/src/mdxComponents.ts @@ -43,6 +43,7 @@ import {SdkApi} from './components/sdkApi'; import {SdkOption} from './components/sdkOption'; import {SignInNote} from './components/signInNote'; import {SmartLink} from './components/smartLink'; +import {StepComponent, StepConnector} from './components/stepConnector'; import {TableOfContents} from './components/tableOfContents'; import {VersionRequirement} from './components/version-requirement'; import {VimeoEmbed} from './components/video'; @@ -95,6 +96,8 @@ export function mdxComponents( RelayMetrics, SandboxLink, SignInNote, + StepComponent, + StepConnector, VimeoEmbed, VersionRequirement, a: SmartLink,