Skip to content

Wizard

Wizard

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

Multi-step wizard with one action per page, breadcrumb navigation, sidebar
menu, and a progress bar. Demonstrates Viewer class pattern, Placeholder
swapping, pn.io.hold() batching, shared disabled state, and 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, defer_load=True, loading_indicator=True)

# Professional theme - refined indigo/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 breadcrumbs and sidebar.""")

    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."""

    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,
        ]

        # Components
        self._breadcrumb_items = [
            {"label": step.title, "icon": step.icon, "view": step}
            for step in self._steps
        ]
        self._breadcrumbs = pmui.Breadcrumbs(
            items=self._breadcrumb_items,
            active=0,
            color="primary",
            separator="›",
            sizing_mode="stretch_width",
        )
        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._content = pn.pane.Placeholder(sizing_mode="stretch_width", min_height=450, margin=20)
        self._progress_bar = pmui.LinearProgress(
            value=25,
            sizing_mode="stretch_width",
            margin=(12, 0, 0, 0),
            sx={
                "height": 4,
                "borderRadius": 2,
                "backgroundColor": "rgba(0,0,0,0.08)",
            },
        )
        self._nav_menu = pmui.MenuList(
            items=[{"label": step.title, "icon": step.icon} for step in self._steps],
            active=0,
            color="primary",
            highlight=True,
        )
        self._nav_row = pmui.Row(
            self._back_btn,
            pn.layout.HSpacer(),
            self._next_btn,
            sizing_mode="stretch_width",
        )
        self._step_text = pmui.Typography(
            f"Step 1 of {len(self._steps)}",
            sx={"color": "primary.contrastText"},
        )

        # Wire up events and syncs
        self._breadcrumbs.on_click(self._on_breadcrumb_click)
        self._back_btn.on_click(self._on_back)
        self._next_btn.on_click(self._on_advance)
        self._nav_menu.param.watch(self._on_menu_select, "active")
        for step in self._steps:
            step.param.watch(self._update_button_state, "complete")

        super().__init__(**params)

    def _on_menu_select(self, event):
        active = event.new
        if active and active[0] != self.active_step:
            self.active_step = active[0]

    def _on_breadcrumb_click(self, event):
        clicked_label = event.get("label") if isinstance(event, dict) else getattr(event, "label", None)
        if not clicked_label:
            return
        for i, item in enumerate(self._breadcrumb_items):
            if item["label"] == clicked_label:
                self.active_step = i
                return

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

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

    def _submit(self):
        self.submitted = True
        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 email shortly.",
                    sx={"color": "text.secondary"},
                ),
                sizing_mode="stretch_width",
            )
        )
        with pn.io.hold():
            self._next_btn.visible = False
            self._back_btn.visible = False
            self._breadcrumbs.visible = False
            for step in self._steps:
                step.disabled = True

    def _update_button_state(self, *args):
        current_step = self._steps[self.active_step]
        self._next_btn.disabled = not current_step.complete

    @param.depends("active_step", watch=True, on_init=True)
    def _update_view(self):
        with pn.io.hold():
            self._breadcrumbs.active = self.active_step

            current_step = 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_step.complete

            self._content.update(current_step)
            self._nav_menu.active = (self.active_step,)
            self._progress_bar.value = int(((self.active_step + 1) / len(self._steps)) * 100)
            self._step_text.object = f"Step {self.active_step + 1} of {len(self._steps)}"

    def __panel__(self):
        if pn.state.served:
            return pmui.Page(
                title="Tax Wizard",
                header=[self._step_text],
                sidebar=[
                    pmui.Typography("Steps", variant="h6", sx={"mb": 1}),
                    pmui.Column(self._nav_menu, sizing_mode="stretch_width"),
                    pn.layout.Divider(margin=(20, 0)),
                    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.Button(
                        label="View FAQ",
                        icon="help_outline",
                        variant="outlined",
                        sizing_mode="stretch_width",
                        margin=(15, 10, 0, 10),
                    ),
                ],
                sidebar_width=280,
                theme_config=THEME_CONFIG,
                main=[
                    pmui.Container(
                        pmui.Column(
                            self._breadcrumbs,
                            self._progress_bar,
                            pmui.Paper(
                                pmui.Column(
                                    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._breadcrumbs, self._content, self._nav_row)


TaxWizard().servable()