Make filtering one composable component both screens extend, rather than a shared drawer with per-screen flags #188

Closed
opened 2026-08-25 14:35:10 -05:00 by bermudalamb · 0 comments
Owner

#169 made the filter drawer shared, which was the right first move and is not the finish. What it left is a component that knows about every screen that uses it: showFavorites, showStatus, and priceRange: null as a way of saying "no slider here". Adding a screen means adding a flag; adding a screen-specific control means the shared component learning about it.

The bar around the drawer was never shared at all. The Filters (N) button, the open state and the tally are written twice — once in InventoryFilters.tsx, once inline in App.tsx — and have already diverged, because the admin bolts on + (filters.status === null ? 0 : 1) by hand.

And one filter sits outside the system entirely: the storefront's Not sold / Sold / All preset, because it belongs in the always-visible bar rather than in the drawer, and the shared component has no way to express that.

The shape

A screen composes a list of filter dimensions. Each declares where it renders, how to render it, and what chips it contributes:

interface FilterDimension {
  key: string;
  placement: 'bar' | 'drawer';
  heading?: string;
  render(ctx: FilterContext): ReactNode;
  chips(ctx: FilterContext): Chip[];
}

The standard dimensions ship as exported factories. A screen that needs something bespoke writes its own and the component treats it identically — it appears in the chip row and counts toward the tally without the shared code knowing what it is.

chips() is a pure function of the filter state, deliberately. The drawer sets destroyOnHidden, so its sections are unmounted whenever it is closed, which is exactly when chips matter most. Any design where sections register themselves on mount loses the chip row the moment the drawer closes. That constraint is what rules out the otherwise-idiomatic context-and-children approach.

The tally becomes the chips

activeFilterCount and ActiveFilterChips compute the same thing by two routes today and agree by coincidence. Under this design the count is the number of chips, so they cannot disagree.

That changes three visible behaviours, recorded here rather than discovered later:

  • Admin with three statuses selected shows Filters (3), not Filters (1), matching how categories and tags already count.
  • Choosing Sold or All on the storefront produces a removable chip, where today nothing appears in that row.
  • hasActiveFilters is deleted. Its job — deciding whether an empty grid reads as "No items match these filters" with a way out, or as an empty shop — becomes chips.length > 0.

Testing

chips() being pure is most of the point, and the frontend has no unit test runner at all, so today it could only be covered end-to-end, expensively and indirectly. A minimal vitest setup scoped to the dimensions is part of this work.

Not in scope

The filter state itself. ItemFilters, the URL serialisation and the parsing are untouched — dimensions render and describe, they never own the state.

Design: docs/superpowers/specs/2026-08-25-filter-dimensions-design.md.

#169 made the filter drawer shared, which was the right first move and is not the finish. What it left is a component that knows about every screen that uses it: `showFavorites`, `showStatus`, and `priceRange: null` as a way of saying "no slider here". Adding a screen means adding a flag; adding a screen-specific control means the shared component learning about it. The bar around the drawer was never shared at all. The `Filters (N)` button, the open state and the tally are written twice — once in `InventoryFilters.tsx`, once inline in `App.tsx` — and have already diverged, because the admin bolts on `+ (filters.status === null ? 0 : 1)` by hand. And one filter sits outside the system entirely: the storefront's `Not sold / Sold / All` preset, because it belongs in the always-visible bar rather than in the drawer, and the shared component has no way to express that. ## The shape A screen composes a list of **filter dimensions**. Each declares where it renders, how to render it, and what chips it contributes: ```ts interface FilterDimension { key: string; placement: 'bar' | 'drawer'; heading?: string; render(ctx: FilterContext): ReactNode; chips(ctx: FilterContext): Chip[]; } ``` The standard dimensions ship as exported factories. A screen that needs something bespoke writes its own and the component treats it identically — it appears in the chip row and counts toward the tally without the shared code knowing what it is. **`chips()` is a pure function of the filter state, deliberately.** The drawer sets `destroyOnHidden`, so its sections are unmounted whenever it is closed, which is exactly when chips matter most. Any design where sections register themselves on mount loses the chip row the moment the drawer closes. That constraint is what rules out the otherwise-idiomatic context-and-children approach. ## The tally becomes the chips `activeFilterCount` and `ActiveFilterChips` compute the same thing by two routes today and agree by coincidence. Under this design the count *is* the number of chips, so they cannot disagree. That changes three visible behaviours, recorded here rather than discovered later: - Admin with three statuses selected shows **Filters (3)**, not **Filters (1)**, matching how categories and tags already count. - Choosing **Sold** or **All** on the storefront produces a removable chip, where today nothing appears in that row. - `hasActiveFilters` is deleted. Its job — deciding whether an empty grid reads as "No items match these filters" with a way out, or as an empty shop — becomes `chips.length > 0`. ## Testing `chips()` being pure is most of the point, and the frontend has **no unit test runner at all**, so today it could only be covered end-to-end, expensively and indirectly. A minimal vitest setup scoped to the dimensions is part of this work. ## Not in scope The filter state itself. `ItemFilters`, the URL serialisation and the parsing are untouched — dimensions render and describe, they never own the state. Design: `docs/superpowers/specs/2026-08-25-filter-dimensions-design.md`.
bermudalamb added this to the Customer and Admin UI review findings project 2026-08-25 16:26:26 -05:00
bermudalamb self-assigned this 2026-08-25 16:26:46 -05:00
bermudalamb moved this to In Progress in Customer and Admin UI review findings on 2026-08-25 16:27:21 -05:00
bermudalamb moved this to Review in Customer and Admin UI review findings on 2026-08-26 08:09:36 -05:00
bermudalamb moved this to Ready for Release in Customer and Admin UI review findings on 2026-09-09 13:33:32 -05:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#188