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:
parent
f270ef64af
commit
92435b2667
12
packages/core/src/components/wizard/index.ts
Normal file
12
packages/core/src/components/wizard/index.ts
Normal 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 });
|
||||
56
packages/core/src/components/wizard/wizard-context.ts
Normal file
56
packages/core/src/components/wizard/wizard-context.ts
Normal 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;
|
||||
30
packages/core/src/components/wizard/wizard-next.tsx
Normal file
30
packages/core/src/components/wizard/wizard-next.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
packages/core/src/components/wizard/wizard-prev.tsx
Normal file
30
packages/core/src/components/wizard/wizard-prev.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
packages/core/src/components/wizard/wizard-root.tsx
Normal file
103
packages/core/src/components/wizard/wizard-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
packages/core/src/components/wizard/wizard-step-content.tsx
Normal file
26
packages/core/src/components/wizard/wizard-step-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
packages/core/src/components/wizard/wizard-step-list.tsx
Normal file
21
packages/core/src/components/wizard/wizard-step-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
packages/core/src/components/wizard/wizard-step-trigger.tsx
Normal file
36
packages/core/src/components/wizard/wizard-step-trigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
packages/core/src/components/wizard/wizard-step.tsx
Normal file
47
packages/core/src/components/wizard/wizard-step.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
packages/core/src/components/wizard/wizard.props.ts
Normal file
36
packages/core/src/components/wizard/wizard.props.ts
Normal 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;
|
||||
150
packages/core/tests/components/wizard/wizard.test.tsx
Normal file
150
packages/core/tests/components/wizard/wizard.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user