feat(filters): add FilterBar, one component both screens compose (#188)

This commit is contained in:
2026-08-25 15:26:21 -05:00
parent 918d6eeab9
commit 50d048a1d6
@@ -0,0 +1,114 @@
import { useMemo, useState } from 'react';
import Drawer from 'antd/es/drawer';
import Button from 'antd/es/button';
import Grid from 'antd/es/grid';
import { FilterOutlined } from '@ant-design/icons';
import type { Category, Tag as ItemTag } from '../../api';
import type { ItemFilters } from '../../filters';
import type { FilterContext, FilterDimension } from './dimension';
import FilterChips from './FilterChips';
type Props = Readonly<{
/** Render order. A screen composes the filters it offers. */
dimensions: FilterDimension[];
filters: ItemFilters;
onChange: (next: ItemFilters) => void;
onClear: () => void;
categories: Category[];
tags: ItemTag[];
priceRange: { min_cents: number; max_cents: number } | null;
resultCount: number;
}>;
const sectionHeading: React.CSSProperties = {
margin: '0 0 8px',
fontSize: 12,
letterSpacing: '.06em',
textTransform: 'uppercase',
opacity: 0.65
};
/**
* Filtering, for any screen that does it.
*
* The screen says which dimensions it offers; this owns everything around them
* — the always-visible controls, the Filters button and its tally, the chip
* row, and the drawer. Before #188 the drawer was shared and this was written
* twice, which is how the admin's tally drifted from the storefront's.
*
* The tally is the number of chips rather than a second count of the same
* thing, so the button and the chip row cannot disagree.
*/
export default function FilterBar({
dimensions,
filters,
onChange,
onClear,
categories,
tags,
priceRange,
resultCount
}: Props) {
const [drawerOpen, setDrawerOpen] = useState(false);
const screens = Grid.useBreakpoint();
const context: FilterContext = useMemo(
() => ({ filters, onChange, categories, tags, priceRange }),
[filters, onChange, categories, tags, priceRange]
);
// Derived, not stored, and derived without rendering anything — the drawer
// unmounts its sections when closed, so anything that needed them mounted
// would lose the chips exactly when they matter.
const chips = dimensions.flatMap((dimension) => dimension.chips(context));
const barDimensions = dimensions.filter((dimension) => dimension.placement === 'bar');
const drawerDimensions = dimensions.filter((dimension) => dimension.placement === 'drawer');
return (
<>
{barDimensions.map((dimension) => (
<div key={dimension.key}>{dimension.render(context)}</div>
))}
<Button
icon={<FilterOutlined />}
onClick={() => setDrawerOpen(true)}
type={chips.length ? 'primary' : 'default'}
>
Filters{chips.length ? ` (${chips.length})` : ''}
</Button>
<FilterChips chips={chips} onClear={onClear} />
<Drawer
title="Filters"
placement="right"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
// Unmounting on close keeps a single copy of controls like "Clear all"
// in the document at any time.
destroyOnHidden
width={screens.md ? 380 : '90%'}
footer={
<div style={{ display: 'flex', gap: 8 }}>
<Button block onClick={onClear}>Clear all</Button>
<Button block type="primary" onClick={() => setDrawerOpen(false)}>
Show {resultCount} {resultCount === 1 ? 'item' : 'items'}
</Button>
</div>
}
>
{drawerDimensions.map((dimension, index) => (
<section
key={dimension.key}
style={index < drawerDimensions.length - 1 ? { marginBottom: 28 } : undefined}
>
{dimension.heading && <h4 style={sectionHeading}>{dimension.heading}</h4>}
{dimension.render(context)}
</section>
))}
</Drawer>
</>
);
}