fix(input-amount): parse amount always on locale once the amount is formatted (#2439)
* fix(input-amount): parse amount always on locale once the amount is formatted * chore: add unit test * chore: add some description * chore: update playwright script to install dependencies * Update .github/workflows/verify-pr.yml * chore: set formatOptions temp and cleanup for programmatic api * feat(form-core): add "user-edit" mode to formatOptions while editing existing value of a form control * chore: enhance code readability and robustness --------- Co-authored-by: Thijs Louisse <thijs.louisse@ing.com>
This commit is contained in:
parent
a992da4340
commit
35e66052b6
11 changed files with 162 additions and 36 deletions
5
.changeset/bright-snakes-smash.md
Normal file
5
.changeset/bright-snakes-smash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ui': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
[form-core] add "user-edit" mode to formatOptions while editing existing value of a form control
|
||||||
|
|
@ -249,7 +249,7 @@ const FormatMixinImplementation = superclass =>
|
||||||
// Apparently, the parser was not able to produce a satisfactory output for the desired
|
// Apparently, the parser was not able to produce a satisfactory output for the desired
|
||||||
// modelValue type, based on the current viewValue. Unparseable allows to restore all
|
// modelValue type, based on the current viewValue. Unparseable allows to restore all
|
||||||
// states (for instance from a lost user session), since it saves the current viewValue.
|
// states (for instance from a lost user session), since it saves the current viewValue.
|
||||||
const result = this.parser(value, this.formatOptions);
|
const result = this.parser(value, { ...this.formatOptions, mode: this.#getFormatMode() });
|
||||||
return result !== undefined ? result : new Unparseable(value);
|
return result !== undefined ? result : new Unparseable(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,13 +269,8 @@ const FormatMixinImplementation = superclass =>
|
||||||
// imperatively, we DO want to format a value (it is the only way to get meaningful
|
// imperatively, we DO want to format a value (it is the only way to get meaningful
|
||||||
// input into `._inputNode` with modelValue as input)
|
// input into `._inputNode` with modelValue as input)
|
||||||
|
|
||||||
if (
|
if (this._isHandlingUserInput && this.hasFeedbackFor?.includes('error') && this._inputNode) {
|
||||||
this._isHandlingUserInput &&
|
return this.value;
|
||||||
this.hasFeedbackFor?.length &&
|
|
||||||
this.hasFeedbackFor.includes('error') &&
|
|
||||||
this._inputNode
|
|
||||||
) {
|
|
||||||
return this._inputNode ? this.value : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.modelValue instanceof Unparseable) {
|
if (this.modelValue instanceof Unparseable) {
|
||||||
|
|
@ -285,7 +280,10 @@ const FormatMixinImplementation = superclass =>
|
||||||
return this.modelValue.viewValue;
|
return this.modelValue.viewValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.formatter(this.modelValue, this.formatOptions);
|
return this.formatter(this.modelValue, {
|
||||||
|
...this.formatOptions,
|
||||||
|
mode: this.#getFormatMode(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -348,7 +346,6 @@ const FormatMixinImplementation = superclass =>
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
__handlePreprocessor() {
|
__handlePreprocessor() {
|
||||||
const unprocessedValue = this.value;
|
|
||||||
let currentCaretIndex = this.value.length;
|
let currentCaretIndex = this.value.length;
|
||||||
// Be gentle with Safari
|
// Be gentle with Safari
|
||||||
if (
|
if (
|
||||||
|
|
@ -364,7 +361,6 @@ const FormatMixinImplementation = superclass =>
|
||||||
prevViewValue: this.__prevViewValue,
|
prevViewValue: this.__prevViewValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.__prevViewValue = unprocessedValue;
|
|
||||||
if (preprocessedValue === undefined) {
|
if (preprocessedValue === undefined) {
|
||||||
// Make sure we do no set back original value, so we preserve
|
// Make sure we do no set back original value, so we preserve
|
||||||
// caret index (== selectionStart/selectionEnd)
|
// caret index (== selectionStart/selectionEnd)
|
||||||
|
|
@ -459,7 +455,7 @@ const FormatMixinImplementation = superclass =>
|
||||||
/**
|
/**
|
||||||
* Configuration object that will be available inside the formatter function
|
* Configuration object that will be available inside the formatter function
|
||||||
*/
|
*/
|
||||||
this.formatOptions = /** @type {FormatOptions} */ ({});
|
this.formatOptions = /** @type {FormatOptions} */ ({ mode: 'auto' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The view value is the result of the formatter function (when available).
|
* The view value is the result of the formatter function (when available).
|
||||||
|
|
@ -543,10 +539,8 @@ const FormatMixinImplementation = superclass =>
|
||||||
*/
|
*/
|
||||||
__onPaste() {
|
__onPaste() {
|
||||||
this._isPasting = true;
|
this._isPasting = true;
|
||||||
this.formatOptions.mode = 'pasted';
|
queueMicrotask(() => {
|
||||||
setTimeout(() => {
|
|
||||||
this._isPasting = false;
|
this._isPasting = false;
|
||||||
this.formatOptions.mode = 'auto';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -588,6 +582,17 @@ const FormatMixinImplementation = superclass =>
|
||||||
this._inputNode.removeEventListener('compositionend', this.__onCompositionEvent);
|
this._inputNode.removeEventListener('compositionend', this.__onCompositionEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#getFormatMode() {
|
||||||
|
if (this._isPasting) {
|
||||||
|
return 'pasted';
|
||||||
|
}
|
||||||
|
const isUserEditing = this._isHandlingUserInput && this.__prevViewValue;
|
||||||
|
if (isUserEditing) {
|
||||||
|
return 'user-edit';
|
||||||
|
}
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FormatMixin = dedupeMixin(FormatMixinImplementation);
|
export const FormatMixin = dedupeMixin(FormatMixinImplementation);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import sinon from 'sinon';
|
||||||
import { Unparseable, Validator, FormatMixin } from '@lion/ui/form-core.js';
|
import { Unparseable, Validator, FormatMixin } from '@lion/ui/form-core.js';
|
||||||
import { getFormControlMembers, mimicUserInput } from '@lion/ui/form-core-test-helpers.js';
|
import { getFormControlMembers, mimicUserInput } from '@lion/ui/form-core-test-helpers.js';
|
||||||
|
|
||||||
|
const isLionInputStepper = (/** @type {FormatClass} */ el) => 'valueTextMapping' in el;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('../types/FormControlMixinTypes.js').FormControlHost} FormControlHost
|
* @typedef {import('../types/FormControlMixinTypes.js').FormControlHost} FormControlHost
|
||||||
* @typedef {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
|
* @typedef {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
|
||||||
|
|
@ -480,7 +482,7 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
/**
|
/**
|
||||||
* @param {FormatClass} el
|
* @param {FormatClass} el
|
||||||
*/
|
*/
|
||||||
function paste(el, val = 'lorem') {
|
function mimicPaste(el, val = 'lorem') {
|
||||||
const { _inputNode } = getFormControlMembers(el);
|
const { _inputNode } = getFormControlMembers(el);
|
||||||
_inputNode.value = val;
|
_inputNode.value = val;
|
||||||
_inputNode.dispatchEvent(new ClipboardEvent('paste', { bubbles: true }));
|
_inputNode.dispatchEvent(new ClipboardEvent('paste', { bubbles: true }));
|
||||||
|
|
@ -494,12 +496,17 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
const formatterSpy = sinon.spy(el, 'formatter');
|
const formatterSpy = sinon.spy(el, 'formatter');
|
||||||
paste(el);
|
mimicPaste(el);
|
||||||
expect(formatterSpy).to.be.called;
|
expect(formatterSpy).to.be.called;
|
||||||
expect(/** @type {{mode: string}} */ (formatterSpy.args[0][1]).mode).to.equal('pasted');
|
expect(/** @type {{mode: string}} */ (formatterSpy.lastCall.args[1]).mode).to.equal(
|
||||||
|
'pasted',
|
||||||
|
);
|
||||||
|
// give microtask of _isPasting chance to reset
|
||||||
await aTimeout(0);
|
await aTimeout(0);
|
||||||
mimicUserInput(el, '');
|
el.modelValue = 'foo';
|
||||||
expect(/** @type {{mode: string}} */ (formatterSpy.args[0][1]).mode).to.equal('auto');
|
expect(/** @type {{mode: string}} */ (formatterSpy.lastCall.args[1]).mode).to.equal(
|
||||||
|
'auto',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets protected value "_isPasting" for Subclassers', async () => {
|
it('sets protected value "_isPasting" for Subclassers', async () => {
|
||||||
|
|
@ -509,7 +516,7 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
const formatterSpy = sinon.spy(el, 'formatter');
|
const formatterSpy = sinon.spy(el, 'formatter');
|
||||||
paste(el);
|
mimicPaste(el);
|
||||||
expect(formatterSpy).to.have.been.called;
|
expect(formatterSpy).to.have.been.called;
|
||||||
// @ts-ignore [allow-protected] in test
|
// @ts-ignore [allow-protected] in test
|
||||||
expect(el._isPasting).to.be.true;
|
expect(el._isPasting).to.be.true;
|
||||||
|
|
@ -526,7 +533,7 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
);
|
);
|
||||||
// @ts-ignore [allow-protected] in test
|
// @ts-ignore [allow-protected] in test
|
||||||
const reflectBackSpy = sinon.spy(el, '_reflectBackOn');
|
const reflectBackSpy = sinon.spy(el, '_reflectBackOn');
|
||||||
paste(el);
|
mimicPaste(el);
|
||||||
expect(reflectBackSpy).to.have.been.called;
|
expect(reflectBackSpy).to.have.been.called;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -538,10 +545,33 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
);
|
);
|
||||||
// @ts-ignore [allow-protected] in test
|
// @ts-ignore [allow-protected] in test
|
||||||
const reflectBackSpy = sinon.spy(el, '_reflectBackOn');
|
const reflectBackSpy = sinon.spy(el, '_reflectBackOn');
|
||||||
paste(el);
|
mimicPaste(el);
|
||||||
expect(reflectBackSpy).to.have.been.called;
|
expect(reflectBackSpy).to.have.been.called;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('On user input', () => {
|
||||||
|
it('adjusts formatOptions.mode to "user-edit" for parser when user changes value', async () => {
|
||||||
|
const el = /** @type {FormatClass} */ (
|
||||||
|
await fixture(html`<${tag}><input slot="input"></${tag}>`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const parserSpy = sinon.spy(el, 'parser');
|
||||||
|
|
||||||
|
// Here we get auto as we start from '' (so there was no value to edit)
|
||||||
|
mimicUserInput(el, 'some val');
|
||||||
|
expect(/** @type {{mode: string}} */ (parserSpy.lastCall.args[1]).mode).to.equal(
|
||||||
|
'auto',
|
||||||
|
);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
mimicUserInput(el, 'some other val');
|
||||||
|
expect(/** @type {{mode: string}} */ (parserSpy.lastCall.args[1]).mode).to.equal(
|
||||||
|
'user-edit',
|
||||||
|
);
|
||||||
|
await el.updateComplete;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Parser', () => {
|
describe('Parser', () => {
|
||||||
|
|
@ -597,6 +627,43 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
expect(_inputNode.value).to.equal(val);
|
expect(_inputNode.value).to.equal(val);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does only calculate derived values as consequence of user input when preprocessed value is different from previous view value', async () => {
|
||||||
|
const val = generateValueBasedOnType({ viewValue: true }) || 'init-value';
|
||||||
|
if (typeof val !== 'string') return;
|
||||||
|
|
||||||
|
const preprocessorSpy = sinon.spy(v => v.replace(/\$$/g, ''));
|
||||||
|
const el = /** @type {FormatClass} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${tag} .preprocessor=${preprocessorSpy}>
|
||||||
|
<input slot="input" .value="${val}">
|
||||||
|
</${tag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: find out why we need to skip this for lion-input-stepper
|
||||||
|
if (isLionInputStepper(el)) return;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The _calculateValues method is called inside _onUserInputChanged w/o providing args
|
||||||
|
* @param {sinon.SinonSpyCall} call
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
const isCalculateCallAfterUserInput = call => call.args[0]?.length === 0;
|
||||||
|
|
||||||
|
const didRecalculateAfterUserInput = (/** @type {sinon.SinonSpy<any[], any>} */ spy) =>
|
||||||
|
spy.callCount > 1 && !spy.getCalls().find(isCalculateCallAfterUserInput);
|
||||||
|
|
||||||
|
// @ts-expect-error [allow-protected] in test
|
||||||
|
const calcValuesSpy = sinon.spy(el, '_calculateValues');
|
||||||
|
// this value gets preprocessed to 'val'
|
||||||
|
mimicUserInput(el, `${val}$`);
|
||||||
|
expect(didRecalculateAfterUserInput(calcValuesSpy)).to.be.false;
|
||||||
|
|
||||||
|
// this value gets preprocessed to 'value' (and thus differs from previous)
|
||||||
|
mimicUserInput(el, `${val}ue$`);
|
||||||
|
expect(didRecalculateAfterUserInput(calcValuesSpy)).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
it('does not preprocess during composition', async () => {
|
it('does not preprocess during composition', async () => {
|
||||||
const el = /** @type {FormatClass} */ (
|
const el = /** @type {FormatClass} */ (
|
||||||
await fixture(html`
|
await fixture(html`
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { LitElement } from 'lit';
|
||||||
import { ValidateHost } from './validate/ValidateMixinTypes.js';
|
import { ValidateHost } from './validate/ValidateMixinTypes.js';
|
||||||
import { FormControlHost } from './FormControlMixinTypes.js';
|
import { FormControlHost } from './FormControlMixinTypes.js';
|
||||||
|
|
||||||
export type FormatOptions = { mode: 'pasted' | 'auto' } & object;
|
export type FormatOptions = { mode: 'pasted' | 'auto' | 'user-edit'} & object;
|
||||||
export declare class FormatHost {
|
export declare class FormatHost {
|
||||||
/**
|
/**
|
||||||
* Converts viewValue to modelValue
|
* Converts viewValue to modelValue
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { parseNumber, getFractionDigits } from '@lion/ui/localize-no-side-effects.js';
|
import { parseNumber, getFractionDigits } from '@lion/ui/localize-no-side-effects.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('../../localize/types/LocalizeMixinTypes.js').FormatNumberOptions} FormatOptions
|
* @typedef {import('../../localize/types/LocalizeMixinTypes.js').FormatNumberOptions} FormatNumberOptions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -28,7 +28,7 @@ function round(value, decimals) {
|
||||||
* parseAmount('1,234.56', {currency: 'JOD'}); => 1234.560
|
* parseAmount('1,234.56', {currency: 'JOD'}); => 1234.560
|
||||||
*
|
*
|
||||||
* @param {string} value Number to be parsed
|
* @param {string} value Number to be parsed
|
||||||
* @param {FormatOptions} [givenOptions] Locale Options
|
* @param {FormatNumberOptions} [givenOptions] Locale Options
|
||||||
*/
|
*/
|
||||||
export function parseAmount(value, givenOptions) {
|
export function parseAmount(value, givenOptions) {
|
||||||
const unmatchedInput = value.match(/[^0-9,.\- ]/g);
|
const unmatchedInput = value.match(/[^0-9,.\- ]/g);
|
||||||
|
|
@ -44,7 +44,7 @@ export function parseAmount(value, givenOptions) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {FormatOptions} */
|
/** @type {FormatNumberOptions} */
|
||||||
const options = {
|
const options = {
|
||||||
...givenOptions,
|
...givenOptions,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { aTimeout, expect, fixture } from '@open-wc/testing';
|
import { aTimeout, expect, fixture } from '@open-wc/testing';
|
||||||
import { html } from 'lit';
|
import { html } from 'lit';
|
||||||
import { localize } from '@lion/ui/localize.js';
|
import { getLocalizeManager } from '@lion/ui/localize-no-side-effects.js';
|
||||||
import { localizeTearDown } from '@lion/ui/localize-test-helpers.js';
|
import { localizeTearDown } from '@lion/ui/localize-test-helpers.js';
|
||||||
import { getInputMembers } from '@lion/ui/input-test-helpers.js';
|
import { getInputMembers } from '@lion/ui/input-test-helpers.js';
|
||||||
import { LionInputAmount, formatAmount, parseAmount } from '@lion/ui/input-amount.js';
|
import { LionInputAmount, formatAmount, parseAmount } from '@lion/ui/input-amount.js';
|
||||||
|
import { mimicUserInput } from '@lion/ui/form-core-test-helpers.js';
|
||||||
|
import sinon from 'sinon';
|
||||||
import '@lion/ui/define/lion-input-amount.js';
|
import '@lion/ui/define/lion-input-amount.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -12,6 +13,8 @@ import '@lion/ui/define/lion-input-amount.js';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
describe('<lion-input-amount>', () => {
|
describe('<lion-input-amount>', () => {
|
||||||
|
const localize = getLocalizeManager();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localizeTearDown();
|
localizeTearDown();
|
||||||
});
|
});
|
||||||
|
|
@ -128,6 +131,37 @@ describe('<lion-input-amount>', () => {
|
||||||
expect(_inputNode.value).to.equal('100.12');
|
expect(_inputNode.value).to.equal('100.12');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adjusts formats with locale when formatOptions.mode is "user-edit"', async () => {
|
||||||
|
const el = /** @type {LionInputAmount} */ (
|
||||||
|
await fixture(
|
||||||
|
html`<lion-input-amount
|
||||||
|
.modelValue=${123456.78}
|
||||||
|
currency="EUR"
|
||||||
|
.formatOptions="${{ locale: 'nl-NL' }}"
|
||||||
|
></lion-input-amount>`,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const parserSpy = sinon.spy(el, 'parser');
|
||||||
|
const formatterSpy = sinon.spy(el, 'formatter');
|
||||||
|
|
||||||
|
// @ts-expect-error [allow-protected] in test
|
||||||
|
expect(el._inputNode.value).to.equal('123.456,78');
|
||||||
|
|
||||||
|
// When editing an already existing value, we interpet the separators as they are
|
||||||
|
mimicUserInput(el, '123.456');
|
||||||
|
expect(parserSpy.lastCall.args[1]?.mode).to.equal('user-edit');
|
||||||
|
expect(formatterSpy.lastCall.args[1]?.mode).to.equal('user-edit');
|
||||||
|
expect(el.modelValue).to.equal(123456);
|
||||||
|
expect(el.formattedValue).to.equal('123.456,00');
|
||||||
|
|
||||||
|
// Formatting should only affect values that should be formatted / parsed as a consequence of user input.
|
||||||
|
// When a user finished editing, the default should be restored.
|
||||||
|
// (think of a programmatically set modelValue, that should behave idempotent, regardless of when it is set)
|
||||||
|
el.modelValue = 1234;
|
||||||
|
expect(el.formattedValue).to.equal('1.234,00');
|
||||||
|
expect(formatterSpy.lastCall.args[1]?.mode).to.equal('auto');
|
||||||
|
});
|
||||||
|
|
||||||
it('sets inputmode attribute to decimal', async () => {
|
it('sets inputmode attribute to decimal', async () => {
|
||||||
const el = /** @type {LionInputAmount} */ (
|
const el = /** @type {LionInputAmount} */ (
|
||||||
await fixture(`<lion-input-amount></lion-input-amount>`)
|
await fixture(`<lion-input-amount></lion-input-amount>`)
|
||||||
|
|
|
||||||
|
|
@ -60,4 +60,11 @@ describe('parseAmount()', async () => {
|
||||||
expect(parseAmount('foo1', { mode: 'pasted' })).to.equal(1);
|
expect(parseAmount('foo1', { mode: 'pasted' })).to.equal(1);
|
||||||
expect(parseAmount('EUR 1,50', { mode: 'pasted' })).to.equal(1.5);
|
expect(parseAmount('EUR 1,50', { mode: 'pasted' })).to.equal(1.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses based on locale when "user-edit" mode used', async () => {
|
||||||
|
expect(parseAmount('123,456.78', { mode: 'auto' })).to.equal(123456.78);
|
||||||
|
expect(parseAmount('123,456.78', { mode: 'user-edit' })).to.equal(123456.78);
|
||||||
|
expect(parseAmount('123.456,78', { mode: 'auto' })).to.equal(123456.78);
|
||||||
|
expect(parseAmount('123.456,78', { mode: 'user-edit' })).to.equal(123.45678);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { getLocale } from '../utils/getLocale.js';
|
||||||
* @returns {string} The separator
|
* @returns {string} The separator
|
||||||
*/
|
*/
|
||||||
export function getDecimalSeparator(locale, options) {
|
export function getDecimalSeparator(locale, options) {
|
||||||
if (options && options.decimalSeparator) {
|
if (options?.decimalSeparator) {
|
||||||
return options.decimalSeparator;
|
return options.decimalSeparator;
|
||||||
}
|
}
|
||||||
const computedLocale = getLocale(locale);
|
const computedLocale = getLocale(locale);
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,15 @@ import { getDecimalSeparator } from './getDecimalSeparator.js';
|
||||||
*
|
*
|
||||||
* @param {string} value Clean number (only [0-9 ,.]) to be parsed
|
* @param {string} value Clean number (only [0-9 ,.]) to be parsed
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
* @param {string?} [options.mode] auto|pasted
|
* @param {string?} [options.mode] auto|pasted|user-edit
|
||||||
* @return {string} unparseable|withLocale|heuristic
|
* @return {string} unparseable|withLocale|heuristic
|
||||||
*/
|
*/
|
||||||
function getParseMode(value, { mode = 'auto' } = {}) {
|
function getParseMode(value, { mode = 'auto' } = {}) {
|
||||||
const separators = value.match(/[., ]/g);
|
const separators = value.match(/[., ]/g);
|
||||||
|
|
||||||
if (!separators) {
|
// When a user edits an existin value, we already formatted it with a certain locale.
|
||||||
|
// For best UX, we stick with this locale
|
||||||
|
if (!separators || mode === 'user-edit') {
|
||||||
return 'withLocale';
|
return 'withLocale';
|
||||||
}
|
}
|
||||||
if (mode === 'auto' && separators.length === 1) {
|
if (mode === 'auto' && separators.length === 1) {
|
||||||
|
|
@ -52,8 +54,7 @@ function getParseMode(value, { mode = 'auto' } = {}) {
|
||||||
* @param {import('../../types/LocalizeMixinTypes.js').FormatNumberOptions} options Locale Options
|
* @param {import('../../types/LocalizeMixinTypes.js').FormatNumberOptions} options Locale Options
|
||||||
*/
|
*/
|
||||||
function parseWithLocale(value, options) {
|
function parseWithLocale(value, options) {
|
||||||
const locale = options && options.locale ? options.locale : undefined;
|
const separator = getDecimalSeparator(options?.locale, options);
|
||||||
const separator = getDecimalSeparator(locale, options);
|
|
||||||
const regexNumberAndLocaleSeparator = new RegExp(`[0-9${separator}-]`, 'g');
|
const regexNumberAndLocaleSeparator = new RegExp(`[0-9${separator}-]`, 'g');
|
||||||
let numberAndLocaleSeparator = value.match(regexNumberAndLocaleSeparator)?.join('');
|
let numberAndLocaleSeparator = value.match(regexNumberAndLocaleSeparator)?.join('');
|
||||||
if (separator === ',') {
|
if (separator === ',') {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,13 @@ describe('parseNumber()', () => {
|
||||||
expect(parseNumber('123456.78', { mode: 'pasted' })).to.equal(123456.78);
|
expect(parseNumber('123456.78', { mode: 'pasted' })).to.equal(123456.78);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects separators withLocale when "user-edit" mode used e.g. 123.456,78', async () => {
|
||||||
|
expect(parseNumber('123,456.78', { mode: 'auto' })).to.equal(123456.78);
|
||||||
|
expect(parseNumber('123,456.78', { mode: 'user-edit' })).to.equal(123456.78);
|
||||||
|
expect(parseNumber('123.456,78', { mode: 'auto' })).to.equal(123456.78);
|
||||||
|
expect(parseNumber('123.456,78', { mode: 'user-edit' })).to.equal(123.45678);
|
||||||
|
});
|
||||||
|
|
||||||
it('detects separators unparseable when there are 2 same ones e.g. 1.234.56', () => {
|
it('detects separators unparseable when there are 2 same ones e.g. 1.234.56', () => {
|
||||||
expect(parseNumber('1.234.56')).to.equal(123456);
|
expect(parseNumber('1.234.56')).to.equal(123456);
|
||||||
expect(parseNumber('1,234,56')).to.equal(123456);
|
expect(parseNumber('1,234,56')).to.equal(123456);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export declare interface FormatDateOptions extends Intl.DateTimeFormatOptions {
|
||||||
|
|
||||||
roundMode?: string;
|
roundMode?: string;
|
||||||
returnIfNaN?: string;
|
returnIfNaN?: string;
|
||||||
mode?: 'pasted' | 'auto';
|
mode?: 'pasted' | 'auto' | 'user-edit';
|
||||||
|
|
||||||
postProcessors?: Map<string, DatePostProcessor>;
|
postProcessors?: Map<string, DatePostProcessor>;
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +41,7 @@ export declare interface FormatNumberOptions extends Intl.NumberFormatOptions {
|
||||||
// https://en.wikipedia.org/wiki/Decimal_separator#Digit_grouping
|
// https://en.wikipedia.org/wiki/Decimal_separator#Digit_grouping
|
||||||
// note the half space in there as well
|
// note the half space in there as well
|
||||||
groupSeparator?: ',' | '.' | ' ' | '_' | ' ' | "'";
|
groupSeparator?: ',' | '.' | ' ' | '_' | ' ' | "'";
|
||||||
mode?: 'pasted' | 'auto';
|
mode?: 'pasted' | 'auto' | 'user-edit';
|
||||||
|
|
||||||
postProcessors?: Map<string, NumberPostProcessor>;
|
postProcessors?: Map<string, NumberPostProcessor>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue