From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 9241B1FF09B for ; Mon, 26 Oct 2026 23:05:20 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 75F6C21664; Mon, 26 Oct 2026 23:04:53 +0100 (CET) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=datenzelt.de; s=MBO0001; t=1793052276; h=from:from:reply-to:subject:subject:date:date:message-id:message-id: to:to:cc:mime-version:mime-version: content-transfer-encoding:content-transfer-encoding: in-reply-to:in-reply-to:references:references; bh=4RYTdg/bbUDObqXQO9sMJlNqg7W+W6Oas6HlCXVZy+c=; b=IbBhZsfs3FYp66QQnOKt9gUGS+8KjmGXPL/KgtjhXI5uBbHvTXOFY3ZV9pgDFR0eT1OYEL 4mH6tU4w6UeY3g3GiUCiodM6NNt3ncZW/WdDAxKPEwjsvZu6RgQ6iBexFqwzmJvcquDG/B kpGkRdjLHf9dEDz8W2j5GL5CgShDA0eanCgyVG08ajmg+NjVHnMRaTkf1KAwE0FzOvvUAJ clFS+shIRxE8997W3dWj3q94wwPPQYyhAUvfl4yVmXDr/BLBocYhCH4rAG6OUvSFsMRkUd ZbrUV3SzCR902VeNwTo/BVBxTR0OBG2V3GhWtSEHN91J7U/3XxLNksxbU/StaQ== Authentication-Results: outgoing_mbo_mout; dkim=none; spf=pass (outgoing_mbo_mout: domain of timo.meschkat@datenzelt.de designates 2001:67c:2050:b231:465::1 as permitted sender) smtp.mailfrom=timo.meschkat@datenzelt.de From: Timo Meschkat To: pve-devel@lists.proxmox.com Subject: [RFC PATCH widget-toolkit 1/3] form: add size field with selectable unit Date: Mon, 26 Oct 2026 23:04:17 +0100 Message-ID: <20261026220423.127776-2-timo.meschkat@datenzelt.de> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261026220423.127776-1-timo.meschkat@datenzelt.de> References: <20261026220423.127776-1-timo.meschkat@datenzelt.de> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Rspamd-Queue-Id: 4jD72X6RWXzKmZB X-SPAM-LEVEL: Spam detection results: 0 AWL 0.088 Adjusted score from AWL reputation of From: address DKIM_SIGNED 0.1 Message has a DKIM or DK signature, not necessarily valid DKIM_VALID -0.1 Message has at least one valid DKIM or DK signature DKIM_VALID_AU -0.1 Message has a valid DKIM or DK signature from author's domain DKIM_VALID_EF -0.1 Message has a valid DKIM or DK signature from envelope-from domain DMARC_PASS -0.1 DMARC pass policy RCVD_IN_DNSWL_LOW -0.7 Sender listed at https://www.dnswl.org/, low trust SPF_HELO_PASS -0.001 SPF: HELO matches SPF record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: WT4LG7BUHVAKBSG7NBMX7SA2QMAZYBVR X-Message-ID-Hash: WT4LG7BUHVAKBSG7NBMX7SA2QMAZYBVR X-MailFrom: timo.meschkat@datenzelt.de X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Add a size input with a unit selector next to it. Value, limits and the submitted value always use the configured backend unit, changing the unit only changes what is displayed. This is a new component instead of making the unit of the existing size field selectable. That one only converts the first value set from the backend, which breaks for fields updated by other fields, like the minimum memory of a VM following its memory. It is also used in other products, so changing its behavior would be riskier. The input is plain text with spinner buttons, so parsing and validation happen in the field itself and no internals of the ExtJS number field need to be overridden. As the unit is not part of the input, screen readers get it announced together with the value. Signed-off-by: Timo Meschkat --- src/Makefile | 1 + src/form/SizeUnitField.js | 287 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 src/form/SizeUnitField.js diff --git a/src/Makefile b/src/Makefile index 9c52339..532725c 100644 --- a/src/Makefile +++ b/src/Makefile @@ -27,6 +27,7 @@ JSSRC= \ data/model/Certificates.js \ data/model/ACME.js \ form/BandwidthSelector.js \ + form/SizeUnitField.js \ form/DisplayEdit.js \ form/ExpireDate.js \ form/IntegerField.js \ diff --git a/src/form/SizeUnitField.js b/src/form/SizeUnitField.js new file mode 100644 index 0000000..e103b59 --- /dev/null +++ b/src/form/SizeUnitField.js @@ -0,0 +1,287 @@ +// Size input with a selectable unit. Values, limits and submit always use 'backendUnit'. +Ext.define('Proxmox.form.SizeUnitField', { + extend: 'Ext.form.FieldContainer', + alias: 'widget.pmxSizeUnitField', + + mixins: { + field: 'Ext.form.field.Field', + }, + + layout: 'hbox', + + backendUnit: 'B', + + // ascending + units: ['MiB', 'GiB', 'TiB'], + + // defaults to the first of 'units' + unit: undefined, + + // spin step per unit, default 1 + unitSteps: {}, + + // in backend units + minValue: 0, + maxValue: undefined, + + allowBlank: true, + + emptyText: '', + + decimalPrecision: 2, + + // otherwise round to whole backend units + allowFractionalValue: false, + + value: null, + + initComponent: function () { + let me = this; + + me.unit ??= me.units[0]; + me.initialUnit = me.unit; + + me.items = [ + { + // plain text, parsing and validation happen in this field + xtype: 'spinnerfield', + itemId: 'input', + isFormField: false, + flex: 1, + // the field label belongs to the container, not the inputs + ariaLabel: me.fieldLabel, + fieldStyle: 'text-align: right', + allowBlank: me.allowBlank, + emptyText: me.emptyText, + validator: (text) => me.validateText(text), + listeners: { + afterrender: () => me.updateAriaValueText(), + change: (field, text) => me.onTextChange(text), + blur: (field) => field.isValid() && me.updateDisplay(), + spinup: () => me.spin(true), + spindown: () => me.spin(false), + validitychange: (field, isValid) => me.fireEvent('validitychange', me, isValid), + }, + }, + { + xtype: 'combobox', + itemId: 'unit', + isFormField: false, + ariaLabel: `${me.fieldLabel ?? ''} ${gettext('Unit')}`.trim(), + width: 70, + margin: '0 0 0 5', + editable: false, + queryMode: 'local', + store: me.units, + value: me.unit, + listeners: { + // only fired on user selection + select: (field) => { + me.userChanged = true; + me.setUnit(field.getValue()); + }, + }, + }, + ]; + + me.callParent(); + + me.inputField = me.getComponent('input'); + me.unitField = me.getComponent('unit'); + + me.initField(); + }, + + getUnitFactor: function (unit = this.unit) { + return Proxmox.Utils.SizeUnits[unit] / Proxmox.Utils.SizeUnits[this.backendUnit]; + }, + + getUnit: function () { + return this.unit; + }, + + setUnit: function (unit) { + let me = this; + me.changeUnit(unit); + me.updateDisplay(); + }, + + // without updating the display + changeUnit: function (unit) { + let me = this; + let oldUnit = me.unit; + me.unit = unit; + me.unitField.setValue(unit); + if (unit !== oldUnit) { + me.fireEvent('unitchange', me, unit, oldUnit); + } + }, + + // typed, spun or picked a unit since the last reset + hasUserInput: function () { + return !!this.userChanged; + }, + + // '8' or '1,5' in the current unit; returns undefined if empty, null if invalid + parseText: function (text) { + let me = this; + text = String(text ?? '').trim(); + if (text === '') { + return undefined; + } + if (!/^\d+(?:[.,]\d*)?$/.test(text)) { + return null; + } + let value = parseFloat(text.replace(',', '.')) * me.getUnitFactor(); + return me.allowFractionalValue ? value : Math.round(value); + }, + + parseValue: function (value) { + if (value === undefined || value === null || value === '') { + return null; + } + value = Number(value); + return Number.isFinite(value) ? value : null; + }, + + formatNumber: function (number) { + let me = this; + let text = String(Number(number.toFixed(me.decimalPrecision))); + return text.replace('.', Ext.util.Format.decimalSeparator); + }, + + setValue: function (value) { + let me = this; + me.mixins.field.setValue.call(me, me.parseValue(value)); + me.updateDisplay(); + return me; + }, + + getValue: function () { + return this.value; + }, + + getSubmitValue: function () { + let me = this; + let value = me.getValue(); + if (value === null) { + return null; + } + if (!me.allowFractionalValue) { + return String(Math.round(value)); + } + // no exponential notation for tiny values + return value.toFixed(12).replace(/\.?0+$/, ''); + }, + + getSubmitData: function () { + let me = this; + let value = me.getSubmitValue(); + if (me.disabled || !me.submitValue || value === null) { + return null; + } + return { [me.getName()]: value }; + }, + + updateDisplay: function () { + let me = this; + let text = me.value === null ? '' : me.formatNumber(me.value / me.getUnitFactor()); + me.inputField.suspendEvent('change'); + me.inputField.setValue(text); + me.inputField.resumeEvent('change'); + me.updateAriaValueText(); + }, + + // the unit sits in its own selector, so tell screen readers e.g. '2 GiB' + updateAriaValueText: function () { + let me = this; + let dom = me.inputField.inputEl?.dom; + if (!dom) { + return; + } + if (me.value === null) { + dom.removeAttribute('aria-valuetext'); + } else { + let number = me.formatNumber(me.value / me.getUnitFactor()); + dom.setAttribute('aria-valuetext', `${number} ${me.unit}`); + } + }, + + onTextChange: function (text) { + let me = this; + me.userChanged = true; + me.mixins.field.setValue.call(me, me.parseText(text) ?? null); + me.updateAriaValueText(); + }, + + validateText: function (text) { + let me = this; + let value = me.parseText(text); + if (value === undefined) { + return true; // allowBlank handles this + } else if (value === null) { + return Ext.String.format(Ext.form.field.Number.prototype.nanText, text); + } + let format = (v) => Proxmox.Utils.format_size(v * Proxmox.Utils.SizeUnits[me.backendUnit]); + let numberField = Ext.form.field.Number.prototype; + if (me.minValue !== undefined && value < me.minValue) { + return Ext.String.format(numberField.minText, format(me.minValue)); + } + if (me.maxValue !== undefined && value > me.maxValue) { + return Ext.String.format(numberField.maxText, format(me.maxValue)); + } + return true; + }, + + // override for non-linear steps + getSpinValue: function (value, up) { + let me = this; + let step = (me.unitSteps[me.unit] ?? 1) * me.getUnitFactor(); + return up ? value + step : value - step; + }, + + spin: function (up) { + let me = this; + let value = me.getSpinValue(me.getValue() ?? 0, up); + me.userChanged = true; + me.mixins.field.setValue.call(me, Ext.Number.constrain(value, me.minValue, me.maxValue)); + me.updateDisplay(); + }, + + setMinValue: function (value) { + this.minValue = this.parseValue(value) ?? undefined; + this.inputField.validate(); + }, + + setMaxValue: function (value) { + this.maxValue = this.parseValue(value) ?? undefined; + this.inputField.validate(); + }, + + setEmptyText: function (emptyText) { + this.inputField.setEmptyText(emptyText); + }, + + reset: function () { + let me = this; + me.userChanged = false; + me.changeUnit(me.initialUnit); + me.mixins.field.reset.call(me); + }, + + isValid: function () { + return this.disabled || this.inputField.isValid(); + }, + + validate: function () { + return this.disabled || this.inputField.validate(); + }, + + markInvalid: function (errors) { + this.inputField.markInvalid(errors); + }, + + clearInvalid: function () { + this.inputField.clearInvalid(); + }, +}); -- 2.47.3