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}>${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}>${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}>${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);
+ });
+ });
});
});
});