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 = ` +
+ + + + + + + + +
+
+
x
+
+
T1
+
T2
+
H1
+
+
+ `; + } + test("initializes and caches items, pushes instance", () => { buildBasicDOM(); InitializeCMSFilter(); @@ -172,6 +203,100 @@ describe("CMSFilter", () => { expect(ordered).toEqual(["gamma", "beta", "alpha"]); }); + test("hybrid mode narrows make like advanced but keeps all relevant body types visible for multi-select", () => { + buildHybridScenarioDOM("hybrid", "bodytype"); + InitializeCMSFilter(); + const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); + const toyota = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="make"]'), + ).find((l) => l.textContent.includes("Toyota")); + const suv = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'), + ).find((l) => l.textContent.includes("SUV")); + toyota.querySelector("input").checked = true; + suv.querySelector("input").checked = true; + toyota + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); + suv.querySelector("input").dispatchEvent(new Event("change", { bubbles: true })); + const instance = window.webtricks[0].CMSFilter; + instance.ApplyFilters(); + + expect(instance.filteredItems.length).toBe(1); + const makeLabels = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="make"]'), + ); + const visibleMake = makeLabels.filter((l) => l.style.display !== "none"); + expect(visibleMake.map((l) => l.textContent.trim())).toEqual(["Toyota"]); + + const bodyLabels = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'), + ); + const visibleBody = bodyLabels.filter((l) => l.style.display !== "none"); + expect(visibleBody.map((l) => l.textContent.trim()).sort()).toEqual([ + "SUV", + "Sedan", + ]); + }); + + test("hybrid with empty wt-cmsfilter-hybrid-categories narrows all facets like advanced", () => { + buildHybridScenarioDOM("hybrid", ""); + InitializeCMSFilter(); + const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); + const toyota = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="make"]'), + ).find((l) => l.textContent.includes("Toyota")); + const suv = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'), + ).find((l) => l.textContent.includes("SUV")); + toyota.querySelector("input").checked = true; + suv.querySelector("input").checked = true; + toyota + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); + suv.querySelector("input").dispatchEvent(new Event("change", { bubbles: true })); + const instance = window.webtricks[0].CMSFilter; + instance.ApplyFilters(); + + const bodyLabels = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'), + ); + const visibleBody = bodyLabels.filter((l) => l.style.display !== "none"); + expect(visibleBody.map((l) => l.textContent.trim())).toEqual(["SUV"]); + }); + + test("advanced mode hides sibling make and hides body types not in result set", () => { + buildHybridScenarioDOM("advanced"); + InitializeCMSFilter(); + const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); + const toyota = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="make"]'), + ).find((l) => l.textContent.includes("Toyota")); + const suv = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'), + ).find((l) => l.textContent.includes("SUV")); + toyota.querySelector("input").checked = true; + suv.querySelector("input").checked = true; + toyota + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); + suv.querySelector("input").dispatchEvent(new Event("change", { bubbles: true })); + const instance = window.webtricks[0].CMSFilter; + instance.ApplyFilters(); + + const makeLabels = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="make"]'), + ); + const visibleMake = makeLabels.filter((l) => l.style.display !== "none"); + expect(visibleMake.map((l) => l.textContent.trim())).toEqual(["Toyota"]); + + const bodyLabels = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'), + ); + const visibleBody = bodyLabels.filter((l) => l.style.display !== "none"); + expect(visibleBody.map((l) => l.textContent.trim())).toEqual(["SUV"]); + }); + test("advanced filtering hides unavailable checkboxes then restores after clearing", () => { buildBasicDOM({ advanced: true }); InitializeCMSFilter(); diff --git a/docs/WebflowOnly/CMSFilter.md b/docs/WebflowOnly/CMSFilter.md index c2f4002..0349446 100644 --- a/docs/WebflowOnly/CMSFilter.md +++ b/docs/WebflowOnly/CMSFilter.md @@ -12,6 +12,7 @@ CMSFilter is a powerful Webflow-specific script that provides advanced filtering - Multiple filter types (checkbox, radio, text, range) - Advanced filtering with dynamic availability updates +- Hybrid filtering mode (`hybrid` + `wt-cmsfilter-hybrid-categories`): like advanced, but listed categories keep sibling checkbox options for multi-select; other facets narrow from the current result set - Pagination support with auto-loading across pages - Dynamic sorting (numeric, date, alphabetical) - Active filter tags with individual removal @@ -46,7 +47,9 @@ Add the script to your Webflow project and include the required attributes on yo #### Core Filter Attributes -- `wt-cmsfilter-filtering="advanced"` - Enables advanced filtering mode with dynamic availability updates +- `wt-cmsfilter-filtering="advanced"` - Enables advanced filtering mode with dynamic availability updates (every facet’s options narrow to the current result set; selecting one **make** hides other makes). +- `wt-cmsfilter-filtering="hybrid"` - Like advanced, but selected categories (see below) keep **all** of their checkbox options that match the current filters **except** that category’s own selections (multi-select friendly). All **other** categories narrow from the current result set like **`advanced`**. +- `wt-cmsfilter-hybrid-categories="bodytype"` - Comma-separated `wt-cmsfilter-category` names that use hybrid self-exclude behavior (e.g. `bodytype` or `bodytype,colour`). **Required** for any facet to use hybrid self-exclude; **omit or leave empty** if every facet should narrow like **`advanced`**. Requires matching `data-*` fields on list items for each listed category. - `wt-cmsfilter-trigger="button"` - Changes filter trigger to button submit instead of real-time - `wt-cmsfilter-class="classname"` - CSS class applied to active filter elements - `wt-cmsfilter-resetix2="true"` - Reset IX2 interactions on filtered items @@ -210,6 +213,27 @@ Add the script to your Webflow project and include the required attributes on yo ``` +### Hybrid mode (advanced + multi-select on chosen facets) + +Use **`hybrid`** instead of **`advanced`** on the form, and set **`wt-cmsfilter-hybrid-categories`** to a comma-separated list of `wt-cmsfilter-category` values that should **not** hide sibling options when you select one value (e.g. body type so users can pick SUV and Minivan at once). **Omit** `wt-cmsfilter-hybrid-categories` or leave it **empty** if every facet should narrow like **`advanced`**. List items need matching `data-*` fields for each category you name. + +```html +
+ + + + +
+
+
+
+``` + +Multiple categories: `wt-cmsfilter-hybrid-categories="bodytype,colour"`. + ### Button-Triggered Filtering ```html @@ -247,7 +271,7 @@ Add the script to your Webflow project and include the required attributes on yo - Use debouncing for text inputs in large collections (adjust `wt-cmsfilter-debounce` value) - Enable pagination for collections with 50+ items -- Use advanced filtering mode only when needed for better performance +- Use advanced or hybrid filtering mode only when needed for better performance - Implement IX2 reset (`wt-cmsfilter-resetix2="true"`) sparingly as it impacts performance - Consider using button-triggered filtering for complex filter sets