Quality pass: fix P0 audit findings across FilterPanel, ArrangementDialog, steps

FilterPanel (4 P0 + 2 P1 fixes):
- Add forwardRef (project convention)
- Use React.useId() for unique popover/heading IDs (was static)
- Change aria-describedby to aria-controls (correct ARIA pattern)
- Add role="dialog" + aria-labelledby on Popover paper
- Popover header now uses label prop (was hardcoded "Filters")
- Clear all font size uses theme.typography.caption (was hardcoded)
- Badge uses aria-hidden + visually-hidden text (cleaner SR output)
- Add maxHeight + overflow scroll to body, aria-label on Done button

ArrangementDialog (3 P0 + 1 P1 fixes):
- Add forwardRef
- Focus management: titleRef focused on step change via useEffect
- Add aria-live region announcing step transitions to screen readers
- Fix borderRadius from 3 to 2 (theme convention)

Sticky header padding (visual fix):
- ProvidersStep + VenueStep: mx/px now responsive { xs: -2/2, md: -3/3 }
  matching the panel's px: { xs: 2, md: 3 } — fixes mobile misalignment

CoffinDetailsStep:
- Wrap CTA area in form element with onSubmit + aria-busy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 08:22:06 +11:00
parent 4ff18d6a9f
commit ae4bcef4c9
5 changed files with 516 additions and 464 deletions

View File

@@ -39,26 +39,14 @@ export interface FilterPanelProps {
* D-C: Popover for desktop MVP. Mobile Drawer variant planned for later. * D-C: Popover for desktop MVP. Mobile Drawer variant planned for later.
* *
* Used in ProvidersStep, VenueStep, and CoffinsStep. * Used in ProvidersStep, VenueStep, and CoffinsStep.
*
* Usage:
* ```tsx
* <FilterPanel activeCount={2} onClear={handleClear}>
* <TextField select label="Category" ... />
* <TextField select label="Price" ... />
* </FilterPanel>
* ```
*/ */
export const FilterPanel: React.FC<FilterPanelProps> = ({ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
label = 'Filters', ({ label = 'Filters', activeCount = 0, children, onClear, minWidth = 280, sx }, ref) => {
activeCount = 0,
children,
onClear,
minWidth = 280,
sx,
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null); const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const popoverId = open ? 'filter-panel-popover' : undefined; const uniqueId = React.useId();
const popoverId = `filter-panel-${uniqueId}`;
const headingId = `filter-panel-heading-${uniqueId}`;
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => { const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@@ -71,29 +59,31 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
return ( return (
<> <>
{/* Trigger button */} {/* Trigger button */}
<Box sx={[{ display: 'inline-flex' }, ...(Array.isArray(sx) ? sx : [sx])]}> <Box ref={ref} sx={[{ display: 'inline-flex' }, ...(Array.isArray(sx) ? sx : [sx])]}>
<Button <Button
variant="outlined" variant="outlined"
color="secondary" color="secondary"
size="small" size="small"
startIcon={<TuneIcon />} startIcon={<TuneIcon />}
onClick={handleOpen} onClick={handleOpen}
aria-describedby={popoverId} aria-controls={open ? popoverId : undefined}
aria-expanded={open} aria-expanded={open}
aria-haspopup="dialog" aria-haspopup="dialog"
> >
{label} {label}
{activeCount > 0 && ( {activeCount > 0 && (
<Badge <Badge variant="filled" color="brand" size="small" sx={{ ml: 1 }} aria-hidden="true">
variant="filled"
color="brand"
size="small"
sx={{ ml: 1 }}
aria-label={`${activeCount} active filter${activeCount !== 1 ? 's' : ''}`}
>
{activeCount} {activeCount}
</Badge> </Badge>
)} )}
{activeCount > 0 && (
<Box
component="span"
sx={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
>
{activeCount} active filter{activeCount !== 1 ? 's' : ''}
</Box>
)}
</Button> </Button>
</Box> </Box>
@@ -109,12 +99,19 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
paper: { paper: {
sx: { sx: {
minWidth, minWidth,
maxHeight: '70vh',
mt: 1, mt: 1,
borderRadius: 2, borderRadius: 2,
boxShadow: 3, boxShadow: 3,
display: 'flex',
flexDirection: 'column',
}, },
}, },
}} }}
PaperProps={{
role: 'dialog' as const,
'aria-labelledby': headingId,
}}
> >
{/* Header */} {/* Header */}
<Box <Box
@@ -125,9 +122,12 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
px: 2.5, px: 2.5,
pt: 2, pt: 2,
pb: 1.5, pb: 1.5,
flexShrink: 0,
}} }}
> >
<Typography variant="h6">Filters</Typography> <Typography id={headingId} variant="h6">
{label}
</Typography>
{onClear && activeCount > 0 && ( {onClear && activeCount > 0 && (
<Link <Link
component="button" component="button"
@@ -135,7 +135,7 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
onClear(); onClear();
}} }}
underline="hover" underline="hover"
sx={{ fontSize: '0.8125rem' }} sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }}
> >
Clear all Clear all
</Link> </Link>
@@ -145,22 +145,40 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
<Divider /> <Divider />
{/* Filter controls */} {/* Filter controls */}
<Box sx={{ px: 2.5, py: 2, display: 'flex', flexDirection: 'column', gap: 2.5 }}> <Box
sx={{
px: 2.5,
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2.5,
overflowY: 'auto',
flex: 1,
}}
>
{children} {children}
</Box> </Box>
<Divider /> <Divider />
{/* Footer — done button */} {/* Footer — done button */}
<Box sx={{ px: 2.5, py: 1.5, display: 'flex', justifyContent: 'flex-end' }}> <Box
<Button variant="contained" size="small" onClick={handleClose}> sx={{ px: 2.5, py: 1.5, display: 'flex', justifyContent: 'flex-end', flexShrink: 0 }}
>
<Button
variant="contained"
size="small"
onClick={handleClose}
aria-label="Close filters"
>
Done Done
</Button> </Button>
</Box> </Box>
</Popover> </Popover>
</> </>
); );
}; },
);
FilterPanel.displayName = 'FilterPanel'; FilterPanel.displayName = 'FilterPanel';
export default FilterPanel; export default FilterPanel;

