luci-base: implement a range slider input/control
authorPaul Donald <[email protected]>
Fri, 25 Apr 2025 18:24:17 +0000 (20:24 +0200)
committerPaul Donald <[email protected]>
Fri, 4 Jul 2025 12:45:05 +0000 (14:45 +0200)
This control is used to set values within a predefined range, and uses
the HTML 'input' element of type 'range' supported in all browsers.

Signed-off-by: Paul Donald <[email protected]>
(cherry picked from commit 3c16c590075eb8bdc9f019258a2357160ac5a912)

modules/luci-base/htdocs/luci-static/resources/form.js
modules/luci-base/htdocs/luci-static/resources/ui.js

index e1fc3ff3e9dd39520ac3d1c4ceda9bbdb341c109..e5e84ec92d98faa11ab56470d443958394e3aff8 100644 (file)
@@ -4104,6 +4104,155 @@ const CBIRichListValue = CBIListValue.extend(/** @lends LuCI.form.ListValue.prot
        }
 });
 
+/**
+ * @class RangeSliderValue
+ * @memberof LuCI.form
+ * @augments LuCI.form.Value
+ * @hideconstructor
+ * @classdesc
+ *
+ * The `RangeSliderValue` class implements a range slider input using
+ * {@link LuCI.ui.RangeSlider}. It is useful in cases where a value shall fall
+ * within a predetermined range. This helps omit various error checks for such
+ * values. The currently chosen value is displayed to the side of the slider.
+ *
+ * @param {LuCI.form.Map|LuCI.form.JSONMap} form
+ * The configuration form this section is added to. It is automatically passed
+ * by [option()]{@link LuCI.form.AbstractSection#option} or
+ * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the
+ * option to the section.
+ *
+ * @param {LuCI.form.AbstractSection} section
+ * The configuration section this option is added to. It is automatically passed
+ * by [option()]{@link LuCI.form.AbstractSection#option} or
+ * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the
+ * option to the section.
+ *
+ * @param {string} option
+ * The name of the UCI option to map.
+ *
+ * @param {string} [title]
+ * The title caption of the option element.
+ *
+ * @param {string} [description]
+ * The description text of the option element.
+ */
+const CBIRangeSliderValue = CBIValue.extend(/** @lends LuCI.form.RangeSliderValue.prototype */ {
+       __name__: 'CBI.RangeSliderValue',
+
+       /**
+        * Minimum value the slider can represent.
+        * @name LuCI.form.RangeSliderValue.prototype#min
+        * @type number
+        * @default 0
+        */
+
+       /**
+        * Maximum value the slider can represent.
+        * @name LuCI.form.RangeSliderValue.prototype#max
+        * @type number
+        * @default 100
+        */
+
+       /**
+        * Step size for each tick of the slider, or the special value "any" when
+        * handling arbitrary precision floating point numbers.
+        * @name LuCI.form.RangeSliderValue.prototype#step
+        * @type string
+        * @default 1
+        */
+
+       /**
+        * Set the default value for the slider. The default value is elided during
+        * save: meaning, a currently chosen value which matches the default is
+        * not saved.
+        * @name LuCI.form.RangeSliderValue.prototype#default
+        * @type string
+        * @default null
+        */
+
+       /**
+        * Override the calculate action.
+        *
+        * When this property is set to a function, it is invoked when the slider
+        * is adjusted. This might be useful to calculate and display a result which
+        * is more meaningful than the currently chosen value. The calculated value
+        * is displayed below the slider.
+        *
+        * @name LuCI.form.RangeSliderValue.prototype#calculate
+        * @type function
+        * @default null
+        */
+
+       /**
+        * Define the units of the calculated value.
+        *
+        * Suffix a unit string to the calculated value, e.g. 'seconds' or 'dBm'.
+        *
+        * @name LuCI.form.RangeSliderValue.prototype#calcunits
+        * @type string
+        * @default null
+        */
+
+       /**
+        * Whether to use the calculated result of the chosen value instead of the
+        * chosen value: the result of the calculation returned by the
+        * <code>calculate</code> function on the chosen value
+        * is written to the configuration instead of the chosen value. The
+        * <code>calcunits</code> displayed units are not included. 
+        * 
+        * Note: Implementers of the <code>calculate</code> function shall be
+        * mindful that it may be possible to return a NaN value which is seldom a
+        * sensible input for the underlying daemon or system. Verification of any
+        * calculated value is an exercise left to the implementer.
+        *
+        * @name LuCI.form.RangeSliderValue.prototype#usecalc
+        * @type boolean
+        * @default false
+        */
+
+       /** @private */
+       renderWidget(section_id, option_index, cfgvalue) {
+               const slider = new ui.RangeSlider((cfgvalue != null) ? cfgvalue : this.default, {
+                       id: this.cbid(section_id),
+                       name: this.cbid(section_id),
+                       optional: this.optional,
+                       min: this.min,
+                       max: this.max,
+                       step: this.step,
+                       calculate: this.calculate,
+                       calcunits: this.calcunits,
+                       usecalc: this.usecalc,
+                       disabled: this.readonly || this.disabled,
+                       datatype: this.datatype,
+                       validate: this.validate,
+               });
+
+               this.widget = slider;
+
+               return slider.render();
+       },
+
+       /**
+        * Query the current form input value.
+        *
+        * @param {string} section_id
+        * The configuration section ID
+        *
+        * @returns {*}
+        * Returns the current input value.
+        */
+       formvalue(section_id) {
+               const elem = this.getUIElement(section_id);
+               if (!elem) return null;
+               let val = (this.usecalc && (typeof this.calculate === 'function'))
+                       ? elem.getCalculatedValue()
+                       : elem.getValue();
+               val = val?.toString();
+               return (val === this.default?.toString()) ? null : val;
+       }
+});
+
 /**
  * @class FlagValue
  * @memberof LuCI.form
@@ -5025,6 +5174,7 @@ return baseclass.extend(/** @lends LuCI.form.prototype */ {
        DynamicList: CBIDynamicList,
        ListValue: CBIListValue,
        RichListValue: CBIRichListValue,
+       RangeSliderValue: CBIRangeSliderValue,
        Flag: CBIFlagValue,
        MultiValue: CBIMultiValue,
        TextValue: CBITextValue,
index 7352130cc893ce4807e9df2f5d148510940c7d37..5c566ce889e8dea284c50770f873965bf8746688 100644 (file)
@@ -2635,6 +2635,123 @@ const UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype
        }
 });
 
