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:
137
src/components/atoms/DialogShell/DialogShell.stories.tsx
Normal file
137
src/components/atoms/DialogShell/DialogShell.stories.tsx
Normal 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'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't need actions.
|
||||
</Typography>
|
||||
</DialogShell>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
174
src/components/atoms/DialogShell/DialogShell.tsx
Normal file
174
src/components/atoms/DialogShell/DialogShell.tsx
Normal 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;
|
||||
2
src/components/atoms/DialogShell/index.ts
Normal file
2
src/components/atoms/DialogShell/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DialogShell } from './DialogShell';
|
||||
export type { DialogShellProps } from './DialogShell';
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user