View File

@@ -157,7 +157,9 @@ function getAuthCTALabel(subStep: AuthSubStep): string {
* *
* Pure presentation component — props in, callbacks out. * Pure presentation component — props in, callbacks out.
*/ */
export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDialogProps>(
(
{
open, open,
onClose, onClose,
step, step,
@@ -175,8 +177,18 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
onContinue, onContinue,
loading = false, loading = false,
sx, sx,
}) => { },
ref,
) => {
const isEmailOnly = authValues.contactPreference === 'email_only'; const isEmailOnly = authValues.contactPreference === 'email_only';
const titleRef = React.useRef<HTMLHeadingElement>(null);
// Focus the dialog title when step changes
React.useEffect(() => {
if (open && titleRef.current) {
titleRef.current.focus();
}
}, [step, open]);
const handleAuthField = (field: keyof AuthValues, value: string) => { const handleAuthField = (field: keyof AuthValues, value: string) => {
onAuthChange({ ...authValues, [field]: value }); onAuthChange({ ...authValues, [field]: value });
@@ -184,6 +196,7 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
return ( return (
<Dialog <Dialog
ref={ref}
open={open} open={open}
onClose={onClose} onClose={onClose}
maxWidth="sm" maxWidth="sm"
@@ -192,7 +205,7 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
aria-labelledby="arrangement-dialog-title" aria-labelledby="arrangement-dialog-title"
sx={sx} sx={sx}
PaperProps={{ PaperProps={{
sx: { borderRadius: 3 }, sx: { borderRadius: 2 },
}} }}
> >
{/* ─── Header ─── */} {/* ─── Header ─── */}
@@ -216,7 +229,7 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
<ArrowBackIcon fontSize="small" /> <ArrowBackIcon fontSize="small" />
</IconButton> </IconButton>
)} )}
<Typography id="arrangement-dialog-title" variant="h5"> <Typography id="arrangement-dialog-title" ref={titleRef} tabIndex={-1} variant="h5">
{step === 'preview' ? 'Your selected package' : 'Save your plan'} {step === 'preview' ? 'Your selected package' : 'Save your plan'}
</Typography> </Typography>
</Box> </Box>
@@ -225,6 +238,15 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
</IconButton> </IconButton>
</Box> </Box>
{/* Screen reader step announcement */}
<Box
aria-live="polite"
aria-atomic="true"
sx={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden' }}
>
{step === 'preview' ? 'Viewing package preview' : 'Create your account'}
</Box>
<DialogContent sx={{ px: 3, pb: 3 }}> <DialogContent sx={{ px: 3, pb: 3 }}>
{/* ═══════════ Step 1: Preview ═══════════ */} {/* ═══════════ Step 1: Preview ═══════════ */}
{step === 'preview' && ( {step === 'preview' && (
@@ -288,7 +310,11 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
</Typography> </Typography>
<List disablePadding> <List disablePadding>
{nextSteps.map((s) => ( {nextSteps.map((s) => (
<ListItem key={s.number} disablePadding sx={{ mb: 1, alignItems: 'flex-start' }}> <ListItem
key={s.number}
disablePadding
sx={{ mb: 1, alignItems: 'flex-start' }}
>
<ListItemIcon sx={{ minWidth: 36, mt: 0.25 }}> <ListItemIcon sx={{ minWidth: 36, mt: 0.25 }}>
<Box <Box
sx={{ sx={{
@@ -535,7 +561,8 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; },
);
ArrangementDialog.displayName = 'ArrangementDialog'; ArrangementDialog.displayName = 'ArrangementDialog';
export default ArrangementDialog; export default ArrangementDialog;

View File

@@ -161,6 +161,13 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
{/* CTAs */} {/* CTAs */}
<Box <Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -170,13 +177,13 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
}} }}
> >
{onSaveAndExit ? ( {onSaveAndExit ? (
<Button variant="text" color="secondary" onClick={onSaveAndExit}> <Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
Save and continue later Save and continue later
</Button> </Button>
) : ( ) : (
<Box /> <Box />
)} )}
<Button variant="contained" size="large" onClick={onContinue} loading={loading}> <Button type="submit" variant="contained" size="large" loading={loading}>
Continue Continue
</Button> </Button>
</Box> </Box>

View File

@@ -145,8 +145,8 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
zIndex: 1, zIndex: 1,
bgcolor: 'background.default', bgcolor: 'background.default',
pb: 1, pb: 1,
mx: -3, mx: { xs: -2, md: -3 },
px: 3, px: { xs: 2, md: 3 },
}} }}
> >
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}> <Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>

View File

@@ -214,8 +214,8 @@ export const VenueStep: React.FC<VenueStepProps> = ({
zIndex: 1, zIndex: 1,
bgcolor: 'background.default', bgcolor: 'background.default',
pb: 1, pb: 1,
mx: -3, mx: { xs: -2, md: -3 },
px: 3, px: { xs: 2, md: 3 },
}} }}
> >
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}> <Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>