CompareBar: collapsible-on-mobile with auto-peek on add

Mobile users can now tap a right-chevron on the expanded bar to slide
it out; a mini peek-pill anchored bottom-right replaces it, showing
just the fraction badge (N/3) + a left-chevron to expand. Tap
anywhere on the mini-pill to bring the full bar back.

Packages-being-tallied feedback: when a new package is added while
the bar is collapsed, the full bar auto-peeks back in for 3 seconds,
then slides out again. The user sees the count update register
without having to tap to expand.

Two stacked Slide wrappers handle the direction-aware transitions:
- Full bar slides up from below (initial show + peek re-entry).
- Mini-pill slides in from the right (on user-triggered collapse).

Collapse state resets to expanded when the basket empties, so the
next fresh fill starts with the friendly default visible.

Desktop (md+) stays permanently expanded — the collapse chevron
doesn't render; there's plenty of space. Collapsing is a mobile-only
affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 12:03:57 +10:00
parent c1a3b30e91
commit eef2ddc844

View File

@@ -2,6 +2,9 @@ import React from 'react';
import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide';
import useMediaQuery from '@mui/material/useMediaQuery';
import IconButton from '@mui/material/IconButton';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import { useTheme, type SxProps, type Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
@@ -31,6 +34,12 @@ export interface CompareBarProps {
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
/** 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. */
const PEEK_DURATION_MS = 3000;
// ─── Component ───────────────────────────────────────────────────────────────
/**
@@ -39,7 +48,14 @@ export interface CompareBarProps {
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
* Present on both ProvidersStep and PackagesStep.
*
* Composes Badge + Button + Typography.
* **Mobile collapse** (xs only): users can tap a right-chevron to slide the
* full bar out to the right. A mini-pill anchored bottom-right replaces it,
* showing just the count badge — tap to bring the full bar back. When a
* new package is added while collapsed, the full bar auto-peeks for
* `PEEK_DURATION_MS` so the user sees the tally update, then re-collapses.
* Desktop (md+) stays expanded — there's plenty of space.
*
* Composes Badge + Button + Typography + IconButton.
*/
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
({ packages, onCompare, error, sx }, ref) => {
@@ -51,8 +67,48 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
// Collapse state — mobile only. Starts expanded; when the basket empties
// (visible → false) we reset to expanded so the next add is visible.
const [collapsed, setCollapsed] = React.useState(false);
const [peeking, setPeeking] = React.useState(false);
const lastCountRef = React.useRef(count);
React.useEffect(() => {
if (!visible) setCollapsed(false);
}, [visible]);
// Auto-peek when a package is added while collapsed — show the full bar
// briefly so the user sees their addition register, then re-collapse.
React.useEffect(() => {
const prev = lastCountRef.current;
lastCountRef.current = count;
if (collapsed && count > prev) {
setPeeking(true);
const t = window.setTimeout(() => setPeeking(false), PEEK_DURATION_MS);
return () => window.clearTimeout(t);
}
}, [count, collapsed]);
// The full bar is visible when the user hasn't collapsed it OR we're
// currently auto-peeking after a count change.
const showFullBar = (!isMobile || !collapsed || peeking) && visible;
// 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 (
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
<>
{/* Full bar — slides up from below (initial show / peek re-entry) */}
<Slide direction="up" in={showFullBar} mountOnEnter unmountOnExit>
<Paper
ref={ref}
elevation={8}
@@ -60,20 +116,14 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
aria-live="polite"
aria-label={`${count} of 3 packages selected for comparison`}
sx={[
(theme: 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: theme.spacing(16),
// Centre via auto-margin rather than `left:50%; translateX(-50%)`
// — Slide (the wrapper) animates via transform, which would
// otherwise clobber the centering transform.
basePaperSx,
{
// Centre via auto-margin — Slide animates transform, which
// would clobber a `translateX(-50%)` centering trick.
left: 0,
right: 0,
mx: 'auto',
width: 'fit-content',
zIndex: theme.zIndex.snackbar,
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
@@ -81,12 +131,11 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
px: { xs: 2, md: 3 },
py: { xs: 1, md: 1.5 },
maxWidth: { xs: 'calc(100vw - 32px)', md: 460 },
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Fraction badge — 1/3, 2/3, 3/3. Responsive: medium on xs,
large on md+ to match the rest of the bar's size step. */}
{/* Fraction badge — medium on xs, large on md+ */}
<Badge
color="brand"
variant="soft"
@@ -96,7 +145,7 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
{count}/3
</Badge>
{/* Status text — body2 on mobile (smaller footprint), body1 on md+ */}
{/* Status text */}
<Typography
variant={isMobile ? 'body2' : 'body1'}
role={error ? 'alert' : undefined}
@@ -109,7 +158,7 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
{error || statusText}
</Typography>
{/* Compare CTA — small (32px) on mobile, medium (40px) on md+ */}
{/* Compare CTA */}
<Button
variant="contained"
size={isMobile ? 'small' : 'medium'}
@@ -119,8 +168,59 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
>
Compare
</Button>
{/* Mobile-only collapse chevron — slides the bar out to the right. */}
{isMobile && (
<IconButton
aria-label="Hide comparison basket"
onClick={() => setCollapsed(true)}
size="small"
sx={{ flexShrink: 0, color: 'text.secondary', ml: -0.5 }}
>
<ChevronRightRoundedIcon />
</IconButton>
)}
</Paper>
</Slide>
{/* Mini peek-pill — slides in from the right when collapsed. */}
<Slide direction="left" in={showMiniPill} mountOnEnter unmountOnExit>
<Paper
elevation={8}
role="button"
tabIndex={0}
aria-label={`Show comparison basket, ${count} of 3 selected`}
onClick={() => setCollapsed(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setCollapsed(false);
}
}}
sx={[
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>
</>
);
},
);