feat(form-core): add live formatter functionality via .preprocessor
This commit is contained in:
parent
90b0d3b114
commit
9c1dfdcd12
4 changed files with 167 additions and 32 deletions
5
.changeset/four-avocados-confess.md
Normal file
5
.changeset/four-avocados-confess.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/form-core': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
FormControl: allow a label-sr-only flag to provide visually hidden labels
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
import { html } from '@mdjs/mdjs-preview';
|
import { html } from '@mdjs/mdjs-preview';
|
||||||
import '@lion/input/define';
|
import '@lion/input/define';
|
||||||
import { Unparseable } from '@lion/form-core';
|
import { Unparseable } from '@lion/form-core';
|
||||||
|
import { liveFormatPhoneNumber } from '@lion/input-tel';
|
||||||
|
import { Unparseable } from '@lion/form-core';
|
||||||
import './assets/h-output.js';
|
import './assets/h-output.js';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -171,6 +173,56 @@ export const preprocessors = () => {
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Live formatters
|
||||||
|
|
||||||
|
Live formatters are a specific type of preprocessor, that format a view value during typing.
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- a phone number that, during typing formats `+316` as `+31 6`
|
||||||
|
- a date that follows a date mask and automatically inserts '-' characters
|
||||||
|
|
||||||
|
Type '6' in the example below and see that a space will be added and the caret in the text box
|
||||||
|
will be automatically moved along.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const liveFormatters = () => {
|
||||||
|
return html`
|
||||||
|
<lion-input
|
||||||
|
label="Live Format"
|
||||||
|
.modelValue="${new Unparseable('+31')}"
|
||||||
|
help-text="Uses .preprocessor to format during typing"
|
||||||
|
.preprocessor=${(viewValue, { currentCaretIndex, prevViewValue }) => {
|
||||||
|
return liveFormatPhoneNumber(viewValue, {
|
||||||
|
regionCode: 'NL',
|
||||||
|
formatStrategy: 'international',
|
||||||
|
currentCaretIndex,
|
||||||
|
prevViewValue,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></lion-input>
|
||||||
|
<h-output .show="${['modelValue']}"></h-output>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that these live formatters need to make an educated guess based on the current (incomplete) view
|
||||||
|
value what the users intentions are. When implemented correctly, they can create a huge improvement
|
||||||
|
in user experience.
|
||||||
|
Next to a changed viewValue, they are also responsible for taking care of the
|
||||||
|
caretIndex. For instance, if `+316` is changed to `+31 6`, the caret needs to be moved one position
|
||||||
|
to the right (to compensate for the extra inserted space).
|
||||||
|
|
||||||
|
#### When to use a live formatter and when a regular formatter?
|
||||||
|
|
||||||
|
Although it might feel more logical to configure live formatters inside the `.formatter` function,
|
||||||
|
it should be configured inside the `.preprocessor` function. The table below shows differences
|
||||||
|
between the two mentioned methods
|
||||||
|
|
||||||
|
| Function | Value type recieved | Reflected back to user on | Supports incomplete values | Supports caret index |
|
||||||
|
| :------------ | :------------------ | :------------------------ | :------------------------- | :------------------- |
|
||||||
|
| .formatter | modelValue | blur (leave) | No | No |
|
||||||
|
| .preprocessor | viewValue | keyup (live) | Yes | Yes |
|
||||||
|
|
||||||
## Flow Diagrams
|
## Flow Diagrams
|
||||||
|
|
||||||
Below we show three flow diagrams to show the flow of formatting, serializing and parsing user input, with the example of a date input:
|
Below we show three flow diagrams to show the flow of formatting, serializing and parsing user input, with the example of a date input:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { ValidateMixin } from './validate/ValidateMixin.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('../types/FormatMixinTypes').FormatMixin} FormatMixin
|
* @typedef {import('../types/FormatMixinTypes').FormatMixin} FormatMixin
|
||||||
* @typedef {import('@lion/localize/types/LocalizeMixinTypes').FormatNumberOptions} FormatOptions
|
* @typedef {import('../types/FormatMixinTypes').FormatOptions} FormatOptions
|
||||||
* @typedef {import('../types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
|
* @typedef {import('../types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -107,20 +107,26 @@ const FormatMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preprocesses the viewValue before it's parsed to a modelValue. Can be used to filter
|
* Preprocessors could be considered 'live formatters'. Their result is shown to the user
|
||||||
* invalid input amongst others.
|
* on keyup instead of after blurring the field. The biggest difference between preprocessors
|
||||||
|
* and formatters is their moment of execution: preprocessors are run before modelValue is
|
||||||
|
* computed (and work based on view value), whereas formatters are run after the parser (and
|
||||||
|
* are based on modelValue)
|
||||||
|
* Automatically formats code while typing. It depends on a preprocessro that smartly
|
||||||
|
* updates the viewValue and caret position for best UX.
|
||||||
* @example
|
* @example
|
||||||
* ```js
|
* ```js
|
||||||
* preprocessor(viewValue) {
|
* preprocessor(viewValue) {
|
||||||
* // only use digits
|
* // only use digits
|
||||||
* return viewValue.replace(/\D/g, '');
|
* return viewValue.replace(/\D/g, '');
|
||||||
* }
|
* }
|
||||||
* ```
|
|
||||||
* @param {string} v - the raw value from the <input> after keyUp/Down event
|
* @param {string} v - the raw value from the <input> after keyUp/Down event
|
||||||
* @returns {string} preprocessedValue: the result of preprocessing for invalid input
|
* @param {FormatOptions & { prevViewValue: string; currentCaretIndex: number }} opts - the raw value from the <input> after keyUp/Down event
|
||||||
|
* @returns {{ viewValue:string; caretIndex:number; }|string|undefined} preprocessedValue: the result of preprocessing for invalid input
|
||||||
*/
|
*/
|
||||||
preprocessor(v) {
|
// eslint-disable-next-line no-unused-vars
|
||||||
return v;
|
preprocessor(v, opts) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -204,6 +210,7 @@ const FormatMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
this._reflectBackFormattedValueToUser();
|
this._reflectBackFormattedValueToUser();
|
||||||
this.__preventRecursiveTrigger = false;
|
this.__preventRecursiveTrigger = false;
|
||||||
|
this.__prevViewValue = this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -218,13 +225,12 @@ const FormatMixinImplementation = superclass =>
|
||||||
if (value === '') {
|
if (value === '') {
|
||||||
// Ideally, modelValue should be undefined for empty strings.
|
// Ideally, modelValue should be undefined for empty strings.
|
||||||
// For backwards compatibility we return an empty string:
|
// For backwards compatibility we return an empty string:
|
||||||
// - it triggers validation for required validators (see ValidateMixin.validate())
|
|
||||||
// - it can be expected by 3rd parties (for instance unit tests)
|
// - it can be expected by 3rd parties (for instance unit tests)
|
||||||
// TODO(@tlouisse): In a breaking refactor of the Validation System, this behavior can be corrected.
|
// TODO(@tlouisse): In a breaking refactor of the Validation System, this behavior can be corrected.
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// A.2) Handle edge cases We might have no view value yet, for instance because
|
// A.2) Handle edge cases. We might have no view value yet, for instance because
|
||||||
// _inputNode.value was not available yet
|
// _inputNode.value was not available yet
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
// This means there is nothing to find inside the view that can be of
|
// This means there is nothing to find inside the view that can be of
|
||||||
|
|
@ -263,8 +269,7 @@ const FormatMixinImplementation = superclass =>
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this._isHandlingUserInput &&
|
this._isHandlingUserInput &&
|
||||||
this.hasFeedbackFor &&
|
this.hasFeedbackFor?.length &&
|
||||||
this.hasFeedbackFor.length &&
|
|
||||||
this.hasFeedbackFor.includes('error') &&
|
this.hasFeedbackFor.includes('error') &&
|
||||||
this._inputNode
|
this._inputNode
|
||||||
) {
|
) {
|
||||||
|
|
@ -282,6 +287,8 @@ const FormatMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Responds to modelValue changes in the synchronous cycle (most subclassers should listen to
|
||||||
|
* the asynchronous cycle ('modelValue' in the .updated lifecycle))
|
||||||
* @param {{ modelValue: unknown; }[]} args
|
* @param {{ modelValue: unknown; }[]} args
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
|
|
@ -320,7 +327,7 @@ const FormatMixinImplementation = superclass =>
|
||||||
*/
|
*/
|
||||||
_syncValueUpwards() {
|
_syncValueUpwards() {
|
||||||
if (!this.__isHandlingComposition) {
|
if (!this.__isHandlingComposition) {
|
||||||
this.value = this.preprocessor(this.value);
|
this.__handlePreprocessor();
|
||||||
}
|
}
|
||||||
const prevFormatted = this.formattedValue;
|
const prevFormatted = this.formattedValue;
|
||||||
this.modelValue = this._callParser(this.value);
|
this.modelValue = this._callParser(this.value);
|
||||||
|
|
@ -330,8 +337,36 @@ const FormatMixinImplementation = superclass =>
|
||||||
if (prevFormatted === this.formattedValue && this.__prevViewValue !== this.value) {
|
if (prevFormatted === this.formattedValue && this.__prevViewValue !== this.value) {
|
||||||
this._calculateValues();
|
this._calculateValues();
|
||||||
}
|
}
|
||||||
/** @type {string} */
|
}
|
||||||
this.__prevViewValue = this.value;
|
|
||||||
|
/**
|
||||||
|
* Handle view value and caretIndex, depending on return type of .preprocessor.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
__handlePreprocessor() {
|
||||||
|
const unprocessedValue = this.value;
|
||||||
|
const preprocessedValue = this.preprocessor(this.value, {
|
||||||
|
...this.formatOptions,
|
||||||
|
currentCaretIndex: this._inputNode?.selectionStart || this.value.length,
|
||||||
|
prevViewValue: this.__prevViewValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.__prevViewValue = unprocessedValue;
|
||||||
|
if (preprocessedValue === undefined) {
|
||||||
|
// Make sure we do no set back original value, so we preserve
|
||||||
|
// caret index (== selectionStart/selectionEnd)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof preprocessedValue === 'string') {
|
||||||
|
this.value = preprocessedValue;
|
||||||
|
} else if (typeof preprocessedValue === 'object') {
|
||||||
|
const { viewValue, caretIndex } = preprocessedValue;
|
||||||
|
this.value = viewValue;
|
||||||
|
if (caretIndex && this._inputNode && 'selectionStart' in this._inputNode) {
|
||||||
|
this._inputNode.selectionStart = caretIndex;
|
||||||
|
this._inputNode.selectionEnd = caretIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -351,7 +386,7 @@ const FormatMixinImplementation = superclass =>
|
||||||
/**
|
/**
|
||||||
* Every time .formattedValue is attempted to sync to the view value (on change/blur and on
|
* 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
|
* modelValue change), this condition is checked. When enhancing it, it's recommended to
|
||||||
* call `super._reflectBackOn()`
|
* call via `return this._myExtraCondition && super._reflectBackOn()`
|
||||||
* @overridable
|
* @overridable
|
||||||
* @return {boolean}
|
* @return {boolean}
|
||||||
* @protected
|
* @protected
|
||||||
|
|
@ -490,6 +525,9 @@ const FormatMixinImplementation = superclass =>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
__onPaste() {
|
__onPaste() {
|
||||||
this._isPasting = true;
|
this._isPasting = true;
|
||||||
this.formatOptions.mode = 'pasted';
|
this.formatOptions.mode = 'pasted';
|
||||||
|
|
@ -510,6 +548,9 @@ const FormatMixinImplementation = superclass =>
|
||||||
if (typeof this.modelValue === 'undefined') {
|
if (typeof this.modelValue === 'undefined') {
|
||||||
this._syncValueUpwards();
|
this._syncValueUpwards();
|
||||||
}
|
}
|
||||||
|
/** @type {string} */
|
||||||
|
this.__prevViewValue = this.value;
|
||||||
|
|
||||||
this._reflectBackFormattedValueToUser();
|
this._reflectBackFormattedValueToUser();
|
||||||
|
|
||||||
if (this._inputNode) {
|
if (this._inputNode) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { aTimeout, defineCE, expect, fixture, html, unsafeStatic } from '@open-w
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { Unparseable, Validator } from '../index.js';
|
import { Unparseable, Validator } from '../index.js';
|
||||||
import { FormatMixin } from '../src/FormatMixin.js';
|
import { FormatMixin } from '../src/FormatMixin.js';
|
||||||
import { getFormControlMembers } from '../test-helpers/getFormControlMembers.js';
|
import { getFormControlMembers, mimicUserInput } from '../test-helpers/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControlHost
|
* @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControlHost
|
||||||
|
|
@ -34,22 +34,6 @@ class FormatClass extends FormatMixin(LitElement) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {FormatClass} formControl
|
|
||||||
* @param {?} newViewValue
|
|
||||||
* @param {{caretIndex?:number}} config
|
|
||||||
*/
|
|
||||||
function mimicUserInput(formControl, newViewValue, { caretIndex } = {}) {
|
|
||||||
formControl.value = newViewValue; // eslint-disable-line no-param-reassign
|
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{tagString?: string, modelValueType?: modelValueType}} [customConfig]
|
* @param {{tagString?: string, modelValueType?: modelValueType}} [customConfig]
|
||||||
*/
|
*/
|
||||||
|
|
@ -57,6 +41,7 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
tagString: null,
|
tagString: null,
|
||||||
childTagString: null,
|
childTagString: null,
|
||||||
|
modelValueType: String,
|
||||||
...customConfig,
|
...customConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -633,6 +618,58 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
_inputNode.dispatchEvent(new Event('compositionend', { bubbles: true }));
|
_inputNode.dispatchEvent(new Event('compositionend', { bubbles: true }));
|
||||||
expect(preprocessorSpy.callCount).to.equal(1);
|
expect(preprocessorSpy.callCount).to.equal(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Live Formatters', () => {
|
||||||
|
it('receives meta object with { prevViewValue: string; currentCaretIndex: number; }', async () => {
|
||||||
|
const spy = sinon.spy();
|
||||||
|
|
||||||
|
const valInitial = generateValueBasedOnType();
|
||||||
|
const el = /** @type {FormatClass} */ (
|
||||||
|
await fixture(
|
||||||
|
html`<${tag} .modelValue="${valInitial}" .preprocessor=${spy}><input slot="input"></${tag}>`,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const viewValInitial = el.value;
|
||||||
|
const valToggled = generateValueBasedOnType({ toggleValue: true });
|
||||||
|
|
||||||
|
mimicUserInput(el, valToggled, { caretIndex: 1 });
|
||||||
|
expect(spy.args[0][0]).to.equal(el.value);
|
||||||
|
const formatOptions = spy.args[0][1];
|
||||||
|
expect(formatOptions.prevViewValue).to.equal(viewValInitial);
|
||||||
|
expect(formatOptions.currentCaretIndex).to.equal(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates return viewValue and caretIndex', async () => {
|
||||||
|
/**
|
||||||
|
* @param {string} viewValue
|
||||||
|
* @param {{ prevViewValue: string; currentCaretIndex: number; }} meta
|
||||||
|
*/
|
||||||
|
function myPreprocessor(viewValue, { currentCaretIndex }) {
|
||||||
|
return { viewValue: `${viewValue}q`, caretIndex: currentCaretIndex + 1 };
|
||||||
|
}
|
||||||
|
const el = /** @type {FormatClass} */ (
|
||||||
|
await fixture(
|
||||||
|
html`<${tag} .modelValue="${'xyz'}" .preprocessor=${myPreprocessor}><input slot="input"></${tag}>`,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
mimicUserInput(el, 'wxyz', { caretIndex: 1 });
|
||||||
|
expect(el._inputNode.value).to.equal('wxyzq');
|
||||||
|
expect(el._inputNode.selectionStart).to.equal(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update when undefined is returned', async () => {
|
||||||
|
const el = /** @type {FormatClass} */ (
|
||||||
|
await fixture(
|
||||||
|
html`<${tag} .modelValue="${'xyz'}" live-format .liveFormatter=${() =>
|
||||||
|
undefined}><input slot="input"></${tag}>`,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
mimicUserInput(el, 'wxyz', { caretIndex: 1 });
|
||||||
|
expect(el._inputNode.value).to.equal('wxyz');
|
||||||
|
// Make sure we do not put our already existing value back, because caret index would be lost
|
||||||
|
expect(el._inputNode.selectionStart).to.equal(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue