From 92435b266762ffe3695bd0b86d1ca836c19e71d7 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 21:15:55 +0700 Subject: [PATCH] Wizard component 17 tests passing. Implements WizardRoot, WizardStep, WizardStepList, WizardStepTrigger, WizardStepContent, WizardPrev, WizardNext with createControllableSignal step state, completedSteps Set, registerStep/unregisterStep registration, linear guard on triggers, onComplete callback, and Object.assign compound export. --- packages/core/src/components/wizard/index.ts | 12 ++ .../src/components/wizard/wizard-context.ts | 56 +++++++ .../src/components/wizard/wizard-next.tsx | 30 ++++ .../src/components/wizard/wizard-prev.tsx | 30 ++++ .../src/components/wizard/wizard-root.tsx | 103 ++++++++++++ .../components/wizard/wizard-step-content.tsx | 26 +++ .../components/wizard/wizard-step-list.tsx | 21 +++ .../components/wizard/wizard-step-trigger.tsx | 36 +++++ .../src/components/wizard/wizard-step.tsx | 47 ++++++ .../src/components/wizard/wizard.props.ts | 36 +++++ .../tests/components/wizard/wizard.test.tsx | 150 ++++++++++++++++++ 11 files changed, 547 insertions(+) create mode 100644 packages/core/src/components/wizard/index.ts create mode 100644 packages/core/src/components/wizard/wizard-context.ts create mode 100644 packages/core/src/components/wizard/wizard-next.tsx create mode 100644 packages/core/src/components/wizard/wizard-prev.tsx create mode 100644 packages/core/src/components/wizard/wizard-root.tsx create mode 100644 packages/core/src/components/wizard/wizard-step-content.tsx create mode 100644 packages/core/src/components/wizard/wizard-step-list.tsx create mode 100644 packages/core/src/components/wizard/wizard-step-trigger.tsx create mode 100644 packages/core/src/components/wizard/wizard-step.tsx create mode 100644 packages/core/src/components/wizard/wizard.props.ts create mode 100644 packages/core/tests/components/wizard/wizard.test.tsx diff --git a/packages/core/src/components/wizard/index.ts b/packages/core/src/components/wizard/index.ts new file mode 100644 index 0000000..01bda1f --- /dev/null +++ b/packages/core/src/components/wizard/index.ts @@ -0,0 +1,12 @@ +import { useWizardContext, useWizardStepContext } from "./wizard-context"; +import { WizardNext } from "./wizard-next"; +import { WizardPrev } from "./wizard-prev"; +import { WizardRoot } from "./wizard-root"; +import { WizardStep } from "./wizard-step"; +import { WizardStepContent } from "./wizard-step-content"; +import { WizardStepList } from "./wizard-step-list"; +import { WizardStepTrigger } from "./wizard-step-trigger"; +export { WizardRootPropsSchema, WizardStepPropsSchema, WizardMeta } from "./wizard.props"; +export type { WizardRootProps, WizardStepProps, WizardStepListProps, WizardStepTriggerProps, WizardStepContentProps, WizardPrevProps, WizardNextProps } from "./wizard.props"; +export type { WizardContextValue, WizardStepContextValue } from "./wizard-context"; +export const Wizard = Object.assign(WizardRoot, { Step: WizardStep, StepList: WizardStepList, StepTrigger: WizardStepTrigger, StepContent: WizardStepContent, Prev: WizardPrev, Next: WizardNext, useContext: useWizardContext, useStepContext: useWizardStepContext }); diff --git a/packages/core/src/components/wizard/wizard-context.ts b/packages/core/src/components/wizard/wizard-context.ts new file mode 100644 index 0000000..e7052ab --- /dev/null +++ b/packages/core/src/components/wizard/wizard-context.ts @@ -0,0 +1,56 @@ +import type { Accessor } from "solid-js"; +import { createContext, useContext } from "solid-js"; + +/** Shared state provided by WizardRoot to all descendant wizard parts. */ +export interface WizardContextValue { + currentStep: Accessor; + completedSteps: Accessor>; + totalSteps: Accessor; + linear: Accessor; + orientation: Accessor<"horizontal" | "vertical">; + registerStep: () => number; + unregisterStep: () => void; + goToStep: (index: number) => void; + goToNext: () => void; + goToPrev: () => void; + isStepReachable: (index: number) => boolean; + baseId: string; +} + +/** Per-step state provided by WizardStep to its trigger, content, etc. */ +export interface WizardStepContextValue { + index: number; + isActive: Accessor; + isCompleted: Accessor; + isDisabled: Accessor; + triggerId: string; + contentId: string; +} + +const WizardContext = createContext(); +const WizardStepContext = createContext(); + +/** Returns the Wizard root context. Throws if used outside . */ +export function useWizardContext(): WizardContextValue { + const ctx = useContext(WizardContext); + if (!ctx) { + throw new Error( + "[PettyUI] Wizard parts must be used within . Fix: Wrap Wizard.Step, Wizard.StepList, and Wizard.StepContent inside .", + ); + } + return ctx; +} + +/** Returns the WizardStep context. Throws if used outside . */ +export function useWizardStepContext(): WizardStepContextValue { + const ctx = useContext(WizardStepContext); + if (!ctx) { + throw new Error( + "[PettyUI] Wizard step parts must be used within . Fix: Wrap Wizard.StepTrigger and Wizard.StepContent inside .", + ); + } + return ctx; +} + +export const WizardContextProvider = WizardContext.Provider; +export const WizardStepContextProvider = WizardStepContext.Provider; diff --git a/packages/core/src/components/wizard/wizard-next.tsx b/packages/core/src/components/wizard/wizard-next.tsx new file mode 100644 index 0000000..f2a48b8 --- /dev/null +++ b/packages/core/src/components/wizard/wizard-next.tsx @@ -0,0 +1,30 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useWizardContext } from "./wizard-context"; +import type { WizardNextProps } from "./wizard.props"; + +/** Button that marks the current step complete and advances to the next step. */ +export function WizardNext(props: WizardNextProps): JSX.Element { + const [local, rest] = splitProps(props, ["children", "onClick", "disabled"]); + const ctx = useWizardContext(); + + const isLast = () => ctx.currentStep() === ctx.totalSteps() - 1; + const isDisabled = () => local.disabled ?? false; + + const handleClick: JSX.EventHandler = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!isDisabled()) ctx.goToNext(); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/wizard/wizard-prev.tsx b/packages/core/src/components/wizard/wizard-prev.tsx new file mode 100644 index 0000000..6503a18 --- /dev/null +++ b/packages/core/src/components/wizard/wizard-prev.tsx @@ -0,0 +1,30 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useWizardContext } from "./wizard-context"; +import type { WizardPrevProps } from "./wizard.props"; + +/** Button that navigates to the previous step. Disabled on the first step. */ +export function WizardPrev(props: WizardPrevProps): JSX.Element { + const [local, rest] = splitProps(props, ["children", "onClick", "disabled"]); + const ctx = useWizardContext(); + + const isFirst = () => ctx.currentStep() === 0; + const isDisabled = () => local.disabled ?? isFirst(); + + const handleClick: JSX.EventHandler = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!isDisabled()) ctx.goToPrev(); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/wizard/wizard-root.tsx b/packages/core/src/components/wizard/wizard-root.tsx new file mode 100644 index 0000000..ab0da24 --- /dev/null +++ b/packages/core/src/components/wizard/wizard-root.tsx @@ -0,0 +1,103 @@ +import type { JSX } from "solid-js"; +import { createSignal, createUniqueId, splitProps } from "solid-js"; +import { createControllableSignal } from "../../primitives/create-controllable-signal"; +import { WizardContextProvider, type WizardContextValue } from "./wizard-context"; +import type { WizardRootProps } from "./wizard.props"; + +/** Root container for the Wizard component. Manages step state and progression. */ +export function WizardRoot(props: WizardRootProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "step", + "defaultStep", + "linear", + "orientation", + "onStepChange", + "onComplete", + "children", + ]); + + const baseId = createUniqueId(); + const [completedSteps, setCompletedSteps] = createSignal>(new Set()); + const [stepCount, setStepCount] = createSignal(0); + + const [currentStep, setCurrentStep] = createControllableSignal({ + value: () => local.step, + defaultValue: () => local.defaultStep ?? 0, + onChange: (v) => local.onStepChange?.(v), + }); + + const isLinear = () => local.linear ?? true; + + /** Registers a step and returns its index. Called by WizardStep on mount. */ + function registerStep(): number { + const index = stepCount(); + setStepCount((n) => n + 1); + return index; + } + + /** Unregisters a step. Called by WizardStep on cleanup. */ + function unregisterStep(): void { + setStepCount((n) => Math.max(0, n - 1)); + } + + /** Returns true if the given step index is reachable in the current mode. */ + function isStepReachable(index: number): boolean { + if (!isLinear()) return true; + if (index === 0) return true; + const completed = completedSteps(); + for (let i = 0; i < index; i++) { + if (!completed.has(i)) return false; + } + return true; + } + + /** Navigate to a specific step if reachable. */ + function goToStep(index: number): void { + if (isStepReachable(index)) setCurrentStep(index); + } + + /** Mark current step complete and advance. Calls onComplete on last step. */ + function goToNext(): void { + const current = currentStep(); + const total = stepCount(); + setCompletedSteps((prev) => { + const next = new Set(prev); + next.add(current); + return next; + }); + if (current >= total - 1) { + local.onComplete?.(); + return; + } + setCurrentStep(current + 1); + } + + /** Navigate to the previous step (always allowed). */ + function goToPrev(): void { + const current = currentStep(); + if (current > 0) setCurrentStep(current - 1); + } + + const ctx: WizardContextValue = { + currentStep, + completedSteps, + totalSteps: stepCount, + linear: isLinear, + orientation: () => local.orientation ?? "horizontal", + registerStep, + unregisterStep, + goToStep, + goToNext, + goToPrev, + isStepReachable, + baseId, + }; + + return ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/wizard/wizard-step-content.tsx b/packages/core/src/components/wizard/wizard-step-content.tsx new file mode 100644 index 0000000..c987976 --- /dev/null +++ b/packages/core/src/components/wizard/wizard-step-content.tsx @@ -0,0 +1,26 @@ +import type { JSX } from "solid-js"; +import { Show, splitProps } from "solid-js"; +import { useWizardStepContext } from "./wizard-context"; +import type { WizardStepContentProps } from "./wizard.props"; + +/** Step content panel. Only rendered when its step is active. */ +export function WizardStepContent(props: WizardStepContentProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const step = useWizardStepContext(); + + return ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/wizard/wizard-step-list.tsx b/packages/core/src/components/wizard/wizard-step-list.tsx new file mode 100644 index 0000000..0bb315d --- /dev/null +++ b/packages/core/src/components/wizard/wizard-step-list.tsx @@ -0,0 +1,21 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useWizardContext } from "./wizard-context"; +import type { WizardStepListProps } from "./wizard.props"; + +/** Step indicator bar. Renders the ordered list of step triggers. */ +export function WizardStepList(props: WizardStepListProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const ctx = useWizardContext(); + + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/wizard/wizard-step-trigger.tsx b/packages/core/src/components/wizard/wizard-step-trigger.tsx new file mode 100644 index 0000000..eaae320 --- /dev/null +++ b/packages/core/src/components/wizard/wizard-step-trigger.tsx @@ -0,0 +1,36 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useWizardContext, useWizardStepContext } from "./wizard-context"; +import type { WizardStepTriggerProps } from "./wizard.props"; + +/** Clickable step indicator. Navigates to the step when clicked (if reachable). */ +export function WizardStepTrigger(props: WizardStepTriggerProps): JSX.Element { + const [local, rest] = splitProps(props, ["children", "onClick", "disabled"]); + const ctx = useWizardContext(); + const step = useWizardStepContext(); + + const isReachable = () => ctx.isStepReachable(step.index); + const isDisabled = () => (local.disabled ?? step.isDisabled()) || !isReachable(); + + const handleClick: JSX.EventHandler = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!isDisabled()) ctx.goToStep(step.index); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/wizard/wizard-step.tsx b/packages/core/src/components/wizard/wizard-step.tsx new file mode 100644 index 0000000..08ea4be --- /dev/null +++ b/packages/core/src/components/wizard/wizard-step.tsx @@ -0,0 +1,47 @@ +import type { JSX } from "solid-js"; +import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js"; +import { useWizardContext, WizardStepContextProvider, type WizardStepContextValue } from "./wizard-context"; +import type { WizardStepProps } from "./wizard.props"; + +/** A single step container. Provides per-step context to its children. */ +export function WizardStep(props: WizardStepProps): JSX.Element { + const [local, rest] = splitProps(props, ["index", "disabled", "completed", "children"]); + const ctx = useWizardContext(); + + const triggerId = `${ctx.baseId}-trigger-${local.index}`; + const contentId = `${ctx.baseId}-content-${local.index}`; + + const isActive = () => ctx.currentStep() === local.index; + const isCompleted = () => local.completed ?? ctx.completedSteps().has(local.index); + const isDisabled = () => local.disabled ?? false; + + const dataState = () => { + if (isActive()) return "active"; + if (isCompleted()) return "completed"; + return "upcoming"; + }; + + onMount(() => { ctx.registerStep(); }); + onCleanup(() => { ctx.unregisterStep(); }); + + const stepCtx: WizardStepContextValue = { + index: local.index, + isActive, + isCompleted, + isDisabled, + triggerId, + contentId, + }; + + return ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/wizard/wizard.props.ts b/packages/core/src/components/wizard/wizard.props.ts new file mode 100644 index 0000000..54b7992 --- /dev/null +++ b/packages/core/src/components/wizard/wizard.props.ts @@ -0,0 +1,36 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const WizardRootPropsSchema = z.object({ + step: z.number().optional().describe("Controlled current step index (0-based)"), + defaultStep: z.number().optional().describe("Initial step (uncontrolled). Defaults to 0"), + linear: z.boolean().optional().describe("Whether steps must be completed in order. Defaults to true"), + orientation: z.enum(["horizontal", "vertical"]).optional().describe("Step indicator layout. Defaults to 'horizontal'"), +}); + +export interface WizardRootProps extends z.infer { + onStepChange?: (step: number) => void; + onComplete?: () => void; + children: JSX.Element; +} + +export const WizardStepPropsSchema = z.object({ + index: z.number().describe("Step index (0-based)"), + disabled: z.boolean().optional().describe("Whether this step is disabled"), + completed: z.boolean().optional().describe("Whether this step is completed"), +}); + +export interface WizardStepProps extends z.infer { children: JSX.Element; } +export interface WizardStepListProps extends JSX.HTMLAttributes { children: JSX.Element; } +export interface WizardStepTriggerProps extends JSX.ButtonHTMLAttributes { children?: JSX.Element; } +export interface WizardStepContentProps extends JSX.HTMLAttributes { children?: JSX.Element; } +export interface WizardPrevProps extends JSX.ButtonHTMLAttributes { children?: JSX.Element; } +export interface WizardNextProps extends JSX.ButtonHTMLAttributes { children?: JSX.Element; } + +export const WizardMeta: ComponentMeta = { + name: "Wizard", + description: "Multi-step flow with step indicators, navigation, and linear/non-linear progression", + parts: ["Root", "StepList", "Step", "StepTrigger", "StepContent", "PrevButton", "NextButton"] as const, + requiredParts: ["Root", "Step", "StepContent"] as const, +} as const; diff --git a/packages/core/tests/components/wizard/wizard.test.tsx b/packages/core/tests/components/wizard/wizard.test.tsx new file mode 100644 index 0000000..0beb476 --- /dev/null +++ b/packages/core/tests/components/wizard/wizard.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent, render, screen } from "@solidjs/testing-library"; +import type { JSX } from "solid-js"; +import { describe, expect, it, vi } from "vitest"; +import { Wizard, WizardMeta, WizardRootPropsSchema } from "../../../src/components/wizard/index"; +import type { WizardRootProps } from "../../../src/components/wizard/index"; + +interface ThreeStepWizardProps extends Omit {} + +function ThreeStepWizard(props: ThreeStepWizardProps): JSX.Element { + return ( + + + + Step 1 + Content 0 + + + Step 2 + Content 1 + + + Step 3 + Content 2 + + + Prev + Next + + ); +} + +function renderWizard(props: ThreeStepWizardProps = {}) { + return render(() => ); +} + +describe("Wizard — rendering", () => { + it("renders first step content by default", () => { + renderWizard(); + expect(screen.getByTestId("content-0")).toBeTruthy(); + expect(screen.queryByTestId("content-1")).toBeNull(); + expect(screen.queryByTestId("content-2")).toBeNull(); + }); + + it("first trigger has aria-selected=true", () => { + renderWizard(); + expect(screen.getByTestId("trigger-0").getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("trigger-1").getAttribute("aria-selected")).toBe("false"); + }); + + it("step list has role=tablist", () => { + renderWizard(); + expect(screen.getByRole("tablist")).toBeTruthy(); + }); + + it("active content has role=tabpanel", () => { + renderWizard(); + expect(screen.getByRole("tabpanel")).toBeTruthy(); + }); +}); + +describe("Wizard — navigation", () => { + it("next button advances to step 1", () => { + renderWizard(); + fireEvent.click(screen.getByTestId("next")); + expect(screen.getByTestId("content-1")).toBeTruthy(); + expect(screen.queryByTestId("content-0")).toBeNull(); + }); + + it("prev button goes back", () => { + renderWizard(); + fireEvent.click(screen.getByTestId("next")); + fireEvent.click(screen.getByTestId("prev")); + expect(screen.getByTestId("content-0")).toBeTruthy(); + }); + + it("prev button not disabled after advancing", () => { + renderWizard(); + fireEvent.click(screen.getByTestId("next")); + expect(screen.getByTestId("prev")).not.toBeDisabled(); + }); +}); + +describe("Wizard — linear mode", () => { + it("unreached step triggers are disabled", () => { + renderWizard(); + expect(screen.getByTestId("trigger-1")).toBeDisabled(); + expect(screen.getByTestId("trigger-2")).toBeDisabled(); + }); + + it("clicking disabled trigger does not navigate", () => { + renderWizard(); + fireEvent.click(screen.getByTestId("trigger-2")); + expect(screen.getByTestId("content-0")).toBeTruthy(); + }); + + it("trigger becomes enabled after completing prior step", () => { + renderWizard(); + fireEvent.click(screen.getByTestId("next")); + expect(screen.getByTestId("trigger-1")).not.toBeDisabled(); + }); + + it("non-linear: all triggers enabled from start", () => { + renderWizard({ linear: false }); + expect(screen.getByTestId("trigger-2")).not.toBeDisabled(); + }); +}); + +describe("Wizard — callbacks", () => { + it("onStepChange called on advance", () => { + const onStepChange = vi.fn(); + renderWizard({ onStepChange }); + fireEvent.click(screen.getByTestId("next")); + expect(onStepChange).toHaveBeenCalledWith(1); + }); + + it("onStepChange called on prev", () => { + const onStepChange = vi.fn(); + renderWizard({ onStepChange }); + fireEvent.click(screen.getByTestId("next")); + fireEvent.click(screen.getByTestId("prev")); + expect(onStepChange).toHaveBeenCalledWith(0); + }); + + it("onComplete called after last step", () => { + const onComplete = vi.fn(); + renderWizard({ onComplete }); + fireEvent.click(screen.getByTestId("next")); + fireEvent.click(screen.getByTestId("next")); + fireEvent.click(screen.getByTestId("next")); + expect(onComplete).toHaveBeenCalledTimes(1); + }); +}); + +describe("Wizard — schema and meta", () => { + it("schema validates correct input", () => { + const result = WizardRootPropsSchema.safeParse({ step: 1, linear: true, orientation: "horizontal" }); + expect(result.success).toBe(true); + }); + + it("schema rejects invalid orientation", () => { + const result = WizardRootPropsSchema.safeParse({ orientation: "diagonal" }); + expect(result.success).toBe(false); + }); + + it("meta has name and required parts", () => { + expect(WizardMeta.name).toBe("Wizard"); + expect(WizardMeta.parts).toContain("Root"); + expect(WizardMeta.parts).toContain("StepContent"); + }); +});