Iterate MiniCard and MapPin based on feedback
MiniCard: - Verified badge → icon-only circle floating in image (top-right) - Reorder content: title → meta → price → badges → chips - Truncated titles show tooltip on hover with full text MapPin: Rethink from price-only pill to two-line label: - Line 1: Provider name (bold, truncated at 180px) - Line 2: "From $X" (smaller, secondary colour) — optional - Communicates who + starting price at a glance - Verified/unverified palette distinction preserved - Dot variant removed (name is always required now) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,9 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
/** Props for the FA MapPin atom */
|
||||
export interface MapPinProps {
|
||||
/** Price in dollars — renders a pill label. Omit for a dot marker. */
|
||||
/** Provider or venue name — always shown on the pin */
|
||||
name: string;
|
||||
/** Starting package price in dollars — shown as "From $X" */
|
||||
price?: number;
|
||||
/** Custom price label (e.g. "POA") — overrides formatted price */
|
||||
priceLabel?: string;
|
||||
@@ -22,23 +24,21 @@ export interface MapPinProps {
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const PIN_HEIGHT = 'var(--fa-map-pin-height)';
|
||||
const PIN_PX = 'var(--fa-map-pin-padding-x)';
|
||||
const PIN_FONT_SIZE = 'var(--fa-map-pin-font-size)';
|
||||
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
|
||||
const DOT_SIZE = 'var(--fa-map-pin-dot-size)';
|
||||
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
|
||||
const MAX_WIDTH = 180;
|
||||
|
||||
// ─── Colour sets ────────────────────────────────────────────────────────────
|
||||
|
||||
const colours = {
|
||||
verified: {
|
||||
bg: 'var(--fa-color-brand-100)',
|
||||
text: 'var(--fa-color-brand-700)',
|
||||
name: 'var(--fa-color-brand-900)',
|
||||
price: 'var(--fa-color-brand-600)',
|
||||
activeBg: 'var(--fa-color-brand-700)',
|
||||
activeText: 'var(--fa-color-white)',
|
||||
dot: 'var(--fa-color-brand-500)',
|
||||
activeDot: 'var(--fa-color-brand-700)',
|
||||
activeName: 'var(--fa-color-white)',
|
||||
activePrice: 'var(--fa-color-brand-200)',
|
||||
nub: 'var(--fa-color-brand-100)',
|
||||
activeNub: 'var(--fa-color-brand-700)',
|
||||
border: 'var(--fa-color-brand-300)',
|
||||
@@ -46,11 +46,11 @@ const colours = {
|
||||
},
|
||||
unverified: {
|
||||
bg: 'var(--fa-color-neutral-100)',
|
||||
text: 'var(--fa-color-neutral-700)',
|
||||
name: 'var(--fa-color-neutral-800)',
|
||||
price: 'var(--fa-color-neutral-500)',
|
||||
activeBg: 'var(--fa-color-neutral-700)',
|
||||
activeText: 'var(--fa-color-white)',
|
||||
dot: 'var(--fa-color-neutral-400)',
|
||||
activeDot: 'var(--fa-color-neutral-700)',
|
||||
activeName: 'var(--fa-color-white)',
|
||||
activePrice: 'var(--fa-color-neutral-200)',
|
||||
nub: 'var(--fa-color-neutral-100)',
|
||||
activeNub: 'var(--fa-color-neutral-700)',
|
||||
border: 'var(--fa-color-neutral-300)',
|
||||
@@ -63,10 +63,12 @@ const colours = {
|
||||
/**
|
||||
* Map marker pin for the FA design system.
|
||||
*
|
||||
* Airbnb-style price pill that sits on a map. When a price is provided,
|
||||
* renders a rounded pill with the price label and a small downward nub
|
||||
* pointing to the exact location. Without a price, renders a small
|
||||
* dot marker.
|
||||
* Two-line label marker showing provider name and starting package
|
||||
* price. Renders as a rounded pill with a downward nub pointing to
|
||||
* the exact map location.
|
||||
*
|
||||
* - **Line 1**: Provider name (bold, truncated)
|
||||
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
|
||||
*
|
||||
* Visual distinction:
|
||||
* - **Verified** providers: warm brand palette (gold bg, copper text)
|
||||
@@ -78,73 +80,34 @@ const colours = {
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <MapPin price={900} verified onClick={() => selectProvider(id)} />
|
||||
* <MapPin verified={false} /> {/* Dot marker, no price *\/}
|
||||
* <MapPin price={900} verified active /> {/* Selected state *\/}
|
||||
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
|
||||
* <MapPin name="Smith & Sons" /> {/* No price, unverified *\/}
|
||||
* <MapPin name="H.Parsons" price={900} verified active />
|
||||
* ```
|
||||
*/
|
||||
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||
({ price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
|
||||
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
|
||||
const palette = verified ? colours.verified : colours.unverified;
|
||||
const hasPrice = price != null || priceLabel != null;
|
||||
|
||||
const label = priceLabel ?? (price != null ? `$${price.toLocaleString('en-AU')}` : undefined);
|
||||
const priceText =
|
||||
priceLabel ?? (price != null ? `From $${price.toLocaleString('en-AU')}` : undefined);
|
||||
|
||||
// Dot variant — no price
|
||||
if (!hasPrice) {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${verified ? 'Verified' : 'Unverified'} provider${active ? ' (selected)' : ''}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e as unknown as React.MouseEvent);
|
||||
}
|
||||
}}
|
||||
sx={[
|
||||
{
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: active ? palette.activeDot : palette.dot,
|
||||
border: '2px solid',
|
||||
borderColor: 'var(--fa-color-white)',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 150ms ease-in-out, background-color 150ms ease-in-out',
|
||||
transform: active ? 'scale(1.3)' : 'scale(1)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.3)',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e as unknown as React.MouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
// Pill variant — price label + nub
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${label}, ${verified ? 'verified' : 'unverified'} provider${active ? ' (selected)' : ''}`}
|
||||
aria-label={`${name}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e as unknown as React.MouseEvent);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
sx={[
|
||||
{
|
||||
display: 'inline-flex',
|
||||
@@ -152,13 +115,13 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 150ms ease-in-out',
|
||||
transform: active ? 'scale(1.1)' : 'scale(1)',
|
||||
transform: active ? 'scale(1.08)' : 'scale(1)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
transform: 'scale(1.08)',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: 'none',
|
||||
'& > .MapPin-pill': {
|
||||
'& > .MapPin-label': {
|
||||
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
@@ -167,32 +130,61 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Pill */}
|
||||
{/* Label pill */}
|
||||
<Box
|
||||
className="MapPin-pill"
|
||||
className="MapPin-label"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: PIN_HEIGHT,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
maxWidth: MAX_WIDTH,
|
||||
py: 0.5,
|
||||
px: PIN_PX,
|
||||
borderRadius: PIN_RADIUS,
|
||||
backgroundColor: active ? palette.activeBg : palette.bg,
|
||||
color: active ? palette.activeText : palette.text,
|
||||
border: '1px solid',
|
||||
borderColor: active ? palette.activeBorder : palette.border,
|
||||
fontSize: PIN_FONT_SIZE,
|
||||
fontWeight: 700,
|
||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
|
||||
transition:
|
||||
'background-color 150ms ease-in-out, color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
|
||||
'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{/* Name */}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
||||
lineHeight: 1.3,
|
||||
color: active ? palette.activeName : palette.name,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
transition: 'color 150ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Box>
|
||||
|
||||
{/* Price line */}
|
||||
{hasPrice && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
||||
lineHeight: 1.2,
|
||||
color: active ? palette.activePrice : palette.price,
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 150ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{priceText}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Nub — downward pointer */}
|
||||
@@ -205,7 +197,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||
borderRight: `${NUB_SIZE} solid transparent`,
|
||||
borderTop: `${NUB_SIZE} solid`,
|
||||
borderTopColor: active ? palette.activeNub : palette.nub,
|
||||
mt: '-1px', // overlap the pill border
|
||||
mt: '-1px',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user