Skip to content

Wizard

Wizard

"""
Wizard/stepper layout example using panel-material-ui.

Multi-step wizard with one action per page, driven by a `pmui.StepperMenu`
that shows live step state (completed / error / active), supports non-linear
navigation, and reports position via its `active` parameter. Also demonstrates
the Viewer class pattern, `pn.pane.Placeholder` step swapping, `pn.io.hold()`
batching, shared `disabled` state, inline `pmui.Alert` validation, a
`pmui.Tooltip`, and the `pmui.Page` template.

Run: panel serve examples/wizard.py --dev --show
"""

import panel as pn
import panel_material_ui as pmui
import param

pn.extension(throttled=True)

# Professional theme - refined navy/teal palette
THEME_CONFIG = {
    "light": {
        "palette": {
            "primary": {"main": "#1e3a5f"},  # Deep navy blue
            "secondary": {"main": "#2e7d6f"},  # Teal accent
            "success": {"main": "#2e7d6f"},
        },
        "typography": {
            "fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
            "h4": {"fontWeight": 600, "letterSpacing": "-0.02em"},
            "h5": {"fontWeight": 600, "letterSpacing": "-0.01em"},
            "body1": {"lineHeight": 1.6},
        },
        "shape": {"borderRadius": 12},
        "components": {
            "MuiButton": {
                "styleOverrides": {
                    "root": {"textTransform": "none", "fontWeight": 600},
                },
            },
            "MuiPaper": {
                "styleOverrides": {
                    "root": {"boxShadow": "0 4px 20px rgba(0,0,0,0.08)"},
                },
            },
        },
    },
    "dark": {
        "palette": {
            "primary": {"main": "#5c8fc2"},
            "secondary": {"main": "#4db6a4"},
            "success": {"main": "#4db6a4"},
        },
        "typography": {
            "fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
            "h4": {"fontWeight": 600, "letterSpacing": "-0.02em"},
            "h5": {"fontWeight": 600, "letterSpacing": "-0.01em"},
            "body1": {"lineHeight": 1.6},
        },
        "shape": {"borderRadius": 12},
        "components": {
            "MuiButton": {
                "styleOverrides": {
                    "root": {"textTransform": "none", "fontWeight": 600},
                },
            },
        },
    },
}


class WizardStep(pn.viewable.Viewer):
    """Base class for wizard steps."""

    complete = param.Boolean(
        default=False,
        doc="""
        Whether this step has valid data and can proceed.""",
    )

    disabled = param.Boolean(
        default=False,
        doc="""
        Whether this step's widgets are disabled (e.g., after submit).""",
    )

    icon = param.String(
        default="circle",
        doc="""
        Material icon name for the stepper.""",
    )

    title = param.String(
        doc="""
        Display title for this step."""
    )

    def __panel__(self):
        raise NotImplementedError("Subclasses must implement __panel__")


class FilingStatusStep(WizardStep):
    """Step 1: Filing status selection."""

    complete = param.Boolean(default=True)

    filing_status = param.Selector(
        default="Single",
        objects=[
            "Single",
            "Married filing jointly",
            "Married filing separately",
            "Head of household",
        ],
        doc="""
        Your tax filing status.""",
    )

    icon = param.String(default="person")

    title = param.String(default="Filing Status")

    def __panel__(self):
        return pmui.Column(
            pmui.Typography(
                "What is your filing status?", variant="h4", sx={"fontWeight": 700, "mb": 1}
            ),
            pmui.Typography(
                "This determines your tax brackets and standard deduction.",
                sx={"color": "text.secondary", "mb": 4},
            ),
            pmui.RadioBoxGroup.from_param(
                self.param.filing_status,
                label="",  # Remove redundant label
                options=[
                    "Single",
                    "Married filing jointly",
                    "Married filing separately",
                    "Head of household",
                ],
                disabled=self.param.disabled,
            ),
            sizing_mode="stretch_width",
        )


