From 5327a14c17ba02310243cccb44d9d2d6cac2a0cc Mon Sep 17 00:00:00 2001 From: frankkluijtmans Date: Mon, 13 Jul 2026 16:45:58 +0200 Subject: [PATCH 1/5] feat(apollo-vertex): add composable stepper component --- apps/apollo-vertex/registry.json | 49 ++++++++++++++ apps/apollo-vertex/registry/stepper/index.ts | 18 +++++ .../registry/stepper/stepper-content.tsx | 44 +++++++++++++ .../registry/stepper/stepper-context.ts | 49 ++++++++++++++ .../registry/stepper/stepper-indicator.tsx | 37 +++++++++++ .../registry/stepper/stepper-item.tsx | 59 +++++++++++++++++ .../registry/stepper/stepper-separator.tsx | 27 ++++++++ .../registry/stepper/stepper-trigger.tsx | 34 ++++++++++ .../registry/stepper/stepper.tsx | 66 +++++++++++++++++++ apps/apollo-vertex/tsconfig.json | 1 + 10 files changed, 384 insertions(+) create mode 100644 apps/apollo-vertex/registry/stepper/index.ts create mode 100644 apps/apollo-vertex/registry/stepper/stepper-content.tsx create mode 100644 apps/apollo-vertex/registry/stepper/stepper-context.ts create mode 100644 apps/apollo-vertex/registry/stepper/stepper-indicator.tsx create mode 100644 apps/apollo-vertex/registry/stepper/stepper-item.tsx create mode 100644 apps/apollo-vertex/registry/stepper/stepper-separator.tsx create mode 100644 apps/apollo-vertex/registry/stepper/stepper-trigger.tsx create mode 100644 apps/apollo-vertex/registry/stepper/stepper.tsx diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 674fa8aab..bc6c9d933 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -2722,6 +2722,55 @@ } ] }, + { + "name": "stepper", + "type": "registry:ui", + "title": "Stepper", + "description": "A composable step indicator for multi-step flows with horizontal and vertical orientations and active, completed, and inactive states.", + "dependencies": ["class-variance-authority", "lucide-react"], + "files": [ + { + "path": "registry/stepper/index.ts", + "type": "registry:ui", + "target": "components/ui/stepper/index.ts" + }, + { + "path": "registry/stepper/stepper.tsx", + "type": "registry:ui", + "target": "components/ui/stepper/stepper.tsx" + }, + { + "path": "registry/stepper/stepper-context.ts", + "type": "registry:ui", + "target": "components/ui/stepper/stepper-context.ts" + }, + { + "path": "registry/stepper/stepper-item.tsx", + "type": "registry:ui", + "target": "components/ui/stepper/stepper-item.tsx" + }, + { + "path": "registry/stepper/stepper-trigger.tsx", + "type": "registry:ui", + "target": "components/ui/stepper/stepper-trigger.tsx" + }, + { + "path": "registry/stepper/stepper-indicator.tsx", + "type": "registry:ui", + "target": "components/ui/stepper/stepper-indicator.tsx" + }, + { + "path": "registry/stepper/stepper-content.tsx", + "type": "registry:ui", + "target": "components/ui/stepper/stepper-content.tsx" + }, + { + "path": "registry/stepper/stepper-separator.tsx", + "type": "registry:ui", + "target": "components/ui/stepper/stepper-separator.tsx" + } + ] + }, { "name": "textarea", "type": "registry:ui", diff --git a/apps/apollo-vertex/registry/stepper/index.ts b/apps/apollo-vertex/registry/stepper/index.ts new file mode 100644 index 000000000..efe80b2e0 --- /dev/null +++ b/apps/apollo-vertex/registry/stepper/index.ts @@ -0,0 +1,18 @@ +export { Stepper, type StepperProps, stepperVariants } from "./stepper"; +export { + StepperContent, + StepperDescription, + StepperTitle, +} from "./stepper-content"; +export type { + StepperItemState, + StepperOrientation, +} from "./stepper-context"; +export { StepperIndicator } from "./stepper-indicator"; +export { + StepperItem, + type StepperItemProps, + stepperItemVariants, +} from "./stepper-item"; +export { StepperSeparator } from "./stepper-separator"; +export { StepperTrigger } from "./stepper-trigger"; diff --git a/apps/apollo-vertex/registry/stepper/stepper-content.tsx b/apps/apollo-vertex/registry/stepper/stepper-content.tsx new file mode 100644 index 000000000..f24d71393 --- /dev/null +++ b/apps/apollo-vertex/registry/stepper/stepper-content.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; +import { useStepperItemContext } from "./stepper-context"; + +function StepperContent({ className, ...props }: ComponentProps<"span">) { + return ( + + ); +} + +function StepperTitle({ className, ...props }: ComponentProps<"span">) { + const { state } = useStepperItemContext(); + + return ( + + ); +} + +function StepperDescription({ className, ...props }: ComponentProps<"span">) { + return ( + + ); +} + +export { StepperContent, StepperDescription, StepperTitle }; diff --git a/apps/apollo-vertex/registry/stepper/stepper-context.ts b/apps/apollo-vertex/registry/stepper/stepper-context.ts new file mode 100644 index 000000000..22531e4c3 --- /dev/null +++ b/apps/apollo-vertex/registry/stepper/stepper-context.ts @@ -0,0 +1,49 @@ +"use client"; + +import { createContext, useContext } from "react"; + +type StepperOrientation = "horizontal" | "vertical"; +type StepperItemState = "active" | "completed" | "inactive"; + +interface StepperContextValue { + activeStep: number; + orientation: StepperOrientation; +} + +const StepperContext = createContext(null); + +function useStepperContext() { + const context = useContext(StepperContext); + if (!context) { + throw new Error("Stepper components must be used within a ."); + } + return context; +} + +interface StepperItemContextValue { + step: number; + state: StepperItemState; +} + +const StepperItemContext = createContext(null); + +function useStepperItemContext() { + const context = useContext(StepperItemContext); + if (!context) { + throw new Error("Stepper item parts must be used within a ."); + } + return context; +} + +export { + StepperContext, + StepperItemContext, + useStepperContext, + useStepperItemContext, +}; +export type { + StepperContextValue, + StepperItemContextValue, + StepperItemState, + StepperOrientation, +}; diff --git a/apps/apollo-vertex/registry/stepper/stepper-indicator.tsx b/apps/apollo-vertex/registry/stepper/stepper-indicator.tsx new file mode 100644 index 000000000..ff1bbb8b3 --- /dev/null +++ b/apps/apollo-vertex/registry/stepper/stepper-indicator.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { CheckIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; +import { useStepperItemContext } from "./stepper-context"; + +function StepperIndicator({ + className, + children, + ...props +}: ComponentProps<"span">) { + const { step, state } = useStepperItemContext(); + + return ( + + {state === "completed" ? ( + + ) : ( + (children ?? step + 1) + )} + + ); +} + +export { StepperIndicator }; diff --git a/apps/apollo-vertex/registry/stepper/stepper-item.tsx b/apps/apollo-vertex/registry/stepper/stepper-item.tsx new file mode 100644 index 000000000..e0a0248aa --- /dev/null +++ b/apps/apollo-vertex/registry/stepper/stepper-item.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { cva } from "class-variance-authority"; +import { type ComponentProps, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { + StepperItemContext, + type StepperItemState, + useStepperContext, +} from "./stepper-context"; + +const stepperItemVariants = cva("group/stepper-item flex", { + variants: { + orientation: { + horizontal: "flex-1 items-center gap-3 last:flex-none", + vertical: "flex-col gap-2", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, +}); + +interface StepperItemProps extends ComponentProps<"li"> { + step: number; + completed?: boolean; +} + +function StepperItem({ + className, + step, + completed, + ...props +}: StepperItemProps) { + const { activeStep, orientation } = useStepperContext(); + + const isCompleted = completed ?? step < activeStep; + const state: StepperItemState = isCompleted + ? "completed" + : step === activeStep + ? "active" + : "inactive"; + + const value = useMemo(() => ({ step, state }), [step, state]); + + return ( + +
  • + + ); +} + +export { StepperItem, stepperItemVariants }; +export type { StepperItemProps }; diff --git a/apps/apollo-vertex/registry/stepper/stepper-separator.tsx b/apps/apollo-vertex/registry/stepper/stepper-separator.tsx new file mode 100644 index 000000000..5177654eb --- /dev/null +++ b/apps/apollo-vertex/registry/stepper/stepper-separator.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; +import { useStepperContext, useStepperItemContext } from "./stepper-context"; + +function StepperSeparator({ className, ...props }: ComponentProps<"span">) { + const { orientation } = useStepperContext(); + const { state } = useStepperItemContext(); + + return ( + + ); +} + +export { StepperSeparator }; diff --git a/apps/apollo-vertex/registry/stepper/stepper-trigger.tsx b/apps/apollo-vertex/registry/stepper/stepper-trigger.tsx new file mode 100644 index 000000000..d310cf12c --- /dev/null +++ b/apps/apollo-vertex/registry/stepper/stepper-trigger.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; + +function StepperTrigger({ + className, + onClick, + disabled, + children, + ...props +}: ComponentProps<"button">) { + const interactive = Boolean(onClick) && !disabled; + + return ( + + ); +} + +export { StepperTrigger }; diff --git a/apps/apollo-vertex/registry/stepper/stepper.tsx b/apps/apollo-vertex/registry/stepper/stepper.tsx new file mode 100644 index 000000000..7ad92f409 --- /dev/null +++ b/apps/apollo-vertex/registry/stepper/stepper.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import { type ComponentProps, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { StepperContext, type StepperOrientation } from "./stepper-context"; + +const stepperVariants = cva("flex w-full", { + variants: { + orientation: { + horizontal: "flex-row items-center", + vertical: "flex-col", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, +}); + +interface StepperProps + extends ComponentProps<"ol">, + VariantProps { + activeStep: number; +} + +function Stepper({ + className, + activeStep, + orientation = "horizontal", + ...props +}: StepperProps) { + const value = useMemo( + () => ({ activeStep, orientation: orientation ?? "horizontal" }), + [activeStep, orientation], + ); + + return ( + +
      + + ); +} + +export { Stepper, stepperVariants }; +export type { StepperProps }; + +// Re-export child parts here too: shadcn may rewrite a barrel import to the main file. +export { + StepperContent, + StepperDescription, + StepperTitle, +} from "./stepper-content"; +export { StepperIndicator } from "./stepper-indicator"; +export { + StepperItem, + type StepperItemProps, + stepperItemVariants, +} from "./stepper-item"; +export { StepperSeparator } from "./stepper-separator"; +export { StepperTrigger } from "./stepper-trigger"; +export type { StepperItemState, StepperOrientation } from "./stepper-context"; diff --git a/apps/apollo-vertex/tsconfig.json b/apps/apollo-vertex/tsconfig.json index a9700ba76..0d7acb722 100644 --- a/apps/apollo-vertex/tsconfig.json +++ b/apps/apollo-vertex/tsconfig.json @@ -102,6 +102,7 @@ "@/components/ui/solution-tests": ["./registry/solution-tests/index"], "@/components/ui/sonner": ["./registry/sonner/sonner"], "@/components/ui/spinner": ["./registry/spinner/spinner"], + "@/components/ui/stepper": ["./registry/stepper/index"], "@/components/ui/switch": ["./registry/switch/switch"], "@/components/ui/table": ["./registry/table/table"], "@/components/ui/tabs": ["./registry/tabs/tabs"], From 57ff6c57269d058f11184cf7ad08d424343425ad Mon Sep 17 00:00:00 2001 From: frankkluijtmans Date: Mon, 13 Jul 2026 16:46:20 +0200 Subject: [PATCH 2/5] feat(apollo-vertex): add multi-step form wizard --- apps/apollo-vertex/package.json | 1 + apps/apollo-vertex/registry.json | 46 +++++++ .../registry/form-wizard/form-wizard-nav.tsx | 36 ++++++ .../registry/form-wizard/form-wizard.tsx | 117 ++++++++++++++++++ .../registry/form-wizard/index.ts | 16 +++ .../registry/form-wizard/use-form-wizard.ts | 116 +++++++++++++++++ .../registry/form-wizard/wizard-schema.ts | 16 +++ apps/apollo-vertex/tsconfig.json | 1 + pnpm-lock.yaml | 3 + 9 files changed, 352 insertions(+) create mode 100644 apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx create mode 100644 apps/apollo-vertex/registry/form-wizard/form-wizard.tsx create mode 100644 apps/apollo-vertex/registry/form-wizard/index.ts create mode 100644 apps/apollo-vertex/registry/form-wizard/use-form-wizard.ts create mode 100644 apps/apollo-vertex/registry/form-wizard/wizard-schema.ts diff --git a/apps/apollo-vertex/package.json b/apps/apollo-vertex/package.json index 52bab1393..8ca51db30 100644 --- a/apps/apollo-vertex/package.json +++ b/apps/apollo-vertex/package.json @@ -60,6 +60,7 @@ "@tanstack/react-form": "^1.33.0", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.132.31", + "@tanstack/react-store": "^0.11.0", "@tanstack/react-table": "^8.21.3", "@ts-rest/core": "^3.53.0-rc.1", "@uipath/uipath-typescript": "^1.3.1", diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index bc6c9d933..70cf4bb67 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -2771,6 +2771,52 @@ } ] }, + { + "name": "form-wizard", + "type": "registry:ui", + "title": "Form Wizard", + "description": "A multi-step wizard built on TanStack Form and Zod. Each step is composed with withForm and validates its own slice through a FormGroup, with conditional steps and optional persistence.", + "dependencies": [ + "@tanstack/react-form", + "@tanstack/react-store", + "@mantine/hooks", + "zod", + "react-i18next" + ], + "registryDependencies": [ + "@uipath/form", + "@uipath/button", + "@uipath/field", + "@uipath/stepper" + ], + "files": [ + { + "path": "registry/form-wizard/index.ts", + "type": "registry:ui", + "target": "components/ui/form-wizard/index.ts" + }, + { + "path": "registry/form-wizard/form-wizard.tsx", + "type": "registry:ui", + "target": "components/ui/form-wizard/form-wizard.tsx" + }, + { + "path": "registry/form-wizard/form-wizard-nav.tsx", + "type": "registry:ui", + "target": "components/ui/form-wizard/form-wizard-nav.tsx" + }, + { + "path": "registry/form-wizard/use-form-wizard.ts", + "type": "registry:ui", + "target": "components/ui/form-wizard/use-form-wizard.ts" + }, + { + "path": "registry/form-wizard/wizard-schema.ts", + "type": "registry:ui", + "target": "components/ui/form-wizard/wizard-schema.ts" + } + ] + }, { "name": "textarea", "type": "registry:ui", diff --git a/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx b/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx new file mode 100644 index 000000000..b17577200 --- /dev/null +++ b/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useFormWizardContext } from "./form-wizard"; + +type FormWizardNavProps = ComponentProps<"div">; + +function FormWizardNav({ className, ...props }: FormWizardNavProps) { + const { form, isFirst, isLast, back } = useFormWizardContext(); + const { t } = useTranslation(); + + return ( +
      + + state.isSubmitting}> + {(isSubmitting) => ( + + )} + +
      + ); +} + +export { FormWizardNav }; +export type { FormWizardNavProps }; diff --git a/apps/apollo-vertex/registry/form-wizard/form-wizard.tsx b/apps/apollo-vertex/registry/form-wizard/form-wizard.tsx new file mode 100644 index 000000000..d7ba0c4b0 --- /dev/null +++ b/apps/apollo-vertex/registry/form-wizard/form-wizard.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { createContext, type ComponentProps, useContext } from "react"; +import { + Stepper, + StepperContent, + StepperDescription, + StepperIndicator, + StepperItem, + StepperSeparator, + StepperTitle, + StepperTrigger, +} from "@/components/ui/stepper"; +import { cn } from "@/lib/utils"; +import { useFormWizard } from "./use-form-wizard"; + +type FormWizardApi = ReturnType; + +const FormWizardContext = createContext(null); + +function useFormWizardContext(): FormWizardApi { + const context = useContext(FormWizardContext); + if (!context) { + throw new Error("FormWizard parts must be used within a ."); + } + return context; +} + +interface FormWizardProps> + extends ComponentProps<"div"> { + wizard: ReturnType>; +} + +function FormWizard>({ + wizard, + className, + children, + ...props +}: FormWizardProps) { + return ( + // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- context is field-type agnostic; parts read only step metadata and generic helpers + +
      + {children} +
      +
      + ); +} + +interface FormWizardStepsProps + extends Omit, "activeStep"> { + clickable?: boolean; +} + +function FormWizardSteps({ + clickable, + className, + ...props +}: FormWizardStepsProps) { + const { steps, stepIndex, goToStep } = useFormWizardContext(); + + return ( + + {steps.map((step, index) => { + const canNavigate = clickable && index < stepIndex; + const navProps = canNavigate + ? { onClick: () => goToStep(step.id) } + : {}; + return ( + + + + + {step.title} + {step.description ? ( + {step.description} + ) : null} + + + {index < steps.length - 1 ? : null} + + ); + })} + + ); +} + +interface FormWizardStepProps extends ComponentProps<"div"> { + stepId: string; +} + +function FormWizardStep({ + stepId, + className, + children, + ...props +}: FormWizardStepProps) { + const { currentStepId } = useFormWizardContext(); + if (stepId !== currentStepId) return null; + + return ( +
      + {children} +
      + ); +} + +export { FormWizard, FormWizardStep, FormWizardSteps, useFormWizardContext }; +export type { FormWizardProps, FormWizardStepProps, FormWizardStepsProps }; diff --git a/apps/apollo-vertex/registry/form-wizard/index.ts b/apps/apollo-vertex/registry/form-wizard/index.ts new file mode 100644 index 000000000..9052b2356 --- /dev/null +++ b/apps/apollo-vertex/registry/form-wizard/index.ts @@ -0,0 +1,16 @@ +export { + FormWizard, + type FormWizardProps, + FormWizardStep, + type FormWizardStepProps, + FormWizardSteps, + type FormWizardStepsProps, + useFormWizardContext, +} from "./form-wizard"; +export { FormWizardNav, type FormWizardNavProps } from "./form-wizard-nav"; +export { + useFormWizard, + type UseFormWizardOptions, + type WizardPersistConfig, +} from "./use-form-wizard"; +export { getVisibleSteps, type WizardStepDef } from "./wizard-schema"; diff --git a/apps/apollo-vertex/registry/form-wizard/use-form-wizard.ts b/apps/apollo-vertex/registry/form-wizard/use-form-wizard.ts new file mode 100644 index 000000000..3fac4d333 --- /dev/null +++ b/apps/apollo-vertex/registry/form-wizard/use-form-wizard.ts @@ -0,0 +1,116 @@ +"use client"; + +import { useLocalStorage } from "@mantine/hooks"; +import { type FormValidateFn, revalidateLogic } from "@tanstack/react-form"; +import { useSelector } from "@tanstack/react-store"; +import { useEffect, useMemo, useState } from "react"; +import type { z } from "zod"; +import { useAppForm } from "@/components/ui/form"; +import { getVisibleSteps, type WizardStepDef } from "./wizard-schema"; + +interface WizardPersistConfig { + key: string; +} + +interface PersistedState { + values: TValues; + stepId: string; +} + +interface UseFormWizardOptions> { + formOptions: { defaultValues: TValues }; + schema: z.ZodType; + steps: ReadonlyArray>; + persist?: WizardPersistConfig; + onSubmit: (values: TValues) => void | Promise; +} + +function useFormWizard>({ + formOptions, + schema, + steps, + persist, + onSubmit, +}: UseFormWizardOptions) { + const persistKey = persist?.key ?? ""; + const [persisted, setPersisted, removePersisted] = useLocalStorage< + PersistedState | undefined + >({ key: persistKey, getInitialValueInEffect: false }); + + // Ignore stored state when persistence is off. + const [initial] = useState(persistKey ? persisted : null); + const [currentStepId, setCurrentStepId] = useState( + () => initial?.stepId ?? steps[0]?.id ?? "", + ); + + const form = useAppForm({ + ...formOptions, + defaultValues: initial?.values ?? formOptions.defaultValues, + validationLogic: revalidateLogic(), + // Full-form backstop; only runs on the final submit. + // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- TanStack accepts a Standard Schema (Zod) here at runtime; the generic slot is typed as the function form only + validators: { onDynamic: schema as unknown as FormValidateFn }, + onSubmit: async ({ value }) => { + await onSubmit(value); + if (persistKey) removePersisted(); + }, + }); + + const values = useSelector(form.store, (state) => state.values); + const visibleSteps = useMemo( + () => getVisibleSteps(steps, values), + [steps, values], + ); + + // Fall back to the first visible step when the current id is hidden or stale. + const foundIndex = visibleSteps.findIndex((s) => s.id === currentStepId); + const stepIndex = foundIndex === -1 ? 0 : foundIndex; + const currentStep = visibleSteps[stepIndex]; + const activeStepId = currentStep?.id ?? currentStepId; + const isFirst = stepIndex === 0; + const isLast = stepIndex === visibleSteps.length - 1; + const progress = + visibleSteps.length > 0 ? ((stepIndex + 1) / visibleSteps.length) * 100 : 0; + + const next = () => { + const nextStep = visibleSteps[stepIndex + 1]; + if (nextStep) setCurrentStepId(nextStep.id); + else void form.handleSubmit(); + }; + + const back = () => { + const previous = visibleSteps[stepIndex - 1]; + if (previous) setCurrentStepId(previous.id); + }; + + const goToStep = (id: string) => setCurrentStepId(id); + + const reset = () => { + form.reset(); + if (persistKey) removePersisted(); + setCurrentStepId(steps[0]?.id ?? ""); + }; + + useEffect(() => { + if (persistKey) setPersisted({ values, stepId: activeStepId }); + }, [persistKey, setPersisted, values, activeStepId]); + + return { + form, + steps: visibleSteps, + currentStep, + currentStepId: activeStepId, + stepIndex, + stepCount: visibleSteps.length, + isFirst, + isLast, + progress, + next, + back, + goToStep, + reset, + }; +} + +export { useFormWizard }; +export type { UseFormWizardOptions, WizardPersistConfig }; diff --git a/apps/apollo-vertex/registry/form-wizard/wizard-schema.ts b/apps/apollo-vertex/registry/form-wizard/wizard-schema.ts new file mode 100644 index 000000000..336be32a9 --- /dev/null +++ b/apps/apollo-vertex/registry/form-wizard/wizard-schema.ts @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +export interface WizardStepDef { + id: string; + title: ReactNode; + description?: ReactNode; + condition?: (values: TValues) => boolean; + optional?: boolean; +} + +export function getVisibleSteps( + steps: ReadonlyArray>, + values: TValues, +): Array> { + return steps.filter((step) => step.condition?.(values) ?? true); +} diff --git a/apps/apollo-vertex/tsconfig.json b/apps/apollo-vertex/tsconfig.json index 0d7acb722..fa920b49b 100644 --- a/apps/apollo-vertex/tsconfig.json +++ b/apps/apollo-vertex/tsconfig.json @@ -67,6 +67,7 @@ "@/components/ui/field": ["./registry/field/field"], "@/components/ui/filter-dropdown": ["./registry/filter-dropdown/index"], "@/components/ui/form": ["./registry/form/index"], + "@/components/ui/form-wizard": ["./registry/form-wizard/index"], "@/components/ui/hover-card": ["./registry/hover-card/hover-card"], "@/components/ui/input": ["./registry/input/input"], "@/components/ui/input-group": ["./registry/input-group/input-group"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4474a24ab..cb5a01e5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: '@tanstack/react-router': specifier: ^1.132.31 version: 1.167.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-store': + specifier: ^0.11.0 + version: 0.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) From 92d01c3120f36208bfe2afafb92675d8286be0b8 Mon Sep 17 00:00:00 2001 From: frankkluijtmans Date: Mon, 13 Jul 2026 16:46:28 +0200 Subject: [PATCH 3/5] docs(apollo-vertex): add form wizard demo and documentation --- apps/apollo-vertex/app/components/_meta.ts | 1 + .../form-wizard/form-wizard-demo.tsx | 258 ++++++++++++++++++ .../app/components/form-wizard/page.mdx | 139 ++++++++++ apps/apollo-vertex/locales/en.json | 8 + 4 files changed, 406 insertions(+) create mode 100644 apps/apollo-vertex/app/components/form-wizard/form-wizard-demo.tsx create mode 100644 apps/apollo-vertex/app/components/form-wizard/page.mdx diff --git a/apps/apollo-vertex/app/components/_meta.ts b/apps/apollo-vertex/app/components/_meta.ts index a3e852589..05fdcd73b 100644 --- a/apps/apollo-vertex/app/components/_meta.ts +++ b/apps/apollo-vertex/app/components/_meta.ts @@ -35,6 +35,7 @@ export default { "feature-flags": "Feature Flags", "filter-dropdown": "Filter Dropdown", form: "Form", + "form-wizard": "Form Wizard", "hover-card": "Hover Card", input: "Input", "input-group": "Input Group", diff --git a/apps/apollo-vertex/app/components/form-wizard/form-wizard-demo.tsx b/apps/apollo-vertex/app/components/form-wizard/form-wizard-demo.tsx new file mode 100644 index 000000000..4ee9eb8f3 --- /dev/null +++ b/apps/apollo-vertex/app/components/form-wizard/form-wizard-demo.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { formOptions } from "@tanstack/react-form"; +import { useState } from "react"; +import { z } from "zod"; +import { FieldGroup } from "@/components/ui/field"; +import { withForm } from "@/components/ui/form"; +import { + FormWizard, + FormWizardNav, + FormWizardStep, + FormWizardSteps, + useFormWizard, + useFormWizardContext, + type WizardStepDef, +} from "@/components/ui/form-wizard"; +import { LocaleProvider } from "@/registry/shell/shell-locale-provider"; + +const accountSchema = z.object({ + fullName: z.string().min(2, "wizard_demo_error_name"), + email: z.email("wizard_demo_error_email"), +}); +const planSchema = z.object({ + tier: z.string().min(1), + seats: z.string().min(1, "wizard_demo_error_seats"), +}); +const billingSchema = z.object({ + poNumber: z.string().min(1, "wizard_demo_error_po"), +}); +const reviewSchema = z.object({ + acceptTerms: z.boolean().refine((value) => value, { + message: "wizard_demo_error_terms", + }), +}); + +const wizardSchema = z.object({ + account: accountSchema, + plan: planSchema, + billing: billingSchema, + review: reviewSchema, +}); + +type WizardValues = z.infer; + +const defaultValues = { + account: { fullName: "", email: "" }, + plan: { tier: "free", seats: "1" }, + billing: { poNumber: "" }, + review: { acceptTerms: false }, +}; + +const wizardOpts = formOptions({ defaultValues }); + +const tierOptions = [ + { label: "Free", value: "free" }, + { label: "Pro", value: "pro" }, + { label: "Enterprise", value: "enterprise" }, +]; + +const steps: WizardStepDef[] = [ + { id: "account", title: "Account" }, + { id: "plan", title: "Plan" }, + { + id: "billing", + title: "Billing", + description: "Enterprise only", + condition: (values) => values.plan.tier === "enterprise", + }, + { id: "review", title: "Review" }, +]; + +const AccountStep = withForm({ + ...wizardOpts, + render: function Render({ form }) { + const { next } = useFormWizardContext(); + return ( + next()} + > + {(group) => ( +
      { + event.preventDefault(); + event.stopPropagation(); + void group.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + +
      + )} +
      + ); + }, +}); + +const PlanStep = withForm({ + ...wizardOpts, + render: function Render({ form }) { + const { next } = useFormWizardContext(); + return ( + next()} + > + {(group) => ( +
      { + event.preventDefault(); + event.stopPropagation(); + void group.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + {(field) => } + + + +
      + )} +
      + ); + }, +}); + +const BillingStep = withForm({ + ...wizardOpts, + render: function Render({ form }) { + const { next } = useFormWizardContext(); + return ( + next()} + > + {(group) => ( +
      { + event.preventDefault(); + event.stopPropagation(); + void group.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + +
      + )} +
      + ); + }, +}); + +const ReviewStep = withForm({ + ...wizardOpts, + render: function Render({ form }) { + const { next } = useFormWizardContext(); + return ( + next()} + > + {(group) => ( +
      { + event.preventDefault(); + event.stopPropagation(); + void group.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + +
      + )} +
      + ); + }, +}); + +export function FormWizardDemo() { + const [submitted, setSubmitted] = useState(null); + + const wizard = useFormWizard({ + formOptions: wizardOpts, + schema: wizardSchema, + steps, + persist: { key: "form-wizard-demo" }, + onSubmit: (values) => setSubmitted(values), + }); + + return ( + + + + + + + + + + + + + + + + + + {submitted ? ( +
      +          {JSON.stringify(submitted, null, 2)}
      +        
      + ) : null} +
      + ); +} diff --git a/apps/apollo-vertex/app/components/form-wizard/page.mdx b/apps/apollo-vertex/app/components/form-wizard/page.mdx new file mode 100644 index 000000000..c1a1c6c9e --- /dev/null +++ b/apps/apollo-vertex/app/components/form-wizard/page.mdx @@ -0,0 +1,139 @@ +import { FormWizardDemo } from './form-wizard-demo'; + +# Form Wizard + +A multi-step form built on TanStack Form's composition model. Values are nested per step, each step is a `withForm` component that validates its own slice through a `FormGroup`, and one form instance holds everything. + +## What it is built on + +- **One form instance.** `useFormWizard` creates a single `useAppForm` from your `formOptions`, wires `revalidateLogic()` and a full-form `onDynamic` schema for the final submit, and owns step navigation, conditional visibility, and optional persistence. +- **Nested values per step.** `defaultValues` is grouped by step (`{ account: {...}, plan: {...} }`), so each step owns a slice of the form. +- **Per-step schemas via `FormGroup`.** Each step is a `withForm` component whose `form.FormGroup` validates only that step's slice with its own schema on advance. The full `schema` passed to `useFormWizard` is not sliced per step; it is only the final-submit backstop. +- **A composable Stepper** renders the progress indicator from the visible steps. + +## Installation + +```bash +npx shadcn@latest add @uipath/form-wizard +``` + +## How it works + +- `useFormWizard` returns the `form`, the visible `steps`, the `currentStepId`, and navigation helpers (`next`, `back`, `goToStep`, `reset`). +- Each step is authored with `withForm` and wraps a `form.FormGroup`. Its `onGroupSubmit` (fired only when the group's schema passes) calls `next()`; on the last step `next()` runs the full `form.handleSubmit()`. +- `FormWizard` provides the wizard through context. `FormWizardStep` renders its child only when it is the active step. `FormWizardSteps` renders the Stepper, and `FormWizardNav` renders Back plus a submit button that advances the current group. + +
      + +
      + +The demo persists to `localStorage`, so refreshing the page restores the answers and the current step. Choosing the Enterprise plan reveals a conditional Billing step. + +## Usage + +Define nested `formOptions` and a schema per step, then author each step with `withForm`: + +```tsx +import { formOptions } from '@tanstack/react-form' +import { z } from 'zod' +import { FieldGroup } from '@/components/ui/field' +import { withForm } from '@/components/ui/form' +import { + FormWizard, + FormWizardNav, + FormWizardStep, + FormWizardSteps, + useFormWizard, + useFormWizardContext, + type WizardStepDef, +} from '@/components/ui/form-wizard' + +const accountSchema = z.object({ email: z.email('Enter a valid email.') }) +const planSchema = z.object({ tier: z.string().min(1) }) + +const wizardSchema = z.object({ account: accountSchema, plan: planSchema }) +type Values = z.infer + +const wizardOpts = formOptions({ + defaultValues: { account: { email: '' }, plan: { tier: 'free' } }, +}) + +const steps: WizardStepDef[] = [ + { id: 'account', title: 'Account' }, + { id: 'plan', title: 'Plan', condition: (v) => v.account.email !== '' }, +] + +const AccountStep = withForm({ + ...wizardOpts, + render: function Render({ form }) { + const { next } = useFormWizardContext() + return ( + next()} + > + {(group) => ( +
      { + e.preventDefault() + void group.handleSubmit() + }} + > + + + {(field) => } + + + +
      + )} +
      + ) + }, +}) + +function SignUpWizard() { + const wizard = useFormWizard({ + formOptions: wizardOpts, + schema: wizardSchema, + steps, + persist: { key: 'sign-up' }, + onSubmit: (values) => console.log(values), + }) + + return ( + + + + + + {/* ...remaining steps... */} + + ) +} +``` + +## Step definition + +| Field | Type | Notes | +| --- | --- | --- | +| `id` | `string` | Stable identifier used for navigation and persistence. | +| `title` | `ReactNode` | Shown in the Stepper. | +| `description` | `ReactNode` | Optional secondary line in the Stepper. | +| `condition` | `(values) => boolean` | Optional. When it returns `false` the step is hidden from navigation and the Stepper. | +| `optional` | `boolean` | Reserved for steps that should not block advancing. | + +Each step's fields and validation live in its `withForm` component, not in the step definition. + +## Conditional steps + +A step with a `condition` is only shown when the predicate passes against the current (nested) values. Hidden steps are skipped by `next`/`back` and dropped from the Stepper. + +## Persistence + +Pass `persist: { key }` to save the values and current step to `localStorage`, backed by `useLocalStorage` from `@mantine/hooks`. Every change is written immediately so a reload resumes where you left off, the saved state seeds `defaultValues` on load, and a successful submit or `wizard.reset()` clears the entry. + +## Validation + +Advancing runs the current step's `FormGroup` schema against its slice; errors render inline on that step's fields. The final `next()` calls `form.handleSubmit()`, which validates the full `onDynamic` schema as a backstop. diff --git a/apps/apollo-vertex/locales/en.json b/apps/apollo-vertex/locales/en.json index 9628b79fe..63e54824e 100644 --- a/apps/apollo-vertex/locales/en.json +++ b/apps/apollo-vertex/locales/en.json @@ -287,6 +287,14 @@ "view_messages": "View messages", "view_payment_details": "View payment details", "warning": "Warning", + "wizard_back": "Back", + "wizard_demo_error_email": "Enter a valid email address.", + "wizard_demo_error_name": "Name must be at least 2 characters.", + "wizard_demo_error_po": "A purchase order number is required.", + "wizard_demo_error_seats": "Enter the number of seats.", + "wizard_demo_error_terms": "You must accept the terms and conditions.", + "wizard_next": "Next", + "wizard_submit": "Submit", "wrong_dimension_type_in_entity": "Field \"{{field}}\" on {{entity}} is {{actual}}; this chart needs a {{expected}} field.", "wrong_dimension_type_in_joined": "Field \"{{field}}\" is {{actual}}; this chart needs a {{expected}} field.", "wrong_metric_type_in_entity": "Field \"{{field}}\" on {{entity}} is {{actual}}; {{aggregation}} requires a numeric field.", From 2970600bf85f61657ad1086f249902931454e954 Mon Sep 17 00:00:00 2001 From: frankkluijtmans Date: Fri, 17 Jul 2026 15:17:21 +0200 Subject: [PATCH 4/5] feat(apollo-vertex): add render-prop steps and composable nav to form wizard --- .../registry/form-wizard/form-wizard-nav.tsx | 61 ++++++++++++---- .../registry/form-wizard/form-wizard.tsx | 72 ++++++++++++++----- .../registry/form-wizard/index.ts | 9 ++- 3 files changed, 110 insertions(+), 32 deletions(-) diff --git a/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx b/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx index b17577200..0993a722a 100644 --- a/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx +++ b/apps/apollo-vertex/registry/form-wizard/form-wizard-nav.tsx @@ -6,31 +6,62 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useFormWizardContext } from "./form-wizard"; -type FormWizardNavProps = ComponentProps<"div">; +type FormWizardButtonProps = Omit< + ComponentProps, + "type" | "disabled" | "onClick" +>; -function FormWizardNav({ className, ...props }: FormWizardNavProps) { - const { form, isFirst, isLast, back } = useFormWizardContext(); +function FormWizardBackButton({ + children, + variant = "outline", + ...props +}: FormWizardButtonProps) { + const { isFirst, back } = useFormWizardContext(); const { t } = useTranslation(); + return ( + + ); +} + +function FormWizardNextButton({ children, ...props }: FormWizardButtonProps) { + const { form, isLast } = useFormWizardContext(); + const { t } = useTranslation(); + + // type="submit" submits the enclosing step FormGroup, which advances on pass. + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ); +} + +type FormWizardNavProps = ComponentProps<"div">; + +function FormWizardNav({ className, ...props }: FormWizardNavProps) { return (
      - - state.isSubmitting}> - {(isSubmitting) => ( - - )} - + +
      ); } -export { FormWizardNav }; -export type { FormWizardNavProps }; +export { FormWizardBackButton, FormWizardNav, FormWizardNextButton }; +export type { FormWizardButtonProps, FormWizardNavProps }; diff --git a/apps/apollo-vertex/registry/form-wizard/form-wizard.tsx b/apps/apollo-vertex/registry/form-wizard/form-wizard.tsx index d7ba0c4b0..72c264b9e 100644 --- a/apps/apollo-vertex/registry/form-wizard/form-wizard.tsx +++ b/apps/apollo-vertex/registry/form-wizard/form-wizard.tsx @@ -1,6 +1,11 @@ "use client"; -import { createContext, type ComponentProps, useContext } from "react"; +import { + createContext, + type ComponentProps, + type ReactNode, + useContext, +} from "react"; import { Stepper, StepperContent, @@ -13,6 +18,7 @@ import { } from "@/components/ui/stepper"; import { cn } from "@/lib/utils"; import { useFormWizard } from "./use-form-wizard"; +import type { WizardStepDef } from "./wizard-schema"; type FormWizardApi = ReturnType; @@ -51,14 +57,24 @@ function FormWizard>({ ); } +interface WizardStepRenderProps { + step: WizardStepDef>; + index: number; + state: "active" | "completed" | "inactive"; + isCurrent: boolean; + goTo: () => void; +} + interface FormWizardStepsProps - extends Omit, "activeStep"> { + extends Omit, "activeStep" | "children"> { clickable?: boolean; + children?: (props: WizardStepRenderProps) => ReactNode; } function FormWizardSteps({ clickable, className, + children, ...props }: FormWizardStepsProps) { const { steps, stepIndex, goToStep } = useFormWizardContext(); @@ -66,22 +82,41 @@ function FormWizardSteps({ return ( {steps.map((step, index) => { + const state = + index < stepIndex + ? "completed" + : index === stepIndex + ? "active" + : "inactive"; + const goTo = () => goToStep(step.id); const canNavigate = clickable && index < stepIndex; - const navProps = canNavigate - ? { onClick: () => goToStep(step.id) } - : {}; + const navProps = canNavigate ? { onClick: goTo } : {}; return ( - - - - {step.title} - {step.description ? ( - {step.description} - ) : null} - - - {index < steps.length - 1 ? : null} + {children ? ( + children({ + step, + index, + state, + isCurrent: state === "active", + goTo, + }) + ) : ( + <> + + + + {step.title} + {step.description ? ( + + {step.description} + + ) : null} + + + {index < steps.length - 1 ? : null} + + )} ); })} @@ -114,4 +149,9 @@ function FormWizardStep({ } export { FormWizard, FormWizardStep, FormWizardSteps, useFormWizardContext }; -export type { FormWizardProps, FormWizardStepProps, FormWizardStepsProps }; +export type { + FormWizardProps, + FormWizardStepProps, + FormWizardStepsProps, + WizardStepRenderProps, +}; diff --git a/apps/apollo-vertex/registry/form-wizard/index.ts b/apps/apollo-vertex/registry/form-wizard/index.ts index 9052b2356..ffc2dc76e 100644 --- a/apps/apollo-vertex/registry/form-wizard/index.ts +++ b/apps/apollo-vertex/registry/form-wizard/index.ts @@ -6,8 +6,15 @@ export { FormWizardSteps, type FormWizardStepsProps, useFormWizardContext, + type WizardStepRenderProps, } from "./form-wizard"; -export { FormWizardNav, type FormWizardNavProps } from "./form-wizard-nav"; +export { + FormWizardBackButton, + type FormWizardButtonProps, + FormWizardNav, + type FormWizardNavProps, + FormWizardNextButton, +} from "./form-wizard-nav"; export { useFormWizard, type UseFormWizardOptions, From ba3f3410612eaeea07cc8aa59d1aa0a6faf0400c Mon Sep 17 00:00:00 2001 From: frankkluijtmans Date: Fri, 17 Jul 2026 15:17:23 +0200 Subject: [PATCH 5/5] docs(apollo-vertex): document form wizard design customization --- .../form-wizard/form-wizard-headless-demo.tsx | 191 ++++++++++++++++++ .../app/components/form-wizard/page.mdx | 33 +++ 2 files changed, 224 insertions(+) create mode 100644 apps/apollo-vertex/app/components/form-wizard/form-wizard-headless-demo.tsx diff --git a/apps/apollo-vertex/app/components/form-wizard/form-wizard-headless-demo.tsx b/apps/apollo-vertex/app/components/form-wizard/form-wizard-headless-demo.tsx new file mode 100644 index 000000000..2127ade89 --- /dev/null +++ b/apps/apollo-vertex/app/components/form-wizard/form-wizard-headless-demo.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { formOptions } from "@tanstack/react-form"; +import { type ReactNode, useState } from "react"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { FieldGroup } from "@/components/ui/field"; +import { withForm } from "@/components/ui/form"; +import { + FormWizard, + FormWizardStep, + useFormWizard, + useFormWizardContext, + type WizardStepDef, +} from "@/components/ui/form-wizard"; +import { LocaleProvider } from "@/registry/shell/shell-locale-provider"; + +const workspaceSchema = z.object({ + name: z.string().min(2, "Enter a workspace name."), + region: z.string().min(1, "Select a region."), +}); +const inviteSchema = z.object({ emails: z.string() }); + +const schema = z.object({ workspace: workspaceSchema, invite: inviteSchema }); +type Values = z.infer; + +const defaultValues = { + workspace: { name: "", region: "" }, + invite: { emails: "" }, +}; +const opts = formOptions({ defaultValues }); + +const regionOptions = [ + { label: "Europe", value: "eu" }, + { label: "United States", value: "us" }, + { label: "Asia Pacific", value: "apac" }, +]; + +const steps: WizardStepDef[] = [ + { id: "workspace", title: "Workspace" }, + { id: "invite", title: "Invite" }, +]; + +function CustomHeader() { + const { stepIndex, stepCount, currentStep, progress } = + useFormWizardContext(); + return ( +
      +
      + {currentStep?.title} + + Step {stepIndex + 1} of {stepCount} + +
      +
      +
      +
      +
      + ); +} + +function StepChrome({ children }: { children: ReactNode }) { + const { isFirst, isLast, back } = useFormWizardContext(); + return ( + + {children} +
      + {isFirst ? null : ( + + )} + +
      +
      + ); +} + +const WorkspaceStep = withForm({ + ...opts, + render: function Render({ form }) { + const { next } = useFormWizardContext(); + return ( + next()} + > + {(group) => ( +
      { + event.preventDefault(); + void group.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + +
      + )} +
      + ); + }, +}); + +const InviteStep = withForm({ + ...opts, + render: function Render({ form }) { + const { next } = useFormWizardContext(); + return ( + next()} + > + {(group) => ( +
      { + event.preventDefault(); + void group.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + +
      + )} +
      + ); + }, +}); + +export function FormWizardHeadlessDemo() { + const [submitted, setSubmitted] = useState(null); + + const wizard = useFormWizard({ + formOptions: opts, + schema, + steps, + onSubmit: (values) => setSubmitted(values), + }); + + return ( + + + + + + + + + + + + {submitted ? ( +
      +          {JSON.stringify(submitted, null, 2)}
      +        
      + ) : null} +
      + ); +} diff --git a/apps/apollo-vertex/app/components/form-wizard/page.mdx b/apps/apollo-vertex/app/components/form-wizard/page.mdx index c1a1c6c9e..4256223b0 100644 --- a/apps/apollo-vertex/app/components/form-wizard/page.mdx +++ b/apps/apollo-vertex/app/components/form-wizard/page.mdx @@ -1,4 +1,5 @@ import { FormWizardDemo } from './form-wizard-demo'; +import { FormWizardHeadlessDemo } from './form-wizard-headless-demo'; # Form Wizard @@ -137,3 +138,35 @@ Pass `persist: { key }` to save the values and current step to `localStorage`, b ## Validation Advancing runs the current step's `FormGroup` schema against its slice; errors render inline on that step's fields. The final `next()` calls `form.handleSubmit()`, which validates the full `onDynamic` schema as a backstop. + +## Customizing the design + +The chrome is composable at three levels, from smallest change to full control. + +### Restyle in place + +Every part carries a `data-slot` attribute and merges an incoming `className`, so most theming needs only Tailwind classes. Because the registry copies the source into your app, you also own it outright. + +### Customize the Stepper and nav + +`FormWizardSteps` accepts a render prop that keeps the step and state wiring while letting you own each node's markup: + +```tsx + + {({ step, index, state, goTo }) => ( + + )} + +``` + +`FormWizardNav` composes `FormWizardBackButton` and `FormWizardNextButton`; use those parts directly to reorder, relabel, or add actions. `FormWizardNextButton` stays a submit button, so it advances the enclosing step's `FormGroup`. + +### Bring your own chrome + +For full control, read everything from `useFormWizardContext()` (`stepIndex`, `stepCount`, `currentStep`, `isFirst`, `isLast`, `progress`, `next`, `back`, `goToStep`, `reset`, `form`) and render your own header and buttons. The step logic, validation, and persistence stay intact. The demo below uses a custom progress bar and custom buttons with no Stepper. + +
      + +