diff --git a/__tests__/dxb-slider.test.js b/__tests__/dxb-slider.test.js index 9515e6c..3bc4740 100644 --- a/__tests__/dxb-slider.test.js +++ b/__tests__/dxb-slider.test.js @@ -49,10 +49,17 @@ describe('DXB Slider Core Tests', () => { expect(numberInput.value).toBe('75'); }); + it('should synchronize range and number input values (0 based)', () => { + slider.value = 0; + slider.dispatchEvent(new window.Event('input')); + expect(numberInput.value).toBe('0'); + }); + it('should initialize dynamically added sliders', async () => { const newSlider = document.createElement('input'); newSlider.type = 'range'; newSlider.setAttribute('data-dxb-slider', ''); + newSlider.id = "myNewSlider"; // Append the new slider to the DOM document.body.appendChild(newSlider); @@ -63,21 +70,34 @@ describe('DXB Slider Core Tests', () => { expect(newSlider.hasAttribute('data-dxb-initialized')).toBe(true); }); - it('should dispatch change event on number input change', () => { - const changeHandler = vi.fn(); - - slider.addEventListener('change', changeHandler); + it('should synchronize values on number input change', () => { numberInput.value = 80; - numberInput.dispatchEvent(new window.Event('change')); + numberInput.dispatchEvent(new window.Event('input')); - expect(changeHandler).toHaveBeenCalled(); + expect(slider.value).toBe('80'); }); - it('should synchronize values on number input change', () => { - numberInput.value = 80; + it('should clamp value to max when number input exceeds max limit', () => { + numberInput.max = 100; + numberInput.value = 1000; numberInput.dispatchEvent(new window.Event('input')); - expect(slider.value).toBe('80'); + expect(numberInput.value).toBe('100'); + }); + + + it('should synchronize values on number input change (0 based)', () => { + numberInput.value = ""; + numberInput.dispatchEvent(new window.Event('input')); + + expect(slider.value).toBe('0'); + }); + + it('should synchronize values on number input change (empty)', () => { + numberInput.value = ""; + numberInput.dispatchEvent(new window.Event('input')); + + expect(numberInput.value).toBe(''); }); it('should set initial ARIA attributes', () => { @@ -93,6 +113,154 @@ describe('DXB Slider Core Tests', () => { }); }); +describe('DXB Slider Negative Value Tests', () => { + let document; + let window; + let slider; + let numberInput; + + beforeEach(() => { + const scriptContent = fs.readFileSync(path.resolve(__dirname, '../dxb-slider.js'), 'utf8'); + + const dom = new JSDOM(` + +
+ + + + + + `, { runScripts: "dangerously", resources: "usable" }); + + document = dom.window.document; + window = dom.window; + global.document = document; + global.window = window; + + slider = document.querySelector('#negativeSlider'); + numberInput = document.querySelector('.dxb-slider-value'); + }); + + it('should initialize the slider with negative min value', () => { + expect(slider.min).toBe('-50'); + expect(slider.max).toBe('50'); + expect(slider.value).toBe('-25'); + }); + + it('should synchronize values between slider and number input (negative values)', () => { + slider.value = -40; + slider.dispatchEvent(new window.Event('input')); + expect(numberInput.value).toBe('-40'); + }); + + it('should synchronize values when number input is updated with a negative value', () => { + numberInput.value = -10; + numberInput.dispatchEvent(new window.Event('input')); + expect(slider.value).toBe('-10'); + }); + + it('should clamp values to min when below min limit', () => { + numberInput.value = -100; + numberInput.dispatchEvent(new window.Event('input')); + expect(numberInput.value).toBe('-50'); + expect(slider.value).toBe('-50'); + }); + + it('should allow entering "-" without immediately parsing', () => { + + // Since JSDOM does not allow incomplete number input states (like "-"), we have to mock it manually + Object.defineProperty(numberInput, "value", { + get: () => "-", + set: () => { }, // Prevents JSDOM from resetting it + configurable: true + }); + + numberInput.dispatchEvent(new window.InputEvent("input", { data: "-" })); + + expect(numberInput.value).toBe("-"); + }); + +}); + +describe('DXB Slider Floating Point Tests', () => { + let document; + let window; + let slider; + let numberInput; + + beforeEach(() => { + const scriptContent = fs.readFileSync(path.resolve(__dirname, '../dxb-slider.js'), 'utf8'); + + const dom = new JSDOM(` + + + + + + + + `, { runScripts: "dangerously", resources: "usable" }); + + document = dom.window.document; + window = dom.window; + global.document = document; + global.window = window; + + slider = document.querySelector('#mySlider'); + numberInput = document.querySelector('.dxb-slider-value'); + }); + + it('should allow entering "." without immediate parsing', () => { + // Prevent JSDOM from resetting an incomplete decimal state + Object.defineProperty(numberInput, "value", { + get: () => "5.", + set: () => { }, + configurable: true + }); + + numberInput.dispatchEvent(new window.InputEvent("input", { data: "." })); + + expect(numberInput.value).toBe("5."); // Allow incomplete decimal state + }); + + it('should synchronize range and number input values with floating points', () => { + slider.value = "7.3"; + slider.dispatchEvent(new window.Event('input')); + expect(numberInput.value).toBe("7.3"); + }); + + it('should clamp values to max boundary for floating points', () => { + numberInput.value = "20.3"; // Exceeding max + numberInput.dispatchEvent(new window.Event('input')); + expect(numberInput.value).toBe("10.5"); // Clamped to max + }); + + it('should clamp values to min boundary for floating points', () => { + numberInput.value = "-5.0"; // Below min + numberInput.dispatchEvent(new window.Event('input')); + expect(numberInput.value).toBe("0.1"); // Clamped to min + }); + + it('should retain correct floating point precision when stepping up', () => { + numberInput.value = "2.2"; + numberInput.stepUp(); + numberInput.dispatchEvent(new window.Event('input')); + expect(numberInput.value).toBe("2.3"); // Step increment of 0.1 + }); + + it('should retain correct floating point precision when stepping down', () => { + numberInput.value = "3.5"; + numberInput.stepDown(); + numberInput.dispatchEvent(new window.Event('input')); + expect(numberInput.value).toBe("3.4"); // Step decrement of 0.1 + }); +}); + + describe('DXB Slider Step Tests', () => { let document; let window; diff --git a/dxb-slider.js b/dxb-slider.js index 93313b8..57ad3d4 100644 --- a/dxb-slider.js +++ b/dxb-slider.js @@ -1,33 +1,128 @@ // dxb-slider.js -(function() { +(function () { function createSliderStructure(rangeInput) { + // Create container and wrapper const container = document.createElement('div'); container.className = 'dxb-slider-container'; - + const wrapper = document.createElement('div'); wrapper.className = 'dxb-slider-wrapper'; - + const track = document.createElement('div'); track.className = 'dxb-slider-track'; - + // Add slider class rangeInput.classList.add('dxb-slider'); - + // Restructure DOM rangeInput.parentNode.insertBefore(container, rangeInput); container.appendChild(wrapper); wrapper.appendChild(track); track.appendChild(rangeInput); - + return wrapper; } + function getRangePercent(value = 0, min = 0, max = 0) { + return ((value - min) / (max - min)) * 100; + } + + function roundToPrecision(value, step) { + const precision = (step.toString().split(".")[1] || "").length; // Get decimal places in step + return parseFloat(value.toFixed(precision)); // Ensure correct precision + } + + function updateFieldValue(field, value) { + + if (field.type === "number") { + + // Allow the input to be empty + if (value === "") { + field.value = value; + return; + } + + // Parse min, max, and step values from the field attributes + const min = parseFloat(field.min); + const max = parseFloat(field.max); + const step = parseFloat(field.step) || 1; // Default step to 1 if not specified + const currentValue = parseFloat(value); + + // Ensure the value stays within the min/max range + const val = Math.max(min, Math.min(currentValue, max)); + + // Round the value to the nearest step increment + const roundedValue = Math.round((val - min) / step) * step + min; + + // Ensure precision is maintained when updating the field value + field.value = roundToPrecision(roundedValue, field.step); + } + + if (field.type === "range") { + const val = Number(value) || 0; + field.value = val; + + // Apply additional settings for range inputs + const percent = getRangePercent(field.value, field.min, field.max); + field.style.setProperty('--value-percent', `${percent}%`); + field.setAttribute('aria-valuenow', field.value); + } + } + + // Proxy object to synchronize field values and update all matching fields when a value changes + const sliderStateProxy = new Proxy({}, { + set(target, key, value) { + + // Guard: If no valid input is found + const validInput = document.getElementById(key); + if (!validInput) { + return; + } + + // Convert input value to a number + const newValue = Number(value); + const max = validInput.max !== "" ? Number(validInput.max) : null; + const min = validInput.min !== "" ? Number(validInput.min) : null; + + // Reset the input value if it exceeds the maximum allowed value + if (max !== null && newValue > max) { + value = max; + } + + // Reset the input value if it falls below the minimum allowed value + if (min !== null && newValue < min) { + // Allow emptiness when clearing + value = value === "" ? "" : min; + } + + target[key] = value; + + const rangeInputWrapper = validInput.closest(".dxb-slider-wrapper"); + const fields = rangeInputWrapper.querySelectorAll('input'); + + fields.forEach(field => updateFieldValue(field, value)); + return true; + } + }); + function initDXBSliders() { document.querySelectorAll('[data-dxb-slider]:not([data-dxb-initialized])').forEach(rangeInput => { + + // Guard: Ensure the range input has a valid "id" + // Missing an "id" breaks slider functionality and can cause errors or infinite loops in the MutationObserver. + if (!rangeInput.id) { + console.error( + `DXB Slider Error: A range input is missing a required "id" attribute. Initialization skipped. + Element details: ${rangeInput.outerHTML}` + ); + + return; + } + const wrapper = createSliderStructure(rangeInput); - + // Create number input programmatically const numberInput = document.createElement('input'); numberInput.type = 'number'; @@ -45,34 +140,57 @@ wrapper.appendChild(numberInput); - function updateValue() { - const val = rangeInput.value; - const min = rangeInput.min; - const max = rangeInput.max; - const percent = (val - min) / (max - min) * 100; - rangeInput.style.setProperty('--value-percent', `${percent}%`); - numberInput.value = val; - numberInput.min = min; - numberInput.max = max; - rangeInput.setAttribute('aria-valuenow', val); + + function handleInputChange(e) { + + // Only update fields within the DXB slider container's scope + if (!e.target.closest('.dxb-slider-container')) { + return; + } + + let proxyKey = e.target.id; + + if (e.target.type === "number") { + const rangeInputWrapper = e.target.closest(".dxb-slider-wrapper"); + proxyKey = rangeInputWrapper.querySelector("input").id; + } + + const eventValue = e.data; // Capture only the newly entered character from the event + + // Allow negative sign (-) or decimal point (.) to be temporarily entered + // This prevents premature validation while the user is still typing + if (eventValue === "-" || eventValue === ".") { + return; + } + + sliderStateProxy[proxyKey] = e.target.value; } - rangeInput.addEventListener('input', updateValue); - numberInput.addEventListener('input', () => { - rangeInput.value = numberInput.value; - updateValue(); - rangeInput.dispatchEvent(new Event('input', { bubbles: true })); - }); + rangeInput.addEventListener('input', handleInputChange); + + numberInput.addEventListener('input', handleInputChange); - numberInput.addEventListener('change', () => { - rangeInput.dispatchEvent(new Event('change', { bubbles: true })); - }); + // Initialize the proxy with the initial value of the range input + sliderStateProxy[rangeInput.id] = rangeInput.value; // Set initial ARIA attributes rangeInput.setAttribute('aria-valuemin', rangeInput.min); rangeInput.setAttribute('aria-valuemax', rangeInput.max); - updateValue(); + // Populate inputs in first initiation + const value = rangeInput.value; + const min = rangeInput.min; + const max = rangeInput.max; + + const percent = getRangePercent(value, min, max); + rangeInput.style.setProperty('--value-percent', `${percent}%`); + + numberInput.value = value; + numberInput.min = min; + numberInput.max = max; + numberInput.name = rangeInput.name; + + rangeInput.setAttribute('aria-valuenow', value); // Mark as initialized rangeInput.setAttribute('data-dxb-initialized', 'true'); @@ -86,8 +204,8 @@ for (const mutation of mutations) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { - if (node.nodeType === Node.ELEMENT_NODE && - (node.matches('[data-dxb-slider]') || node.querySelector('[data-dxb-slider]'))) { + if (node.nodeType === Node.ELEMENT_NODE && + (node.matches('[data-dxb-slider]') || node.querySelector('[data-dxb-slider]'))) { shouldInit = true; break; } @@ -101,4 +219,4 @@ }); observer.observe(document.body, { childList: true, subtree: true }); -})(); \ No newline at end of file +})(); diff --git a/dxb-slider.min.css b/dxb-slider.min.css index f5d52d8..d3aae3c 100644 --- a/dxb-slider.min.css +++ b/dxb-slider.min.css @@ -1 +1 @@ -.dxb-slider-container{display:flex;flex-direction:column;align-items:flex-start;width:100%;margin-bottom:20px}.dxb-slider-container+label{margin-bottom:10px;font-weight:700;font-size:1.2em}.dxb-slider-wrapper{display:flex;align-items:center;width:100%}.dxb-slider-track{flex:1}.dxb-slider{-webkit-appearance:none;width:calc(100% - 20px);height:5px;background:transparent;outline:none}.dxb-slider::-webkit-slider-runnable-track{height:5px;background-image:linear-gradient(#0550e6,#0550e6),linear-gradient(#dadfe7,#dadfe7);background-size:var(--value-percent) 5px,100% 5px;background-repeat:no-repeat;background-position:0}.dxb-slider::-moz-range-track{height:5px;background-image:linear-gradient(#0550e6,#0550e6),linear-gradient(#dadfe7,#dadfe7);background-size:var(--value-percent) 5px,100% 5px;background-repeat:no-repeat;background-position:0}.dxb-slider::-webkit-slider-thumb{appearance:none;width:20px;height:20px;background:#fff;cursor:pointer;border:none;outline:none;box-shadow:0 .0625rem .125rem rgba(16,24,40,.06),0 .0625rem .1875rem rgba(16,24,40,.1);margin-top:-8px;border-radius:0;transition:background .3s,transform .3s}.dxb-slider::-moz-range-thumb{width:20px;height:20px;background:#fff;cursor:pointer;border:none;outline:none;box-shadow:0 .0625rem .125rem rgba(16,24,40,.06),0 .0625rem .1875rem rgba(16,24,40,.1);margin-top:-8px;border-radius:0;transition:background .3s,transform .3s}.dxb-slider:hover::-moz-range-thumb,.dxb-slider:hover::-webkit-slider-thumb{background:#e0e0e0;transform:scale(1.1)}.dxb-slider:focus::-moz-range-thumb,.dxb-slider:focus::-webkit-slider-thumb{background:silver;transform:scale(1.1);box-shadow:0 0 0 3px rgba(0,123,255,.25)}.dxb-slider-value{width:60px;text-align:center;margin-inline-start:10px;border:1px solid #ddd;border-radius:0;height:24px;font-size:1em;box-shadow:0 .0625rem .125rem rgba(16,24,40,.06),0 .0625rem .1875rem rgba(16,24,40,.1)}[dir=rtl] .dxb-slider-wrapper{flex-direction:row-reverse}[dir=rtl] .dxb-slider{direction:rtl}[dir=rtl] .dxb-slider::-webkit-slider-runnable-track{background-position:100%}[dir=rtl] .dxb-slider::-moz-range-track{background-position:100%}[dir=rtl] .dxb-slider-value{margin-inline-start:10px;margin-inline-end:0;order:-1}[lang=ar] .dxb-slider-value{font-variant-numeric:arabic-indic}[lang=fa] .dxb-slider-value{font-variant-numeric:persian}[lang=bn] .dxb-slider-value{font-variant-numeric:bengali}[lang=hi],[lang=mr],[lang=ne] .dxb-slider-value{font-variant-numeric:devanagari} \ No newline at end of file +.dxb-slider-container{display:flex;flex-direction:column;align-items:flex-start;width:100%;margin-bottom:20px}.dxb-slider-wrapper{display:flex;align-items:center;width:100%}.dxb-slider-track{flex:1}.dxb-slider{-webkit-appearance:none;width:calc(100% - 20px);height:5px;background:transparent;outline:none}.dxb-slider::-webkit-slider-runnable-track{height:5px;background-image:linear-gradient(#0550e6,#0550e6),linear-gradient(#dadfe7,#dadfe7);background-size:var(--value-percent) 5px,100% 5px;background-repeat:no-repeat;background-position:0}.dxb-slider::-moz-range-track{height:5px;background-image:linear-gradient(#0550e6,#0550e6),linear-gradient(#dadfe7,#dadfe7);background-size:var(--value-percent) 5px,100% 5px;background-repeat:no-repeat;background-position:0}.dxb-slider::-webkit-slider-thumb{appearance:none;width:20px;height:20px;background:#fff;cursor:pointer;border:none;outline:none;box-shadow:0 .0625rem .125rem rgba(16,24,40,.06),0 .0625rem .1875rem rgba(16,24,40,.1);margin-top:-8px;border-radius:0;transition:background .3s,transform .3s}.dxb-slider::-moz-range-thumb{width:20px;height:20px;background:#fff;cursor:pointer;border:none;outline:none;box-shadow:0 .0625rem .125rem rgba(16,24,40,.06),0 .0625rem .1875rem rgba(16,24,40,.1);margin-top:-8px;border-radius:0;transition:background .3s,transform .3s}.dxb-slider:hover::-moz-range-thumb,.dxb-slider:hover::-webkit-slider-thumb{background:#e0e0e0;transform:scale(1.1)}.dxb-slider:focus::-moz-range-thumb,.dxb-slider:focus::-webkit-slider-thumb{background:silver;transform:scale(1.1);box-shadow:0 0 0 3px rgba(0,123,255,.25)}.dxb-slider-value{width:60px;text-align:center;margin-inline-start:10px;border:1px solid #ddd;border-radius:0;height:24px;font-size:1em;box-shadow:0 .0625rem .125rem rgba(16,24,40,.06),0 .0625rem .1875rem rgba(16,24,40,.1)}[dir=rtl] .dxb-slider-wrapper{flex-direction:row-reverse}[dir=rtl] .dxb-slider{direction:rtl}[dir=rtl] .dxb-slider::-webkit-slider-runnable-track{background-position:100%}[dir=rtl] .dxb-slider::-moz-range-track{background-position:100%}[dir=rtl] .dxb-slider-value{margin-inline-start:10px;margin-inline-end:0;order:-1}[lang=ar] .dxb-slider-value{font-variant-numeric:arabic-indic}[lang=fa] .dxb-slider-value{font-variant-numeric:persian}[lang=bn] .dxb-slider-value{font-variant-numeric:bengali}[lang=hi],[lang=mr],[lang=ne] .dxb-slider-value{font-variant-numeric:devanagari} \ No newline at end of file diff --git a/dxb-slider.min.js b/dxb-slider.min.js index 19bf47e..cd4b213 100644 --- a/dxb-slider.min.js +++ b/dxb-slider.min.js @@ -1 +1,2 @@ -(()=>{function i(){document.querySelectorAll("[data-dxb-slider]:not([data-dxb-initialized])").forEach(d=>{e=d,(a=document.createElement("div")).className="dxb-slider-container",(t=document.createElement("div")).className="dxb-slider-wrapper",(r=document.createElement("div")).className="dxb-slider-track",e.classList.add("dxb-slider"),e.parentNode.insertBefore(a,e),a.appendChild(t),t.appendChild(r),r.appendChild(e);var e,t,a=t;let i=document.createElement("input");i.type="number",i.className="dxb-slider-value",i.setAttribute("tabindex","-1"),i.setAttribute("pattern","[0-9]*"),i.setAttribute("step",d.step);var r=parseFloat(d.step);function n(){var e=d.value,t=d.min,a=d.max;d.style.setProperty("--value-percent",(e-t)/(a-t)*100+"%"),i.value=e,i.min=t,i.max=a,d.setAttribute("aria-valuenow",e)}r&&r%1!=0?i.setAttribute("inputmode","decimal"):i.setAttribute("inputmode","numeric"),a.appendChild(i),d.addEventListener("input",n),i.addEventListener("input",()=>{d.value=i.value,n(),d.dispatchEvent(new Event("input",{bubbles:!0}))}),i.addEventListener("change",()=>{d.dispatchEvent(new Event("change",{bubbles:!0}))}),d.setAttribute("aria-valuemin",d.min),d.setAttribute("aria-valuemax",d.max),n(),d.setAttribute("data-dxb-initialized","true")})}i(),new MutationObserver(e=>{let t=!1;for(var a of e)if("childList"===a.type){for(var d of a.addedNodes)if(d.nodeType===Node.ELEMENT_NODE&&(d.matches("[data-dxb-slider]")||d.querySelector("[data-dxb-slider]"))){t=!0;break}if(t)break}t&&i()}).observe(document.body,{childList:!0,subtree:!0})})(); \ No newline at end of file +(()=>{function l(e=0,t=0,a=0){return(e-t)/(a-t)*100}function s(e,t){if("number"===e.type){if(""===t)return void(e.value=t);var a=parseFloat(e.min),r=parseFloat(e.max),i=parseFloat(e.step)||1,n=parseFloat(t),n=Math.max(a,Math.min(n,r)),r=Math.round((n-a)/i)*i+a;e.value=(n=r,i=((i=e.step).toString().split(".")[1]||"").length,parseFloat(n.toFixed(i)))}"range"===e.type&&(a=Number(t)||0,e.value=a,r=l(e.value,e.min,e.max),e.style.setProperty("--value-percent",r+"%"),e.setAttribute("aria-valuenow",e.value))}let u=new Proxy({},{set(e,t,a){var r,i,n,d=document.getElementById(t);if(d)return r=Number(a),i=""!==d.max?Number(d.max):null,n=""!==d.min?Number(d.min):null,null!==i&&i