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:
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user