Skip to content

Commit

Permalink
feat(form-core): add live formatter functionality via .preprocessor
Browse files Browse the repository at this point in the history
  • Loading branch information
tlouisse committed Mar 16, 2022
1 parent 90b0d3b commit 9c1dfdc
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .changeset/four-avocados-confess.md
@@ -0,0 +1,5 @@
---
'@lion/form-core': patch
---

FormControl: allow a label-sr-only flag to provide visually hidden labels
52 changes: 52 additions & 0 deletions docs/docs/systems/form/formatting-and-parsing.md
Expand Up @@ -4,6 +4,8 @@
import { html } from '@mdjs/mdjs-preview';
import '@lion/input/define';
import { Unparseable } from '@lion/form-core';
import { liveFormatPhoneNumber } from '@lion/input-tel';
import { Unparseable } from '@lion/form-core';
import './assets/h-output.js';
```

Expand Down Expand Up @@ -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

Below we show three flow diagrams to show the flow of formatting, serializing and parsing user input, with the example of a date input:
Expand Down
71 changes: 56 additions & 15 deletions packages/form-core/src/FormatMixin.js
Expand Up @@ -7,7 +7,7 @@ import { ValidateMixin } from './validate/ValidateMixin.js';

/**
* @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
*/

Expand Down Expand Up @@ -107,20 +107,26 @@ const FormatMixinImplementation = superclass =>
}

/**
* Preprocesses the viewValue before it's parsed to a modelValue. Can be used to filter
* invalid input amongst others.
* Preprocessors could be considered 'live formatters'. Their result is shown to the user
* 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
* ```js
* preprocessor(viewValue) {
* // only use digits
* return viewValue.replace(/\D/g, '');
* }
* ```
* @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) {
return v;
// eslint-disable-next-line no-unused-vars
preprocessor(v, opts) {
return undefined;
}

/**
Expand Down Expand Up @@ -204,6 +210,7 @@ const FormatMixinImplementation = superclass =>
}
this._reflectBackFormattedValueToUser();
this.__preventRecursiveTrigger = false;
this.__prevViewValue = this.value;
}

/**
Expand All @@ -218,13 +225,12 @@ const FormatMixinImplementation = superclass =>
if (value === '') {
// Ideally, modelValue should be undefined for empty strings.
// 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)
// TODO(@tlouisse): In a breaking refactor of the Validation System, this behavior can be corrected.
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
if (typeof value !== 'string') {
// This means there is nothing to find inside the view that can be of
Expand Down Expand Up @@ -263,8 +269,7 @@ const FormatMixinImplementation = superclass =>

if (
this._isHandlingUserInput &&
this.hasFeedbackFor &&
this.hasFeedbackFor.length &&
this.hasFeedbackFor?.length &&
this.hasFeedbackFor.includes('error') &&
this._inputNode
) {
Expand All @@ -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
* @protected
*/
Expand Down Expand Up @@ -320,7 +327,7 @@ const FormatMixinImplementation = superclass =>
*/
_syncValueUpwards() {
if (!this.__isHandlingComposition) {
this.value = this.preprocessor(this.value);
this.__handlePreprocessor();
}
const prevFormatted = this.formattedValue;
this.modelValue = this._callParser(this.value);
Expand All @@ -330,8 +337,36 @@ const FormatMixinImplementation = superclass =>
if (prevFormatted === this.formattedValue && this.__prevViewValue !== this.value) {
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;
}
}
}

/**
Expand All @@ -351,7 +386,7 @@ 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()`
* call via `return this._myExtraCondition && super._reflectBackOn()`
* @overridable
* @return {boolean}
* @protected
Expand Down Expand Up @@ -490,6 +525,9 @@ const FormatMixinImplementation = superclass =>
};
}

/**
* @private
*/
__onPaste() {
this._isPasting = true;
this.formatOptions.mode = 'pasted';
Expand All @@ -510,6 +548,9 @@ const FormatMixinImplementation = superclass =>
if (typeof this.modelValue === 'undefined') {
this._syncValueUpwards();
}
/** @type {string} */
this.__prevViewValue = this.value;

this._reflectBackFormattedValueToUser();

if (this._inputNode) {
Expand Down
71 changes: 54 additions & 17 deletions packages/form-core/test-suites/FormatMixin.suite.js
Expand Up @@ -4,7 +4,7 @@ import { aTimeout, defineCE, expect, fixture, html, unsafeStatic } from '@open-w
import sinon from 'sinon';
import { Unparseable, Validator } from '../index.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
Expand Down Expand Up @@ -34,29 +34,14 @@ 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]
*/
export function runFormatMixinSuite(customConfig) {
const cfg = {
tagString: null,
childTagString: null,
modelValueType: String,
...customConfig,
};

Expand Down Expand Up @@ -633,6 +618,58 @@ export function runFormatMixinSuite(customConfig) {
_inputNode.dispatchEvent(new Event('compositionend', { bubbles: true }));
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);
});
});
});
});
});
Expand Down

0 comments on commit 9c1dfdc

Please sign in to comment.