Skip to content

Commit ec8c28f

Browse files
committed
Improve stepper
1 parent 52a5d92 commit ec8c28f

3 files changed

Lines changed: 276 additions & 58 deletions

File tree

src/stepper.tsx

Lines changed: 155 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { ClassNameValue, twMerge } from 'tailwind-merge';
33
import { CheckIcon } from './icons/outline/check';
44
import { flushSync } from 'react-dom';
5+
import { Icon } from './icon';
56

67
type StepperContextType = {
78
value: number;
@@ -103,36 +104,48 @@ export function Stepper({
103104
);
104105
}
105106

107+
type StepListContextType = {
108+
alignment: 'center' | 'start';
109+
compact: boolean;
110+
labelPlacement: 'top' | 'bottom';
111+
};
112+
113+
const StepListContext = React.createContext<StepListContextType>({
114+
alignment: 'center',
115+
compact: false,
116+
labelPlacement: 'bottom',
117+
});
118+
106119
export function StepList({
107120
className,
108-
centered = true,
121+
alignment = 'center',
122+
compact = false,
123+
labelPlacement = 'bottom',
109124
...props
110-
}: React.JSX.IntrinsicElements['ol'] & {
111-
centered?: boolean;
112-
}) {
125+
}: React.JSX.IntrinsicElements['ol'] & Partial<StepListContextType>) {
113126
return (
114-
<ol
115-
{...props}
116-
{...(centered && {
117-
'data-centered': true,
118-
})}
119-
className={twMerge(
120-
'flex w-full gap-x-2 overflow-x-auto',
121-
'[--separator-radius:calc(infinity_*_1px)]',
122-
'[&.gap-x-0]:[--separator-radius:0]',
123-
'[&.gap-x-0]:rounded-full',
124-
'[--counter-size:--spacing(6)]',
125-
'[--counter-padding:--spacing(1)]',
126-
'[--counter:var(--color-zinc-200)]/75',
127-
'dark:[--counter:var(--color-zinc-700)]',
128-
'[--counter-text:var(--muted)]',
129-
'[--counter-highlight:var(--accent)]',
130-
'[--counter-highlight-text:lch(from_var(--counter-highlight)_calc((49.44_-_l)_*_infinity)_0_0)]',
131-
'[--separator-h:--spacing(0.5)]',
127+
<StepListContext.Provider value={{ alignment, compact, labelPlacement }}>
128+
<ol
129+
{...props}
130+
className={twMerge(
131+
'flex w-full overflow-x-auto',
132+
!compact && 'gap-x-2',
133+
'[--separator-radius:calc(infinity_*_1px)]',
134+
'[&.gap-x-0]:[--separator-radius:0]',
135+
'[&.gap-x-0]:rounded-full',
136+
'[--counter-size:--spacing(6)]',
137+
'[--counter-padding:--spacing(1)]',
138+
'[--counter:var(--color-zinc-200)]/75',
139+
'dark:[--counter:var(--color-zinc-700)]',
140+
'[--counter-text:var(--muted)]',
141+
'[--counter-highlight:var(--accent)]',
142+
'[--counter-highlight-text:lch(from_var(--counter-highlight)_calc((49.44_-_l)_*_infinity)_0_0)]',
143+
'[--separator-h:--spacing(0.5)]',
132144

133-
className,
134-
)}
135-
/>
145+
className,
146+
)}
147+
/>
148+
</StepListContext.Provider>
136149
);
137150
}
138151

