Batch 3: FilterPanel molecule + integration across 3 steps (D-C, D-F)

New molecule:
- FilterPanel: Popover-based reusable filter trigger with active
  count badge, Clear all, Done actions. D-C: Popover for MVP.

Step integrations:
- ProvidersStep: inline Chip filter bar → FilterPanel Popover,
  search bar + filter button side-by-side in sticky header
- VenueStep: same pattern, filter chips moved into Popover
- CoffinsStep (D-F): grid-sidebar layout → wide-form (full-width
  4-col grid), category + price selects moved into FilterPanel

WizardLayout:
- Added wide-form variant (maxWidth lg, single column) for
  card grids that benefit from full width
- wide-form included in STEPPER_VARIANTS for progress bar

Storybook:
- FilterPanel stories: Default, WithActiveFilters, SelectFilters,
  CustomLabel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 22:24:54 +11:00
parent 1c3cdbc101
commit c5581c6e9f
8 changed files with 576 additions and 252 deletions

View File

@@ -0,0 +1,100 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { FilterPanel } from './FilterPanel';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import { Chip } from '../../atoms/Chip';
const meta: Meta<typeof FilterPanel> = {
title: 'Molecules/FilterPanel',
component: FilterPanel,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
label: { control: 'text' },
activeCount: { control: 'number' },
minWidth: { control: 'number' },
},
decorators: [
(Story) => (
<Box sx={{ p: 4, minHeight: 400 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof FilterPanel>;
/** Default state — no active filters */
export const Default: Story = {
args: {
activeCount: 0,
children: (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Chip label="Verified providers" variant="outlined" size="small" />
<Chip label="Within 10km" variant="outlined" size="small" />
<Chip label="Reviews 4+★" variant="outlined" size="small" />
</Box>
),
},
};
/** With active filters — badge count shown */
export const WithActiveFilters: Story = {
args: {
activeCount: 2,
onClear: () => {},
children: (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Chip label="Verified providers" variant="outlined" size="small" selected />
<Chip label="Within 10km" variant="outlined" size="small" selected />
<Chip label="Reviews 4+★" variant="outlined" size="small" />
</Box>
),
},
};
/** Select-based filters — category + price (CoffinsStep pattern) */
export const SelectFilters: Story = {
args: {
activeCount: 1,
onClear: () => {},
minWidth: 300,
children: (
<>
<TextField select label="Category" value="solid_timber" fullWidth>
<MenuItem value="all">All categories</MenuItem>
<MenuItem value="solid_timber">Solid Timber</MenuItem>
<MenuItem value="environmental">Environmental</MenuItem>
<MenuItem value="designer">Designer</MenuItem>
</TextField>
<TextField select label="Price range" value="all" fullWidth>
<MenuItem value="all">All prices</MenuItem>
<MenuItem value="under_2000">Under $2,000</MenuItem>
<MenuItem value="2000_4000">$2,000 $4,000</MenuItem>
<MenuItem value="over_4000">Over $4,000</MenuItem>
</TextField>
</>
),
},
};
/** Custom label */
export const CustomLabel: Story = {
args: {
label: 'Sort & Filter',
activeCount: 0,
children: (
<TextField select label="Sort by" value="popular" fullWidth>
<MenuItem value="popular">Most popular</MenuItem>
<MenuItem value="price_low">Price: Low to high</MenuItem>
<MenuItem value="price_high">Price: High to low</MenuItem>
</TextField>
),
},
};

View File

@@ -0,0 +1,166 @@
import React from 'react';
import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover';
import TuneIcon from '@mui/icons-material/Tune';
import type { SxProps, Theme } from '@mui/material/styles';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Typography } from '../../atoms/Typography';
import { Link } from '../../atoms/Link';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FilterPanel molecule */
export interface FilterPanelProps {
/** Trigger button label */
label?: string;
/** Number of active filters (shown as count on the trigger) */
activeCount?: number;
/** Filter controls — rendered inside the Popover body */
children: React.ReactNode;
/** Callback when "Clear all" is clicked */
onClear?: () => void;
/** Popover min-width */
minWidth?: number;
/** MUI sx prop for the trigger button */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Reusable filter panel for the FA arrangement wizard.
*
* Renders a trigger button ("Filters") that opens a Popover containing
* arbitrary filter controls (chips, selects, sliders, etc.) passed as
* children. Active filter count shown as a badge on the trigger.
*
* D-C: Popover for desktop MVP. Mobile Drawer variant planned for later.
*
* Used in ProvidersStep, VenueStep, and CoffinsStep.
*
* Usage:
* ```tsx
* <FilterPanel activeCount={2} onClear={handleClear}>
* <TextField select label="Category" ... />
* <TextField select label="Price" ... />
* </FilterPanel>
* ```
*/
export const FilterPanel: React.FC<FilterPanelProps> = ({
label = 'Filters',
activeCount = 0,
children,
onClear,
minWidth = 280,
sx,
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const popoverId = open ? 'filter-panel-popover' : undefined;
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
{/* Trigger button */}
<Box sx={[{ display: 'inline-flex' }, ...(Array.isArray(sx) ? sx : [sx])]}>
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<TuneIcon />}
onClick={handleOpen}
aria-describedby={popoverId}
aria-expanded={open}
aria-haspopup="dialog"
>
{label}
{activeCount > 0 && (
<Badge
variant="filled"
color="brand"
size="small"
sx={{ ml: 1 }}
aria-label={`${activeCount} active filter${activeCount !== 1 ? 's' : ''}`}
>
{activeCount}
</Badge>
)}
</Button>
</Box>
{/* Popover panel */}
<Popover
id={popoverId}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
minWidth,
mt: 1,
borderRadius: 2,
boxShadow: 3,
},
},
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
pt: 2,
pb: 1.5,
}}
>
<Typography variant="h6">Filters</Typography>
{onClear && activeCount > 0 && (
<Link
component="button"
onClick={() => {
onClear();
}}
underline="hover"
sx={{ fontSize: '0.8125rem' }}
>
Clear all
</Link>
)}
</Box>
<Divider />
{/* Filter controls */}
<Box sx={{ px: 2.5, py: 2, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{children}
</Box>
<Divider />
{/* Footer — done button */}
<Box sx={{ px: 2.5, py: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={handleClose}>
Done
</Button>
</Box>
</Popover>
</>
);
};
FilterPanel.displayName = 'FilterPanel';
export default FilterPanel;

View File

@@ -0,0 +1,3 @@
export { FilterPanel } from './FilterPanel';
export type { FilterPanelProps } from './FilterPanel';
export { default } from './FilterPanel';