Add IntroStep page (wizard step 1) + audit fixes

IntroStep: urgency-sensitive segmentation entry point. ToggleButtonGroup
for forWhom (Myself/Someone else) with progressive disclosure revealing
hasPassedAway (Yes/No) via Collapse. Auto-sets hasPassedAway="no" when
forWhom="myself". Grief-sensitive copy adapts subheading per selection.
Pure presentation — props in, callbacks out.

Audit fixes (18/20 → 20/20):
- P1: Add <main> landmark wrapper in WizardLayout (all variants)
- P2: Wrap IntroStep fields in <form> for landmark + Enter-to-submit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:26:53 +11:00
parent 110c62e21e
commit 2631a2e4bb
4 changed files with 410 additions and 1 deletions

View File

@@ -0,0 +1,214 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { IntroStep } from './IntroStep';
import type { IntroStepValues, IntroStepErrors } from './IntroStep';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const FALogo = () => (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
component="img"
src="/brandlogo/logo-full.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
/>
<Box
component="img"
src="/brandlogo/logo-short.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
/>
</Box>
);
const nav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof IntroStep> = {
title: 'Pages/IntroStep',
component: IntroStep,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof IntroStep>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Fully interactive — click through the progressive disclosure flow */
export const Default: Story = {
render: () => {
const [values, setValues] = useState<IntroStepValues>({
forWhom: null,
hasPassedAway: null,
});
const [errors, setErrors] = useState<IntroStepErrors>({});
const handleContinue = () => {
const newErrors: IntroStepErrors = {};
if (!values.forWhom) {
newErrors.forWhom = 'We need to know who the funeral is for to show you the right options.';
}
if (values.forWhom === 'someone' && !values.hasPassedAway) {
newErrors.hasPassedAway = 'This helps us tailor the process to your situation.';
}
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) {
alert(`Continue with: ${JSON.stringify(values)}`);
}
};
return (
<IntroStep
values={values}
onChange={(v) => {
setValues(v);
setErrors({});
}}
onContinue={handleContinue}
errors={errors}
navigation={nav}
/>
);
},
};
// ─── Pre-planning (myself) ──────────────────────────────────────────────────
/** User has selected "Myself" — hasPassedAway auto-set to "no", no second question */
export const PrePlanningMyself: Story = {
render: () => {
const [values, setValues] = useState<IntroStepValues>({
forWhom: 'myself',
hasPassedAway: 'no',
});
return (
<IntroStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
navigation={nav}
/>
);
},
};
// ─── At-need (someone, yes) ─────────────────────────────────────────────────
/** User arranging for someone who has passed — at-need flow */
export const AtNeedSomeone: Story = {
render: () => {
const [values, setValues] = useState<IntroStepValues>({
forWhom: 'someone',
hasPassedAway: 'yes',
});
return (
<IntroStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
navigation={nav}
/>
);
},
};
// ─── Pre-planning (someone, no) ─────────────────────────────────────────────
/** User arranging for someone who is alive — pre-planning flow */
export const PrePlanningSomeone: Story = {
render: () => {
const [values, setValues] = useState<IntroStepValues>({
forWhom: 'someone',
hasPassedAway: 'no',
});
return (
<IntroStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
navigation={nav}
/>
);
},
};
// ─── Validation errors ──────────────────────────────────────────────────────
/** Both fields showing validation errors */
export const WithErrors: Story = {
render: () => {
const [values, setValues] = useState<IntroStepValues>({
forWhom: null,
hasPassedAway: null,
});
return (
<IntroStep
values={values}
onChange={setValues}
onContinue={() => {}}
errors={{
forWhom: 'We need to know who the funeral is for to show you the right options.',
}}
navigation={nav}
/>
);
},
};
// ─── Loading state ──────────────────────────────────────────────────────────
/** Continue button in loading state */
export const Loading: Story = {
render: () => {
const [values, setValues] = useState<IntroStepValues>({
forWhom: 'myself',
hasPassedAway: 'no',
});
return (
<IntroStep
values={values}
onChange={setValues}
onContinue={() => {}}
loading
navigation={nav}
/>
);
},
};
// ─── No navigation (embedded) ───────────────────────────────────────────────
/** Without navigation — for embedded/iframe use */
export const Embedded: Story = {
render: () => {
const [values, setValues] = useState<IntroStepValues>({
forWhom: null,
hasPassedAway: null,
});
return (
<IntroStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
hideHelpBar
/>
);
},
};

View File

