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.
This commit is contained in:
Mats Bosson 2026-03-29 21:15:55 +07:00
parent f270ef64af
commit 92435b2667
11 changed files with 547 additions and 0 deletions

View File

@ -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 });

View File

@ -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<number>;
completedSteps: Accessor<Set<number>>;
totalSteps: Accessor<number>;
linear: Accessor<boolean>;
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<boolean>;
isCompleted: Accessor<boolean>;
isDisabled: Accessor<boolean>;
triggerId: string;
contentId: string;
}
const WizardContext = createContext<WizardContextValue>();
const WizardStepContext = createContext<WizardStepContextValue>();
/** Returns the Wizard root context. Throws if used outside <Wizard>. */
export function useWizardContext(): WizardContextValue {
const ctx = useContext(WizardContext);
if (!ctx) {
throw new Error(
"[PettyUI] Wizard parts must be used within <Wizard>. Fix: Wrap Wizard.Step, Wizard.StepList, and Wizard.StepContent inside <Wizard>.",
);
}
return ctx;
}
/** Returns the WizardStep context. Throws if used outside <Wizard.Step>. */
export function useWizardStepContext(): WizardStepContextValue {
const ctx = useContext(WizardStepContext);
if (!ctx) {
throw new Error(
"[PettyUI] Wizard step parts must be used within <Wizard.Step>. Fix: Wrap Wizard.StepTrigger and Wizard.StepContent inside <Wizard.Step>.",
);
}
return ctx;
}
export const WizardContextProvider = WizardContext.Provider;
export const WizardStepContextProvider = WizardStepContext.Provider;

View File

@ -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<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
if (!isDisabled()) ctx.goToNext();
};
return (
<button
type="button"
disabled={isDisabled() || undefined}
data-state={isLast() ? "last" : "default"}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -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<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
if (!isDisabled()) ctx.goToPrev();
};
return (
<button
type="button"
disabled={isDisabled() || undefined}
data-state={isFirst() ? "first" : "default"}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -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<Set<number>>(new Set());
const [stepCount, setStepCount] = createSignal(0);
const [currentStep, setCurrentStep] = createControllableSignal<number>({
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 (
<WizardContextProvider value={ctx}>
<div data-orientation={local.orientation ?? "horizontal"} {...rest}>
{local.children}
</div>
</WizardContextProvider>
);
}

View File

@ -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 (
<Show when={step.isActive()}>
<div
role="tabpanel"
id={step.contentId}
aria-labelledby={step.triggerId}
data-state="active"
// biome-ignore lint/a11y/noNoninteractiveTabindex: tabpanel requires tabIndex per WAI-ARIA spec
tabIndex={0}
{...rest}
>
{local.children}
</div>
</Show>
);
}

View File

@ -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 (
<div
role="tablist"
aria-orientation={ctx.orientation()}
data-orientation={ctx.orientation()}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -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<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
if (!isDisabled()) ctx.goToStep(step.index);
};
return (
<button
type="button"
role="tab"
id={step.triggerId}
aria-selected={step.isActive() ? "true" : "false"}
aria-controls={step.contentId}
data-state={step.isActive() ? "active" : step.isCompleted() ? "completed" : "upcoming"}
tabIndex={step.isActive() ? 0 : -1}
disabled={isDisabled() || undefined}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -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 (
<WizardStepContextProvider value={stepCtx}>
<div
data-state={dataState()}
data-disabled={isDisabled() || undefined}
{...rest}
>
{local.children}
</div>
</WizardStepContextProvider>
);
}

View File

@ -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<typeof WizardRootPropsSchema> {
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<typeof WizardStepPropsSchema> { children: JSX.Element; }
export interface WizardStepListProps extends JSX.HTMLAttributes<HTMLDivElement> { children: JSX.Element; }
export interface WizardStepTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
export interface WizardStepContentProps extends JSX.HTMLAttributes<HTMLDivElement> { children?: JSX.Element; }
export interface WizardPrevProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
export interface WizardNextProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { 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;

View File

@ -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<WizardRootProps, "children"> {}
function ThreeStepWizard(props: ThreeStepWizardProps): JSX.Element {
return (
<Wizard {...props}>
<Wizard.StepList>
<Wizard.Step index={0}>
<Wizard.StepTrigger data-testid="trigger-0">Step 1</Wizard.StepTrigger>
<Wizard.StepContent data-testid="content-0">Content 0</Wizard.StepContent>
</Wizard.Step>
<Wizard.Step index={1}>
<Wizard.StepTrigger data-testid="trigger-1">Step 2</Wizard.StepTrigger>
<Wizard.StepContent data-testid="content-1">Content 1</Wizard.StepContent>
</Wizard.Step>
<Wizard.Step index={2}>
<Wizard.StepTrigger data-testid="trigger-2">Step 3</Wizard.StepTrigger>
<Wizard.StepContent data-testid="content-2">Content 2</Wizard.StepContent>
</Wizard.Step>
</Wizard.StepList>
<Wizard.Prev data-testid="prev">Prev</Wizard.Prev>
<Wizard.Next data-testid="next">Next</Wizard.Next>
</Wizard>
);
}
function renderWizard(props: ThreeStepWizardProps = {}) {
return render(() => <ThreeStepWizard {...props} />);
}
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");
});
});