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:
100
src/components/molecules/FilterPanel/FilterPanel.stories.tsx
Normal file
100
src/components/molecules/FilterPanel/FilterPanel.stories.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
};
|
||||
166
src/components/molecules/FilterPanel/FilterPanel.tsx
Normal file
166
src/components/molecules/FilterPanel/FilterPanel.tsx
Normal 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;
|
||||
3
src/components/molecules/FilterPanel/index.ts
Normal file
3
src/components/molecules/FilterPanel/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { FilterPanel } from './FilterPanel';
|
||||
export type { FilterPanelProps } from './FilterPanel';
|
||||
export { default } from './FilterPanel';
|
||||
Reference in New Issue
Block a user