CompareBar: unify collapse animation + polish + z-index below drawer

- Single-Paper collapse: dropped the two-Slide scheme for a single
  right-anchored Paper on mobile. The middle content (status text +
  Compare button) animates to max-width:0 while the pill's right edge
  stays fixed, so the whole thing appears to retract to the corner as
  one unit rather than two stacked transitions.

- Collapse chevron: grey-filled circle (neutral-200 bg, neutral-300
  hover) that swaps between right-chevron (collapse) and left-chevron
  (expand) based on state. Always rendered — the IconButton stays in
  the layout so the icon swap happens in place.

- Collapsed badge: shows just the count ("1") instead of "1/3" so it
  reads as a circle at mini size. Min-width pinned to badge-height-md
  so any digit (1–3) renders circular. Expanded state keeps "N/3".

- z-index fix: CompareBar dropped from snackbar (1400) → drawer (1200);
  MapProviderDrawer raised from 3 → modal (1300). The drawer now
  visually covers the CompareBar when a pin or cluster is active on
  the mobile map view. CompareBar returns as soon as the drawer is
  dismissed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 12:18:26 +10:00
parent eef2ddc844
commit 9281020d3a
2 changed files with 127 additions and 118 deletions

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide'; import Slide from '@mui/material/Slide';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
@@ -37,8 +38,10 @@ export interface CompareBarProps {
// ─── Constants ────────────────────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────────────────────
/** How long the bar stays expanded after a new package is added while /** How long the bar stays expanded after a new package is added while
* collapsed. Long enough to read the update, short enough not to obstruct. */ * collapsed. Long enough to read, short enough not to obstruct. */
const PEEK_DURATION_MS = 3000; const PEEK_DURATION_MS = 3000;
/** Middle-content expand/collapse duration (width + opacity). */
const COLLAPSE_MS = 300;
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
@@ -48,12 +51,16 @@ const PEEK_DURATION_MS = 3000;
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA. * Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
* Present on both ProvidersStep and PackagesStep. * Present on both ProvidersStep and PackagesStep.
* *
* **Mobile collapse** (xs only): users can tap a right-chevron to slide the * **Mobile collapse** (xs only): users can tap a right-chevron to retract
* full bar out to the right. A mini-pill anchored bottom-right replaces it, * the pill to the right edge — the middle content (status text + Compare
* showing just the count badge — tap to bring the full bar back. When a * button) animates to width:0 while the pill stays anchored at the same
* new package is added while collapsed, the full bar auto-peeks for * right offset, so the whole thing appears to shrink into the corner as
* one unit rather than two separate elements. Tap again to expand. When
* a new package is added while collapsed, the bar auto-peeks for
* `PEEK_DURATION_MS` so the user sees the tally update, then re-collapses. * `PEEK_DURATION_MS` so the user sees the tally update, then re-collapses.
* Desktop (md+) stays expanded — there's plenty of space. *
* Desktop (md+) stays expanded — there's plenty of space, and the
* collapse chevron is not rendered.
* *
* Composes Badge + Button + Typography + IconButton. * Composes Badge + Button + Typography + IconButton.
*/ */
@@ -68,7 +75,7 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare'; const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
// Collapse state — mobile only. Starts expanded; when the basket empties // Collapse state — mobile only. Starts expanded; when the basket empties
// (visible → false) we reset to expanded so the next add is visible. // we reset so the next fresh fill starts visible.
const [collapsed, setCollapsed] = React.useState(false); const [collapsed, setCollapsed] = React.useState(false);
const [peeking, setPeeking] = React.useState(false); const [peeking, setPeeking] = React.useState(false);
const lastCountRef = React.useRef(count); const lastCountRef = React.useRef(count);
@@ -77,8 +84,7 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
if (!visible) setCollapsed(false); if (!visible) setCollapsed(false);
}, [visible]); }, [visible]);
// Auto-peek when a package is added while collapsed — show the full bar // Auto-peek when a package is added while collapsed.
// briefly so the user sees their addition register, then re-collapse.
React.useEffect(() => { React.useEffect(() => {
const prev = lastCountRef.current; const prev = lastCountRef.current;
lastCountRef.current = count; lastCountRef.current = count;
@@ -89,63 +95,84 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
} }
}, [count, collapsed]); }, [count, collapsed]);
// The full bar is visible when the user hasn't collapsed it OR we're /** Effective "is the middle content hidden?" — only on mobile, when the
// currently auto-peeking after a count change. * user has collapsed and we're not currently peeking. */
const showFullBar = (!isMobile || !collapsed || peeking) && visible; const mobileCollapsed = isMobile && collapsed && !peeking;
// The mini peek-pill renders only on mobile when collapsed AND we're
// not currently auto-peeking the full bar back in.
const showMiniPill = isMobile && visible && collapsed && !peeking;
const basePaperSx = (t: Theme) =>
({
position: 'fixed',
// Clear the sticky HelpBar (~40px tall) with ~25px of breathing
// space. FA theme.spacing uses a 4px base, so spacing(16) = 64px.
bottom: t.spacing(16),
zIndex: t.zIndex.snackbar,
}) as const;
return ( return (
<> <Slide direction="up" in={visible} mountOnEnter unmountOnExit>
{/* Full bar — slides up from below (initial show / peek re-entry) */} <Paper
<Slide direction="up" in={showFullBar} mountOnEnter unmountOnExit> ref={ref}
<Paper elevation={8}
ref={ref} role="status"
elevation={8} aria-live="polite"
role="status" aria-label={`${count} of 3 packages selected for comparison`}
aria-live="polite" sx={[
aria-label={`${count} of 3 packages selected for comparison`} (t: Theme) => ({
sx={[ position: 'fixed',
basePaperSx, // Clear the sticky HelpBar (~40px) + breathing room. FA theme
{ // uses a 4px spacing base, so spacing(16) = 64px.
// Centre via auto-margin — Slide animates transform, which bottom: t.spacing(16),
// would clobber a `translateX(-50%)` centering trick. // z-index sits below the mobile map-view drawer (modal: 1300)
left: 0, // but above app chrome (appBar: 1100). snackbar (1400) was too
right: 0, // aggressive — the drawer visually covers this bar on mobile.
mx: 'auto', zIndex: t.zIndex.drawer,
width: 'fit-content', // Mobile: right-anchored so when the middle collapses the pill
borderRadius: '9999px', // appears to retract to the right corner. Desktop: centered.
display: 'flex', ...(isMobile
alignItems: 'center', ? { right: t.spacing(4), left: 'auto' }
gap: { xs: 1.5, md: 2 }, : { left: 0, right: 0, mx: 'auto' }),
px: { xs: 2, md: 3 }, width: 'fit-content',
py: { xs: 1, md: 1.5 }, borderRadius: '9999px',
maxWidth: { xs: 'calc(100vw - 32px)', md: 460 }, display: 'flex',
}, alignItems: 'center',
...(Array.isArray(sx) ? sx : [sx]), gap: { xs: 1.25, md: 2 },
]} px: { xs: 1.5, md: 3 },
py: { xs: 0.75, md: 1.5 },
maxWidth: { xs: 'calc(100vw - 32px)', md: 460 },
overflow: 'hidden',
transition: `padding ${COLLAPSE_MS}ms ease-out`,
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Fraction badge — shows "N/3" when expanded, just "N" when
collapsed on mobile (reads as a circle at mini size). */}
<Badge
color="brand"
variant="soft"
size={isMobile ? 'medium' : 'large'}
sx={{
flexShrink: 0,
// When collapsed, force the badge toward a circle by
// equalising min-width and min-height at the medium-badge
// height (26px).
...(mobileCollapsed && {
minWidth: 'var(--fa-badge-height-md)',
justifyContent: 'center',
px: 0,
}),
}}
> >
{/* Fraction badge — medium on xs, large on md+ */} {mobileCollapsed ? count : `${count}/3`}
<Badge </Badge>
color="brand"
variant="soft"
size={isMobile ? 'medium' : 'large'}
sx={{ flexShrink: 0 }}
>
{count}/3
</Badge>
{/* Status text */} {/* Middle content (status + Compare CTA) — animates to zero
max-width when collapsed, letting the pill shrink as one unit
with the right edge staying fixed. */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: { xs: 1.25, md: 2 },
maxWidth: mobileCollapsed ? 0 : 600,
opacity: mobileCollapsed ? 0 : 1,
overflow: 'hidden',
transition: `max-width ${COLLAPSE_MS}ms ease-out, opacity ${Math.round(
COLLAPSE_MS * 0.6,
)}ms ease-out`,
}}
>
<Typography <Typography
variant={isMobile ? 'body2' : 'body1'} variant={isMobile ? 'body2' : 'body1'}
role={error ? 'alert' : undefined} role={error ? 'alert' : undefined}
@@ -153,74 +180,53 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
fontWeight: 500, fontWeight: 500,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary', color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
flexShrink: 0,
}} }}
> >
{error || statusText} {error || statusText}
</Typography> </Typography>
{/* Compare CTA */}
<Button <Button
variant="contained" variant="contained"
size={isMobile ? 'small' : 'medium'} size={isMobile ? 'small' : 'medium'}
onClick={onCompare} onClick={onCompare}
disabled={!canCompare} disabled={!canCompare}
tabIndex={mobileCollapsed ? -1 : 0}
sx={{ flexShrink: 0, borderRadius: '9999px' }} sx={{ flexShrink: 0, borderRadius: '9999px' }}
> >
Compare Compare
</Button> </Button>
</Box>
{/* Mobile-only collapse chevron — slides the bar out to the right. */} {/* Mobile-only collapse/expand chevron — grey-filled circle that
{isMobile && ( swaps icon direction based on state. Rendered at all times so
<IconButton the IconButton container stays in the layout and the icon swap
aria-label="Hide comparison basket" happens in place without mount/unmount. */}
onClick={() => setCollapsed(true)} {isMobile && (
size="small" <IconButton
sx={{ flexShrink: 0, color: 'text.secondary', ml: -0.5 }} aria-label={mobileCollapsed ? 'Show comparison basket' : 'Hide comparison basket'}
> aria-expanded={!mobileCollapsed}
<ChevronRightRoundedIcon /> onClick={() => setCollapsed((c) => !c)}
</IconButton> size="small"
)} sx={{
</Paper> flexShrink: 0,
</Slide> width: 32,
height: 32,
{/* Mini peek-pill — slides in from the right when collapsed. */} borderRadius: '50%',
<Slide direction="left" in={showMiniPill} mountOnEnter unmountOnExit> bgcolor: 'var(--fa-color-neutral-200)',
<Paper color: 'text.secondary',
elevation={8} '&:hover': { bgcolor: 'var(--fa-color-neutral-300)' },
role="button" }}
tabIndex={0} >
aria-label={`Show comparison basket, ${count} of 3 selected`} {mobileCollapsed ? (
onClick={() => setCollapsed(false)} <ChevronLeftRoundedIcon fontSize="small" />
onKeyDown={(e) => { ) : (
if (e.key === 'Enter' || e.key === ' ') { <ChevronRightRoundedIcon fontSize="small" />
e.preventDefault(); )}
setCollapsed(false); </IconButton>
} )}
}} </Paper>
sx={[ </Slide>
basePaperSx,
{
right: (t) => t.spacing(4),
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
gap: 0.5,
pl: 1,
pr: 0.5,
py: 0.5,
cursor: 'pointer',
// Tap target — ensure at least 44px high even with small badge
minHeight: 44,
},
]}
>
<Badge color="brand" variant="soft" size="medium">
{count}/3
</Badge>
<ChevronLeftRoundedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</Paper>
</Slide>
</>
); );
}, },
); );

View File

@@ -163,12 +163,15 @@ export const MapProviderDrawer = React.forwardRef<HTMLDivElement, MapProviderDra
aria-label={ariaLabel} aria-label={ariaLabel}
aria-hidden={!isOpen} aria-hidden={!isOpen}
sx={[ sx={[
{ (t) => ({
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
zIndex: 3, // Sit above the floating CompareBar (which uses zIndex.drawer)
// so that when a pin or cluster is active the drawer visually
// covers the bar, not vice versa.
zIndex: t.zIndex.modal,
maxHeight: '60vh', maxHeight: '60vh',
overflow: 'auto', overflow: 'auto',
borderRadius: 0, borderRadius: 0,
@@ -179,7 +182,7 @@ export const MapProviderDrawer = React.forwardRef<HTMLDivElement, MapProviderDra
transition: 'transform 220ms ease-out', transition: 'transform 220ms ease-out',
pointerEvents: isOpen ? 'auto' : 'none', pointerEvents: isOpen ? 'auto' : 'none',
visibility: isOpen || isExiting ? 'visible' : 'hidden', visibility: isOpen || isExiting ? 'visible' : 'hidden',
}, }),
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
> >