diff --git a/.changeset/four-avocados-confess.md b/.changeset/four-avocados-confess.md new file mode 100644 index 000000000..46a136368 --- /dev/null +++ b/.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 diff --git a/docs/docs/systems/form/formatting-and-parsing.md b/docs/docs/systems/form/formatting-and-parsing.md index f0f11f7e6..3b30eec9f 100644 --- a/docs/docs/systems/form/formatting-and-parsing.md +++ b/docs/docs/systems/form/formatting-and-parsing.md @@ -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'; ``` @@ -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` + { + return liveFormatPhoneNumber(viewValue, { + regionCode: 'NL', + formatStrategy: 'international', + currentCaretIndex, + prevViewValue, + }); + }} + > + + `; +}; +``` + +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: diff --git a/packages/form-core/src/FormatMixin.js b/packages/form-core/src/FormatMixin.js index 62ce2f2e8..ff1d1db2d 100644 --- a/packages/form-core/src/FormatMixin.js +++ b/packages/form-core/src/FormatMixin.js @@ -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 */ @@ -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 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 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; } /** @@ -204,6 +210,7 @@ const FormatMixinImplementation = superclass => } this._reflectBackFormattedValueToUser(); this.__preventRecursiveTrigger = false; + this.__prevViewValue = this.value; } /** @@ -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 @@ -263,8 +269,7 @@ const FormatMixinImplementation = superclass => if ( this._isHandlingUserInput && - this.hasFeedbackFor && - this.hasFeedbackFor.length && + this.hasFeedbackFor?.length && this.hasFeedbackFor.includes('error') && 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 * @protected */ @@ -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); @@ -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; + } + } } /** @@ -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 @@ -490,6 +525,9 @@ const FormatMixinImplementation = superclass => }; } + /** + * @private + */ __onPaste() { this._isPasting = true; this.formatOptions.mode = 'pasted'; @@ -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) { diff --git a/packages/form-core/test-suites/FormatMixin.suite.js b/packages/form-core/test-suites/FormatMixin.suite.js index 7bfa1c144..c09a6ee32 100644 --- a/packages/form-core/test-suites/FormatMixin.suite.js +++ b/packages/form-core/test-suites/FormatMixin.suite.js @@ -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 @@ -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] */ @@ -57,6 +41,7 @@ export function runFormatMixinSuite(customConfig) { const cfg = { tagString: null, childTagString: null, + modelValueType: String, ...customConfig, }; @@ -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}>`, + ) + ); + 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}>`, + ) + ); + 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}>`, + ) + ); + 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); + }); + }); }); }); });