Skip to content

Commit 3b45ccd

Browse files
feat(components): Add StepConnector component (#15003)
## Summary This PR introduces the StepConnector component - a visual component that connects sequential headings with a vertical rail and numbered circles, perfect for step-by-step guides and tutorials. ## Features - Visual connection of sequential headings with vertical rail and circles - Optional step numbering inside circles (showNumbers prop) - User-checkable completion state for interactive tutorials - Session persistence for completion states - Configurable starting number and heading selector - Reset functionality for completed steps - Full keyboard accessibility with ARIA attributes ## Usage ```jsx // Basic usage <StepConnector> <h2>First Step</h2> <p>Content...</p> <h2>Second Step</h2> <p>Content...</p> </StepConnector> // With checkable steps and custom options <StepConnector checkable startAt={1} selector="h2" persistence="session" showReset > {children} </StepConnector> ``` ## Props - `startAt`: Starting number for steps (default: 1) - `selector`: CSS selector for headings to connect (default: 'h2') - `showNumbers`: Show numbers inside circles (default: true) - `checkable`: Allow users to mark steps complete (default: false) - `persistence`: 'session' | 'none' for completion state storage (default: 'session') - `showReset`: Show reset button when checkable (default: true) ## Files Added - `src/components/stepConnector/index.tsx` - Component implementation - `src/components/stepConnector/style.module.scss` - Styles - Updated `src/mdxComponents.ts` to register component for MDX usage ## Status - [ ] Component implementation complete - [ ] Styles finalized - [ ] Session persistence tested - [ ] Accessibility tested - [ ] MDX integration tested - [ ] Documentation added - [ ] Review requested --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 61e7f67 commit 3b45ccd

File tree

3 files changed

+291
-0
lines changed

3 files changed

+291
-0
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
'use client';
2+
3+
/**
4+
* Component: StepConnector / StepComponent
5+
*
6+
* Visually connects sequential headings with a vertical rail and circles.
7+
* Supports optional numbering and a user‑checkable “completed” state.
8+
*
9+
* Props overview
10+
* - startAt: number — first step number (default 1)
11+
* - selector: string — heading selector (default 'h2')
12+
* - showNumbers: boolean — show numbers inside circles (default true)
13+
* - checkable: boolean — allow toggling completion (default false)
14+
* - persistence: 'session' | 'none' — completion storage (default 'session')
15+
* - showReset: boolean — show a small “Reset steps” action (default true)
16+
*
17+
* Usage
18+
* <StepConnector checkable />
19+
* <StepConnector showNumbers={false} checkable persistence="none" />
20+
*
21+
* Accessibility
22+
* - Each circle becomes a button when `checkable` is enabled (keyboard + aria‑pressed).
23+
* - When `showNumbers` is false, a subtle dot is shown; numbering remains implicit
24+
* via the DOM order, and buttons include descriptive aria‑labels.
25+
*
26+
* Theming / CSS variables (in style.module.scss)
27+
* - --rail-x, --circle, --gap control rail position and circle size/spacing.
28+
*/
29+
30+
import {useEffect, useMemo, useRef, useState} from 'react';
31+
32+
import styles from './style.module.scss';
33+
34+
type Persistence = 'session' | 'none';
35+
36+
type Props = {
37+
children: React.ReactNode;
38+
/** Allow users to check off steps (circle becomes a button). @defaultValue false */
39+
checkable?: boolean;
40+
/** Completion storage: 'session' | 'none'. @defaultValue 'session' */
41+
persistence?: Persistence;
42+
/** Which heading level to connect (CSS selector). @defaultValue 'h2' */
43+
selector?: string;
44+
/** Show numeric labels inside circles. Set false for blank circles. @defaultValue true */
45+
showNumbers?: boolean;
46+
/** Show a small "Reset steps" action when checkable. @defaultValue true */
47+
showReset?: boolean;
48+
/** Start numbering from this value. @defaultValue 1 */
49+
startAt?: number;
50+
};
51+
52+
export function StepComponent({
53+
children,
54+
startAt = 1,
55+
selector = 'h2',
56+
showNumbers = true,
57+
checkable = false,
58+
persistence = 'session',
59+
showReset = true,
60+
}: Props) {
61+
const containerRef = useRef<HTMLDivElement | null>(null);
62+
const [completed, setCompleted] = useState<Set<string>>(new Set());
63+
64+
const storageKey = useMemo(() => {
65+
if (typeof window === 'undefined' || persistence !== 'session') return null;
66+
try {
67+
const path = window.location?.pathname ?? '';
68+
return `stepConnector:${path}:${selector}:${startAt}`;
69+
} catch {
70+
return null;
71+
}
72+
}, [persistence, selector, startAt]);
73+
74+
useEffect(() => {
75+
const container = containerRef.current;
76+
if (!container) {
77+
// Return empty cleanup function for consistent return
78+
return () => {};
79+
}
80+
81+
const headings = Array.from(
82+
container.querySelectorAll<HTMLElement>(`:scope ${selector}`)
83+
);
84+
85+
headings.forEach(h => {
86+
h.classList.remove(styles.stepHeading);
87+
h.removeAttribute('data-step');
88+
h.removeAttribute('data-completed');
89+
const existingToggle = h.querySelector(`.${styles.stepToggle}`);
90+
if (existingToggle) existingToggle.remove();
91+
});
92+
93+
headings.forEach((h, idx) => {
94+
const stepNumber = startAt + idx;
95+
h.setAttribute('data-step', String(stepNumber));
96+
h.classList.add(styles.stepHeading);
97+
98+
if (checkable) {
99+
const btn = document.createElement('button');
100+
btn.type = 'button';
101+
btn.className = styles.stepToggle;
102+
btn.setAttribute('aria-label', `Toggle completion for step ${stepNumber}`);
103+
btn.setAttribute('aria-pressed', completed.has(h.id) ? 'true' : 'false');
104+
btn.addEventListener('click', () => {
105+
setCompleted(prev => {
106+
const next = new Set(prev);
107+
if (next.has(h.id)) next.delete(h.id);
108+
else next.add(h.id);
109+
return next;
110+
});
111+
});
112+
h.insertBefore(btn, h.firstChild);
113+
}
114+
});
115+
116+
// Cleanup function
117+
return () => {
118+
headings.forEach(h => {
119+
h.classList.remove(styles.stepHeading);
120+
h.removeAttribute('data-step');
121+
h.removeAttribute('data-completed');
122+
const existingToggle = h.querySelector(`.${styles.stepToggle}`);
123+
if (existingToggle) existingToggle.remove();
124+
});
125+
};
126+
// eslint-disable-next-line react-hooks/exhaustive-deps
127+
}, [startAt, selector, checkable]);
128+
129+
useEffect(() => {
130+
if (!storageKey || !checkable) return;
131+
try {
132+
const raw = sessionStorage.getItem(storageKey);
133+
if (raw) setCompleted(new Set(JSON.parse(raw) as string[]));
134+
} catch {
135+
// Ignore storage errors
136+
}
137+
// eslint-disable-next-line react-hooks/exhaustive-deps
138+
}, [storageKey, checkable]);
139+
140+
useEffect(() => {
141+
const container = containerRef.current;
142+
if (!container) return;
143+
const headings = Array.from(
144+
container.querySelectorAll<HTMLElement>(`:scope ${selector}`)
145+
);
146+
headings.forEach(h => {
147+
const isDone = completed.has(h.id);
148+
if (isDone) h.setAttribute('data-completed', 'true');
149+
else h.removeAttribute('data-completed');
150+
const btn = h.querySelector(`.${styles.stepToggle}`) as HTMLButtonElement | null;
151+
if (btn) btn.setAttribute('aria-pressed', isDone ? 'true' : 'false');
152+
});
153+
154+
if (storageKey && checkable) {
155+
try {
156+
sessionStorage.setItem(storageKey, JSON.stringify(Array.from(completed)));
157+
} catch {
158+
// Ignore storage errors
159+
}
160+
}
161+
}, [completed, selector, storageKey, checkable]);
162+
163+
const handleReset = () => {
164+
setCompleted(new Set());
165+
if (storageKey) {
166+
try {
167+
sessionStorage.removeItem(storageKey);
168+
} catch {
169+
// Ignore storage errors
170+
}
171+
}
172+
};
173+
174+
return (
175+
<div
176+
ref={containerRef}
177+
className={styles.stepContainer}
178+
data-shownumbers={showNumbers ? 'true' : 'false'}
179+
>
180+
{checkable && showReset && (
181+
<div className={styles.resetRow}>
182+
<button type="button" className={styles.resetBtn} onClick={handleReset}>
183+
Reset steps
184+
</button>
185+
</div>
186+
)}
187+
{children}
188+
</div>
189+
);
190+
}
191+
192+
// Alias to match usage <StepConnector>...</StepConnector>
193+
export function StepConnector(props: Props) {
194+
return <StepComponent {...props} />;
195+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
.stepContainer {
2+
position: relative;
3+
--rail-x: 18px;
4+
--circle: 36px;
5+
--gap: 8px;
6+
--pad-left: calc(var(--rail-x) + var(--circle) + var(--gap));
7+
padding-left: var(--pad-left);
8+
}
9+
10+
.stepContainer::before {
11+
content: '';
12+
position: absolute;
13+
left: var(--rail-x);
14+
top: 0;
15+
bottom: 0;
16+
width: 2px;
17+
background: var(--gray-a4);
18+
}
19+
20+
.stepHeading {
21+
position: relative;
22+
scroll-margin-top: var(--header-height, 80px);
23+
}
24+
25+
.stepHeading::before {
26+
content: attr(data-step);
27+
position: absolute;
28+
left: calc(var(--rail-x) - (var(--circle) / 2) - var(--pad-left));
29+
top: 0.05em;
30+
width: var(--circle);
31+
height: var(--circle);
32+
border-radius: 9999px;
33+
display: grid;
34+
place-items: center;
35+
font-size: 1.18rem;
36+
font-weight: 600;
37+
line-height: 1;
38+
z-index: 1;
39+
background: var(--gray-1);
40+
color: var(--gray-12);
41+
border: 1px solid var(--gray-a6);
42+
box-shadow: 0 1px 2px var(--gray-a3);
43+
}
44+
45+
.stepContainer[data-shownumbers='false'] .stepHeading::before { content: ''; }
46+
.stepContainer[data-shownumbers='false'] .stepHeading:not([data-completed='true'])::after {
47+
content: '';
48+
position: absolute;
49+
left: calc(var(--rail-x) - 3px - var(--pad-left));
50+
top: calc(0.05em + (var(--circle) / 2) - 3px);
51+
width: 6px;
52+
height: 6px;
53+
border-radius: 9999px;
54+
background: var(--gray-a8);
55+
z-index: 2;
56+
}
57+
58+
.stepHeading[data-completed='true']::before {
59+
content: '';
60+
background: var(--accent-11);
61+
color: white;
62+
border-color: var(--accent-11);
63+
}
64+
65+
.stepToggle {
66+
position: absolute;
67+
left: calc(var(--rail-x) - (var(--circle) / 2) - var(--pad-left));
68+
top: 0.05em;
69+
width: var(--circle);
70+
height: var(--circle);
71+
border: 0;
72+
padding: 0;
73+
background: transparent;
74+
cursor: pointer;
75+
z-index: 3;
76+
}
77+
.stepToggle:focus-visible {
78+
outline: 2px solid var(--accent);
79+
outline-offset: 2px;
80+
border-radius: 9999px;
81+
}
82+
83+
.resetRow { display: flex; justify-content: flex-end; margin-bottom: 0.5rem; }
84+
.resetBtn {
85+
font-size: 0.8rem;
86+
color: var(--gray-11);
87+
background: transparent;
88+
border: 1px solid var(--gray-a5);
89+
border-radius: 9999px;
90+
padding: 2px 8px;
91+
}
92+
.resetBtn:hover { background: var(--gray-a3); }
93+

src/mdxComponents.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {SdkApi} from './components/sdkApi';
4343
import {SdkOption} from './components/sdkOption';
4444
import {SignInNote} from './components/signInNote';
4545
import {SmartLink} from './components/smartLink';
46+
import {StepComponent, StepConnector} from './components/stepConnector';
4647
import {TableOfContents} from './components/tableOfContents';
4748
import {VersionRequirement} from './components/version-requirement';
4849
import {VimeoEmbed} from './components/video';
@@ -95,6 +96,8 @@ export function mdxComponents(
9596
RelayMetrics,
9697
SandboxLink,
9798
SignInNote,
99+
StepComponent,
100+
StepConnector,
98101
VimeoEmbed,
99102
VersionRequirement,
100103
a: SmartLink,

0 commit comments

Comments
 (0)