class IncomeStep(WizardStep):
    """Step 2: Income input."""

    icon = param.String(default="attach_money")

    interest = param.Number(
        default=0,
        bounds=(0, None),
        doc="""
        Interest and dividend income from 1099 forms.""",
    )

    title = param.String(default="Income")

    wages = param.Number(
        default=0,
        bounds=(0, None),
        doc="""
        W-2 wages from Box 1.""",
    )

    @param.depends("wages", "interest", watch=True)
    def _on_income_change(self):
        self.complete = (self.wages + self.interest) > 0

    def __panel__(self):
        return pmui.Column(
            pmui.Typography(
                "What did you earn this year?", variant="h4", sx={"fontWeight": 600, "mb": 1}
            ),
            pmui.Typography(
                "Enter your total income from all sources. "
                "We'll use this to calculate your tax bracket.",
                sx={"color": "text.secondary", "mb": 4},
            ),
            pmui.Column(
                pmui.Typography("W-2 Wages", sx={"fontWeight": 500, "mb": 1}),
                pmui.FloatInput.from_param(
                    self.param.wages,
                    label="$ Wages",
                    sizing_mode="stretch_width",
                    disabled=self.param.disabled,
                ),
                pmui.Typography(
                    "From Box 1 of your W-2 form",
                    sx={"color": "text.secondary", "fontSize": 13, "mt": 0.5},
                ),
                sizing_mode="stretch_width",
                margin=(0, 0, 24, 0),
            ),
            pmui.Column(
                pmui.Typography("Interest & Dividends", sx={"fontWeight": 500, "mb": 1}),
                pmui.FloatInput.from_param(
                    self.param.interest,
                    label="$ Interest",
                    sizing_mode="stretch_width",
                    disabled=self.param.disabled,
                ),
                pmui.Typography(
                    "From 1099-INT and 1099-DIV forms",
                    sx={"color": "text.secondary", "fontSize": 13, "mt": 0.5},
                ),
                sizing_mode="stretch_width",
            ),
            sizing_mode="stretch_width",
        )


class DeductionsStep(WizardStep):
    """Step 3: Deductions."""

    complete = param.Boolean(default=True)

    deduction_type = param.Selector(
        default="Standard",
        objects=["Standard", "Itemized"],
        doc="""
        Standard or itemized deduction.""",
    )

    icon = param.String(default="receipt_long")

    itemized_amount = param.Number(
        default=0,
        bounds=(0, None),
        doc="""
        Total itemized deductions if not using standard.""",
    )

    title = param.String(default="Deductions")

    def __init__(self, **params):
        self._itemized_input = pmui.FloatInput.from_param(
            self.param.itemized_amount,
            label="Itemized Amount ($)",
            sizing_mode="stretch_width",
            disabled=self.param.disabled,
        )
        self._standard_info = pmui.Paper(
            pmui.Typography(
                "Standard deduction: $14,600 (Single) / $29,200 (Married)",
                sx={"color": "text.secondary"},
            ),
            sx={"p": 2, "mt": 2, "backgroundColor": "rgba(0,0,0,0.02)"},
        )
        self._deduction_details = pn.pane.Placeholder(sizing_mode="stretch_width")
        super().__init__(**params)

    @param.depends("deduction_type", watch=True, on_init=True)
    def _update_deduction_details(self):
        if self.deduction_type == "Itemized":
            self._deduction_details.update(self._itemized_input)
        else:
            self._deduction_details.update(self._standard_info)

    def __panel__(self):
        return pmui.Column(
            pmui.Typography("Choose your deduction", variant="h4", sx={"fontWeight": 600, "mb": 1}),
            pmui.Typography(
                "Most filers benefit from the standard deduction.",
                sx={"color": "text.secondary", "mb": 4},
            ),
            pmui.RadioButtonGroup.from_param(
                self.param.deduction_type, disabled=self.param.disabled
            ),
            self._deduction_details,
            sizing_mode="stretch_width",
        )


