diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b09bad6..006bbcc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,14 +32,34 @@ jobs: run: npx jest --runInBand --coverage --coverageReporters=text-summary --coverageReporters=lcov --coverageReporters=json-summary - name: Upload coverage artifact - if: always() + 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() + if: always() && hashFiles('coverage/coverage-summary.json') != '' shell: bash run: | - node -e "const fs=require('fs');const p='coverage/coverage-summary.json';if(fs.existsSync(p)){const s=JSON.parse(fs.readFileSync(p,'utf8'));const t=(o)=>o.total;const c=t(s);const md=\`## Coverage summary\\n\\n- Statements: \${c.statements.pct}% (\${c.statements.covered}/\${c.statements.total})\\n- Branches: \${c.branches.pct}% (\${c.branches.covered}/\${c.branches.total})\\n- Functions: \${c.functions.pct}% (\${c.functions.covered}/\${c.functions.total})\\n- Lines: \${c.lines.pct}% (\${c.lines.covered}/\${c.lines.total})\\n\`;fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);}else{console.log('coverage-summary.json not found')}}" + 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/.gitignore b/.gitignore index 417c6ce..dac9c29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ -.vscode/ \ No newline at end of file +.vscode/ +coverage/ \ No newline at end of file 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/WebflowOnly/CMSFilter.js b/Dist/WebflowOnly/CMSFilter.js index 03f0f1f..fcb261e 100644 --- a/Dist/WebflowOnly/CMSFilter.js +++ b/Dist/WebflowOnly/CMSFilter.js @@ -652,7 +652,8 @@ class CMSFilter { ShowResultCount() { if (!this.resultCount) return; - this.resultCount.innerText = this.GetResults(); + // textContent so updates are visible to tests and matches non-layout engines (e.g. jsdom) + this.resultCount.textContent = String(this.GetResults()); } GetFilters() { diff --git a/Dist/WebflowOnly/CMSSelect.js b/Dist/WebflowOnly/CMSSelect.js index 20b10b3..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); } diff --git a/README.md b/README.md index 582f885..2e2a8ce 100644 --- a/README.md +++ b/README.md @@ -78,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. 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/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