From e4c67020de60517b35c50363a556bdc3e141c895 Mon Sep 17 00:00:00 2001 From: Paul Donald Date: Fri, 25 Apr 2025 20:24:17 +0200 Subject: [PATCH] luci-base: implement a range slider input/control 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 (cherry picked from commit 3c16c590075eb8bdc9f019258a2357160ac5a912) --- .../htdocs/luci-static/resources/form.js | 150 ++++++++++++++++++ .../htdocs/luci-static/resources/ui.js | 118 ++++++++++++++ 2 files changed, 268 insertions(+) diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index e1fc3ff3e9..e5e84ec92d 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -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 + * calculate function on the chosen value + * is written to the configuration instead of the chosen value. The + * calcunits displayed units are not included. + * + * Note: Implementers of the calculate 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, diff --git a/modules/luci-base/htdocs/luci-static/resources/ui.js b/modules/luci-base/htdocs/luci-static/resources/ui.js index 7352130cc8..5c566ce889 100644 --- a/modules/luci-base/htdocs/luci-static/resources/ui.js +++ b/modules/luci-base/htdocs/luci-static/resources/ui.js @@ -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 + ? ' ' + 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, -- 2.30.2