11import React from 'react' ;
22
3- import { Badge , Button , descriptors , Flex , Icon , Text , useLocalizations } from '@/customizables' ;
3+ import { Badge , Box , Button , descriptors , Flex , Icon , Spinner , Text , useLocalizations } from '@/customizables' ;
44import { CaretLeft , CaretRight } from '@/icons' ;
55import { Route , Switch , useRouter } from '@/router' ;
66
@@ -14,11 +14,17 @@ interface WizardRootProps<TData = unknown> {
1414 * referenced shape changes, the active step list is recomputed
1515 */
1616 data ?: TData ;
17+ /**
18+ * `true` while the parent flow is still loading async dependencies.
19+ * The header renders a skeleton breadcrumb, the content renders a
20+ * centered spinner, and the footer's buttons are disabled
21+ */
22+ isLoading ?: boolean ;
1723 children : React . ReactNode ;
1824}
1925
2026const Root = < TData , > ( props : WizardRootProps < TData > ) : JSX . Element => {
21- const { steps, data, children } = props ;
27+ const { steps, data, isLoading = false , children } = props ;
2228 const router = useRouter ( ) ;
2329
2430 const activeSteps = React . useMemo ( ( ) => steps . filter ( s => ! s . shouldSkip ?.( data as TData ) ) , [ steps , data ] ) ;
@@ -127,6 +133,7 @@ const Root = <TData,>(props: WizardRootProps<TData>): JSX.Element => {
127133 currentStep = { currentStep }
128134 innerSteps = { innerSteps }
129135 currentInnerStep = { currentInnerStep }
136+ isLoading = { isLoading }
130137 goNext = { goNext }
131138 goPrev = { goPrev }
132139 goToStep = { goToStep }
@@ -169,7 +176,24 @@ const StepRoutes = <TData,>({ step }: { step: WizardStep<TData> }): JSX.Element
169176 * doesn't match another main step (e.g. its own inner-step paths)
170177 */
171178const Content = ( ) : JSX . Element | null => {
172- const { activeSteps } = useWizard ( ) ;
179+ const { activeSteps, isLoading } = useWizard ( ) ;
180+
181+ if ( isLoading ) {
182+ return (
183+ < Flex
184+ align = 'center'
185+ justify = 'center'
186+ sx = { { flex : 1 } }
187+ >
188+ < Spinner
189+ size = 'xs'
190+ colorScheme = 'neutral'
191+ elementDescriptor = { descriptors . spinner }
192+ />
193+ </ Flex >
194+ ) ;
195+ }
196+
173197 if ( activeSteps . length === 0 ) {
174198 return null ;
175199 }
@@ -196,7 +220,7 @@ const Content = (): JSX.Element | null => {
196220 * are clickable for backwards navigation, future steps are disabled
197221 */
198222const Header = ( ) : JSX . Element => {
199- const { activeSteps, currentIndex, goToStep } = useWizard ( ) ;
223+ const { activeSteps, currentIndex, isLoading , goToStep } = useWizard ( ) ;
200224 const { t } = useLocalizations ( ) ;
201225
202226 return (
@@ -219,47 +243,51 @@ const Header = (): JSX.Element => {
219243
220244 return (
221245 < React . Fragment key = { step . id } >
222- < Button
223- variant = 'unstyled'
224- isDisabled = { ! isReachable }
225- onClick = { ( ) => {
226- if ( isReachable ) {
227- void goToStep ( step . id ) ;
228- }
229- } }
230- sx = { theme => ( {
231- gap : theme . space . $1x5 ,
232- padding : 0 ,
233- color : isCurrent ? theme . colors . $colorForeground : theme . colors . $colorMutedForeground ,
234- } ) }
235- >
236- < Flex
237- align = 'center'
238- justify = 'center'
246+ { isLoading ? (
247+ < SkeletonBreadcrumbStep />
248+ ) : (
249+ < Button
250+ variant = 'unstyled'
251+ isDisabled = { ! isReachable }
252+ onClick = { ( ) => {
253+ if ( isReachable ) {
254+ void goToStep ( step . id ) ;
255+ }
256+ } }
239257 sx = { theme => ( {
240- width : theme . sizes . $5 ,
241- height : theme . sizes . $5 ,
242- borderRadius : theme . radii . $circle ,
243- fontSize : theme . fontSizes . $xs ,
244- fontWeight : theme . fontWeights . $semibold ,
245- backgroundColor : isCurrent
246- ? theme . colors . $colorForeground
247- : isCompleted
248- ? theme . colors . $neutralAlpha200
249- : theme . colors . $neutralAlpha100 ,
250- color : isCurrent ? theme . colors . $colorBackground : theme . colors . $colorMutedForeground ,
258+ gap : theme . space . $1x5 ,
259+ padding : 0 ,
260+ color : isCurrent ? theme . colors . $colorForeground : theme . colors . $colorMutedForeground ,
251261 } ) }
252262 >
253- { index + 1 }
254- </ Flex >
255- < Text
256- as = 'span'
257- variant = 'body'
258- sx = { { fontWeight : 'inherit' , color : 'inherit' } }
259- >
260- { label }
261- </ Text >
262- </ Button >
263+ < Flex
264+ align = 'center'
265+ justify = 'center'
266+ sx = { theme => ( {
267+ width : theme . sizes . $5 ,
268+ height : theme . sizes . $5 ,
269+ borderRadius : theme . radii . $circle ,
270+ fontSize : theme . fontSizes . $xs ,
271+ fontWeight : theme . fontWeights . $semibold ,
272+ backgroundColor : isCurrent
273+ ? theme . colors . $colorForeground
274+ : isCompleted
275+ ? theme . colors . $neutralAlpha200
276+ : theme . colors . $neutralAlpha100 ,
277+ color : isCurrent ? theme . colors . $colorBackground : theme . colors . $colorMutedForeground ,
278+ } ) }
279+ >
280+ { index + 1 }
281+ </ Flex >
282+ < Text
283+ as = 'span'
284+ variant = 'body'
285+ sx = { { fontWeight : 'inherit' , color : 'inherit' } }
286+ >
287+ { label }
288+ </ Text >
289+ </ Button >
290+ ) }
263291 { index < activeSteps . length - 1 && (
264292 < Icon
265293 icon = { CaretRight }
@@ -274,6 +302,30 @@ const Header = (): JSX.Element => {
274302 ) ;
275303} ;
276304
305+ const SkeletonBreadcrumbStep = ( ) : JSX . Element => (
306+ < Flex
307+ align = 'center'
308+ sx = { t => ( { gap : t . space . $1x5 } ) }
309+ >
310+ < Box
311+ sx = { t => ( {
312+ width : t . sizes . $5 ,
313+ height : t . sizes . $5 ,
314+ borderRadius : t . radii . $circle ,
315+ backgroundColor : t . colors . $neutralAlpha100 ,
316+ } ) }
317+ />
318+ < Box
319+ sx = { t => ( {
320+ width : t . sizes . $16 ,
321+ height : t . space . $3 ,
322+ borderRadius : t . radii . $md ,
323+ backgroundColor : t . colors . $neutralAlpha100 ,
324+ } ) }
325+ />
326+ </ Flex >
327+ ) ;
328+
277329/**
278330 * Compact "Step X / Y" badge that tracks the current main step's
279331 * inner-step progress. Renders nothing when the current step has no
@@ -322,6 +374,12 @@ interface FooterProps {
322374 * default)
323375 */
324376 hidePrevious ?: boolean ;
377+ /**
378+ * Force-disables both Previous and Continue regardless of the
379+ * wizard's own state. Useful while async dependencies of the flow
380+ * are still loading
381+ */
382+ isDisabled ?: boolean ;
325383}
326384
327385/**
@@ -330,8 +388,9 @@ interface FooterProps {
330388 * simply advances to the next step
331389 */
332390const Footer = ( props : FooterProps ) : JSX . Element => {
333- const { previousLabel = 'Previous' , continueLabel = 'Continue' , hidePrevious = false } = props ;
334- const { isFirstStep, isLastStep, goPrev, goNext, continueAction } = useWizard ( ) ;
391+ const { previousLabel = 'Previous' , continueLabel = 'Continue' , hidePrevious = false , isDisabled = false } = props ;
392+ const { isFirstStep, isLastStep, isLoading, goPrev, goNext, continueAction } = useWizard ( ) ;
393+ const isForceDisabled = isDisabled || isLoading ;
335394 const { t } = useLocalizations ( ) ;
336395
337396 const continueLabelToShow =
@@ -367,7 +426,7 @@ const Footer = (props: FooterProps): JSX.Element => {
367426 < Button
368427 variant = 'outline'
369428 size = 'sm'
370- isDisabled = { isFirstStep }
429+ isDisabled = { isForceDisabled || isFirstStep }
371430 onClick = { ( ) => void goPrev ( ) }
372431 >
373432 < Icon
@@ -381,7 +440,7 @@ const Footer = (props: FooterProps): JSX.Element => {
381440 < Button
382441 variant = 'solid'
383442 size = 'sm'
384- isDisabled = { continueAction ?. isDisabled || isLastStep }
443+ isDisabled = { isForceDisabled || continueAction ?. isDisabled || isLastStep }
385444 isLoading = { continueAction ?. isLoading }
386445 onClick = { handleContinue }
387446 >
0 commit comments