feat(filters): move the availability preset into a bar dimension (#188)

This commit is contained in:
2026-08-25 15:18:23 -05:00
parent eb5799dd17
commit 1d7f928c48
2 changed files with 94 additions and 0 deletions
@@ -5,13 +5,17 @@ import Switch from 'antd/es/switch';
import Tag from 'antd/es/tag'; import Tag from 'antd/es/tag';
import Slider from 'antd/es/slider'; import Slider from 'antd/es/slider';
import InputNumber from 'antd/es/input-number'; import InputNumber from 'antd/es/input-number';
import Segmented from 'antd/es/segmented';
import { import {
buildCategoryTree, buildCategoryTree,
categoryPath, categoryPath,
formatPriceRange, formatPriceRange,
ItemStatus, ItemStatus,
SaleState,
saleStateFromStatuses,
STATUS_OPTIONS, STATUS_OPTIONS,
statusLabel, statusLabel,
STOREFRONT_SALE_STATUSES,
toCategoryTreeData toCategoryTreeData
} from '../../filters'; } from '../../filters';
import type { FilterDimension } from './dimension'; import type { FilterDimension } from './dimension';
@@ -269,3 +273,59 @@ export const statusDimension: FilterDimension = {
} }
})) }))
}; };
const SALE_STATE_LABELS: Record<SaleState, string> = {
'not-sold': 'Not sold',
sold: 'Sold',
all: 'All'
};
/**
* The storefront's three-way availability preset.
*
* A bar dimension rather than a drawer one: it is the coarsest cut a customer
* makes and is worth having visible without opening anything. Before #188 it
* was hand-written markup in App.tsx because the shared component had no way to
* say "this belongs in the bar", which is the gap that design closed.
*/
export const availabilityDimension: FilterDimension = {
key: 'availability',
placement: 'bar',
render: ({ filters, onChange }) => (
<Segmented
aria-label="Filter by availability"
value={saleStateFromStatuses(
filters.status,
STOREFRONT_SALE_STATUSES,
filters.favoritesOnly ? 'all' : 'not-sold'
)}
onChange={(value) => {
const state = value as SaleState;
// The default is stored as "no preference" rather than as an explicit
// list, which keeps it out of the URL and out of the chip row.
onChange({
...filters,
status: state === 'not-sold' ? null : STOREFRONT_SALE_STATUSES[state]
});
}}
options={[
{ label: SALE_STATE_LABELS['not-sold'], value: 'not-sold' },
{ label: SALE_STATE_LABELS.sold, value: 'sold' },
{ label: SALE_STATE_LABELS.all, value: 'all' }
]}
/>
),
chips: ({ filters, onChange }) => {
const state = saleStateFromStatuses(filters.status, STOREFRONT_SALE_STATUSES, 'not-sold');
if (state === 'not-sold') return [];
return [
{
key: 'availability',
label: SALE_STATE_LABELS[state],
onRemove: () => onChange({ ...filters, status: null })
}
];
}
};
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { EMPTY_FILTERS, ItemFilters } from '../../src/filters'; import { EMPTY_FILTERS, ItemFilters } from '../../src/filters';
import type { FilterContext } from '../../src/components/filters/dimension'; import type { FilterContext } from '../../src/components/filters/dimension';
import { import {
availabilityDimension,
categoryDimension, categoryDimension,
favoritesDimension, favoritesDimension,
priceDimension, priceDimension,
@@ -155,3 +156,36 @@ describe('statusDimension', () => {
expect(state.latest?.status).toBeNull(); expect(state.latest?.status).toBeNull();
}); });
}); });
describe('availabilityDimension', () => {
it('renders in the bar rather than the drawer', () => {
expect(availabilityDimension.placement).toBe('bar');
});
// Not sold is the default. A filter nobody chose must not read as one, or it
// shows in the tally and in the chip row on a page nobody has filtered.
it('reports no chip at the default', () => {
expect(availabilityDimension.chips(contextFor({ status: null }).ctx)).toEqual([]);
expect(
availabilityDimension.chips(contextFor({ status: ['available', 'reserved'] }).ctx)
).toEqual([]);
});
// The behaviour change in #188. This is what lets chips.length serve as both
// the tally and the "is anything filtered" predicate the empty state needs.
it('reports a chip for Sold and for All', () => {
expect(availabilityDimension.chips(contextFor({ status: ['sold'] }).ctx).map((c) => c.label))
.toEqual(['Sold']);
expect(
availabilityDimension
.chips(contextFor({ status: ['available', 'reserved', 'sold'] }).ctx)
.map((c) => c.label)
).toEqual(['All']);
});
it('returns to the default when removed', () => {
const { ctx, state } = contextFor({ status: ['sold'] });
availabilityDimension.chips(ctx)[0]?.onRemove();
expect(state.latest?.status).toBeNull();
});
});