Skip to content

Add functionality to filter projects #13

@AJaccP

Description

@AJaccP

🧠 Context

Disclaimer: The proposed approach may or may not work exactly as intended. If you find errors with the logic, try to work around them or flag it to Jacc.

The /projects gallery renders every project card with no way to narrow them. This ticket adds a filter toolbar below the page header that lets visitors filter the grid by domain and year, both multi-select. All cards stay in the DOM — filtering just sets a marker attribute that CSS hides — so nothing is removed, and a separate search feature can compose with it without sharing any code (see step 4).

Each card already carries the filter contract on its <article>: data-domain and data-year (plus others used by search). Note a project can have more than one domain, so data-domain is a comma-joined list (e.g. "web, cybersecurity"). This ticket only reads those attributes; it must not change them.

Filtering semantics:

  • Within a facet: OR. Selecting Web and Mobile shows projects in either.
  • Across facets: AND. Selecting Web + 2024 shows web projects from 2024.
  • Nothing selected = show everything (that's also the cleared state).

The filter toolbar lives in the header's toolbar row. A search box is added to this same toolbar later, and the two arrange responsively: on desktop, filters on the left and search on the right (same row); on mobile they stack with the search bar on top and the filters below. So build the filter controls as the left/main content of that row, don't assume you own the whole width, and expect to sit below the search bar when stacked on mobile.

Mobile chips = horizontal scroll. Stacking every domain + year pill vertically eats too much screen height on mobile. Instead, lay each pill group out as a single horizontal-scrolling row (e.g. flex + overflow-x-auto, no wrap) so it stays one line, with a clear affordance that it scrolls sideways — a fade-out edge, a peeking partial chip, and/or a chevron — so it's obvious there's more off-screen. On desktop the pills can just wrap normally.

Checkout the screenshot of an example mockup below for visual reference (not a strict guideline, just a starting point). Note: I'm not the best at UI design, so if you think a different layout/design might look better, feel free to play around with options and share for feedback!

Desktop:
Image

Mobile:
Image

Files you'll touch:

  • src/pages/projects.astro (the FILTER BAR insertion point + a small client script)

Don't touch:

  • The data-* attributes on the card <article> (in Card.astro) — read-only contract; don't modify Card.astro.
  • The #project-grid structure or the card markup — just toggle a class on grid items.
  • The header CTA button row above the toolbar.

🛠️ Implementation Plan

Note: Any code snippets are starting points and may not work immediately. Adjust them as needed.

  1. Compute the facet options in frontmatter. From the already-loaded projects, derive the set of domains present and the set of years present (years sorted descending). Each project's data.domains is an array, so flatten across all projects for the domain set. Readable domain labels come from the shared DOMAIN_LABELS map in src/lib/domains.ts (kebab-case enum value → display label) — import it, don't redefine it (the detail page uses the same map). Add under the existing frontmatter in src/pages/projects.astro:

    ---
    import { DOMAIN_LABELS } from '../lib/domains';
    
    const domains = [...new Set(projects.flatMap((p) => p.data.domains))];
    const years = [...new Set(projects.map((p) => p.data.year))].sort(
      (a, b) => b - a,
    );
    ---

    Deriving from the data avoids rendering pills that match zero projects.

  2. Render the filter toolbar at the FILTER BAR insertion point (replace the comment). Two pill groups — domains and years — as toggle buttons (aria-pressed), each tagged with the value it filters on. Render them by mapping over the domains / years arrays from step 1 (the value below is the loop variable — it only exists inside the .map()). Keep the pills in a container that leaves room for a search box to be added on the right later. Example shape (token-styled, with an active state):

    {
      domains.map((value) => (
        <button
          type="button"
          data-filter-domain={value}
          aria-pressed="false"
          class="border-line aria-pressed:bg-accent aria-pressed:text-accent-contrast aria-pressed:border-accent rounded-full border px-3 py-1 text-sm"
        >
          {DOMAIN_LABELS[value]}
        </button>
      ))
    }

    Adjust styling as needed. Year pills are the same idea — map over years with data-filter-year={year} and {year} as the label. Group each set with a small label ("Domain", "Year") for clarity/accessibility.

  3. How cards are hidden/shown. Hide a card by setting a marker attribute on its grid <li>, with a CSS rule that hides any <li> carrying it — don't toggle hidden directly (that's one slot the filter and a future search would fight over). This feature owns data-filter-hide. A search feature will be added to this toolbar later and will own a second marker, data-search-hide — so write the rule below to hide an <li> that has either marker now (the data-search-hide half is harmless until search ships, and means search will just work without touching your code). A card shows only when it has neither marker — passing both filter and search — with no shared JS. Put the rule in a <style> in the template part of projects.astro (bottom of the file; default scoping is fine).

    #project-grid li[data-filter-hide],
    #project-grid li[data-search-hide] {
      display: none;
    }
  4. Client script. Add a <script> that keeps the selected domains and years (two Sets), toggled by aria-pressed on click. On any change, for each card <article> read data-domain / data-year and set/remove data-filter-hide on its closest('li'):

    • A card has multiple domains: split data-domain on , into the card's domain list.
    • Filter predicate: (selectedDomains.size === 0 || cardDomains.some((d) => selectedDomains.has(d))) && (selectedYears.size === 0 || selectedYears.has(String(year))) — i.e. a card matches the domain facet if any of its domains is selected.

    The marker means "hide me", so the polarity is inverted: add data-filter-hide to a card's <li> when it does NOT match the filters (to hide it), and remove data-filter-hide when it DOES match (to show it). Touch only data-filter-hide — never hidden, never the search marker.

  5. Empty-state. Add a hidden element after the grid (e.g. "No projects match.") and toggle it by counting genuinely-visible items with the union selector — written so it's already correct once search exists: show it when grid.querySelectorAll('li:not([data-filter-hide]):not([data-search-hide])').length === 0. (The search feature will reuse this same empty-state and selector later — you're establishing both here.)

  6. Verify in pnpm dev: selecting multiple domains unions them; adding a year narrows within those; clearing all pills shows everything; the empty-state appears only when nothing matches; cards are only hidden via data-filter-hide (still in the DOM); Card.astro and the data-* filter contract are untouched.


✅ Acceptance Criteria

  • A filter toolbar below the header offers multi-select domain and year pills, both derived from the projects present.
  • Domain pills show readable labels via a value→label map aligned with the schema enum.
  • Within a facet selections are OR'd; across facets they're AND'd; no selection shows all.
  • Filtering only sets/removes data-filter-hide (CSS unions it with data-search-hide) — all cards stay in the DOM; Card.astro and the data-* filter contract are unchanged.
  • An empty-state shows when no cards match (counted via the union selector) and hides otherwise.
  • The feature works standalone and composes with a search feature with no shared JS (only the documented data-*-hide marker + CSS union convention).
  • The toolbar arranges responsively: filters left / search right on desktop, search-on-top / filters-below when stacked on mobile.
  • On mobile each pill group is a single horizontal-scrolling row with a clear scroll affordance (no tall vertical stack of chips).
  • pnpm format:check, pnpm check, and pnpm build all pass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions