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);