class ReviewStep(WizardStep):
    """Step 4: Review and submit."""

    complete = param.Boolean(default=True)

    icon = param.String(default="check_circle")

    title = param.String(default="Review")

    def __init__(self, steps, **params):
        super().__init__(**params)
        self._steps = steps

    def __panel__(self):
        filing = self._steps[0]
        income = self._steps[1]
        deductions = self._steps[2]

        total_income = income.wages + income.interest
        deduction_amount = 14600 if filing.filing_status == "Single" else 29200
        if deductions.deduction_type == "Itemized":
            deduction_amount = deductions.itemized_amount
        taxable = max(0, total_income - deduction_amount)

        return pmui.Column(
            pmui.Typography("Review your return", variant="h4", sx={"fontWeight": 600, "mb": 1}),
            pmui.Typography(
                "Please confirm your information before submitting.",
                sx={"color": "text.secondary", "mb": 3},
            ),
            pmui.Paper(
                pmui.Column(
                    pmui.Row(
                        pmui.Typography(
                            "Filing Status", sx={"color": "text.secondary", "minWidth": 140}
                        ),
                        pmui.Typography(
                            filing.filing_status or "Not selected", sx={"fontWeight": 500}
                        ),
                    ),
                    pmui.Row(
                        pmui.Typography(
                            "W-2 Wages", sx={"color": "text.secondary", "minWidth": 140}
                        ),
                        pmui.Typography(f"${income.wages:,.2f}", sx={"fontWeight": 500}),
                    ),
                    pmui.Row(
                        pmui.Typography(
                            "Interest Income", sx={"color": "text.secondary", "minWidth": 140}
                        ),
                        pmui.Typography(f"${income.interest:,.2f}", sx={"fontWeight": 500}),
                    ),
                    pmui.Row(
                        pmui.Typography(
                            "Deduction", sx={"color": "text.secondary", "minWidth": 140}
                        ),
                        pmui.Typography(
                            f"{deductions.deduction_type} (${deduction_amount:,.0f})",
                            sx={"fontWeight": 500},
                        ),
                    ),
                    pn.layout.Divider(margin=(16, 0)),
                    pmui.Row(
                        pmui.Typography("Taxable Income", sx={"fontWeight": 600, "minWidth": 140}),
                        pmui.Typography(
                            f"${taxable:,.2f}", sx={"fontWeight": 600, "color": "primary.main"}
                        ),
                    ),
                    sizing_mode="stretch_width",
                ),
                sx={"p": 3},
            ),
            sizing_mode="stretch_width",
        )


