Filters

Four selects bind signals. Type, destination, and limit changes fetch /api/filters-results (results only). A region change resets $destination and fetches /api/filters, which patches both the controls (fresh destination options) and the results in one SSE response.

Find an experience

23 matches · showing first 5

  • Old Town ghost walkCulture · Edinburgh, Scotland
  • Festival fringe crawlCulture · Edinburgh, Scotland
  • West End food tourFood · Glasgow, Scotland
  • Barras weekend marketMarket · Glasgow, Scotland
  • Munro bagging weekendOutdoors · Highlands, Scotland

The controls

src/components/FilterControls.astro
---
/** Filter selects; a region change re-renders this block with fresh destinations. */
import { regions, types } from '../data/experiences';

interface Props {
  region: string;
}

const { region } = Astro.props;
const destinations = region ? (regions[region] ?? []) : [];
---

<div id="filter-controls" class="filter-grid">
  <div>
    <label for="f-type">Experience type</label>
    <select
      id="f-type"
      data-bind:type
      data-on:change="@get('/api/filters-results')"
    >
      <option value="">Any type</option>
      {types.map((t) => <option value={t}>{t}</option>)}
    </select>
  </div>
  <div>
    <label for="f-region">Region</label>
    <select
      id="f-region"
      data-bind:region
      data-on:change="$destination = ''; @get('/api/filters')"
    >
      <option value="">Any region</option>
      {Object.keys(regions).map((r) => <option value={r}>{r}</option>)}
    </select>
  </div>
  <div>
    <label for="f-destination">Destination</label>
    <select
      id="f-destination"
      data-bind:destination
      data-on:change="@get('/api/filters-results')"
      disabled={destinations.length === 0}
    >
      <option value="">
        {destinations.length ? 'Any destination' : 'Pick a region first'}
      </option>
      {destinations.map((d) => <option value={d}>{d}</option>)}
    </select>
  </div>
  <div>
    <label for="f-limit">Show</label>
    <select
      id="f-limit"
      data-bind:limit
      data-on:change="@get('/api/filters-results')"
    >
      <option value="5">5 results</option>
      <option value="10">10 results</option>
      <option value="20">20 results</option>
    </select>
  </div>
</div>

The results

src/components/FilterResults.astro
---
import { experiences } from '../data/experiences';

interface Props {
  type: string;
  region: string;
  destination: string;
  limit: number;
}

const { type, region, destination, limit } = Astro.props;

const matches = experiences.filter(
  (e) =>
    (!type || e.type === type) &&
    (!region || e.region === region) &&
    (!destination || e.destination === destination),
);
const shown = matches.slice(0, limit);
---

<div id="filter-results">
  <p class="hint">
    {matches.length} match{matches.length === 1 ? '' : 'es'}
    {matches.length > shown.length && ` · showing first ${shown.length}`}
  </p>
  <ul class="entry-list">
    {
      shown.map((e) => (
        <li>
          <strong>{e.title}</strong>
          <span class="hint">
            {e.type} · {e.destination}, {e.region}
          </span>
        </li>
      ))
    }
    {matches.length === 0 && <li class="hint">Nothing matches those filters.</li>}
  </ul>
</div>

The region endpoint

src/pages/api/filters.ts
import {
  patchElements,
  readSignals,
  SignalsValidationError,
  sse,
} from '@wrux/astro-datastar/server';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { z } from 'astro/zod';
import FilterControls from '../../components/FilterControls.astro';
import FilterResults from '../../components/FilterResults.astro';

export const prerender = false;

export const filterSignals = z.object({
  type: z.string().catch(''),
  region: z.string().catch(''),
  destination: z.string().catch(''),
  limit: z.coerce.number().int().catch(5),
});

// Region changed: destination options are stale, so re-render the controls
// and the results in one response.
export const GET: APIRoute = async ({ request }) => {
  let signals: z.infer<typeof filterSignals>;
  try {
    signals = await readSignals(request, filterSignals);
  } catch (err) {
    if (err instanceof SignalsValidationError) return err.response();
    throw err;
  }

  const container = await AstroContainer.create();
  const [controls, results] = await Promise.all([
    container.renderToString(FilterControls, {
      props: { region: signals.region },
    }),
    container.renderToString(FilterResults, { props: signals }),
  ]);

  return sse(patchElements(controls), patchElements(results));
};

The results endpoint

src/pages/api/filters-results.ts
import {
  html,
  readSignals,
  SignalsValidationError,
} from '@wrux/astro-datastar/server';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import type { z } from 'astro/zod';
import FilterResults from '../../components/FilterResults.astro';
import { filterSignals } from './filters';

export const prerender = false;

export const GET: APIRoute = async ({ request }) => {
  let signals: z.infer<typeof filterSignals>;
  try {
    signals = await readSignals(request, filterSignals);
  } catch (err) {
    if (err instanceof SignalsValidationError) return err.response();
    throw err;
  }

  const container = await AstroContainer.create();
  return html(
    await container.renderToString(FilterResults, { props: signals }),
  );
};