+/**
+ * Instantiate a range slider widget.
+ *
+ * @constructor Slider
+ * @memberof LuCI.ui
+ * @augments LuCI.ui.AbstractElement
+ *
+ * @classdesc
+ *
+ * The `RangeSlider` class implements a widget which allows the user to set a 
+ * value from a predefined range.
+ *
+ * UI widget instances are usually not supposed to be created by view code
+ * directly. Instead they're implicitly created by `LuCI.form` when
+ * instantiating CBI forms.
+ *
+ * This class is automatically instantiated as part of `LuCI.ui`. To use it
+ * in views, use `'require ui'` and refer to `ui.Slider`. To import it in
+ * external JavaScript, use `L.require("ui").then(...)` and access the
+ * `Slider` property of the class instance value.
+ *
+ * @param {string|string[]} [value=null]
+ * ...
+ *
+ */
+const UIRangeSlider = UIElement.extend({
+       __init__(value, options) {
+               this.value = value;
+               this.options = Object.assign({
+                       optional: true,
+                       min: 0,
+                       max: 100,
+                       step: 1,
+                       calculate: null,
+                       calcunits: null,
+                       usecalc: false,
+                       disabled: false,
+               }, options);
+       },
+
+       /** @override */
+       render() {
+               this.sliderEl = E('input', {
+                       'type': 'range',
+                       'id': this.options.id,
+                       'min': this.options.min,
+                       'max': this.options.max,
+                       'step': this.options.step || 'any',
+                       'value': this.value,
+                       'disabled': this.options.disabled ? '' : null
+               });
+
+               this.calculatedvalue = (typeof this.options.calculate === 'function')
+                       ? this.options.calculate(this.value)
+                       : null;
+
+               this.calcEl = E('output', { 'class': 'cbi-range-slider-calc' }, this.calculatedvalue);
+
+               this.calcunitsEl = E('span', { 'class': 'cbi-range-slider-calc-units' }, 
+                       this.options.calcunits 
+                       ? '&nbsp;' + this.options.calcunits 
+                       : ''
+               );
+
+               const container = E('div', { 'class': 'cbi-range-slider' }, [
+                       this.sliderEl,
+                       this.valueEl = E('output', { 'for': this.options.id, 'class': 'cbi-range-slider-value' }, this.value),
+                       this.calculatedvalue ? E('br') : null,
+                       this.calculatedvalue ? this.calcEl : null,
+                       this.calculatedvalue ? this.calcunitsEl : null,
+               ].filter(Boolean));
+
+               this.node = container;
+
+               this.setUpdateEvents(this.sliderEl, 'input', 'blur');
+               this.setChangeEvents(this.sliderEl, 'change');
+
+               this.sliderEl.addEventListener('input', () => {
+                       const val = this.sliderEl.value;
+                       this.valueEl.textContent = val;
+                       
+                       if (typeof this.options.calculate === 'function') {
+                               // update the stored calculated value, and the displayed values
+                               this.calculatedvalue = this.options.calculate(val);
+                               this.calcEl.textContent = this.calculatedvalue;
+                       }
+
+                       this.node.setAttribute('data-changed', true);
+               });
+
+               dom.bindClassInstance(container, this);
+
+               return container;
+       },
+
+       /** @override */
+       getValue() {
+               return this.sliderEl.value;
+       },
+
+       /** @private */
+       getCalculatedValue() {
+               return this.calculatedvalue;
+       },
+
+       /** @override */
+       setValue(value) {
+               this.sliderEl.value = value;
+               this.valueEl.textContent = value;
+
+               if (typeof this.options.calculate === 'function') {
+                       this.calculatedvalue = this.options.calculate(value);
+                       this.calcEl.textContent = this.calculatedvalue;
+               }
+       }
+});
+
 /**
  * Instantiate a hidden input field widget.
  *
@@ -5166,6 +5283,7 @@ const UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
        Select: UISelect,
        Dropdown: UIDropdown,
        DynamicList: UIDynamicList,
+       RangeSlider: UIRangeSlider,
        Combobox: UICombobox,
        ComboButton: UIComboButton,
        Hiddenfield: UIHiddenfield,