Add Navigation organism — responsive site header with mobile drawer
- Maps to Figma Main Nav (14:108) desktop + Mobile Header (2391:41508) - Desktop: logo left, nav links right, optional CTA button - Mobile: hamburger + drawer with nav items, CTA, and help footer - Sticky header with warm surface background and border - Composes AppBar, Link, IconButton, Button, Divider, Drawer - 6 stories: Default, WithCTA, WithPageContent (sticky scroll demo), Minimal, ExtendedNavigation, MobilePriceTracker Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
174
src/components/organisms/Navigation/Navigation.stories.tsx
Normal file
174
src/components/organisms/Navigation/Navigation.stories.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Navigation } from './Navigation';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
// Placeholder logo — in production this would be an SVG or <img>
|
||||||
|
const FALogo = () => (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"Noto Serif SC", Georgia, serif',
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: { xs: '1rem', md: '1.25rem' },
|
||||||
|
color: 'var(--fa-color-brand-900)',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
FuneralArranger
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultItems = [
|
||||||
|
{ label: 'FAQ', href: '/faq' },
|
||||||
|
{ label: 'Contact Us', href: '/contact' },
|
||||||
|
{ label: 'Log in', href: '/login' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta: Meta<typeof Navigation> = {
|
||||||
|
title: 'Organisms/Navigation',
|
||||||
|
component: Navigation,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
design: {
|
||||||
|
type: 'figma',
|
||||||
|
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=14-108',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
ctaLabel: { control: 'text' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Navigation>;
|
||||||
|
|
||||||
|
// ─── Default ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Desktop — logo left, navigation links right */
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
logo: <FALogo />,
|
||||||
|
items: defaultItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── With CTA ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Desktop with a primary call-to-action button */
|
||||||
|
export const WithCTA: Story = {
|
||||||
|
args: {
|
||||||
|
logo: <FALogo />,
|
||||||
|
items: defaultItems,
|
||||||
|
ctaLabel: 'Start planning',
|
||||||
|
onCtaClick: () => alert('Navigate to arrangement flow'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── With Page Content ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Full page layout — sticky header with scrollable content */
|
||||||
|
export const WithPageContent: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Box>
|
||||||
|
<Navigation
|
||||||
|
logo={<FALogo />}
|
||||||
|
items={defaultItems}
|
||||||
|
ctaLabel="Start planning"
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxWidth: 'lg',
|
||||||
|
mx: 'auto',
|
||||||
|
px: { xs: 2, md: 4 },
|
||||||
|
py: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h2" sx={{ mb: 3 }}>
|
||||||
|
Find a funeral director
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600 }}>
|
||||||
|
Compare trusted funeral directors in your area. View services,
|
||||||
|
pricing, and reviews to find the right support for your family.
|
||||||
|
</Typography>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
mb: 2,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Provider {i + 1}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Placeholder content to demonstrate scroll behaviour with sticky header.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Minimal ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Minimal — logo only, no navigation items */
|
||||||
|
export const Minimal: Story = {
|
||||||
|
args: {
|
||||||
|
logo: <FALogo />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Extended Navigation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** More nav items for arrangements flow context */
|
||||||
|
export const ExtendedNavigation: Story = {
|
||||||
|
args: {
|
||||||
|
logo: <FALogo />,
|
||||||
|
items: [
|
||||||
|
{ label: 'Directors', href: '/directors' },
|
||||||
|
{ label: 'Venues', href: '/venues' },
|
||||||
|
{ label: 'Pricing', href: '/pricing' },
|
||||||
|
{ label: 'FAQ', href: '/faq' },
|
||||||
|
{ label: 'Contact Us', href: '/contact' },
|
||||||
|
{ label: 'Log in', href: '/login' },
|
||||||
|
],
|
||||||
|
ctaLabel: 'Start planning',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Mobile Price Tracker ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Mobile with price tracker trailing content (resize browser to see) */
|
||||||
|
export const MobilePriceTracker: Story = {
|
||||||
|
args: {
|
||||||
|
logo: <FALogo />,
|
||||||
|
items: defaultItems,
|
||||||
|
mobileTrailing: (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.5,
|
||||||
|
borderRadius: '10px 0 0 10px',
|
||||||
|
boxShadow: '2px 2px 3px rgba(0,0,0,0.25)',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="captionSm" sx={{ fontWeight: 600, display: 'block' }}>
|
||||||
|
Your plan
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="label" sx={{ fontWeight: 600 }}>
|
||||||
|
$3,600
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
293
src/components/organisms/Navigation/Navigation.tsx
Normal file
293
src/components/organisms/Navigation/Navigation.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import AppBar from '@mui/material/AppBar';
|
||||||
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Drawer from '@mui/material/Drawer';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { IconButton } from '../../atoms/IconButton';
|
||||||
|
import { Link } from '../../atoms/Link';
|
||||||
|
import { Button } from '../../atoms/Button';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import { Divider } from '../../atoms/Divider';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A navigation link item */
|
||||||
|
export interface NavItem {
|
||||||
|
/** Display label */
|
||||||
|
label: string;
|
||||||
|
/** URL to navigate to */
|
||||||
|
href: string;
|
||||||
|
/** Click handler (alternative to href for SPA navigation) */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for the FA Navigation organism */
|
||||||
|
export interface NavigationProps {
|
||||||
|
/** Site logo — rendered on the left (desktop) or centre (mobile) */
|
||||||
|
logo: React.ReactNode;
|
||||||
|
/** Click handler for the logo (navigate to home) */
|
||||||
|
onLogoClick?: () => void;
|
||||||
|
/** Navigation links displayed in the header */
|
||||||
|
items?: NavItem[];
|
||||||
|
/** Optional CTA button (e.g. "Start planning") on desktop */
|
||||||
|
ctaLabel?: string;
|
||||||
|
/** Click handler for the CTA button */
|
||||||
|
onCtaClick?: () => void;
|
||||||
|
/** Optional right-aligned content for mobile (e.g. price tracker) */
|
||||||
|
mobileTrailing?: React.ReactNode;
|
||||||
|
/** MUI sx prop for the root AppBar */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site header navigation for the FA design system.
|
||||||
|
*
|
||||||
|
* Responsive header with logo, navigation links, and optional CTA.
|
||||||
|
* Desktop shows links inline; mobile collapses to hamburger + drawer.
|
||||||
|
*
|
||||||
|
* Maps to Figma "Main Nav" (14:108) desktop and "Mobile Header"
|
||||||
|
* (2391:41508) mobile patterns.
|
||||||
|
*
|
||||||
|
* Composes AppBar + Link + IconButton + Button + Divider + Drawer.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* <Navigation
|
||||||
|
* logo={<img src="/logo.svg" alt="Funeral Arranger" height={40} />}
|
||||||
|
* onLogoClick={() => navigate('/')}
|
||||||
|
* items={[
|
||||||
|
* { label: 'FAQ', href: '/faq' },
|
||||||
|
* { label: 'Contact Us', href: '/contact' },
|
||||||
|
* { label: 'Log in', href: '/login' },
|
||||||
|
* ]}
|
||||||
|
* ctaLabel="Start planning"
|
||||||
|
* onCtaClick={() => navigate('/arrange')}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||||
|
({ logo, onLogoClick, items = [], ctaLabel, onCtaClick, mobileTrailing, sx }, ref) => {
|
||||||
|
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||||
|
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
const handleDrawerToggle = () => setDrawerOpen((prev) => !prev);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBar
|
||||||
|
ref={ref}
|
||||||
|
position="sticky"
|
||||||
|
elevation={0}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
bgcolor: 'var(--fa-color-surface-warm)',
|
||||||
|
color: 'text.primary',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Toolbar
|
||||||
|
sx={{
|
||||||
|
minHeight: { xs: 56, md: 80 },
|
||||||
|
px: { xs: 2, md: 4 },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left: hamburger (mobile) + logo */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton
|
||||||
|
aria-label={drawerOpen ? 'Close menu' : 'Open menu'}
|
||||||
|
onClick={handleDrawerToggle}
|
||||||
|
size="medium"
|
||||||
|
edge="start"
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
onClick={onLogoClick}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: onLogoClick ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
role={onLogoClick ? 'link' : undefined}
|
||||||
|
aria-label={onLogoClick ? 'Home' : undefined}
|
||||||
|
>
|
||||||
|
{logo}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right: nav links (desktop) or trailing content (mobile) */}
|
||||||
|
{!isMobile ? (
|
||||||
|
<Box
|
||||||
|
component="nav"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 3.5 }}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
onClick={item.onClick}
|
||||||
|
underline="hover"
|
||||||
|
sx={{
|
||||||
|
color: 'var(--fa-color-brand-900)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '1rem',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'primary.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{ctaLabel && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="medium"
|
||||||
|
onClick={onCtaClick}
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
mobileTrailing && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{mobileTrailing}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
{/* Mobile drawer */}
|
||||||
|
{isMobile && (
|
||||||
|
<Drawer
|
||||||
|
anchor="left"
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={handleDrawerToggle}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
width: 300,
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drawer header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: 'var(--fa-color-surface-warm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{logo}
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Close menu"
|
||||||
|
onClick={handleDrawerToggle}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<List component="nav" aria-label="Main navigation">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.label}
|
||||||
|
component="a"
|
||||||
|
href={item.href}
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
if (item.onClick) {
|
||||||
|
e.preventDefault();
|
||||||
|
item.onClick();
|
||||||
|
}
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
px: 3,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'var(--fa-color-brand-100)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.label}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{ctaLabel && (
|
||||||
|
<Box sx={{ px: 3, py: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => {
|
||||||
|
if (onCtaClick) onCtaClick();
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box sx={{ mt: 'auto' }}>
|
||||||
|
<Divider />
|
||||||
|
<Box sx={{ px: 3, py: 2, bgcolor: 'var(--fa-color-surface-warm)' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Need help? Call us on
|
||||||
|
</Typography>
|
||||||
|
<Link
|
||||||
|
href="tel:1800987888"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
1800 987 888
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigation.displayName = 'Navigation';
|
||||||
|
export default Navigation;
|
||||||
2
src/components/organisms/Navigation/index.ts
Normal file
2
src/components/organisms/Navigation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Navigation } from './Navigation';
|
||||||
|
export type { NavigationProps, NavItem } from './Navigation';
|
||||||
Reference in New Issue
Block a user