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,128 +39,146 @@ export interface FilterPanelProps {
* D-C: Popover for desktop MVP. Mobile Drawer variant planned for later.
*
* 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> = ({
label = 'Filters',
activeCount = 0,
children,
onClear,
minWidth = 280,
sx,
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const popoverId = open ? 'filter-panel-popover' : undefined;
export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
({ label = 'Filters', activeCount = 0, children, onClear, minWidth = 280, sx }, ref) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const uniqueId = React.useId();
const popoverId = `filter-panel-${uniqueId}`;
const headingId = `filter-panel-heading-${uniqueId}`;
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
{/* Trigger button */}
<Box sx={[{ display: 'inline-flex' }, ...(Array.isArray(sx) ? sx : [sx])]}>
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<TuneIcon />}
onClick={handleOpen}
aria-describedby={popoverId}
aria-expanded={open}
aria-haspopup="dialog"
>
{label}
{activeCount > 0 && (
<Badge
variant="filled"
color="brand"
size="small"
sx={{ ml: 1 }}
aria-label={`${activeCount} active filter${activeCount !== 1 ? 's' : ''}`}
>
{activeCount}
</Badge>
)}
</Button>
</Box>
{/* Popover panel */}
<Popover
id={popoverId}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
minWidth,
mt: 1,
borderRadius: 2,
boxShadow: 3,
},
},
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
pt: 2,
pb: 1.5,
}}
>
<Typography variant="h6">Filters</Typography>
{onClear && activeCount > 0 && (
<Link
component="button"
onClick={() => {
onClear();
}}
underline="hover"
sx={{ fontSize: '0.8125rem' }}
>
Clear all
</Link>
)}
</Box>
<Divider />
{/* Filter controls */}
<Box sx={{ px: 2.5, py: 2, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{children}
</Box>
<Divider />
{/* Footer — done button */}
<Box sx={{ px: 2.5, py: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={handleClose}>
Done
return (
<>
{/* Trigger button */}
<Box ref={ref} sx={[{ display: 'inline-flex' }, ...(Array.isArray(sx) ? sx : [sx])]}>
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<TuneIcon />}
onClick={handleOpen}
aria-controls={open ? popoverId : undefined}
aria-expanded={open}
aria-haspopup="dialog"
>
{label}
{activeCount > 0 && (
<Badge variant="filled" color="brand" size="small" sx={{ ml: 1 }} aria-hidden="true">
{activeCount}
</Badge>
)}
{activeCount > 0 && (
<Box
component="span"
sx={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
>
{activeCount} active filter{activeCount !== 1 ? 's' : ''}
</Box>
)}
</Button>
</Box>
</Popover>
</>
);
};
{/* Popover panel */}
<Popover
id={popoverId}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
minWidth,
maxHeight: '70vh',
mt: 1,
borderRadius: 2,
boxShadow: 3,
display: 'flex',
flexDirection: 'column',
},
},
}}
PaperProps={{
role: 'dialog' as const,
'aria-labelledby': headingId,
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
pt: 2,
pb: 1.5,
flexShrink: 0,
}}
>
<Typography id={headingId} variant="h6">
{label}
</Typography>
{onClear && activeCount > 0 && (
<Link
component="button"
onClick={() => {
onClear();
}}
underline="hover"
sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }}
>
Clear all
</Link>
)}
</Box>
<Divider />
{/* Filter controls */}
<Box
sx={{
px: 2.5,
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2.5,
overflowY: 'auto',
flex: 1,
}}
>
{children}
</Box>
<Divider />
{/* Footer — done button */}
<Box
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
</Button>
</Box>
</Popover>
</>
);
},
);
FilterPanel.displayName = 'FilterPanel';
export default FilterPanel;