feat(settings/providers): replace Add another provider CTA with a search + kind filter

Per the design review, the "Add another provider · Browse providers"
card was a redirect to content one scroll-tick away. A search input
plus kind chips lets users self-segment the catalog and is the right
tool once it grows beyond the four to twelve providers we ship today.

- New providers_filter Stimulus controller — case-insensitive free
  text search across name/region/kind, plus a chip group with
  All / Banks / Crypto / Investment that toggle visibility via
  Tailwind's `hidden` class.
- _search_filters partial: search box (count-pluralized placeholder)
  + chip group, ARIA-labelled and aria-pressed for the chips.
- ProviderCard exposes filter_data (target + name/region/kind data
  attrs) so the controller can match without re-rendering.
- Lunchflow's `kind` was "Lunch" — switched to "Bank" so it falls
  under the Banks chip alongside its actual offering (it aggregates
  banks).
- Drops the add_provider_cta partial and its locale entries; adds
  search_filters.* and an empty_filter message.
This commit is contained in:
Guillem Arias
2026-05-09 11:33:13 +02:00
committed by Guillem Arias
parent bf73e3a1e3
commit d037412b8d
9 changed files with 146 additions and 49 deletions

View File

@@ -0,0 +1,54 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="providers-filter"
// Filters provider cards by free-text query and a chip-selected kind.
export default class extends Controller {
static targets = ["input", "chip", "card", "empty"];
static values = { kind: { type: String, default: "all" } };
connect() {
this.syncChipState();
}
filter() {
const query = this.hasInputTarget
? this.inputTarget.value.toLocaleLowerCase().trim()
: "";
const activeKind = this.kindValue;
let visibleCount = 0;
this.cardTargets.forEach((card) => {
const name = card.dataset.providerName ?? "";
const region = card.dataset.providerRegion ?? "";
const kind = card.dataset.providerKind ?? "";
const haystack = `${name} ${region} ${kind}`;
const matchesQuery = !query || haystack.includes(query);
const matchesKind = activeKind === "all" || kind === activeKind;
const visible = matchesQuery && matchesKind;
card.classList.toggle("hidden", !visible);
if (visible) visibleCount++;
});
if (this.hasEmptyTarget) {
this.emptyTarget.classList.toggle("hidden", visibleCount > 0);
}
}
selectChip(event) {
this.kindValue = event.currentTarget.dataset.kind ?? "all";
this.syncChipState();
this.filter();
}
syncChipState() {
if (!this.hasChipTarget) return;
this.chipTargets.forEach((chip) => {
const active = chip.dataset.kind === this.kindValue;
chip.classList.toggle("bg-container", active);
chip.classList.toggle("shadow-border-xs", active);
chip.classList.toggle("text-primary", active);
chip.classList.toggle("text-secondary", !active);
chip.setAttribute("aria-pressed", active ? "true" : "false");
});
}
}