fix(form-core): do not preprocess during composition

This commit is contained in:
Thijs Louisse 2021-03-31 19:47:56 +02:00
parent 3c57fc3062
commit 3b5ed3222f
4 changed files with 394 additions and 323 deletions

View file

@ -0,0 +1,7 @@
---
'@lion/form-core': patch
---
### Bug fixes
fix(form-core): do not preprocess during composition

View file

@ -233,13 +233,6 @@ const FormatMixinImplementation = superclass =>
this.__preventRecursiveTrigger = false; this.__preventRecursiveTrigger = false;
} }
/**
* @param {string} value
*/
__callPreprocessor(value) {
return this.preprocessor(value);
}
/** /**
* @param {string|undefined} value * @param {string|undefined} value
* @return {?} * @return {?}
@ -348,7 +341,9 @@ const FormatMixinImplementation = superclass =>
* to the parsing/formatting/serializing loop. * to the parsing/formatting/serializing loop.
*/ */
_syncValueUpwards() { _syncValueUpwards() {
this.value = this.__callPreprocessor(this.value); if (!this.__isHandlingComposition) {
this.value = this.preprocessor(this.value);
}
this.modelValue = this.__callParser(this.value); this.modelValue = this.__callParser(this.value);
} }
@ -366,6 +361,10 @@ const FormatMixinImplementation = superclass =>
} }
/** /**
* Every time .formattedValue is attempted to sync to the view value (on change/blur and on
* modelValue change), this condition is checked. When enhancing it, it's recommended to
* call `super._reflectBackOn()`
* @overridable
* @return {boolean} * @return {boolean}
*/ */
_reflectBackOn() { _reflectBackOn() {
@ -393,10 +392,25 @@ const FormatMixinImplementation = superclass =>
this.__isHandlingUserInput = false; this.__isHandlingUserInput = false;
} }
/**
* @param {Event} event
*/
__onCompositionEvent({ type }) {
if (type === 'compositionstart') {
this.__isHandlingComposition = true;
} else if (type === 'compositionend') {
this.__isHandlingComposition = false;
// in all other cases this would be triggered via user-input-changed
this._syncValueUpwards();
}
}
constructor() { constructor() {
super(); super();
this.formatOn = 'change'; this.formatOn = 'change';
this.formatOptions = /** @type {FormatOptions} */ ({}); this.formatOptions = /** @type {FormatOptions} */ ({});
this.__onCompositionEvent = this.__onCompositionEvent.bind(this);
} }
connectedCallback() { connectedCallback() {
@ -422,6 +436,8 @@ const FormatMixinImplementation = superclass =>
if (this._inputNode) { if (this._inputNode) {
this._inputNode.addEventListener(this.formatOn, this._reflectBackFormattedValueDebounced); this._inputNode.addEventListener(this.formatOn, this._reflectBackFormattedValueDebounced);
this._inputNode.addEventListener('input', this._proxyInputEvent); this._inputNode.addEventListener('input', this._proxyInputEvent);
this._inputNode.addEventListener('compositionstart', this.__onCompositionEvent);
this._inputNode.addEventListener('compositionend', this.__onCompositionEvent);
} }
} }
@ -435,6 +451,8 @@ const FormatMixinImplementation = superclass =>
/** @type {EventListenerOrEventListenerObject} */ (this /** @type {EventListenerOrEventListenerObject} */ (this
._reflectBackFormattedValueDebounced), ._reflectBackFormattedValueDebounced),
); );
this._inputNode.removeEventListener('compositionstart', this.__onCompositionEvent);
this._inputNode.removeEventListener('compositionend', this.__onCompositionEvent);
} }
} }
}; };

View file

