From a6944baa070b15ab5baa0cfa2771d58525cc731e Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 8 Oct 2026 17:28:45 +0000 Subject: [PATCH 1/2] fanctrlplus2: apply a field's floor on blur, not while typing (#17) Typing 20 into the interval field gave 50: the first digit was raised to the 5-second floor before the second one arrived. The floor now applies when the field is left; while typing, only the ceiling applies, and a value below the floor gets an outline and a tooltip naming the minimum. --- .../plugins/fanctrlplus2/css/fcp.base.css | 6 +++ .../plugins/fanctrlplus2/fanctrlplus2.page | 31 +++++++++++--- tests/interval_ui_test.js | 40 +++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/css/fcp.base.css b/src/usr/local/emhttp/plugins/fanctrlplus2/css/fcp.base.css index 29162bd..9f50cd3 100644 --- a/src/usr/local/emhttp/plugins/fanctrlplus2/css/fcp.base.css +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/css/fcp.base.css @@ -130,6 +130,12 @@ /* Center the range separator. */ .fcp-center { text-align: center; } +/* A value under the field's floor while typing; it is raised on blur. */ +#fan-form input.fcp-below-min { + outline: 2px solid #e6a23c; + outline-offset: -2px; +} + /* Mobile: full-width interval with Run Now on the next line. */ #fan-form .fcp-interval-input { width: 100%; diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page index f3e8d88..088d4ec 100644 --- a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page @@ -643,24 +643,45 @@ $(function() { function bindUnitInputs(unitConfigs) { unitConfigs.forEach(({ selector, unit, min = 0, max = 100 }) => { document.querySelectorAll(selector).forEach(input => { - const enforceUnit = () => { + const belowMinHint = `Minimum is ${min}${unit}; a lower value is raised to it when you leave the field.`; + let ownTitle = input.title; // Some fields carry their own tooltip; put it back afterwards. + const flagBelowMin = (on) => { + input.classList.toggle('fcp-below-min', on); + if (on && input.title !== belowMinHint) { + ownTitle = input.title; + input.title = belowMinHint; + } else if (!on && input.title === belowMinHint) { + input.title = ownTitle; + } + }; + + // While typing, only the ceiling applies: raising a partial entry such + // as the "2" of "20" to the floor would make the next digit land on + // the floor instead (issue #17). The floor is applied on blur. + const enforceUnit = (applyMin) => { let val = (input.value ?? '').toString().replace(/\D/g, ''); // Do not add a unit when conversion produced an empty value. if (!val) { input.value = ''; + flagBelowMin(false); return; } - val = Math.min(Math.max(parseInt(val), min), max); + val = Math.min(parseInt(val, 10), max); + if (applyMin) val = Math.max(val, min); input.value = val + unit; - // Move the cursor before the unit. + flagBelowMin(val < min); + + // Move the cursor before the unit; Safari would refocus a blurred field. + if (document.activeElement !== input) return; const pos = input.value.indexOf(unit); input.setSelectionRange(pos, pos); }; - input.addEventListener('input', enforceUnit); + input.addEventListener('input', () => enforceUnit(false)); + input.addEventListener('blur', () => enforceUnit(true)); input.addEventListener('click', () => { setTimeout(() => { @@ -669,7 +690,7 @@ $(function() { }, 0); }); - enforceUnit(); // Apply once during initialization. + enforceUnit(true); // Apply once during initialization. }); }); } diff --git a/tests/interval_ui_test.js b/tests/interval_ui_test.js index 884b3df..1786663 100644 --- a/tests/interval_ui_test.js +++ b/tests/interval_ui_test.js @@ -37,6 +37,46 @@ assert.strictEqual(stripUnit('5 min'), '5'); assert.strictEqual(stripUnit('60 °C'), '60'); assert.strictEqual(stripUnit('40%'), '40'); +// Typing "20" must give 20, not 50: the floor is applied when the field is +// left, never to a partial entry (issue #17). +const bindMatch = page.match(/function bindUnitInputs\(unitConfigs\) \{[\s\S]*?\n \}/); +assert(bindMatch, 'bindUnitInputs must exist'); +function fakeInput() { + const handlers = {}; + const classes = new Set(); + return { + value: '', title: '', handlers, + classList: { + contains: c => classes.has(c), + remove: c => classes.delete(c), + toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)), + }, + addEventListener: (event, fn) => { handlers[event] = fn; }, + setSelectionRange() {}, + }; +} +const input = fakeInput(); +input.title = 'Low Temp: 40°C'; +const fakeDocument = { activeElement: input, querySelectorAll: () => [input] }; +const bindUnitInputs = new Function('document', `${bindMatch[0]}; return bindUnitInputs;`)(fakeDocument); +bindUnitInputs([{ selector: 'x', unit: ' sec', min: 5, max: 3600 }]); +const type = text => { input.value = text; input.handlers.input(); }; + +type('2'); +assert.strictEqual(input.value, '2 sec', 'A partial entry below the floor is left alone'); +assert(input.classList.contains('fcp-below-min'), 'A value below the floor is flagged'); +assert.match(input.title, /Minimum is 5 sec/, 'A value below the floor explains the floor'); +type('20 sec'); +assert.strictEqual(input.value, '20 sec', 'Typing 20 gives 20, not 50'); +assert(!input.classList.contains('fcp-below-min'), 'The flag clears once the floor is met'); +assert.strictEqual(input.title, 'Low Temp: 40°C', "The field's own tooltip comes back once the floor is met"); +type('99999'); +assert.strictEqual(input.value, '3600 sec', 'The ceiling still applies while typing'); +type('3'); +input.handlers.blur(); +assert.strictEqual(input.value, '5 sec', 'Leaving the field raises the value to the floor'); +assert(!input.classList.contains('fcp-below-min'), 'The flag clears after the floor is applied'); + // The rendered field shows seconds, converted from whatever the config holds. assert.match(render, /cfg_interval_seconds\(\$cfg\)/, 'The interval field must render through the shared seconds helper'); From 5056f50cca2843feeba1bf192fb2f9b6de06ed10 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 8 Oct 2026 17:37:58 +0000 Subject: [PATCH 2/2] fanctrlplus2: bind each unit field once Add Fan and Add Disk Group rebind every matching field, so a field being typed in was re-initialized and raised to its floor mid-entry. --- src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page | 4 ++++ tests/interval_ui_test.js | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page index 088d4ec..b38eab2 100644 --- a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page @@ -643,6 +643,10 @@ $(function() { function bindUnitInputs(unitConfigs) { unitConfigs.forEach(({ selector, unit, min = 0, max = 100 }) => { document.querySelectorAll(selector).forEach(input => { + // Add Fan and Add Disk Group rebind every match; only new inputs need it, + // and re-initializing one being typed in would apply its floor mid-entry. + if (input.dataset.fcpUnitBound) return; + input.dataset.fcpUnitBound = '1'; const belowMinHint = `Minimum is ${min}${unit}; a lower value is raised to it when you leave the field.`; let ownTitle = input.title; // Some fields carry their own tooltip; put it back afterwards. const flagBelowMin = (on) => { diff --git a/tests/interval_ui_test.js b/tests/interval_ui_test.js index 1786663..3c257ca 100644 --- a/tests/interval_ui_test.js +++ b/tests/interval_ui_test.js @@ -45,7 +45,7 @@ function fakeInput() { const handlers = {}; const classes = new Set(); return { - value: '', title: '', handlers, + value: '', title: '', handlers, dataset: {}, classList: { contains: c => classes.has(c), remove: c => classes.delete(c), @@ -72,6 +72,10 @@ assert(!input.classList.contains('fcp-below-min'), 'The flag clears once the flo assert.strictEqual(input.title, 'Low Temp: 40°C', "The field's own tooltip comes back once the floor is met"); type('99999'); assert.strictEqual(input.value, '3600 sec', 'The ceiling still applies while typing'); +// Adding a fan or disk group rebinds every field; one mid-entry is untouched. +type('2'); +bindUnitInputs([{ selector: 'x', unit: ' sec', min: 5, max: 3600 }]); +assert.strictEqual(input.value, '2 sec', 'Rebinding leaves a field being typed in alone'); type('3'); input.handlers.blur(); assert.strictEqual(input.value, '5 sec', 'Leaving the field raises the value to the floor');