diff --git a/web/internal/ui/src/components/step-wizard/index.ts b/web/internal/ui/src/components/step-wizard/index.ts new file mode 100644 index 0000000000..6fa7f0c2e5 --- /dev/null +++ b/web/internal/ui/src/components/step-wizard/index.ts @@ -0,0 +1,2 @@ +export { StepWizard, useStepWizard } from "./step-wizard"; +export type { StepKind, StepMeta, StepPosition, StepWizardContextValue } from "./types"; diff --git a/web/internal/ui/src/components/step-wizard/step-wizard.tsx b/web/internal/ui/src/components/step-wizard/step-wizard.tsx new file mode 100644 index 0000000000..c69060243b --- /dev/null +++ b/web/internal/ui/src/components/step-wizard/step-wizard.tsx @@ -0,0 +1,239 @@ +"use client"; + +// biome-ignore lint: React in this context is used throughout, so biome will change to types because no APIs are used even though React is needed. +import * as React from "react"; +import type { ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useReducer } from "react"; +import { cn } from "../../lib/utils"; +import type { + StepKind, + StepMeta, + StepPosition, + StepWizardContextValue, + WizardAction, + WizardState, +} from "./types"; + +function derivePosition(activeStepIndex: number, totalSteps: number): StepPosition { + if (totalSteps === 0 || activeStepIndex < 0) { + return "empty"; + } + if (totalSteps === 1) { + return "only"; + } + if (activeStepIndex === 0) { + return "first"; + } + if (activeStepIndex === totalSteps - 1) { + return "last"; + } + return "middle"; +} + +function wizardReducer(state: WizardState, action: WizardAction): WizardState { + switch (action.type) { + case "REGISTER_STEP": { + const { meta, defaultStepId } = action; + + const hasExistingOrder = state.insertionOrder.has(meta.id); + const nextCounter = hasExistingOrder ? state.insertionCounter : state.insertionCounter + 1; + const nextInsertionOrder = hasExistingOrder + ? state.insertionOrder + : new Map([...state.insertionOrder, [meta.id, state.insertionCounter]]); + + const exists = state.steps.some((s) => s.id === meta.id); + const unsorted = exists + ? state.steps.map((s) => (s.id === meta.id ? meta : s)) + : [...state.steps, meta]; + + const sorted = [...unsorted].sort( + (a, b) => (nextInsertionOrder.get(a.id) ?? 0) - (nextInsertionOrder.get(b.id) ?? 0), + ); + + const activeStepId = + state.activeStepId === "" ? (defaultStepId ?? meta.id) : state.activeStepId; + + return { + ...state, + steps: sorted, + activeStepId, + insertionOrder: nextInsertionOrder, + insertionCounter: nextCounter, + }; + } + + case "UNREGISTER_STEP": { + const nextSteps = state.steps.filter((s) => s.id !== action.id); + const nextInsertionOrder = new Map(state.insertionOrder); + nextInsertionOrder.delete(action.id); + return { + ...state, + steps: nextSteps, + insertionOrder: nextInsertionOrder, + }; + } + + case "GO_NEXT": { + const idx = state.steps.findIndex((s) => s.id === state.activeStepId); + if (idx < 0 || idx >= state.steps.length - 1) { + return state; + } + return { ...state, activeStepId: state.steps[idx + 1].id }; + } + + case "GO_BACK": { + const idx = state.steps.findIndex((s) => s.id === state.activeStepId); + if (idx <= 0) { + return state; + } + const currentStep = state.steps[idx]; + if (currentStep.preventBack) { + return state; + } + return { ...state, activeStepId: state.steps[idx - 1].id }; + } + + case "GO_TO": { + if (!state.steps.some((s) => s.id === action.id)) { + return state; + } + return { ...state, activeStepId: action.id }; + } + } +} + +const StepWizardContext = createContext(undefined); + +export const useStepWizard = (): StepWizardContextValue => { + const context = useContext(StepWizardContext); + if (context === undefined) { + throw new Error("useStepWizard must be used within a StepWizard.Root"); + } + return context; +}; + +type StepWizardRootProps = { + onComplete?: () => void; + defaultStepId?: string; + className?: string; + children: ReactNode; +}; + +const StepWizardRoot = ({ + onComplete, + defaultStepId, + className, + children, +}: StepWizardRootProps) => { + const [state, dispatch] = useReducer(wizardReducer, { + steps: [], + activeStepId: defaultStepId ?? "", + insertionOrder: new Map(), + insertionCounter: 0, + }); + + const activeStepIndex = state.steps.findIndex((s) => s.id === state.activeStepId); + const totalSteps = state.steps.length; + const position = derivePosition(activeStepIndex, totalSteps); + const currentStep = activeStepIndex >= 0 ? state.steps[activeStepIndex] : undefined; + + const isFirstStep = position === "first" || position === "only"; + const isLastStep = position === "last" || position === "only"; + const canGoBack = !isFirstStep && !(currentStep?.preventBack ?? false); + const canGoForward = position === "first" || position === "middle"; + + const registerStep = useCallback( + (meta: StepMeta) => dispatch({ type: "REGISTER_STEP", meta, defaultStepId }), + [defaultStepId], + ); + const unregisterStep = useCallback((id: string) => dispatch({ type: "UNREGISTER_STEP", id }), []); + const back = useCallback(() => dispatch({ type: "GO_BACK" }), []); + const goTo = useCallback((id: string) => dispatch({ type: "GO_TO", id }), []); + + const next = useCallback(() => { + if (isLastStep) { + onComplete?.(); + return; + } + dispatch({ type: "GO_NEXT" }); + }, [isLastStep, onComplete]); + + const skip = useCallback(() => { + if (currentStep?.kind !== "optional") { + return; + } + next(); + }, [currentStep, next]); + + const contextValue: StepWizardContextValue = { + steps: state.steps, + activeStepId: state.activeStepId, + activeStepIndex, + totalSteps, + position, + next, + back, + skip, + goTo, + registerStep, + unregisterStep, + canGoBack, + canGoForward, + isLastStep, + isFirstStep, + }; + + return ( + +
{children}
+
+ ); +}; + +type StepWizardStepProps = { + id: string; + label: string; + kind?: StepKind; + preventBack?: boolean; + children: ReactNode; +}; + +const StepWizardStep = ({ + id, + label, + kind = "required", + preventBack, + children, +}: StepWizardStepProps) => { + const { registerStep, unregisterStep, activeStepId } = useStepWizard(); + + useEffect(() => { + registerStep({ id, label, kind, preventBack }); + return () => unregisterStep(id); + }, [id, label, kind, preventBack, unregisterStep, registerStep]); + + const isActive = id === activeStepId; + + return ( +
+ {children} +
+ ); +}; + +StepWizardRoot.displayName = "StepWizardRoot"; +StepWizardStep.displayName = "StepWizardStep"; + +export const StepWizard = { + Root: StepWizardRoot, + Step: StepWizardStep, +}; diff --git a/web/internal/ui/src/components/step-wizard/types.ts b/web/internal/ui/src/components/step-wizard/types.ts new file mode 100644 index 0000000000..de5618436e --- /dev/null +++ b/web/internal/ui/src/components/step-wizard/types.ts @@ -0,0 +1,57 @@ +export type StepKind = "required" | "optional"; + +export type StepMeta = { + id: string; + label: string; + kind: StepKind; + preventBack?: boolean; +}; + +export type StepPosition = "empty" | "only" | "first" | "middle" | "last"; + +export type WizardState = { + readonly steps: readonly StepMeta[]; + readonly activeStepId: string; + readonly insertionOrder: ReadonlyMap; + readonly insertionCounter: number; +}; + +export type WizardAction = + | { type: "REGISTER_STEP"; meta: StepMeta; defaultStepId: string | undefined } + | { type: "UNREGISTER_STEP"; id: string } + | { type: "GO_NEXT" } + | { type: "GO_BACK" } + | { type: "GO_TO"; id: string }; + +export type StepWizardContextValue = { + /** Ordered step metadata */ + steps: readonly StepMeta[]; + /** Current active step ID */ + activeStepId: string; + /** Current step index (derived) */ + activeStepIndex: number; + /** Total step count */ + totalSteps: number; + /** Semantic position of the active step */ + position: StepPosition; + /** Navigate to next step */ + next: () => void; + /** Navigate to previous step */ + back: () => void; + /** Skip current step (only for optional steps) */ + skip: () => void; + /** Navigate to a specific step by ID */ + goTo: (id: string) => void; + /** Register a step (called by StepWizard.Step on mount) */ + registerStep: (meta: StepMeta) => void; + /** Unregister a step (called on unmount) */ + unregisterStep: (id: string) => void; + /** Whether the current step allows going back */ + canGoBack: boolean; + /** Whether there is a next step */ + canGoForward: boolean; + /** Whether the current step is the last */ + isLastStep: boolean; + /** Whether the current step is the first */ + isFirstStep: boolean; +}; diff --git a/web/internal/ui/src/index.ts b/web/internal/ui/src/index.ts index b897bc01f6..a2f78c6c2d 100644 --- a/web/internal/ui/src/index.ts +++ b/web/internal/ui/src/index.ts @@ -34,4 +34,5 @@ export * from "./components/separator"; export * from "./components/toaster"; export * from "./components/visually-hidden"; export * from "./components/slider"; +export * from "./components/step-wizard"; export * from "./hooks/use-mobile";