Iterate MapPopup: consistent hierarchy, clickable card, icon badge

- Hierarchy now matches MiniCard: title → meta → price
- Whole card is clickable (onClick prop) — removed View details link
- Verified badge → icon-only circle in image (matches MiniCard)
- Name truncated at 1 line with tooltip on hover
- No-image fallback shows inline verified icon + text
- Added keyboard support (Enter/Space) and focus ring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 20:59:55 +10:00
parent c457ee8b0d
commit 723cdf908a
2 changed files with 131 additions and 77 deletions

View File

@@ -20,6 +20,9 @@ const meta: Meta<typeof MapPopup> = {
values: [{ name: 'map', value: '#E5E3DF' }], values: [{ name: 'map', value: '#E5E3DF' }],
}, },
}, },
argTypes: {
onClick: { action: 'clicked' },
},
}; };
export default meta; export default meta;
@@ -34,7 +37,6 @@ export const VerifiedProvider: Story = {
location: 'Wollongong', location: 'Wollongong',
rating: 4.8, rating: 4.8,
verified: true, verified: true,
onViewDetails: () => {},
}, },
}; };
@@ -44,7 +46,6 @@ export const UnverifiedProvider: Story = {
name: 'Smith & Sons Funeral Services', name: 'Smith & Sons Funeral Services',
price: 1200, price: 1200,
location: 'Sutherland', location: 'Sutherland',
onViewDetails: () => {},
}, },
}; };
@@ -56,23 +57,31 @@ export const Venue: Story = {
price: 450, price: 450,
location: 'Albany Creek', location: 'Albany Creek',
capacity: 120, capacity: 120,
onViewDetails: () => {},
}, },
}; };
/** Minimal — just name and view details */ /** Long name — truncated at 1 line, tooltip on hover */
export const LongName: Story = {
args: {
name: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_PROVIDER,
price: 1200,
location: 'Northern Beaches',
verified: true,
},
};
/** Minimal — just name */
export const Minimal: Story = { export const Minimal: Story = {
args: { args: {
name: 'Local Funeral Provider', name: 'Local Funeral Provider',
onViewDetails: () => {},
}, },
}; };
/** No view details link — display only */ /** Verified without image — inline verified indicator */
export const DisplayOnly: Story = { export const VerifiedNoImage: Story = {
args: { args: {
name: 'H.Parsons Funeral Directors', name: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900, price: 900,
location: 'Wollongong', location: 'Wollongong',
verified: true, verified: true,
@@ -87,7 +96,6 @@ export const CustomPriceLabel: Story = {
priceLabel: 'Price on application', priceLabel: 'Price on application',
location: 'Sydney CBD', location: 'Sydney CBD',
verified: true, verified: true,
onViewDetails: () => {},
}, },
}; };
@@ -122,7 +130,7 @@ export const WithPin: Story = {
location="Wollongong" location="Wollongong"
rating={4.8} rating={4.8}
verified verified
onViewDetails={() => {}} onClick={() => {}}
/> />
<MapPin name="H.Parsons" price={900} verified active /> <MapPin name="H.Parsons" price={900} verified active />
</> </>

View File

@@ -1,14 +1,13 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Tooltip from '@mui/material/Tooltip';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded'; import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'; import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
import { Link } from '../../atoms/Link';
import type { BadgeProps } from '../../atoms/Badge/Badge';
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -18,7 +17,7 @@ export interface MapPopupProps {
name: string; name: string;
/** Hero image URL */ /** Hero image URL */
imageUrl?: string; imageUrl?: string;
/** Price in dollars */ /** Price in dollars — shown as "From $X" */
price?: number; price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */ /** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string; priceLabel?: string;
@@ -28,13 +27,11 @@ export interface MapPopupProps {
rating?: number; rating?: number;
/** Venue capacity */ /** Venue capacity */
capacity?: number; capacity?: number;
/** Whether this provider is verified */ /** Whether this provider is verified — shows icon badge in image */
verified?: boolean; verified?: boolean;
/** Badge colour for the verified badge */ /** Click handler — entire card is clickable */
badgeColor?: BadgeProps['color']; onClick?: () => void;
/** "View details" click handler */ /** MUI sx prop for the root element */
onViewDetails?: () => void;
/** MUI sx prop for the root Paper */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -50,15 +47,18 @@ const NUB_SIZE = 8;
* Map popup card for the FA design system. * Map popup card for the FA design system.
* *
* Floating card anchored to a MapPin on click. Shows a compact * Floating card anchored to a MapPin on click. Shows a compact
* preview of a provider or venue — image, name, price, meta row, * preview of a provider or venue — image, name, meta, and price.
* and a "View details" link. A downward nub visually connects the * The entire card is clickable to navigate to the provider/venue.
* popup to the pin below. *
* Content hierarchy matches MiniCard: **title → meta → price**.
* Truncated names show a tooltip on hover. Verified providers
* show an icon-only badge floating in the image.
* *
* Designed for use as a custom popup in Mapbox GL / Google Maps. * Designed for use as a custom popup in Mapbox GL / Google Maps.
* The parent map container handles positioning; this component * The parent map container handles positioning; this component
* handles content and styling only. * handles content and styling only.
* *
* Composes: Paper + Typography + Badge + Link. * Composes: Paper + Typography + Tooltip.
* *
* Usage: * Usage:
* ```tsx * ```tsx
@@ -69,7 +69,7 @@ const NUB_SIZE = 8;
* location="Wollongong" * location="Wollongong"
* rating={4.8} * rating={4.8}
* verified * verified
* onViewDetails={() => selectProvider(id)} * onClick={() => selectProvider(id)}
* /> * />
* ``` * ```
*/ */
@@ -84,8 +84,7 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
rating, rating,
capacity, capacity,
verified = false, verified = false,
badgeColor = 'brand', onClick,
onViewDetails,
sx, sx,
}, },
ref, ref,
@@ -93,16 +92,52 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
const hasMeta = location != null || rating != null || capacity != null; const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null; const hasPrice = price != null || priceLabel != null;
// Detect name truncation for tooltip
const nameRef = React.useRef<HTMLElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
React.useEffect(() => {
const el = nameRef.current;
if (el) {
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
}
}, [name]);
return ( return (
<Box <Box
ref={ref} ref={ref}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={onClick}
onKeyDown={
onClick
? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
aria-label={onClick ? `View ${name}` : undefined}
sx={[ sx={[
{ {
display: 'inline-flex', display: 'inline-flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
// Offset the popup upward so the nub sits on top of the pin
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))', filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
cursor: onClick ? 'pointer' : 'default',
transition: 'transform 150ms ease-in-out',
'&:hover': onClick
? {
transform: 'scale(1.02)',
}
: undefined,
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
borderRadius: 'var(--fa-card-border-radius-default)',
},
}, },
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
@@ -130,45 +165,48 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
backgroundColor: 'var(--fa-color-neutral-100)', backgroundColor: 'var(--fa-color-neutral-100)',
}} }}
> >
{/* Verified badge inside image */} {/* Verified icon badge — floating top-right */}
{verified && ( {verified && (
<Box sx={{ position: 'absolute', top: 8, right: 8 }}> <Tooltip title="Verified provider" arrow placement="top">
<Badge variant="filled" color={badgeColor} size="small"> <Box
Verified sx={{
</Badge> position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: 'var(--fa-color-brand-600)',
color: 'var(--fa-color-white)',
boxShadow: 'var(--fa-shadow-sm)',
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />
</Box> </Box>
</Tooltip>
)} )}
</Box> </Box>
)} )}
{/* ── Content ── */} {/* ── Content ── */}
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: '4px' }}> <Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* Name */} {/* 1. Name — with tooltip when truncated */}
<Typography variant="body2" sx={{ fontWeight: 600 }} maxLines={2}> <Tooltip
title={isTruncated ? name : ''}
arrow
placement="top"
enterDelay={300}
disableHoverListener={!isTruncated}
>
<Typography ref={nameRef} variant="body2" sx={{ fontWeight: 600 }} maxLines={1}>
{name} {name}
</Typography> </Typography>
</Tooltip>
{/* Price */} {/* 2. Meta row */}
{hasPrice && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
{priceLabel ? (
<Typography variant="caption" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography variant="caption" color="primary" sx={{ fontWeight: 600 }}>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)}
{/* Meta row */}
{hasMeta && ( {hasMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{location && ( {location && (
@@ -209,26 +247,34 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
</Box> </Box>
)} )}
{/* Verified badge (no image fallback) */} {/* 3. Price */}
{verified && !imageUrl && ( {hasPrice && (
<Badge variant="filled" color={badgeColor} size="small"> <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
Verified {priceLabel ? (
</Badge> <Typography variant="caption" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography variant="caption" color="primary" sx={{ fontWeight: 600 }}>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)} )}
{/* View details link */} {/* Verified indicator (no-image fallback) */}
{onViewDetails && ( {verified && !imageUrl && (
<Link <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
component="button" <VerifiedOutlinedIcon sx={{ fontSize: 14, color: 'var(--fa-color-brand-600)' }} />
variant="caption" <Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
onClick={(e: React.MouseEvent) => { Verified
e.stopPropagation(); </Typography>
onViewDetails(); </Box>
}}
sx={{ mt: 0.5, alignSelf: 'flex-start' }}
>
View details
</Link>
)} )}
</Box> </Box>
</Paper> </Paper>