-
Notifications
You must be signed in to change notification settings - Fork 610
feat: add step-wizard #5162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat: add step-wizard #5162
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
239
web/internal/ui/src/components/step-wizard/step-wizard.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } | ||
ogzhanolguncu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| 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> | ||
ogzhanolguncu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ); | ||
| }; | ||
|
|
||
| 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, | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.