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:
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user