fix(form-core): do not preprocess during composition
This commit is contained in:
parent
3c57fc3062
commit
3b5ed3222f
4 changed files with 394 additions and 323 deletions
7
.changeset/curly-lamps-cheer.md
Normal file
7
.changeset/curly-lamps-cheer.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
'@lion/form-core': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
fix(form-core): do not preprocess during composition
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,34 +104,74 @@ 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 () => {
|
it('has modelValue, formattedValue and serializedValue which are computed synchronously', async () => {
|
||||||
|
expect(nonFormat.modelValue).to.equal('', 'modelValue initially');
|
||||||
|
expect(nonFormat.formattedValue).to.equal('', 'formattedValue initially');
|
||||||
|
expect(nonFormat.serializedValue).to.equal('', 'serializedValue initially');
|
||||||
|
const generatedValue = generateValueBasedOnType();
|
||||||
|
nonFormat.modelValue = generatedValue;
|
||||||
|
expect(nonFormat.modelValue).to.equal(generatedValue, 'modelValue as provided');
|
||||||
|
expect(nonFormat.formattedValue).to.equal(generatedValue, 'formattedValue synchronized');
|
||||||
|
expect(nonFormat.serializedValue).to.equal(generatedValue, 'serializedValue synchronized');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder: modelValue is the SSOT all other values are derived from
|
||||||
|
* (and should be translated to)
|
||||||
|
*/
|
||||||
|
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(
|
const formatEl = /** @type {FormatClass} */ (await fixture(
|
||||||
html`<${elem}><input slot="input"></${elem}>`,
|
html`<${tag}><input slot="input"></${tag}>`,
|
||||||
));
|
));
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
|
|
@ -154,41 +201,88 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
expect(counter).to.equal(2 + counterOffset);
|
expect(counter).to.equal(2 + counterOffset);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fires `model-value-changed` for every programmatic modelValue change', async () => {
|
it('synchronizes _inputNode.value as a fallback mechanism on init (when no modelValue provided)', async () => {
|
||||||
const el = /** @type {FormatClass} */ (await fixture(
|
// Note that in lion-field, the attribute would be put on <lion-field>, not on <input>
|
||||||
html`<${elem}><input slot="input"></${elem}>`,
|
const formatElem = /** @type {FormatClass} */ (await fixture(html`
|
||||||
));
|
<${tag}
|
||||||
let counter = 0;
|
value="string"
|
||||||
let isTriggeredByUser = false;
|
.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');
|
||||||
|
|
||||||
el.addEventListener('model-value-changed', event => {
|
expect(formatElem._inputNode.value).to.equal('foo: string');
|
||||||
counter += 1;
|
|
||||||
isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
|
expect(formatElem.serializedValue).to.equal('[foo] string');
|
||||||
|
expect(formatElem.modelValue).to.equal('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
el.modelValue = 'one';
|
describe('Unparseable values', () => {
|
||||||
expect(counter).to.equal(1);
|
it('converts to Unparseable when wrong value inputted by user', async () => {
|
||||||
expect(isTriggeredByUser).to.be.false;
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
||||||
|
<${tag} .parser=${
|
||||||
// no change means no event
|
/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
|
||||||
el.modelValue = 'one';
|
}
|
||||||
expect(counter).to.equal(1);
|
>
|
||||||
|
<input slot="input">
|
||||||
el.modelValue = 'two';
|
</${tag}>
|
||||||
expect(counter).to.equal(2);
|
`));
|
||||||
|
mimicUserInput(el, 'test');
|
||||||
|
expect(el.modelValue).to.be.an.instanceof(Unparseable);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has modelValue, formattedValue and serializedValue which are computed synchronously', async () => {
|
it('preserves the viewValue when unparseable', async () => {
|
||||||
expect(nonFormat.modelValue).to.equal('', 'modelValue initially');
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
||||||
expect(nonFormat.formattedValue).to.equal('', 'formattedValue initially');
|
<${tag}
|
||||||
expect(nonFormat.serializedValue).to.equal('', 'serializedValue initially');
|
.parser=${
|
||||||
const generatedValue = generateValueBasedOnType();
|
/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
|
||||||
nonFormat.modelValue = generatedValue;
|
}
|
||||||
expect(nonFormat.modelValue).to.equal(generatedValue, 'modelValue as provided');
|
>
|
||||||
expect(nonFormat.formattedValue).to.equal(generatedValue, 'formattedValue synchronized');
|
<input slot="input">
|
||||||
expect(nonFormat.serializedValue).to.equal(generatedValue, 'serializedValue synchronized');
|
</${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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('View value', () => {
|
||||||
it('has an input node (like <input>/<textarea>) which holds the formatted (view) value', async () => {
|
it('has an input node (like <input>/<textarea>) which holds the formatted (view) value', async () => {
|
||||||
fooFormat.modelValue = 'string';
|
fooFormat.modelValue = 'string';
|
||||||
expect(fooFormat.formattedValue).to.equal('foo: string');
|
expect(fooFormat.formattedValue).to.equal('foo: string');
|
||||||
|
|
@ -196,6 +290,81 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
expect(fooFormat._inputNode.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Parser / formatter / serializer / preprocessor', () => {
|
||||||
|
it('calls the parser|formatter|serializer|preprocessor provided by user', async () => {
|
||||||
|
const formatterSpy = sinon.spy(value => `foo: ${value}`);
|
||||||
|
const parserSpy = sinon.spy(value => value.replace('foo: ', ''));
|
||||||
|
const serializerSpy = sinon.spy(value => `[foo] ${value}`);
|
||||||
|
const preprocessorSpy = sinon.spy(value => value.replace('bar', ''));
|
||||||
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
||||||
|
<${tag}
|
||||||
|
.formatter=${formatterSpy}
|
||||||
|
.parser=${parserSpy}
|
||||||
|
.serializer=${serializerSpy}
|
||||||
|
.preprocessor=${preprocessorSpy}
|
||||||
|
.modelValue=${'test'}
|
||||||
|
>
|
||||||
|
<input slot="input">
|
||||||
|
</${tag}>
|
||||||
|
`));
|
||||||
|
expect(formatterSpy.called).to.be.true;
|
||||||
|
expect(serializerSpy.called).to.be.true;
|
||||||
|
|
||||||
|
el.formattedValue = 'raw';
|
||||||
|
expect(parserSpy.called).to.be.true;
|
||||||
|
el.dispatchEvent(new CustomEvent('user-input-changed'));
|
||||||
|
expect(preprocessorSpy.called).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Value conversions', () => {
|
||||||
it('converts modelValue => formattedValue (via this.formatter)', async () => {
|
it('converts modelValue => formattedValue (via this.formatter)', async () => {
|
||||||
fooFormat.modelValue = 'string';
|
fooFormat.modelValue = 'string';
|
||||||
expect(fooFormat.formattedValue).to.equal('foo: string');
|
expect(fooFormat.formattedValue).to.equal('foo: string');
|
||||||
|
|
@ -216,153 +385,10 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
fooFormat.serializedValue = '[foo] string';
|
fooFormat.serializedValue = '[foo] string';
|
||||||
expect(fooFormat.modelValue).to.equal('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 () => {
|
describe('Formatter', () => {
|
||||||
const formatEl = /** @type {FormatClass} */ (await fixture(html`
|
it('only calls the formatter for valid values on `user-input-changed` ', async () => {
|
||||||
<${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 parserSpy = sinon.spy(value => value.replace('foo: ', ''));
|
|
||||||
const serializerSpy = sinon.spy(value => `[foo] ${value}`);
|
|
||||||
const preprocessorSpy = sinon.spy(value => value.replace('bar', ''));
|
|
||||||
const el = /** @type {FormatClass} */ (await fixture(html`
|
|
||||||
<${elem}
|
|
||||||
.formatter=${formatterSpy}
|
|
||||||
.parser=${parserSpy}
|
|
||||||
.serializer=${serializerSpy}
|
|
||||||
.preprocessor=${preprocessorSpy}
|
|
||||||
.modelValue=${'test'}
|
|
||||||
>
|
|
||||||
<input slot="input">
|
|
||||||
</${elem}>
|
|
||||||
`));
|
|
||||||
expect(formatterSpy.called).to.equal(true);
|
|
||||||
expect(serializerSpy.called).to.equal(true);
|
|
||||||
|
|
||||||
el.formattedValue = 'raw';
|
|
||||||
expect(parserSpy.called).to.equal(true);
|
|
||||||
el.dispatchEvent(new CustomEvent('user-input-changed'));
|
|
||||||
expect(preprocessorSpy.called).to.equal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have formatOptions available in formatter', async () => {
|
|
||||||
const formatterSpy = sinon.spy(value => `foo: ${value}`);
|
|
||||||
const generatedViewValue = /** @type {string} */ (generateValueBasedOnType({
|
|
||||||
viewValue: true,
|
|
||||||
}));
|
|
||||||
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 formatterSpy = sinon.spy(value => `foo: ${value}`);
|
||||||
|
|
||||||
const generatedModelValue = generateValueBasedOnType();
|
const generatedModelValue = generateValueBasedOnType();
|
||||||
|
|
@ -375,9 +401,9 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const el = /** @type {FormatClass} */ (await fixture(html`
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
||||||
<${elem} .formatter=${formatterSpy}>
|
<${tag} .formatter=${formatterSpy}>
|
||||||
<input slot="input" .value="${generatedViewValue}">
|
<input slot="input" .value="${generatedViewValue}">
|
||||||
</${elem}>
|
</${tag}>
|
||||||
`));
|
`));
|
||||||
expect(formatterSpy.callCount).to.equal(1);
|
expect(formatterSpy.callCount).to.equal(1);
|
||||||
|
|
||||||
|
|
@ -411,6 +437,48 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
expect(el.formattedValue).to.equal(`foo: ${generatedModelValue}`);
|
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(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preprocessor', () => {
|
||||||
it('changes `.value` on keyup, before passing on to parser', async () => {
|
it('changes `.value` on keyup, before passing on to parser', async () => {
|
||||||
const val = generateValueBasedOnType({ viewValue: true }) || 'init-value';
|
const val = generateValueBasedOnType({ viewValue: true }) || 'init-value';
|
||||||
if (typeof val !== 'string') {
|
if (typeof val !== 'string') {
|
||||||
|
|
@ -421,9 +489,9 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
const preprocessorSpy = sinon.spy(v => v.replace(/\$$/g, ''));
|
const preprocessorSpy = sinon.spy(v => v.replace(/\$$/g, ''));
|
||||||
|
|
||||||
const el = /** @type {FormatClass} */ (await fixture(html`
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
||||||
<${elem} .preprocessor=${preprocessorSpy}>
|
<${tag} .preprocessor=${preprocessorSpy}>
|
||||||
<input slot="input">
|
<input slot="input">
|
||||||
</${elem}>
|
</${tag}>
|
||||||
`));
|
`));
|
||||||
|
|
||||||
expect(preprocessorSpy.callCount).to.equal(1);
|
expect(preprocessorSpy.callCount).to.equal(1);
|
||||||
|
|
@ -435,45 +503,22 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
expect(parserSpy.lastCall.args[0]).to.equal(val);
|
expect(parserSpy.lastCall.args[0]).to.equal(val);
|
||||||
expect(el._inputNode.value).to.equal(val);
|
expect(el._inputNode.value).to.equal(val);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('Unparseable values', () => {
|
it('does not preprocess during composition', async () => {
|
||||||
it('should convert to Unparseable when wrong value inputted by user', async () => {
|
|
||||||
const el = /** @type {FormatClass} */ (await fixture(html`
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
||||||
<${elem} .parser=${
|
<${tag} .preprocessor=${(/** @type {string} */ v) => v.replace(/\$$/g, '')}>
|
||||||
/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<input slot="input">
|
<input slot="input">
|
||||||
</${elem}>
|
</${tag}>
|
||||||
`));
|
`));
|
||||||
mimicUserInput(el, 'test');
|
const preprocessorSpy = sinon.spy(el, 'preprocessor');
|
||||||
expect(el.modelValue).to.be.an.instanceof(Unparseable);
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should preserve the viewValue when not parseable', async () => {
|
|
||||||
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 el = /** @type {FormatClass} */ (await fixture(html`
|
|
||||||
<${elem}
|
|
||||||
.parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined}
|
|
||||||
>
|
|
||||||
<input slot="input">
|
|
||||||
</${elem}>
|
|
||||||
`));
|
|
||||||
el.modelValue = new Unparseable('foo');
|
|
||||||
expect(el.value).to.equal('foo');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue