From: Timo Meschkat <timo.meschkat@datenzelt.de>
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 [thread overview]
Message-ID: <20261026220423.127776-2-timo.meschkat@datenzelt.de> (raw)
In-Reply-To: <20261026220423.127776-1-timo.meschkat@datenzelt.de>
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 <timo.meschkat@datenzelt.de>
---
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
next prev parent reply other threads:[~2026-10-26 22:05 UTC|newest]
Thread overview: 8+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-26 22:04 [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes Timo Meschkat
2026-10-26 22:04 ` Timo Meschkat [this message]
2026-10-26 22:04 ` [RFC PATCH widget-toolkit 2/3] form: size field: accept a unit suffix like 8G Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH widget-toolkit 3/3] form: size field: add optional field description Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH manager 1/4] fix #3760: ui: qemu: allow selecting the memory unit Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH manager 2/4] ui: lxc: allow selecting the unit for memory and swap Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH manager 3/4] ui: disk storage selector: allow selecting the size unit Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH manager 4/4] ui: disk resize: " Timo Meschkat
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261026220423.127776-2-timo.meschkat@datenzelt.de \
--to=timo.meschkat@datenzelt.de \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox