@@ -2,6 +2,7 @@ import React from 'react';
22import { ClassNameValue , twMerge } from 'tailwind-merge' ;
33import { CheckIcon } from './icons/outline/check' ;
44import { flushSync } from 'react-dom' ;
5+ import { Icon } from './icon' ;
56
67type 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+
106119export 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+
215260export 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)' ,
0 commit comments