From 0d919c4fce4e07fdf1f4d1390c67bf0609d08e49 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 5 Aug 2025 15:09:29 +0200 Subject: [PATCH] hooks/stepper: add generic stepper hook --- pkgs/clan-app/ui/src/hooks/stepper.tsx | 120 +++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 pkgs/clan-app/ui/src/hooks/stepper.tsx diff --git a/pkgs/clan-app/ui/src/hooks/stepper.tsx b/pkgs/clan-app/ui/src/hooks/stepper.tsx new file mode 100644 index 000000000..b7de50845 --- /dev/null +++ b/pkgs/clan-app/ui/src/hooks/stepper.tsx @@ -0,0 +1,120 @@ +import { + Accessor, + createContext, + createSignal, + JSX, + Setter, + useContext, +} from "solid-js"; + +export interface StepBase { + id: string; +} + +export type Step = StepBase & ExtraFields; + +export interface StepOptions { + initialStep: Id; +} + +export function createStepper< + T extends readonly Step[], + StepId extends T[number]["id"], + Extra = unknown, +>(s: { steps: T }, stepOpts: StepOptions): StepperReturn { + const [activeStep, setActiveStep] = createSignal( + stepOpts.initialStep, + ); + + /** + * Hooks to manage the current step in the workflow. + * It provides the active step and a function to set the active step. + */ + return { + activeStep, + setActiveStep, + currentStep: () => { + const curr = s.steps.find((step) => step.id === activeStep()); + if (!curr) { + throw new Error(`Step with id ${activeStep()} not found`); + } + return curr; + }, + next: () => { + const currentIndex = s.steps.findIndex( + (step) => step.id === activeStep(), + ); + if (currentIndex === -1 || currentIndex === s.steps.length - 1) { + throw new Error("No next step available"); + } + setActiveStep(s.steps[currentIndex + 1].id); + }, + previous: () => { + const currentIndex = s.steps.findIndex( + (step) => step.id === activeStep(), + ); + if (currentIndex <= 0) { + throw new Error("No previous step available"); + } + setActiveStep(s.steps[currentIndex - 1].id); + }, + hasPrevious: () => { + const currentIndex = s.steps.findIndex( + (step) => step.id === activeStep(), + ); + return currentIndex > 0; + }, + hasNext: () => { + const currentIndex = s.steps.findIndex( + (step) => step.id === activeStep(), + ); + return currentIndex >= 0 && currentIndex < s.steps.length - 1; + }, + }; +} + +export interface StepperReturn< + T extends readonly Step[], + StepId = T[number]["id"], +> { + activeStep: Accessor; + setActiveStep: Setter; + currentStep: () => T[number]; + next: () => void; + previous: () => void; + hasPrevious: () => boolean; + hasNext: () => boolean; +} + +const StepperContext = createContext(); // Concrete type will be provided by the provider + +// Default assignment to "never" forces users to specify the type when using the hook, otherwise the return type will be `never`. +export function useStepper() { + const ctx = useContext(StepperContext); + if (!ctx) throw new Error("useStepper must be used inside StepperProvider"); + return ctx as T extends never ? never : StepperReturn; // type casting required due to context limitations +} + +interface ProviderProps { + stepper: StepperReturn; + children: JSX.Element; +} + +interface ProviderProps< + T extends readonly Step[], + StepId extends T[number]["id"], +> { + stepper: StepperReturn; + children: JSX.Element; +} + +export function StepperProvider< + T extends readonly Step[], + StepId extends T[number]["id"], +>(props: ProviderProps) { + return ( + + {props.children} + + ); +}