diff --git a/api/js/etemplate/Et2Textbox/Et2Number.md b/api/js/etemplate/Et2Textbox/Et2Number.md new file mode 100644 index 0000000000..d10774db7d --- /dev/null +++ b/api/js/etemplate/Et2Textbox/Et2Number.md @@ -0,0 +1,46 @@ +## Examples ## + +### Precision ### + +To enforce a certain number of decimal places, set `precision`. + +```html:preview + + +``` + +### Number Format ### + +Normally numbers use the user's number format for thousands and decimal separator from preferences, but it is possible +to specify for a particular number. The internal value is not affected. + +```html:preview + +``` + +### Minimum and Maximum ### + +Limit the value with `min` and `max` + +```html:preview + + +``` + +### Prefix & Suffix ### + +Use `prefix` and `suffix` attributes to add text before or after the input field. To include HTML or other widgets, use +the `prefix` and `suffix` slots instead. + +```html:preview + +``` + +### Currency ### + +Using `suffix`,`min` and `precision` together + +```html:preview + +``` + diff --git a/api/js/etemplate/Et2Textbox/Et2Number.ts b/api/js/etemplate/Et2Textbox/Et2Number.ts index 2ba4874303..a1e66dd4e6 100644 --- a/api/js/etemplate/Et2Textbox/Et2Number.ts +++ b/api/js/etemplate/Et2Textbox/Et2Number.ts @@ -9,8 +9,32 @@ */ import {Et2Textbox} from "./Et2Textbox"; -import {css, html, render} from "lit"; +import {css, html, nothing} from "lit"; +import {customElement} from "lit/decorators/custom-element.js"; +import {property} from "lit/decorators/property.js"; +import {number} from "prop-types"; + +/** + * @summary Enter a numeric value. Number formatting comes from preferences by default + * @since 23.1 + * + * @dependency sl-input + * + * @slot label - The input's label. Alternatively, you can use the `label` attribute. + * @slot prefix - Used to prepend a presentational icon or similar element to the combobox. + * @slot suffix - Like prefix, but after + * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. + * + * @event change - Emitted when the control's value changes. + * + * @csspart form-control - The form control that wraps the label, input, and help text. + * @csspart form-control-label - The label's wrapper. + * @csspart form-control-input - The input's wrapper. + * @csspart form-control-help-text - The help text's wrapper. + */ + +@customElement("et2-number") export class Et2Number extends Et2Textbox { static get styles() @@ -20,50 +44,80 @@ export class Et2Number extends Et2Textbox css` /* Scroll buttons */ - :host(:hover) ::slotted(et2-button-scroll) { - display: flex; - } + :host(:hover) et2-button-scroll { + visibility: visible; + } - ::slotted(et2-button-scroll) { - display: none; - } - - .input--medium .input__suffix ::slotted(et2-button-scroll) { - padding: 0px; - } + et2-button-scroll { + visibility: hidden; + padding: 0px; + margin: 0px; + margin-left: var(--sl-spacing-small); + } .form-control-input { min-width: min-content; - max-width: 6em; + max-width: 7em; } `, ]; } - static get properties() - { - return { - ...super.properties, - /** - * Minimum value - */ - min: Number, - /** - * Maximum value - */ - max: Number, - /** - * Step value - */ - step: Number, - /** - * Precision of float number or 0 for integer - */ - precision: Number, - } - } + /** + * Minimum value + */ + @property({type: Number}) + min; + /** + * Maximum value + */ + @property({type: Number}) + max; + + /** + * Step value + */ + @property({type: Number}) + step; + + + /** + * Precision of float number or 0 for integer + */ + @property({type: Number}) + precision; + + /** + * Thousands separator. Defaults to user preference. + */ + @property() + thousandsSeparator; + + /** + * Decimal separator. Defaults to user preference. + */ + @property() + decimalSeparator; + + /** + * Text placed before the value + * @type {string} + */ + @property() + prefix = ""; + + /** + * Text placed after the value + * @type {string} + */ + @property() + suffix = ""; + + inputMode = "numeric"; + + get _inputNode() {return this.shadowRoot.querySelector("input");} constructor() { @@ -76,8 +130,33 @@ export class Et2Number extends Et2Textbox { super.connectedCallback(); - // Add spinners - render(this._incrementButtonTemplate(), this); + let numberFormat = "."; + if(this.egw() && this.egw().preference) + { + numberFormat = this.egw().preference("number_format", "common") ?? "."; + } + const decimal = numberFormat ? numberFormat[0] : '.'; + const thousands = numberFormat ? numberFormat[1] : ''; + this.decimalSeparator = this.decimalSeparator || decimal || "."; + this.thousandsSeparator = this.thousandsSeparator || thousands || ""; + } + + firstUpdated() + { + super.firstUpdated(); + + // Add content to slots + ["prefix", "suffix"].forEach(slot => + { + if(!this[slot]) + { + return; + } + this.append(Object.assign(document.createElement("span"), { + slot: slot, + textContent: this[slot] + })); + }); } transformAttributes(attrs) @@ -90,7 +169,6 @@ export class Et2Number extends Et2Textbox { attrs.validator = attrs.precision === 0 ? '/^-?[0-9]*$/' : '/^-?[0-9]*[,.]?[0-9]*$/'; } - attrs.inputmode = "numeric"; super.transformAttributes(attrs); } @@ -114,12 +192,7 @@ export class Et2Number extends Et2Textbox // Do nothing } - handleBlur() - { - this.value = this.input.value; - super.handleBlur(); - } - + @property({type: String}) set value(val) { if("" + val !== "") @@ -142,51 +215,109 @@ export class Et2Number extends Et2Textbox { val = parseFloat(val); } - // Put separator back in, if different - if(typeof val === 'string' && format && sep && sep !== '.') - { - val = val.replace('.', sep); - } } super.value = val; } - get value() + get value() : string { return super.value; } getValue() : any { + if(this.value == "" || typeof this.value == "undefined") + { + return ""; + } // Needs to be string to pass validator return "" + this.valueAsNumber; } get valueAsNumber() : number { - let val = super.value; - - if("" + val !== "") + let formattedValue = this._mask?.unmaskedValue ?? this.value; + if(typeof this.precision !== 'undefined') { - // remove decimal separator from user prefs - const format = this.egw().preference('number_format'); - const sep = format ? format[0] : '.'; - if(typeof val === 'string' && format && sep && sep !== '.') + formattedValue = parseFloat(parseFloat(formattedValue).toFixed(this.precision)); + } + else + { + formattedValue = parseFloat(formattedValue); + } + return formattedValue; + } + + /** + * Remove special formatting from a string to get just a number value + * @param {string | number} formattedValue + * @returns {number} + */ + stripFormat(formattedValue : string | number) + { + if("" + formattedValue !== "") + { + // remove thousands separator + if(typeof formattedValue === "string" && this.thousandsSeparator) { - val = val.replace(sep, '.'); + formattedValue = formattedValue.replaceAll(this.thousandsSeparator, ""); + } + // remove decimal separator + if(typeof formattedValue === 'string' && this.decimalSeparator !== '.') + { + formattedValue = formattedValue.replace(this.decimalSeparator, '.'); } if(typeof this.precision !== 'undefined') { - val = parseFloat(parseFloat(val).toFixed(this.precision)); + formattedValue = parseFloat(parseFloat(formattedValue).toFixed(this.precision)); } else { - val = parseFloat(val); + formattedValue = parseFloat(formattedValue); } } - return val; + return formattedValue; } + /** + * Get the options for masking. + * Overridden to use number-only masking + * + * @see https://imask.js.org/guide.html#masked-number + */ + protected get maskOptions() + { + let options = { + ...super.maskOptions, + skipInvalid: true, + // The initial options need to match an actual number + radix: this.decimalSeparator, + thousandsSeparator: this.thousandsSeparator, + mask: Number + } + if(typeof this.precision != "undefined") + { + options.scale = this.precision; + } + if(typeof this.min != "undefined") + { + options.min = this.min; + } + if(typeof this.max != "undefined") + { + options.max = this.max; + } + return options; + } + + updateMaskValue() + { + this._mask.updateValue(); + this._mask.unmaskedValue = "" + this.value; + this._mask.updateValue(); + } + + private handleScroll(e) { if (this.disabled) return; @@ -211,14 +342,55 @@ export class Et2Number extends Et2Textbox // No increment buttons on mobile if(typeof egwIsMobile == "function" && egwIsMobile()) { - return ''; + return nothing; + } + // Other reasons for no buttons + if(this.disabled || this.readonly || !this.step) + { + return nothing; } - return this.disabled ? '' : html` + return html` `; } + + _inputTemplate() + { + return html` + + + ${this.prefix ? html`${this.prefix}` : nothing} + ${this.suffix ? html`${this.suffix}` : nothing} + + ${this._incrementButtonTemplate()} + + `; + } +} + +/** + * Format a number according to user preferences + * @param {number} value + * @returns {string} + */ +export function formatNumber(value : number, decimalSeparator : string = ".", thousandsSeparator : string = "") : string +{ + // Split by . because value is a number, so . is decimal separator + let parts = ("" + value).split("."); + + parts[0] = parts[0].replace(/\B(? +``` + +### Prefix & Suffix ### + +Use `prefix` and `suffix` slots to add content before or after the text + +```html:preview + + + + +``` + +### Mask ### + +Setting a mask limits what the user can enter into the field. + +```html:preview + +``` \ No newline at end of file diff --git a/api/js/etemplate/Et2Textbox/Et2Textbox.ts b/api/js/etemplate/Et2Textbox/Et2Textbox.ts index 44c4799195..d484bf99cf 100644 --- a/api/js/etemplate/Et2Textbox/Et2Textbox.ts +++ b/api/js/etemplate/Et2Textbox/Et2Textbox.ts @@ -9,12 +9,16 @@ */ -import {css, PropertyValues} from "lit"; +import {css, html, nothing, PropertyValues} from "lit"; +import {customElement} from "lit/decorators/custom-element.js"; +import {property} from "lit/decorators/property.js"; import {Regex} from "../Validators/Regex"; -import {SlInput} from "@shoelace-style/shoelace"; import shoelace from "../Styles/shoelace"; import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget"; +import IMask, {InputMask} from "imask"; +import {SlInput} from "@shoelace-style/shoelace"; +@customElement("et2-textbox") export class Et2Textbox extends Et2InputWidget(SlInput) { @@ -42,19 +46,37 @@ export class Et2Textbox extends Et2InputWidget(SlInput) ]; } - static get properties() - { - return { - ...super.properties, - /** - * Perl regular expression eg. '/^[0-9][a-f]{4}$/i' - * - * Not to be confused with this.validators, which is a list of validators for this widget - */ - validator: String, - onkeypress: Function, - } - } + @property() + value = ""; + + /** + * Placeholder text to show as a hint when the input is empty. + */ + @property() + placeholder; + + /** + * Mask the input to enforce format. The mask is enforced as the user types, preventing invalid input. + */ + @property() + mask; + + /** + * Disables the input. It is still visible. + * @type {boolean} + */ + @property({type: Boolean}) + disabled = false; + + @property({type: Function}) + onkeypress; + + private __validator : any; + private _mask : InputMask; + protected _value : string = ""; + + inputMode = "text"; + static get translate() { @@ -73,6 +95,20 @@ export class Et2Textbox extends Et2InputWidget(SlInput) super.connectedCallback(); } + disconnectedCallback() + { + super.disconnectedCallback(); + this.removeEventListener("focus", this.handleFocus); + } + + firstUpdated() + { + if(this.maskOptions.mask) + { + this.updateMask(); + } + } + /** @param changedProperties */ updated(changedProperties : PropertyValues) { @@ -83,8 +119,13 @@ export class Et2Textbox extends Et2InputWidget(SlInput) this.validators = (this.validators || []).filter((validator) => !(validator instanceof Regex)) this.validators.push(new Regex(this.validator)); } + if(changedProperties.has('mask')) + { + this.updateMask(); + } } + @property() get validator() { return this.__validator; @@ -107,7 +148,114 @@ export class Et2Textbox extends Et2InputWidget(SlInput) this.requestUpdate("validator"); } } -} -// @ts-ignore TypeScript is not recognizing that Et2Textbox is a LitElement -customElements.define("et2-textbox", Et2Textbox); \ No newline at end of file + /** + * Get the options for masking. + * Can be overridden by subclass for additional options. + * + * @see https://imask.js.org/guide.html#masked + */ + protected get maskOptions() + { + return { + mask: this.mask, + lazy: this.placeholder ? true : false, + autofix: true, + eager: "append", + overwrite: "shift" + } + } + + protected updateMask() + { + const input = this.shadowRoot.querySelector("input") + if(!this._mask) + { + this._mask = IMask(input, this.maskOptions); + this.addEventListener("focus", this.handleFocus) + window.setTimeout(() => + { + this._mask.updateControl(); + }, 1); + } + else + { + this._mask.updateOptions(this.maskOptions); + } + + if(this._mask) + { + this.updateMaskValue(); + } + } + + protected updateMaskValue() + { + this._mask.unmaskedValue = "" + this.value; + this._mask.updateValue(); + this.updateComplete.then(() => + { + this._mask.updateControl(); + }); + } + + protected handleFocus(event) + { + if(this._mask) + { + // this._mask.updateValue(); + } + } + + protected _inputTemplate() + { + return html` + + { + if(this.__mask) + { + this.__mask.updateCursor(this.__mask.cursorPos) + } + }} + > + + + + `; + } + + /* + render() + { + const labelTemplate = this._labelTemplate(); + const helpTemplate = this._helpTextTemplate(); + + return html` +
+ ${labelTemplate} +
+ ${this._inputTemplate()} +
+ ${helpTemplate} +
+ `; + } + + */ +} diff --git a/api/js/etemplate/Et2Textbox/test/Et2Number.test.ts b/api/js/etemplate/Et2Textbox/test/Et2Number.test.ts index b981ad8498..adf1c9064b 100644 --- a/api/js/etemplate/Et2Textbox/test/Et2Number.test.ts +++ b/api/js/etemplate/Et2Textbox/test/Et2Number.test.ts @@ -65,6 +65,14 @@ describe("Number widget", () => assert.equal(element.value, "1", "Wrong number of decimals"); }) + it("Min limit", () => + { + element.value = 0; + element.min = 2; + element.value = "1.234"; + assert.equal(element.value, "2", "Value allowed below minimum"); + }); + describe("Check number preferences", () => { diff --git a/api/js/etemplate/Validators/Regex.ts b/api/js/etemplate/Validators/Regex.ts index fc890e735b..dfbcf670a7 100644 --- a/api/js/etemplate/Validators/Regex.ts +++ b/api/js/etemplate/Validators/Regex.ts @@ -9,7 +9,6 @@ export class Regex extends Pattern */ static async getMessage(data) { - // TODO: This is a poor error message, it shows the REGEX - return data.formControl.egw().lang("'%1' has an invalid format !!!", data.params); + return data.formControl.egw().lang("'%1' does not match the required pattern '%2'", data.modelValue, data.params); } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9837417da0..7703a3d3fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "colortranslator": "^1.9.2", "core-js": "^3.29.1", "dexie": "^3.2.4", + "imask": "^7.6.1", "lit": "^2.7.5", "lit-flatpickr": "^0.3.0", "shortcut-buttons-flatpickr": "^0.4.0", @@ -299,12 +300,13 @@ } }, "node_modules/@75lb/deep-merge": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@75lb/deep-merge/-/deep-merge-1.1.1.tgz", - "integrity": "sha512-xvgv6pkMGBA6GwdyJbNAnDmfAIR/DfWhrj9jgWh3TY7gRm3KO46x/GPjRg6wJ0nOepwqrNxFfojebh0Df4h4Tw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@75lb/deep-merge/-/deep-merge-1.1.2.tgz", + "integrity": "sha512-08K9ou5VNbheZFxM5tDWoqjA3ImC50DiuuJ2tj1yEPRfkp8lLLg6XAaJ4On+a0yAXor/8ay5gHnAIshRM44Kpw==", "dev": true, + "license": "MIT", "dependencies": { - "lodash.assignwith": "^4.2.0", + "lodash": "^4.17.21", "typical": "^7.1.1" }, "engines": { @@ -2260,6 +2262,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.25.0.tgz", + "integrity": "sha512-BOehWE7MgQ8W8Qn0CQnMtg2tHPHPulcS/5AVpFvs2KCK1ET+0WqZqPvnpRpFN81gYoFopdIEJX9Sgjw3ZBccPg==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -4179,12 +4194,6 @@ "integrity": "sha512-ARATsLdrGPUnaBvxLhUlnltcMgn7pQG312S8ccdYlnyijabrX9RN/KN/iGj9Am96CoW8e/K9628BA7Bv4XHdrA==", "dev": true }, - "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "peer": true - }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -4197,16 +4206,6 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", "dev": true }, - "node_modules/@types/react": { - "version": "18.3.3", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", - "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "peer": true, - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -6957,6 +6956,17 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-js-pure": { + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.37.1.tgz", + "integrity": "sha512-J/r5JTHSmzTxbiYYrzXg9w1VpqrYt+gexenBE9pugeyhwPZTAEJddyiReJWsLO6uNQ8xJZFbod6XC7KKwatCiA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -7002,12 +7012,6 @@ "node": ">=14" } }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "peer": true - }, "node_modules/custom-element-jet-brains-integration": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/custom-element-jet-brains-integration/-/custom-element-jet-brains-integration-1.2.1.tgz", @@ -9635,6 +9639,18 @@ "node": ">= 4" } }, + "node_modules/imask": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/imask/-/imask-7.6.1.tgz", + "integrity": "sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg==", + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs3": "^7.24.4" + }, + "engines": { + "npm": ">=4.0.0" + } + }, "node_modules/immutable": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", @@ -10948,12 +10964,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, - "node_modules/lodash.assignwith": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.assignwith/-/lodash.assignwith-4.2.0.tgz", - "integrity": "sha512-ZznplvbvtjK2gMvnQ1BR/zqPFZmS6jbK4p+6Up4xcRYA7yMIwxHCfbTcrYxXKzzqLsQ05eJPVznEW3tuwV7k1g==", - "dev": true - }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -13082,8 +13092,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==", - "dev": true + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, "node_modules/regenerator-transform": { "version": "0.15.2", @@ -13314,7 +13323,7 @@ "version": "2.79.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "devOptional": true, + "dev": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/package.json b/package.json index ddd49d2e55..a6dad9b266 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "colortranslator": "^1.9.2", "core-js": "^3.29.1", "dexie": "^3.2.4", + "imask": "^7.6.1", "lit": "^2.7.5", "lit-flatpickr": "^0.3.0", "shortcut-buttons-flatpickr": "^0.4.0",