@@ -163,6 +176,8 @@ export function Step({
163176
const { value, steps } = useStepIndicator();
164177
const completed = value >= step;
165178
const current = value === step - 1;
179+
const { alignment, labelPlacement, compact } =
180+
React.useContext(StepListContext);
166181

167182
return (
168183
<StepContext.Provider
@@ -172,35 +187,63 @@ export function Step({
172187
{...props}
173188
className={twMerge(
174189
'relative isolate flex flex-1 shrink-0 flex-col gap-2 text-sm/6',
175-
'in-[[data-centered]]:items-center in-[[data-centered]]:text-center',
190+
alignment === 'center' && ['items-center', 'text-center'],
191+
ariaLabel && ['[&>[data-ui=label]]:sr-only'],
192+
labelPlacement === 'top' && [
193+
'min-w-0 [&>[data-ui=label]]:w-full [&>[data-ui=label]]:truncate',
194+
],
195+
compact && ['[&>[data-ui=label]]:px-1'],
176196
typeof className == 'function'
177197
? className({ completed, current })
178198
: className,
179199
)}
180200
>
181201
{counter ? (
182202
<>
203+
{labelPlacement === 'top' && (
204+
<span
205+
data-ui="label"
206+
{...(ariaLabel && { 'aria-label': ariaLabel })}
207+
>
208+
{children}
209+
</span>
210+
)}
211+
183212
{typeof counter === 'function'
184213
? counter({ completed, step })
185214
: counter}
186-
<span
187-
{...(ariaLabel && { 'aria-label': ariaLabel })}
188-
className="contents"
189-
>
190-
{children}
191-
</span>
215+
216+
{labelPlacement === 'bottom' && (
217+
<span
218+
data-ui="label"
219+
{...(ariaLabel && { 'aria-label': ariaLabel })}
220+
>
221+
{children}
222+
</span>
223+
)}
192224

193225
{step < steps && (separator ? separator : <StepSeparator />)}
194226
</>
195227
) : (
196228
<>
229+
{labelPlacement === 'top' && (
230+
<span
231+
data-ui="label"
232+
{...(ariaLabel && { 'aria-label': ariaLabel })}
233+
>
234+
{children}
235+
</span>
236+
)}
197237
{separator ? separator : <StepSeparator />}
198-
<span
199-
{...(ariaLabel && { 'aria-label': ariaLabel })}
200-
className="contents"
201-
>
202-
{children}
203-
</span>
238+
239+
{labelPlacement === 'bottom' && (
240+
<span
241+
data-ui="label"
242+
{...(ariaLabel && { 'aria-label': ariaLabel })}
243+
>
244+
{children}
245+
</span>
246+
)}
204247
</>
205248
)}
206249

@@ -212,13 +255,17 @@ export function Step({
212255
);
213256
}
214257

258+
type CounterVariant = 'dot' | 'number';
259+
215260
export function StepCounter({
216261
className,
217262
children,
218263
checkIcon = true,
264+
variant = 'number',
219265
...props
220266
}: React.JSX.IntrinsicElements['div'] & {
221267
checkIcon?: boolean;
268+
variant?: CounterVariant;
222269
}) {
223270
const { step, completed, current } = useStepContext();
224271

@@ -227,14 +274,54 @@ export function StepCounter({
227274
{...props}
228275
aria-hidden
229276
className={twMerge(
230-
'flex size-(--counter-size) shrink-0 items-center justify-center rounded-full p-(--counter-padding) text-sm/6 font-medium transition-colors duration-300 ease-in-out [&_svg[data-ui=icon]:not([class*=size-])]:size-full',
231-
'bg-(--counter) text-(--counter-text)',
232-
(completed || current) &&
233-
'bg-(--counter-highlight) text-(--counter-highlight-text)',
277+
'flex size-(--counter-size) shrink-0 items-center justify-center rounded-full p-(--counter-padding) text-sm/6 font-medium [&_svg[data-ui=icon]:not([class*=size-])]:size-full',
278+
279+
variant === 'number' && [
280+
'transition-colors duration-300 ease-in-out',
281+
'bg-(--counter) text-(--counter-text)',
282+
(completed || current) && [
283+
'bg-(--counter-highlight) text-(--counter-highlight-text)',
284+
],
285+
],
286+
287+
variant === 'dot' && [
288+
completed
289+
? 'bg-(--counter-highlight) text-(--counter-highlight-text)'
290+
: [
291+
'border-2',
292+
current ? 'border-(--counter-highlight)' : 'border-(--counter)',
293+
],
294+
],
295+
234296
className,
235297
)}
236298
>
237-
{completed && checkIcon ? <CheckIcon /> : children ? children : step}
299+
{completed && checkIcon ? (
300+
<CheckIcon />
301+
) : children ? (
302+
children
303+
) : variant === 'number' ? (
304+
step
305+
) : current ? (
306+
<Icon>
307+
<svg
308+
xmlns="http://www.w3.org/2000/svg"
309+
width={24}
310+
height={24}
311+
viewBox="0 0 48 48"
312+
fill="currentColor"
313+
>
314+
<path
315+
fill="currentColor"
316+
stroke="currentColor"
317+
strokeWidth={4}
318+
d="M24 33a9 9 0 1 0 0-18a9 9 0 0 0 0 18Z"
319+
></path>
320+
</svg>
321+
</Icon>
322+
) : (
323+
<></>
324+
)}
238325
</div>
239326
);
240327
}
@@ -244,6 +331,8 @@ export function StepSeparator({
244331
...props
245332
}: Omit<React.JSX.IntrinsicElements['div'], 'children'>) {
246333
const { hasCounter, completed, current } = useStepContext();
334+
const { alignment, labelPlacement, compact } =
335+
React.useContext(StepListContext);
247336

248337
return hasCounter ? (
249338
<div
@@ -252,11 +341,30 @@ export function StepSeparator({
252341
aria-hidden
253342
className={twMerge(
254343
'transition-colors duration-300 ease-in-out',
255-
'absolute top-[calc(var(--counter-size)/2)] h-(--separator-h) -translate-y-1/2 rounded-(--separator-radius)',
256-
'left-[calc(var(--counter-size,0)+0.25rem)]',
344+
'absolute h-(--separator-h) rounded-(--separator-radius)',
345+
346+
labelPlacement === 'top'
347+
? ['bottom-[calc(var(--counter-size)/2)]', 'translate-y-1/2']
348+
: ['top-[calc(var(--counter-size)/2)]', '-translate-y-1/2'],
349+
257350
'w-[calc(100%-var(--counter-size,0))]',
258-
'in-[[data-centered]]:left-[calc(50%+calc(var(--counter-size,0)/2)+0.125rem)]',
259-
'in-[[data-centered]]:w-[calc(100%-var(--counter-size,0)+0.5rem-0.25rem)]',
351+
352+
compact
353+
? [
354+
'left-[calc(var(--counter-size,0))]',
355+
alignment === 'center' && [
356+
'left-[calc(50%+calc(var(--counter-size,0)/2))]',
357+
'w-[calc(100%-var(--counter-size,0))]',
358+
],
359+
]
360+
: [
361+
'left-[calc(var(--counter-size,0)+0.25rem)]',
362+
alignment === 'center' && [
363+
'left-[calc(50%+calc(var(--counter-size,0)/2)+0.125rem)]',
364+
'w-[calc(100%-var(--counter-size,0)+0.5rem-0.25rem)]',
365+
],
366+
],
367+
260368
'in-[.gap-x-0]:rounded-0',
261369
'bg-(--counter)',
262370
completed && 'bg-(--counter-highlight)',

stories/Stepper.mdx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,33 @@ A <a href="https://designsystem.digital.gov/components/step-indicator" target="_
1010

1111
<Canvas of={StepperStories.BasicExample} />
1212

13+
## Step label placement
1314

14-
## Step Title Alignment
15+
<Canvas of={StepperStories.LabelPlacement} />
1516

16-
<Canvas of={StepperStories.StepTitleAlignment} />
17+
## Step counter variant
1718

19+
<Canvas of={StepperStories.CounterVariant} />
1820

19-
## No Step Title
21+
## Step label alignment
2022

21-
<Canvas of={StepperStories.NoStepTitle} />
23+
<Canvas of={StepperStories.StepLabelAlignment} />
2224

25+
## No step label
2326

24-
## No Step Counter
27+
<Canvas of={StepperStories.NoStepLabel} />
28+
29+
30+
## No step counter
2531

2632
<Canvas of={StepperStories.NoStepCounter} />
2733

2834

29-
## Step Customization
35+
## Step customization
3036

3137
<Canvas of={StepperStories.Customization} />
3238

3339

34-
## Multi Steps Form
40+
## Multi steps form
3541

3642
<Canvas of={StepperStories.MultiStepsForm} />

0 commit comments

Comments
 (0)