From b5f1fdc56c821f307c2a1f407475d9e10cf6b471 Mon Sep 17 00:00:00 2001 From: Matthew Simpson Date: Wed, 25 Feb 2026 12:53:35 -0800 Subject: [PATCH 1/2] adds optional parameter for maximum elements displayed if the wt-cms-element="empty" flag is set --- .gitignore | 3 +- Dist/WebflowOnly/CMSFilter.js | 1913 ++++++++++++++++++--------------- __tests__/CMSFilter.test.js | 215 +++- docs/WebflowOnly/CMSFilter.md | 6 + 4 files changed, 1201 insertions(+), 936 deletions(-) diff --git a/.gitignore b/.gitignore index 40b878d..417c6ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules/ \ No newline at end of file +node_modules/ +.vscode/ \ No newline at end of file diff --git a/Dist/WebflowOnly/CMSFilter.js b/Dist/WebflowOnly/CMSFilter.js index bc3cc29..66bae7c 100644 --- a/Dist/WebflowOnly/CMSFilter.js +++ b/Dist/WebflowOnly/CMSFilter.js @@ -1,960 +1,1115 @@ -'use strict'; +"use strict"; class CMSFilter { - constructor() { - //CORE elements - this.filterForm = document.querySelector('[wt-cmsfilter-element="filter-form"]'); - this.listElement = document.querySelector('[wt-cmsfilter-element="list"]'); - this.filterElements = this.filterForm.querySelectorAll('[wt-cmsfilter-category]'); - this.currentPage = 1; // default value - this.itemsPerPage = 0; // gets updated during init - this.debounceDelay = parseInt(this.filterForm.getAttribute('wt-cmsfilter-debounce') || '300'); - - //TAG elements - this.tagTemplate = document.querySelector('[wt-cmsfilter-element="tag-template"]'); - this.tagTemplateContainer = (this.tagTemplate) ? this.tagTemplate.parentElement : null; - - //Pagination & Loading - //Pagination wrapper is a MUST for the full functionality of the filter to work properly, - //if not added the filter will only work with whatever is loaded by default. - this.paginationWrapper = document.querySelector('[wt-cmsfilter-element="pagination-wrapper"]') || null; - this.loadMode = this.listElement.getAttribute('wt-cmsfilter-loadmode') || 'load-all'; - this.previousButton = document.querySelector('[wt-cmsfilter-pagination="prev"]'); - this.nextButton = document.querySelector('[wt-cmsfilter-pagination="next"]'); - this.customNextButton = document.querySelector('[wt-cmsfilter-element="custom-next"]'); - this.customPrevButton = document.querySelector('[wt-cmsfilter-element="custom-prev"]'); - - this.paginationcounter = document.querySelector('[wt-cmsfilter-element="page-count"]'); - this.activeFilterClass = this.filterForm.getAttribute('wt-cmsfilter-class'); - this.clearAll = document.querySelector('[wt-cmsfilter-element="clear-all"]'); - this.sortOptions = document.querySelector('[wt-cmsfilter-element="sort-options"]'); - this.resultCount = document.querySelector('[wt-cmsfilter-element="results-count"]'); - this.emptyElement = document.querySelector('[wt-cmsfilter-element="empty"]'); - this.resetIx2 = this.listElement.getAttribute('wt-cmsfilter-resetix2') || false; - - this.allItems = []; - this.filteredItems = []; - this.totalPages = 1; - this.activeFilters = {}; - this.availableFilters = {}; - this.dataRanges = {}; // Store calculated min/max for numeric categories (set once) - this.originalDisplayStyles = new Map(); // Store original display styles for filter elements - - this.init(); + constructor() { + //CORE elements + this.filterForm = document.querySelector( + '[wt-cmsfilter-element="filter-form"]', + ); + this.listElement = document.querySelector('[wt-cmsfilter-element="list"]'); + this.filterElements = this.filterForm.querySelectorAll( + "[wt-cmsfilter-category]", + ); + this.currentPage = 1; // default value + this.itemsPerPage = 0; // gets updated during init + this.debounceDelay = parseInt( + this.filterForm.getAttribute("wt-cmsfilter-debounce") || "300", + ); + + //TAG elements + this.tagTemplate = document.querySelector( + '[wt-cmsfilter-element="tag-template"]', + ); + this.tagTemplateContainer = this.tagTemplate + ? this.tagTemplate.parentElement + : null; + + //Pagination & Loading + //Pagination wrapper is a MUST for the full functionality of the filter to work properly, + //if not added the filter will only work with whatever is loaded by default. + this.paginationWrapper = + document.querySelector('[wt-cmsfilter-element="pagination-wrapper"]') || + null; + this.loadMode = + this.listElement.getAttribute("wt-cmsfilter-loadmode") || "load-all"; + this.previousButton = document.querySelector( + '[wt-cmsfilter-pagination="prev"]', + ); + this.nextButton = document.querySelector( + '[wt-cmsfilter-pagination="next"]', + ); + this.customNextButton = document.querySelector( + '[wt-cmsfilter-element="custom-next"]', + ); + this.customPrevButton = document.querySelector( + '[wt-cmsfilter-element="custom-prev"]', + ); + + this.paginationcounter = document.querySelector( + '[wt-cmsfilter-element="page-count"]', + ); + this.activeFilterClass = this.filterForm.getAttribute("wt-cmsfilter-class"); + this.clearAll = document.querySelector( + '[wt-cmsfilter-element="clear-all"]', + ); + this.sortOptions = document.querySelector( + '[wt-cmsfilter-element="sort-options"]', + ); + this.resultCount = document.querySelector( + '[wt-cmsfilter-element="results-count"]', + ); + this.emptyElement = document.querySelector( + '[wt-cmsfilter-element="empty"]', + ); + this.emptyMaxCount = 0; + if (this.emptyElement) { + const emptyMaxValue = parseInt( + this.emptyElement.getAttribute("wt-cmsfilter-empty-max"), + 10, + ); + if (Number.isInteger(emptyMaxValue) && emptyMaxValue >= 0) { + this.emptyMaxCount = emptyMaxValue; + } } - - async init() { - this.allItems = Array.from(this.listElement.children); - this.itemsPerPage = this.allItems.length; - if (this.paginationWrapper) { - await this.LoadAllItems(); - if (this.paginationcounter && this.paginationcounter != this.paginationWrapper.querySelector('.w-page-count')) { - this.paginationWrapper.querySelector('.w-page-count').remove(); - } else { - this.paginationcounter = this.paginationWrapper.querySelector('.w-page-count'); - } + this.resetIx2 = + this.listElement.getAttribute("wt-cmsfilter-resetix2") || false; + + this.allItems = []; + this.filteredItems = []; + this.totalPages = 1; + this.activeFilters = {}; + this.availableFilters = {}; + this.dataRanges = {}; // Store calculated min/max for numeric categories (set once) + this.originalDisplayStyles = new Map(); // Store original display styles for filter elements + + this.init(); + } + + async init() { + this.allItems = Array.from(this.listElement.children); + this.itemsPerPage = this.allItems.length; + if (this.paginationWrapper) { + await this.LoadAllItems(); + if ( + this.paginationcounter && + this.paginationcounter != + this.paginationWrapper.querySelector(".w-page-count") + ) { + this.paginationWrapper.querySelector(".w-page-count").remove(); + } else { + this.paginationcounter = + this.paginationWrapper.querySelector(".w-page-count"); + } + } + this.SetupEventListeners(); + + this.InitializeTagTemplate(); + // Capture original display styles before any filtering occurs + this.captureOriginalDisplayStyles(); + + // Cache search text for all items once during initialization + this.cacheItemSearchData(); + + this.RenderItems(); + this.UpdateAvailableFilters(); + + // Calculate range slider bounds once from original data (never changes during filtering) + this.calculateInitialRanges(); + + // Initialize range inputs defaults (from=min, to=max) once + this.initializeRangeInputsDefaults(); + + this.activeFilters = this.GetFilters(); + this.ShowResultCount(); + } + + /** + * Calculates initial data ranges from all items and configures range sliders + * This is called once during initialization and ranges never change during filtering + */ + calculateInitialRanges() { + this.dataRanges = {}; + + // Get all categories from filter elements + const categories = new Set(); + this.filterElements.forEach((element) => { + const category = element.getAttribute("wt-cmsfilter-category"); + if (category && category !== "*") { + categories.add(this.GetDataSet(category)); + } + }); + + categories.forEach((category) => { + const values = this.allItems + .map((item) => parseFloat(item.dataset[category])) + .filter((value) => !isNaN(value) && isFinite(value)); + + if (values.length > 0) { + this.dataRanges[category] = { + min: Math.min(...values), + max: Math.max(...values), + count: values.length, + }; + } + }); + + // Configure range sliders with calculated ranges (only once) + this.configureRangeSliders(); + } + + /** + * Configures range sliders with calculated data ranges + * Sets min/max values for sliders configured with wt-rangeslider-category + * Only called once during initialization + */ + configureRangeSliders() { + // Find all range slider elements + const rangeSliders = document.querySelectorAll( + '[wt-rangeslider-element="slider"]', + ); + + if (!rangeSliders.length) return; + + rangeSliders.forEach((slider) => { + // Try to get category from slider attribute first + let category = slider.getAttribute("wt-rangeslider-category"); + + // If not found on slider, look for category from associated filter inputs + if (!category) { + const wrapper = slider.closest( + '[wt-rangeslider-element="slider-wrapper"]', + ); + if (wrapper) { + const categoryInput = wrapper.querySelector( + "[wt-cmsfilter-category]", + ); + if (categoryInput) { + category = categoryInput.getAttribute("wt-cmsfilter-category"); + } } - this.SetupEventListeners(); - - this.InitializeTagTemplate(); - // Capture original display styles before any filtering occurs - this.captureOriginalDisplayStyles(); - - // Cache search text for all items once during initialization - this.cacheItemSearchData(); - - this.RenderItems(); - this.UpdateAvailableFilters(); - - // Calculate range slider bounds once from original data (never changes during filtering) - this.calculateInitialRanges(); - - // Initialize range inputs defaults (from=min, to=max) once - this.initializeRangeInputsDefaults(); - - this.activeFilters = this.GetFilters(); - this.ShowResultCount(); + } + + // Skip if no category found or no data range for this category + if (!category || !this.dataRanges[this.GetDataSet(category)]) return; + + const datasetCategory = this.GetDataSet(category); + + // Check if manual configuration exists (manual takes precedence) + const hasManualMin = slider.hasAttribute("wt-rangeslider-min"); + const hasManualMax = slider.hasAttribute("wt-rangeslider-max"); + + // Only set auto-detected values if manual ones aren't provided + if (!hasManualMin) { + slider.setAttribute( + "wt-rangeslider-min", + this.dataRanges[datasetCategory].min.toString(), + ); + } + if (!hasManualMax) { + slider.setAttribute( + "wt-rangeslider-max", + this.dataRanges[datasetCategory].max.toString(), + ); + } + + // Set intelligent default steps if not specified + if (!slider.hasAttribute("wt-rangeslider-steps")) { + const range = + this.dataRanges[datasetCategory].max - + this.dataRanges[datasetCategory].min; + const defaultSteps = + range > 1000 ? 100 : range > 100 ? 10 : range > 10 ? 1 : 0.1; + slider.setAttribute("wt-rangeslider-steps", defaultSteps.toString()); + } + + console.log( + `Configured range slider for ${category}: min=${this.dataRanges[datasetCategory].min}, max=${this.dataRanges[datasetCategory].max}`, + ); + }); + } + + /** + * Build and store search cache for all items + */ + cacheItemSearchData() { + if (!this.allItems || this.allItems.length === 0) return; + this.allItems.forEach((item) => this.cacheItemForSearch(item)); + } + + /** + * Build and attach a normalized search cache for a single item + * Cache shape: + * { + * globalSearchText: string, + * datasetValues: Map, + * categoryTexts: Map + * } + */ + cacheItemForSearch(item) { + if (!item || !(item instanceof Element)) return; + + const normalize = (text) => + (text || "") + .toString() + .toLowerCase() + .replace(/(?: |\s)+/gi, " ") + .trim(); + + const datasetValues = new Map(); + const categoryTexts = new Map(); + + // Cache dataset values (normalized) + if (item.dataset) { + Object.keys(item.dataset).forEach((key) => { + const value = item.dataset[key]; + datasetValues.set(key, normalize(value)); + }); } - /** - * Calculates initial data ranges from all items and configures range sliders - * This is called once during initialization and ranges never change during filtering - */ - calculateInitialRanges() { - this.dataRanges = {}; - - // Get all categories from filter elements - const categories = new Set(); - this.filterElements.forEach(element => { - const category = element.getAttribute('wt-cmsfilter-category'); - if (category && category !== '*') { - categories.add(this.GetDataSet(category)); - } + // Cache category-specific text found inside the item + const categoryNodes = item.querySelectorAll("[wt-cmsfilter-category]"); + categoryNodes.forEach((node) => { + const category = node.getAttribute("wt-cmsfilter-category"); + if (!category) return; + const text = node.textContent || node.innerText || ""; + categoryTexts.set(category, normalize(text)); + }); + + // Global searchable text: item's text + dataset values + const itemText = normalize(item.textContent || item.innerText || ""); + const datasetConcat = Array.from(datasetValues.values()).join(" "); + const globalSearchText = normalize(`${itemText} ${datasetConcat}`); + + item._wtSearchCache = { globalSearchText, datasetValues, categoryTexts }; + } + + /** + * Initialize default values for range inputs using precomputed data ranges + * - Sets wt-cmsfilter-default to min (from) or max (to) if not present + * - Populates the input's value if it's empty + */ + initializeRangeInputsDefaults() { + if (!this.filterElements || !this.dataRanges) return; + + this.filterElements.forEach((element) => { + const input = + element.tagName === "INPUT" + ? element + : element.querySelector('input[type="text"]'); + + if (!input || input.type !== "text") return; + + const rangeType = element.getAttribute("wt-cmsfilter-range"); + if (rangeType !== "from" && rangeType !== "to") return; + + const categoryAttr = element.getAttribute("wt-cmsfilter-category"); + if (!categoryAttr) return; + + const datasetCategory = this.GetDataSet(categoryAttr); + const ranges = this.dataRanges[datasetCategory]; + if (!ranges) return; + + const defaultValue = rangeType === "from" ? ranges.min : ranges.max; + if (!Number.isFinite(defaultValue)) return; + + if (!input.hasAttribute("wt-cmsfilter-default")) { + input.setAttribute("wt-cmsfilter-default", String(defaultValue)); + } + + if (input.value.trim() === "") { + input.value = String(defaultValue); + } + }); + } + + SetupEventListeners() { + // Create a debounced version of ApplyFilters + const debouncedApplyFilters = this.debounce( + () => this.ApplyFilters(), + this.debounceDelay, + ); + + if (this.filterForm.hasAttribute("wt-cmsfilter-trigger")) { + if (this.filterForm.getAttribute("wt-cmsfilter-trigger") === "button") { + this.filterForm.addEventListener("submit", (event) => { + event.preventDefault(); + this.ApplyFilters(); // No debounce needed for button submission }); - - categories.forEach(category => { - const values = this.allItems - .map(item => parseFloat(item.dataset[category])) - .filter(value => !isNaN(value) && isFinite(value)); - - if (values.length > 0) { - this.dataRanges[category] = { - min: Math.min(...values), - max: Math.max(...values), - count: values.length - }; - } + } else { + this.filterForm.addEventListener("change", () => { + debouncedApplyFilters(); }); - - // Configure range sliders with calculated ranges (only once) - this.configureRangeSliders(); - } - - /** - * Configures range sliders with calculated data ranges - * Sets min/max values for sliders configured with wt-rangeslider-category - * Only called once during initialization - */ - configureRangeSliders() { - // Find all range slider elements - const rangeSliders = document.querySelectorAll('[wt-rangeslider-element="slider"]'); - - if(!rangeSliders.length) return; - - rangeSliders.forEach(slider => { - // Try to get category from slider attribute first - let category = slider.getAttribute('wt-rangeslider-category'); - - // If not found on slider, look for category from associated filter inputs - if (!category) { - const wrapper = slider.closest('[wt-rangeslider-element="slider-wrapper"]'); - if (wrapper) { - const categoryInput = wrapper.querySelector('[wt-cmsfilter-category]'); - if (categoryInput) { - category = categoryInput.getAttribute('wt-cmsfilter-category'); - } - } - } - - // Skip if no category found or no data range for this category - if (!category || !this.dataRanges[this.GetDataSet(category)]) return; - - const datasetCategory = this.GetDataSet(category); - - // Check if manual configuration exists (manual takes precedence) - const hasManualMin = slider.hasAttribute('wt-rangeslider-min'); - const hasManualMax = slider.hasAttribute('wt-rangeslider-max'); - - // Only set auto-detected values if manual ones aren't provided - if (!hasManualMin) { - slider.setAttribute('wt-rangeslider-min', this.dataRanges[datasetCategory].min.toString()); - } - if (!hasManualMax) { - slider.setAttribute('wt-rangeslider-max', this.dataRanges[datasetCategory].max.toString()); - } - - // Set intelligent default steps if not specified - if (!slider.hasAttribute('wt-rangeslider-steps')) { - const range = this.dataRanges[datasetCategory].max - this.dataRanges[datasetCategory].min; - const defaultSteps = range > 1000 ? 100 : range > 100 ? 10 : range > 10 ? 1 : 0.1; - slider.setAttribute('wt-rangeslider-steps', defaultSteps.toString()); - } - - console.log(`Configured range slider for ${category}: min=${this.dataRanges[datasetCategory].min}, max=${this.dataRanges[datasetCategory].max}`); + this.filterForm.addEventListener("input", () => { + debouncedApplyFilters(); }); + } + } else { + this.filterForm.addEventListener("change", () => { + debouncedApplyFilters(); + }); + this.filterForm.addEventListener("input", () => { + debouncedApplyFilters(); + }); } - /** - * Build and store search cache for all items - */ - cacheItemSearchData() { - if (!this.allItems || this.allItems.length === 0) return; - this.allItems.forEach(item => this.cacheItemForSearch(item)); + if (this.previousButton || this.customPrevButton) { + if (this.customPrevButton) { + this.customPrevButton.addEventListener("click", (event) => { + event.preventDefault(); + this.PrevPage(); + }); + if (this.previousButton) { + this.previousButton.remove(); + } + } else { + this.previousButton.addEventListener("click", (event) => { + event.preventDefault(); + this.PrevPage(); + }); + } } - - /** - * Build and attach a normalized search cache for a single item - * Cache shape: - * { - * globalSearchText: string, - * datasetValues: Map, - * categoryTexts: Map - * } - */ - cacheItemForSearch(item) { - if (!item || !(item instanceof Element)) return; - - const normalize = (text) => (text || '') - .toString() - .toLowerCase() - .replace(/(?: |\s)+/gi, ' ') - .trim(); - - const datasetValues = new Map(); - const categoryTexts = new Map(); - - // Cache dataset values (normalized) - if (item.dataset) { - Object.keys(item.dataset).forEach(key => { - const value = item.dataset[key]; - datasetValues.set(key, normalize(value)); - }); + if (this.nextButton || this.customNextButton) { + if (this.customNextButton) { + this.customNextButton.addEventListener("click", (event) => { + event.preventDefault(); + this.NextPage(); + }); + if (this.nextButton) { + this.nextButton.remove(); } - - // Cache category-specific text found inside the item - const categoryNodes = item.querySelectorAll('[wt-cmsfilter-category]'); - categoryNodes.forEach(node => { - const category = node.getAttribute('wt-cmsfilter-category'); - if (!category) return; - const text = node.textContent || node.innerText || ''; - categoryTexts.set(category, normalize(text)); + } else { + this.nextButton.addEventListener("click", (event) => { + event.preventDefault(); + this.NextPage(); }); - - // Global searchable text: item's text + dataset values - const itemText = normalize(item.textContent || item.innerText || ''); - const datasetConcat = Array.from(datasetValues.values()).join(' '); - const globalSearchText = normalize(`${itemText} ${datasetConcat}`); - - item._wtSearchCache = { globalSearchText, datasetValues, categoryTexts }; + } } - /** - * Initialize default values for range inputs using precomputed data ranges - * - Sets wt-cmsfilter-default to min (from) or max (to) if not present - * - Populates the input's value if it's empty - */ - initializeRangeInputsDefaults() { - if (!this.filterElements || !this.dataRanges) return; - - this.filterElements.forEach(element => { - const input = (element.tagName === 'INPUT') - ? element - : element.querySelector('input[type="text"]'); - - if (!input || input.type !== 'text') return; - - const rangeType = element.getAttribute('wt-cmsfilter-range'); - if (rangeType !== 'from' && rangeType !== 'to') return; - - const categoryAttr = element.getAttribute('wt-cmsfilter-category'); - if (!categoryAttr) return; - - const datasetCategory = this.GetDataSet(categoryAttr); - const ranges = this.dataRanges[datasetCategory]; - if (!ranges) return; - - const defaultValue = rangeType === 'from' ? ranges.min : ranges.max; - if (!Number.isFinite(defaultValue)) return; + if (this.clearAll) { + this.clearAll.addEventListener("click", (event) => { + event.preventDefault(); + this.ClearAllFilters(); + }); + } + if (this.sortOptions) { + this.sortOptions.addEventListener("change", (event) => { + event.preventDefault(); + this.ApplyFilters(); + }); + } + } - if (!input.hasAttribute('wt-cmsfilter-default')) { - input.setAttribute('wt-cmsfilter-default', String(defaultValue)); - } + generatePaginationLinksFromString(paginationString, baseUrl) { + const [currentPage, totalPages] = paginationString.split(" / ").map(Number); + const links = []; - if (input.value.trim() === '') { - input.value = String(defaultValue); - } - }); + for (let page = currentPage + 1; page <= totalPages; page++) { + const updatedUrl = baseUrl.replace(/page=\d+/, `page=${page}`); + links.push(updatedUrl); } - SetupEventListeners() { - // Create a debounced version of ApplyFilters - const debouncedApplyFilters = this.debounce(() => this.ApplyFilters(), this.debounceDelay); - - if(this.filterForm.hasAttribute('wt-cmsfilter-trigger')){ - if (this.filterForm.getAttribute('wt-cmsfilter-trigger') === 'button') { - this.filterForm.addEventListener('submit', (event) => { - event.preventDefault(); - this.ApplyFilters(); // No debounce needed for button submission - }); - } else { - this.filterForm.addEventListener('change', () => { - debouncedApplyFilters(); - }); - this.filterForm.addEventListener('input', () => { - debouncedApplyFilters(); - }); + return links; + } + + async LoadAllItems() { + if (!this.paginationWrapper) return; + this.itemsPerPage = this.allItems.length; + + const paginationPages = + this.paginationWrapper.querySelector(".w-page-count"); + const baseLink = this.paginationWrapper.querySelector("a"); + const links = this.generatePaginationLinksFromString( + paginationPages.innerText, + baseLink.href, + ); + if (!links || links.length === 0) return; + + const itemsBeforeLoad = this.allItems.length; + + for (const link of links) { + try { + const htmlDoc = await this.FetchHTML(link); + if (htmlDoc) { + const cards = Array.from( + htmlDoc.querySelector('[wt-cmsfilter-element="list"]')?.children || + [], + ); + + if (cards.length > 0) { + for (const card of cards) { + if (card instanceof Node) { + // Ensure it's a valid DOM node + this.allItems.push(card); + } else { + console.warn("Non-DOM element skipped:", card); + } } + } } else { - this.filterForm.addEventListener('change', () => { - debouncedApplyFilters(); - }); - this.filterForm.addEventListener('input', () => { - debouncedApplyFilters(); - }); - } - - if(this.previousButton || this.customPrevButton) { - if(this.customPrevButton) { - this.customPrevButton.addEventListener('click', (event) => { - event.preventDefault(); - this.PrevPage(); - }); - if (this.previousButton) { - this.previousButton.remove(); - } - } else { - this.previousButton.addEventListener('click', (event) => { - event.preventDefault(); - this.PrevPage(); - }); - } - } - if(this.nextButton || this.customNextButton) { - if(this.customNextButton) { - this.customNextButton.addEventListener('click', (event) => { - event.preventDefault(); - this.NextPage(); - }); - if (this.nextButton) { - this.nextButton.remove(); - } - } else { - this.nextButton.addEventListener('click', (event) => { - event.preventDefault(); - this.NextPage(); - }); - } + console.error("Failed to fetch HTML from the URL:", link.href); } + } catch (error) { + console.error("Error fetching HTML:", error); + } + } - if(this.clearAll) { - this.clearAll.addEventListener('click', (event) => { - event.preventDefault(); - this.ClearAllFilters(); - }); + // Cache search data for newly loaded items only + if (this.allItems.length > itemsBeforeLoad) { + const newItems = this.allItems.slice(itemsBeforeLoad); + newItems.forEach((item) => { + // Cache search data for new item + if (!item._wtSearchCache) { + this.cacheItemForSearch(item); } - if (this.sortOptions) { - this.sortOptions.addEventListener('change', (event) => { - event.preventDefault(); - this.ApplyFilters(); - }); + }); + } + } + + async FetchHTML(url) { + const response = await fetch(url, { + headers: { "X-Requested-With": "XMLHttpRequest" }, + }); + const text = await response.text(); + const parser = new DOMParser(); + return parser.parseFromString(text, "text/html"); + } + + FiltersApplied() { + return Object.values(this.activeFilters).some( + (arr) => Array.isArray(arr) && arr.length > 0, + ); + } + + RenderItems() { + this.listElement.innerHTML = ""; + if (this.filteredItems.length === 0) { + if (!this.FiltersApplied()) { + this.filteredItems = this.allItems; + } + } + if (this.paginationWrapper) { + if (this.loadMode === "load-all") { + this.filteredItems.forEach((item) => { + this.listElement.appendChild(item); + }); + if (this.paginationWrapper) { + this.paginationWrapper.remove(); } + } else if (this.loadMode === "paginate") { + this.totalPages = Math.ceil( + this.filteredItems.length / this.itemsPerPage, + ); + const currentSlice = + this.currentPage * this.itemsPerPage - this.itemsPerPage; + const currentPage = this.filteredItems.slice( + currentSlice, + currentSlice + this.itemsPerPage, + ); + currentPage.forEach((item) => { + this.listElement.appendChild(item); + if (this.resetIx2) this.ResetInteraction(item); + }); + } + } else { + this.filteredItems.forEach((item) => { + this.listElement.appendChild(item); + if (this.resetIx2) this.ResetInteraction(item); + }); } - generatePaginationLinksFromString(paginationString, baseUrl) { - const [currentPage, totalPages] = paginationString.split(' / ').map(Number); - const links = []; - - for (let page = currentPage + 1; page <= totalPages; page++) { - const updatedUrl = baseUrl.replace(/page=\d+/, `page=${page}`); - links.push(updatedUrl); + this.ToggleEmptyState(); + this.UpdatePaginationDisplay(); + } + + SortItems() { + if (!this.sortOptions) return; + + let [key, order] = this.sortOptions.value.split("-"); + this.filteredItems = this.filteredItems.filter( + (item) => !item.hasAttribute("wt-renderstatic-element"), + ); + this.filteredItems.sort((a, b) => { + let aValue = a.dataset[key]; + let bValue = b.dataset[key]; + + // Handle null or undefined values + if (aValue === undefined || aValue === null) aValue = ""; + if (bValue === undefined || bValue === null) bValue = ""; + + // Handle numeric values + if (!isNaN(aValue) && !isNaN(bValue)) { + aValue = parseFloat(aValue); + bValue = parseFloat(bValue); + } + // Handle date values + else if (!isNaN(Date.parse(aValue)) && !isNaN(Date.parse(bValue))) { + aValue = new Date(aValue); + bValue = new Date(bValue); + } + // Handle text values + else { + aValue = aValue.toString().toLowerCase(); + bValue = bValue.toString().toLowerCase(); + } + + if (order === "asc") { + return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; + } else { + return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; + } + }); + } + + 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, " "); } - - return links; - } - async LoadAllItems() { - if (!this.paginationWrapper) return; - this.itemsPerPage = this.allItems.length; - - const paginationPages = this.paginationWrapper.querySelector('.w-page-count'); - const baseLink = this.paginationWrapper.querySelector('a'); - const links = this.generatePaginationLinksFromString(paginationPages.innerText, baseLink.href); - if (!links || links.length === 0) return; - - const itemsBeforeLoad = this.allItems.length; - - for (const link of links) { - try { - const htmlDoc = await this.FetchHTML(link); - if (htmlDoc) { - const cards = Array.from(htmlDoc.querySelector('[wt-cmsfilter-element="list"]')?.children || []); - - if (cards.length > 0) { - for (const card of cards) { - if (card instanceof Node) { // Ensure it's a valid DOM node - this.allItems.push(card); - } else { - console.warn('Non-DOM element skipped:', card); - } - } - } - } else { - console.error('Failed to fetch HTML from the URL:', link.href); + 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()); } - } catch (error) { - console.error('Error fetching HTML:', error); + 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; + } + 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) + ); } + }); } - - // Cache search data for newly loaded items only - if (this.allItems.length > itemsBeforeLoad) { - const newItems = this.allItems.slice(itemsBeforeLoad); - newItems.forEach(item => { - // Cache search data for new item - if (!item._wtSearchCache) { - this.cacheItemForSearch(item); + }); + }); + + this.activeFilters = filters; + this.SortItems(); + this.RenderItems(); + this.UpdateAvailableFilters(); + this.ShowResultCount(); + this.SetActiveTags(); + } + + ShowResultCount() { + if (!this.resultCount) return; + this.resultCount.innerText = this.GetResults(); + } + + GetFilters() { + const filters = {}; + const rangeFilters = {}; + + this.filterElements.forEach((element) => { + const category = element.getAttribute("wt-cmsfilter-category"); + + if (!filters[category]) { + filters[category] = []; + } + + const input = + element.tagName === "INPUT" + ? element + : element.querySelector( + 'input[type="checkbox"], input[type="radio"], input[type="text"]', + ); + + if (input) { + if (input.type === "text") { + const rangeType = element.getAttribute("wt-cmsfilter-range"); + if (rangeType === "from" || rangeType === "to") { + if (!rangeFilters[category]) { + rangeFilters[category] = { from: null, to: null }; + } + + const value = parseFloat(input.value.trim()); + if (Number.isFinite(value)) { + const datasetCategory = this.GetDataSet(category); + const ranges = this.dataRanges + ? this.dataRanges[datasetCategory] + : null; + // Determine default for comparison without mutating attributes here + let numericDefault = parseFloat( + input.getAttribute("wt-cmsfilter-default"), + ); + if (!Number.isFinite(numericDefault) && ranges) { + numericDefault = rangeType === "from" ? ranges.min : ranges.max; + } + + if (Number.isFinite(numericDefault)) { + if (rangeType === "from" && value !== numericDefault) { + rangeFilters[category].from = value; + } else if (rangeType === "to" && value !== numericDefault) { + rangeFilters[category].to = value; } - }); + } + } else { + rangeFilters[category][rangeType] = null; + } + } else if (input.value.trim() !== "") { + filters[category].push(input.value.trim()); + } else { + filters[category] = []; + } + } else if (input.checked) { + filters[category].push(input.nextElementSibling.textContent.trim()); + if (this.activeFilterClass) { + element.classList.add(this.activeFilterClass); + } + } else { + if (this.activeFilterClass) { + element.classList.remove(this.activeFilterClass); + } + } + } + }); + + Object.keys(rangeFilters).forEach((category) => { + const range = rangeFilters[category]; + if (range.from !== null && range.to !== null) { + filters[category].push({ from: range.from, to: range.to }); + } else if (range.from !== null && range.to == null) { + filters[category].push({ from: range.from, to: null }); + } else if (range.from == null && range.to !== null) { + filters[category].push({ from: null, to: range.to }); + } + }); + + return filters; + } + + GetDataSet(str) { + return str + .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) { + return index === 0 ? word.toLowerCase() : word.toUpperCase(); + }) + .replace(/\s+/g, "") + .replace("-", ""); + } + + /** + * Captures original display styles for filter elements + * Called once during initialization to preserve original CSS + */ + captureOriginalDisplayStyles() { + this.filterElements.forEach((element) => { + const istoggle = element.querySelector( + 'input[type="checkbox"], input[type="radio"]', + ); + if (istoggle) { + // Get computed style to capture the actual display value (flex, block, etc.) + const computedStyle = window.getComputedStyle(element); + const originalDisplay = computedStyle.display; + this.originalDisplayStyles.set(element, originalDisplay); + } + }); + } + + UpdateAvailableFilters() { + if (this.filterForm.getAttribute("wt-cmsfilter-filtering") !== "advanced") + return; + this.availableFilters = {}; + + this.filterElements.forEach((element) => { + const category = this.GetDataSet( + element.getAttribute("wt-cmsfilter-category"), + ); + + // Safari-compatible dataset access + const availableValues = new Set( + this.filteredItems + .map((item) => + item.dataset && item.dataset[category] + ? item.dataset[category] + : "", + ) + .filter((value) => value !== ""), + ); + this.availableFilters[category] = availableValues; + + const istoggle = element.querySelector( + 'input[type="checkbox"], input[type="radio"]', + ); + if (istoggle) { + // Safari-compatible text extraction and comparison + let elementText = ""; + if (element.textContent) { + elementText = element.textContent.trim(); + } else if (element.innerText) { + elementText = element.innerText.trim(); } - } - async FetchHTML(url) { - const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); - const text = await response.text(); - const parser = new DOMParser(); - return parser.parseFromString(text, 'text/html'); - } + // Normalize whitespace for Safari compatibility + elementText = elementText.replace(/\s+/g, " "); - FiltersApplied() { - return Object.values(this.activeFilters).some(arr => Array.isArray(arr) && arr.length > 0); - } + // Safari-compatible Set.has() check + let isAvailable = false; + availableValues.forEach((value) => { + const normalizedValue = value.toString().replace(/\s+/g, " ").trim(); + if (normalizedValue === elementText) { + isAvailable = true; + } + }); - RenderItems() { - this.listElement.innerHTML = ''; - if(this.filteredItems.length === 0) { - if(!this.FiltersApplied()) { - this.filteredItems = this.allItems; - } - } - if(this.paginationWrapper) { - if(this.loadMode === 'load-all') { - this.filteredItems.forEach(item => { - this.listElement.appendChild(item); - }); - if(this.paginationWrapper){ - this.paginationWrapper.remove(); - } - } else if (this.loadMode === 'paginate') { - this.totalPages = Math.ceil(this.filteredItems.length / this.itemsPerPage); - const currentSlice = (this.currentPage * this.itemsPerPage) - this.itemsPerPage; - const currentPage = this.filteredItems.slice(currentSlice, currentSlice + this.itemsPerPage); - currentPage.forEach(item => { - this.listElement.appendChild(item); - if(this.resetIx2) this.ResetInteraction(item); - }); - } + // Restore original display style or hide + if (isAvailable) { + // Restore original display style + const originalDisplay = this.originalDisplayStyles.get(element); + if (originalDisplay && originalDisplay !== "none") { + element.style.display = originalDisplay; + } else { + // Fallback: remove display override to use CSS default + element.style.display = ""; + } + element.style.visibility = "visible"; } else { - this.filteredItems.forEach(item => { - this.listElement.appendChild(item); - if(this.resetIx2) this.ResetInteraction(item); - }); + element.style.display = "none"; + element.style.visibility = "hidden"; } - - this.ToggleEmptyState(); - this.UpdatePaginationDisplay(); + } + }); + } + + ToggleEmptyState() { + if (this.emptyElement) { + if (this.filteredItems.length <= this.emptyMaxCount) { + this.emptyElement.style.display = "block"; + } else { + this.emptyElement.style.display = "none"; + } } + } + + InitializeTagTemplate() { + if (!this.tagTemplate) return; + this.tagTemplateContainer.innerHTML = ""; + } + + SetActiveTags() { + if (!this.tagTemplateContainer) return; + this.InitializeTagTemplate(); + + const filterTags = Object.keys(this.activeFilters); + filterTags.forEach((tag) => { + if (this.activeFilters[tag].length !== 0) { + this.activeFilters[tag].forEach((filterValue) => { + const newTag = this.tagTemplate.cloneNode(true); + const tagText = newTag.querySelector( + '[wt-cmsfilter-element="tag-text"]', + ); + const showTagCategory = + newTag.getAttribute("wt-cmsfilter-tag-category") || "true"; + const tagRemove = newTag.querySelector( + '[wt-cmsfilter-element="tag-remove"]', + ); + + if ( + typeof filterValue === "object" && + filterValue.from !== null && + filterValue.to !== null + ) { + tagText.innerText = `${showTagCategory === "true" ? `${tag}:` : ""} ${filterValue?.from} - ${filterValue?.to}`; + } else if ( + typeof filterValue === "object" && + filterValue.from !== null && + filterValue.to === null + ) { + tagText.innerText = `${showTagCategory === "true" ? `${tag}:` : ""} ${filterValue?.from}`; + } else if ( + typeof filterValue === "object" && + filterValue.from === null && + filterValue.to !== null + ) { + tagText.innerText = `${showTagCategory === "true" ? `${tag}:` : ""} ${filterValue?.to}`; + } else { + tagText.innerText = `${showTagCategory === "true" ? `${tag}:` : ""} ${filterValue}`; + } + this.tagTemplateContainer.append(newTag); + + // Bind the remove event listener + tagRemove.addEventListener("click", (event) => { + event.preventDefault(); + this.RemoveActiveTag(newTag, tag, filterValue); + }); + }); + } + }); + } + + RemoveActiveTag(_tag, filterTag, value) { + const categoryElements = this.filterForm.querySelectorAll( + `[wt-cmsfilter-category="${filterTag}"]`, + ); + const advancedFiltering = this.filterForm.getAttribute( + "wt-cmsfilter-filtering", + ); + categoryElements.forEach((categoryElement) => { + const input = + categoryElement.tagName === "INPUT" + ? categoryElement + : categoryElement.querySelector( + 'input[type="checkbox"], input[type="radio"], input[type="text"]', + ); - SortItems() { - if (!this.sortOptions) return; - - let [key, order] = this.sortOptions.value.split('-'); - this.filteredItems = this.filteredItems.filter(item => !item.hasAttribute('wt-renderstatic-element')); - this.filteredItems.sort((a, b) => { - let aValue = a.dataset[key]; - let bValue = b.dataset[key]; - - // Handle null or undefined values - if (aValue === undefined || aValue === null) aValue = ''; - if (bValue === undefined || bValue === null) bValue = ''; - - // Handle numeric values - if (!isNaN(aValue) && !isNaN(bValue)) { - aValue = parseFloat(aValue); - bValue = parseFloat(bValue); + if (input) { + if (input.type === "text") { + if (input.hasAttribute("wt-cmsfilter-default")) { + input.value = input.getAttribute("wt-cmsfilter-default"); + } else { + input.value = ""; + } + } else if (input.type === "checkbox") { + if (advancedFiltering === "advanced") { + input.checked = false; + } else { + if (categoryElement.innerText === value) { + input.checked = false; } - // Handle date values - else if (!isNaN(Date.parse(aValue)) && !isNaN(Date.parse(bValue))) { - aValue = new Date(aValue); - bValue = new Date(bValue); - } - // Handle text values - else { - aValue = aValue.toString().toLowerCase(); - bValue = bValue.toString().toLowerCase(); - } - - if (order === 'asc') { - return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; - } else { - return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; - } - }); - } - - 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, ' '); - } + } + } + } + }); - 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; - } - 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); - } - }); - } - }); - }); + this.activeFilters[filterTag] = this.activeFilters[filterTag].filter( + (filter) => filter !== value, + ); - this.activeFilters = filters; - this.SortItems(); - this.RenderItems(); - this.UpdateAvailableFilters(); - this.ShowResultCount(); - this.SetActiveTags(); - } + _tag.remove(); - ShowResultCount() { - if(!this.resultCount) return; - this.resultCount.innerText = this.GetResults(); - } + this.ApplyFilters(); + } - GetFilters() { - const filters = {}; - const rangeFilters = {}; - - this.filterElements.forEach(element => { - const category = element.getAttribute('wt-cmsfilter-category'); - - if (!filters[category]) { - filters[category] = []; - } - - const input = (element.tagName === "INPUT") ? element : element.querySelector('input[type="checkbox"], input[type="radio"], input[type="text"]'); - - if (input) { - if (input.type === 'text') { - const rangeType = element.getAttribute('wt-cmsfilter-range'); - if (rangeType === 'from' || rangeType === 'to') { - if (!rangeFilters[category]) { - rangeFilters[category] = { from: null, to: null }; - } - - const value = parseFloat(input.value.trim()); - if (Number.isFinite(value)) { - const datasetCategory = this.GetDataSet(category); - const ranges = this.dataRanges ? this.dataRanges[datasetCategory] : null; - // Determine default for comparison without mutating attributes here - let numericDefault = parseFloat(input.getAttribute('wt-cmsfilter-default')); - if (!Number.isFinite(numericDefault) && ranges) { - numericDefault = rangeType === 'from' ? ranges.min : ranges.max; - } - - if (Number.isFinite(numericDefault)) { - if (rangeType === 'from' && value !== numericDefault) { - rangeFilters[category].from = value; - } else if (rangeType === 'to' && value !== numericDefault) { - rangeFilters[category].to = value; - } - } - } else { - rangeFilters[category][rangeType] = null; - } - } else if (input.value.trim() !== '') { - filters[category].push(input.value.trim()); - } else { - filters[category] = []; - } - } else if (input.checked) { - filters[category].push(input.nextElementSibling.textContent.trim()); - if (this.activeFilterClass) { - element.classList.add(this.activeFilterClass); - } - } else { - if (this.activeFilterClass) { - element.classList.remove(this.activeFilterClass); - } - } - } - }); - - Object.keys(rangeFilters).forEach(category => { - const range = rangeFilters[category]; - if (range.from !== null && range.to !== null) { - filters[category].push({ from: range.from, to: range.to }); - } - else if (range.from !== null && range.to == null) { - filters[category].push({ from: range.from, to: null }); - } - else if (range.from == null && range.to !== null) { - filters[category].push({ from: null, to: range.to }); - } - }); - - return filters; - } - - GetDataSet(str) { - return str.replace(/(?:^\w|[A-Z]|\b\w)/g, function(word, index) { - return index === 0 ? word.toLowerCase() : word.toUpperCase(); - }).replace(/\s+/g, '').replace('-', ''); + NextPage() { + if (this.currentPage <= this.totalPages) { + this.currentPage = this.currentPage + 1; + this.RenderItems(); } + } - /** - * Captures original display styles for filter elements - * Called once during initialization to preserve original CSS - */ - captureOriginalDisplayStyles() { - this.filterElements.forEach(element => { - const istoggle = element.querySelector('input[type="checkbox"], input[type="radio"]'); - if (istoggle) { - // Get computed style to capture the actual display value (flex, block, etc.) - const computedStyle = window.getComputedStyle(element); - const originalDisplay = computedStyle.display; - this.originalDisplayStyles.set(element, originalDisplay); - } - }); + PrevPage() { + if (this.currentPage > 1) { + this.currentPage = this.currentPage - 1; + this.RenderItems(); } + } - UpdateAvailableFilters() { - if (this.filterForm.getAttribute('wt-cmsfilter-filtering') !== 'advanced') return; - this.availableFilters = {}; - - this.filterElements.forEach(element => { - const category = this.GetDataSet(element.getAttribute('wt-cmsfilter-category')); - - // Safari-compatible dataset access - const availableValues = new Set( - this.filteredItems - .map(item => (item.dataset && item.dataset[category]) ? item.dataset[category] : '') - .filter(value => value !== "") - ); - this.availableFilters[category] = availableValues; - - const istoggle = element.querySelector('input[type="checkbox"], input[type="radio"]'); - if (istoggle) { - // Safari-compatible text extraction and comparison - let elementText = ''; - if (element.textContent) { - elementText = element.textContent.trim(); - } else if (element.innerText) { - elementText = element.innerText.trim(); - } - - // Normalize whitespace for Safari compatibility - elementText = elementText.replace(/\s+/g, ' '); - - // Safari-compatible Set.has() check - let isAvailable = false; - availableValues.forEach(value => { - const normalizedValue = value.toString().replace(/\s+/g, ' ').trim(); - if (normalizedValue === elementText) { - isAvailable = true; - } - }); - - // Restore original display style or hide - if (isAvailable) { - // Restore original display style - const originalDisplay = this.originalDisplayStyles.get(element); - if (originalDisplay && originalDisplay !== 'none') { - element.style.display = originalDisplay; - } else { - // Fallback: remove display override to use CSS default - element.style.display = ''; - } - element.style.visibility = 'visible'; - } else { - element.style.display = 'none'; - element.style.visibility = 'hidden'; - } - } - }); - } + UpdatePaginationDisplay() { + if (!this.paginationWrapper) return; - ToggleEmptyState() { - if (this.emptyElement) { - if (this.filteredItems.length === 0) { - this.emptyElement.style.display = 'block'; - } else { - this.emptyElement.style.display = 'none'; - } - } + if (this.paginationcounter) { + this.paginationcounter.innerText = `${this.currentPage} / ${this.totalPages}`; } - - InitializeTagTemplate() { - if(!this.tagTemplate) return; - this.tagTemplateContainer.innerHTML = ""; + if (this.currentPage === 1) { + if (this.previousButton) this.previousButton.hidden = true; + if (this.customPrevButton) this.customPrevButton.hidden = true; + } else { + if (this.previousButton) this.previousButton.hidden = false; + if (this.customPrevButton) this.customPrevButton.hidden = false; } - - SetActiveTags() { - if(!this.tagTemplateContainer) return; - this.InitializeTagTemplate(); - - const filterTags = Object.keys(this.activeFilters); - filterTags.forEach(tag => { - if (this.activeFilters[tag].length !== 0) { - this.activeFilters[tag].forEach(filterValue => { - const newTag = this.tagTemplate.cloneNode(true); - const tagText = newTag.querySelector('[wt-cmsfilter-element="tag-text"]'); - const showTagCategory = newTag.getAttribute('wt-cmsfilter-tag-category') || 'true'; - const tagRemove = newTag.querySelector('[wt-cmsfilter-element="tag-remove"]'); - - if (typeof filterValue === 'object' && filterValue.from !== null && filterValue.to !== null) { - tagText.innerText = `${showTagCategory === 'true' ? `${tag}:` : ''} ${filterValue?.from} - ${filterValue?.to}`; - } - else if (typeof filterValue === 'object' && filterValue.from !== null && filterValue.to === null ) { - tagText.innerText = `${showTagCategory === 'true' ? `${tag}:` : ''} ${filterValue?.from}`; - } - else if (typeof filterValue === 'object' && filterValue.from === null && filterValue.to !== null ) { - tagText.innerText = `${showTagCategory === 'true' ? `${tag}:` : ''} ${filterValue?.to}`; - } - else{ - tagText.innerText = `${showTagCategory === 'true' ? `${tag}:` : ''} ${filterValue}`; - } - this.tagTemplateContainer.append(newTag); - - // Bind the remove event listener - tagRemove.addEventListener('click', (event) => { - event.preventDefault(); - this.RemoveActiveTag(newTag, tag, filterValue); - }); - }); - } - }); + if (this.currentPage === this.totalPages) { + if (this.nextButton) this.nextButton.hidden = true; + if (this.customNextButton) this.customNextButton.hidden = true; + } else { + if (this.nextButton) this.nextButton.hidden = false; + if (this.customNextButton) this.customNextButton.hidden = false; } - - RemoveActiveTag(_tag, filterTag, value) { - const categoryElements = this.filterForm.querySelectorAll(`[wt-cmsfilter-category="${filterTag}"]`); - const advancedFiltering = this.filterForm.getAttribute('wt-cmsfilter-filtering'); - categoryElements.forEach(categoryElement => { - const input = (categoryElement.tagName === "INPUT") - ? categoryElement - : categoryElement.querySelector('input[type="checkbox"], input[type="radio"], input[type="text"]'); - - if (input) { - if (input.type === 'text') { - if(input.hasAttribute('wt-cmsfilter-default')) { - input.value = input.getAttribute('wt-cmsfilter-default'); - } - else { - input.value = ''; - } - } else if (input.type === 'checkbox') { - if(advancedFiltering === 'advanced') { - input.checked = false; - } - else { - if(categoryElement.innerText === value) { - input.checked = false; - } - } - } - } - }); - - this.activeFilters[filterTag] = this.activeFilters[filterTag].filter(filter => filter !== value); - - _tag.remove(); - - this.ApplyFilters(); + } + + GetResults() { + if (this.activeFilters) { + let currActive = Object.values(this.activeFilters).filter( + (filter) => filter.length > 0, + ); + if (currActive.length > 0) { + return this.filteredItems.length; + } } - - NextPage() { - if (this.currentPage <= this.totalPages) { - this.currentPage = this.currentPage + 1; - this.RenderItems(); - } + if (this.allItems) { + //trim out static elements from RenderStatic + let elements = this.allItems.filter( + (item) => !item.hasAttribute("wt-renderstatic-element"), + ); + if (elements.length > 0) { + return elements.length; + } + return 0; } + return 0; + } + + ClearAllFilters() { + this.filterElements.forEach((element) => { + const input = + element.tagName === "INPUT" + ? element + : element.querySelector( + 'input[type="checkbox"], input[type="radio"], input[type="text"]', + ); - PrevPage() { - if (this.currentPage > 1) { - this.currentPage = this.currentPage - 1; - this.RenderItems(); + if (input) { + if (input.type === "text") { + if (input.hasAttribute("wt-cmsfilter-default")) { + input.value = input.getAttribute("wt-cmsfilter-default"); + } else { + input.value = ""; + } + } else if (input.type === "checkbox") { + input.checked = false; } - } + } - UpdatePaginationDisplay() { - if(!this.paginationWrapper) return; + if (this.activeFilterClass) { + element.classList.remove(this.activeFilterClass); + } + }); - if (this.paginationcounter) { - this.paginationcounter.innerText = `${this.currentPage} / ${this.totalPages}`; - } - if(this.currentPage === 1){ - if(this.previousButton) this.previousButton.hidden = true; - if(this.customPrevButton) this.customPrevButton.hidden = true; - } else { - if(this.previousButton) this.previousButton.hidden = false; - if(this.customPrevButton) this.customPrevButton.hidden = false; - } - if(this.currentPage === this.totalPages){ - if(this.nextButton) this.nextButton.hidden = true; - if(this.customNextButton) this.customNextButton.hidden = true; - } else { - if(this.nextButton) this.nextButton.hidden = false; - if(this.customNextButton) this.customNextButton.hidden = false; - } - } + this.activeFilters = {}; - GetResults() { - if(this.activeFilters){ - let currActive = Object.values(this.activeFilters).filter(filter => filter.length > 0); - if(currActive.length > 0){ - return this.filteredItems.length; - } - } - if(this.allItems){ - //trim out static elements from RenderStatic - let elements = this.allItems.filter(item => !item.hasAttribute('wt-renderstatic-element')); - if(elements.length > 0) { - return elements.length; - } - return 0; - } - return 0; + if (this.tagTemplateContainer) { + this.tagTemplateContainer.innerHTML = ""; } - ClearAllFilters() { - this.filterElements.forEach(element => { - const input = (element.tagName === "INPUT") - ? element - : element.querySelector('input[type="checkbox"], input[type="radio"], input[type="text"]'); - - if (input) { - if (input.type === 'text') { - if(input.hasAttribute('wt-cmsfilter-default')) { - input.value = input.getAttribute('wt-cmsfilter-default'); - } - else { - input.value = ''; - } - } else if (input.type === 'checkbox') { - input.checked = false; - } - } - - if (this.activeFilterClass) { - element.classList.remove(this.activeFilterClass); - } - }); - - this.activeFilters = {}; - - if (this.tagTemplateContainer) { - this.tagTemplateContainer.innerHTML = ""; - } - - this.ApplyFilters(); - } + this.ApplyFilters(); + } - ResetInteraction(element) { - if (!element) { - console.error('Element not found'); - return; - } - - const WebflowIX2 = window.Webflow && Webflow.require('ix2'); - if (!WebflowIX2) { - console.error('Webflow IX2 engine not found.'); - return; - } + ResetInteraction(element) { + if (!element) { + console.error("Element not found"); + return; + } - const targetElement = element.hasAttribute('data-w-id') - ? element - : element.querySelector('[data-w-id]'); - - if (!targetElement) { - console.warn('No IX2 interaction found on the element or its children.'); - return; - } + const WebflowIX2 = window.Webflow && Webflow.require("ix2"); + if (!WebflowIX2) { + console.error("Webflow IX2 engine not found."); + return; + } - const dataWId = targetElement.getAttribute('data-w-id'); - if (dataWId) { - targetElement.removeAttribute('data-w-id'); - targetElement.setAttribute('data-w-id', dataWId); + const targetElement = element.hasAttribute("data-w-id") + ? element + : element.querySelector("[data-w-id]"); - WebflowIX2.init(); - } else { - console.warn('No valid data-w-id attribute found.'); - } + if (!targetElement) { + console.warn("No IX2 interaction found on the element or its children."); + return; } - GetFilterData() { - let filterData = { - 'filters': this.filterElements, - 'active': this.activeFilters, - 'available': this.availableFilters, - 'results': this.GetResults(), - 'per-page-items': this.itemsPerPage, - 'total-pages': this.totalPages, - 'current-page': this.currentPage, - 'all-items': this.allItems, - 'filtered-items': this.filteredItems, - 'load-mode': this.loadMode, - 'range-sliders': this.dataRanges - } - return filterData; - } + const dataWId = targetElement.getAttribute("data-w-id"); + if (dataWId) { + targetElement.removeAttribute("data-w-id"); + targetElement.setAttribute("data-w-id", dataWId); - // Utility method for debouncing function calls - debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func.apply(this, args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; + WebflowIX2.init(); + } else { + console.warn("No valid data-w-id attribute found."); } + } + + GetFilterData() { + let filterData = { + filters: this.filterElements, + active: this.activeFilters, + available: this.availableFilters, + results: this.GetResults(), + "per-page-items": this.itemsPerPage, + "total-pages": this.totalPages, + "current-page": this.currentPage, + "all-items": this.allItems, + "filtered-items": this.filteredItems, + "load-mode": this.loadMode, + "range-sliders": this.dataRanges, + }; + return filterData; + } + + // Utility method for debouncing function calls + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func.apply(this, args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } } const InitializeCMSFilter = () => { - window.webtricks = window.webtricks || []; - let instance = new CMSFilter(); - window.webtricks.push({'CMSFilter': instance}); -} + window.webtricks = window.webtricks || []; + let instance = new CMSFilter(); + window.webtricks.push({ CMSFilter: instance }); +}; if (/complete|interactive|loaded/.test(document.readyState)) { - InitializeCMSFilter(); -} else { - window.addEventListener('DOMContentLoaded', InitializeCMSFilter) + InitializeCMSFilter(); +} else { + window.addEventListener("DOMContentLoaded", InitializeCMSFilter); } // Allow requiring this module in test environments without affecting browser usage try { - if (typeof module !== 'undefined' && module.exports) { - module.exports = { CMSFilter, InitializeCMSFilter }; - } -} catch {} \ No newline at end of file + if (typeof module !== "undefined" && module.exports) { + module.exports = { CMSFilter, InitializeCMSFilter }; + } +} catch {} diff --git a/__tests__/CMSFilter.test.js b/__tests__/CMSFilter.test.js index 1d617d4..52ac3c0 100644 --- a/__tests__/CMSFilter.test.js +++ b/__tests__/CMSFilter.test.js @@ -1,30 +1,45 @@ /** @jest-environment jsdom */ // Prevent auto init before we control DOM -Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); +Object.defineProperty(document, "readyState", { + value: "loading", + configurable: true, +}); // requestAnimationFrame polyfill for consistency if (!global.requestAnimationFrame) global.requestAnimationFrame = (cb) => cb(); -describe('CMSFilter', () => { +describe("CMSFilter", () => { let CMSFilter, InitializeCMSFilter; beforeEach(() => { - document.body.innerHTML = ''; + document.body.innerHTML = ""; window.webtricks = []; jest.resetModules(); - ({ CMSFilter, InitializeCMSFilter } = require('../Dist/WebflowOnly/CMSFilter.js')); + ({ + CMSFilter, + InitializeCMSFilter, + } = require("../Dist/WebflowOnly/CMSFilter.js")); }); - function buildBasicDOM({ withPagination=false, loadMode='load-all', advanced=false }={}) { - const paginationMarkup = withPagination ? ` + function buildBasicDOM({ + withPagination = false, + loadMode = "load-all", + advanced = false, + emptyMax, + } = {}) { + const paginationMarkup = withPagination + ? `
Page1
1 / 1
Prev Next -
` : ''; - const advancedAttr = advanced ? 'wt-cmsfilter-filtering="advanced" wt-cmsfilter-class="is-active"' : ''; + ` + : ""; + const advancedAttr = advanced + ? 'wt-cmsfilter-filtering="advanced" wt-cmsfilter-class="is-active"' + : ""; document.body.innerHTML = `
@@ -53,73 +68,90 @@ describe('CMSFilter', () => {
Beta Item
Gamma Item
-
No results
+
No results
${paginationMarkup} `; } - test('initializes and caches items, pushes instance', () => { + test("initializes and caches items, pushes instance", () => { buildBasicDOM(); InitializeCMSFilter(); - const instance = window.webtricks.find(e => e.CMSFilter).CMSFilter; + const instance = window.webtricks.find((e) => e.CMSFilter).CMSFilter; // resultCount may be updated after init sequence; ensure fallback to computing directly - const countText = instance.resultCount.textContent || String(instance.filteredItems.length); + const countText = + instance.resultCount.textContent || String(instance.filteredItems.length); expect(instance.allItems.length).toBe(3); expect(instance.filteredItems.length).toBe(3); - expect(countText).toBe('3'); + expect(countText).toBe("3"); }); - test('category checkbox filter reduces items and shows result count', () => { + test("category checkbox filter reduces items and shows result count", () => { buildBasicDOM(); InitializeCMSFilter(); const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); // Check Beta only - const betaLabel = Array.from(form.querySelectorAll('label')).find(l => l.textContent.includes('Beta')); - betaLabel.querySelector('input').checked = true; - betaLabel.querySelector('input').dispatchEvent(new Event('change', { bubbles: true })); + const betaLabel = Array.from(form.querySelectorAll("label")).find((l) => + l.textContent.includes("Beta"), + ); + betaLabel.querySelector("input").checked = true; + betaLabel + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); const instance = window.webtricks[0].CMSFilter; // Manually apply filters to bypass debounce timing instance.ApplyFilters(); expect(instance.filteredItems.length).toBe(1); - expect(instance.filteredItems[0].dataset.title).toBe('beta'); - expect(instance.resultCount.textContent).toBe('1'); + expect(instance.filteredItems[0].dataset.title).toBe("beta"); + expect(instance.resultCount.textContent).toBe("1"); }); - test('global search via * category filters list items', () => { + test("global search via * category filters list items", () => { buildBasicDOM(); InitializeCMSFilter(); - const searchInput = document.querySelector('[wt-cmsfilter-category="*"] input'); - searchInput.value = 'gamma'; - searchInput.dispatchEvent(new Event('input', { bubbles: true })); + const searchInput = document.querySelector( + '[wt-cmsfilter-category="*"] input', + ); + searchInput.value = "gamma"; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); const instance = window.webtricks[0].CMSFilter; instance.ApplyFilters(); expect(instance.filteredItems.length).toBe(1); - expect(instance.filteredItems[0].textContent.toLowerCase()).toContain('gamma'); + expect(instance.filteredItems[0].textContent.toLowerCase()).toContain( + "gamma", + ); }); - test('range filtering narrows items between from/to values', () => { + test("range filtering narrows items between from/to values", () => { buildBasicDOM(); InitializeCMSFilter(); - const priceFrom = document.querySelector('[wt-cmsfilter-category="Price"][wt-cmsfilter-range="from"] input'); - const priceTo = document.querySelector('[wt-cmsfilter-category="Price"][wt-cmsfilter-range="to"] input'); + const priceFrom = document.querySelector( + '[wt-cmsfilter-category="Price"][wt-cmsfilter-range="from"] input', + ); + const priceTo = document.querySelector( + '[wt-cmsfilter-category="Price"][wt-cmsfilter-range="to"] input', + ); // After init these should have defaults set (min=10 max=50). Narrow to 20 - 30 - priceFrom.value = '20'; - priceTo.value = '30'; - priceFrom.dispatchEvent(new Event('input', { bubbles: true })); - priceTo.dispatchEvent(new Event('input', { bubbles: true })); + priceFrom.value = "20"; + priceTo.value = "30"; + priceFrom.dispatchEvent(new Event("input", { bubbles: true })); + priceTo.dispatchEvent(new Event("input", { bubbles: true })); const instance = window.webtricks[0].CMSFilter; instance.ApplyFilters(); expect(instance.filteredItems.length).toBe(1); - expect(instance.filteredItems[0].dataset.price).toBe('25'); + expect(instance.filteredItems[0].dataset.price).toBe("25"); }); - test('clear all resets filters and shows all items again', () => { + test("clear all resets filters and shows all items again", () => { buildBasicDOM(); InitializeCMSFilter(); const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); - const alpha = Array.from(form.querySelectorAll('label')).find(l => l.textContent.includes('Alpha')); - alpha.querySelector('input').checked = true; - alpha.querySelector('input').dispatchEvent(new Event('change', { bubbles: true })); + const alpha = Array.from(form.querySelectorAll("label")).find((l) => + l.textContent.includes("Alpha"), + ); + alpha.querySelector("input").checked = true; + alpha + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); const instance = window.webtricks[0].CMSFilter; instance.ApplyFilters(); expect(instance.filteredItems.length).toBe(1); @@ -127,51 +159,122 @@ describe('CMSFilter', () => { expect(instance.filteredItems.length).toBe(3); }); - test('sort options reorder items (title-desc)', () => { + test("sort options reorder items (title-desc)", () => { buildBasicDOM(); InitializeCMSFilter(); - const select = document.querySelector('[wt-cmsfilter-element="sort-options"]'); - select.value = 'title-desc'; - select.dispatchEvent(new Event('change', { bubbles: true })); + const select = document.querySelector( + '[wt-cmsfilter-element="sort-options"]', + ); + select.value = "title-desc"; + select.dispatchEvent(new Event("change", { bubbles: true })); const instance = window.webtricks[0].CMSFilter; - const ordered = instance.filteredItems.map(i => i.dataset.title); - expect(ordered).toEqual(['gamma','beta','alpha']); + const ordered = instance.filteredItems.map((i) => i.dataset.title); + expect(ordered).toEqual(["gamma", "beta", "alpha"]); }); - test('advanced filtering hides unavailable checkboxes then restores after clearing', () => { - buildBasicDOM({ advanced:true }); + test("advanced filtering hides unavailable checkboxes then restores after clearing", () => { + buildBasicDOM({ advanced: true }); InitializeCMSFilter(); const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); // Apply search that matches only Gamma const searchInput = form.querySelector('[wt-cmsfilter-category="*"] input'); - searchInput.value = 'gamma'; - searchInput.dispatchEvent(new Event('input', { bubbles:true })); + searchInput.value = "gamma"; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); const instance = window.webtricks[0].CMSFilter; instance.ApplyFilters(); // Only Gamma toggle should be visible - const labels = Array.from(form.querySelectorAll('label[wt-cmsfilter-category="Category"]')); - const visible = labels.filter(l => l.style.display !== 'none').map(l => l.textContent.trim()); - expect(visible).toEqual(expect.arrayContaining(['Gamma'])); + const labels = Array.from( + form.querySelectorAll('label[wt-cmsfilter-category="Category"]'), + ); + const visible = labels + .filter((l) => l.style.display !== "none") + .map((l) => l.textContent.trim()); + expect(visible).toEqual(expect.arrayContaining(["Gamma"])); expect(visible).toHaveLength(1); // Clear all restores document.querySelector('[wt-cmsfilter-element="clear-all"]').click(); - const restoredVisible = labels.filter(l => l.style.display !== 'none'); + const restoredVisible = labels.filter((l) => l.style.display !== "none"); expect(restoredVisible.length).toBe(3); }); - test('tag template displays active filters and can remove a tag', () => { - buildBasicDOM({ advanced:true }); + test("tag template displays active filters and can remove a tag", () => { + buildBasicDOM({ advanced: true }); InitializeCMSFilter(); const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); - const beta = Array.from(form.querySelectorAll('label')).find(l => l.textContent.includes('Beta')); - beta.querySelector('input').checked = true; - beta.querySelector('input').dispatchEvent(new Event('change', { bubbles:true })); + const beta = Array.from(form.querySelectorAll("label")).find((l) => + l.textContent.includes("Beta"), + ); + beta.querySelector("input").checked = true; + beta + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); const instance = window.webtricks[0].CMSFilter; instance.ApplyFilters(); const tagsContainer = instance.tagTemplateContainer; expect(tagsContainer.children.length).toBeGreaterThan(0); - const remove = tagsContainer.querySelector('[wt-cmsfilter-element="tag-remove"]'); + const remove = tagsContainer.querySelector( + '[wt-cmsfilter-element="tag-remove"]', + ); remove.click(); expect(instance.filteredItems.length).toBe(3); // back to all }); + + test("empty element shows when filtered result count is less than or equal to wt-cmsfilter-empty-max", () => { + buildBasicDOM({ emptyMax: 1 }); + InitializeCMSFilter(); + const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); + const betaLabel = Array.from(form.querySelectorAll("label")).find((l) => + l.textContent.includes("Beta"), + ); + betaLabel.querySelector("input").checked = true; + betaLabel + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); + + const instance = window.webtricks[0].CMSFilter; + instance.ApplyFilters(); + + expect(instance.filteredItems.length).toBe(1); + expect(instance.emptyElement.style.display).toBe("block"); + }); + + test('wt-cmsfilter-element="empty" works by itself', () => { + buildBasicDOM(); + InitializeCMSFilter(); + const instance = window.webtricks[0].CMSFilter; + + expect(instance.emptyElement).toBeTruthy(); + expect(instance.emptyElement.style.display).toBe("none"); + + const searchInput = document.querySelector( + '[wt-cmsfilter-category="*"] input', + ); + searchInput.value = "no-match-value"; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + instance.ApplyFilters(); + + expect(instance.filteredItems.length).toBe(0); + expect(instance.emptyElement.style.display).toBe("block"); + }); + + test("missing wt-cmsfilter-empty-max defaults to 0", () => { + buildBasicDOM(); + InitializeCMSFilter(); + const instance = window.webtricks[0].CMSFilter; + + expect(instance.emptyMaxCount).toBe(0); + + const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); + const betaLabel = Array.from(form.querySelectorAll("label")).find((l) => + l.textContent.includes("Beta"), + ); + betaLabel.querySelector("input").checked = true; + betaLabel + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); + instance.ApplyFilters(); + + expect(instance.filteredItems.length).toBe(1); + expect(instance.emptyElement.style.display).toBe("none"); + }); }); diff --git a/docs/WebflowOnly/CMSFilter.md b/docs/WebflowOnly/CMSFilter.md index e8d0f92..4fc8b96 100644 --- a/docs/WebflowOnly/CMSFilter.md +++ b/docs/WebflowOnly/CMSFilter.md @@ -54,6 +54,7 @@ Add the script to your Webflow project and include the required attributes on yo #### Additional Elements - `wt-cmsfilter-element="results-count"` - Shows the number of filtered results - `wt-cmsfilter-element="empty"` - Element shown when no results are found +- `wt-cmsfilter-empty-max="n"` - Optional on the empty element; shows empty block when filtered results are ≤ n (default: 0, so only when zero results; e.g. `wt-cmsfilter-empty-max="3"` shows at 3 or fewer) - `wt-cmsfilter-element="clear-all"` - Button to clear all active filters - `wt-cmsfilter-element="sort-options"` - Select element for sorting options - `wt-cmsfilter-element="tag-template"` - Template for active filter tags @@ -111,6 +112,11 @@ Add the script to your Webflow project and include the required attributes on yo
No results found.
+ + +
+ Only a few results found. +
``` From f95a2158eacac63c08b44bdd3cef27cae2f4b7c9 Mon Sep 17 00:00:00 2001 From: Matthew Simpson Date: Wed, 25 Feb 2026 16:36:49 -0800 Subject: [PATCH 2/2] address PR feedback --- Dist/WebflowOnly/CMSFilter.js | 11 +++--- __tests__/CMSFilter.test.js | 63 +++++++++++++++++++++++++++++++++++ docs/WebflowOnly/CMSFilter.md | 2 +- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/Dist/WebflowOnly/CMSFilter.js b/Dist/WebflowOnly/CMSFilter.js index 66bae7c..03f0f1f 100644 --- a/Dist/WebflowOnly/CMSFilter.js +++ b/Dist/WebflowOnly/CMSFilter.js @@ -63,12 +63,11 @@ class CMSFilter { ); this.emptyMaxCount = 0; if (this.emptyElement) { - const emptyMaxValue = parseInt( - this.emptyElement.getAttribute("wt-cmsfilter-empty-max"), - 10, + const emptyMaxAttr = this.emptyElement.getAttribute( + "wt-cmsfilter-empty-max", ); - if (Number.isInteger(emptyMaxValue) && emptyMaxValue >= 0) { - this.emptyMaxCount = emptyMaxValue; + if (emptyMaxAttr !== null && /^[1-9]\d*$/.test(emptyMaxAttr)) { + this.emptyMaxCount = Number(emptyMaxAttr); } } this.resetIx2 = @@ -446,7 +445,7 @@ class CMSFilter { } } } else { - console.error("Failed to fetch HTML from the URL:", link.href); + console.error("Failed to fetch HTML from the URL:", link); } } catch (error) { console.error("Error fetching HTML:", error); diff --git a/__tests__/CMSFilter.test.js b/__tests__/CMSFilter.test.js index 52ac3c0..516ab00 100644 --- a/__tests__/CMSFilter.test.js +++ b/__tests__/CMSFilter.test.js @@ -277,4 +277,67 @@ describe("CMSFilter", () => { expect(instance.filteredItems.length).toBe(1); expect(instance.emptyElement.style.display).toBe("none"); }); + + test("invalid wt-cmsfilter-empty-max defaults to 0", () => { + buildBasicDOM({ emptyMax: "3px" }); + InitializeCMSFilter(); + const instance = window.webtricks[0].CMSFilter; + + expect(instance.emptyMaxCount).toBe(0); + + const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); + const betaLabel = Array.from(form.querySelectorAll("label")).find((l) => + l.textContent.includes("Beta"), + ); + betaLabel.querySelector("input").checked = true; + betaLabel + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); + instance.ApplyFilters(); + + expect(instance.filteredItems.length).toBe(1); + expect(instance.emptyElement.style.display).toBe("none"); + }); + + test("negative wt-cmsfilter-empty-max defaults to 0", () => { + buildBasicDOM({ emptyMax: "-1" }); + InitializeCMSFilter(); + const instance = window.webtricks[0].CMSFilter; + + expect(instance.emptyMaxCount).toBe(0); + + const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); + const betaLabel = Array.from(form.querySelectorAll("label")).find((l) => + l.textContent.includes("Beta"), + ); + betaLabel.querySelector("input").checked = true; + betaLabel + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); + instance.ApplyFilters(); + + expect(instance.filteredItems.length).toBe(1); + expect(instance.emptyElement.style.display).toBe("none"); + }); + + test("non-numeric wt-cmsfilter-empty-max defaults to 0", () => { + buildBasicDOM({ emptyMax: "abc" }); + InitializeCMSFilter(); + const instance = window.webtricks[0].CMSFilter; + + expect(instance.emptyMaxCount).toBe(0); + + const form = document.querySelector('[wt-cmsfilter-element="filter-form"]'); + const betaLabel = Array.from(form.querySelectorAll("label")).find((l) => + l.textContent.includes("Beta"), + ); + betaLabel.querySelector("input").checked = true; + betaLabel + .querySelector("input") + .dispatchEvent(new Event("change", { bubbles: true })); + instance.ApplyFilters(); + + expect(instance.filteredItems.length).toBe(1); + expect(instance.emptyElement.style.display).toBe("none"); + }); }); diff --git a/docs/WebflowOnly/CMSFilter.md b/docs/WebflowOnly/CMSFilter.md index 4fc8b96..071a04a 100644 --- a/docs/WebflowOnly/CMSFilter.md +++ b/docs/WebflowOnly/CMSFilter.md @@ -54,7 +54,7 @@ Add the script to your Webflow project and include the required attributes on yo #### Additional Elements - `wt-cmsfilter-element="results-count"` - Shows the number of filtered results - `wt-cmsfilter-element="empty"` - Element shown when no results are found -- `wt-cmsfilter-empty-max="n"` - Optional on the empty element; shows empty block when filtered results are ≤ n (default: 0, so only when zero results; e.g. `wt-cmsfilter-empty-max="3"` shows at 3 or fewer) +- `wt-cmsfilter-empty-max="n"` - Optional on the empty element; accepts positive whole integers only (`1+`) and shows empty block when filtered results are ≤ n. Missing, `0`, or invalid values default to `0` (show only when there are zero results; e.g. `wt-cmsfilter-empty-max="3"` shows at 3 or fewer). Valid: `1`, `2`, `3`. Invalid: `0`, `3.5`, `3px`, `-1`, `abc`. - `wt-cmsfilter-element="clear-all"` - Button to clear all active filters - `wt-cmsfilter-element="sort-options"` - Select element for sorting options - `wt-cmsfilter-element="tag-template"` - Template for active filter tags