Feedback iteration: DialogShell, page consistency, popup standardisation

- Add DialogShell atom — shared dialog container (header, scrollable body, footer)
- Refactor FilterPanel to use DialogShell (Popover → centered Dialog)
- Refactor ArrangementDialog to use DialogShell
- Remove PreviewStep + AuthGateStep pages (consolidated into ArrangementDialog, D-E)
- IntroStep: static subheading, top-left aligned toggle button content
- ProvidersStep: h4 heading "Find a funeral director", location search with pin icon,
  filter moved below search right-aligned, map fill fix, hover scrollbar
- VenueStep: same consistency fixes (h4 heading, filter layout, location icon, map fix)
- PackagesStep: grouped packages ("Matching your preferences" / "Other packages from
  [Provider]"), removed budget filter + Most Popular badge, clickable provider card,
  onArrange replaces onContinue, h4 heading
- WizardLayout: list-map left panel gets thin scrollbar visible on hover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 12:20:26 +11:00
parent 5c3e0c4e56
commit 1faa320f4b
22 changed files with 904 additions and 1721 deletions

View File

@@ -0,0 +1,137 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { DialogShell } from './DialogShell';
import { Button } from '../Button';
import { Typography } from '../Typography';
import Box from '@mui/material/Box';
const meta: Meta<typeof DialogShell> = {
title: 'Atoms/DialogShell',
component: DialogShell,
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
export default meta;
type Story = StoryObj<typeof DialogShell>;
/** Default dialog with title, body, and footer */
export const Default: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Dialog title"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={() => setOpen(false)}>
Done
</Button>
</Box>
}
>
<Typography variant="body1">
This is the dialog body content. It scrolls when the content exceeds the max height.
</Typography>
</DialogShell>
</>
);
},
};
/** Dialog with a back button */
export const WithBackButton: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Step 2 of 3"
onBack={() => alert('Back')}
backLabel="Back to step 1"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="outlined" color="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="contained" onClick={() => setOpen(false)}>
Continue
</Button>
</Box>
}
>
<Typography variant="body1">
Content for the second step of a multi-step dialog.
</Typography>
</DialogShell>
</>
);
},
};
/** Long content that triggers scrollable body */
export const LongContent: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Scrollable content"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={() => setOpen(false)}>
Done
</Button>
</Box>
}
>
{Array.from({ length: 12 }, (_, i) => (
<Typography key={i} variant="body1" sx={{ mb: 2 }}>
Paragraph {i + 1}: This is sample content to demonstrate the scrollable body area.
When the content exceeds the dialog&apos;s max height, the body scrolls while the
header and footer remain fixed.
</Typography>
))}
</DialogShell>
</>
);
},
};
/** Dialog without a footer */
export const NoFooter: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell open={open} onClose={() => setOpen(false)} title="Information">
<Typography variant="body1" sx={{ mb: 2 }}>
This dialog has no footer just a close button in the header.
</Typography>
<Typography variant="body2" color="text.secondary">
Useful for informational popups or content that doesn&apos;t need actions.
</Typography>
</DialogShell>
</>
);
},
};

View File

@@ -0,0 +1,174 @@
import React from 'react';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import type { DialogProps } from '@mui/material/Dialog';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../Typography';
import { Divider } from '../Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the DialogShell atom */
export interface DialogShellProps {
/** Whether the dialog is open */
open: boolean;
/** Callback when the dialog is closed (close button or backdrop) */
onClose: () => void;
/** Dialog title */
title: React.ReactNode;
/** Show a back arrow before the title */
onBack?: () => void;
/** Back button aria-label */
backLabel?: string;
/** Main content — rendered in the scrollable body */
children: React.ReactNode;
/** Footer actions — rendered below the body divider */
footer?: React.ReactNode;
/** MUI Dialog maxWidth */
maxWidth?: DialogProps['maxWidth'];
/** Whether the dialog should be full-width up to maxWidth */
fullWidth?: boolean;
/** MUI sx prop for the Dialog Paper */
paperSx?: SxProps<Theme>;
/** MUI sx prop for the root Dialog */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Standard dialog container for the FA design system.
*
* Provides consistent chrome for all popup dialogs across the site:
* header (title + optional back + close), scrollable body, optional footer.
*
* Used by FilterPanel, ArrangementDialog, and any future popup pattern.
*
* Usage:
* ```tsx
* <DialogShell open={open} onClose={handleClose} title="Filters" footer={<Button>Done</Button>}>
* {filterControls}
* </DialogShell>
* ```
*/
export const DialogShell = React.forwardRef<HTMLDivElement, DialogShellProps>(
(
{
open,
onClose,
title,
onBack,
backLabel = 'Back',
children,
footer,
maxWidth = 'sm',
fullWidth = true,
paperSx,
sx,
},
ref,
) => {
const titleId = React.useId();
const titleRef = React.useRef<HTMLHeadingElement>(null);
// Focus title on open or when title changes (e.g. step transitions)
React.useEffect(() => {
if (open && titleRef.current) {
titleRef.current.focus();
}
}, [open, title]);
return (
<Dialog
ref={ref}
open={open}
onClose={onClose}
maxWidth={maxWidth}
fullWidth={fullWidth}
aria-labelledby={titleId}
sx={sx}
PaperProps={{
sx: [
{
borderRadius: 2,
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
},
...(Array.isArray(paperSx) ? paperSx : paperSx ? [paperSx] : []),
],
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 3,
pt: 2.5,
pb: 2,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
{onBack && (
<IconButton
onClick={onBack}
aria-label={backLabel}
sx={{ minWidth: 44, minHeight: 44 }}
>
<ArrowBackIcon fontSize="small" />
</IconButton>
)}
<Typography
id={titleId}
ref={titleRef}
variant="h6"
component="h2"
tabIndex={-1}
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{title}
</Typography>
</Box>
<IconButton
onClick={onClose}
aria-label="Close"
sx={{ color: 'text.secondary', flexShrink: 0, ml: 1, minWidth: 44, minHeight: 44 }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Divider />
{/* Scrollable body */}
<Box
sx={{
px: 3,
py: 2.5,
overflowY: 'auto',
flex: 1,
}}
>
{children}
</Box>
{/* Footer (optional) */}
{footer && (
<>
<Divider />
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>{footer}</Box>
</>
)}
</Dialog>
);
},
);
DialogShell.displayName = 'DialogShell';
export default DialogShell;

View File

@@ -0,0 +1,2 @@
export { DialogShell } from './DialogShell';
export type { DialogShellProps } from './DialogShell';

View File

@@ -159,6 +159,7 @@ export const ToggleButtonGroup = React.forwardRef<HTMLFieldSetElement, ToggleBut
textTransform: 'none',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-start',
gap: 0.5,
py: option.description ? 2 : 1.5,
px: 3,