Files
Parsons/src/components/molecules/CompareBar/CompareBar.tsx
Richie 9281020d3a 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>
2026-04-23 12:18:26 +10:00

236 lines
9.3 KiB
TypeScript

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';
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';
import { Badge } from '../../atoms/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A package in the comparison basket */
export interface CompareBarPackage {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Provider name */
providerName: string;
}
/** Props for the CompareBar molecule */
export interface CompareBarProps {
/** Packages currently in the comparison basket (max 3 user-selected) */
packages: CompareBarPackage[];
/** Called when user clicks "Compare" CTA */
onCompare: () => void;
/** Error/status message shown inline (e.g. "Maximum 3 packages") */
error?: string;
/** MUI sx prop for the root wrapper */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
/** How long the bar stays expanded after a new package is added while
* 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 ───────────────────────────────────────────────────────────────
/**
* Floating comparison basket pill for the FA design system.
*
* 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 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, and the
* collapse chevron is not rendered.
*
* Composes Badge + Button + Typography + IconButton.
*/
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
({ packages, onCompare, error, sx }, ref) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const count = packages.length;
const visible = count > 0;
const canCompare = count >= 2;
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
// Collapse state — mobile only. Starts expanded; when the basket empties
// 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);
React.useEffect(() => {
if (!visible) setCollapsed(false);
}, [visible]);
// Auto-peek when a package is added while collapsed.
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]);
/** 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 (
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
<Paper
ref={ref}
elevation={8}
role="status"
aria-live="polite"
aria-label={`${count} of 3 packages selected for comparison`}
sx={[
(t: Theme) => ({
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). */}
<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,
}),
}}
>
{mobileCollapsed ? count : `${count}/3`}
</Badge>
{/* 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
variant={isMobile ? 'body2' : 'body1'}
role={error ? 'alert' : undefined}
sx={{
fontWeight: 500,
whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
flexShrink: 0,
}}
>
{error || statusText}
</Typography>
<Button
variant="contained"
size={isMobile ? 'small' : 'medium'}
onClick={onCompare}
disabled={!canCompare}
tabIndex={mobileCollapsed ? -1 : 0}
sx={{ flexShrink: 0, borderRadius: '9999px' }}
>
Compare
</Button>
</Box>
{/* 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 && (
<IconButton
aria-label={mobileCollapsed ? 'Show comparison basket' : 'Hide comparison basket'}
aria-expanded={!mobileCollapsed}
onClick={() => 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 ? (
<ChevronLeftRoundedIcon fontSize="small" />
) : (
<ChevronRightRoundedIcon fontSize="small" />
)}
</IconButton>
)}
</Paper>
</Slide>
);
},
);
CompareBar.displayName = 'CompareBar';
export default CompareBar;