feat(form-core): add live formatter functionality via .preprocessor

This commit is contained in:
Thijs Louisse 2022-03-15 18:54:10 +01:00
parent 90b0d3b114
commit 9c1dfdcd12
4 changed files with 167 additions and 32 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/form-core': patch
---
FormControl: allow a label-sr-only flag to provide visually hidden labels

View file

@ -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:

View file

@ -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) {

View file

@ -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);
});
});
}); });
}); });
}); });