Add MapPin atom — price-pill map markers with verified/unverified distinction
Airbnb-style markers: pill variant (price label + nub) and dot variant (no price). Verified = brand palette, unverified = neutral grey. Active state inverts colours + scale-up. Pure CSS for map overlay use. Keyboard accessible with role="button" and focus ring. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
141
src/components/atoms/MapPin/MapPin.stories.tsx
Normal file
141
src/components/atoms/MapPin/MapPin.stories.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { MapPin } from './MapPin';
|
||||||
|
|
||||||
|
const meta: Meta<typeof MapPin> = {
|
||||||
|
title: 'Atoms/MapPin',
|
||||||
|
component: MapPin,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
backgrounds: {
|
||||||
|
default: 'map',
|
||||||
|
values: [{ name: 'map', value: '#E5E3DF' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
onClick: { action: 'clicked' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof MapPin>;
|
||||||
|
|
||||||
|
/** Verified provider with price — warm brand pill */
|
||||||
|
export const VerifiedWithPrice: Story = {
|
||||||
|
args: {
|
||||||
|
price: 900,
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Unverified provider with price — neutral grey pill */
|
||||||
|
export const UnverifiedWithPrice: Story = {
|
||||||
|
args: {
|
||||||
|
price: 1200,
|
||||||
|
verified: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Active/selected state — inverted colours, slight scale-up */
|
||||||
|
export const Active: Story = {
|
||||||
|
args: {
|
||||||
|
price: 900,
|
||||||
|
verified: true,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Active unverified */
|
||||||
|
export const ActiveUnverified: Story = {
|
||||||
|
args: {
|
||||||
|
price: 1200,
|
||||||
|
verified: false,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dot marker — no price, verified */
|
||||||
|
export const DotVerified: Story = {
|
||||||
|
args: {
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dot marker — no price, unverified */
|
||||||
|
export const DotUnverified: Story = {
|
||||||
|
args: {
|
||||||
|
verified: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dot marker — active */
|
||||||
|
export const DotActive: Story = {
|
||||||
|
args: {
|
||||||
|
verified: true,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Custom price label */
|
||||||
|
export const CustomLabel: Story = {
|
||||||
|
args: {
|
||||||
|
priceLabel: 'POA',
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** High price — wider pill */
|
||||||
|
export const HighPrice: Story = {
|
||||||
|
args: {
|
||||||
|
price: 12500,
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map simulation — multiple pins on a mock map background */
|
||||||
|
export const MapSimulation: Story = {
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: 600,
|
||||||
|
height: 400,
|
||||||
|
bgcolor: '#E5E3DF',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
render: () => (
|
||||||
|
<>
|
||||||
|
{/* Verified providers */}
|
||||||
|
<Box sx={{ position: 'absolute', top: 80, left: 120 }}>
|
||||||
|
<MapPin price={900} verified onClick={() => {}} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: 'absolute', top: 160, left: 300 }}>
|
||||||
|
<MapPin price={1450} verified active onClick={() => {}} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: 'absolute', top: 240, left: 180 }}>
|
||||||
|
<MapPin price={2200} verified onClick={() => {}} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Unverified providers */}
|
||||||
|
<Box sx={{ position: 'absolute', top: 120, left: 420 }}>
|
||||||
|
<MapPin price={1100} onClick={() => {}} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: 'absolute', top: 280, left: 380 }}>
|
||||||
|
<MapPin onClick={() => {}} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dot markers */}
|
||||||
|
<Box sx={{ position: 'absolute', top: 60, left: 480 }}>
|
||||||
|
<MapPin verified onClick={() => {}} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
217
src/components/atoms/MapPin/MapPin.tsx
Normal file
217
src/components/atoms/MapPin/MapPin.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the FA MapPin atom */
|
||||||
|
export interface MapPinProps {
|
||||||
|
/** Price in dollars — renders a pill label. Omit for a dot marker. */
|
||||||
|
price?: number;
|
||||||
|
/** Custom price label (e.g. "POA") — overrides formatted price */
|
||||||
|
priceLabel?: string;
|
||||||
|
/** Whether this provider/venue is verified (brand colour vs neutral) */
|
||||||
|
verified?: boolean;
|
||||||
|
/** Whether this pin is currently active/selected */
|
||||||
|
active?: boolean;
|
||||||
|
/** Click handler */
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
/** MUI sx prop for the root element */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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)';
|
||||||
|
|
||||||
|
// ─── Colour sets ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const colours = {
|
||||||
|
verified: {
|
||||||
|
bg: 'var(--fa-color-brand-100)',
|
||||||
|
text: 'var(--fa-color-brand-700)',
|
||||||
|
activeBg: 'var(--fa-color-brand-700)',
|
||||||
|
activeText: 'var(--fa-color-white)',
|
||||||
|
dot: 'var(--fa-color-brand-500)',
|
||||||
|
activeDot: 'var(--fa-color-brand-700)',
|
||||||
|
nub: 'var(--fa-color-brand-100)',
|
||||||
|
activeNub: 'var(--fa-color-brand-700)',
|
||||||
|
border: 'var(--fa-color-brand-300)',
|
||||||
|
activeBorder: 'var(--fa-color-brand-700)',
|
||||||
|
},
|
||||||
|
unverified: {
|
||||||
|
bg: 'var(--fa-color-neutral-100)',
|
||||||
|
text: 'var(--fa-color-neutral-700)',
|
||||||
|
activeBg: 'var(--fa-color-neutral-700)',
|
||||||
|
activeText: 'var(--fa-color-white)',
|
||||||
|
dot: 'var(--fa-color-neutral-400)',
|
||||||
|
activeDot: 'var(--fa-color-neutral-700)',
|
||||||
|
nub: 'var(--fa-color-neutral-100)',
|
||||||
|
activeNub: 'var(--fa-color-neutral-700)',
|
||||||
|
border: 'var(--fa-color-neutral-300)',
|
||||||
|
activeBorder: 'var(--fa-color-neutral-700)',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Visual distinction:
|
||||||
|
* - **Verified** providers: warm brand palette (gold bg, copper text)
|
||||||
|
* - **Unverified** providers: neutral grey palette
|
||||||
|
* - **Active/selected**: inverted colours (dark bg, white text) + scale-up
|
||||||
|
*
|
||||||
|
* Designed for use as custom HTML markers in Mapbox GL / Google Maps.
|
||||||
|
* Pure CSS — no canvas, no SVG dependency.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* <MapPin price={900} verified onClick={() => selectProvider(id)} />
|
||||||
|
* <MapPin verified={false} /> {/* Dot marker, no price *\/}
|
||||||
|
* <MapPin price={900} verified active /> {/* Selected state *\/}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||||
|
({ 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);
|
||||||
|
|
||||||
|
// 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]),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pill variant — price label + nub
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${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={[
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 150ms ease-in-out',
|
||||||
|
transform: active ? 'scale(1.1)' : 'scale(1)',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.1)',
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: 'none',
|
||||||
|
'& > .MapPin-pill': {
|
||||||
|
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Pill */}
|
||||||
|
<Box
|
||||||
|
className="MapPin-pill"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: PIN_HEIGHT,
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Nub — downward pointer */}
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderLeft: `${NUB_SIZE} solid transparent`,
|
||||||
|
borderRight: `${NUB_SIZE} solid transparent`,
|
||||||
|
borderTop: `${NUB_SIZE} solid`,
|
||||||
|
borderTopColor: active ? palette.activeNub : palette.nub,
|
||||||
|
mt: '-1px', // overlap the pill border
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
MapPin.displayName = 'MapPin';
|
||||||
|
export default MapPin;
|
||||||
2
src/components/atoms/MapPin/index.ts
Normal file
2
src/components/atoms/MapPin/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { MapPin, default } from './MapPin';
|
||||||
|
export type { MapPinProps } from './MapPin';
|
||||||
Reference in New Issue
Block a user