diff --git a/packages/localization/src/NumberFormat.ts b/packages/localization/src/NumberFormat.ts new file mode 100644 index 000000000000..27167ae1b5b5 --- /dev/null +++ b/packages/localization/src/NumberFormat.ts @@ -0,0 +1,8 @@ +import type NumberFormatT from "sap/ui/core/format/NumberFormat"; +// @ts-ignore +import NumberFormatNative from "./sap/ui/core/format/NumberFormat.js"; + +const NumberFormatWrapped = NumberFormatNative as typeof NumberFormatT; +class NumberFormat extends NumberFormatWrapped {} + +export default NumberFormat; diff --git a/packages/localization/used-modules.txt b/packages/localization/used-modules.txt index a62e0e5f3598..9c5a8b4e7f0c 100644 --- a/packages/localization/used-modules.txt +++ b/packages/localization/used-modules.txt @@ -48,4 +48,5 @@ sap/ui/core/date/Persian.js sap/ui/core/date/UI5Date.js sap/ui/core/date/UniversalDate.js sap/ui/core/format/TimezoneUtil.js -sap/ui/core/format/DateFormat.js \ No newline at end of file +sap/ui/core/format/DateFormat.js +sap/ui/core/format/NumberFormat.js \ No newline at end of file diff --git a/packages/main/cypress/specs/StepInput.cy.tsx b/packages/main/cypress/specs/StepInput.cy.tsx index 7339f9d975f0..60891ad2174f 100644 --- a/packages/main/cypress/specs/StepInput.cy.tsx +++ b/packages/main/cypress/specs/StepInput.cy.tsx @@ -622,6 +622,36 @@ describe("StepInput events", () => { }); }); +describe("StepInput thousand separator formatting", () => { + it("should display value with thousand separator", () => { + cy.mount( + + ); + + cy.get("[ui5-step-input]") + .ui5StepInputGetInnerInput() + .should($input => { + const val = $input.val(); + // Accepts both comma and dot as separator depending on locale + expect(val).to.match(/12[,.]345/); + }); + }); + + it("should parse formatted value correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-step-input]") + .ui5StepInputGetInnerInput() + .should($input => { + const val = $input.val() as string; + const num = Number(val.replace(/[^\d]/g, "")); + expect(num).to.equal(12345); + }); + }); +}); + describe("StepInput property propagation", () => { it("should propagate 'placeholder' property to inner input", () => { cy.mount( @@ -632,31 +662,33 @@ describe("StepInput property propagation", () => { .ui5StepInputCheckInnerInputProperty("placeholder", "Enter number"); }); - it("should propagate 'min' property to inner input", () => { + it("should not propagate 'min' property to inner input", () => { cy.mount( ); + // min should not be propogated because step input uses input with type="text" cy.get("[ui5-step-input]") - .ui5StepInputCheckInnerInputProperty("min", "0"); + .ui5StepInputCheckInnerInputProperty("min", "0", false); }); - it("should propagate 'max' property to inner input", () => { + it("should not propagate 'max' property to inner input", () => { cy.mount( ); + // min should not be propogated because step input uses input with type="text" cy.get("[ui5-step-input]") - .ui5StepInputCheckInnerInputProperty("max", "10"); + .ui5StepInputCheckInnerInputProperty("max", "10", false); }); - it("should propagate 'step' property to inner input", () => { + it("should not propagate 'step' property to inner input", () => { cy.mount( ); cy.get("[ui5-step-input]") - .ui5StepInputCheckInnerInputProperty("step", "2"); + .ui5StepInputCheckInnerInputProperty("step", "2", false); }); it("should propagate 'disabled' property to inner input", () => { @@ -685,6 +717,42 @@ describe("StepInput property propagation", () => { cy.get("[ui5-step-input]") .ui5StepInputCheckInnerInputProperty("value", "5"); }); + + it("should increase value on mouse wheel up", () => { + cy.mount( + + ); + + cy.get("[ui5-step-input]") + .as("stepInput"); + + cy.get("@stepInput") + .ui5StepInputScrollToChangeValue(7, false); + }); + + it("should decrease value on mouse wheel down", () => { + cy.mount( + + ); + + cy.get("[ui5-step-input]") + .as("stepInput"); + + cy.get("@stepInput") + .ui5StepInputScrollToChangeValue(3, true); + }); + + it("should not change value when readonly", () => { + cy.mount( + + ); + + cy.get("[ui5-step-input]") + .as("stepInput"); + + cy.get("@stepInput") + .ui5StepInputScrollToChangeValue(5, true); + }); }); describe("Validation inside form", () => { diff --git a/packages/main/cypress/support/commands/StepInput.commands.ts b/packages/main/cypress/support/commands/StepInput.commands.ts index 264e5a773893..58ba36d8a59c 100644 --- a/packages/main/cypress/support/commands/StepInput.commands.ts +++ b/packages/main/cypress/support/commands/StepInput.commands.ts @@ -44,7 +44,7 @@ Cypress.Commands.add("ui5StepInputAttachHandler", { prevSubject: true }, (subje }); }); -Cypress.Commands.add("ui5StepInputCheckInnerInputProperty", { prevSubject: true }, (subject, propName: string, expectedValue: any) => { +Cypress.Commands.add("ui5StepInputGetInnerInput", { prevSubject: true }, (subject) => { cy.wrap(subject) .as("stepInput") .should("be.visible"); @@ -56,8 +56,16 @@ Cypress.Commands.add("ui5StepInputCheckInnerInputProperty", { prevSubject: true .find("input") .as("innerInput"); - cy.get("@innerInput") - .should("have.prop", propName, expectedValue); + return cy.get("@innerInput"); +}); + +Cypress.Commands.add("ui5StepInputCheckInnerInputProperty", { prevSubject: true }, (subject, propName: string, expectedValue: any, shouldBePropagated: boolean = true) => { + cy.get(subject) + .ui5StepInputGetInnerInput() + .then($innerInput => { + const condition = shouldBePropagated ? "have.prop" : "not.have.prop"; + cy.wrap($innerInput).should(condition, propName, expectedValue); + }); }); Cypress.Commands.add("ui5StepInputTypeNumber", { prevSubject: true }, (subject, value: number) => { @@ -75,14 +83,45 @@ Cypress.Commands.add("ui5StepInputTypeNumber", { prevSubject: true }, (subject, .realPress("Enter"); }); +Cypress.Commands.add("ui5StepInputScrollToChangeValue", { prevSubject: true }, (subject, expectedValue: number, decreaseValue: boolean) => { + const deltaY = decreaseValue ? 100 : -100; + + cy.wrap(subject) + .as("stepInput") + .should("be.visible"); + + cy.get("@stepInput") + .realClick(); + + cy.get("@stepInput") + .should("be.focused"); + + cy.get("@stepInput") + .shadow() + .find(".ui5-step-input-root") + .then($el => { + const wheelEvent = new WheelEvent("wheel", { deltaY, bubbles: true, cancelable: true }); + $el[0].dispatchEvent(wheelEvent); + }); + + cy.realPress("Tab"); // To trigger change event + + cy.get("@stepInput") + .should("have.prop", "value", expectedValue); +}); + + + declare global { namespace Cypress { interface Chainable { ui5StepInputChangeValueWithArrowKeys(expectedValue: number, decreaseValue?: boolean): Chainable ui5StepInputChangeValueWithButtons(expectedValue: number, decreaseValue?: boolean): Chainable ui5StepInputAttachHandler(eventName: string, stubName: string): Chainable - ui5StepInputCheckInnerInputProperty(propName: string, expectedValue: any): Chainable + ui5StepInputGetInnerInput(): Chainable> + ui5StepInputCheckInnerInputProperty(propName: string, expectedValue: any, shouldBePropagated?: boolean): Chainable ui5StepInputTypeNumber(value: number): Chainable + ui5StepInputScrollToChangeValue(expectedValue: number, decreaseValue: boolean): Chainable } } } \ No newline at end of file diff --git a/packages/main/src/StepInput.ts b/packages/main/src/StepInput.ts index 83e0622b12a4..b228312b495c 100644 --- a/packages/main/src/StepInput.ts +++ b/packages/main/src/StepInput.ts @@ -17,6 +17,7 @@ import { isPageDownShift, isEscape, isEnter, + isMinus, } from "@ui5/webcomponents-base/dist/Keys.js"; import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; @@ -38,6 +39,7 @@ import "@ui5/webcomponents-icons/dist/add.js"; import type Input from "./Input.js"; import type { InputAccInfo, InputEventDetail } from "./Input.js"; import InputType from "./types/InputType.js"; +import NumberFormat from "@ui5/webcomponents-localization/dist/NumberFormat.js"; // Styles import StepInputCss from "./generated/themes/StepInput.css.js"; @@ -96,10 +98,12 @@ type StepInputValueStateChangeEventDetail = { */ @customElement({ tag: "ui5-step-input", + cldr: true, formAssociated: true, renderer: jsxRenderer, styles: StepInputCss, template: StepInputTemplate, + languageAware: true, }) /** * Fired when the input operation has finished by pressing Enter or on focusout. @@ -293,6 +297,8 @@ class StepInput extends UI5Element implements IFormInputElement { _initialValueState?: `${ValueState}`; + _formatter?: NumberFormat; + @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; @@ -329,7 +335,7 @@ class StepInput extends UI5Element implements IFormInputElement { } get type() { - return InputType.Number; + return InputType.Text; } // icons-related @@ -355,15 +361,17 @@ class StepInput extends UI5Element implements IFormInputElement { } get _displayValue() { + // For the cases when there is set value precision but the input value is not with correct precision we don't need to format it + const value = this.input?.value && !this._isValueWithCorrectPrecision ? this.input.value : this._formatNumber(this.value); if ((this.value === 0) || (Number.isInteger(this.value))) { - return this.value.toFixed(this.valuePrecision); + return value } if (this.input && this.value === Number(this.input.value)) { // For the cases where the number is fractional and is ending with 0s. return this.input.value; } - return this.value.toString(); + return value; } get accInfo(): InputAccInfo { @@ -385,6 +393,16 @@ class StepInput extends UI5Element implements IFormInputElement { this._setButtonState(); } + get formatter(): NumberFormat { + if (!this._formatter) { + this._formatter = NumberFormat.getFloatInstance({ + decimals: this.valuePrecision + }); + } + + return this._formatter; + } + get input(): Input { return this.shadowRoot!.querySelector("[ui5-input]")!; } @@ -422,6 +440,20 @@ class StepInput extends UI5Element implements IFormInputElement { this._onInputChange(); } + _onMouseWheel(e: WheelEvent) { + if (this.disabled || this.readonly) { + return; + } + + if (this._isFocused) { + e.preventDefault(); + } + + const isScrollUp = e.deltaY < 0; + const modifier = isScrollUp ? this.step : -this.step; + this._modifyValue(modifier, true); + } + _setButtonState() { this._decIconDisabled = this.min !== undefined && this.value <= this.min; this._incIconDisabled = this.max !== undefined && this.value >= this.max; @@ -485,7 +517,7 @@ class StepInput extends UI5Element implements IFormInputElement { value = this._preciseValue(value); if (value !== this.value) { this.value = value; - this.input.value = value.toFixed(this.valuePrecision); + this.input.value = this._formatNumber(value); this._validate(); this._setButtonState(); this.focused = true; @@ -498,6 +530,22 @@ class StepInput extends UI5Element implements IFormInputElement { } } + /** + * Formats a number with thousands separator based on current locale + * @private + */ + _formatNumber(value: number): string { + return this.formatter.format(value); + } + + /** + * Parses formatted number string back to numeric value + * @private + */ + _parseNumber(formattedValue: string): number { + return this.formatter.parse(formattedValue) as number; + } + _incValue() { if (this._incIconClickable && !this.disabled && !this.readonly) { this._modifyValue(this.step, true); @@ -521,8 +569,9 @@ class StepInput extends UI5Element implements IFormInputElement { } // gets either "." or "," as delimiter which is based on locale, and splits the number by it - const delimiter = this.input?.value?.includes(".") ? "." : ","; - const numberParts = this.input?.value?.split(delimiter); + // @ts-ignore oFormatOptions is a private API of NumberFormat but we need it here to get the decimal separator + const delimiter = this.formatter?.oFormatOptions?.decimalSeparator || "."; + const numberParts = this.input?.value?.split(delimiter as string); const decimalPartLength = numberParts?.length > 1 ? numberParts[1].length : 0; return decimalPartLength === this.valuePrecision; @@ -530,16 +579,16 @@ class StepInput extends UI5Element implements IFormInputElement { _onInputChange() { this._setDefaultInputValueIfNeeded(); - - const inputValue = Number(this.input.value); + const inputValue = this._parseNumber(this.input.value); if (this._isValueChanged(inputValue)) { - this._updateValueAndValidate(inputValue); + this._updateValueAndValidate(Number.isNaN(inputValue) ? this.min || 0 : inputValue); + this.innerInput.value = this.input.value; } } _setDefaultInputValueIfNeeded() { if (this.input.value === "") { - const defaultValue = (this.min || 0).toFixed(this.valuePrecision); + const defaultValue = this._formatNumber(this.min || 0); this.input.value = defaultValue; this.innerInput.value = defaultValue; // we need to update inner input value as well, to avoid empty input scenario } @@ -555,7 +604,8 @@ class StepInput extends UI5Element implements IFormInputElement { || this.value !== inputValue || inputValue === 0 || !isValueWithCorrectPrecision - || isPrecisionCorrectButValueStateError; + || isPrecisionCorrectButValueStateError + || Number.isNaN(inputValue); } _updateValueAndValidate(inputValue: number) { @@ -608,9 +658,36 @@ class StepInput extends UI5Element implements IFormInputElement { } else if (!isUpCtrl(e) && !isDownCtrl(e) && !isUpShift(e) && !isDownShift(e)) { preventDefault = false; } - if (preventDefault) { + + if (e.key && e.key.length !== 1) { + return; + } + + const cursorPosition = this._getCursorPosition(); + const inputValue = this.innerInput.value; + const typedValue = this._getValueOnkeyDown(e, inputValue, cursorPosition!); + const parsedValue = this._parseNumber(typedValue); + const isValidTypedValue = this._isTypedValueValid(typedValue, parsedValue); + + if (preventDefault || !isValidTypedValue) { e.preventDefault(); } + + if (cursorPosition === 0 && isMinus(e)) { + this._updateValueAndValidate(parsedValue); + } + } + + _getCursorPosition() { + return this.input.getDomRef()!.querySelector("input")!.selectionStart; + } + + _getValueOnkeyDown(e: KeyboardEvent, inputValue: string, cursorPosition?: number) { + return `${inputValue.substring(0, cursorPosition)}${e.key}${inputValue.substring(cursorPosition!)}`; + } + + _isTypedValueValid(typedValue: string, parsedValue: number) { + return !Number.isNaN(parsedValue) && !/, {2,}/.test(typedValue); } _decSpin() { diff --git a/packages/main/src/StepInputTemplate.tsx b/packages/main/src/StepInputTemplate.tsx index 75f33862e56c..775b861f91eb 100644 --- a/packages/main/src/StepInputTemplate.tsx +++ b/packages/main/src/StepInputTemplate.tsx @@ -13,6 +13,7 @@ export default function StepInputTemplate(this: StepInput) { onKeyDown={this._onkeydown} onFocusIn={this._onfocusin} onFocusOut={this._onfocusout} + onWheel={this._onMouseWheel} > {/* Decrement Icon */} {!this.readonly && diff --git a/packages/main/test/pages/StepInput.html b/packages/main/test/pages/StepInput.html index 21a5ed3d911a..9e148722545a 100644 --- a/packages/main/test/pages/StepInput.html +++ b/packages/main/test/pages/StepInput.html @@ -25,11 +25,10 @@ -

StepInput

Event [change] :: N/A
-
+

StepInput in Cozy

'input' event prevented

'change' event result

+
+

StepInput with large value and precision (thousands separator)

+ + +

Form validation

@@ -177,7 +184,6 @@

Form validation

min="5" max="10" step="0.05" - value="6" value-precision="2" required id="formStepInput"> diff --git a/packages/website/docs/_samples/main/StepInput/ValuePrecision/sample.html b/packages/website/docs/_samples/main/StepInput/ValuePrecision/sample.html index ca33525c7c66..c2d861e8b4ae 100644 --- a/packages/website/docs/_samples/main/StepInput/ValuePrecision/sample.html +++ b/packages/website/docs/_samples/main/StepInput/ValuePrecision/sample.html @@ -7,7 +7,7 @@ Sample -
+