From 9281020d3a132af00b24ccbab219c0790c8cf4d7 Mon Sep 17 00:00:00 2001 From: Richie Date: Thu, 23 Apr 2026 12:18:26 +1000 Subject: [PATCH] CompareBar: unify collapse animation + polish + z-index below drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../molecules/CompareBar/CompareBar.tsx | 236 +++++++++--------- .../MapProviderDrawer/MapProviderDrawer.tsx | 9 +- 2 files changed, 127 insertions(+), 118 deletions(-) diff --git a/src/components/molecules/CompareBar/CompareBar.tsx b/src/components/molecules/CompareBar/CompareBar.tsx index a240c91..80de000 100644 --- a/src/components/molecules/CompareBar/CompareBar.tsx +++ b/src/components/molecules/CompareBar/CompareBar.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import Box from '@mui/material/Box'; import Paper from '@mui/material/Paper'; import Slide from '@mui/material/Slide'; import useMediaQuery from '@mui/material/useMediaQuery'; @@ -37,8 +38,10 @@ export interface CompareBarProps { // ─── 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. */ + * collapsed. Long enough to read, short enough not to obstruct. */ const PEEK_DURATION_MS = 3000; +/** Middle-content expand/collapse duration (width + opacity). */ +const COLLAPSE_MS = 300; // ─── 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. * Present on both ProvidersStep and PackagesStep. * - * **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 + * **Mobile collapse** (xs only): users can tap a right-chevron to retract + * the pill to the right edge — the middle content (status text + Compare + * button) animates to width:0 while the pill stays anchored at the same + * 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. - * 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. */ @@ -68,7 +75,7 @@ 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. + // we reset so the next fresh fill starts visible. const [collapsed, setCollapsed] = React.useState(false); const [peeking, setPeeking] = React.useState(false); const lastCountRef = React.useRef(count); @@ -77,8 +84,7 @@ export const CompareBar = React.forwardRef( 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. + // Auto-peek when a package is added while collapsed. React.useEffect(() => { const prev = lastCountRef.current; lastCountRef.current = count; @@ -89,63 +95,84 @@ export const CompareBar = React.forwardRef( } }, [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; + /** Effective "is the middle content hidden?" — only on mobile, when the + * user has collapsed and we're not currently peeking. */ + const mobileCollapsed = isMobile && collapsed && !peeking; return ( - <> - {/* Full bar — slides up from below (initial show / peek re-entry) */} - - + ({ + position: 'fixed', + // Clear the sticky HelpBar (~40px) + breathing room. FA theme + // uses a 4px spacing base, so spacing(16) = 64px. + bottom: t.spacing(16), + // z-index sits below the mobile map-view drawer (modal: 1300) + // but above app chrome (appBar: 1100). snackbar (1400) was too + // aggressive — the drawer visually covers this bar on mobile. + zIndex: t.zIndex.drawer, + // Mobile: right-anchored so when the middle collapses the pill + // appears to retract to the right corner. Desktop: centered. + ...(isMobile + ? { right: t.spacing(4), left: 'auto' } + : { left: 0, right: 0, mx: 'auto' }), + width: 'fit-content', + borderRadius: '9999px', + display: 'flex', + alignItems: 'center', + 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). */} + - {/* Fraction badge — medium on xs, large on md+ */} - - {count}/3 - + {mobileCollapsed ? count : `${count}/3`} + - {/* 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. */} + ( fontWeight: 500, whiteSpace: 'nowrap', color: error ? 'var(--fa-color-text-brand)' : 'text.primary', + flexShrink: 0, }} > {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, - }, - ]} - > - - {count}/3 - - - - - + {/* Mobile-only collapse/expand chevron — grey-filled circle that + swaps icon direction based on state. Rendered at all times so + the IconButton container stays in the layout and the icon swap + happens in place without mount/unmount. */} + {isMobile && ( + setCollapsed((c) => !c)} + size="small" + sx={{ + flexShrink: 0, + width: 32, + height: 32, + borderRadius: '50%', + bgcolor: 'var(--fa-color-neutral-200)', + color: 'text.secondary', + '&:hover': { bgcolor: 'var(--fa-color-neutral-300)' }, + }} + > + {mobileCollapsed ? ( + + ) : ( + + )} + + )} + + ); }, ); diff --git a/src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx b/src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx index ecc7b4d..9e3f44d 100644 --- a/src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx +++ b/src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx @@ -163,12 +163,15 @@ export const MapProviderDrawer = React.forwardRef ({ position: 'absolute', bottom: 0, left: 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', overflow: 'auto', borderRadius: 0, @@ -179,7 +182,7 @@ export const MapProviderDrawer = React.forwardRef