class TaxWizard(pn.viewable.Viewer):
    """TurboTax-style wizard with one action per page, driven by StepperMenu."""

    active_step = param.Integer(
        default=0,
        bounds=(0, None),
        doc="""
        Current step index (0-based).""",
    )

    submitted = param.Boolean(
        default=False,
        doc="""
        Whether the wizard has been submitted.""",
    )

    def __init__(self, **params):
        # Step instances
        self._filing_step = FilingStatusStep()
        self._income_step = IncomeStep()
        self._deductions_step = DeductionsStep()
        self._review_step = ReviewStep(
            steps=[self._filing_step, self._income_step, self._deductions_step]
        )
        self._steps = [
            self._filing_step,
            self._income_step,
            self._deductions_step,
            self._review_step,
        ]
        self._visited = {0}

        # StepperMenu replaces the old Breadcrumbs + sidebar MenuList +
        # LinearProgress — it conveys order, progress, and per-step state in
        # one component. non_linear=True lets users click a step to jump to it.
        self._stepper = pmui.StepperMenu(
            items=self._build_items(),
            active=0,
            non_linear=True,
            alternative_label=True,
            color="primary",
            sizing_mode="stretch_width",
            margin=(0, 0, 8, 0),
        )

        # Inline validation banner shown when the current step is incomplete.
        self._alert = pmui.Alert(
            object="",
            severity="warning",
            variant="outlined",
            visible=False,
            sizing_mode="stretch_width",
            margin=(0, 0, 16, 0),
        )

        # Navigation
        self._back_btn = pmui.Button(
            label="Back", icon="arrow_back", variant="outlined", visible=False
        )
        self._next_btn = pmui.Button(
            label="Continue", icon="arrow_forward", variant="contained", color="primary"
        )
        self._nav_row = pmui.Row(
            self._back_btn, pn.layout.HSpacer(), self._next_btn, sizing_mode="stretch_width"
        )
        self._content = pn.pane.Placeholder(sizing_mode="stretch_width", min_height=420, margin=20)
        self._step_text = pmui.Typography(
            f"Step 1 of {len(self._steps)}", sx={"color": "primary.contrastText"}
        )

        # Wire up events and syncs
        self._stepper.param.watch(self._on_stepper, "active")
        self._back_btn.on_click(self._on_back)
        self._next_btn.on_click(self._on_advance)
        for step in self._steps:
            step.param.watch(self._refresh_state, "complete")

        super().__init__(**params)

    # -- Stepper items reflect live completion / error state --

    def _build_items(self):
        items = []
        for i, step in enumerate(self._steps):
            item = {"label": step.title, "icon": step.icon, "tooltip": step.title}
            # The active step keeps the active highlight (no flag). Only steps
            # the user has actually visited and left get a completed/error flag;
            # unvisited future steps stay pending/grey. `step.complete` means
            # "has valid data" — not "the user has finished this step" — so it
            # must be gated on visitation or every default-valid step would
            # render in the completed color and the active step wouldn't stand out.
            if i != self.active_step and i in self._visited:
                if step.complete:
                    item["completed"] = True
                else:
                    item["error"] = True
            items.append(item)
        return items

    # -- Navigation handlers --

    def _on_stepper(self, event):
        if event.new is None or event.new == self.active_step:
            return
        self.active_step = event.new

    def _on_back(self, event=None):
        if self.active_step > 0:
            self.active_step -= 1

    def _on_advance(self, event=None):
        if self.active_step < len(self._steps) - 1:
            self.active_step += 1
        else:
            self._submit()

    def _submit(self):
        self.submitted = True
        with pn.io.hold():
            for step in self._steps:
                step.disabled = True
            self._stepper.items = [
                {"label": s.title, "icon": s.icon, "completed": True} for s in self._steps
            ]
            self._next_btn.visible = False
            self._back_btn.visible = False
            self._alert.visible = False
            self._content.update(
                pmui.Column(
                    pmui.Typography(
                        "Your return has been submitted!",
                        variant="h4",
                        sx={"fontWeight": 700, "mb": 2},
                    ),
                    pmui.Typography(
                        "Thank you for using Tax Wizard. You will receive a confirmation shortly.",
                        sx={"color": "text.secondary"},
                    ),
                    sizing_mode="stretch_width",
                )
            )

    # -- State sync --

    def _refresh_state(self, *events):
        with pn.io.hold():
            self._stepper.items = self._build_items()
            self._update_controls()

    def _update_controls(self):
        current = self._steps[self.active_step]
        is_first = self.active_step == 0
        is_last = self.active_step == len(self._steps) - 1
        self._back_btn.visible = not is_first and not self.submitted
        self._next_btn.label = "Submit" if is_last else "Continue"
        self._next_btn.disabled = not current.complete
        if not current.complete and not self.submitted:
            self._alert.object = "Complete this step before continuing."
            self._alert.visible = True
        else:
            self._alert.visible = False
        self._step_text.object = f"Step {self.active_step + 1} of {len(self._steps)}"

    @param.depends("active_step", watch=True, on_init=True)
    def _update_view(self):
        with pn.io.hold():
            self._visited.add(self.active_step)
            if self._stepper.active != self.active_step:
                self._stepper.active = self.active_step
            self._stepper.items = self._build_items()
            self._content.update(self._steps[self.active_step])
            self._update_controls()

    def __panel__(self):
        if pn.state.served:
            return pmui.Page(
                title="Tax Wizard",
                header=[self._step_text],
                sidebar=[
                    pmui.Typography("Need Help?", variant="h6", sx={"mb": 1}),
                    pmui.Typography(
                        "Have questions about filing? Visit our FAQ or contact support.",
                        sx={"color": "text.secondary", "fontSize": 13},
                    ),
                    pmui.Tooltip(
                        pmui.Button(
                            label="View FAQ",
                            icon="help_outline",
                            variant="outlined",
                            sizing_mode="stretch_width",
                            margin=(15, 10, 0, 10),
                        ),
                        title="Browse frequently asked questions about filing",
                    ),
                ],
                sidebar_width=280,
                theme_config=THEME_CONFIG,
                main=[
                    pmui.Container(
                        pmui.Column(
                            self._stepper,
                            pmui.Paper(
                                pmui.Column(
                                    self._alert,
                                    self._content,
                                    self._nav_row,
                                    sizing_mode="stretch_width",
                                    margin=(0, 0, 30, 0),
                                ),
                                sx={"p": 5, "mt": 2},
                                sizing_mode="stretch_width",
                            ),
                            sizing_mode="stretch_width",
                            margin=(40, 0, 0, 0),
                        ),
                        width_option="md",
                    )
                ],
            )
        return pmui.Column(self._stepper, self._alert, self._content, self._nav_row)


TaxWizard().servable()