From eef2ddc8449db38404092477794a3284ebd50fab Mon Sep 17 00:00:00 2001 From: Richie Date: Thu, 23 Apr 2026 12:03:57 +1000 Subject: [PATCH] CompareBar: collapsible-on-mobile with auto-peek on add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../molecules/CompareBar/CompareBar.tsx | 232 +++++++++++++----- 1 file changed, 166 insertions(+), 66 deletions(-) diff --git a/src/components/molecules/CompareBar/CompareBar.tsx b/src/components/molecules/CompareBar/CompareBar.tsx index ee00b29..a240c91 100644 --- a/src/components/molecules/CompareBar/CompareBar.tsx +++ b/src/components/molecules/CompareBar/CompareBar.tsx @@ -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; } +// ─── 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( ({ packages, onCompare, error, sx }, ref) => { @@ -51,76 +67,160 @@ export const CompareBar = React.forwardRef( 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 ( - - ({ - 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. - left: 0, - right: 0, - mx: 'auto', - width: 'fit-content', - zIndex: theme.zIndex.snackbar, - borderRadius: '9999px', - display: 'flex', - alignItems: 'center', - gap: { xs: 1.5, md: 2 }, - 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. */} - + {/* Full bar — slides up from below (initial show / peek re-entry) */} + + - {count}/3 - + {/* Fraction badge — medium on xs, large on md+ */} + + {count}/3 + - {/* Status text — body2 on mobile (smaller footprint), body1 on md+ */} - + {error || statusText} + + + {/* Compare CTA */} + + + {/* Mobile-only collapse chevron — slides the bar out to the right. */} + {isMobile && ( + setCollapsed(true)} + size="small" + sx={{ flexShrink: 0, color: 'text.secondary', ml: -0.5 }} + > + + + )} + + + + {/* Mini peek-pill — slides in from the right when collapsed. */} + + 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, + }, + ]} > - {error || statusText} - - - {/* Compare CTA — small (32px) on mobile, medium (40px) on md+ */} - - - + + {count}/3 + + + + + ); }, );