@@ -0,0 +1,191 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ToggleButtonGroup } from '../../atoms/ToggleButtonGroup';
import { Collapse } from '../../atoms/Collapse';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Form values for the intro step */
export interface IntroStepValues {
/** Who the funeral is being arranged for */
forWhom: 'myself' | 'someone' | null;
/** Whether the person has passed away (only relevant when forWhom="someone") */
hasPassedAway: 'yes' | 'no' | null;
}
/** Field-level error messages */
export interface IntroStepErrors {
/** Error for the forWhom field */
forWhom?: string;
/** Error for the hasPassedAway field */
hasPassedAway?: string;
}
/** Props for the IntroStep page component */
export interface IntroStepProps {
/** Current form values */
values: IntroStepValues;
/** Callback when any field value changes */
onChange: (values: IntroStepValues) => void;
/** Callback when the Continue button is clicked */
onContinue: () => void;
/** Field-level validation errors */
errors?: IntroStepErrors;
/** Whether the Continue button is in a loading state */
loading?: boolean;
/** Navigation bar — passed through to WizardLayout */
navigation?: React.ReactNode;
/** Hide the help bar */
hideHelpBar?: boolean;
/** MUI sx prop for the root */
sx?: SxProps<Theme>;
}
// ─── Copy helpers ────────────────────────────────────────────────────────────
function getSubheading(values: IntroStepValues): string {
if (values.forWhom === 'someone' && values.hasPassedAway === 'yes') {
return "We'll guide you through each step. You can save your progress and come back anytime.";
}
if (values.forWhom === 'myself' || values.hasPassedAway === 'no') {
return "Explore your options and plan at your own pace. Nothing is locked in until you're ready.";
}
return "We'll guide you through arranging a funeral, step by step. You can save your progress and come back anytime.";
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Step 1 — Intro page for the FA arrangement wizard.
*
* Entry point with urgency-sensitive segmentation. User selects who
* the funeral is for, and (if arranging for someone else) whether
* that person has died.
*
* Uses the Centered Form layout variant. Progressive disclosure:
* selecting "Someone else" reveals the hasPassedAway question.
* Selecting "Myself" auto-sets hasPassedAway to "no" (pre-planning).
*
* Pure presentation component — props in, callbacks out.
* No routing, state management, or API calls.
*
* Spec: documentation/steps/steps/01_intro.yaml
*/
export const IntroStep: React.FC<IntroStepProps> = ({
values,
onChange,
onContinue,
errors,
loading = false,
navigation,
hideHelpBar,
sx,
}) => {
const handleForWhomChange = (newValue: string | null) => {
const forWhom = newValue as IntroStepValues['forWhom'];
if (forWhom === 'myself') {
// Auto-set hasPassedAway to 'no' — user is alive, pre-planning
onChange({ forWhom, hasPassedAway: 'no' });
} else {
// Reset hasPassedAway when switching to "someone"
onChange({ forWhom, hasPassedAway: null });
}
};
const handleHasPassedAwayChange = (newValue: string | null) => {
onChange({ ...values, hasPassedAway: newValue as IntroStepValues['hasPassedAway'] });
};
const showHasPassedAway = values.forWhom === 'someone';
return (
<WizardLayout variant="centered-form" navigation={navigation} hideHelpBar={hideHelpBar} sx={sx}>
{/* Page heading — receives focus on entry for screen readers */}
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
Let&apos;s get started
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 5 }} aria-live="polite">
{getSubheading(values)}
</Typography>
<Box
component="form"
noValidate
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
onContinue();
}}
>
{/* forWhom field */}
<Box sx={{ mb: 3 }}>
<ToggleButtonGroup
label="Who is this funeral being arranged for?"
options={[
{
value: 'myself',
label: 'Myself',
description: 'I want to plan my own funeral',
},
{
value: 'someone',
label: 'Someone else',
description: 'I am arranging for a family member or friend',
},
]}
value={values.forWhom}
onChange={handleForWhomChange}
error={!!errors?.forWhom}
helperText={errors?.forWhom}
required
fullWidth
/>
</Box>
{/* hasPassedAway field — revealed when forWhom="someone" */}
<Collapse in={showHasPassedAway}>
<Box sx={{ mb: 3 }}>
<ToggleButtonGroup
label="Has the person died?"
options={[
{
value: 'yes',
label: 'Yes',
description: 'I need to arrange a funeral now',
},
{
value: 'no',
label: 'No',
description: 'I am planning ahead',
},
]}
value={values.hasPassedAway}
onChange={handleHasPassedAwayChange}
error={!!errors?.hasPassedAway}
helperText={errors?.hasPassedAway}
required
fullWidth
/>
</Box>
</Collapse>
<Divider sx={{ my: 4 }} />
{/* CTA */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="submit" variant="contained" size="large" loading={loading}>
Continue
</Button>
</Box>
</Box>
</WizardLayout>
);
};
IntroStep.displayName = 'IntroStep';
export default IntroStep;

View File

@@ -0,0 +1,2 @@
export { default } from './IntroStep';
export * from './IntroStep';