@ -35,10 +35,17 @@ class FormatClass extends FormatMixin(LitElement) {
/** /**
* @param {FormatClass} formControl * @param {FormatClass} formControl
* @param {?} newViewValue * @param {?} newViewValue
* @param {{caretIndex?:number}} config
*/ */
function mimicUserInput(formControl, newViewValue) { function mimicUserInput(formControl, newViewValue, { caretIndex } = {}) {
formControl.value = newViewValue; // eslint-disable-line no-param-reassign formControl.value = newViewValue; // eslint-disable-line no-param-reassign
formControl._inputNode.dispatchEvent(new CustomEvent('input', { bubbles: true })); if (caretIndex) {
// eslint-disable-next-line no-param-reassign
formControl._inputNode.selectionStart = caretIndex;
// eslint-disable-next-line no-param-reassign
formControl._inputNode.selectionEnd = caretIndex;
}
formControl._inputNode.dispatchEvent(new Event('input', { bubbles: true }));
} }
/** /**
@ -87,7 +94,7 @@ export function runFormatMixinSuite(customConfig) {
describe('FormatMixin', async () => { describe('FormatMixin', async () => {
/** @type {{d: any}} */ /** @type {{d: any}} */
let elem; let tag;
/** @type {FormatClass} */ /** @type {FormatClass} */
let nonFormat; let nonFormat;
/** @type {FormatClass} */ /** @type {FormatClass} */
@ -97,87 +104,31 @@ export function runFormatMixinSuite(customConfig) {
if (!cfg.tagString) { if (!cfg.tagString) {
cfg.tagString = defineCE(FormatClass); cfg.tagString = defineCE(FormatClass);
} }
elem = unsafeStatic(cfg.tagString); tag = unsafeStatic(cfg.tagString);
nonFormat = await fixture(html` nonFormat = await fixture(html`
<${elem} <${tag}
.formatter="${/** @param {?} v */ v => v}" .formatter="${/** @param {?} v */ v => v}"
.parser="${/** @param {string} v */ v => v}" .parser="${/** @param {string} v */ v => v}"
.serializer="${/** @param {?} v */ v => v}" .serializer="${/** @param {?} v */ v => v}"
.deserializer="${/** @param {string} v */ v => v}" .deserializer="${/** @param {string} v */ v => v}"
> >
<input slot="input"> <input slot="input">
</${elem}> </${tag}>
`); `);
fooFormat = await fixture(html` fooFormat = await fixture(html`
<${elem} <${tag}
.formatter="${/** @param {string} value */ value => `foo: ${value}`}" .formatter="${/** @param {string} value */ value => `foo: ${value}`}"
.parser="${/** @param {string} value */ value => value.replace('foo: ', '')}" .parser="${/** @param {string} value */ value => value.replace('foo: ', '')}"
.serializer="${/** @param {string} value */ value => `[foo] ${value}`}" .serializer="${/** @param {string} value */ value => `[foo] ${value}`}"
.deserializer="${/** @param {string} value */ value => value.replace('[foo] ', '')}" .deserializer="${/** @param {string} value */ value => value.replace('[foo] ', '')}"
> >
<input slot="input"> <input slot="input">
</${elem}> </${tag}>
`); `);
}); });
it('fires `model-value-changed` for every input triggered by user', async () => {
const formatEl = /** @type {FormatClass} */ (await fixture(
html`<${elem}><input slot="input"></${elem}>`,
));
let counter = 0;
let isTriggeredByUser = false;
formatEl.addEventListener('model-value-changed', (
/** @param {CustomEvent} event */ event,
) => {
counter += 1;
isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
});
mimicUserInput(formatEl, generateValueBasedOnType());
expect(counter).to.equal(1);
expect(isTriggeredByUser).to.be.true;
// Counter offset +1 for Date because parseDate created a new Date object
// when the user changes the value.
// This will result in a model-value-changed trigger even if the user value was the same
// TODO: a proper solution would be to add `hasChanged` to input-date, like isSameDate()
// from calendar utils
const counterOffset = cfg.modelValueType === Date ? 1 : 0;
mimicUserInput(formatEl, generateValueBasedOnType());
expect(counter).to.equal(1 + counterOffset);
mimicUserInput(formatEl, generateValueBasedOnType({ toggleValue: true }));
expect(counter).to.equal(2 + counterOffset);
});
it('fires `model-value-changed` for every programmatic modelValue change', async () => {
const el = /** @type {FormatClass} */ (await fixture(
html`<${elem}><input slot="input"></${elem}>`,
));
let counter = 0;
let isTriggeredByUser = false;
el.addEventListener('model-value-changed', event => {
counter += 1;
isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
});
el.modelValue = 'one';
expect(counter).to.equal(1);
expect(isTriggeredByUser).to.be.false;
// no change means no event
el.modelValue = 'one';
expect(counter).to.equal(1);
el.modelValue = 'two';
expect(counter).to.equal(2);
});
it('has modelValue, formattedValue and serializedValue which are computed synchronously', async () => { it('has modelValue, formattedValue and serializedValue which are computed synchronously', async () => {
expect(nonFormat.modelValue).to.equal('', 'modelValue initially'); expect(nonFormat.modelValue).to.equal('', 'modelValue initially');
expect(nonFormat.formattedValue).to.equal('', 'formattedValue initially'); expect(nonFormat.formattedValue).to.equal('', 'formattedValue initially');
@ -189,110 +140,212 @@ export function runFormatMixinSuite(customConfig) {
expect(nonFormat.serializedValue).to.equal(generatedValue, 'serializedValue synchronized'); expect(nonFormat.serializedValue).to.equal(generatedValue, 'serializedValue synchronized');
}); });
it('has an input node (like <input>/<textarea>) which holds the formatted (view) value', async () => { /**
fooFormat.modelValue = 'string'; * Reminder: modelValue is the SSOT all other values are derived from
expect(fooFormat.formattedValue).to.equal('foo: string'); * (and should be translated to)
expect(fooFormat.value).to.equal('foo: string'); */
expect(fooFormat._inputNode.value).to.equal('foo: string'); describe('ModelValue', () => {
it('fires `model-value-changed` for every programmatic modelValue change', async () => {
const el = /** @type {FormatClass} */ (await fixture(
html`<${tag}><input slot="input"></${tag}>`,
));
let counter = 0;
let isTriggeredByUser = false;
el.addEventListener('model-value-changed', event => {
counter += 1;
isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
});
el.modelValue = 'one';
expect(counter).to.equal(1);
expect(isTriggeredByUser).to.be.false;
// no change means no event
el.modelValue = 'one';
expect(counter).to.equal(1);
el.modelValue = 'two';
expect(counter).to.equal(2);
});
it('fires `model-value-changed` for every user input, adding `isTriggeredByUser` in event detail', async () => {
const formatEl = /** @type {FormatClass} */ (await fixture(
html`<${tag}><input slot="input"></${tag}>`,
));
let counter = 0;
let isTriggeredByUser = false;
formatEl.addEventListener('model-value-changed', (
/** @param {CustomEvent} event */ event,
) => {
counter += 1;
isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
});
mimicUserInput(formatEl, generateValueBasedOnType());
expect(counter).to.equal(1);
expect(isTriggeredByUser).to.be.true;
// Counter offset +1 for Date because parseDate created a new Date object
// when the user changes the value.
// This will result in a model-value-changed trigger even if the user value was the same
// TODO: a proper solution would be to add `hasChanged` to input-date, like isSameDate()
// from calendar utils
const counterOffset = cfg.modelValueType === Date ? 1 : 0;
mimicUserInput(formatEl, generateValueBasedOnType());
expect(counter).to.equal(1 + counterOffset);
mimicUserInput(formatEl, generateValueBasedOnType({ toggleValue: true }));
expect(counter).to.equal(2 + counterOffset);
});
it('synchronizes _inputNode.value as a fallback mechanism on init (when no modelValue provided)', async () => {
// Note that in lion-field, the attribute would be put on <lion-field>, not on <input>
const formatElem = /** @type {FormatClass} */ (await fixture(html`
<${tag}
value="string"
.formatter=${/** @param {string} value */ value => `foo: ${value}`}
.parser=${/** @param {string} value */ value => value.replace('foo: ', '')}
.serializer=${/** @param {string} value */ value => `[foo] ${value}`}
.deserializer=${/** @param {string} value */ value => value.replace('[foo] ', '')}
>
<input slot="input" value="string" />
</${tag}>
`));
// Now check if the format/parse/serialize loop has been triggered
await formatElem.updateComplete;
expect(formatElem.formattedValue).to.equal('foo: string');
expect(formatElem._inputNode.value).to.equal('foo: string');
expect(formatElem.serializedValue).to.equal('[foo] string');
expect(formatElem.modelValue).to.equal('string');
});
describe('Unparseable values', () => {
it('converts to Unparseable when wrong value inputted by user', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${tag} .parser=${
/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
}
>
<input slot="input">
</${tag}>
`));
mimicUserInput(el, 'test');
expect(el.modelValue).to.be.an.instanceof(Unparseable);
});
it('preserves the viewValue when unparseable', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${tag}
.parser=${
/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
}
>
<input slot="input">
</${tag}>
`));
mimicUserInput(el, 'test');
expect(el.formattedValue).to.equal('test');
expect(el.value).to.equal('test');
});
it('displays the viewValue when modelValue is of type Unparseable', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${tag}
.parser=${
/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
}
>
<input slot="input">
</${tag}>
`));
el.modelValue = new Unparseable('foo');
expect(el.value).to.equal('foo');
});
it('empty strings are not Unparseable', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${tag}>
<input slot="input" value="string">
</${tag}>
`));
// This could happen when the user erases the input value
mimicUserInput(el, '');
// For backwards compatibility, we keep the modelValue an empty string here.
// Undefined would be more appropriate 'conceptually', however
expect(el.modelValue).to.equal('');
});
});
}); });
it('converts modelValue => formattedValue (via this.formatter)', async () => { describe('View value', () => {
fooFormat.modelValue = 'string'; it('has an input node (like <input>/<textarea>) which holds the formatted (view) value', async () => {
expect(fooFormat.formattedValue).to.equal('foo: string'); fooFormat.modelValue = 'string';
expect(fooFormat.serializedValue).to.equal('[foo] string'); expect(fooFormat.formattedValue).to.equal('foo: string');
expect(fooFormat.value).to.equal('foo: string');
expect(fooFormat._inputNode.value).to.equal('foo: string');
});
it('works if there is no underlying _inputNode', async () => {
const tagNoInputString = defineCE(class extends FormatMixin(LitElement) {});
const tagNoInput = unsafeStatic(tagNoInputString);
expect(async () => {
/** @type {FormatClass} */ (await fixture(html`<${tagNoInput}></${tagNoInput}>`));
}).to.not.throw();
});
describe('Presenting value to end user', () => {
it('reflects back formatted value to user on leave', async () => {
const formatEl = /** @type {FormatClass} */ (await fixture(html`
<${tag} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
<input slot="input" />
</${tag}>
`));
const generatedViewValue = generateValueBasedOnType({ viewValue: true });
const generatedModelValue = generateValueBasedOnType();
mimicUserInput(formatEl, generatedViewValue);
expect(formatEl._inputNode.value).to.not.equal(`foo: ${generatedModelValue}`);
// user leaves field
formatEl._inputNode.dispatchEvent(new CustomEvent(formatEl.formatOn, { bubbles: true }));
await aTimeout(0);
expect(formatEl._inputNode.value).to.equal(`foo: ${generatedModelValue}`);
});
it('reflects back .formattedValue immediately when .modelValue changed imperatively', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${tag} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
<input slot="input" />
</${tag}>
`));
// The FormatMixin can be used in conjunction with the ValidateMixin, in which case
// it can hold errorState (affecting the formatting)
el.hasFeedbackFor = ['error'];
// users types value 'test'
mimicUserInput(el, 'test');
expect(el._inputNode.value).to.not.equal('foo: test');
// Now see the difference for an imperative change
el.modelValue = 'test2';
expect(el._inputNode.value).to.equal('foo: test2');
});
});
}); });
it('converts modelValue => serializedValue (via this.serializer)', async () => { describe('Parser / formatter / serializer / preprocessor', () => {
fooFormat.modelValue = 'string'; it('calls the parser|formatter|serializer|preprocessor provided by user', async () => {
expect(fooFormat.serializedValue).to.equal('[foo] string');
});
it('converts formattedValue => modelValue (via this.parser)', async () => {
fooFormat.formattedValue = 'foo: string';
expect(fooFormat.modelValue).to.equal('string');
});
it('converts serializedValue => modelValue (via this.deserializer)', async () => {
fooFormat.serializedValue = '[foo] string';
expect(fooFormat.modelValue).to.equal('string');
});
it('synchronizes _inputNode.value as a fallback mechanism', async () => {
// Note that in lion-field, the attribute would be put on <lion-field>, not on <input>
const formatElem = /** @type {FormatClass} */ (await fixture(html`
<${elem}
value="string"
.formatter=${/** @param {string} value */ value => `foo: ${value}`}
.parser=${/** @param {string} value */ value => value.replace('foo: ', '')}
.serializer=${/** @param {string} value */ value => `[foo] ${value}`}
.deserializer=${/** @param {string} value */ value => value.replace('[foo] ', '')}
>
<input slot="input" value="string" />
</${elem}>
`));
// Now check if the format/parse/serialize loop has been triggered
await formatElem.updateComplete;
expect(formatElem.formattedValue).to.equal('foo: string');
expect(formatElem._inputNode.value).to.equal('foo: string');
expect(formatElem.serializedValue).to.equal('[foo] string');
expect(formatElem.modelValue).to.equal('string');
});
it('reflects back formatted value to user on leave', async () => {
const formatEl = /** @type {FormatClass} */ (await fixture(html`
<${elem} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
<input slot="input" />
</${elem}>
`));
const generatedViewValue = generateValueBasedOnType({ viewValue: true });
const generatedModelValue = generateValueBasedOnType();
mimicUserInput(formatEl, generatedViewValue);
expect(formatEl._inputNode.value).to.not.equal(`foo: ${generatedModelValue}`);
// user leaves field
formatEl._inputNode.dispatchEvent(new CustomEvent(formatEl.formatOn, { bubbles: true }));
await aTimeout(0);
expect(formatEl._inputNode.value).to.equal(`foo: ${generatedModelValue}`);
});
it('reflects back .formattedValue immediately when .modelValue changed imperatively', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
<input slot="input" />
</${elem}>
`));
// The FormatMixin can be used in conjunction with the ValidateMixin, in which case
// it can hold errorState (affecting the formatting)
el.hasFeedbackFor = ['error'];
// users types value 'test'
mimicUserInput(el, 'test');
expect(el._inputNode.value).to.not.equal('foo: test');
// Now see the difference for an imperative change
el.modelValue = 'test2';
expect(el._inputNode.value).to.equal('foo: test2');
});
it('works if there is no underlying _inputNode', async () => {
const tagNoInputString = defineCE(class extends FormatMixin(LitElement) {});
const tagNoInput = unsafeStatic(tagNoInputString);
expect(async () => {
/** @type {FormatClass} */ (await fixture(html`<${tagNoInput}></${tagNoInput}>`));
}).to.not.throw();
});
describe('parsers/formatters/serializers/preprocessors', () => {
it('should call the parser|formatter|serializer|preprocessor provided by user', async () => {
const formatterSpy = sinon.spy(value => `foo: ${value}`); const formatterSpy = sinon.spy(value => `foo: ${value}`);
const parserSpy = sinon.spy(value => value.replace('foo: ', '')); const parserSpy = sinon.spy(value => value.replace('foo: ', ''));
const serializerSpy = sinon.spy(value => `[foo] ${value}`); const serializerSpy = sinon.spy(value => `[foo] ${value}`);
const preprocessorSpy = sinon.spy(value => value.replace('bar', '')); const preprocessorSpy = sinon.spy(value => value.replace('bar', ''));
const el = /** @type {FormatClass} */ (await fixture(html` const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} <${tag}
.formatter=${formatterSpy} .formatter=${formatterSpy}
.parser=${parserSpy} .parser=${parserSpy}
.serializer=${serializerSpy} .serializer=${serializerSpy}
@ -300,180 +353,172 @@ export function runFormatMixinSuite(customConfig) {
.modelValue=${'test'} .modelValue=${'test'}
> >
<input slot="input"> <input slot="input">
</${elem}> </${tag}>
`)); `));
expect(formatterSpy.called).to.equal(true); expect(formatterSpy.called).to.be.true;
expect(serializerSpy.called).to.equal(true); expect(serializerSpy.called).to.be.true;
el.formattedValue = 'raw'; el.formattedValue = 'raw';
expect(parserSpy.called).to.equal(true); expect(parserSpy.called).to.be.true;
el.dispatchEvent(new CustomEvent('user-input-changed')); el.dispatchEvent(new CustomEvent('user-input-changed'));
expect(preprocessorSpy.called).to.equal(true); expect(preprocessorSpy.called).to.be.true;
}); });
it('should have formatOptions available in formatter', async () => { describe('Value conversions', () => {
const formatterSpy = sinon.spy(value => `foo: ${value}`); it('converts modelValue => formattedValue (via this.formatter)', async () => {
const generatedViewValue = /** @type {string} */ (generateValueBasedOnType({ fooFormat.modelValue = 'string';
viewValue: true, expect(fooFormat.formattedValue).to.equal('foo: string');
})); expect(fooFormat.serializedValue).to.equal('[foo] string');
await fixture(html`
<${elem} value="${generatedViewValue}" .formatter="${formatterSpy}"
.formatOptions="${{ locale: 'en-GB', decimalSeparator: '-' }}">
<input slot="input" .value="${generatedViewValue}">
</${elem}>
`);
/** @type {{locale: string, decimalSeparator: string}[]} */
const spyItem = formatterSpy.args[0];
const spyArg = spyItem[1];
expect(spyArg.locale).to.equal('en-GB');
expect(spyArg.decimalSeparator).to.equal('-');
});
it('will only call the parser for defined values', async () => {
/** @type {?} */
const generatedValue = generateValueBasedOnType();
const parserSpy = sinon.spy();
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .parser="${parserSpy}">
<input slot="input" .value="${generatedValue}">
</${elem}>
`));
expect(parserSpy.callCount).to.equal(1);
// This could happen for instance in a reset
el.modelValue = undefined;
expect(parserSpy.callCount).to.equal(1);
// This could happen when the user erases the input value
mimicUserInput(el, '');
expect(parserSpy.callCount).to.equal(1);
});
it('will not return Unparseable when empty strings are inputted', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem}>
<input slot="input" value="string">
</${elem}>
`));
// This could happen when the user erases the input value
mimicUserInput(el, '');
// For backwards compatibility, we keep the modelValue an empty string here.
// Undefined would be more appropriate 'conceptually', however
expect(el.modelValue).to.equal('');
});
it('will only call the formatter for valid values on `user-input-changed` ', async () => {
const formatterSpy = sinon.spy(value => `foo: ${value}`);
const generatedModelValue = generateValueBasedOnType();
/** @type {?} */
const generatedViewValue = generateValueBasedOnType({ viewValue: true });
/** @type {?} */
const generatedViewValueAlt = generateValueBasedOnType({
viewValue: true,
toggleValue: true,
}); });
const el = /** @type {FormatClass} */ (await fixture(html` it('converts modelValue => serializedValue (via this.serializer)', async () => {
<${elem} .formatter=${formatterSpy}> fooFormat.modelValue = 'string';
<input slot="input" .value="${generatedViewValue}"> expect(fooFormat.serializedValue).to.equal('[foo] string');
</${elem}> });
it('converts formattedValue => modelValue (via this.parser)', async () => {
fooFormat.formattedValue = 'foo: string';
expect(fooFormat.modelValue).to.equal('string');
});
it('converts serializedValue => modelValue (via this.deserializer)', async () => {
fooFormat.serializedValue = '[foo] string';
expect(fooFormat.modelValue).to.equal('string');
});
});
describe('Formatter', () => {
it('only calls the formatter for valid values on `user-input-changed` ', async () => {
const formatterSpy = sinon.spy(value => `foo: ${value}`);
const generatedModelValue = generateValueBasedOnType();
/** @type {?} */
const generatedViewValue = generateValueBasedOnType({ viewValue: true });
/** @type {?} */
const generatedViewValueAlt = generateValueBasedOnType({
viewValue: true,
toggleValue: true,
});
const el = /** @type {FormatClass} */ (await fixture(html`
<${tag} .formatter=${formatterSpy}>
<input slot="input" .value="${generatedViewValue}">
</${tag}>
`));
expect(formatterSpy.callCount).to.equal(1);
el.hasFeedbackFor.push('error');
// Ensure hasError is always true by putting a validator on it that always returns false.
// Setting hasError = true is not enough if the element has errorValidators (uses ValidateMixin)
// that set hasError back to false when the user input is mimicked.
const AlwaysInvalid = class extends Validator {
static get validatorName() {
return 'AlwaysInvalid';
}
execute() {
return true;
}
};
el.validators = [new AlwaysInvalid()];
mimicUserInput(el, generatedViewValueAlt);
expect(formatterSpy.callCount).to.equal(1);
// Due to hasError, the formatter should not have ran.
expect(el.formattedValue).to.equal(generatedViewValueAlt);
el.hasFeedbackFor.filter(/** @param {string} type */ type => type !== 'error');
el.validators = [];
mimicUserInput(el, generatedViewValue);
expect(formatterSpy.callCount).to.equal(2);
expect(el.formattedValue).to.equal(`foo: ${generatedModelValue}`);
});
it('has formatOptions available in formatter', async () => {
const formatterSpy = sinon.spy(value => `foo: ${value}`);
const generatedViewValue = /** @type {string} */ (generateValueBasedOnType({
viewValue: true,
}));
await fixture(html`
<${tag} value="${generatedViewValue}" .formatter="${formatterSpy}"
.formatOptions="${{ locale: 'en-GB', decimalSeparator: '-' }}">
<input slot="input" .value="${generatedViewValue}">
</${tag}>
`);
/** @type {{locale: string, decimalSeparator: string}[]} */
const spyItem = formatterSpy.args[0];
const spyArg = spyItem[1];
expect(spyArg.locale).to.equal('en-GB');
expect(spyArg.decimalSeparator).to.equal('-');
});
});
describe('Parser', () => {
it('only calls the parser for defined values', async () => {
/** @type {?} */
const generatedValue = generateValueBasedOnType();
const parserSpy = sinon.spy();
const el = /** @type {FormatClass} */ (await fixture(html`
<${tag} .parser="${parserSpy}">
<input slot="input" .value="${generatedValue}">
</${tag}>
`)); `));
expect(formatterSpy.callCount).to.equal(1);
el.hasFeedbackFor.push('error'); expect(parserSpy.callCount).to.equal(1);
// Ensure hasError is always true by putting a validator on it that always returns false. // This could happen for instance in a reset
// Setting hasError = true is not enough if the element has errorValidators (uses ValidateMixin) el.modelValue = undefined;
// that set hasError back to false when the user input is mimicked. expect(parserSpy.callCount).to.equal(1);
// This could happen when the user erases the input value
mimicUserInput(el, '');
expect(parserSpy.callCount).to.equal(1);
});
});
const AlwaysInvalid = class extends Validator { describe('Preprocessor', () => {
static get validatorName() { it('changes `.value` on keyup, before passing on to parser', async () => {
return 'AlwaysInvalid'; const val = generateValueBasedOnType({ viewValue: true }) || 'init-value';
if (typeof val !== 'string') {
return;
} }
execute() { const toBeCorrectedVal = `${val}$`;
return true; const preprocessorSpy = sinon.spy(v => v.replace(/\$$/g, ''));
}
};
el.validators = [new AlwaysInvalid()]; const el = /** @type {FormatClass} */ (await fixture(html`
mimicUserInput(el, generatedViewValueAlt); <${tag} .preprocessor=${preprocessorSpy}>
expect(formatterSpy.callCount).to.equal(1);
// Due to hasError, the formatter should not have ran.
expect(el.formattedValue).to.equal(generatedViewValueAlt);
el.hasFeedbackFor.filter(/** @param {string} type */ type => type !== 'error');
el.validators = [];
mimicUserInput(el, generatedViewValue);
expect(formatterSpy.callCount).to.equal(2);
expect(el.formattedValue).to.equal(`foo: ${generatedModelValue}`);
});
it('changes `.value` on keyup, before passing on to parser', async () => {
const val = generateValueBasedOnType({ viewValue: true }) || 'init-value';
if (typeof val !== 'string') {
return;
}
const toBeCorrectedVal = `${val}$`;
const preprocessorSpy = sinon.spy(v => v.replace(/\$$/g, ''));
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .preprocessor=${preprocessorSpy}>
<input slot="input">
</${elem}>
`));
expect(preprocessorSpy.callCount).to.equal(1);
const parserSpy = sinon.spy(el, 'parser');
mimicUserInput(el, toBeCorrectedVal);
expect(preprocessorSpy.callCount).to.equal(2);
expect(parserSpy.lastCall.args[0]).to.equal(val);
expect(el._inputNode.value).to.equal(val);
});
});
describe('Unparseable values', () => {
it('should convert to Unparseable when wrong value inputted by user', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .parser=${
/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
}
>
<input slot="input"> <input slot="input">
</${elem}> </${tag}>
`)); `));
mimicUserInput(el, 'test');
expect(el.modelValue).to.be.an.instanceof(Unparseable);
});
it('should preserve the viewValue when not parseable', async () => { expect(preprocessorSpy.callCount).to.equal(1);
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem}
.parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined}
>
<input slot="input">
</${elem}>
`));
mimicUserInput(el, 'test');
expect(el.formattedValue).to.equal('test');
expect(el.value).to.equal('test');
});
it('should display the viewValue when modelValue is of type Unparseable', async () => { const parserSpy = sinon.spy(el, 'parser');
const el = /** @type {FormatClass} */ (await fixture(html` mimicUserInput(el, toBeCorrectedVal);
<${elem}
.parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined} expect(preprocessorSpy.callCount).to.equal(2);
> expect(parserSpy.lastCall.args[0]).to.equal(val);
<input slot="input"> expect(el._inputNode.value).to.equal(val);
</${elem}> });
`));
el.modelValue = new Unparseable('foo'); it('does not preprocess during composition', async () => {
expect(el.value).to.equal('foo'); const el = /** @type {FormatClass} */ (await fixture(html`
<${tag} .preprocessor=${(/** @type {string} */ v) => v.replace(/\$$/g, '')}>
<input slot="input">
</${tag}>
`));
const preprocessorSpy = sinon.spy(el, 'preprocessor');
el._inputNode.dispatchEvent(new Event('compositionstart', { bubbles: true }));
mimicUserInput(el, '`');
expect(preprocessorSpy.callCount).to.equal(0);
// "à" would be sent by the browser after pressing "option + `", followed by "a"
mimicUserInput(el, 'à');
el._inputNode.dispatchEvent(new Event('compositionend', { bubbles: true }));
expect(preprocessorSpy.callCount).to.equal(1);
});
}); });
}); });
}); });

View file

@ -16,6 +16,7 @@ export declare class FormatHost {
formatter(v: unknown, opts: FormatNumberOptions): string; formatter(v: unknown, opts: FormatNumberOptions): string;
serializer(v: unknown): string; serializer(v: unknown): string;
deserializer(v: string): unknown; deserializer(v: string): unknown;
preprocessor(v: string): string;
get value(): string; get value(): string;
set value(value: string); set value(value: string);