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:
214
src/components/pages/IntroStep/IntroStep.stories.tsx
Normal file
214
src/components/pages/IntroStep/IntroStep.stories.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
191
src/components/pages/IntroStep/IntroStep.tsx
Normal file
191
src/components/pages/IntroStep/IntroStep.tsx
Normal 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'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;
|
||||
2
src/components/pages/IntroStep/index.ts
Normal file
2
src/components/pages/IntroStep/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './IntroStep';
|
||||
export * from './IntroStep';
|
||||
@@ -324,7 +324,9 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<LayoutComponent secondaryPanel={secondaryPanel}>{children}</LayoutComponent>
|
||||
<Box component="main" sx={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<LayoutComponent secondaryPanel={secondaryPanel}>{children}</LayoutComponent>
|
||||
</Box>
|
||||
|
||||
{/* Sticky help bar */}
|
||||
{!hideHelpBar && <HelpBar phone={helpPhone} />}
|
||||
|
||||
Reference in New Issue
Block a user