diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..006bbcc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,65 @@ +name: tests + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + +jobs: + test: + name: tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies (lockfile) + if: ${{ hashFiles('package-lock.json') != '' }} + run: npm ci + + - name: Install dependencies + if: ${{ hashFiles('package-lock.json') == '' }} + run: npm install + + - name: Run tests with coverage + run: npx jest --runInBand --coverage --coverageReporters=text-summary --coverageReporters=lcov --coverageReporters=json-summary + + - name: Upload coverage artifact + if: always() && hashFiles('coverage/coverage-summary.json') != '' + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage/** + + - name: Add coverage summary to job summary + if: always() && hashFiles('coverage/coverage-summary.json') != '' + shell: bash + run: | + node <<'NODE' + const fs = require('fs'); + const p = 'coverage/coverage-summary.json'; + const out = process.env.GITHUB_STEP_SUMMARY; + if (!out) { + console.log('GITHUB_STEP_SUMMARY not set; skipping'); + process.exit(0); + } + const s = JSON.parse(fs.readFileSync(p, 'utf8')); + const c = s.total; + const lines = [ + '## Coverage summary', + '', + `- Statements: ${c.statements.pct}% (${c.statements.covered}/${c.statements.total})`, + `- Branches: ${c.branches.pct}% (${c.branches.covered}/${c.branches.total})`, + `- Functions: ${c.functions.pct}% (${c.functions.covered}/${c.functions.total})`, + `- Lines: ${c.lines.pct}% (${c.lines.covered}/${c.lines.total})`, + '', + ]; + fs.appendFileSync(out, lines.join('\n')); + NODE diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml new file mode 100644 index 0000000..b945a59 --- /dev/null +++ b/.github/workflows/update-contributors.yml @@ -0,0 +1,31 @@ +name: Update contributors in README + +on: + push: + branches: [ main ] + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' # Weekly, Mondays at 06:00 UTC + +jobs: + update-contributors: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Update contributors section in README + uses: akhilmhdh/contributors-readme-action@v2.3.11 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + image_size: 80 + use_username: true + pr_merge: true + columns_per_row: 8 + commit_message: 'docs(readme): update contributors [skip ci]' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dac9c29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.vscode/ +coverage/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c08c1df..bcd8d4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,17 @@ Testing - Test your changes thoroughly before submitting. - Add examples in the examples/ folder for any new functionality. --- + +## Contributors +This project is made possible by: + +The contributor list (avatars + profile links) in `README.md` is updated automatically after merges to `main` via a GitHub Action. To appear there: +1. Make at least one merged commit to the `main` branch (directly or via PR merge). +2. Ensure your GitHub profile has a public avatar (default works fine). + +Inactive or bot accounts may be filtered out automatically. + + ## Join the Discussion Participate in ongoing discussions in [GitHub Discussions](https://github.com/TheCodeRaccoons/WebTricks/discussions). Share your feedback, ideas, and improvements. diff --git a/Dist/Functional/CookieConsent.js b/Dist/Functional/CookieConsent.js index e72fcbe..97f08eb 100644 --- a/Dist/Functional/CookieConsent.js +++ b/Dist/Functional/CookieConsent.js @@ -173,3 +173,10 @@ if (/complete|interactive|loaded/.test(document.readyState)) { } else { window.addEventListener('DOMContentLoaded', InitializeCookieConsent); } + +// Allow requiring this module in test environments +try { + if (typeof module !== 'undefined' && module.exports) { + module.exports = { CookieConsent, InitializeCookieConsent }; + } +} catch {} diff --git a/Dist/Functional/CopyToClipboard.js b/Dist/Functional/CopyToClipboard.js index d6b14f0..957aab6 100644 --- a/Dist/Functional/CopyToClipboard.js +++ b/Dist/Functional/CopyToClipboard.js @@ -5,7 +5,7 @@ class CopyToClipboard { this.ctcTarget = this.ctcContainer.querySelector(`[wt-copycb-element="target"]`) || null; this.textTarget = this.ctcTrigger.querySelector(`[wt-copycb-element="texttarget"]`) || null; this.ctcDefaultTxt = this.ctcTrigger.innerText; - this.textToCopy = this.ctcTarget.innerText; + this.textToCopy = this.ctcTarget ? this.ctcTarget.innerText : ''; this.copiedTxt = this.ctcTrigger.getAttribute("wt-copycb-message") || null; this.activeClass = this.ctcTrigger.getAttribute('wt-copycb-active') || 'is-copy'; this.timeOut = this.ctcTrigger.getAttribute('wt-copycb-timeout') || 2000; @@ -54,3 +54,10 @@ if (/complete|interactive|loaded/.test(document.readyState)) { } else { window.addEventListener('DOMContentLoaded', initializeCopyToClipboard); } + +// Allow requiring this module in test environments without affecting browser usage +try { + if (typeof module !== 'undefined' && module.exports) { + module.exports = { CopyToClipboard, InitializeCopyToClipboard: initializeCopyToClipboard }; + } +} catch {} diff --git a/Dist/Functional/CountUp.js b/Dist/Functional/CountUp.js index 681483e..9cded64 100644 --- a/Dist/Functional/CountUp.js +++ b/Dist/Functional/CountUp.js @@ -45,3 +45,10 @@ if (/complete|interactive|loaded/.test(document.readyState)) { } else { window.addEventListener('DOMContentLoaded', InitializeCountUp); } + +// Allow requiring this module in test environments without affecting browser usage +try { + if (typeof module !== 'undefined' && module.exports) { + module.exports = { CountUp, InitializeCountUp }; + } +} catch {} diff --git a/Dist/Functional/FormCheck.js b/Dist/Functional/FormCheck.js index b1f6534..fb68974 100644 --- a/Dist/Functional/FormCheck.js +++ b/Dist/Functional/FormCheck.js @@ -126,3 +126,10 @@ if (/complete|interactive|loaded/.test(document.readyState)) { } else { window.addEventListener('DOMContentLoaded', initializeFormCheck) } + +// Allow requiring this module in test environments without affecting browser usage +try { + if (typeof module !== 'undefined' && module.exports) { + module.exports = { FormCheck, InitializeFormCheck: initializeFormCheck }; + } +} catch {} diff --git a/Dist/Functional/FormatNumbers.js b/Dist/Functional/FormatNumbers.js index fbd1e28..2b18d60 100644 --- a/Dist/Functional/FormatNumbers.js +++ b/Dist/Functional/FormatNumbers.js @@ -85,3 +85,10 @@ if (/complete|interactive|loaded/.test(document.readyState)) { } else { window.addEventListener('DOMContentLoaded', InitializeFormatNumbers); } + +// Allow requiring this module in test environments without affecting browser usage +try { + if (typeof module !== 'undefined' && module.exports) { + module.exports = { NumberFormatter, InitializeFormatNumbers }; + } +} catch {} diff --git a/Dist/Functional/Marquee.js b/Dist/Functional/Marquee.js index 02a138a..48cdefd 100644 --- a/Dist/Functional/Marquee.js +++ b/Dist/Functional/Marquee.js @@ -36,15 +36,21 @@ class Marquee { fillContainer() { let totalSize = this.calculateTotalSize(); - const targetSize = this.parentSize * 1.5; + const targetSize = this.parentSize * 1.5; + // Safety guards: avoid infinite loop if sizes cannot be measured (e.g., 0 widths in tests/SSR) + let iterations = 0; + const maxIterations = 200; // cap to a reasonable number - while (totalSize < targetSize) { + while (totalSize < targetSize && iterations < maxIterations) { + const beforeSize = totalSize; this.elements.forEach(el => { const clone = el.cloneNode(true); this.container.appendChild(clone); }); this.elements = Array.from(this.container.children); totalSize = this.calculateTotalSize(); + iterations++; + if (totalSize <= beforeSize) break; // cannot grow, abort to prevent infinite loop } } @@ -135,3 +141,12 @@ if (/complete|interactive|loaded/.test(document.readyState)) { } else { window.addEventListener('DOMContentLoaded', InitializeMarquee); }; + +// Allow requiring this module in test environments without affecting browser usage +try { + if (typeof module !== 'undefined' && module.exports) { + module.exports = { Marquee, InitializeMarquee }; + } +} catch { + // Suppress errors when module/module.exports are undefined (e.g., in browser environments). +} diff --git a/Dist/Functional/RangeSlider.js b/Dist/Functional/RangeSlider.js index 6d8dd77..5639bb3 100644 --- a/Dist/Functional/RangeSlider.js +++ b/Dist/Functional/RangeSlider.js @@ -5,150 +5,183 @@ * (c) 2023 Jorge Cortez * MIT License * https://github.com/JorchCortez/Weblfow-Trickery + * + * Self-contained: no separate shared script required (backward compatible with single-tag embeds). */ +'use strict'; + +/** @private Core helpers (IIFE keeps globals clean if RangeSliderSimple.js is also on the page) */ +var __WT_RANGE_SLIDER_CORE = (function () { + function validateNumber(value) { + const num = parseFloat(value); + if (isNaN(num)) { + throw new Error(`Invalid number value: ${value}`); + } + return num; + } + + function formatNumber(number) { + return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + } + + function readSliderConfig(sliderEl, prefix) { + const minAttr = sliderEl.getAttribute(`${prefix}-min`); + const sliderMin = minAttr !== null ? validateNumber(minAttr) : 0; + + const maxAttr = sliderEl.getAttribute(`${prefix}-max`); + const sliderMax = maxAttr !== null ? validateNumber(maxAttr) : 100; + + const stepsAttr = sliderEl.getAttribute(`${prefix}-steps`); + const sliderSteps = stepsAttr !== null ? validateNumber(stepsAttr) : 1; + + const minDiffAttr = sliderEl.getAttribute(`${prefix}-mindifference`); + const minDifference = minDiffAttr !== null ? validateNumber(minDiffAttr) : sliderSteps; + + return { + sliderMin, + sliderMax, + sliderSteps, + minDifference, + rightSuffix: sliderEl.getAttribute(`${prefix}-rightsuffix`) || null, + defaultSuffix: sliderEl.getAttribute(`${prefix}-defaultsuffix`) || null, + shouldFormatNumber: sliderEl.getAttribute(`${prefix}-formatnumber`) || null, + }; + } + + function constrainLeftValue(rawValue, rightValueStr, minDifference) { + return Math.min( + Number(rawValue), + Number(rightValueStr) - minDifference, + ); + } + + function constrainRightValue(rawValue, leftValueStr, minDifference) { + return Math.max( + Number(rawValue), + Number(leftValueStr) + minDifference, + ); + } + + function setNativeTextInputValue(input, constrainedValue, suspendBegin, suspendEnd) { + if (!input) return; + const valueProp = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + suspendBegin(); + valueProp.set.call(input, constrainedValue); + suspendEnd(); + } + + function formatStartDisplayContent(constrainedValue, shouldFormatNumberAttr) { + const displayLeft = + shouldFormatNumberAttr === 'true' + ? formatNumber(constrainedValue) + : constrainedValue; + return String(displayLeft); + } + + function formatEndDisplayContent( + constrainedValue, + rawInputValue, + sliderMax, + shouldFormatNumberAttr, + rightSuffix, + defaultSuffix, + ) { + let finalDisplay = + shouldFormatNumberAttr === 'true' + ? formatNumber(constrainedValue) + : constrainedValue; + const rawNum = parseFloat(rawInputValue); + if (rightSuffix && !isNaN(rawNum) && rawNum >= sliderMax) { + return `${finalDisplay}${rightSuffix}`; + } + if (defaultSuffix) { + return `${finalDisplay}${defaultSuffix}`; + } + return String(finalDisplay); + } + + function hookInputValueSync(input, handler, isSuspended) { + if (!input) return; + const valueProp = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + try { + Object.defineProperty(input, 'value', { + get() { + return valueProp.get.call(this); + }, + set(v) { + valueProp.set.call(this, v); + if (!isSuspended()) { + handler(v); + } + }, + configurable: true, + enumerable: true, + }); + } catch (err) { + // Fallback: rely on 'input'/'change' listeners if defineProperty fails + } + } + + function dispatchInputEvent(element) { + if (element) { + element.dispatchEvent(new Event('input', { bubbles: true })); + } + } + + return { + readSliderConfig, + constrainLeftValue, + constrainRightValue, + setNativeTextInputValue, + formatStartDisplayContent, + formatEndDisplayContent, + hookInputValueSync, + dispatchInputEvent, + }; +}()); + /** * @file RangeSlider.js * @description A customizable dual-handle range slider for selecting value ranges. - * - * Key Features: - * - Dual handles for selecting a range of values - * - Real-time visual updates - * - Form integration with input fields - * - Display elements for current values - * - Customizable thumbs with SVG/image support - * - Mutation observer support for programmatic value changes - * - Configurable min/max values and step size - * - * Required Attributes: - * - wt-rangeslider-element="slider-wrapper": Container element - * - wt-rangeslider-element="slider": Main slider element - * - wt-rangeslider-element="input-left": Left range input - * - wt-rangeslider-element="input-right": Right range input - * - wt-rangeslider-element="thumb-left": Left thumb element - * - wt-rangeslider-element="thumb-right": Right thumb element - * - wt-rangeslider-element="range": Range indicator element - * - * Optional Attributes: - * - wt-rangeslider-min: Minimum value (default: 0) - * - wt-rangeslider-max: Maximum value (default: 100) - * - wt-rangeslider-steps: Step size (default: 1) - * - * Optional Elements: - * - [wt-rangeslider-range="from"]: Form input for start value - * - [wt-rangeslider-range="to"]: Form input for end value - * - [wt-rangeslider-display="from"]: Display element for start value - * - [wt-rangeslider-display="to"]: Display element for end value - * - * @example - * - *
- * - *
0
- *
100
- * - * - * - * - * - * - *
- *
- *
- *
- * - * - *
- *
- * - * @example - * - *
- *
- *
- *
- * - * - * - *
- *
- * - * - * - *
- * - * - *
- *
- * - * Behavior: - * 1. Slider updates in real-time during interaction - * 2. Form inputs update simultaneously with slider movement - * 3. Display elements update in real-time - * 4. Programmatic changes to form inputs trigger slider updates - * 5. Values are constrained within min/max range - * 6. Left value cannot exceed right value minus step size - * 7. Right value cannot be less than left value plus step size + * + * For native-only handles (no custom thumbs), see RangeSliderSimple.js. */ /** * @class RangeSlider - * @classdesc Creates a range slider component with two handles for selecting a range of values. - * The component supports both visual slider interaction and direct input of values. - * - * @param {HTMLElement} wrapper - The container element for the range slider - * @throws {Error} If required elements or attributes are missing + * @param {HTMLElement} wrapper */ class RangeSlider { -/** - * @constructor - * @param {HTMLElement} wrapper - The wrapper element containing all slider components - */ -constructor(wrapper) { - try { - this.wrapper = wrapper; - this.slider = wrapper.querySelector('[wt-rangeslider-element="slider"]'); - // Guard flag to avoid recursive updates when syncing external inputs - this.__suspendExternalSync = false; + constructor(wrapper) { + try { + this.rs = __WT_RANGE_SLIDER_CORE; - if (!this.slider) { - throw new Error('Slider element not found within wrapper'); - } - - // Add required styles - this.addStyles(); - - // Initialize configuration - this.initConfig(); + this.wrapper = wrapper; + this.slider = wrapper.querySelector('[wt-rangeslider-element="slider"]'); + this.__suspendExternalSync = false; - // Initialize elements - this.initElements(); - - // Setup initial state - this.initState(); + if (!this.slider) { + throw new Error('Slider element not found within wrapper'); + } - // Setup event listeners - this.setupEventListeners(); - } catch (err) { - console.error(`RangeSlider initialization failed: ${err.message}`); + this.addStyles(); + this.initConfig(); + this.initElements(); + this.initState(); + this.setupEventListeners(); + } catch (err) { + console.error(`RangeSlider initialization failed: ${err.message}`); + } } -} -/** - * Adds required styles for proper thumb alignment - * @private - */ -addStyles() { - // Inject styles once per document - const existing = document.getElementById('wt-rangeslider-styles'); - if (existing) return; - - const style = document.createElement('style'); - style.id = 'wt-rangeslider-styles'; - style.textContent = ` + addStyles() { + const existing = document.getElementById('wt-rangeslider-styles'); + if (existing) return; + + const style = document.createElement('style'); + style.id = 'wt-rangeslider-styles'; + style.textContent = ` [wt-rangeslider-element="slider"] { position: relative; } @@ -195,430 +228,292 @@ addStyles() { will-change: transform; } `; - document.head.appendChild(style); -} + document.head.appendChild(style); + } -/** - * Initialize slider configuration from attributes - * @private - */ -initConfig() { - const minAttr = this.slider.getAttribute('wt-rangeslider-min'); - this.sliderMin = minAttr !== null ? this.validateNumber(minAttr) : 0; - - const maxAttr = this.slider.getAttribute('wt-rangeslider-max'); - this.sliderMax = maxAttr !== null ? this.validateNumber(maxAttr) : 100; - - const stepsAttr = this.slider.getAttribute('wt-rangeslider-steps'); - this.sliderSteps = stepsAttr !== null ? this.validateNumber(stepsAttr) : 1; - - // Minimum difference between left and right values - const minDiffAttr = this.slider.getAttribute('wt-rangeslider-mindifference'); - this.minDifference = minDiffAttr !== null ? this.validateNumber(minDiffAttr) : this.sliderSteps; - - // Show preffix - this.rightSuffix = - this.slider.getAttribute('wt-rangeslider-rightsuffix') || null; - // Show preffix - this.defaultSuffix = - this.slider.getAttribute('wt-rangeslider-defaultsuffix') || null; - // Show preffix - this.shouldFormatNumber = - this.slider.getAttribute('wt-rangeslider-formatnumber') || null; -} + initConfig() { + const cfg = this.rs.readSliderConfig(this.slider, 'wt-rangeslider'); + this.sliderMin = cfg.sliderMin; + this.sliderMax = cfg.sliderMax; + this.sliderSteps = cfg.sliderSteps; + this.minDifference = cfg.minDifference; + this.rightSuffix = cfg.rightSuffix; + this.defaultSuffix = cfg.defaultSuffix; + this.shouldFormatNumber = cfg.shouldFormatNumber; + } -/** - * Initialize DOM elements - * @private - */ -initElements() { - // Range inputs (form elements) - this.rangeStart = this.wrapper.querySelector( - '[wt-rangeslider-range="from"]', - ); - this.rangeEnd = this.wrapper.querySelector('[wt-rangeslider-range="to"]'); - - // Display elements - this.displayStart = this.wrapper.querySelector( - '[wt-rangeslider-display="from"]', - ); - this.displayEnd = this.wrapper.querySelector( - '[wt-rangeslider-display="to"]', - ); - - // Slider elements - this.inputLeft = this.slider.querySelector( - '[wt-rangeslider-element="input-left"]', - ); - this.inputRight = this.slider.querySelector( - '[wt-rangeslider-element="input-right"]', - ); - this.thumbLeft = this.slider.querySelector( - '[wt-rangeslider-element="thumb-left"]', - ); - this.thumbRight = this.slider.querySelector( - '[wt-rangeslider-element="thumb-right"]', - ); - this.range = this.slider.querySelector('[wt-rangeslider-element="range"]'); - - this.validateRequiredElements(); - this.setupThumbStyles(); -} + initElements() { + this.rangeStart = this.wrapper.querySelector( + '[wt-rangeslider-range="from"]', + ); + this.rangeEnd = this.wrapper.querySelector('[wt-rangeslider-range="to"]'); + + this.displayStart = this.wrapper.querySelector( + '[wt-rangeslider-display="from"]', + ); + this.displayEnd = this.wrapper.querySelector( + '[wt-rangeslider-display="to"]', + ); + + this.inputLeft = this.slider.querySelector( + '[wt-rangeslider-element="input-left"]', + ); + this.inputRight = this.slider.querySelector( + '[wt-rangeslider-element="input-right"]', + ); + this.thumbLeft = this.slider.querySelector( + '[wt-rangeslider-element="thumb-left"]', + ); + this.thumbRight = this.slider.querySelector( + '[wt-rangeslider-element="thumb-right"]', + ); + this.range = this.slider.querySelector('[wt-rangeslider-element="range"]'); + + this.validateRequiredElements(); + this.setupThumbStyles(); + } -/** - * Sets up proper thumb styling to ensure clickable areas align with visuals - * @private - */ -setupThumbStyles() { - const setupThumb = (thumb, input) => { - // Ensure the input's thumb aligns with our custom thumb - const thumbWidth = thumb.offsetWidth || parseInt(getComputedStyle(thumb).width) || 20; - input.style.setProperty('--thumb-width', `${thumbWidth}px`); - - // Apply styles to ensure proper positioning and hit areas - thumb.style.position = 'absolute'; - thumb.style.pointerEvents = 'none'; - - // Create a custom property for the thumb offset - this.slider.style.setProperty('--thumb-offset', `${thumbWidth / 2}px`); - }; + setupThumbStyles() { + const setupThumb = (thumb, input) => { + const thumbWidth = + thumb.offsetWidth || + parseInt(getComputedStyle(thumb).width, 10) || + 20; + input.style.setProperty('--thumb-width', `${thumbWidth}px`); - setupThumb(this.thumbLeft, this.inputLeft); - setupThumb(this.thumbRight, this.inputRight); -} + thumb.style.position = 'absolute'; + thumb.style.pointerEvents = 'none'; -/** - * Initialize slider state - * @private - */ -initState() { - // Configure range inputs - [this.inputLeft, this.inputRight].forEach((input) => { - input.setAttribute('min', this.sliderMin); - input.setAttribute('max', this.sliderMax); - input.setAttribute('step', this.sliderSteps); - input.setAttribute('formnovalidate', ''); - input.setAttribute('data-form-ignore', ''); - }); - - // Set initial values from range inputs if they exist - if (this.rangeStart && this.rangeStart.value) { - this.updateLeftValues(this.rangeStart.value); - } else { - this.updateLeftValues(this.sliderMin); - } + this.slider.style.setProperty('--thumb-offset', `${thumbWidth / 2}px`); + }; - if (this.rangeEnd && this.rangeEnd.value) { - this.updateRightValues(this.rangeEnd.value); - } else { - this.updateRightValues(this.sliderMax); + setupThumb(this.thumbLeft, this.inputLeft); + setupThumb(this.thumbRight, this.inputRight); } -} -/** - * Formats a number with commas as thousand separators - * @param {number} number - The number to format - * @returns {string} The formatted number string - * @private - */ -formatNumber(number) { - return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); -} + initState() { + [this.inputLeft, this.inputRight].forEach((input) => { + input.setAttribute('min', this.sliderMin); + input.setAttribute('max', this.sliderMax); + input.setAttribute('step', this.sliderSteps); + input.setAttribute('formnovalidate', ''); + input.setAttribute('data-form-ignore', ''); + }); -/** - * Updates all visual elements and values for the left handle - * @private - */ -updateLeftValues(value) { - const constrainedValue = Math.min( - parseInt(value), - parseInt(this.inputRight.value) - this.minDifference, - ); - - // Update slider input - this.inputLeft.value = constrainedValue; - - // Update form input - if (this.rangeStart) { - // Avoid recursive reaction to our own programmatic updates - const valueProp = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); - this.__suspendExternalSync = true; - valueProp.set.call(this.rangeStart, constrainedValue); - this.__suspendExternalSync = false; + if (this.rangeStart && this.rangeStart.value) { + this.updateLeftValues(this.rangeStart.value); + } else { + this.updateLeftValues(this.sliderMin); + } + + if (this.rangeEnd && this.rangeEnd.value) { + this.updateRightValues(this.rangeEnd.value); + } else { + this.updateRightValues(this.sliderMax); + } } - // Update display - if (this.displayStart) { - const displayLeft = this.shouldFormatNumber === 'true' ? this.formatNumber(constrainedValue) : constrainedValue; - this.displayStart.textContent = String(displayLeft); + updateLeftValues(value) { + const constrainedValue = this.rs.constrainLeftValue( + value, + this.inputRight.value, + this.minDifference, + ); + + this.inputLeft.value = constrainedValue; + + if (this.rangeStart) { + this.rs.setNativeTextInputValue( + this.rangeStart, + constrainedValue, + () => { + this.__suspendExternalSync = true; + }, + () => { + this.__suspendExternalSync = false; + }, + ); + } + + if (this.displayStart) { + this.displayStart.textContent = this.rs.formatStartDisplayContent( + constrainedValue, + this.shouldFormatNumber, + ); + } + + this.updateThumbPosition( + this.inputLeft, + this.thumbLeft, + this.range, + 'left', + ); } - // Update visual position - this.updateThumbPosition( - this.inputLeft, - this.thumbLeft, - this.range, - 'left', - ); -} - -/** - * Updates all visual elements and values for the right handle - * @private - */ -updateRightValues(value) { - const constrainedValue = Math.max( - parseInt(value), - parseInt(this.inputLeft.value) + this.minDifference, - ); - - // Update slider input - this.inputRight.value = constrainedValue; - - // Update form input - if (this.rangeEnd) { - // Avoid recursive reaction to our own programmatic updates - const valueProp = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); - this.__suspendExternalSync = true; - valueProp.set.call(this.rangeEnd, constrainedValue); - this.__suspendExternalSync = false; + updateRightValues(value) { + const constrainedValue = this.rs.constrainRightValue( + value, + this.inputLeft.value, + this.minDifference, + ); + + this.inputRight.value = constrainedValue; + + if (this.rangeEnd) { + this.rs.setNativeTextInputValue( + this.rangeEnd, + constrainedValue, + () => { + this.__suspendExternalSync = true; + }, + () => { + this.__suspendExternalSync = false; + }, + ); + } + + if (this.displayEnd) { + this.displayEnd.textContent = this.rs.formatEndDisplayContent( + constrainedValue, + value, + this.sliderMax, + this.shouldFormatNumber, + this.rightSuffix, + this.defaultSuffix, + ); + } + + this.updateThumbPosition( + this.inputRight, + this.thumbRight, + this.range, + 'right', + ); } - // Update display - if (this.displayEnd) { - let finalDisplay = this.shouldFormatNumber === 'true' ? this.formatNumber(constrainedValue) : constrainedValue; - - if (this.rightSuffix && value >= this.sliderMax) { - this.displayEnd.textContent = `${finalDisplay}${this.rightSuffix}`; - } else if (this.defaultSuffix) { - this.displayEnd.textContent = `${finalDisplay}${this.defaultSuffix}`; - } else { - this.displayEnd.textContent = String(finalDisplay); - } - } + setupEventListeners() { + this.inputLeft.addEventListener('input', () => { + this.updateLeftValues(this.inputLeft.value); + if (this.rangeStart) { + this.rs.dispatchInputEvent(this.rangeStart); + } + }); - // Update visual position - this.updateThumbPosition( - this.inputRight, - this.thumbRight, - this.range, - 'right', - ); -} + this.inputRight.addEventListener('input', () => { + this.updateRightValues(this.inputRight.value); + if (this.rangeEnd) { + this.rs.dispatchInputEvent(this.rangeEnd); + } + }); -/** - * Sets up event listeners for the range slider - * @private - */ -setupEventListeners() { - // Input events for the slider inputs - this.inputLeft.addEventListener('input', () => { - this.updateLeftValues(this.inputLeft.value); - if (this.rangeStart) { - this.triggerEvent(this.rangeStart); + if (this.rangeStart) { + this.rangeStart.addEventListener('input', (e) => { + this.updateLeftValues(e.target.value); + }); + this.rangeStart.addEventListener('change', (e) => { + this.updateLeftValues(e.target.value); + }); + this.rs.hookInputValueSync( + this.rangeStart, + (val) => { + this.updateLeftValues(val); + }, + () => this.__suspendExternalSync, + ); + } + + if (this.rangeEnd) { + this.rangeEnd.addEventListener('input', (e) => { + this.updateRightValues(e.target.value); + }); + this.rangeEnd.addEventListener('change', (e) => { + this.updateRightValues(e.target.value); + }); + this.rs.hookInputValueSync( + this.rangeEnd, + (val) => { + this.updateRightValues(val); + }, + () => this.__suspendExternalSync, + ); + } } - }); - this.inputRight.addEventListener('input', () => { - this.updateRightValues(this.inputRight.value); - if (this.rangeEnd) { - this.triggerEvent(this.rangeEnd); - } - }); - - // Watch for changes to the form inputs - if (this.rangeStart) { - // React to user typing immediately - this.rangeStart.addEventListener('input', (e) => { - this.updateLeftValues(e.target.value); - }); - this.rangeStart.addEventListener('change', (e) => { - this.updateLeftValues(e.target.value); - }); - // Hook into programmatic value assignments - this.hookInputValueSync(this.rangeStart, (val) => { - this.updateLeftValues(val); - }); + updateThumbPosition(input, thumb, range, side) { + const min = parseFloat(input.min); + const max = parseFloat(input.max); + const current = parseFloat(input.value); + const percent = ((current - min) / (max - min)) * 100; + + if (side === 'left') { + thumb.style.left = `${percent}%`; + thumb.style.transform = 'translateX(-50%)'; + range.style.left = `${percent}%`; + } else { + thumb.style.right = `${100 - percent}%`; + thumb.style.transform = 'translateX(50%)'; + range.style.right = `${100 - percent}%`; + } } - if (this.rangeEnd) { - // React to user typing immediately - this.rangeEnd.addEventListener('input', (e) => { - this.updateRightValues(e.target.value); - }); - this.rangeEnd.addEventListener('change', (e) => { - this.updateRightValues(e.target.value); - }); - // Hook into programmatic value assignments - this.hookInputValueSync(this.rangeEnd, (val) => { - this.updateRightValues(val); - }); + setFrom(value) { + this.updateLeftValues(value); } -} -/** - * Updates the position of a thumb element - * @param {HTMLInputElement} input - The input element - * @param {HTMLElement} thumb - The thumb element - * @param {HTMLElement} range - The range element - * @param {string} side - The side of the slider ('left' or 'right') - * @private - */ -updateThumbPosition(input, thumb, range, side) { - const min = parseInt(input.min); - const max = parseInt(input.max); - const current = parseInt(input.value); - const percent = ((current - min) / (max - min)) * 100; - - // Get the thumb's width to account for its dimensions - const thumbWidth = thumb.offsetWidth || parseInt(getComputedStyle(thumb).width) || 20; - const sliderWidth = this.slider.offsetWidth || parseInt(getComputedStyle(this.slider).width) || 1; - - // Calculate the percentage that represents half the thumb width - const thumbHalfPercent = (thumbWidth / sliderWidth) * 100; - - if (side === 'left') { - thumb.style.left = `${percent}%`; - thumb.style.transform = 'translateX(-50%)'; - range.style.left = `${percent}%`; - } else { - thumb.style.right = `${100 - percent}%`; - thumb.style.transform = 'translateX(50%)'; - range.style.right = `${100 - percent}%`; + setTo(value) { + this.updateRightValues(value); } -} -/** - * Validates that a value is a valid number - * @param {string} value - The value to validate - * @returns {number} The parsed number - * @throws {Error} If the value is not a valid number - */ -validateNumber(value) { - const num = parseFloat(value); - if (isNaN(num)) { - throw new Error(`Invalid number value: ${value}`); + setRange(from, to) { + this.updateLeftValues(from); + this.updateRightValues(to); } - return num; -} -/** - * Triggers an input event on an element - * @param {HTMLElement} element - The element to trigger the event on - * @private - */ -triggerEvent(element) { - if (element) { - element.dispatchEvent(new Event('input', { bubbles: true })); + reset() { + this.setRange(this.sliderMin, this.sliderMax); } -} -/** - * Hook into a text input's value property to react to programmatic assignments - * @param {HTMLInputElement} input - The input to hook - * @param {(val: string|number) => void} handler - Handler to run on assignment - * @private - */ -hookInputValueSync(input, handler) { - if (!input) return; - const valueProp = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); - const self = this; - try { - Object.defineProperty(input, 'value', { - get() { - return valueProp.get.call(this); - }, - set(v) { - valueProp.set.call(this, v); - if (!self.__suspendExternalSync) { - handler(v); + validateRequiredElements() { + const requiredElements = { + inputLeft: this.inputLeft, + inputRight: this.inputRight, + thumbLeft: this.thumbLeft, + thumbRight: this.thumbRight, + range: this.range, + }; + + Object.entries(requiredElements).forEach(([name, element]) => { + if (!element) { + throw new Error(`Required element ${name} is missing`); } - }, - configurable: true, - enumerable: true, }); - } catch (err) { - // Fallback: rely on 'input'/'change' listeners if defineProperty fails } } -/** - * Public API: set left range value - * @param {number|string} value - */ -setFrom(value) { - this.updateLeftValues(value); -} - -/** - * Public API: set right range value - * @param {number|string} value - */ -setTo(value) { - this.updateRightValues(value); -} - -/** - * Public API: set both range values atomically and in correct order - * @param {number|string} from - * @param {number|string} to - */ -setRange(from, to) { - this.updateLeftValues(from); - this.updateRightValues(to); -} - -/** - * Public API: reset slider to configured bounds - */ -reset() { - this.setRange(this.sliderMin, this.sliderMax); -} - -/** - * Validates that all required elements are present - * @private - * @throws {Error} If any required element is missing - */ -validateRequiredElements() { - const requiredElements = { - inputLeft: this.inputLeft, - inputRight: this.inputRight, - thumbLeft: this.thumbLeft, - thumbRight: this.thumbRight, - range: this.range, - }; - - Object.entries(requiredElements).forEach(([name, element]) => { - if (!element) { - throw new Error(`Required element ${name} is missing`); - } - }); -} -} - -/** - * Initialize all range sliders on the page - */ const initializeRangeSlider = () => { try { window.webtricks = window.webtricks || []; - const wrappers = document.querySelectorAll('[wt-rangeslider-element="slider-wrapper"]'); + const wrappers = document.querySelectorAll( + '[wt-rangeslider-element="slider-wrapper"]', + ); if (!wrappers || wrappers.length === 0) return; - wrappers.forEach(wrapper => { + wrappers.forEach((wrapper) => { const instance = new RangeSlider(wrapper); - window.webtricks.push({ 'RangeSlider': instance }); + window.webtricks.push({ RangeSlider: instance }); }); } catch (err) { console.error(`RangeSlider initialization error: ${err.message}`); } }; -// Initialize on DOM ready if (/complete|interactive|loaded/.test(document.readyState)) { initializeRangeSlider(); } else { window.addEventListener('DOMContentLoaded', initializeRangeSlider); -} \ No newline at end of file +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { RangeSlider, InitializeRangeSlider: initializeRangeSlider }; +} diff --git a/Dist/Functional/RangeSliderSimple.js b/Dist/Functional/RangeSliderSimple.js new file mode 100644 index 0000000..0ea6001 --- /dev/null +++ b/Dist/Functional/RangeSliderSimple.js @@ -0,0 +1,618 @@ +/*! + * WebTricks — RangeSliderSimple + * @version 1.0.0 — bump semver and docs/Functional/RangeSliderSimple.md when releasing. + * Dual native range inputs (no custom thumb DOM). Self-contained (single script tag). + * MIT License + */ + +'use strict'; + +/** @private Duplicated core logic (same behavior as RangeSlider) so this file has no shared dependency. */ +var __WT_RANGE_SLIDER_SIMPLE_CORE = (function () { + function validateNumber(value) { + const num = parseFloat(value); + if (isNaN(num)) { + throw new Error(`Invalid number value: ${value}`); + } + return num; + } + + function formatNumber(number) { + return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + } + + function readSliderConfig(sliderEl, prefix) { + const minAttr = sliderEl.getAttribute(`${prefix}-min`); + const sliderMin = minAttr !== null ? validateNumber(minAttr) : 0; + + const maxAttr = sliderEl.getAttribute(`${prefix}-max`); + const sliderMax = maxAttr !== null ? validateNumber(maxAttr) : 100; + + const stepsAttr = sliderEl.getAttribute(`${prefix}-steps`); + const sliderSteps = stepsAttr !== null ? validateNumber(stepsAttr) : 1; + + const minDiffAttr = sliderEl.getAttribute(`${prefix}-mindifference`); + const minDifference = minDiffAttr !== null ? validateNumber(minDiffAttr) : sliderSteps; + + return { + sliderMin, + sliderMax, + sliderSteps, + minDifference, + rightSuffix: sliderEl.getAttribute(`${prefix}-rightsuffix`) || null, + defaultSuffix: sliderEl.getAttribute(`${prefix}-defaultsuffix`) || null, + shouldFormatNumber: sliderEl.getAttribute(`${prefix}-formatnumber`) || null, + }; + } + + function constrainLeftValue(rawValue, rightValueStr, minDifference) { + return Math.min( + Number(rawValue), + Number(rightValueStr) - minDifference, + ); + } + + function constrainRightValue(rawValue, leftValueStr, minDifference) { + return Math.max( + Number(rawValue), + Number(leftValueStr) + minDifference, + ); + } + + function setNativeTextInputValue(input, constrainedValue, suspendBegin, suspendEnd) { + if (!input) return; + const valueProp = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + suspendBegin(); + valueProp.set.call(input, constrainedValue); + suspendEnd(); + } + + function formatStartDisplayContent(constrainedValue, shouldFormatNumberAttr) { + const displayLeft = + shouldFormatNumberAttr === 'true' + ? formatNumber(constrainedValue) + : constrainedValue; + return String(displayLeft); + } + + function formatEndDisplayContent( + constrainedValue, + rawInputValue, + sliderMax, + shouldFormatNumberAttr, + rightSuffix, + defaultSuffix, + ) { + let finalDisplay = + shouldFormatNumberAttr === 'true' + ? formatNumber(constrainedValue) + : constrainedValue; + const rawNum = parseFloat(rawInputValue); + if (rightSuffix && !isNaN(rawNum) && rawNum >= sliderMax) { + return `${finalDisplay}${rightSuffix}`; + } + if (defaultSuffix) { + return `${finalDisplay}${defaultSuffix}`; + } + return String(finalDisplay); + } + + function hookInputValueSync(input, handler, isSuspended) { + if (!input) return; + const valueProp = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + try { + Object.defineProperty(input, 'value', { + get() { + return valueProp.get.call(this); + }, + set(v) { + valueProp.set.call(this, v); + if (!isSuspended()) { + handler(v); + } + }, + configurable: true, + enumerable: true, + }); + } catch (err) { + // Fallback: rely on 'input'/'change' listeners if defineProperty fails + } + } + + function dispatchInputEvent(element) { + if (element) { + element.dispatchEvent(new Event('input', { bubbles: true })); + } + } + + return { + readSliderConfig, + constrainLeftValue, + constrainRightValue, + setNativeTextInputValue, + formatStartDisplayContent, + formatEndDisplayContent, + hookInputValueSync, + dispatchInputEvent, + }; +}()); + +const ATTR_PREFIX = 'wt-rangeslidersimple'; + +/** + * Native dual-handle range slider; visible thumbs match browser hit targets. + * @param {HTMLElement} wrapper + */ +class RangeSliderSimple { + constructor(wrapper) { + try { + this.rs = __WT_RANGE_SLIDER_SIMPLE_CORE; + + this.wrapper = wrapper; + this.slider = wrapper.querySelector( + `[${ATTR_PREFIX}-element="slider"]`, + ); + this.__suspendExternalSync = false; + + if (!this.slider) { + throw new Error('Slider element not found within wrapper'); + } + + this.addStyles(); + this.initConfig(); + this.initElements(); + this.syncThemeVarsFromSliderToInputs(); + this.initState(); + this.setupEventListeners(); + } catch (err) { + console.error(`RangeSliderSimple initialization failed: ${err.message}`); + } + } + + addStyles() { + const existing = document.getElementById('wt-rangeslidersimple-styles'); + if (existing) existing.remove(); + + const style = document.createElement('style'); + style.id = 'wt-rangeslidersimple-styles'; + /* Shared track on ::before. Default: solid rail (--wt-rs-track-bg). Optional rangehighlight paints fill between thumbs. */ + style.textContent = ` + [${ATTR_PREFIX}-element="slider"] { + --wt-rs-track-fill: #3b82f6; + --wt-rs-track-bg: #111; + --wt-rs-thumb-bg: #ffffff; + --wt-rs-thumb-border: #aeb6c2; + --wt-rs-thumb-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), 0 1px 4px rgba(0, 0, 0, 0.18); + --wt-rs-range-from: 0%; + --wt-rs-range-to: 100%; + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + align-items: center; + justify-items: stretch; + min-height: 2.75rem; + box-sizing: border-box; + } + + [${ATTR_PREFIX}-element="slider"]::before { + content: ""; + grid-column: 1; + grid-row: 1; + align-self: center; + width: 100%; + height: 6px; + border-radius: 3px; + pointer-events: none; + z-index: 0; + box-sizing: border-box; + background: var(--wt-rs-track-bg, #111); + } + + [${ATTR_PREFIX}-element="slider"][${ATTR_PREFIX}-rangehighlight="true"]::before { + background: linear-gradient( + to right, + var(--wt-rs-track-bg, #111) 0%, + var(--wt-rs-track-bg, #111) var(--wt-rs-range-from, 0%), + var(--wt-rs-track-fill, #3b82f6) var(--wt-rs-range-from, 0%), + var(--wt-rs-track-fill, #3b82f6) var(--wt-rs-range-to, 100%), + var(--wt-rs-track-bg, #111) var(--wt-rs-range-to, 100%), + var(--wt-rs-track-bg, #111) 100% + ); + } + + input[type="range"][${ATTR_PREFIX}-element="input-left"], + input[type="range"][${ATTR_PREFIX}-element="input-right"] { + --wt-rs-track-fill: #3b82f6; + --wt-rs-track-bg: #111; + --wt-rs-thumb-bg: #ffffff; + --wt-rs-thumb-border: #aeb6c2; + --wt-rs-thumb-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), 0 1px 4px rgba(0, 0, 0, 0.18); + grid-column: 1; + grid-row: 1; + width: 100%; + max-width: 100%; + margin: 0; + padding: 0; + box-sizing: border-box; + pointer-events: none; + accent-color: transparent; + z-index: 2; + height: 1.75rem; + min-height: 1.75rem; + background: transparent; + -webkit-appearance: none !important; + appearance: none !important; + -moz-appearance: none !important; + } + + input[type="range"][${ATTR_PREFIX}-element="input-left"]::-webkit-slider-runnable-track, + input[type="range"][${ATTR_PREFIX}-element="input-right"]::-webkit-slider-runnable-track { + pointer-events: none; + height: 6px; + border-radius: 3px; + background: transparent; + border: none; + } + + input[type="range"][${ATTR_PREFIX}-element="input-left"]::-webkit-slider-thumb, + input[type="range"][${ATTR_PREFIX}-element="input-right"]::-webkit-slider-thumb { + -webkit-appearance: none !important; + pointer-events: auto; + position: relative; + z-index: 1; + width: 24px; + height: 24px; + margin-top: -9px; + border-radius: 50%; + background: var(--wt-rs-thumb-bg, #ffffff) !important; + border: 1px solid var(--wt-rs-thumb-border, #aeb6c2) !important; + box-shadow: var(--wt-rs-thumb-shadow, 0 0 0 1px rgba(0, 0, 0, 0.04), 0 1px 4px rgba(0, 0, 0, 0.18)) !important; + cursor: pointer; + } + + input[type="range"][${ATTR_PREFIX}-element="input-left"]::-moz-range-track, + input[type="range"][${ATTR_PREFIX}-element="input-right"]::-moz-range-track { + pointer-events: none; + height: 6px; + border-radius: 3px; + background: transparent; + border: none; + } + + input[type="range"][${ATTR_PREFIX}-element="input-left"]::-moz-range-progress, + input[type="range"][${ATTR_PREFIX}-element="input-right"]::-moz-range-progress { + pointer-events: none; + height: 6px; + border-radius: 3px; + background: transparent; + border: none; + } + + input[type="range"][${ATTR_PREFIX}-element="input-left"]::-moz-range-thumb, + input[type="range"][${ATTR_PREFIX}-element="input-right"]::-moz-range-thumb { + pointer-events: auto; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--wt-rs-thumb-bg, #ffffff) !important; + border: 1px solid var(--wt-rs-thumb-border, #aeb6c2) !important; + box-shadow: var(--wt-rs-thumb-shadow, 0 0 0 1px rgba(0, 0, 0, 0.04), 0 1px 4px rgba(0, 0, 0, 0.18)) !important; + cursor: pointer; + } + + input[type="range"][${ATTR_PREFIX}-element="input-left"]:focus-visible, + input[type="range"][${ATTR_PREFIX}-element="input-right"]:focus-visible { + outline: 2px solid var(--wt-rs-track-fill, #3b82f6); + outline-offset: 2px; + } + `; + document.head.appendChild(style); + } + + syncTrackFillPercents() { + if (!this.slider || !this.inputLeft || !this.inputRight) return; + const min = parseFloat(this.inputLeft.min); + const max = parseFloat(this.inputLeft.max); + const safeMin = Number.isFinite(min) ? min : 0; + const safeMax = Number.isFinite(max) ? max : 100; + const span = safeMax <= safeMin ? 1 : safeMax - safeMin; + const leftVal = parseFloat(this.inputLeft.value); + const rightVal = parseFloat(this.inputRight.value); + const safeL = Number.isFinite(leftVal) ? leftVal : safeMin; + const safeR = Number.isFinite(rightVal) ? rightVal : safeMax; + const pctFrom = ((safeL - safeMin) / span) * 100; + const pctTo = ((safeR - safeMin) / span) * 100; + this.slider.style.setProperty('--wt-rs-range-from', `${pctFrom}%`); + this.slider.style.setProperty('--wt-rs-range-to', `${pctTo}%`); + } + + /** WebKit range pseudos resolve theme vars on the input; copy from slider after config. */ + syncThemeVarsFromSliderToInputs() { + if (!this.slider || !this.inputLeft || !this.inputRight) return; + const names = [ + '--wt-rs-track-fill', + '--wt-rs-track-bg', + '--wt-rs-thumb-bg', + '--wt-rs-thumb-border', + '--wt-rs-thumb-shadow', + ]; + const cs = getComputedStyle(this.slider); + names.forEach((name) => { + const val = cs.getPropertyValue(name); + if (val && val.trim()) { + const v = val.trim(); + this.inputLeft.style.setProperty(name, v); + this.inputRight.style.setProperty(name, v); + } + }); + } + + initConfig() { + const cfg = this.rs.readSliderConfig(this.slider, ATTR_PREFIX); + this.sliderMin = cfg.sliderMin; + this.sliderMax = cfg.sliderMax; + this.sliderSteps = cfg.sliderSteps; + this.minDifference = cfg.minDifference; + this.rightSuffix = cfg.rightSuffix; + this.defaultSuffix = cfg.defaultSuffix; + this.shouldFormatNumber = cfg.shouldFormatNumber; + + const trackFill = this.slider.getAttribute(`${ATTR_PREFIX}-trackfill`); + if (trackFill) { + this.slider.style.setProperty('--wt-rs-track-fill', trackFill); + } + const trackBg = this.slider.getAttribute(`${ATTR_PREFIX}-trackbg`); + if (trackBg) { + this.slider.style.setProperty('--wt-rs-track-bg', trackBg); + } + } + + initElements() { + this.rangeStart = this.wrapper.querySelector( + `[${ATTR_PREFIX}-range="from"]`, + ); + this.rangeEnd = this.wrapper.querySelector(`[${ATTR_PREFIX}-range="to"]`); + + this.displayStart = this.wrapper.querySelector( + `[${ATTR_PREFIX}-display="from"]`, + ); + this.displayEnd = this.wrapper.querySelector( + `[${ATTR_PREFIX}-display="to"]`, + ); + + this.inputLeft = this.slider.querySelector( + `[${ATTR_PREFIX}-element="input-left"]`, + ); + this.inputRight = this.slider.querySelector( + `[${ATTR_PREFIX}-element="input-right"]`, + ); + + this.validateRequiredElements(); + } + + initState() { + [this.inputLeft, this.inputRight].forEach((input) => { + input.setAttribute('min', this.sliderMin); + input.setAttribute('max', this.sliderMax); + input.setAttribute('step', this.sliderSteps); + input.setAttribute('formnovalidate', ''); + input.setAttribute('data-form-ignore', ''); + }); + + if (this.rangeStart && this.rangeStart.value) { + this.updateLeftValues(this.rangeStart.value); + } else { + this.updateLeftValues(this.sliderMin); + } + + if (this.rangeEnd && this.rangeEnd.value) { + this.updateRightValues(this.rangeEnd.value); + } else { + this.updateRightValues(this.sliderMax); + } + } + + bringInputToFront(which) { + if (which === 'left') { + this.inputLeft.style.zIndex = '10'; + this.inputRight.style.zIndex = '2'; + } else { + this.inputRight.style.zIndex = '10'; + this.inputLeft.style.zIndex = '2'; + } + } + + updateLeftValues(value) { + const constrainedValue = this.rs.constrainLeftValue( + value, + this.inputRight.value, + this.minDifference, + ); + + this.inputLeft.value = constrainedValue; + + if (this.rangeStart) { + this.rs.setNativeTextInputValue( + this.rangeStart, + constrainedValue, + () => { + this.__suspendExternalSync = true; + }, + () => { + this.__suspendExternalSync = false; + }, + ); + } + + if (this.displayStart) { + this.displayStart.textContent = this.rs.formatStartDisplayContent( + constrainedValue, + this.shouldFormatNumber, + ); + } + + this.syncTrackFillPercents(); + } + + updateRightValues(value) { + const constrainedValue = this.rs.constrainRightValue( + value, + this.inputLeft.value, + this.minDifference, + ); + + this.inputRight.value = constrainedValue; + + if (this.rangeEnd) { + this.rs.setNativeTextInputValue( + this.rangeEnd, + constrainedValue, + () => { + this.__suspendExternalSync = true; + }, + () => { + this.__suspendExternalSync = false; + }, + ); + } + + if (this.displayEnd) { + this.displayEnd.textContent = this.rs.formatEndDisplayContent( + constrainedValue, + value, + this.sliderMax, + this.shouldFormatNumber, + this.rightSuffix, + this.defaultSuffix, + ); + } + + this.syncTrackFillPercents(); + } + + setupEventListeners() { + const onLeftPointer = () => this.bringInputToFront('left'); + const onRightPointer = () => this.bringInputToFront('right'); + + this.inputLeft.addEventListener('pointerdown', onLeftPointer); + this.inputRight.addEventListener('pointerdown', onRightPointer); + + this.inputLeft.addEventListener('input', () => { + this.updateLeftValues(this.inputLeft.value); + if (this.rangeStart) { + this.rs.dispatchInputEvent(this.rangeStart); + } + }); + + this.inputRight.addEventListener('input', () => { + this.updateRightValues(this.inputRight.value); + if (this.rangeEnd) { + this.rs.dispatchInputEvent(this.rangeEnd); + } + }); + + if (this.rangeStart) { + this.rangeStart.addEventListener('input', (e) => { + this.updateLeftValues(e.target.value); + }); + this.rangeStart.addEventListener('change', (e) => { + this.updateLeftValues(e.target.value); + }); + this.rs.hookInputValueSync( + this.rangeStart, + (val) => { + this.updateLeftValues(val); + }, + () => this.__suspendExternalSync, + ); + } + + if (this.rangeEnd) { + this.rangeEnd.addEventListener('input', (e) => { + this.updateRightValues(e.target.value); + }); + this.rangeEnd.addEventListener('change', (e) => { + this.updateRightValues(e.target.value); + }); + this.rs.hookInputValueSync( + this.rangeEnd, + (val) => { + this.updateRightValues(val); + }, + () => this.__suspendExternalSync, + ); + } + } + + setFrom(value) { + this.updateLeftValues(value); + } + + setTo(value) { + this.updateRightValues(value); + } + + setRange(from, to) { + this.updateLeftValues(from); + this.updateRightValues(to); + } + + reset() { + this.setRange(this.sliderMin, this.sliderMax); + } + + validateRequiredElements() { + const requiredElements = { + inputLeft: this.inputLeft, + inputRight: this.inputRight, + }; + + Object.entries(requiredElements).forEach(([name, element]) => { + if (!element) { + throw new Error(`Required element ${name} is missing`); + } + }); + } +} + +const initializeRangeSliderSimple = () => { + try { + window.webtricks = window.webtricks || []; + const wrappers = document.querySelectorAll( + `[${ATTR_PREFIX}-element="slider-wrapper"]`, + ); + + if (!wrappers || wrappers.length === 0) return; + + wrappers.forEach((wrapper) => { + const instance = new RangeSliderSimple(wrapper); + window.webtricks.push({ RangeSliderSimple: instance }); + }); + + const bumpStyleOrder = () => { + const injectedStyle = document.getElementById('wt-rangeslidersimple-styles'); + if (injectedStyle && document.head) { + document.head.appendChild(injectedStyle); + } + }; + bumpStyleOrder(); + setTimeout(bumpStyleOrder, 0); + } catch (err) { + console.error(`RangeSliderSimple initialization error: ${err.message}`); + } +}; + +if (/complete|interactive|loaded/.test(document.readyState)) { + initializeRangeSliderSimple(); +} else { + window.addEventListener('DOMContentLoaded', initializeRangeSliderSimple); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + RangeSliderSimple, + InitializeRangeSliderSimple: initializeRangeSliderSimple, + }; +} diff --git a/Dist/Functional/ReadTime.js b/Dist/Functional/ReadTime.js index b5e273c..80132ed 100644 --- a/Dist/Functional/ReadTime.js +++ b/Dist/Functional/ReadTime.js @@ -44,3 +44,12 @@ if (/complete|interactive|loaded/.test(document.readyState)) { } else { window.addEventListener('DOMContentLoaded',InitializeReadTime ) } + +// Allow requiring this module in test environments without affecting browser usage +try { + if (typeof module !== 'undefined' && module.exports) { + module.exports = { ReadTime, InitializeReadTime }; + } +} catch { + // Intentionally suppress errors for compatibility between browser and Node.js environments. +} diff --git a/Dist/Functional/ShareLink.js b/Dist/Functional/ShareLink.js index b9c498d..2f9eefb 100644 --- a/Dist/Functional/ShareLink.js +++ b/Dist/Functional/ShareLink.js @@ -2,52 +2,208 @@ class ShareLink { constructor(element) { + if (!(element instanceof HTMLElement)) { + throw new Error("Initialization failed: A valid HTMLElement was not provided to the constructor."); + } + this.element = element; this.platform = element.getAttribute('wt-share-element'); this.title = document.title; this.url = window.location.href; + + if (!this.platform || this.platform.trim() === '') { + console.warn('ShareLink: No platform specified for element:', element); + return; + } + + if (!this.url || this.url.trim() === '') { + console.error('ShareLink: Invalid URL detected:', this.url); + return; + } + + this.copySuccessClass = this.element.getAttribute('wt-share-copysuccess') || null; + this.copyErrorClass = this.element.getAttribute('wt-share-copyerror') || null; + this.copyMessage = this.element.getAttribute('wt-share-copymessage') || null; // plain text message + const _copyTemplateKey = this.element.getAttribute('wt-share-copytemplate') || 'copied'; + // Static cache to avoid repeated DOM queries and allow removal of source template + if (!ShareLink._templateCache) { + ShareLink._templateCache = Object.create(null); + } + if (ShareLink._templateCache[_copyTemplateKey] == null) { + const _copyTemplateEl = document.querySelector(`[wt-share-copyelement="${_copyTemplateKey}"]`); + if (_copyTemplateEl) { + ShareLink._templateCache[_copyTemplateKey] = _copyTemplateEl.innerHTML; + // Remove original template node from DOM after caching to prevent accidental display and reduce layout cost + try { _copyTemplateEl.remove(); } catch {} + } else { + ShareLink._templateCache[_copyTemplateKey] = null; + } + } + this.copyMessageTemplate = ShareLink._templateCache[_copyTemplateKey]; // html message from cached template element + this.copyFailMessage = this.element.getAttribute('wt-share-copymessage-fail') || 'failed to copy'; // plain text fail + + if ( this.copyMessage || this.copyMessageTemplate || this.copySuccessClass || this.copyErrorClass) { + this.copyResetTimer = null; + this.isCopyLocked = false; + this.copyTimeout = parseInt(this.element.getAttribute('wt-share-copytimeout'), 10) || 1000; + // Provide basic screen reader feedback when message swaps occur + this.element.setAttribute('aria-live', 'polite'); + } + this.encoded = this.encodeURIParams(); this.initializeShareLinks(); } encodeURIParams() { - let unencodedURI = `${this.url}&title='${this.title}'&description='${this.title}'`; - return encodeURI(unencodedURI); + const title = encodeURIComponent(this.title || ''); + const url = encodeURIComponent(this.url || ''); + return { title, url }; } initializeShareLinks() { const socialSelectors = { - facebook: `https://www.facebook.com/sharer/sharer.php?u=${this.encoded}`, - twitter: `https://twitter.com/share?url=${this.encoded}`, - linkedin: `https://www.linkedin.com/shareArticle?mini=true&url='${this.encoded}`, - whatsapp: `https://wa.me/?text=${this.encoded}`, - pinterest: `http://pinterest.com/pin/create/button/?url=${this.encoded}`, - reddit: `http://www.reddit.com/submit?url=${this.encoded}` + facebook: `https://www.facebook.com/sharer/sharer.php?u=${this.encoded.url}`, + twitter: `https://twitter.com/intent/tweet?url=${this.encoded.url}&text=${this.encoded.title}`, + linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${this.encoded.url}&title=${this.encoded.title}`, + whatsapp: `https://wa.me/?text=${this.encoded.title}%20${this.encoded.url}`, + pinterest:`https://www.pinterest.com/pin/create/button/?url=${this.encoded.url}&description=${this.encoded.title}`, + reddit: `https://www.reddit.com/submit?url=${this.encoded.url}&title=${this.encoded.title}` }; - let _link = socialSelectors[this.platform]; - if(!_link) return; // handle errors if the platform has issues + const _link = socialSelectors[this.platform]; - if(_link === 'copy') { - this.element.addEventListener('click', () => navigator.clipboard.writeText(`${this.url}`)); - } - else{ + if (this.platform === 'copy') { + // Prevent accidental navigation if element is an + this.element.removeAttribute('href'); + this.element.addEventListener('click', this.handleCopyClick.bind(this)); + } else { + if (!_link) { + console.error(`Unknown platform: ${this.platform}`); + return; + } this.element.setAttribute("href", _link); - this.element.setAttribute("target", '_blank'); + this.element.setAttribute("target", "_blank"); + this.element.setAttribute("rel", "noopener noreferrer"); + } + } + + async handleCopyClick(e) { + e.preventDefault(); + + if (this.isCopyLocked) return; + this.isCopyLocked = true; + + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(this.url); + this.triggerCopyChange(); + } else { + this.copyFallback(this.url); + } + } catch (error) { + console.warn('ShareLink: Clipboard API failed, using fallback:', error); + this.copyFallback(this.url); + } finally { + setTimeout(() => (this.isCopyLocked = false), 300); + } + } + + /** Fallback method for copying text in unsupported environments + * @param {string} text - The text to be copied to the clipboard + * @deprecated Use navigator.clipboard API where possible, this is only for legacy support + * and will be removed in future versions. + */ + copyFallback(text) { + const ta = document.createElement('textarea'); + ta.value = text; + // Use off-screen positioning to ensure selection works across browsers + ta.setAttribute('readonly', ''); + ta.style.cssText = 'position:absolute;left:-9999px;top:0;opacity:0;'; + document.body.appendChild(ta); + ta.select(); + try { ta.setSelectionRange(0, ta.value.length); } catch {} + try { + const ok = document.execCommand('copy'); + ok ? this.triggerCopyChange() : this.triggerCopyChange(true); + } catch { + this.triggerCopyChange(true); + } + document.body.removeChild(ta); + } + + triggerCopyChange(error = false) { + // Preserve original content across repeated triggers until reset + if (this._copyOriginalHTML == null) { + this._copyOriginalHTML = this.element.innerHTML; + } + + if (this.copyResetTimer) clearTimeout(this.copyResetTimer); + + // Prefer HTML from external template element when provided, fallback to plain text attributes + const successContent = this.copyMessageTemplate != null ? this.copyMessageTemplate : this.copyMessage; + const failContent = this.copyFailMessage; // Only plain text fail for now + if (error) { + // Fail path uses plain text to avoid accidental HTML execution + this.element.textContent = failContent ?? ''; + } else if (successContent) { + this.element.innerHTML = successContent; + } + + if (this.copySuccessClass && !error) { + this.element.classList.add(this.copySuccessClass); } + if (this.copyErrorClass && error) { + this.element.classList.add(this.copyErrorClass); + } + + // Dispatch a custom event for integrations/analytics + try { + this.element.dispatchEvent(new CustomEvent('sharelink:copy', { + bubbles: true, + detail: { success: !error, url: this.url, platform: 'copy' } + })); + } catch {} + + this.copyResetTimer = setTimeout(() => { + if (!this.element.isConnected) return; + if (this._copyOriginalHTML != null) { + this.element.innerHTML = this._copyOriginalHTML; + } + if (this.copySuccessClass && !error) { + this.element.classList.remove(this.copySuccessClass); + } + if (this.copyErrorClass && error) { + this.element.classList.remove(this.copyErrorClass); + } + this.copyResetTimer = null; + this._copyOriginalHTML = null; + }, this.copyTimeout); } } -const InitializeShareLink = () => { +function InitializeShareLink() { window.webtricks = window.webtricks || []; - let links = document.querySelectorAll("[wt-share-element]"); + const links = document.querySelectorAll("[wt-share-element]"); if (!links || links.length === 0) return; links.forEach(link => { - let instance = new ShareLink(link); - window.webtricks.push({'ShareLink': instance }); + // Check if element already has a ShareLink instance + if (link._shareLinkInstance) { + console.warn('ShareLink: Element already initialized, skipping:', link); + return; + } + + try { + const instance = new ShareLink(link); + if (instance.platform) { // Only add if platform was valid + link._shareLinkInstance = instance; + window.webtricks.push({ 'ShareLink': instance }); + } + } catch (error) { + console.error('ShareLink: Failed to initialize element:', link, error); + } }); -}; +} // Execute InitializeShareLink when the DOM is fully loaded if (/complete|interactive|loaded/.test(document.readyState)) { @@ -55,3 +211,10 @@ if (/complete|interactive|loaded/.test(document.readyState)) { } else { window.addEventListener('DOMContentLoaded', InitializeShareLink); } + +// Allow requiring this module in test environments without affecting browser usage +try { + if (typeof module !== 'undefined' && module.exports) { + module.exports = { ShareLink, InitializeShareLink }; + } +} catch {} diff --git a/Dist/WebflowOnly/CMSFilter.js b/Dist/WebflowOnly/CMSFilter.js index 71a2395..fcb261e 100644 --- a/Dist/WebflowOnly/CMSFilter.js +++ b/Dist/WebflowOnly/CMSFilter.js @@ -1,952 +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 emptyMaxAttr = this.emptyElement.getAttribute( + "wt-cmsfilter-empty-max", + ); + if (emptyMaxAttr !== null && /^[1-9]\d*$/.test(emptyMaxAttr)) { + this.emptyMaxCount = Number(emptyMaxAttr); + } } - - 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); } + } 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; + // textContent so updates are visible to tests and matches non-layout engines (e.g. jsdom) + this.resultCount.textContent = String(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); - } - // Handle date values - else if (!isNaN(Date.parse(aValue)) && !isNaN(Date.parse(bValue))) { - aValue = new Date(aValue); - bValue = new Date(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 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 original dataset access - const datasetValue = (item.dataset && item.dataset[category]) ? item.dataset[category] : ''; - 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(); - } - - ResetInteraction(element) { - if (!element) { - console.error('Element not found'); - return; - } + this.ApplyFilters(); + } - 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) -} \ No newline at end of file + 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 {} diff --git a/Dist/WebflowOnly/CMSSelect.js b/Dist/WebflowOnly/CMSSelect.js index 61a3515..8f0d9b7 100644 --- a/Dist/WebflowOnly/CMSSelect.js +++ b/Dist/WebflowOnly/CMSSelect.js @@ -22,9 +22,9 @@ class CMSSelect { try { this.options.forEach(opt => { const value = opt.getAttribute('wt-cmsselect-value'); - const text = opt.innerText; - - if (text && text.trim() !== "") { + const text = (opt.textContent || opt.innerText || '').trim(); + + if (text !== '') { const option = new Option(text, value || text); this.selectElement.add(option); } @@ -57,3 +57,8 @@ if (/complete|interactive|loaded/.test(document.readyState)) { } else { window.addEventListener('DOMContentLoaded', InitializeCMSSelect); } + +// Export for CommonJS (testing / bundler environments) +if (typeof module !== 'undefined' && module.exports) { + module.exports = { CMSSelect, InitializeCMSSelect }; +} diff --git a/README.md b/README.md index 8de7f33..2e2a8ce 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,7 @@ GitHub repository stars - GitHub License - - - Static Badge + Static Badge


@@ -63,7 +60,8 @@ All of the documentation is explained by functionality in documentation in my site for the complete list of functionalities and scripts available.

- WebTricks might have been started as a personal project, but I'm a believer that a project for the comunity by the comunity can offer way more value than any single dev could provide so feel free to contribute to this project and use any solution here. + WebTricks might have been started as a personal project, but I'm a believer that a project for the comunity by the comunity can offer way more value than any single dev could provide so feel free to contribute to this project and use any solution here.
+ You can follow development and news here

Getting Started

@@ -80,6 +78,23 @@ Multiple Scripts: Add as many scripts as you need to your project by referencing ``` + +**Range sliders:** each script is **self-contained** (one tag). Use **`RangeSlider.js`** for custom thumbs or **`RangeSliderSimple.js`** for native thumbs only. Use jsDelivr (not raw `githubusercontent.com`, which often serves `text/plain` and blocks execution). The URLs below use **`@1`**, same as the other jsDelivr examples in this README—**not `@main`**, so embeds stay on a stable major ref. + +``` + +``` + +Native-thumb variant (`RangeSliderSimple` is **1.0.0** in source): + +``` + +``` + +For a **stricter** pin, use a [release tag](https://github.com/TheCodeRaccoons/WebTricks/releases) (e.g. `@v1.1.0` for RangeSlider, `@v1.0.0` for RangeSliderSimple) or a **commit SHA**. + +If a page uses **both** slider types, you may include **both** scripts; they use separate attribute namespaces (`wt-rangeslider-*` vs `wt-rangeslidersimple-*`). + Ready to Use: Once imported, the scripts initialize automatically, provided the correct HTML attributes are in place. @@ -129,12 +144,44 @@ All official releases shall be in master. Any updates in between (u After a pull request has been open for over 30 days with no activity or response from the author, it'll be automatically marked as stale. We might fork your changes and merge the changes ourselves. Since GitHub tracks contributions by commits, you will be credited.


+ +

Our Contributors

+

+ The community supports this project; huge thanks to everyone who has made this possible! +

+ + + + + + + + + + +
+ + JorchCortez +
+ JorchCortez +
+
+ + matthewcsimpson +
+ matthewcsimpson +
+
+ + Likhithakathireddy +
+ Likhithakathireddy +
+
+ +
- - -### Disclaimer -Even though this project has Webflow on its name I'm by no means part of the webflow team, just a dev sharing a bit of the work I've done over the years and trying to help out others looking for easier ways to build for the web. diff --git a/__tests__/CMSFilter.test.js b/__tests__/CMSFilter.test.js new file mode 100644 index 0000000..516ab00 --- /dev/null +++ b/__tests__/CMSFilter.test.js @@ -0,0 +1,343 @@ +/** @jest-environment jsdom */ + +// Prevent auto init before we control DOM +Object.defineProperty(document, "readyState", { + value: "loading", + configurable: true, +}); + +// requestAnimationFrame polyfill for consistency +if (!global.requestAnimationFrame) global.requestAnimationFrame = (cb) => cb(); + +describe("CMSFilter", () => { + let CMSFilter, InitializeCMSFilter; + + beforeEach(() => { + document.body.innerHTML = ""; + window.webtricks = []; + jest.resetModules(); + ({ + CMSFilter, + InitializeCMSFilter, + } = require("../Dist/WebflowOnly/CMSFilter.js")); + }); + + 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"' + : ""; + document.body.innerHTML = ` +
+
+ + + + + + +
+ + +
+
+
+
+ + x +
+
+
+
Alpha Item
+
Beta Item
+
Gamma Item
+
+
No results
+ ${paginationMarkup} + `; + } + + test("initializes and caches items, pushes instance", () => { + buildBasicDOM(); + InitializeCMSFilter(); + 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); + expect(instance.allItems.length).toBe(3); + expect(instance.filteredItems.length).toBe(3); + expect(countText).toBe("3"); + }); + + 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 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"); + }); + + 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 instance = window.webtricks[0].CMSFilter; + instance.ApplyFilters(); + expect(instance.filteredItems.length).toBe(1); + expect(instance.filteredItems[0].textContent.toLowerCase()).toContain( + "gamma", + ); + }); + + 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', + ); + // 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 })); + const instance = window.webtricks[0].CMSFilter; + instance.ApplyFilters(); + expect(instance.filteredItems.length).toBe(1); + expect(instance.filteredItems[0].dataset.price).toBe("25"); + }); + + 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 instance = window.webtricks[0].CMSFilter; + instance.ApplyFilters(); + expect(instance.filteredItems.length).toBe(1); + document.querySelector('[wt-cmsfilter-element="clear-all"]').click(); + expect(instance.filteredItems.length).toBe(3); + }); + + 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 instance = window.webtricks[0].CMSFilter; + 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 }); + 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 })); + 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"])); + expect(visible).toHaveLength(1); + // Clear all restores + document.querySelector('[wt-cmsfilter-element="clear-all"]').click(); + 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 }); + 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 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"]', + ); + 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"); + }); + + 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/__tests__/CMSSelect.test.js b/__tests__/CMSSelect.test.js new file mode 100644 index 0000000..45e1cc8 --- /dev/null +++ b/__tests__/CMSSelect.test.js @@ -0,0 +1,95 @@ +/** @jest-environment jsdom */ + +describe('CMSSelect', () => { + let InitializeCMSSelect; + + beforeEach(() => { + // Keep readyState as loading so auto init doesn't run before we call initializer + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + document.body.innerHTML = ''; + window.webtricks = []; + jest.resetModules(); + ({ InitializeCMSSelect } = require('../Dist/WebflowOnly/CMSSelect.js')); + }); + + test('single select is populated with basic options', () => { + document.body.innerHTML = ` + +
Option 1
+
Option 2
+
Option 3
+ `; + InitializeCMSSelect(); + const instance = window.webtricks.find(e => e.CMSSelect).CMSSelect; + expect(instance.selectElement.options.length).toBe(3); + expect(Array.from(instance.selectElement.options).map(o => o.text)).toEqual(['Option 1','Option 2','Option 3']); + }); + + test('custom value attribute overrides option value', () => { + document.body.innerHTML = ` + +
Display A
+
Display B
+ `; + InitializeCMSSelect(); + const instance = window.webtricks[0].CMSSelect; + const values = Array.from(instance.selectElement.options).map(o => o.value); + expect(values).toEqual(['val-a','val-b']); + }); + + test('empty text sources are ignored', () => { + document.body.innerHTML = ` + +
First
+
+
+
Last
+ `; + InitializeCMSSelect(); + const instance = window.webtricks[0].CMSSelect; + expect(instance.selectElement.options.length).toBe(2); + expect(Array.from(instance.selectElement.options).map(o => o.text)).toEqual(['First','Last']); + }); + + test('multiple selects get their own target options', () => { + document.body.innerHTML = ` + + +
Alpha
+
Beta
+
Gamma
+
Delta
+ `; + InitializeCMSSelect(); + const instances = window.webtricks.filter(e => e.CMSSelect).map(e => e.CMSSelect); + expect(instances.length).toBe(2); + const [first, second] = instances; + expect(Array.from(first.selectElement.options).map(o => o.text)).toEqual(['Alpha','Beta']); + expect(Array.from(second.selectElement.options).map(o => o.text)).toEqual(['Gamma','Delta']); + }); + + test('missing targets logs error and leaves select empty', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + document.body.innerHTML = ` + + `; + InitializeCMSSelect(); + const instance = window.webtricks[0].CMSSelect; + expect(instance.selectElement.options.length).toBe(0); + expect(spy).toHaveBeenCalled(); + const logged = spy.mock.calls.flat().join(' '); + expect(logged).toMatch(/No options found/); + spy.mockRestore(); + }); + + test('falls back to text for value when custom value missing', () => { + document.body.innerHTML = ` + +
Plain Text
+ `; + InitializeCMSSelect(); + const opt = window.webtricks[0].CMSSelect.selectElement.options[0]; + expect(opt.text).toBe('Plain Text'); + expect(opt.value).toBe('Plain Text'); + }); +}); diff --git a/__tests__/CookieConsent.test.js b/__tests__/CookieConsent.test.js new file mode 100644 index 0000000..021c15e --- /dev/null +++ b/__tests__/CookieConsent.test.js @@ -0,0 +1,128 @@ +/** @jest-environment jsdom */ + +// Prevent auto-init on require +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +describe('CookieConsent', () => { + let CookieConsent, InitializeCookieConsent; + + beforeEach(() => { + document.body.innerHTML = ''; + document.head.innerHTML = ''; + // Clear cookie between tests + document.cookie = 'cookieConsent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; + window.localStorage.clear(); + // Reset global counters used by inline scripts + delete window.__necessary; + delete window.__analytics; + delete window.__marketing; + window.webtricks = []; + jest.resetModules(); + ({ CookieConsent, InitializeCookieConsent } = require('../Dist/Functional/CookieConsent.js')); + }); + + function addDOM({ withManage = true } = {}) { + const manage = withManage ? '' : ''; + document.body.innerHTML = ` + ${manage} + + + + + `; + } + + test('shows banner and wires events when no cookie; accept all sets cookie and removes banner', () => { + addDOM(); + InitializeCookieConsent(); + + const banner = document.getElementById('banner'); + expect(banner.style.display).toBe('block'); + + // Click accept all (first button) + document.getElementById('acceptAll1').click(); + + // Cookie set + expect(document.cookie).toContain('cookieConsent=all'); + // Banner removed + expect(document.getElementById('banner')).toBeNull(); + // Instance pushed + expect(window.webtricks.some(e => e.CookieConsent)).toBe(true); + + // Scripts injected (necessary + analytics + marketing as all) + // Originals remaining should be 0 + expect(document.querySelectorAll('script[wt-cookieconsent-script]').length).toBe(0); + // Executed inline scripts should have incremented globals + expect(window.__necessary).toBe(1); + expect(window.__analytics).toBe(1); + expect(window.__marketing).toBe(1); + }); + + test('accept necessary sets cookie and only injects necessary scripts', () => { + addDOM(); + InitializeCookieConsent(); + + document.getElementById('acceptNec').click(); + + expect(document.cookie).toContain('cookieConsent=necessary'); + expect(document.querySelectorAll('script[wt-cookieconsent-script]').length).toBe(2); // analytics + marketing remain + // Necessary script may run multiple times if multiple necessary scripts exist or re-evaluation occurs; ensure at least once + expect((window.__necessary || 0)).toBeGreaterThanOrEqual(1); + expect(window.__analytics || 0).toBe(0); + expect(window.__marketing || 0).toBe(0); + }); + + test('category form submit composes cookie with necessary prefix when missing', () => { + addDOM(); + InitializeCookieConsent(); + + // Check only marketing; necessary should be auto-added + document.getElementById('cat-marketing').checked = true; + const form = document.getElementById('cats'); + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + + expect(document.cookie).toMatch(/cookieConsent=necessary,marketing/); + // Necessary and marketing execute; analytics remains + expect((window.__necessary || 0)).toBeGreaterThanOrEqual(1); + expect(window.__marketing).toBe(1); + expect(window.__analytics || 0).toBe(0); + }); + + test('manage cookies button shows the banner when hidden', () => { + addDOM(); + InitializeCookieConsent(); + + const banner = document.getElementById('banner'); + banner.style.display = 'none'; + document.getElementById('manage').click(); + expect(banner.style.display).toBe('block'); + }); + + test('on load with existing cookie, banner removed and scripts loaded; analytics grants fb consent', () => { + // Pre-set cookie to analytics + document.cookie = 'cookieConsent=analytics; path=/'; + // Provide fbq + window.fbq = jest.fn(); + addDOM(); + + InitializeCookieConsent(); + + // Banner removed immediately + expect(document.getElementById('banner')).toBeNull(); + // fb consent granted + expect(window.localStorage.getItem('fbGrantConsent')).toBe('true'); + expect(window.fbq).toHaveBeenCalledWith('consent', 'grant'); + // Only necessary + analytics injected; marketing should remain unexecuted + expect((window.__necessary || 0)).toBeGreaterThanOrEqual(1); + expect(window.__analytics).toBe(1); + expect(window.__marketing || 0).toBe(0); + }); +}); diff --git a/__tests__/CopyToClipboard.test.js b/__tests__/CopyToClipboard.test.js new file mode 100644 index 0000000..3638e02 --- /dev/null +++ b/__tests__/CopyToClipboard.test.js @@ -0,0 +1,169 @@ +/** @jest-environment jsdom */ + +// Prevent auto-initialize on require +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +// jsdom innerText polyfill mapping to textContent +if (!('innerText' in document.createElement('div'))) { + Object.defineProperty(HTMLElement.prototype, 'innerText', { + get() { return this.textContent; }, + set(v) { this.textContent = v; } + }); +} + +describe('CopyToClipboard', () => { + let CopyToClipboard, InitializeCopyToClipboard; + + beforeEach(() => { + document.body.innerHTML = ''; + window.webtricks = []; + jest.resetModules(); + ({ CopyToClipboard, InitializeCopyToClipboard } = require('../Dist/Functional/CopyToClipboard.js')); + }); + + test('InitializeCopyToClipboard creates instance and pushes to webtricks', () => { + document.body.innerHTML = ` +
+ +
Hello World
+
+ `; + + // Clipboard present but we won't assert calls in this test + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + global.navigator.clipboard = { writeText: jest.fn().mockResolvedValue() }; + + InitializeCopyToClipboard(); + + expect(Array.isArray(window.webtricks)).toBe(true); + expect(window.webtricks.length).toBe(1); + const entry = window.webtricks[0]; + expect(entry && entry.CopyToClipboard).toBeTruthy(); + + // Instance has expected wiring + const instance = entry.CopyToClipboard; + const container = document.getElementById('ctc'); + expect(instance.ctcContainer).toBe(container); + expect(instance.ctcTrigger).toBe(document.getElementById('trigger')); + }); + + test('Click updates texttarget, toggles class, writes to clipboard, and resets', async () => { + jest.useFakeTimers(); + + document.body.innerHTML = ` +
+ +
SECRET TEXT
+
+ `; + + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + const writeText = jest.fn().mockResolvedValue(); + global.navigator.clipboard = { writeText }; + + InitializeCopyToClipboard(); + + const trigger = document.getElementById('trigger'); + const textTarget = document.getElementById('tt'); + + // Click to copy + trigger.click(); + + // Clipboard called with target text + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith('SECRET TEXT'); + + // Text swap and class toggle + expect(textTarget.textContent).toBe('Copied!'); + expect(trigger.classList.contains('copied')).toBe(true); + + // After timeout, restored + jest.advanceTimersByTime(25); + expect(textTarget.textContent.trim()).toBe('Copy'); + expect(trigger.classList.contains('copied')).toBe(false); + + jest.useRealTimers(); + }); + + test('Without texttarget, trigger text updates and resets', () => { + jest.useFakeTimers(); + + document.body.innerHTML = ` +
+ +
A
+
+ `; + + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + global.navigator.clipboard = { writeText: jest.fn().mockResolvedValue() }; + + InitializeCopyToClipboard(); + + const trigger = document.getElementById('trigger'); + trigger.click(); + + expect(trigger.textContent).toBe('Done'); + + jest.advanceTimersByTime(10); + expect(trigger.textContent).toBe('Copy Now'); + + jest.useRealTimers(); + }); + + test('If target missing, no listener is attached (no clipboard call/changes on click)', () => { + document.body.innerHTML = ` +
+ + +
+ `; + + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + const writeText = jest.fn(); + global.navigator.clipboard = { writeText }; + + InitializeCopyToClipboard(); + + const trigger = document.getElementById('trigger'); + trigger.click(); + + // No copy attempted, text unchanged + expect(writeText).not.toHaveBeenCalled(); + expect(trigger.textContent).toBe('Copy'); + }); + + test('No copied message leaves text intact but toggles active class if provided', () => { + jest.useFakeTimers(); + + document.body.innerHTML = ` +
+ +
DATA
+
+ `; + + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + global.navigator.clipboard = { writeText: jest.fn().mockResolvedValue() }; + + InitializeCopyToClipboard(); + + const trigger = document.getElementById('trigger'); + trigger.click(); + + // Text remained the same + expect(trigger.textContent).toBe('Copy'); + // Class toggled on + expect(trigger.classList.contains('is-copy')).toBe(true); + + jest.advanceTimersByTime(5); + // Class toggled off + expect(trigger.classList.contains('is-copy')).toBe(false); + + jest.useRealTimers(); + }); +}); diff --git a/__tests__/CountUp.test.js b/__tests__/CountUp.test.js new file mode 100644 index 0000000..0ae53cf --- /dev/null +++ b/__tests__/CountUp.test.js @@ -0,0 +1,98 @@ +/** @jest-environment jsdom */ + +// Prevent auto-init on require +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +describe('CountUp', () => { + let CountUp, InitializeCountUp; + + beforeEach(() => { + document.body.innerHTML = ''; + window.webtricks = []; + jest.resetModules(); + ({ CountUp, InitializeCountUp } = require('../Dist/Functional/CountUp.js')); + jest.useFakeTimers(); + }); + + afterEach(() => { + try { jest.runOnlyPendingTimers(); } catch {} + jest.useRealTimers(); + }); + + test('InitializeCountUp creates instances and pushes to webtricks', () => { + document.body.innerHTML = ` +
+
+ `; + + InitializeCountUp(); + + expect(window.webtricks.filter(e => e.CountUp).length).toBe(2); + }); + + test('counts up with prefix and suffix and stops at target', () => { + document.body.innerHTML = ` +
+ `; + + InitializeCountUp(); + + const el = document.getElementById('c1'); + + // Tick a few times + jest.advanceTimersByTime(1 * 10); // enough to pass 5 increments + // Should not exceed target and should have prefix/suffix + expect(el.textContent).toMatch(/^\$\d+ USD$/); + const value = parseInt(el.textContent.replace(/[^0-9]/g, ''), 10); + expect(value).toBeLessThanOrEqual(5); + }); + + test('custom step increments by step', () => { + document.body.innerHTML = ` +
+ `; + + const el = document.getElementById('c1'); + // Instantiate directly so we can control ticks deterministically + const instance = new CountUp(el); + // Stop the internal interval to avoid timer flakiness in tests + clearInterval(instance.stop); + + // After constructor: displayed 0, internal currentVal = 2 + expect(parseInt(el.textContent, 10)).toBe(0); + + // Manual ticks via counterUp + instance.counterUp(); // display 2, currentVal -> 4 + expect(parseInt(el.textContent, 10)).toBe(2); + + instance.counterUp(); // display 4, currentVal -> 6 + expect(parseInt(el.textContent, 10)).toBe(4); + + // Advance until target reached (do not exceed by continuing calls) + while (instance.currentVal <= instance.counterTarget) { + instance.counterUp(); + } + const value = parseInt(el.textContent, 10); + expect(value).toBe(10); // exact target should be last displayed + expect(instance.currentVal).toBeGreaterThan(10); // internal increment passed target + }); + + test('zero step falls back to +1', () => { + document.body.innerHTML = ` +
+ `; + + InitializeCountUp(); + const el = document.getElementById('c1'); + + jest.advanceTimersByTime(1 * 4); + const value = parseInt(el.textContent, 10); + expect(value).toBeLessThanOrEqual(3); + }); + + test('no counters found does nothing', () => { + document.body.innerHTML = `
`; + InitializeCountUp(); + expect(window.webtricks.length).toBe(0); + }); +}); diff --git a/__tests__/FormCheck.test.js b/__tests__/FormCheck.test.js new file mode 100644 index 0000000..e14a575 --- /dev/null +++ b/__tests__/FormCheck.test.js @@ -0,0 +1,189 @@ +/** @jest-environment jsdom */ + +// Prevent auto-init on require +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +describe('FormCheck', () => { + let FormCheck, InitializeFormCheck; + + beforeEach(() => { + document.body.innerHTML = ''; + window.webtricks = []; + jest.resetModules(); + ({ FormCheck, InitializeFormCheck } = require('../Dist/Functional/FormCheck.js')); + }); + + function buildBasicForm({ includeDefaultSubmit = true } = {}) { + const defaultSubmit = includeDefaultSubmit + ? '' + : ''; + document.body.innerHTML = ` +
+
+ +
Name is required
+
+ + ${defaultSubmit} + +
+ `; + } + + test('initialization hides error elements, removes error class, and pushes instance', () => { + buildBasicForm(); + const err = document.getElementById('nameErr'); + // Set visible and add class before init to ensure it gets cleared + err.style.display = 'block'; + const field = document.getElementById('name'); + field.classList.add('has-error'); + + InitializeFormCheck(); + + expect(err.style.display).toBe('none'); + expect(field.classList.contains('has-error')).toBe(false); + expect(window.webtricks.some(e => e.FormCheck)).toBe(true); + }); + + test('invalid required field shows error and prevents success actions', () => { + buildBasicForm(); + InitializeFormCheck(); + + const field = document.getElementById('name'); + const err = document.getElementById('nameErr'); + const def = document.getElementById('def'); + const defClick = jest.fn(); + def.click = defClick; + + // Click submit with empty field + document.getElementById('submit').click(); + + // Error shown and class added + expect(err.style.display).toBe('block'); + expect(field.classList.contains('has-error')).toBe(true); + // No success action + expect(defClick).not.toHaveBeenCalled(); + }); + + test('success path with default submit button updates label and triggers default submit', () => { + buildBasicForm(); + InitializeFormCheck(); + + const field = document.getElementById('name'); + field.value = 'Alice'; + + const def = document.getElementById('def'); + const defClick = jest.fn(); + def.click = defClick; + + const submitBtn = document.getElementById('submit'); + submitBtn.click(); + + expect(defClick).toHaveBeenCalled(); + expect(submitBtn.textContent).toBe('Thanks!'); + }); + + test('success path without default submit calls form.submit', () => { + buildBasicForm({ includeDefaultSubmit: false }); + const form = document.getElementById('f'); + form.submit = jest.fn(); + + InitializeFormCheck(); + document.getElementById('name').value = 'Bob'; + document.getElementById('submit').click(); + + expect(form.submit).toHaveBeenCalled(); + }); + + test('clearError hides error and removes class on keypress/blur', () => { + buildBasicForm(); + InitializeFormCheck(); + + const field = document.getElementById('name'); + const err = document.getElementById('nameErr'); + + // Cause an error first + document.getElementById('submit').click(); + expect(err.style.display).toBe('block'); + expect(field.classList.contains('has-error')).toBe(true); + + // Trigger keypress to clear + field.dispatchEvent(new Event('keypress', { bubbles: true })); + expect(err.style.display).toBe('none'); + expect(field.classList.contains('has-error')).toBe(false); + }); + + test('reset clears errors, classes, and restores submit button text', () => { + buildBasicForm(); + InitializeFormCheck(); + + const field = document.getElementById('name'); + const err = document.getElementById('nameErr'); + const submitBtn = document.getElementById('submit'); + + // Cause an error and change label via success then reset + document.getElementById('submit').click(); // set error + field.value = 'Now valid'; + // Simulate success to change label + const def = document.getElementById('def'); + def.click = jest.fn(); + submitBtn.click(); + expect(submitBtn.textContent).toBe('Thanks!'); + + // Reset + document.getElementById('reset').click(); + expect(err.style.display).toBe('none'); + expect(field.classList.contains('has-error')).toBe(false); + expect(submitBtn.textContent).toBe('Send'); + }); + + test('type validations: email/number/tel/checkbox', () => { + document.body.innerHTML = ` +
+
+ +
email err
+
+
+ +
num err
+
+
+ +
tel err
+
+
+ +
cb err
+
+ +
+ `; + + const form = document.getElementById('f'); + form.submit = jest.fn(); + InitializeFormCheck(); + const clickSubmit = () => document.getElementById('submit').click(); + + // Invalid values + document.getElementById('em').value = 'not-an-email'; + document.getElementById('num').value = 'abc'; + document.getElementById('tel').value = 'xxx'; + document.getElementById('cb').checked = false; + clickSubmit(); + + expect(document.getElementById('emErr').style.display).toBe('block'); + expect(document.getElementById('numErr').style.display).toBe('block'); + expect(document.getElementById('telErr').style.display).toBe('block'); + expect(document.getElementById('cbErr').style.display).toBe('block'); + expect(form.submit).not.toHaveBeenCalled(); + + // Fix values + document.getElementById('em').value = 'a@b.com'; + document.getElementById('num').value = '42'; + document.getElementById('tel').value = '+1-202-555-0191'; + document.getElementById('cb').checked = true; + clickSubmit(); + expect(form.submit).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/FormatNumbers.test.js b/__tests__/FormatNumbers.test.js new file mode 100644 index 0000000..10c54af --- /dev/null +++ b/__tests__/FormatNumbers.test.js @@ -0,0 +1,95 @@ +/** @jest-environment jsdom */ + +// Prevent auto init on require +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +describe('NumberFormatter', () => { + let NumberFormatter, InitializeFormatNumbers; + + beforeEach(() => { + document.body.innerHTML = ''; + window.webtricks = []; + jest.resetModules(); + ({ NumberFormatter, InitializeFormatNumbers } = require('../Dist/Functional/FormatNumbers.js')); + }); + + test('formats plain decimal number with en-US locale default style=decimal', () => { + document.body.innerHTML = ` +
1234567.89
+ `; + InitializeFormatNumbers(); + const el = document.querySelector('[wt-formatnumber-element="number"]'); + // Expect grouping and decimal + expect(el.textContent).toBe('1,234,567.89'); + }); + + test('currency formatting applies symbol and grouping', () => { + document.body.innerHTML = ` +
9876.5
+ `; + InitializeFormatNumbers(); + const el = document.querySelector('[wt-formatnumber-element="number"]'); + // Currency formatting: "$9,876.50" (locale dependent but stable in jsdom Node) + expect(el.textContent).toMatch(/\$9,?876\.50/); + }); + + test('percent style formats number as percent', () => { + document.body.innerHTML = ` +
0.256
+ `; + InitializeFormatNumbers(); + const el = document.querySelector('[wt-formatnumber-element="number"]'); + expect(el.textContent).toBe('26%'); // typical rounding + }); + + test('unit style with provided unit', () => { + document.body.innerHTML = ` +
1500
+ `; + InitializeFormatNumbers(); + const el = document.querySelector('[wt-formatnumber-element="number"]'); + // Example output "1,500 km"; unit formatting may vary slightly, so assert contains km + expect(el.textContent).toMatch(/1,?500.*km/i); + }); + + test('invalid style causes graceful error logging and leaves value intact', () => { + const originalError = console.error; const errSpy = jest.fn(); console.error = errSpy; + document.body.innerHTML = ` +
1234
+ `; + InitializeFormatNumbers(); + const el = document.querySelector('[wt-formatnumber-element="number"]'); + // Should not format, error logged, text still original + expect(el.textContent).toBe('1234'); + expect(errSpy).toHaveBeenCalled(); + console.error = originalError; + }); + + test('invalid number value logs error and leaves content', () => { + const originalError = console.error; const errSpy = jest.fn(); console.error = errSpy; + document.body.innerHTML = ` +
abc
+ `; + InitializeFormatNumbers(); + const el = document.querySelector('[wt-formatnumber-element="number"]'); + expect(el.textContent).toBe('abc'); + expect(errSpy).toHaveBeenCalled(); + console.error = originalError; + }); + + test('multiple numbers produce multiple instances pushed to webtricks', () => { + document.body.innerHTML = ` +
1000
+
2000
+ `; + InitializeFormatNumbers(); + expect(window.webtricks.filter(e => e.FormatNumber).length).toBe(2); + }); + + test('no elements to format logs error but does not throw', () => { + const originalError = console.error; const errSpy = jest.fn(); console.error = errSpy; + InitializeFormatNumbers(); + expect(errSpy).toHaveBeenCalled(); + console.error = originalError; + }); +}); diff --git a/__tests__/Marquee.test.js b/__tests__/Marquee.test.js new file mode 100644 index 0000000..a65c0ca --- /dev/null +++ b/__tests__/Marquee.test.js @@ -0,0 +1,183 @@ +/** @jest-environment jsdom */ + +// Prevent auto-init on require +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +// requestAnimationFrame polyfill for jsdom +if (!global.requestAnimationFrame) { + global.requestAnimationFrame = (cb) => cb(); +} + +describe('Marquee', () => { + let Marquee, InitializeMarquee; + const origGetComputedStyle = window.getComputedStyle; + + beforeEach(() => { + document.body.innerHTML = ''; + window.webtricks = []; + jest.resetModules(); + // Default: no gap + window.getComputedStyle = jest.fn(() => ({ gap: '0' })); + ({ Marquee, InitializeMarquee } = require('../Dist/Functional/Marquee.js')); + jest.useFakeTimers(); + }); + + afterEach(() => { + // Proactively stop any running marquee intervals to avoid hanging timers + try { + if (Array.isArray(window.webtricks)) { + window.webtricks.forEach(entry => { + const inst = entry && (entry.Marquee || entry.marquee || entry.marqueeInstance); + if (inst && typeof inst.stopMarquee === 'function') { + inst.stopMarquee(); + } + }); + } + } catch {} + // Drain pending timers before switching back to real timers + try { jest.runOnlyPendingTimers(); } catch {} + window.getComputedStyle = origGetComputedStyle; + jest.useRealTimers(); + }); + + function setOffsetWidth(el, value) { + Object.defineProperty(el, 'offsetWidth', { value, configurable: true }); + } + function setOffsetHeight(el, value) { + Object.defineProperty(el, 'offsetHeight', { value, configurable: true }); + } + + test('InitializeMarquee sets styles, clones to fill, and pushes instance', () => { + document.body.innerHTML = ` +
+
+ A + B +
+
+ `; + const parent = document.getElementById('parent'); + const container = document.getElementById('mq'); + const [a, b] = container.children; + // Define dimensions + setOffsetWidth(parent, 400); + setOffsetWidth(container, 100); + setOffsetWidth(a, 50); + setOffsetWidth(b, 50); + + InitializeMarquee(); + + // One instance pushed + expect(window.webtricks.some(e => e.Marquee)).toBe(true); + // Styles applied + expect(container.style.display).toBe('flex'); + expect(container.style.flexDirection).toBe('row'); + + // Should clone at least once ( > initial 2 children ) + expect(container.children.length).toBeGreaterThan(2); + }); + + test('Left direction scrolls and cycles first element after threshold', () => { + document.body.innerHTML = ` +
+
+ A + B +
+
+ `; + const parent = document.getElementById('parent'); + const container = document.getElementById('mq'); + const itemA = container.children[0]; + const itemB = container.children[1]; + setOffsetWidth(parent, 200); + setOffsetWidth(container, 100); + setOffsetWidth(itemA, 50); + setOffsetWidth(itemB, 50); + + InitializeMarquee(); + + const instance = window.webtricks[0].Marquee; + + // One tick => transform -1px on X + jest.advanceTimersByTime(5); + expect(container.style.transform).toBe('translate3d(-1px, 0, 0)'); + + const firstBefore = container.firstElementChild; + + // After 51 ticks, first item should have cycled to end + jest.advanceTimersByTime(5 * 51); + const firstAfter = container.firstElementChild; + const lastAfter = container.lastElementChild; + + expect(firstAfter).not.toBe(firstBefore); + expect(lastAfter.textContent).toBe(firstBefore.textContent); + + // Keep ESLint/unused vars happy + expect(instance).toBeTruthy(); + }); + + test('Right direction scrolls and moves last before first quickly', () => { + document.body.innerHTML = ` +
+
+ A + B +
+
+ `; + const parent = document.getElementById('parent'); + const container = document.getElementById('mq'); + const itemA = container.children[0]; + const itemB = container.children[1]; + setOffsetWidth(parent, 200); + setOffsetWidth(container, 100); + setOffsetWidth(itemA, 50); + setOffsetWidth(itemB, 50); + + InitializeMarquee(); + + const firstBefore = container.firstElementChild; + // First tick: x becomes +1 + jest.advanceTimersByTime(5); + expect(container.style.transform).toBe('translate3d(1px, 0, 0)'); + + // Because of logic threshold, last should have moved before first + const firstAfter = container.firstElementChild; + expect(firstAfter.textContent).toBe('B'); + expect(firstAfter).not.toBe(firstBefore); + }); + + test('Resize increases parent size and triggers refill and restart', () => { + document.body.innerHTML = ` +
+
+ A + B +
+
+ `; + const parent = document.getElementById('parent'); + const container = document.getElementById('mq'); + const itemA = container.children[0]; + const itemB = container.children[1]; + setOffsetWidth(parent, 200); + setOffsetWidth(container, 100); + setOffsetWidth(itemA, 50); + setOffsetWidth(itemB, 50); + + InitializeMarquee(); + + const countBefore = container.children.length; + + // Increase parent size and dispatch resize + Object.defineProperty(parent, 'offsetWidth', { value: 600, configurable: true }); + window.dispatchEvent(new Event('resize')); + + // Let resize handler run start/clone + jest.advanceTimersByTime(5); + + const countAfter = container.children.length; + expect(countAfter).toBeGreaterThan(countBefore); + }); +}); diff --git a/__tests__/RangeSlider.test.js b/__tests__/RangeSlider.test.js new file mode 100644 index 0000000..9250eca --- /dev/null +++ b/__tests__/RangeSlider.test.js @@ -0,0 +1,123 @@ +/** @jest-environment jsdom */ + +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +describe('RangeSlider', () => { + let RangeSlider; + let InitializeRangeSlider; + + beforeEach(() => { + document.body.innerHTML = ''; + window.webtricks = []; + jest.resetModules(); + ({ RangeSlider, InitializeRangeSlider } = require('../Dist/Functional/RangeSlider.js')); + }); + + function mountRangeSlider() { + document.body.innerHTML = ` +
+
+
+
+
+ + +
+
+ `; + } + + test('constructor sets initial values from min/max', () => { + mountRangeSlider(); + const wrapper = document.querySelector( + '[wt-rangeslider-element="slider-wrapper"]', + ); + new RangeSlider(wrapper); + const left = wrapper.querySelector('[wt-rangeslider-element="input-left"]'); + const right = wrapper.querySelector('[wt-rangeslider-element="input-right"]'); + expect(left.value).toBe('0'); + expect(right.value).toBe('100'); + }); + + test('InitializeRangeSlider pushes instance to webtricks', () => { + mountRangeSlider(); + InitializeRangeSlider(); + expect(window.webtricks.some((e) => e.RangeSlider)).toBe(true); + }); + + describe('constraint numeric parsing (integers and decimals)', () => { + test('integer string handles setRange without truncation', () => { + mountRangeSlider(); + const wrapper = document.querySelector( + '[wt-rangeslider-element="slider-wrapper"]', + ); + const rs = new RangeSlider(wrapper); + rs.setRange('33', '77'); + const left = wrapper.querySelector( + '[wt-rangeslider-element="input-left"]', + ); + const right = wrapper.querySelector( + '[wt-rangeslider-element="input-right"]', + ); + expect(left.value).toBe('33'); + expect(right.value).toBe('77'); + }); + + test('decimal string handles keep fractional precision when constraining', () => { + document.body.innerHTML = ` +
+
+
+
+
+ + +
+
+ `; + const wrapper = document.querySelector( + '[wt-rangeslider-element="slider-wrapper"]', + ); + const rs = new RangeSlider(wrapper); + rs.setTo('2.5'); + rs.setFrom('2.35'); + const left = wrapper.querySelector( + '[wt-rangeslider-element="input-left"]', + ); + expect(left.value).toBe('2.3'); + }); + }); + + test('decimal mindifference constrains without parseInt truncation', () => { + document.body.innerHTML = ` +
+
+
+
+
+ + +
+
+ `; + const wrapper = document.querySelector( + '[wt-rangeslider-element="slider-wrapper"]', + ); + const rs = new RangeSlider(wrapper); + rs.setTo('5'); + rs.setFrom('4.9'); + const left = wrapper.querySelector('[wt-rangeslider-element="input-left"]'); + expect(left.value).toBe('4.7'); + }); +}); diff --git a/__tests__/RangeSliderSimple.test.js b/__tests__/RangeSliderSimple.test.js new file mode 100644 index 0000000..f20f244 --- /dev/null +++ b/__tests__/RangeSliderSimple.test.js @@ -0,0 +1,216 @@ +/** @jest-environment jsdom */ + +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +describe('RangeSliderSimple', () => { + let RangeSliderSimple; + let InitializeRangeSliderSimple; + + beforeEach(() => { + document.body.innerHTML = ''; + window.webtricks = []; + jest.resetModules(); + ({ + RangeSliderSimple, + InitializeRangeSliderSimple, + } = require('../Dist/Functional/RangeSliderSimple.js')); + }); + + function mountSlider() { + document.body.innerHTML = ` +
+
0
+
100
+
+ + +
+
+ `; + } + + test('constructor wires inputs and displays', () => { + mountSlider(); + const wrapper = document.querySelector( + '[wt-rangeslidersimple-element="slider-wrapper"]', + ); + const instance = new RangeSliderSimple(wrapper); + const left = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-left"]', + ); + const right = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-right"]', + ); + const displayFrom = wrapper.querySelector( + '[wt-rangeslidersimple-display="from"]', + ); + const displayTo = wrapper.querySelector( + '[wt-rangeslidersimple-display="to"]', + ); + expect(left.value).toBe('0'); + expect(right.value).toBe('100'); + expect(left.min).toBe('0'); + expect(left.max).toBe('100'); + expect(left.step).toBe('1'); + expect(displayFrom.textContent).toBe('0'); + expect(displayTo.textContent).toBe('100'); + expect(instance.sliderMin).toBe(0); + expect(instance.sliderMax).toBe(100); + }); + + test('InitializeRangeSliderSimple pushes instance to webtricks', () => { + mountSlider(); + InitializeRangeSliderSimple(); + expect(window.webtricks.some((e) => e.RangeSliderSimple)).toBe(true); + }); + + describe('constraint numeric parsing (supports integers and decimals, not parseInt truncation)', () => { + test('integer string handles setRange without truncation', () => { + mountSlider(); + const wrapper = document.querySelector( + '[wt-rangeslidersimple-element="slider-wrapper"]', + ); + const instance = new RangeSliderSimple(wrapper); + const left = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-left"]', + ); + const right = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-right"]', + ); + + instance.setRange('33', '77'); + + expect(left.value).toBe('33'); + expect(right.value).toBe('77'); + }); + + test('decimal string handles keep fractional precision when constraining', () => { + document.body.innerHTML = ` +
+
+ + +
+
+ `; + const wrapper = document.querySelector( + '[wt-rangeslidersimple-element="slider-wrapper"]', + ); + const instance = new RangeSliderSimple(wrapper); + + instance.setTo('2.5'); + instance.setFrom('2.35'); + + // min(2.35, 2.5 - 0.2) = 2.3 — parseInt would wrongly use min(2, 2.3) = 2 + expect( + wrapper.querySelector( + '[wt-rangeslidersimple-element="input-left"]', + ).value, + ).toBe('2.3'); + expect( + wrapper.querySelector( + '[wt-rangeslidersimple-element="input-right"]', + ).value, + ).toBe('2.5'); + }); + }); + + test('integer step and minDifference still constrain (Number handles whole numbers)', () => { + mountSlider(); + const wrapper = document.querySelector( + '[wt-rangeslidersimple-element="slider-wrapper"]', + ); + const instance = new RangeSliderSimple(wrapper); + const left = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-left"]', + ); + const right = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-right"]', + ); + + instance.setTo('20'); + instance.setFrom('25'); + + // min(25, 20 - 1) = 19 with default mindifference === steps === 1 + expect(left.value).toBe('19'); + expect(right.value).toBe('20'); + }); + + test('decimal minDifference constrains with floats, not parseInt truncation', () => { + document.body.innerHTML = ` +
+
+ + +
+
+ `; + const wrapper = document.querySelector( + '[wt-rangeslidersimple-element="slider-wrapper"]', + ); + const instance = new RangeSliderSimple(wrapper); + + instance.setTo('5'); + instance.setFrom('4.9'); + + // min(4.9, 5 - 0.3) = 4.7 — parseInt would wrongly yield 4 + expect( + wrapper.querySelector('[wt-rangeslidersimple-element="input-left"]') + .value, + ).toBe('4.7'); + expect( + wrapper.querySelector('[wt-rangeslidersimple-element="input-right"]') + .value, + ).toBe('5'); + }); + + test('constrainRightValue keeps at least minDifference above left handle', () => { + mountSlider(); + const wrapper = document.querySelector( + '[wt-rangeslidersimple-element="slider-wrapper"]', + ); + const instance = new RangeSliderSimple(wrapper); + const left = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-left"]', + ); + const right = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-right"]', + ); + + instance.setFrom('60'); + instance.setTo('30'); + + // max(30, 60 + 1) = 61 with mindifference === 1 + expect(left.value).toBe('60'); + expect(right.value).toBe('61'); + }); + + test('reset restores min and max on both inputs', () => { + mountSlider(); + const wrapper = document.querySelector( + '[wt-rangeslidersimple-element="slider-wrapper"]', + ); + const instance = new RangeSliderSimple(wrapper); + instance.setRange('40', '50'); + instance.reset(); + const left = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-left"]', + ); + const right = wrapper.querySelector( + '[wt-rangeslidersimple-element="input-right"]', + ); + expect(left.value).toBe('0'); + expect(right.value).toBe('100'); + }); +}); diff --git a/__tests__/ReadTime.test.js b/__tests__/ReadTime.test.js new file mode 100644 index 0000000..006679b --- /dev/null +++ b/__tests__/ReadTime.test.js @@ -0,0 +1,82 @@ +/** @jest-environment jsdom */ + +// Prevent auto-init on require +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +// jsdom innerText polyfill +if (!('innerText' in document.createElement('div'))) { + Object.defineProperty(HTMLElement.prototype, 'innerText', { + get() { return this.textContent; }, + set(v) { this.textContent = v; } + }); +} + +describe('ReadTime', () => { + let ReadTime, InitializeReadTime; + + beforeEach(() => { + document.body.innerHTML = ''; + window.webtricks = []; + jest.resetModules(); + ({ ReadTime, InitializeReadTime } = require('../Dist/Functional/ReadTime.js')); + }); + + test('less than a minute uses default when no smallsuffix', () => { + document.body.innerHTML = ` +
one two three four five six seven eight nine ten
+ + `; + + InitializeReadTime(); + + expect(window.webtricks.some(e => e.ReadTime)).toBe(true); + const d1 = document.getElementById('d1'); + expect(d1.textContent).toBe('less than a minute.'); + }); + + test('less than a minute uses provided smallsuffix', () => { + document.body.innerHTML = ` +
one two three
+ + + `; + + InitializeReadTime(); + + expect(document.getElementById('d1').textContent).toBe('<1 min'); + expect(document.getElementById('d2').textContent).toBe('<1 min'); + }); + + test('exactly one minute renders "a minute."', () => { + document.body.innerHTML = ` +
one two three four five
+ + `; + + InitializeReadTime(); + + expect(document.getElementById('d1').textContent).toBe('a minute.'); + }); + + test('more than one minute uses ceil(rawTime) with suffix when provided', () => { + document.body.innerHTML = ` +
one two three four five six
+ + `; + + InitializeReadTime(); + + expect(document.getElementById('d1').textContent).toBe('2 min read'); + }); + + test('no articles found does not throw and does not push instances', () => { + document.body.innerHTML = ` + + `; + + InitializeReadTime(); + + expect(Array.isArray(window.webtricks)).toBe(true); + expect(window.webtricks.length).toBe(0); + }); +}); diff --git a/__tests__/ShareLink.test.js b/__tests__/ShareLink.test.js new file mode 100644 index 0000000..a5072d3 --- /dev/null +++ b/__tests__/ShareLink.test.js @@ -0,0 +1,154 @@ +/** @jest-environment jsdom */ + +// Ensure the module doesn't auto-initialize on require +Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + +describe('ShareLink', () => { + let ShareLink, InitializeShareLink; + + beforeEach(() => { + // Clean DOM + document.body.innerHTML = ''; + // Reset globals + window.webtricks = []; + // Freshly require module each test to re-evaluate top-level + jest.resetModules(); + ({ ShareLink, InitializeShareLink } = require('../Dist/Functional/ShareLink.js')); + }); + + test('InitializeShareLink wires up instance and pushes to webtricks', () => { + document.body.innerHTML = ` +
+ Copy Link +
+
OK
+ `; + + // Mock clipboard + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + global.navigator.clipboard = { writeText: jest.fn().mockResolvedValue() }; + + InitializeShareLink(); + + const el = document.getElementById('copyBtn'); + expect(el._shareLinkInstance).toBeTruthy(); + expect(Array.isArray(window.webtricks)).toBe(true); + expect(window.webtricks.some(e => e.ShareLink)).toBe(true); + }); + + test('copy success shows template HTML, dispatches event, and restores after timeout', async () => { + jest.useFakeTimers(); + + document.body.innerHTML = ` + + Copy + +
OK
+ `; + + // Clipboard success path + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + const writeText = jest.fn().mockResolvedValue(); + global.navigator.clipboard = { writeText }; + + InitializeShareLink(); + + const el = document.getElementById('copyBtn'); + + // Listen for the custom event + const eventSpy = jest.fn(); + el.addEventListener('sharelink:copy', eventSpy); + + // Click to copy + el.click(); + + // Wait for async clipboard write to resolve + await Promise.resolve(); + + // Wrote to clipboard + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(window.location.href); + + // Success class applied and template HTML shown + expect(el.classList.contains('copied-class')).toBe(true); + expect(el.innerHTML).toContain('class="ok"'); + + // Event dispatched with success detail + expect(eventSpy).toHaveBeenCalledTimes(1); + const evt = eventSpy.mock.calls[0][0]; + expect(evt.detail).toMatchObject({ success: true, platform: 'copy' }); + + // After timeout, restore original + jest.advanceTimersByTime(25); + expect(el.innerHTML).toContain('class="original"'); + expect(el.classList.contains('copied-class')).toBe(false); + + jest.useRealTimers(); + }); + + test('uses per-element template key when wt-share-copytemplate is provided', async () => { + jest.useFakeTimers(); + + document.body.innerHTML = ` + +
DEFAULT
+
ALT
+ `; + + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + const writeText = jest.fn().mockResolvedValue(); + global.navigator.clipboard = { writeText }; + + InitializeShareLink(); + + const el = document.getElementById('copyBtn'); + el.click(); + await Promise.resolve(); + + expect(writeText).toHaveBeenCalled(); + expect(el.innerHTML).toContain('class="alt"'); + + jest.advanceTimersByTime(10); + jest.useRealTimers(); + }); + + test('fallback uses execCommand when clipboard is unavailable', () => { + jest.useFakeTimers(); + + document.body.innerHTML = ` + +
OK
+ `; + + // No secure context / no clipboard API + Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true }); + delete global.navigator.clipboard; + + // Mock execCommand (jsdom doesn't implement it by default) + document.execCommand = jest.fn(() => true); + + InitializeShareLink(); + + const el = document.getElementById('copyBtn'); + + // Listen for the custom event + const eventSpy = jest.fn(); + el.addEventListener('sharelink:copy', eventSpy); + + el.click(); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + // Event dispatched + expect(eventSpy).toHaveBeenCalledTimes(1); + expect(eventSpy.mock.calls[0][0].detail.success).toBe(true); + + // Restore + jest.advanceTimersByTime(10); + + // Cleanup + delete document.execCommand; + jest.useRealTimers(); + }); +}); diff --git a/docs/Functional/CookieConsent.md b/docs/Functional/CookieConsent.md index 81aa7d1..b7c57df 100644 --- a/docs/Functional/CookieConsent.md +++ b/docs/Functional/CookieConsent.md @@ -1,12 +1,15 @@ # CookieConsent ## Version + Current Version: 1.0.0 ## Description + CookieConsent is a GDPR-compliant cookie consent management system that provides granular control over cookie preferences and script loading. It supports multiple consent categories, manages consent persistence, and controls script loading based on user preferences. ## Functionality + - Cookie consent banner management - Multiple consent categories - Granular script loading control @@ -17,18 +20,22 @@ CookieConsent is a GDPR-compliant cookie consent management system that provides - Category-based script loading ## Usage + Add the script to your project and include the required attributes on your banner and script elements. ### Installation + ```html ``` ### Required Attributes + - `wt-cookieconsent-element="banner"` - Applied to the main cookie banner container - `wt-cookieconsent-script="category"` - Applied to scripts that require consent ### Optional Elements and Attributes + - `wt-cookieconsent-element="accept-all"` - Accept all cookies button - `wt-cookieconsent-element="accept-necessary"` - Accept necessary cookies only button - `wt-cookieconsent-element="manage-cookies"` - Manage cookies settings button @@ -36,6 +43,7 @@ Add the script to your project and include the required attributes on your banne - `wt-cookieconsent-category="category-name"` - Category checkbox inputs ## Considerations + 1. **GDPR Compliance**: Supports granular consent management 2. **Script Loading**: Automatically handles script loading based on consent 3. **Persistence**: Stores consent in cookies with configurable expiry @@ -45,6 +53,7 @@ Add the script to your project and include the required attributes on your banne ## Examples ### Basic Implementation + ```html
@@ -59,6 +68,7 @@ Add the script to your project and include the required attributes on your banne ``` ### Advanced Implementation with Categories + ```html

Choose your cookie preferences

@@ -102,6 +112,7 @@ Add the script to your project and include the required attributes on your banne ``` ### Inline Script Implementation + ```html ``` ### Required Attributes + - `wt-copycb-element="container"` - Applied to the container element - `wt-copycb-element="trigger"` - Applied to the click trigger element - `wt-copycb-element="target"` - Applied to the element containing text to copy ### Optional Attributes + - `wt-copycb-message="Copied!"` - Custom success message - `wt-copycb-active="is-copy"` - CSS class for active state - `wt-copycb-timeout="2000"` - Duration to show success state (ms) - `wt-copycb-element="texttarget"` - Element within trigger to update with success message ## Considerations + 1. **Clipboard API**: Uses modern navigator.clipboard API 2. **State Management**: Automatically resets to original state 3. **Visual Feedback**: Supports both text and class-based feedback @@ -44,6 +52,7 @@ Add the script to your project and include the required attributes on your copy ## Examples ### Basic Implementation + ```html
@@ -52,6 +61,7 @@ Add the script to your project and include the required attributes on your copy ``` ### Advanced Implementation + ```html