Skip to content

Commit d036e5f

Browse files
committed
Improve stepper
1 parent 52a5d92 commit d036e5f

3 files changed

Lines changed: 164 additions & 53 deletions

File tree

src/stepper.tsx

Lines changed: 99 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -103,36 +103,48 @@ export function Stepper({
103103
);
104104
}
105105

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

133-
className,
134-
)}
135-
/>
144+
className,
145+
)}
146+
/>
147+
</StepListContext.Provider>
136148
);
137149
}
138150

@@ -163,6 +175,7 @@ export function Step({
163175
const { value, steps } = useStepIndicator();
164176
const completed = value >= step;
165177
const current = value === step - 1;
178+
const { alignment, labelPlacement } = React.useContext(StepListContext);
166179

167180
return (
168181
<StepContext.Provider
@@ -172,35 +185,58 @@ export function Step({
172185
{...props}
173186
className={twMerge(
174187
'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',
188+
alignment === 'center' && ['items-center', 'text-center'],
176189
typeof className == 'function'
177190
? className({ completed, current })
178191
: className,
179192
)}
180193
>
181194
{counter ? (
182195
<>
196+
{labelPlacement === 'top' && (
197+
<span
198+
{...(ariaLabel && { 'aria-label': ariaLabel })}
199+
className="contents"
200+
>
201+
{children}
202+
</span>
203+
)}
204+
183205
{typeof counter === 'function'
184206
? counter({ completed, step })
185207
: counter}
186-
<span
187-
{...(ariaLabel && { 'aria-label': ariaLabel })}
188-
className="contents"
189-
>
190-
{children}
191-
</span>
208+
209+
{labelPlacement === 'bottom' && (
210+
<span
211+
{...(ariaLabel && { 'aria-label': ariaLabel })}
212+
className="contents"
213+
>
214+
{children}
215+
</span>
216+
)}
192217

193218
{step < steps && (separator ? separator : <StepSeparator />)}
194219
</>
195220
) : (
196221
<>
222+
{labelPlacement === 'top' && (
223+
<span
224+
{...(ariaLabel && { 'aria-label': ariaLabel })}
225+
className="contents"
226+
>
227+
{children}
228+
</span>
229+
)}
197230
{separator ? separator : <StepSeparator />}
198-
<span
199-
{...(ariaLabel && { 'aria-label': ariaLabel })}
200-
className="contents"
201-
>
202-
{children}
203-
</span>
231+
232+
{labelPlacement === 'bottom' && (
233+
<span
234+
{...(ariaLabel && { 'aria-label': ariaLabel })}
235+
className="contents"
236+
>
237+
{children}
238+
</span>
239+
)}
204240
</>
205241
)}
206242

@@ -244,6 +280,8 @@ export function StepSeparator({
244280
...props
245281
}: Omit<React.JSX.IntrinsicElements['div'], 'children'>) {
246282
const { hasCounter, completed, current } = useStepContext();
283+
const { alignment, labelPlacement, compact } =
284+
React.useContext(StepListContext);
247285

248286
return hasCounter ? (
249287
<div
@@ -252,11 +290,30 @@ export function StepSeparator({
252290
aria-hidden
253291
className={twMerge(
254292
'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)]',
293+
'absolute h-(--separator-h) -translate-y-1/2 rounded-(--separator-radius)',
294+
295+
labelPlacement === 'top'
296+
? 'bottom-[calc(var(--counter-size)/2)]'
297+
: 'top-[calc(var(--counter-size)/2)]',
298+
257299
'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)]',
300+
301+
compact
302+
? [
303+
'left-[calc(var(--counter-size,0))]',
304+
alignment === 'center' && [
305+
'left-[calc(50%+calc(var(--counter-size,0)/2))]',
306+
'w-[calc(100%-var(--counter-size,0))]',
307+
],
308+
]
309+
: [
310+
'left-[calc(var(--counter-size,0)+0.25rem)]',
311+
alignment === 'center' && [
312+
'left-[calc(50%+calc(var(--counter-size,0)/2)+0.125rem)]',
313+
'w-[calc(100%-var(--counter-size,0)+0.5rem-0.25rem)]',
314+
],
315+
],
316+
260317
'in-[.gap-x-0]:rounded-0',
261318
'bg-(--counter)',
262319
completed && 'bg-(--counter-highlight)',

stories/Stepper.mdx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,31 @@ 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} />
1717

18+
## Step label alignment
1819

19-
## No Step Title
20+
<Canvas of={StepperStories.StepLabelAlignment} />
2021

21-
<Canvas of={StepperStories.NoStepTitle} />
2222

23+
## No step label
2324

24-
## No Step Counter
25+
<Canvas of={StepperStories.NoStepLabel} />
26+
27+
28+
## No step counter
2529

2630
<Canvas of={StepperStories.NoStepCounter} />
2731

2832

29-
## Step Customization
33+
## Step customization
3034

3135
<Canvas of={StepperStories.Customization} />
3236

3337

34-
## Multi Steps Form
38+
## Multi steps form
3539

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

stories/Stepper.stories.tsx

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,57 @@ export const BasicExample = () => {
9393
);
9494
};
9595

96-
export function StepTitleAlignment() {
96+
export const LabelPlacement = () => {
97+
const steps = 3;
98+
const [value, setValue] = React.useState(0);
99+
100+
return (
101+
<div className="flex flex-col p-8">
102+
<Stepper value={value} steps={steps} onStepChange={setValue}>
103+
{({ isFirstStep, isCompleted, prev, next }) => {
104+
return (
105+
<div className="space-y-12">
106+
<StepList className="mx-auto max-w-xl" labelPlacement="top">
107+
<Step step={1} counter={<StepCounter />}>
108+
Personal information
109+
</Step>
110+
<Step step={2} counter={<StepCounter />}>
111+
Household status
112+
</Step>
113+
<Step step={3} counter={<StepCounter />}>
114+
Supporting documents
115+
</Step>
116+
</StepList>
117+
118+
<div className="flex justify-center gap-x-4">
119+
<Button
120+
variant="outline"
121+
type="button"
122+
className="min-w-24"
123+
isDisabled={isFirstStep}
124+
onPress={prev}
125+
>
126+
Previous
127+
</Button>
128+
<Button
129+
variant="outline"
130+
type="button"
131+
className="min-w-24"
132+
isDisabled={isCompleted}
133+
onPress={next}
134+
>
135+
Next
136+
</Button>
137+
</div>
138+
</div>
139+
);
140+
}}
141+
</Stepper>
142+
</div>
143+
);
144+
};
145+
146+
export function StepLabelAlignment() {
97147
const steps = 3;
98148
const [value, setValue] = React.useState(0);
99149
return (
@@ -102,7 +152,7 @@ export function StepTitleAlignment() {
102152
{({ isFirstStep, isCompleted, prev, next }) => {
103153
return (
104154
<div className="space-y-12">
105-
<StepList centered={false}>
155+
<StepList alignment='start' compact>
106156
<Step step={1} counter={<StepCounter />}>
107157
Personal information
108158
</Step>
@@ -142,7 +192,7 @@ export function StepTitleAlignment() {
142192
);
143193
}
144194

145-
export function NoStepTitle() {
195+
export function NoStepLabel() {
146196
const steps = 3;
147197
const [value, setValue] = React.useState(0);
148198

@@ -219,7 +269,7 @@ export function NoStepCounter() {
219269
<Step step={3}>Supporting documents</Step>
220270
</StepList>
221271

222-
<StepList className="gap-x-0">
272+
<StepList compact>
223273
<Step step={1} aria-label="Personal information" />
224274
<Step step={2} aria-label="Household status" />
225275
<Step step={3} aria-label="Supporting documents" />

0 commit comments

Comments
 (0)