diff --git a/Dist/WebflowOnly/CMSFilter.js b/Dist/WebflowOnly/CMSFilter.js index fcb261e..0dcb606 100644 --- a/Dist/WebflowOnly/CMSFilter.js +++ b/Dist/WebflowOnly/CMSFilter.js @@ -559,88 +559,100 @@ class CMSFilter { }); } - ApplyFilters() { - const filters = this.GetFilters(); - this.currentPage = 1; // Reset pagination to first page - this.filteredItems = this.allItems.filter((item) => { - return Object.keys(filters).every((category) => { - // Fix 1: Safari-compatible array handling - const categoryFilters = filters[category] || []; - const values = Array.isArray(categoryFilters) - ? categoryFilters.slice() - : []; - if (values.length === 0) return true; - - // Use cached search data instead of live DOM queries - const searchCache = item._wtSearchCache; - if (!searchCache) { - console.warn( - "Search cache missing for item, falling back to live query", - ); - // Fallback to original method if cache is missing - const categoryElement = item.querySelector( - `[wt-cmsfilter-category="${category}"]`, - ); - let matchingText = ""; - if (categoryElement && categoryElement.innerText) { - matchingText = categoryElement.innerText.toLowerCase(); - } - matchingText = matchingText.replace(/(?: |\s)+/gi, " "); + /** + * Whether a single list item matches the given filter map (same rules as ApplyFilters). + */ + itemMatchesFilters(item, filters) { + return Object.keys(filters).every((category) => { + const categoryFilters = filters[category] || []; + const values = Array.isArray(categoryFilters) + ? categoryFilters.slice() + : []; + if (values.length === 0) return true; + + const searchCache = item._wtSearchCache; + if (!searchCache) { + console.warn( + "Search cache missing for item, falling back to live query", + ); + const categoryElement = item.querySelector( + `[wt-cmsfilter-category="${category}"]`, + ); + let matchingText = ""; + if (categoryElement && categoryElement.innerText) { + matchingText = categoryElement.innerText.toLowerCase(); } + matchingText = matchingText.replace(/(?: |\s)+/gi, " "); + } - if (category === "*") { - // Global search using cached text - const globalText = searchCache ? searchCache.globalSearchText : ""; - return ( - values.some((value) => globalText.includes(value.toLowerCase())) || - Object.values(item.dataset || {}).some((dataValue) => - values.some((value) => { - if (dataValue && typeof dataValue.toLowerCase === "function") { - return dataValue.toLowerCase().includes(value.toLowerCase()); - } - return false; - }), - ) - ); - } else { - return values.some((value) => { - if (typeof value === "object" && value !== null) { - // Range filtering - use normalized dataset key - const datasetCategory = this.GetDataSet(category); - const datasetValue = - item.dataset && item.dataset[datasetCategory] - ? item.dataset[datasetCategory] - : ""; - const itemValue = parseFloat(datasetValue); - if (isNaN(itemValue)) return false; - if (value.from !== null && value.to !== null) { - return itemValue >= value.from && itemValue <= value.to; - } else if (value.from !== null && value.to == null) { - return itemValue >= value.from; - } else if (value.from == null && value.to !== null) { - return itemValue <= value.to; + if (category === "*") { + const globalText = searchCache ? searchCache.globalSearchText : ""; + return ( + values.some((value) => globalText.includes(value.toLowerCase())) || + Object.values(item.dataset || {}).some((dataValue) => + values.some((value) => { + if (dataValue && typeof dataValue.toLowerCase === "function") { + return dataValue.toLowerCase().includes(value.toLowerCase()); } return false; - } else { - // Text filtering using cached data - const datasetCategory = this.GetDataSet(category); - const cachedDatasetValue = searchCache - ? searchCache.datasetValues.get(datasetCategory) || "" - : ""; - const cachedCategoryText = searchCache - ? searchCache.categoryTexts.get(category) || "" - : ""; - const valueStr = value ? value.toString().toLowerCase() : ""; - - return ( - cachedDatasetValue.includes(valueStr) || - cachedCategoryText.includes(valueStr) - ); - } - }); + }), + ) + ); + } + return values.some((value) => { + if (typeof value === "object" && value !== null) { + const datasetCategory = this.GetDataSet(category); + const datasetValue = + item.dataset && item.dataset[datasetCategory] + ? item.dataset[datasetCategory] + : ""; + const itemValue = parseFloat(datasetValue); + if (isNaN(itemValue)) return false; + if (value.from !== null && value.to !== null) { + return itemValue >= value.from && itemValue <= value.to; + } else if (value.from !== null && value.to == null) { + return itemValue >= value.from; + } else if (value.from == null && value.to !== null) { + return itemValue <= value.to; + } + return false; + } else { + const datasetCategory = this.GetDataSet(category); + const cachedDatasetValue = searchCache + ? searchCache.datasetValues.get(datasetCategory) || "" + : ""; + const cachedCategoryText = searchCache + ? searchCache.categoryTexts.get(category) || "" + : ""; + const valueStr = value ? value.toString().toLowerCase() : ""; + + return ( + cachedDatasetValue.includes(valueStr) || + cachedCategoryText.includes(valueStr) + ); } }); }); + } + + /** + * Items matching current filters but ignoring one or more categories (for hybrid availability). + */ + getFilteredItemsIgnoringCategories(excludedCategoryAttrs) { + const filters = this.GetFilters(); + const f = { ...filters }; + excludedCategoryAttrs.forEach((cat) => { + f[cat] = []; + }); + return this.allItems.filter((item) => this.itemMatchesFilters(item, f)); + } + + ApplyFilters() { + const filters = this.GetFilters(); + this.currentPage = 1; // Reset pagination to first page + this.filteredItems = this.allItems.filter((item) => + this.itemMatchesFilters(item, filters), + ); this.activeFilters = filters; this.SortItems(); @@ -765,19 +777,48 @@ class CMSFilter { }); } + /** + * Categories whose checkbox availability ignores that facet’s own selections (hybrid mode). + * Set on the form: wt-cmsfilter-hybrid-categories="bodytype" or "bodytype,colour" + * Omit or leave empty for no self-exclude categories (hybrid availability matches advanced for every facet). + */ + getHybridSelfExcludeCategories() { + const raw = this.filterForm.getAttribute( + "wt-cmsfilter-hybrid-categories", + ); + if (raw === null || raw.trim() === "") { + return []; + } + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + UpdateAvailableFilters() { - if (this.filterForm.getAttribute("wt-cmsfilter-filtering") !== "advanced") - return; + const filteringMode = this.filterForm.getAttribute( + "wt-cmsfilter-filtering", + ); + if (filteringMode !== "advanced" && filteringMode !== "hybrid") return; + this.availableFilters = {}; this.filterElements.forEach((element) => { - const category = this.GetDataSet( - element.getAttribute("wt-cmsfilter-category"), - ); + const categoryAttr = element.getAttribute("wt-cmsfilter-category"); + const category = this.GetDataSet(categoryAttr); + + let sourceItems = this.filteredItems; + if ( + filteringMode === "hybrid" && + categoryAttr && + this.getHybridSelfExcludeCategories().includes(categoryAttr) + ) { + sourceItems = this.getFilteredItemsIgnoringCategories([categoryAttr]); + } // Safari-compatible dataset access const availableValues = new Set( - this.filteredItems + sourceItems .map((item) => item.dataset && item.dataset[category] ? item.dataset[category] @@ -919,7 +960,10 @@ class CMSFilter { input.value = ""; } } else if (input.type === "checkbox") { - if (advancedFiltering === "advanced") { + if ( + advancedFiltering === "advanced" || + advancedFiltering === "hybrid" + ) { input.checked = false; } else { if (categoryElement.innerText === value) { diff --git a/__tests__/CMSFilter.test.js b/__tests__/CMSFilter.test.js index 516ab00..ef5d188 100644 --- a/__tests__/CMSFilter.test.js +++ b/__tests__/CMSFilter.test.js @@ -73,6 +73,37 @@ describe("CMSFilter", () => { `; } + /** make, bodytype, Category — hybrid (bodytype self-exclude) vs advanced */ + function buildHybridScenarioDOM(filteringMode = "hybrid", hybridCategoriesAttr) { + const modeAttr = filteringMode + ? `wt-cmsfilter-filtering="${filteringMode}"` + : ""; + const hybridAttr = + filteringMode === "hybrid" + ? ` wt-cmsfilter-hybrid-categories="${hybridCategoriesAttr ?? ""}"` + : ""; + document.body.innerHTML = ` +
+ +