Skip to content
AstroCraft Docs
On this theme

Browse & Filter

src/components/Sections/Browse/filter.ts is 316 lines and it is the only substantial client-side logic on the marketplace. It makes the browse hubs’ rail, sort, pager, chips and view toggle live — over cars on /used-cars/, /new-cars/ and /electric-cars/, and over specialist firms on the London city page, from the same file.

Progressive, not required

Every record is server-rendered. With JavaScript off, the rail renders, every <details> group still opens, the sort select still shows its options, and all 37 records stay visible. The engine only ever hides things — there is no client-side fetch, no template, no hydration.

That is a deliberate contract, and it is stated at the top of the file. It also means the page is indexable: a crawler sees the full result set, not an empty shell waiting for a bundle.

What it reads

The engine does not receive the data. It reads the DOM the cards have already rendered — every CarRecordCard and SpecialistCard carries its filterable facts as data-* attributes, and a group key in the rail maps to one of them:

const ATTRS: Record<string, string> = {
  make: "make", "body-type": "body", fuel: "fuel", transmission: "transmission",
  seller: "seller", features: "features", borough: "borough",
  discipline: "discipline", facilities: "facilities", availability: "availability",
};

That indirection is why one engine serves two record types. Cars have a make, firms have a borough; both are just a key whose values are matched against the checked boxes.

Three groups are special-cased because they are not set membership. price handles the two checkbox options — price drops, and a monthly-payment budget. year/established filter on the min/max bounds a banded option carries. And the free inputs — postcode, distance, price min/max, mileage from/to — are numeric comparisons against the record’s own attributes.

Within a group the match is OR; across groups it is AND. That is what a filter rail is expected to do, and it is eight lines rather than a library.

Cut, sort, page

apply() runs the whole pipeline on every change:

const visible = sortWith(records.filter((el) => passes(el)));
const pages = Math.max(1, Math.ceil(visible.length / pageSize));
const slice = new Set(visible.slice((page - 1) * pageSize, page * pageSize));
records.forEach((el, i) => { items[i].style.display = slice.has(el) ? "" : "none"; });
list?.append(...[...slice].map((el) => items[records.indexOf(el)]));

The last line is the part worth pausing on. Hiding and showing gets membership right but leaves the visible rows in their original DOM order, so a sort would appear to do nothing. Re-appending the shown wrappers in sort order fixes it — and because they are the same nodes being moved, no markup is rebuilt and nothing loses focus.

pageSize is 8, read from a data attribute the section renders from config rather than hard-coded in the script. The pager is rebuilt each pass with first, last and the current page’s neighbours, an ellipsis where it skips, aria-current="page" on the active one, and a smooth scroll back to the top of the list on a page change.

The active-filter chips are rebuilt the same way, each with a close button that resets exactly the control it came from and re-applies. A bound pair renders as one chip — “£10,000 – £45,000”, or “from 20,000 mi” when only one end is set.

The one bug this design invites

The rail is authored copy; the records are data. Nothing in the type system connects a checkbox labelled “Estate” to a record with bodyType: "Estate" — so a renamed value, or a rail option for a body type no record carries, would empty the list. The page would look broken while both files looked fine.

That is what carsData.test.ts and hubsData.test.ts pin: every rail option, in every group, must match at least one record in that hub’s dataset. It is the check that makes the loose coupling safe, and it costs a few lines rather than a schema.

Saved hearts

initHearts() runs on every page, not just the browse hubs — it scans the whole document for [data-save] buttons, so a heart works on a record page or in a collection’s picks too. Each button toggles the indexa:saved ref array and the indexa:savedAt timestamp map through the shared pure helpers in src/js/favorites.ts, and syncs its own aria-pressed. Saved Cars covers the store and the dashboard that reads it.

View transitions

init() is idempotent — it marks its root with data-browse-ready and each heart with data-save-ready, then runs again on astro:page-load. Under <ClientRouter /> a navigation swaps the DOM without reloading the page, so a script that only ran on first load would leave the second browse page inert. Every scripted feature in Indexa follows one of the two patterns for this; Layout & Page Shell has both.

Extending it

A new filter group is a rail group in config plus, if it is a new field, one line in ATTRS and the attribute on the card. A new sort option is an entry in browseData.toolbar.sortOptions shaped field:asc|desc — the engine reads the field name off the option’s value and compares that data attribute numerically, so any numeric field already works.

The ceiling is worth knowing: this filters the DOM, so it filters what the page rendered. At a few dozen records that is the cheapest possible implementation. At a few thousand it becomes a server-side query and a paginated route, and the rail stays exactly as it is.

NEXT STEPSaved Cars