Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/internal/ui/src/components/step-wizard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { StepWizard, useStepWizard } from "./step-wizard";
export type { StepKind, StepMeta, StepPosition, StepWizardContextValue } from "./types";
239 changes: 239 additions & 0 deletions web/internal/ui/src/components/step-wizard/step-wizard.tsx
Original file line number Diff line number Diff line change
@@ -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<StepWizardContextValue | undefined>(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 (
<StepWizardContext.Provider value={contextValue}>
<div className={cn("flex flex-col", className)}>{children}</div>
</StepWizardContext.Provider>
);
};

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 (
<div
className={cn(
"w-full absolute inset-0 overflow-y-auto scrollbar-hide",
"transition-all duration-300 ease-out",
isActive
? "opacity-100 translate-x-0 z-10"
: "opacity-0 translate-x-5 z-0 pointer-events-none",
)}
aria-hidden={!isActive}
>
{children}
</div>
);
};

StepWizardRoot.displayName = "StepWizardRoot";
StepWizardStep.displayName = "StepWizardStep";

export const StepWizard = {
Root: StepWizardRoot,
Step: StepWizardStep,
};
57 changes: 57 additions & 0 deletions web/internal/ui/src/components/step-wizard/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
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;
};
1 change: 1 addition & 0 deletions web/internal/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";