import { html, dataFetch } from "../util.js";
import buttonCSS from "../buttonStyles.js";
class PxmeFilter extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._selectedPeople = new Set();
this._selectedCategories = new Set();
this._allCategories = []; // [{name, count}] already sorted most-common-first
this._cap = 10; // CAT-20/CAT-21: display cap on category chips
this._catQuery = "";
this.shadowRoot.innerHTML = html`
`;
}
connectedCallback() {
const toggle = this.shadowRoot.querySelector(".pxme-filter__toggle");
const dropdown = this.shadowRoot.querySelector("#dropdown");
toggle.addEventListener("click", () => {
const isOpen = dropdown.classList.toggle("pxme-filter__dropdown--open");
toggle.classList.toggle("pxme-filter__toggle--active", isOpen);
});
this._loadFilters();
}
async _loadFilters() {
const peopleGroup = this.shadowRoot.querySelector("#people-group");
const categoriesGroup = this.shadowRoot.querySelector("#categories-group");
const [peopleData, categoriesData] = await Promise.all([
dataFetch({ url: "/api/people/" }).catch(() => ({ people: [] })),
dataFetch({ url: "/api/categories/" }).catch(() => []),
]);
const people = peopleData.people || [];
// /api/categories/ returns [{name, count}] most-common-first. Tolerate a bare
// string list (legacy) so the filter still works if the shape is rolled back.
const rawCats = Array.isArray(categoriesData)
? categoriesData
: categoriesData.categories || [];
this._allCategories = rawCats
.map((c) =>
typeof c === "object" && c !== null ? { name: c.name, count: c.count || 0 } : { name: c, count: 0 }
)
.filter((c) => c && c.name);
if (people.length) {
const label = document.createElement("span");
label.className = "pxme-filter__label";
label.textContent = "People:";
peopleGroup.appendChild(label);
people.forEach((person) => {
const name = typeof person === "object" ? person.name : person;
const chip = document.createElement("button");
chip.className = "pxme-btn pxme-filter__chip";
chip.textContent = name;
chip.setAttribute("aria-pressed", "false");
chip.addEventListener("click", () => {
const isActive = this._selectedPeople.has(name);
if (isActive) {
this._selectedPeople.delete(name);
chip.classList.remove("pxme-filter__chip--active");
chip.setAttribute("aria-pressed", "false");
} else {
this._selectedPeople.add(name);
chip.classList.add("pxme-filter__chip--active");
chip.setAttribute("aria-pressed", "true");
}
this._emitFilterChange();
});
peopleGroup.appendChild(chip);
});
}
if (this._allCategories.length) {
const label = document.createElement("span");
label.className = "pxme-filter__label";
label.textContent = "Categories:";
categoriesGroup.appendChild(label);
// A search box is only shown once the list exceeds the display cap
// (CAT-20: optional/hidden when the list is small; CAT-21: available when big).
if (this._allCategories.length > this._cap) {
const search = document.createElement("input");
search.className = "pxme-filter__category-search";
search.type = "search";
search.placeholder = "Find a category…";
search.setAttribute("aria-label", "Search categories");
search.addEventListener("input", () => {
this._catQuery = search.value;
this._renderCategoryChips(categoriesGroup);
});
categoriesGroup.appendChild(search);
}
this._renderCategoryChips(categoriesGroup);
}
if (!people.length && !this._allCategories.length) {
const msg = document.createElement("span");
msg.className = "pxme-filter__empty";
msg.textContent = "No filters available";
this.shadowRoot.querySelector("#dropdown").appendChild(msg);
}
}
// Renders the category chips into categoriesGroup, applying the cap + search.
// The cap (CAT-21/23) is display-only: searching can select a category ranked
// beyond the cap, and the backend filters the full dataset.
_renderCategoryChips(categoriesGroup) {
// Remove existing chips (keep the label + search input).
categoriesGroup
.querySelectorAll(".pxme-filter__chip")
.forEach((el) => el.remove());
const q = this._catQuery.trim().toLowerCase();
let visible = this._allCategories;
if (q) {
// A search reveals categories beyond the cap (CAT-22).
visible = visible.filter((c) => c.name.toLowerCase().includes(q));
} else {
// No search: cap to the most common N (already sorted most-common-first).
visible = visible.slice(0, this._cap);
}
visible.forEach(({ name }) => {
const chip = document.createElement("button");
chip.className = "pxme-btn pxme-filter__chip";
// No count is displayed next to a chip (counts rank only, not shown).
chip.textContent = name;
chip.setAttribute("aria-pressed", this._selectedCategories.has(name) ? "true" : "false");
if (this._selectedCategories.has(name)) {
chip.classList.add("pxme-filter__chip--active");
}
chip.addEventListener("click", () => {
const isActive = this._selectedCategories.has(name);
if (isActive) {
this._selectedCategories.delete(name);
chip.classList.remove("pxme-filter__chip--active");
chip.setAttribute("aria-pressed", "false");
} else {
this._selectedCategories.add(name);
chip.classList.add("pxme-filter__chip--active");
chip.setAttribute("aria-pressed", "true");
}
this._emitFilterChange();
});
categoriesGroup.appendChild(chip);
});
}
_emitFilterChange() {
this.dispatchEvent(
new CustomEvent("filter-change", {
detail: {
people: [...this._selectedPeople],
categories: [...this._selectedCategories],
},
bubbles: true,
composed: true,
})
);
}
}
customElements.define("pxme-filter", PxmeFilter);