|
| 1 | +'use client' |
| 2 | + |
| 3 | +import * as React from 'react' |
| 4 | +import { cn } from '@/lib/core/utils/cn' |
| 5 | +import { Button } from '../button/button' |
| 6 | +import { |
| 7 | + Modal, |
| 8 | + ModalBody, |
| 9 | + ModalContent, |
| 10 | + ModalFooter, |
| 11 | + ModalHeader, |
| 12 | + type ModalSize, |
| 13 | +} from '../modal/modal' |
| 14 | + |
| 15 | +/** |
| 16 | + * A multi-step modal wizard primitive. |
| 17 | + * |
| 18 | + * @remarks |
| 19 | + * Wraps the emcn Modal with a step progress bar, a shared Back / Next / Done |
| 20 | + * footer, and declarative `Wizard.Step` children. Step state is controlled |
| 21 | + * from the outside so the consumer can hydrate from persisted state, reset |
| 22 | + * on close, or jump around imperatively. |
| 23 | + * |
| 24 | + * @example |
| 25 | + * ```tsx |
| 26 | + * const [open, setOpen] = useState(false) |
| 27 | + * const [step, setStep] = useState(0) |
| 28 | + * |
| 29 | + * <Wizard |
| 30 | + * open={open} |
| 31 | + * onOpenChange={(next) => { if (!next) setStep(0); setOpen(next) }} |
| 32 | + * currentStep={step} |
| 33 | + * onStepChange={setStep} |
| 34 | + * size='lg' |
| 35 | + * height='h-[580px]' |
| 36 | + * > |
| 37 | + * <Wizard.Step title='Configure'> |
| 38 | + * <ConfigureForm /> |
| 39 | + * </Wizard.Step> |
| 40 | + * <Wizard.Step title='Review' canAdvance={isValid}> |
| 41 | + * <Review /> |
| 42 | + * </Wizard.Step> |
| 43 | + * <Wizard.Step title='Done'> |
| 44 | + * <DoneSummary /> |
| 45 | + * </Wizard.Step> |
| 46 | + * </Wizard> |
| 47 | + * ``` |
| 48 | + */ |
| 49 | + |
| 50 | +export interface WizardStepProps { |
| 51 | + /** Title shown in the modal header when this step is active. */ |
| 52 | + title: string |
| 53 | + /** Step body. Rendered inside the modal body when this step is active. */ |
| 54 | + children: React.ReactNode |
| 55 | + /** |
| 56 | + * When false, the Next button on this step is disabled. Lets the consumer |
| 57 | + * gate progression on validation or async state. |
| 58 | + * @default true |
| 59 | + */ |
| 60 | + canAdvance?: boolean |
| 61 | +} |
| 62 | + |
| 63 | +/** |
| 64 | + * Declares one step in the wizard. Carries metadata via props; the actual |
| 65 | + * children are extracted and rendered by the `Wizard` root. |
| 66 | + */ |
| 67 | +const Step: React.FC<WizardStepProps> = ({ children }) => <>{children}</> |
| 68 | +Step.displayName = 'Wizard.Step' |
| 69 | + |
| 70 | +const STEP_DISPLAY_NAME = 'Wizard.Step' |
| 71 | + |
| 72 | +function isStepElement(node: React.ReactNode): node is React.ReactElement<WizardStepProps> { |
| 73 | + if (!React.isValidElement(node)) return false |
| 74 | + const type = node.type as { displayName?: string } | string |
| 75 | + return typeof type !== 'string' && type?.displayName === STEP_DISPLAY_NAME |
| 76 | +} |
| 77 | + |
| 78 | +export interface WizardProps { |
| 79 | + /** Whether the wizard modal is open. */ |
| 80 | + open: boolean |
| 81 | + /** Called when the modal's open state changes. */ |
| 82 | + onOpenChange: (next: boolean) => void |
| 83 | + /** Zero-indexed current step. */ |
| 84 | + currentStep: number |
| 85 | + /** Called with the new step index when the user clicks Back / Next. */ |
| 86 | + onStepChange: (next: number) => void |
| 87 | + /** |
| 88 | + * Modal size variant. Matches `ModalContent` sizes. |
| 89 | + * @default 'lg' |
| 90 | + */ |
| 91 | + size?: ModalSize |
| 92 | + /** |
| 93 | + * Optional fixed height for the modal content. Pass a Tailwind class |
| 94 | + * (e.g. `h-[580px]`) to keep the modal a stable size across steps. |
| 95 | + */ |
| 96 | + height?: string |
| 97 | + /** |
| 98 | + * Called when the user clicks Done on the final step. Fires before the |
| 99 | + * modal closes; the wizard will close the modal itself after. |
| 100 | + */ |
| 101 | + onComplete?: () => void |
| 102 | + /** One or more `<Wizard.Step>` elements. Non-step children are ignored. */ |
| 103 | + children: React.ReactNode |
| 104 | + /** Label for the Back button. @default 'Back' */ |
| 105 | + backLabel?: string |
| 106 | + /** Label for the Next button. @default 'Next' */ |
| 107 | + nextLabel?: string |
| 108 | + /** Label for the Done button on the final step. @default 'Done' */ |
| 109 | + doneLabel?: string |
| 110 | +} |
| 111 | + |
| 112 | +const WizardRoot: React.FC<WizardProps> = ({ |
| 113 | + open, |
| 114 | + onOpenChange, |
| 115 | + currentStep, |
| 116 | + onStepChange, |
| 117 | + size = 'lg', |
| 118 | + height, |
| 119 | + onComplete, |
| 120 | + children, |
| 121 | + backLabel = 'Back', |
| 122 | + nextLabel = 'Next', |
| 123 | + doneLabel = 'Done', |
| 124 | +}) => { |
| 125 | + const steps = React.Children.toArray(children).filter(isStepElement) |
| 126 | + const total = steps.length |
| 127 | + const clamped = Math.min(Math.max(0, currentStep), Math.max(0, total - 1)) |
| 128 | + const activeStep = steps[clamped] |
| 129 | + const canAdvance = activeStep?.props.canAdvance ?? true |
| 130 | + const isLast = total > 0 && clamped === total - 1 |
| 131 | + |
| 132 | + const handleBack = React.useCallback(() => { |
| 133 | + onStepChange(Math.max(0, clamped - 1)) |
| 134 | + }, [clamped, onStepChange]) |
| 135 | + |
| 136 | + const handleNext = React.useCallback(() => { |
| 137 | + onStepChange(Math.min(total - 1, clamped + 1)) |
| 138 | + }, [clamped, total, onStepChange]) |
| 139 | + |
| 140 | + const handleDone = React.useCallback(() => { |
| 141 | + onComplete?.() |
| 142 | + onOpenChange(false) |
| 143 | + }, [onComplete, onOpenChange]) |
| 144 | + |
| 145 | + if (total === 0) return null |
| 146 | + |
| 147 | + return ( |
| 148 | + <Modal open={open} onOpenChange={onOpenChange}> |
| 149 | + <ModalContent size={size} className={height}> |
| 150 | + <ModalHeader> |
| 151 | + <div className='flex items-baseline justify-between gap-3'> |
| 152 | + <span>{activeStep?.props.title}</span> |
| 153 | + <span className='font-normal text-[var(--text-muted)] text-xs'> |
| 154 | + Step {clamped + 1} of {total} |
| 155 | + </span> |
| 156 | + </div> |
| 157 | + </ModalHeader> |
| 158 | + |
| 159 | + <div className='flex gap-1.5 px-6 pb-4'> |
| 160 | + {steps.map((step, i) => ( |
| 161 | + <div |
| 162 | + key={step.props.title} |
| 163 | + className={cn( |
| 164 | + 'h-1 flex-1 rounded-full transition-colors', |
| 165 | + i <= clamped ? 'bg-[var(--brand-secondary)]' : 'bg-[var(--surface-5)]' |
| 166 | + )} |
| 167 | + /> |
| 168 | + ))} |
| 169 | + </div> |
| 170 | + |
| 171 | + <ModalBody>{activeStep}</ModalBody> |
| 172 | + |
| 173 | + <ModalFooter> |
| 174 | + <Button variant='default' onClick={handleBack} disabled={clamped === 0}> |
| 175 | + {backLabel} |
| 176 | + </Button> |
| 177 | + {isLast ? ( |
| 178 | + <Button variant='primary' onClick={handleDone}> |
| 179 | + {doneLabel} |
| 180 | + </Button> |
| 181 | + ) : ( |
| 182 | + <Button variant='primary' onClick={handleNext} disabled={!canAdvance}> |
| 183 | + {nextLabel} |
| 184 | + </Button> |
| 185 | + )} |
| 186 | + </ModalFooter> |
| 187 | + </ModalContent> |
| 188 | + </Modal> |
| 189 | + ) |
| 190 | +} |
| 191 | + |
| 192 | +export const Wizard = Object.assign(WizardRoot, { |
| 193 | + Step, |
| 194 | +}) |
0 commit comments