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:
@@ -39,26 +39,14 @@ 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,
|
||||
}) => {
|
||||
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 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>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
@@ -71,29 +59,31 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
return (
|
||||
<>
|
||||
{/* 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
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="small"
|
||||
startIcon={<TuneIcon />}
|
||||
onClick={handleOpen}
|
||||
aria-describedby={popoverId}
|
||||
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-label={`${activeCount} active filter${activeCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -109,12 +99,19 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
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
|
||||
@@ -125,9 +122,12 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
px: 2.5,
|
||||
pt: 2,
|
||||
pb: 1.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Filters</Typography>
|
||||
<Typography id={headingId} variant="h6">
|
||||
{label}
|
||||
</Typography>
|
||||
{onClear && activeCount > 0 && (
|
||||
<Link
|
||||
component="button"
|
||||
@@ -135,7 +135,7 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
onClear();
|
||||
}}
|
||||
underline="hover"
|
||||
sx={{ fontSize: '0.8125rem' }}
|
||||
sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }}
|
||||
>
|
||||
Clear all
|
||||
</Link>
|
||||
@@ -145,22 +145,40 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
<Divider />
|
||||
|
||||
{/* 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}
|
||||
</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}>
|
||||
<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;
|
||||
|
||||
@@ -157,7 +157,9 @@ function getAuthCTALabel(subStep: AuthSubStep): string {
|
||||
*
|
||||
* Pure presentation component — props in, callbacks out.
|
||||
*/
|
||||
export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
|
||||
export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDialogProps>(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
step,
|
||||
@@ -175,8 +177,18 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
|
||||
onContinue,
|
||||
loading = false,
|
||||
sx,
|
||||
}) => {
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
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) => {
|
||||
onAuthChange({ ...authValues, [field]: value });
|
||||
@@ -184,6 +196,7 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
@@ -192,7 +205,7 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
|
||||
aria-labelledby="arrangement-dialog-title"
|
||||
sx={sx}
|
||||
PaperProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
sx: { borderRadius: 2 },
|
||||
}}
|
||||
>
|
||||
{/* ─── Header ─── */}
|
||||
@@ -216,7 +229,7 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</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'}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -225,6 +238,15 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
|
||||
</IconButton>
|
||||
</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 }}>
|
||||
{/* ═══════════ Step 1: Preview ═══════════ */}
|
||||
{step === 'preview' && (
|
||||
@@ -288,7 +310,11 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
|
||||
</Typography>
|
||||
<List disablePadding>
|
||||
{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 }}>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -535,7 +561,8 @@ export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
ArrangementDialog.displayName = 'ArrangementDialog';
|
||||
export default ArrangementDialog;
|
||||
|
||||
@@ -161,6 +161,13 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
|
||||
|
||||
{/* CTAs */}
|
||||
<Box
|
||||
component="form"
|
||||
noValidate
|
||||
aria-busy={loading}
|
||||
onSubmit={(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!loading) onContinue();
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
@@ -170,13 +177,13 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
|
||||
}}
|
||||
>
|
||||
{onSaveAndExit ? (
|
||||
<Button variant="text" color="secondary" onClick={onSaveAndExit}>
|
||||
<Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
|
||||
Save and continue later
|
||||
</Button>
|
||||
) : (
|
||||
<Box />
|
||||
)}
|
||||
<Button variant="contained" size="large" onClick={onContinue} loading={loading}>
|
||||
<Button type="submit" variant="contained" size="large" loading={loading}>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -145,8 +145,8 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
zIndex: 1,
|
||||
bgcolor: 'background.default',
|
||||
pb: 1,
|
||||
mx: -3,
|
||||
px: 3,
|
||||
mx: { xs: -2, md: -3 },
|
||||
px: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||
|
||||
@@ -214,8 +214,8 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
||||
zIndex: 1,
|
||||
bgcolor: 'background.default',
|
||||
pb: 1,
|
||||
mx: -3,
|
||||
px: 3,
|
||||
mx: { xs: -2, md: -3 },
|
||||
px: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||
|
||||
Reference in New Issue
Block a user