Initial commit: FA Design System source files
Copy of the Funeral Arranger design system components, theme, tokens, and Storybook config from the original Parsons project. Pre-upgrade baseline with React 18, MUI v5, Storybook 8. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
77
src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx
Normal file
77
src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ClusterMarker } from './ClusterMarker';
|
||||
|
||||
const meta: Meta<typeof ClusterMarker> = {
|
||||
title: 'Atoms/ClusterMarker',
|
||||
component: ClusterMarker,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'map',
|
||||
values: [{ name: 'map', value: '#E5E3DF' }],
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
onClick: { action: 'clicked' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ClusterMarker>;
|
||||
|
||||
/** Cluster containing at least one verified provider — promoted palette */
|
||||
export const MixedOrVerified: Story = {
|
||||
args: {
|
||||
count: 5,
|
||||
hasVerified: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Cluster of all-unverified providers — neutral palette */
|
||||
export const AllUnverified: Story = {
|
||||
args: {
|
||||
count: 3,
|
||||
hasVerified: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** Small cluster — pair of providers */
|
||||
export const Pair: Story = {
|
||||
args: {
|
||||
count: 2,
|
||||
hasVerified: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Large cluster — double-digit count */
|
||||
export const LargeCluster: Story = {
|
||||
args: {
|
||||
count: 27,
|
||||
hasVerified: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Side-by-side comparison — verified vs unverified at various counts */
|
||||
export const PaletteGrid: Story = {
|
||||
render: () => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: 6,
|
||||
p: 4,
|
||||
}}
|
||||
>
|
||||
<ClusterMarker count={2} hasVerified />
|
||||
<ClusterMarker count={5} hasVerified />
|
||||
<ClusterMarker count={12} hasVerified />
|
||||
<ClusterMarker count={99} hasVerified />
|
||||
<ClusterMarker count={2} />
|
||||
<ClusterMarker count={5} />
|
||||
<ClusterMarker count={12} />
|
||||
<ClusterMarker count={99} />
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal file
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Props for the FA ClusterMarker atom */
|
||||
export interface ClusterMarkerProps {
|
||||
/** Number of providers in this cluster */
|
||||
count: number;
|
||||
/** True if any provider in the cluster is verified — drives the promoted palette */
|
||||
hasVerified?: boolean;
|
||||
/** Click handler — opens the cluster popup */
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
/** MUI sx prop for the root element */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
|
||||
const BADGE_SIZE = 36;
|
||||
|
||||
// ─── Colour sets — matches MapPin ───────────────────────────────────────────
|
||||
|
||||
const colours = {
|
||||
verified: {
|
||||
bg: 'var(--fa-color-brand-700)',
|
||||
text: 'var(--fa-color-white)',
|
||||
border: 'var(--fa-color-brand-700)',
|
||||
nub: 'var(--fa-color-brand-700)',
|
||||
},
|
||||
unverified: {
|
||||
bg: 'var(--fa-color-neutral-100)',
|
||||
text: 'var(--fa-color-neutral-800)',
|
||||
border: 'var(--fa-color-neutral-300)',
|
||||
nub: 'var(--fa-color-neutral-100)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cluster map marker for the FA design system.
|
||||
*
|
||||
* Circular pill with a count, representing N provider pins grouped at the
|
||||
* same screen location. Sibling to `MapPin` — same palette language (verified
|
||||
* promoted, unverified neutral), same nub treatment, same shadow.
|
||||
*
|
||||
* `hasVerified` drives the palette: if *any* provider in the cluster is
|
||||
* verified, the cluster adopts the promoted (brand-700) palette. All-unverified
|
||||
* clusters use the neutral palette.
|
||||
*
|
||||
* Designed for use as the `render`-ed output of `@googlemaps/markerclusterer`.
|
||||
* Pure CSS + SVG — no canvas. role="button" + keyboard + focus ring.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <ClusterMarker count={5} hasVerified onClick={...} />
|
||||
* <ClusterMarker count={12} />
|
||||
* ```
|
||||
*/
|
||||
export const ClusterMarker = React.forwardRef<HTMLDivElement, ClusterMarkerProps>(
|
||||
({ count, hasVerified = false, onClick, sx }, ref) => {
|
||||
const palette = hasVerified ? colours.verified : colours.unverified;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e as unknown as React.MouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const label = `${count} providers in this area`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
sx={[
|
||||
{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 150ms ease-in-out',
|
||||
// Fade in on mount — matches MapPin and popups for a consistent
|
||||
// entry timing across the map.
|
||||
'@keyframes clusterMarkerIn': {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
},
|
||||
animation: 'clusterMarkerIn 180ms ease-out',
|
||||
'&:hover': { transform: 'scale(1.08)' },
|
||||
'&:focus-visible': {
|
||||
outline: 'none',
|
||||
'& > .ClusterMarker-badge': {
|
||||
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Circular badge */}
|
||||
<Box
|
||||
className="ClusterMarker-badge"
|
||||
sx={{
|
||||
width: BADGE_SIZE,
|
||||
height: BADGE_SIZE,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: palette.bg,
|
||||
border: '1px solid',
|
||||
borderColor: palette.border,
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
color: palette.text,
|
||||
fontFamily: 'var(--fa-font-family-body)',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</Box>
|
||||
|
||||
{/* Nub — same SVG pattern as MapPin for visual continuity */}
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 16 8"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: `calc(2 * ${NUB_SIZE})`,
|
||||
height: NUB_SIZE,
|
||||
marginTop: '-1px',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
|
||||
<path
|
||||
d="M 0 0 L 8 8 L 16 0"
|
||||
fill="none"
|
||||
stroke={palette.border}
|
||||
strokeWidth={1}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ClusterMarker.displayName = 'ClusterMarker';
|
||||
export default ClusterMarker;
|
||||
1
src/components/atoms/ClusterMarker/index.ts
Normal file
1
src/components/atoms/ClusterMarker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker';
|
||||
Reference in New Issue
Block a user