From 8cd0142d1288e36d953e5c6e61fadee77f53df59 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Fri, 30 Apr 2021 18:29:17 +0200 Subject: [PATCH] feat(form-core): member descriptions for editors and api tables --- .changeset/selfish-ears-flash.md | 9 + packages/form-core/src/FocusMixin.js | 2 +- packages/form-core/src/FormControlMixin.js | 195 ++++++++++-------- packages/form-core/src/FormatMixin.js | 147 +++++++------ .../form-core/src/InteractionStateMixin.js | 121 ++++++----- packages/form-core/src/LionField.js | 30 +-- .../form-core/src/NativeTextFieldMixin.js | 17 ++ .../src/choice-group/ChoiceGroupMixin.js | 31 +-- .../src/choice-group/ChoiceInputMixin.js | 67 +++--- .../src/form-group/FormGroupMixin.js | 137 ++++++------ .../registration/FormControlsCollection.js | 2 +- .../src/registration/FormRegisteringMixin.js | 6 +- .../src/registration/FormRegistrarMixin.js | 31 ++- .../registration/FormRegistrarPortalMixin.js | 7 +- .../form-core/src/utils/SyncUpdatableMixin.js | 14 +- .../src/validate/LionValidationFeedback.js | 2 + .../form-core/src/validate/Unparseable.js | 9 + .../form-core/src/validate/ValidateMixin.js | 189 +++++++++++------ .../InteractionStateMixin.suite.js | 2 + .../form-group/FormGroupMixin.suite.js | 6 +- packages/form-core/types/FocusMixinTypes.d.ts | 3 + .../types/FormControlMixinTypes.d.ts | 64 +++++- .../form-core/types/FormatMixinTypes.d.ts | 141 ++++++++++++- .../types/InteractionStateMixinTypes.d.ts | 64 +++++- .../types/NativeTextFieldMixinTypes.d.ts | 17 ++ .../choice-group/ChoiceInputMixinTypes.d.ts | 4 + .../types/form-group/FormGroupMixinTypes.d.ts | 110 +++++++++- .../FormRegisteringMixinTypes.d.ts | 8 + .../registration/FormRegistrarMixinTypes.d.ts | 49 +++++ .../FormRegistrarPortalMixinTypes.d.ts | 4 + .../types/utils/SyncUpdatableMixinTypes.d.ts | 13 ++ .../types/validate/ValidateMixinTypes.d.ts | 160 +++++++++++++- packages/input-range/src/LionInputRange.js | 4 +- .../radio-group/test/lion-radio-group.test.js | 2 +- packages/select/src/LionSelect.js | 15 ++ 35 files changed, 1253 insertions(+), 429 deletions(-) create mode 100644 .changeset/selfish-ears-flash.md diff --git a/.changeset/selfish-ears-flash.md b/.changeset/selfish-ears-flash.md new file mode 100644 index 000000000..11205270d --- /dev/null +++ b/.changeset/selfish-ears-flash.md @@ -0,0 +1,9 @@ +--- +'@lion/form-core': minor +'@lion/input-range': minor +'@lion/listbox': minor +'@lion/radio-group': minor +'@lion/select': minor +--- + +member descriptions for editors and api tables diff --git a/packages/form-core/src/FocusMixin.js b/packages/form-core/src/FocusMixin.js index 52583d082..f7549c923 100644 --- a/packages/form-core/src/FocusMixin.js +++ b/packages/form-core/src/FocusMixin.js @@ -41,7 +41,7 @@ const FocusMixinImplementation = superclass => /** * Whether the focusable element within (`._focusableNode`) matches ':focus-visible' * Reflects to attribute '[focused-visible]' as a styling hook - * @see https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible + * See: https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible * @type {boolean} */ this.focusedVisible = false; diff --git a/packages/form-core/src/FormControlMixin.js b/packages/form-core/src/FormControlMixin.js index a4638e765..42395af7c 100644 --- a/packages/form-core/src/FormControlMixin.js +++ b/packages/form-core/src/FormControlMixin.js @@ -40,83 +40,22 @@ const FormControlMixinImplementation = superclass => /** @type {any} */ static get properties() { return { - /** - * The name the element will be registered on to the .formElements collection - * of the parent. - */ - name: { - type: String, - reflect: true, - }, - /** - * A Boolean attribute which, if present, indicates that the user should not be able to edit - * the value of the input. The difference between disabled and readonly is that read-only - * controls can still function, whereas disabled controls generally do not function as - * controls until they are enabled. - * - * (From: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-readonly) - */ - readOnly: { - type: Boolean, - attribute: 'readonly', - reflect: true, - }, - /** - * The label text for the input node. - * When no light dom defined via [slot=label], this value will be used - */ + name: { type: String, reflect: true }, + readOnly: { type: Boolean, attribute: 'readonly', reflect: true }, label: String, // FIXME: { attribute: false } breaks a bunch of tests, but shouldn't... - /** - * The helpt text for the input node. - * When no light dom defined via [slot=help-text], this value will be used - */ - helpText: { - type: String, - attribute: 'help-text', - }, - - /** - * The model value is the result of the parser function(when available). - * It should be considered as the internal value used for validation and reasoning/logic. - * The model value is 'ready for consumption' by the outside world (think of a Date - * object or a float). The modelValue can(and is recommended to) be used as both input - * value and output value of the `LionField`. - * - * Examples: - * - For a date input: a String '20/01/1999' will be converted to new Date('1999/01/20') - * - For a number input: a formatted String '1.234,56' will be converted to a Number: - * 1234.56 - */ + helpText: { type: String, attribute: 'help-text' }, modelValue: { attribute: false }, - - /** - * Contains all elements that should end up in aria-labelledby of `._inputNode` - */ _ariaLabelledNodes: { attribute: false }, - /** - * Contains all elements that should end up in aria-describedby of `._inputNode` - */ _ariaDescribedNodes: { attribute: false }, - /** - * Based on the role, details of handling model-value-changed repropagation differ. - */ _repropagationRole: { attribute: false }, - /** - * By default, a field with _repropagationRole 'choice-group' will act as an - * 'endpoint'. This means it will be considered as an individual field: for - * a select, individual options will not be part of the formPath. They - * will. - * Similarly, components that (a11y wise) need to be fieldsets, but 'interaction wise' - * (from Application Developer perspective) need to be more like fields - * (think of an amount-input with a currency select box next to it), can set this - * to true to hide private internals in the formPath. - */ _isRepropagationEndpoint: { attribute: false }, }; } /** - * @return {string} + * The label text for the input node. + * When no light dom defined via [slot=label], this value will be used. + * @type {string} */ get label() { return this.__label || (this._labelNode && this._labelNode.textContent) || ''; @@ -133,7 +72,9 @@ const FormControlMixinImplementation = superclass => } /** - * @return {string} + * The helpt text for the input node. + * When no light dom defined via [slot=help-text], this value will be used + * @type {string} */ get helpText() { return this.__helpText || (this._helpTextNode && this._helpTextNode.textContent) || ''; @@ -150,7 +91,8 @@ const FormControlMixinImplementation = superclass => } /** - * @return {string} + * Will be used in validation messages to refer to the current field + * @type {string} */ get fieldName() { return this.__fieldName || this.label || this.name || ''; @@ -165,7 +107,7 @@ const FormControlMixinImplementation = superclass => } /** - * @type {SlotsMap} + * @configure SlotMixin */ get slots() { return { @@ -183,20 +125,33 @@ const FormControlMixinImplementation = superclass => }; } - /** @protected */ + /** + * The interactive (form) element. Can be a native element like input/textarea/select or + * an element with tabindex > -1 + * @protected + */ get _inputNode() { return /** @type {HTMLElementWithValue} */ (this.__getDirectSlotChild('input')); } + /** + * Element where label will be rendered to + * @protected + */ get _labelNode() { return /** @type {HTMLElement} */ (this.__getDirectSlotChild('label')); } + /** + * Element where help text will be rendered to + * @protected + */ get _helpTextNode() { return /** @type {HTMLElement} */ (this.__getDirectSlotChild('help-text')); } /** + * Element where validation feedback will be rendered to * @protected */ get _feedbackNode() { @@ -205,19 +160,91 @@ const FormControlMixinImplementation = superclass => constructor() { super(); - /** @type {string} */ + + /** + * The name the element will be registered with to the .formElements collection + * of the parent. Also, it serves as the key of key/value pairs in + * modelValue/serializedValue objects + * @type {string} + */ this.name = ''; - /** @type {string} */ + + /** + * A Boolean attribute which, if present, indicates that the user should not be able to edit + * the value of the input. The difference between disabled and readonly is that read-only + * controls can still function, whereas disabled controls generally do not function as + * controls until they are enabled. + * (From: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-readonly) + * @type {boolean} + */ + this.readOnly = false; + + /** + * The label text for the input node. + * When no value is defined, textContent of [slot=label] will be used + * @type {string} + */ + this.label = ''; + + /** + * The helpt text for the input node. + * When no value is defined, textContent of [slot=help-text] will be used + * @type {string} + */ + this.helpText = ''; + + /** + * The model value is the result of the parser function(when available). + * It should be considered as the internal value used for validation and reasoning/logic. + * The model value is 'ready for consumption' by the outside world (think of a Date + * object or a float). The modelValue can(and is recommended to) be used as both input + * value and output value of the `LionField`. + * + * Examples: + * - For a date input: a String '20/01/1999' will be converted to new Date('1999/01/20') + * - For a number input: a formatted String '1.234,56' will be converted to a Number: + * 1234.56 + */ + // TODO: we can probably set this up properly once propert effects run from firstUpdated + // this.modelValue = undefined; + /** + * Unique id that can be used in all light dom + * @type {string} + * @protected + */ this._inputId = uuid(this.localName); - /** @type {HTMLElement[]} */ + + /** + * Contains all elements that should end up in aria-labelledby of `._inputNode` + * @type {HTMLElement[]} + */ this._ariaLabelledNodes = []; - /** @type {HTMLElement[]} */ + + /** + * Contains all elements that should end up in aria-describedby of `._inputNode` + * @type {HTMLElement[]} + */ this._ariaDescribedNodes = []; - /** @type {'child'|'choice-group'|'fieldset'} */ + + /** + * Based on the role, details of handling model-value-changed repropagation differ. + * @type {'child'|'choice-group'|'fieldset'} + */ this._repropagationRole = 'child'; + + /** + * By default, a field with _repropagationRole 'choice-group' will act as an + * 'endpoint'. This means it will be considered as an individual field: for + * a select, individual options will not be part of the formPath. They + * will. + * Similarly, components that (a11y wise) need to be fieldsets, but 'interaction wise' + * (from Application Developer perspective) need to be more like fields + * (think of an amount-input with a currency select box next to it), can set this + * to true to hide private internals in the formPath. + * @type {boolean} + */ this._isRepropagationEndpoint = false; - /** @private */ - this.__label = ''; + this.addEventListener( 'model-value-changed', /** @type {EventListenerOrEventListenerObject} */ (this.__repropagateChildrenValues), @@ -274,6 +301,7 @@ const FormControlMixinImplementation = superclass => if (changedProperties.has('name')) { this.dispatchEvent( + /** @privateEvent */ new CustomEvent('form-element-name-changed', { detail: { oldName: changedProperties.get('name'), newName: this.name }, bubbles: true, @@ -551,6 +579,7 @@ const FormControlMixinImplementation = superclass => } /** + * Used for Required validation and computation of interaction states * @param {any} modelValue * @return {boolean} * @protected @@ -713,7 +742,7 @@ const FormControlMixinImplementation = superclass => } /** - * Meant for Application Developers wanting to add to aria-labelledby attribute. + * Allows to add extra element references to aria-labelledby attribute. * @param {HTMLElement} element * @param {{idPrefix?:string; reorder?: boolean}} customConfig */ @@ -729,7 +758,7 @@ const FormControlMixinImplementation = superclass => } /** - * Meant for Application Developers wanting to delete from aria-labelledby attribute. + * Allows to remove element references from aria-labelledby attribute. * @param {HTMLElement} element */ removeFromAriaLabelledBy(element) { @@ -744,7 +773,7 @@ const FormControlMixinImplementation = superclass => } /** - * Meant for Application Developers wanting to add to aria-describedby attribute. + * Allows to add element references to aria-describedby attribute. * @param {HTMLElement} element * @param {{idPrefix?:string; reorder?: boolean}} customConfig */ @@ -760,7 +789,7 @@ const FormControlMixinImplementation = superclass => } /** - * Meant for Application Developers wanting to delete from aria-describedby attribute. + * Allows to remove element references from aria-describedby attribute. * @param {HTMLElement} element */ removeFromAriaDescribedBy(element) { @@ -810,6 +839,8 @@ const FormControlMixinImplementation = superclass => } /** + * Hook for Subclassers to add logic before repropagation + * @configurable * @param {CustomEvent} ev * @protected */ diff --git a/packages/form-core/src/FormatMixin.js b/packages/form-core/src/FormatMixin.js index 84d01ec28..b907b8884 100644 --- a/packages/form-core/src/FormatMixin.js +++ b/packages/form-core/src/FormatMixin.js @@ -62,46 +62,8 @@ const FormatMixinImplementation = superclass => /** @type {any} */ static get properties() { return { - /** - * The view value is the result of the formatter function (when available). - * The result will be stored in the native _inputNode (usually an input[type=text]). - * - * Examples: - * - For a date input, this would be '20/01/1999' (dependent on locale). - * - For a number input, this could be '1,234.56' (a String representation of modelValue - * 1234.56) - * - * @private - */ formattedValue: { attribute: false }, - - /** - * The serialized version of the model value. - * This value exists for maximal compatibility with the platform API. - * The serialized value can be an interface in context where data binding is not - * supported and a serialized string needs to be set. - * - * Examples: - * - For a date input, this would be the iso format of a date, e.g. '1999-01-20'. - * - For a number input this would be the String representation of a float ('1234.56' - * instead of 1234.56) - * - * When no parser is available, the value is usually the same as the formattedValue - * (being _inputNode.value) - * - */ serializedValue: { attribute: false }, - - /** - * Event that will trigger formatting (more precise, visual update of the view, so the - * user sees the formatted value) - * Default: 'change' - */ - formatOn: { attribute: false }, - - /** - * Configuration object that will be available inside the formatter function - */ formatOptions: { attribute: false }, }; } @@ -124,11 +86,13 @@ const FormatMixinImplementation = superclass => } } + /** + * The view value. Will be delegated to `._inputNode.value` + */ get value() { return (this._inputNode && this._inputNode.value) || this.__value || ''; } - // We don't delegate, because we want to preserve caret position via _setValueAndPreserveCaret /** @param {string} value */ set value(value) { // if not yet connected to dom can't change the value @@ -142,6 +106,15 @@ const FormatMixinImplementation = superclass => } /** + * Preprocesses the viewValue before it's parsed to a modelValue. Can be used to filter + * invalid input amongst others. + * @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 */ @@ -150,9 +123,9 @@ const FormatMixinImplementation = superclass => } /** - * Converts formattedValue to modelValue + * Converts viewValue to modelValue * For instance, a localized date to a Date Object - * @param {string} v - formattedValue: the formatted value inside + * @param {string} v - viewValue: the formatted value inside * @param {FormatOptions} opts * @returns {*} modelValue */ @@ -187,7 +160,7 @@ const FormatMixinImplementation = superclass => } /** - * Converts `LionField.value` to `.modelValue` + * Converts `.serializedValue` to `.modelValue` * For instance, an iso formatted date string to a Date object * @param {?} v - modelValue: can be an Object, Number, String depending on the * input type(date, number, email etc) @@ -223,11 +196,9 @@ const FormatMixinImplementation = superclass => } } if (source !== 'formatted') { - /** @type {string} */ this.formattedValue = this._callFormatter(); } if (source !== 'serialized') { - /** @type {string} */ this.serializedValue = this.serializer(this.modelValue); } this._reflectBackFormattedValueToUser(); @@ -310,7 +281,6 @@ const FormatMixinImplementation = superclass => } /** - * Observer Handlers * @param {{ modelValue: unknown; }[]} args * @protected */ @@ -320,15 +290,16 @@ const FormatMixinImplementation = superclass => } /** - * @param {{ modelValue: unknown; }[]} args * This is wrapped in a distinct method, so that parents can control when the changed event * is fired. For objects, a deep comparison might be needed. + * @param {{ modelValue: unknown; }[]} args * @protected */ // eslint-disable-next-line no-unused-vars _dispatchModelValueChangedEvent(...args) { /** @event model-value-changed */ this.dispatchEvent( + /** @privateEvent model-value-changed: FormControl redispatches it as public event */ new CustomEvent('model-value-changed', { bubbles: true, detail: /** @type { ModelValueEventDetails } */ ({ @@ -396,12 +367,9 @@ const FormatMixinImplementation = superclass => * @protected */ _proxyInputEvent() { - this.dispatchEvent( - new CustomEvent('user-input-changed', { - bubbles: true, - composed: true, - }), - ); + // TODO: [v1] remove composed (and bubbles as well if possible) + /** @protectedEvent user-input-changed meant for usage by Subclassers only */ + this.dispatchEvent(new Event('user-input-changed', { bubbles: true })); } /** @protected */ @@ -428,8 +396,52 @@ const FormatMixinImplementation = superclass => constructor() { super(); + + // TODO: [v1] delete; use 'change' event directly within this file + /** + * Event that will trigger formatting (more precise, visual update of the view, so the + * user sees the formatted value) + * Default: 'change' + * @deprecated use _reflectBackOn() + * @protected + */ this.formatOn = 'change'; + + /** + * Configuration object that will be available inside the formatter function + */ this.formatOptions = /** @type {FormatOptions} */ ({}); + + /** + * The view value is the result of the formatter function (when available). + * The result will be stored in the native _inputNode (usually an input[type=text]). + * + * Examples: + * - For a date input, this would be '20/01/1999' (dependent on locale). + * - For a number input, this could be '1,234.56' (a String representation of modelValue + * 1234.56) + * @type {string|undefined} + * @readOnly + */ + this.formattedValue = undefined; + + /** + * The serialized version of the model value. + * This value exists for maximal compatibility with the platform API. + * The serialized value can be an interface in context where data binding is not + * supported and a serialized string needs to be set. + * + * Examples: + * - For a date input, this would be the iso format of a date, e.g. '1999-01-20'. + * - For a number input this would be the String representation of a float ('1234.56' + * instead of 1234.56) + * + * When no parser is available, the value is usually the same as the formattedValue + * (being _inputNode.value) + * @type {string|undefined} + */ + this.serializedValue = undefined; + /** * Whether the user is pasting content. Allows Subclassers to do this in their subclass: * @example @@ -439,8 +451,18 @@ const FormatMixinImplementation = superclass => * } * ``` * @protected + * @type {boolean} */ this._isPasting = false; + + /** + * Flag that will be set when user interaction takes place (for instance after an 'input' + * event). Will be added as meta info to the `model-value-changed` event. Depending on + * whether a user is interacting, formatting logic will be handled differently. + * @protected + * @type {boolean} + */ + this._isHandlingUserInput = false; /** * @private * @type {string} @@ -451,6 +473,20 @@ const FormatMixinImplementation = superclass => this.addEventListener('user-input-changed', this._onUserInputChanged); // This sets the formatted viewValue after paste this.addEventListener('paste', this.__onPaste); + + /** + * @protected + */ + this._reflectBackFormattedValueToUser = this._reflectBackFormattedValueToUser.bind(this); + + /** + * @private + */ + this._reflectBackFormattedValueDebounced = () => { + // Make sure this is fired after the change event of _inputNode, so that formattedValue + // is guaranteed to be calculated + setTimeout(this._reflectBackFormattedValueToUser); + }; } __onPaste() { @@ -464,13 +500,6 @@ const FormatMixinImplementation = superclass => connectedCallback() { super.connectedCallback(); - this._reflectBackFormattedValueToUser = this._reflectBackFormattedValueToUser.bind(this); - - this._reflectBackFormattedValueDebounced = () => { - // Make sure this is fired after the change event of _inputNode, so that formattedValue - // is guaranteed to be calculated - setTimeout(this._reflectBackFormattedValueToUser); - }; // Connect the value found in to the formatting/parsing/serializing loop as a // fallback mechanism. Assume the user uses the value property of the diff --git a/packages/form-core/src/InteractionStateMixin.js b/packages/form-core/src/InteractionStateMixin.js index 353093aea..38cb07f22 100644 --- a/packages/form-core/src/InteractionStateMixin.js +++ b/packages/form-core/src/InteractionStateMixin.js @@ -23,47 +23,15 @@ const InteractionStateMixinImplementation = superclass => /** @type {any} */ static get properties() { return { - /** - * True when user has focused and left(blurred) the field. - */ - touched: { - type: Boolean, - reflect: true, - }, - /** - * True when user has changed the value of the field. - */ - dirty: { - type: Boolean, - reflect: true, - }, - /** - * True when the modelValue is non-empty (see _isEmpty in FormControlMixin) - */ - filled: { - type: Boolean, - reflect: true, - }, - /** - * True when user has left non-empty field or input is prefilled. - * The name must be seen from the point of view of the input field: - * once the user enters the input field, the value is non-empty. - */ - prefilled: { - attribute: false, - }, - /** - * True when user has attempted to submit the form, e.g. through a button - * of type="submit" - */ - submitted: { - attribute: false, - }, + touched: { type: Boolean, reflect: true }, + dirty: { type: Boolean, reflect: true }, + filled: { type: Boolean, reflect: true }, + prefilled: { attribute: false }, + submitted: { attribute: false }, }; } /** - * * @param {PropertyKey} name * @param {*} oldVal */ @@ -86,18 +54,65 @@ const InteractionStateMixinImplementation = superclass => constructor() { super(); + + /** + * True when user has focused and left(blurred) the field. + * @type {boolean} + */ this.touched = false; + + /** + * True when user has changed the value of the field. + * @type {boolean} + */ this.dirty = false; + + /** + * True when user has left non-empty field or input is prefilled. + * The name must be seen from the point of view of the input field: + * once the user enters the input field, the value is non-empty. + * @type {boolean} + */ this.prefilled = false; + + /** + * True when the modelValue is non-empty (see _isEmpty in FormControlMixin) + * @type {boolean} + */ this.filled = false; - /** @type {string} */ + /** + * True when user has attempted to submit the form, e.g. through a button + * of type="submit" + * @type {boolean} + */ + // TODO: [v1] this might be fixable by scheduling property effects till firstUpdated + // this.submitted = false; + + /** + * The event that triggers the touched state + * @type {string} + * @protected + */ this._leaveEvent = 'blur'; - /** @type {string} */ + + /** + * The event that triggers the dirty state + * @type {string} + * @protected + */ this._valueChangedEvent = 'model-value-changed'; - /** @type {EventHandlerNonNull} */ + + /** + * @type {EventHandlerNonNull} + * @protected + */ this._iStateOnLeave = this._iStateOnLeave.bind(this); - /** @type {EventHandlerNonNull} */ + + /** + * @type {EventHandlerNonNull} + * @protected + */ this._iStateOnValueChange = this._iStateOnValueChange.bind(this); } @@ -118,10 +133,9 @@ const InteractionStateMixinImplementation = superclass => } /** - * Evaluations performed on connectedCallback. Since some components can be out of sync - * (due to interdependence on light children that can only be processed - * after connectedCallback and affect the initial value). - * This method is exposed, so it can be called after they are initialized themselves. + * Evaluations performed on connectedCallback. + * This method is public, so it can be called at a later moment (when we need to wait for + * registering children for instance) as well. * Since this method will be called twice in last mentioned scenario, it must stay idempotent. */ initInteractionState() { @@ -130,8 +144,7 @@ const InteractionStateMixinImplementation = superclass => } /** - * Sets touched value to true - * Reevaluates prefilled state. + * Sets touched value to true and reevaluates prefilled state. * When false, on next interaction, user will start with a clean state. * @protected */ @@ -159,22 +172,25 @@ const InteractionStateMixinImplementation = superclass => } /** - * Dispatches custom event on touched state change + * Dispatches event on touched state change * @protected */ _onTouchedChanged() { - this.dispatchEvent(new CustomEvent('touched-changed', { bubbles: true, composed: true })); + /** @protectedEvent touched-changed */ + this.dispatchEvent(new Event('touched-changed', { bubbles: true, composed: true })); } /** - * Dispatches custom event on touched state change + * Dispatches event on touched state change * @protected */ _onDirtyChanged() { - this.dispatchEvent(new CustomEvent('dirty-changed', { bubbles: true, composed: true })); + /** @protectedEvent dirty-changed */ + this.dispatchEvent(new Event('dirty-changed', { bubbles: true, composed: true })); } /** + * @override ValidateMixin * Show the validity feedback when one of the following conditions is met: * * - submitted @@ -199,6 +215,9 @@ const InteractionStateMixinImplementation = superclass => return (meta.touched && meta.dirty) || meta.prefilled || meta.submitted; } + /** + * @enhance ValidateMixin + */ get _feedbackConditionMeta() { return { // @ts-ignore to fix, InteractionStateMixin needs to depend on ValidateMixin diff --git a/packages/form-core/src/LionField.js b/packages/form-core/src/LionField.js index 5c8f25584..2b998380b 100644 --- a/packages/form-core/src/LionField.js +++ b/packages/form-core/src/LionField.js @@ -25,26 +25,6 @@ import { InteractionStateMixin } from './InteractionStateMixin.js'; // applies F export class LionField extends FormControlMixin( InteractionStateMixin(FocusMixin(FormatMixin(ValidateMixin(SlotMixin(LitElement))))), ) { - /** @type {any} */ - static get properties() { - return { - autocomplete: { - type: String, - reflect: true, - }, - value: { - type: String, - }, - }; - } - - constructor() { - super(); - this.name = ''; - /** @type {string | undefined} */ - this.autocomplete = undefined; - } - /** * @param {import('@lion/core').PropertyValues } changedProperties */ @@ -85,7 +65,8 @@ export class LionField extends FormControlMixin( * Interaction states are not cleared (use resetInteractionState for this) */ clear() { - this.modelValue = ''; // can't set null here, because IE11 treats it as a string + // TODO: [v1] set to undefined + this.modelValue = ''; } /** @@ -93,11 +74,8 @@ export class LionField extends FormControlMixin( * @protected */ _onChange() { - this.dispatchEvent( - new CustomEvent('user-input-changed', { - bubbles: true, - }), - ); + /** @protectedEvent user-input-changed */ + this.dispatchEvent(new Event('user-input-changed', { bubbles: true })); } /** diff --git a/packages/form-core/src/NativeTextFieldMixin.js b/packages/form-core/src/NativeTextFieldMixin.js index 877bc45f0..69747ea51 100644 --- a/packages/form-core/src/NativeTextFieldMixin.js +++ b/packages/form-core/src/NativeTextFieldMixin.js @@ -10,6 +10,23 @@ import { FormatMixin } from './FormatMixin.js'; */ const NativeTextFieldMixinImplementation = superclass => class NativeTextFieldMixin extends FormatMixin(FocusMixin(FormControlMixin(superclass))) { + /** @type {any} */ + static get properties() { + return { + autocomplete: { type: String, reflect: true }, + }; + } + + constructor() { + super(); + + /** + * Delegates this property to input/textarea/select. + * @type {string | undefined} + */ + this.autocomplete = undefined; + } + /** * @protected * @type {HTMLInputElement | HTMLTextAreaElement} diff --git a/packages/form-core/src/choice-group/ChoiceGroupMixin.js b/packages/form-core/src/choice-group/ChoiceGroupMixin.js index 4c21ae9ea..4c0415b17 100644 --- a/packages/form-core/src/choice-group/ChoiceGroupMixin.js +++ b/packages/form-core/src/choice-group/ChoiceGroupMixin.js @@ -25,15 +25,7 @@ const ChoiceGroupMixinImplementation = superclass => /** @type {any} */ static get properties() { return { - /** - * @desc When false (default), modelValue and serializedValue will reflect the - * currently selected choice (usually a string). When true, modelValue will and - * serializedValue will be an array of strings. - */ - multipleChoice: { - type: Boolean, - attribute: 'multiple-choice', - }, + multipleChoice: { type: Boolean, attribute: 'multiple-choice' }, }; } @@ -132,11 +124,21 @@ const ChoiceGroupMixinImplementation = superclass => constructor() { super(); + + /** + * When false (default), modelValue and serializedValue will reflect the + * currently selected choice (usually a string). When true, modelValue will and + * serializedValue will be an array of strings. + * @type {boolean} + */ this.multipleChoice = false; - /** @type {'child'|'choice-group'|'fieldset'} + + /** + * @type {'child'|'choice-group'|'fieldset'} + * @configure FormControlMixin event propagation * @protected */ - this._repropagationRole = 'choice-group'; // configures event propagation logic of FormControlMixin + this._repropagationRole = 'choice-group'; /** @private */ this.__isInitialModelValue = true; /** @private */ @@ -156,7 +158,7 @@ const ChoiceGroupMixinImplementation = superclass => } /** - * @enhance FormRegistrarMixin + * @enhance FormRegistrarMixin: we need one extra microtask to complete */ _completeRegistration() { // Double microtask queue to account for Webkit race condition @@ -175,7 +177,7 @@ const ChoiceGroupMixinImplementation = superclass => } /** - * @override from FormRegistrarMixin + * @enhance FormRegistrarMixin * @param {FormControl} child * @param {number} indexToInsertAt */ @@ -353,10 +355,9 @@ const ChoiceGroupMixinImplementation = superclass => } /** - * Don't repropagate unchecked single choice choiceInputs * @param {FormControlHost & ChoiceInputHost} target * @protected - * @overridable + * @configure FormControlMixin: don't repropagate unchecked single choice choiceInputs */ _repropagationCondition(target) { return !( diff --git a/packages/form-core/src/choice-group/ChoiceInputMixin.js b/packages/form-core/src/choice-group/ChoiceInputMixin.js index adb4ed66f..7afb33708 100644 --- a/packages/form-core/src/choice-group/ChoiceInputMixin.js +++ b/packages/form-core/src/choice-group/ChoiceInputMixin.js @@ -25,42 +25,18 @@ const ChoiceInputMixinImplementation = superclass => /** @type {any} */ static get properties() { return { - /** - * Boolean indicating whether or not this element is checked by the end user. - */ - checked: { - type: Boolean, - reflect: true, - }, - /** - * Boolean indicating whether or not this element is disabled. - */ - disabled: { - type: Boolean, - reflect: true, - }, - /** - * Whereas 'normal' `.modelValue`s usually store a complex/typed version - * of a view value, choice inputs have a slightly different approach. - * In order to remain their Single Source of Truth characteristic, choice inputs - * store both the value and 'checkedness', in the format { value: 'x', checked: true } - * Different from the platform, this also allows to serialize the 'non checkedness', - * allowing to restore form state easily and inform the server about unchecked options. - */ - modelValue: { - type: Object, - hasChanged, - }, - /** - * The value property of the modelValue. It provides an easy interface for storing - * (complex) values in the modelValue - */ - choiceValue: { - type: Object, - }, + checked: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + modelValue: { type: Object, hasChanged }, + choiceValue: { type: Object }, }; } + /** + * The value that will be registered to the modelValue of the parent ChoiceGroup. Recommended + * to be a string + * @type {string|any} + */ get choiceValue() { return this.modelValue.value; } @@ -123,8 +99,33 @@ const ChoiceInputMixinImplementation = superclass => constructor() { super(); + /** + * Boolean indicating whether or not this element is checked by the end user. + */ + // TODO: [v1] this can be solved when property effects are scheduled until firstUpdated + // this.checked = false; + /** + * Whereas 'normal' `.modelValue`s usually store a complex/typed version + * of a view value, choice inputs have a slightly different approach. + * In order to remain their Single Source of Truth characteristic, choice inputs + * store both the value and 'checkedness', in the format { value: 'x', checked: true } + * Different from the platform, this also allows to serialize the 'non checkedness', + * allowing to restore form state easily and inform the server about unchecked options. + * @type {{value:string|any,checked:boolean}} + */ this.modelValue = { value: '', checked: false }; + // TODO: maybe disabled is more a concern of FormControl/Field? + /** + * Boolean indicating whether or not this element is disabled. + * @type {boolean} + */ this.disabled = false; + + /** + * The value property of the modelValue. It provides an easy interface for storing + * (complex) values in the modelValue + */ + /** @protected */ this._preventDuplicateLabelClick = this._preventDuplicateLabelClick.bind(this); /** @protected */ diff --git a/packages/form-core/src/form-group/FormGroupMixin.js b/packages/form-core/src/form-group/FormGroupMixin.js index f97720c78..925dfb63e 100644 --- a/packages/form-core/src/form-group/FormGroupMixin.js +++ b/packages/form-core/src/form-group/FormGroupMixin.js @@ -34,54 +34,25 @@ const FormGroupMixinImplementation = superclass => /** @type {any} */ static get properties() { return { - /** - * Interaction state that can be used to compute the visibility of - * feedback messages - */ - submitted: { - type: Boolean, - reflect: true, - }, - /** - * Interaction state that will be active when any of the children - * is focused. - */ - focused: { - type: Boolean, - reflect: true, - }, - /** - * Interaction state that will be active when any of the children - * is dirty (see InteractionStateMixin for more details.) - */ - dirty: { - type: Boolean, - reflect: true, - }, - /** - * Interaction state that will be active when the group as a whole is - * blurred - */ - touched: { - type: Boolean, - reflect: true, - }, - /** - * Interaction state that will be active when all of the children - * are prefilled (see InteractionStateMixin for more details.) - */ - prefilled: { - type: Boolean, - reflect: true, - }, + submitted: { type: Boolean, reflect: true }, + focused: { type: Boolean, reflect: true }, + dirty: { type: Boolean, reflect: true }, + touched: { type: Boolean, reflect: true }, + prefilled: { type: Boolean, reflect: true }, }; } - /** @protected */ + /** + * The host element with role group (or radigroup or form) containing neccessary aria attributes + * @protected + */ get _inputNode() { return this; } + /** + * Object keyed by formElements names, containing formElements' modelValues + */ get modelValue() { return this._getFromAllFormElements('modelValue'); } @@ -97,6 +68,9 @@ const FormGroupMixinImplementation = superclass => } } + /** + * Object keyed by formElements names, containing formElements' serializedValues + */ get serializedValue() { return this._getFromAllFormElements('serializedValue'); } @@ -112,6 +86,9 @@ const FormGroupMixinImplementation = superclass => } } + /** + * Object keyed by formElements names, containing formElements' formattedValues + */ get formattedValue() { return this._getFromAllFormElements('formattedValue'); } @@ -120,24 +97,51 @@ const FormGroupMixinImplementation = superclass => this._setValueMapForAllFormElements('formattedValue', values); } + /** + * True when all of the children are prefilled (see InteractionStateMixin for more details.) + */ get prefilled() { return this._everyFormElementHas('prefilled'); } constructor() { super(); - // ._inputNode = this, which always requires a value prop + + // ._inputNode === this, which always requires a value prop this.value = ''; + /** + * Disables all formElements in group + */ this.disabled = false; - this.submitted = false; - this.dirty = false; - this.touched = false; - this.focused = false; - this.__addedSubValidators = false; - this.__isInitialModelValue = true; - this.__isInitialSerializedValue = true; + /** + * True when parent form is submitted + */ + this.submitted = false; + + /** + * True when any of the children is dirty (see InteractionStateMixin for more details.) + */ + this.dirty = false; + + /** + * True when the group as a whole is blurred (see InteractionStateMixin for more details.) + */ + this.touched = false; + + /** + * True when any of the children is focused. + */ + this.focused = false; + + /** @private */ + this.__addedSubValidators = false; + /** @private */ + this.__isInitialModelValue = true; + /** @private */ + this.__isInitialSerializedValue = true; + /** @private */ this._checkForOutsideClick = this._checkForOutsideClick.bind(this); this.addEventListener('focusin', this._syncFocused); @@ -255,7 +259,7 @@ const FormGroupMixinImplementation = superclass => } /** - * @desc Handles interaction state 'submitted'. + * Handles interaction state 'submitted'. * This allows children to enable visibility of validation feedback */ submitGroup() { @@ -269,6 +273,9 @@ const FormGroupMixinImplementation = superclass => }); } + /** + * Resets to initial/prefilled values and interaction states of all FormControls in group, + */ resetGroup() { this.formElements.forEach(child => { if (typeof child.resetGroup === 'function') { @@ -281,6 +288,9 @@ const FormGroupMixinImplementation = superclass => this.resetInteractionState(); } + /** + * Clears all values and resets all interaction states of all FormControls in group, + */ clearGroup() { this.formElements.forEach(child => { if (typeof child.clearGroup === 'function') { @@ -293,6 +303,9 @@ const FormGroupMixinImplementation = superclass => this.resetInteractionState(); } + /** + * Resets all interaction states for all formElements + */ resetInteractionState() { this.submitted = false; this.touched = false; @@ -305,7 +318,9 @@ const FormGroupMixinImplementation = superclass => } /** + * Gets a keyed be name object for requested property (like modelValue/serializedValue) * @param {string} property + * @returns {{[name:string]: any}} */ _getFromAllFormElements(property, filterFn = (/** @type {FormControl} */ el) => !el.disabled) { const result = {}; @@ -326,6 +341,7 @@ const FormGroupMixinImplementation = superclass => } /** + * Sets the same value for requested property in all formElements * @param {string | number} property * @param {any} value */ @@ -336,6 +352,7 @@ const FormGroupMixinImplementation = superclass => } /** + * Allows to set formElements values via a keyed object structure * @param {string} property * @param {{ [x: string]: any; }} values */ @@ -360,6 +377,7 @@ const FormGroupMixinImplementation = superclass => } /** + * Returns true when one of the formElements has requested * @param {string} property */ _anyFormElementHas(property) { @@ -389,6 +407,7 @@ const FormGroupMixinImplementation = superclass => } /** + * Returns true when all of the formElements have requested property * @param {string} property */ _everyFormElementHas(property) { @@ -400,11 +419,13 @@ const FormGroupMixinImplementation = superclass => }); } + // TODO: the same functionality has been implemented with model-value-changed event, which + // covers the same and works with FormRegistrarPortalMixin /** * Gets triggered by event 'validate-performed' which enabled us to handle 2 different situations - * - react on modelValue change, which says something about the validity as a whole - * (at least two checkboxes for instance) and nothing about the children's values - * - children validity states have changed, so fieldset needs to update itself based on that + * - react on modelValue change, which says something about the validity as a whole + * (at least two checkboxes for instance) and nothing about the children's values + * - children validity states have changed, so fieldset needs to update itself based on that * @param {Event} ev */ __onChildValidatePerformed(ev) { @@ -441,6 +462,7 @@ const FormGroupMixinImplementation = superclass => * In case one of the inputs was in error state as well, the SR user would * first hear the local error, followed by #group-error * @example + * ```html * * * ... @@ -448,6 +470,7 @@ const FormGroupMixinImplementation = superclass => * Park Avenue only has numbers up to 80 * * + * ``` */ __storeAllDescriptionElementsInParentChain() { const unTypedThis = /** @type {unknown} */ (this); @@ -487,8 +510,7 @@ const FormGroupMixinImplementation = superclass => } /** - * @override of FormRegistrarMixin. - * @desc Connects ValidateMixin and DisabledMixin + * @enhance FormRegistrarMixin: connects ValidateMixin and DisabledMixin. * On top of this, error messages of children are linked to their parents * @param {FormControl & {serializedValue:string|object}} child * @param {number} indexToInsertAt @@ -520,15 +542,14 @@ const FormGroupMixinImplementation = superclass => } /** - * Gathers initial model values of all children. Used - * when resetGroup() is called. + * Gathers initial model values of all children. Used when resetGroup() is called. */ get _initialModelValue() { return this._getFromAllFormElements('_initialModelValue'); } /** - * @override of FormRegistrarMixin. Connects ValidateMixin + * @override FormRegistrarMixin; Connects ValidateMixin * @param {FormRegisteringHost & FormControl} el */ removeFormElement(el) { diff --git a/packages/form-core/src/registration/FormControlsCollection.js b/packages/form-core/src/registration/FormControlsCollection.js index 233db5090..1d9ca70be 100644 --- a/packages/form-core/src/registration/FormControlsCollection.js +++ b/packages/form-core/src/registration/FormControlsCollection.js @@ -1,7 +1,7 @@ /* eslint-disable */ /** - * @desc This class closely mimics the natively + * This class closely mimics the natively * supported HTMLFormControlsCollection. It can be accessed * both like an array and an object (based on control/element names). * @example diff --git a/packages/form-core/src/registration/FormRegisteringMixin.js b/packages/form-core/src/registration/FormRegisteringMixin.js index 5786d37c7..540a84548 100644 --- a/packages/form-core/src/registration/FormRegisteringMixin.js +++ b/packages/form-core/src/registration/FormRegisteringMixin.js @@ -21,7 +21,11 @@ const FormRegisteringMixinImplementation = superclass => class extends superclass { constructor() { super(); - /** @type {FormRegistrarHost | undefined} */ + /** + * The registrar this FormControl registers to, Usually a descendant of FormGroup or + * ChoiceGroup + * @type {FormRegistrarHost | undefined} + */ this._parentFormGroup = undefined; } diff --git a/packages/form-core/src/registration/FormRegistrarMixin.js b/packages/form-core/src/registration/FormRegistrarMixin.js index e3fc9ab3c..a499fd8b9 100644 --- a/packages/form-core/src/registration/FormRegistrarMixin.js +++ b/packages/form-core/src/registration/FormRegistrarMixin.js @@ -29,23 +29,31 @@ const FormRegistrarMixinImplementation = superclass => /** @type {any} */ static get properties() { return { - /** - * @desc Flag that determines how ".formElements" should behave. - * For a regular fieldset (see LionFieldset) we expect ".formElements" - * to be accessible as an object. - * In case of a radio-group, a checkbox-group or a select/listbox, - * it should act like an array (see ChoiceGroupMixin). - * Usually, when false, we deal with a choice-group (radio-group, checkbox-group, - * (multi)select) - */ _isFormOrFieldset: { type: Boolean }, }; } constructor() { super(); + + /** + * Closely mimics the natively supported HTMLFormControlsCollection. It can be accessed + * both like an array and an object (based on control/element names). + * @type {FormControlsCollection} + */ this.formElements = new FormControlsCollection(); + /** + * Flag that determines how ".formElements" should behave. + * For a regular fieldset (see LionFieldset) we expect ".formElements" + * to be accessible as an object. + * In case of a radio-group, a checkbox-group or a select/listbox, + * it should act like an array (see ChoiceGroupMixin). + * Usually, when false, we deal with a choice-group (radio-group, checkbox-group, + * (multi)select) + * @type {boolean} + * @protected + */ this._isFormOrFieldset = false; this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this); @@ -100,6 +108,10 @@ const FormRegistrarMixinImplementation = superclass => this._completeRegistration(); } + /** + * Resolves the registrationComplete promise. Subclassers can delay if needed + * @overridable + */ _completeRegistration() { Promise.resolve().then(() => this.__resolveRegistrationComplete(undefined)); } @@ -197,6 +209,7 @@ const FormRegistrarMixinImplementation = superclass => } /** + * Hook for Subclassers to perform logic before an element is added * @param {CustomEvent} ev * @protected */ diff --git a/packages/form-core/src/registration/FormRegistrarPortalMixin.js b/packages/form-core/src/registration/FormRegistrarPortalMixin.js index 8b41855a4..a6d4efb7a 100644 --- a/packages/form-core/src/registration/FormRegistrarPortalMixin.js +++ b/packages/form-core/src/registration/FormRegistrarPortalMixin.js @@ -24,7 +24,12 @@ const FormRegistrarPortalMixinImplementation = superclass => class extends superclass { constructor() { super(); - /** @type {(FormRegistrarPortalHost & HTMLElement) | undefined} */ + + /** + * Registration target: an element, usually in the body of the dom, that captures events + * and redispatches them on host + * @type {(FormRegistrarPortalHost & HTMLElement) | undefined} + */ this.registrationTarget = undefined; this.__redispatchEventForFormRegistrarPortalMixin = this.__redispatchEventForFormRegistrarPortalMixin.bind( this, diff --git a/packages/form-core/src/utils/SyncUpdatableMixin.js b/packages/form-core/src/utils/SyncUpdatableMixin.js index 485ff139e..5058fe9e7 100644 --- a/packages/form-core/src/utils/SyncUpdatableMixin.js +++ b/packages/form-core/src/utils/SyncUpdatableMixin.js @@ -8,7 +8,7 @@ import { dedupeMixin } from '@lion/core'; */ /** - * @desc Why this mixin? + * Why this mixin? * - it adheres to the "Member Order Independence" web components standard: * https://github.com/webcomponents/gold-standard/wiki/Member-Order-Independence * - sync observers can be dependent on the outcome of the render function (or, more generically @@ -27,8 +27,9 @@ const SyncUpdatableMixinImplementation = superclass => class extends superclass { constructor() { super(); - // Namespace for this mixin that guarantees naming clashes will not occur... + /** + * Namespace for this mixin that guarantees naming clashes will not occur... * @type {SyncUpdatableNamespace} */ this.__SyncUpdatableNamespace = {}; @@ -113,7 +114,14 @@ const SyncUpdatableMixinImplementation = superclass => } /** - * @desc A public abstraction that has the exact same api as `requestUpdateInternal`. + * An abstraction that has the exact same api as `requestUpdateInternal`, but taking + * into account: + * - [member order independence](https://github.com/webcomponents/gold-standard/wiki/Member-Order-Independence) + * - property effects start when all (light) dom has initialized (on firstUpdated) + * - property effects don't interrupt the first meaningful paint + * - compatible with propertyAccessor.`hasChanged`: no manual checks needed or accidentally + * run property effects / events when no change happened + * effects when values didn't change * All code previously present in requestUpdateInternal can be placed in this method. * @param {string} name * @param {*} oldValue diff --git a/packages/form-core/src/validate/LionValidationFeedback.js b/packages/form-core/src/validate/LionValidationFeedback.js index 1bfcae9f3..64f4b5e9e 100644 --- a/packages/form-core/src/validate/LionValidationFeedback.js +++ b/packages/form-core/src/validate/LionValidationFeedback.js @@ -42,6 +42,8 @@ export class LionValidationFeedback extends LitElement { this.setAttribute('type', this.feedbackData[0].type); this.currentType = this.feedbackData[0].type; window.clearTimeout(this.removeMessage); + // TODO: this logic should be in ValidateMixin, so that [show-feedback-for] is in sync, + // plus duration should be configurable if (this.currentType === 'success') { this.removeMessage = window.setTimeout(() => { this.removeAttribute('type'); diff --git a/packages/form-core/src/validate/Unparseable.js b/packages/form-core/src/validate/Unparseable.js index 5c90dd8a3..3824e39b1 100644 --- a/packages/form-core/src/validate/Unparseable.js +++ b/packages/form-core/src/validate/Unparseable.js @@ -16,7 +16,16 @@ export class Unparseable { /** @param {string} value */ constructor(value) { + /** + * Meta info for restoring serialized Unparseable values + * @type {'unparseable'} + */ this.type = 'unparseable'; + /** + * Stores current view value. For instance, value '09-' is an unparseable Date. + * This info can be used to restore previous form states. + * @type {string} + */ this.viewValue = value; } diff --git a/packages/form-core/src/validate/ValidateMixin.js b/packages/form-core/src/validate/ValidateMixin.js index 0ce50cfb1..67d4d797d 100644 --- a/packages/form-core/src/validate/ValidateMixin.js +++ b/packages/form-core/src/validate/ValidateMixin.js @@ -12,8 +12,11 @@ import { Validator } from './Validator.js'; import { Required } from './validators/Required.js'; import { FormControlMixin } from '../FormControlMixin.js'; +// TODO: [v1] make all @readOnly => @readonly and actually make sure those values cannot be set + /** * @typedef {import('../../types/validate/ValidateMixinTypes').ValidateMixin} ValidateMixin + * @typedef {import('../../types/validate/ValidateMixinTypes').ValidationType} ValidationType */ /** @@ -25,7 +28,7 @@ function arrayDiff(array1 = [], array2 = []) { } /** - * @desc Handles all validation, based on modelValue changes. It has no knowledge about dom and + * Handles all validation, based on modelValue changes. It has no knowledge about dom and * UI. All error visibility, dom interaction and accessibility are handled in FeedbackMixin. * * @type {ValidateMixin} @@ -48,11 +51,8 @@ export const ValidateMixinImplementation = superclass => static get properties() { return { validators: { attribute: false }, - hasFeedbackFor: { attribute: false }, - shouldShowFeedbackFor: { attribute: false }, - showsFeedbackFor: { type: Array, attribute: 'shows-feedback-for', @@ -62,36 +62,22 @@ export const ValidateMixinImplementation = superclass => toAttribute: /** @param {[]} value */ value => value.join(','), }, }, - validationStates: { attribute: false }, - - /** - * @desc flag that indicates whether async validation is pending - */ isPending: { type: Boolean, attribute: 'is-pending', reflect: true, }, - - /** - * @desc specialized fields (think of input-date and input-email) can have preconfigured - * validators. - */ defaultValidators: { attribute: false }, - - /** - * Subclassers can enable this to show multiple feedback messages at the same time - * By default, just like the platform, only one message (with highest prio) is visible. - */ _visibleMessagesAmount: { attribute: false }, - __childModelValueChanged: { attribute: false }, }; } /** + * Types of validation supported by this FormControl (for instance 'error'|'warning'|'info') * @overridable + * @type {ValidationType[]} */ static get validationTypes() { return ['error']; @@ -124,6 +110,11 @@ export const ValidateMixinImplementation = superclass => }; } + /** + * Combination of validators provided by Application Developer and the default validators + * @type {Validator[]} + * @protected + */ get _allValidators() { return [...this.validators, ...this.defaultValidators]; } @@ -131,28 +122,93 @@ export const ValidateMixinImplementation = superclass => constructor() { super(); - /** @type {string[]} */ + /** + * As soon as validation happens (after modelValue/validators/validator param change), this + * array is updated with the active ValidationTypes ('error'|'warning'|'success'|'info' etc.). + * Notice the difference with `.showsFeedbackFor`, which filters `.hasFeedbackFor` based on + * `.feedbackCondition()`. + * + * For styling purposes, will be reflected to [has-feedback-for="error warning"]. This can + * be useful for subtle visual feedback on keyup, like a red/green border around an input. + * + * @example + * ```css + * :host([has-feedback-for~="error"]) .input-group__container { + * border: 1px solid red; + * } + * ``` + * @type {ValidationType[]} + * @readOnly + */ this.hasFeedbackFor = []; - /** @type {string[]} */ - this.shouldShowFeedbackFor = []; - - /** @type {string[]} */ + /** + * Based on outcome of feedbackCondition, this array decides what ValidationTypes should be + * shown in validationFeedback, based on meta data like interaction states. + * + * For styling purposes, it reflects it `[shows-feedback-for="error warning"]` + * @type {ValidationType[]} + * @readOnly + * @example + * ```css + * :host([shows-feedback-for~="success"]) .form-field__feedback { + * transform: scaleY(1); + * } + * ``` + */ this.showsFeedbackFor = []; - /** @type {Object.>} */ + // TODO: [v1] make this fully private (preifix __)? + /** + * A temporary storage to transition from hasFeedbackFor to showsFeedbackFor + * @type {ValidationType[]} + * @readOnly + * @private + */ + this.shouldShowFeedbackFor = []; + + /** + * The outcome of a validation 'round'. Keyed by ValidationType and Validator name + * @readOnly + * @type {Object.>} + */ this.validationStates = {}; - /** @protected */ - this._visibleMessagesAmount = 1; - + /** + * Flag indicating whether async validation is pending. + * Creates attribute [is-pending] as a styling hook + * @type {boolean} + */ this.isPending = false; - /** @type {Validator[]} */ + /** + * Used by Application Developers to add Validators to a FormControl. + * @example + * ```html + * + * + * ``` + * @type {Validator[]} + */ this.validators = []; - /** @type {Validator[]} */ + + /** + * Used by Subclassers to add default Validators to a particular FormControl. + * A date input for instance, always needs the isDate validator. + * @example + * ```js + * this.defaultValidators.push(new IsDate()); + * ``` + * @type {Validator[]} + */ this.defaultValidators = []; + /** + * The amount of feedback messages that will visible in LionValidationFeedback + * @protected + */ + this._visibleMessagesAmount = 1; + /** * @type {Validator[]} * @private @@ -166,29 +222,35 @@ export const ValidateMixinImplementation = superclass => this.__asyncValidationResult = []; /** - * @desc contains results from sync Validators, async Validators and ResultValidators + * Aggregated result from sync Validators, async Validators and ResultValidators * @type {Validator[]} * @private */ this.__validationResult = []; + /** * @type {Validator[]} * @private */ this.__prevValidationResult = []; - /** @type {Validator[]} */ + + /** + * @type {Validator[]} + * @private + */ this.__prevShownValidationResult = []; + /** + * The updated children validity affects the validity of the parent. Helper to recompute + * validatity of parent FormGroup + * @private + */ + this.__childModelValueChanged = false; + /** @private */ this.__onValidatorUpdated = this.__onValidatorUpdated.bind(this); /** @protected */ this._updateFeedbackComponent = this._updateFeedbackComponent.bind(this); - - /** - * This will be used for FormGroups that listen for `model-value-changed` of children - * @private - */ - this.__childModelValueChanged = false; } connectedCallback() { @@ -271,28 +333,28 @@ export const ValidateMixinImplementation = superclass => } /** - * @desc The main function of this mixin. Triggered by: - * - a modelValue change - * - a change in the 'validators' array - * - a change in the config of an individual Validator + * Triggered by: + * - modelValue change + * - change in the 'validators' array + * - change in the config of an individual Validator * * Three situations are handled: - * - A.1 The FormControl is empty: further execution is halted. When the Required Validator + * - a1) the FormControl is empty: further execution is halted. When the Required Validator * (being mutually exclusive to the other Validators) is applied, it will end up in the * validation result (as the only Validator, since further execution was halted). - * - A.2 There are synchronous Validators: this is the most common flow. When modelValue hasn't + * - a2) there are synchronous Validators: this is the most common flow. When modelValue hasn't * changed since last async results were generated, 'sync results' are merged with the * 'async results'. - * - A.3 There are asynchronous Validators: for instance when server side evaluation is needed. + * - a3) there are asynchronous Validators: for instance when server side evaluation is needed. * Executions are scheduled and awaited and the 'async results' are merged with the * 'sync results'. * - * - B. There are ResultValidators. After steps A.1, A.2, or A.3 are finished, the holistic - * ResultValidators (evaluating the total result of the 'regular' (A.1, A.2 and A.3) validators) + * - b) there are ResultValidators. After steps a1, a2, or a3 are finished, the holistic + * ResultValidators (evaluating the total result of the 'regular' (a1, a2 and a3) validators) * will be run... * - * Situations A.2 and A.3 are not mutually exclusive and can be triggered within one validate() - * call. Situation B will occur after every call. + * Situations a2 and a3 are not mutually exclusive and can be triggered within one `validate()` + * call. Situation b will occur after every call. * * @param {{ clearCurrentResult?: boolean }} [opts] */ @@ -318,7 +380,7 @@ export const ValidateMixinImplementation = superclass => } /** - * @desc step A1-3 + B (as explained in 'validate') + * @desc step a1-3 + b (as explained in `validate()`) */ async __executeValidators() { this.validateComplete = new Promise(resolve => { @@ -380,7 +442,7 @@ export const ValidateMixinImplementation = superclass => } /** - * @desc step A2, calls __finishValidation + * step a2 (as explained in `validate()`): calls `__finishValidation` * @param {Validator[]} syncValidators * @param {unknown} value * @param {{ hasAsync: boolean }} opts @@ -396,7 +458,7 @@ export const ValidateMixinImplementation = superclass => } /** - * @desc step A3, calls __finishValidation + * step a3 (as explained in `validate()`), calls __finishValidation * @param {Validator[]} asyncValidators all Validators except required and ResultValidators * @param {?} value * @private @@ -415,7 +477,7 @@ export const ValidateMixinImplementation = superclass => } /** - * @desc step B, called by __finishValidation + * step b (as explained in `validate()`), called by __finishValidation * @param {Validator[]} regularValidationResult result of steps 1-3 * @private */ @@ -541,6 +603,7 @@ export const ValidateMixinImplementation = superclass => } /** + * Helper method for the mutually exclusive Required Validator * @param {?} v * @private */ @@ -593,7 +656,7 @@ export const ValidateMixinImplementation = superclass => } /** - * @desc Responsible for retrieving messages from Validators and + * Responsible for retrieving messages from Validators and * (delegation of) rendering them. * * For `._feedbackNode` (extension of LionValidationFeedback): @@ -640,8 +703,8 @@ export const ValidateMixinImplementation = superclass => } /** - * The default feedbackCondition condition that will be used when the - * feedbackCondition is not overridden. + * Default feedbackCondition condition, used by Subclassers, that will be used when + * `feedbackCondition()` is not overridden by Application Developer. * Show the validity feedback when returning true, don't show when false * @param {string} type could be 'error', 'warning', 'info', 'success' or any other custom * Validator type @@ -654,7 +717,7 @@ export const ValidateMixinImplementation = superclass => } /** - * Allows super classes to add meta info for feedbackCondition + * Allows Subclassers to add meta info for feedbackCondition * @configurable */ get _feedbackConditionMeta() { @@ -664,6 +727,7 @@ export const ValidateMixinImplementation = superclass => /** * Allows the end user to specify when a feedback message should be shown * @example + * ```js * feedbackCondition(type, meta, defaultCondition) { * if (type === 'info') { * return return; @@ -672,6 +736,7 @@ export const ValidateMixinImplementation = superclass => * } * return defaultCondition(type, meta); * } + * ``` * @overridable * @param {string} type could be 'error', 'warning', 'info', 'success' or any other custom * Validator type @@ -690,6 +755,7 @@ export const ValidateMixinImplementation = superclass => } /** + * Used to translate `.hasFeedbackFor` and `.shouldShowFeedbackFor` to `.showsFeedbackFor` * @param {string} type * @protected */ @@ -767,13 +833,12 @@ export const ValidateMixinImplementation = superclass => } /** - * @overridable - * @desc Orders all active validators in this.__validationResult. Can + * Orders all active validators in this.__validationResult. Can * also filter out occurrences (based on interaction states) + * @overridable * @param {{ validationResult: Validator[] }} opts - * @return {Validator[]} ordered list of Validators with feedback messages visible to the + * @return {Validator[]} ordered list of Validators with feedback messages visible to the end user * @protected - * end user */ _prioritizeAndFilterFeedback({ validationResult }) { const ctor = /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (this diff --git a/packages/form-core/test-suites/InteractionStateMixin.suite.js b/packages/form-core/test-suites/InteractionStateMixin.suite.js index aca9e8df3..10bcc4fd4 100644 --- a/packages/form-core/test-suites/InteractionStateMixin.suite.js +++ b/packages/form-core/test-suites/InteractionStateMixin.suite.js @@ -216,9 +216,11 @@ export function runInteractionStateMixinSuite(customConfig) { const el = /** @type {IState} */ (await fixture(html` <${tag}> `)); + // @ts-ignore [allow-private] in test expect(el.shouldShowFeedbackFor).to.deep.equal([]); el.submitted = true; await el.updateComplete; + // @ts-ignore [allow-private] in test expect(el.shouldShowFeedbackFor).to.deep.equal(['error']); }); diff --git a/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js b/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js index 9e9929633..b5e09e4cc 100644 --- a/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js +++ b/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js @@ -570,7 +570,7 @@ export function runFormGroupMixinSuite(cfg = {}) { const button = /** @type {HTMLButtonElement} */ (await fixture(``)); expect(el.touched).to.equal(false, 'initially, touched state is false'); - el.children[2].focus(); + el.formElements[1].focus(); expect(el.touched).to.equal(false, 'focus is on second checkbox'); button.focus(); expect(el.touched).to.equal( @@ -602,8 +602,8 @@ export function runFormGroupMixinSuite(cfg = {}) { outside.click(); expect(el.touched, 'unfocused fieldset should stay untouched').to.be.false; - el.children[1].focus(); - el.children[2].focus(); + el.formElements[0].focus(); + el.formElements[1].focus(); expect(el.touched).to.be.false; outside.click(); // blur the group via a click diff --git a/packages/form-core/types/FocusMixinTypes.d.ts b/packages/form-core/types/FocusMixinTypes.d.ts index b3ff9d346..668cf4a45 100644 --- a/packages/form-core/types/FocusMixinTypes.d.ts +++ b/packages/form-core/types/FocusMixinTypes.d.ts @@ -7,16 +7,19 @@ export declare class FocusHost { * Reflects to attribute '[focused]' as a styling hook */ focused: boolean; + /** * Whether the focusable element within (`._focusableNode`) matches ':focus-visible' * Reflects to attribute '[focused-visible]' as a styling hook * @see https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible */ focusedVisible: boolean; + /** * Calls `focus()` on focusable element within */ focus(): void; + /** * Calls `blur()` on focusable element within */ diff --git a/packages/form-core/types/FormControlMixinTypes.d.ts b/packages/form-core/types/FormControlMixinTypes.d.ts index 93b005cbe..156bb9f1a 100644 --- a/packages/form-core/types/FormControlMixinTypes.d.ts +++ b/packages/form-core/types/FormControlMixinTypes.d.ts @@ -15,6 +15,7 @@ export type ModelValueEventDetails = { * itself to the beginning of formPath) */ formPath: HTMLElement[]; + /** * Sometimes it can be helpful to detect whether a value change was caused by a user or * via a programmatical change. @@ -24,6 +25,7 @@ export type ModelValueEventDetails = { * like 'input'/'change'/'user-input-changed' etc.) */ isTriggeredByUser: boolean; + /** * Whether it is the first event sent on initialization of the form (other * model-value-changed events are triggered imperatively or via user input (in the latter @@ -59,19 +61,23 @@ export declare class FormControlHost { _repropagationRole: { attribute: boolean }; _isRepropagationEndpoint: { attribute: boolean }; }; + /** - * A Boolean attribute which, if present, indicates that the user should not be able to edit + * A boolean attribute which, if present, indicates that the user should not be able to edit * the value of the input. The difference between disabled and readonly is that read-only * controls can still function, whereas disabled controls generally do not function as * controls until they are enabled. - * (From: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-readonly) + * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-readonly */ readOnly: boolean; + /** * The name the element will be registered with to the .formElements collection - * of the parent. + * of the parent. Also, it serves as the key of key/value pairs in + * modelValue/serializedValue objects */ name: string; + /** * The model value is the result of the parser function(when available). * It should be considered as the internal value used for validation and reasoning/logic. @@ -86,12 +92,14 @@ export declare class FormControlHost { */ get modelValue(): any | Unparseable; set modelValue(value: any | Unparseable); + /** * The label text for the input node. * When no light dom defined via [slot=label], this value will be used */ get label(): string; set label(arg: string); + /** * The helpt text for the input node. * When no light dom defined via [slot=help-text], this value will be used @@ -99,9 +107,15 @@ export declare class FormControlHost { get helpText(): string; set helpText(arg: string); + /** + * Will be used in validation messages to refer to the current field + */ set fieldName(arg: string); get fieldName(): string; + /** + * Allows to add extra element references to aria-labelledby attribute. + */ addToAriaLabelledBy( element: HTMLElement, customConfig?: { @@ -109,6 +123,10 @@ export declare class FormControlHost { reorder?: boolean | undefined; }, ): void; + + /** + * Allows to add extra element references to aria-describedby attribute. + */ addToAriaDescribedBy( element: HTMLElement, customConfig?: { @@ -116,31 +134,71 @@ export declare class FormControlHost { reorder?: boolean | undefined; }, ): void; + + /** + * Allows to remove element references from aria-labelledby attribute. + */ removeFromAriaLabelledBy( element: HTMLElement, customConfig?: { reorder?: boolean | undefined; }, ): void; + + /** + * Allows to remove element references from aria-describedby attribute. + */ removeFromAriaDescribedBy( element: HTMLElement, customConfig?: { reorder?: boolean | undefined; }, ): void; + updated(changedProperties: import('@lion/core').PropertyValues): void; + /** + * The interactive (form) element. Can be a native element like input/textarea/select or + * an element with tabindex > -1 + */ protected get _inputNode(): HTMLElementWithValue | HTMLInputElement | HTMLTextAreaElement; + + /** + * Element where label will be rendered to + */ protected get _labelNode(): HTMLElement; + + /** + * Element where help text will be rendered to + */ protected get _helpTextNode(): HTMLElement; + + /** + * Element where validation feedback will be rendered to + */ protected get _feedbackNode(): LionValidationFeedback; + + /** + * Unique id that can be used in all light dom + */ protected _inputId: string; + + /** + * Contains all elements that should end up in aria-labelledby of `._inputNode` + * @type {HTMLElement[]} + */ protected _ariaLabelledNodes: HTMLElement[]; + + /** + * Contains all elements that should end up in aria-describedby of `._inputNode` + */ protected _ariaDescribedNodes: HTMLElement[]; + /** * Based on the role, details of handling model-value-changed repropagation differ. */ protected _repropagationRole: 'child' | 'choice-group' | 'fieldset'; + /** * By default, a field with _repropagationRole 'choice-group' will act as an * 'endpoint'. This means it will be considered as an individual field: for diff --git a/packages/form-core/types/FormatMixinTypes.d.ts b/packages/form-core/types/FormatMixinTypes.d.ts index 47500ff12..848080467 100644 --- a/packages/form-core/types/FormatMixinTypes.d.ts +++ b/packages/form-core/types/FormatMixinTypes.d.ts @@ -5,36 +5,167 @@ import { ValidateHost } from './validate/ValidateMixinTypes'; import { FormControlHost } from './FormControlMixinTypes'; export declare class FormatHost { + /** + * Converts viewValue to modelValue + * For instance, a localized date to a Date Object + * @param {string} v - viewValue: the formatted value inside + * @param {FormatOptions} opts + * @returns {*} modelValue + */ parser(v: string, opts: FormatNumberOptions): unknown; + + /** + * Converts modelValue to formattedValue (formattedValue will be synced with + * `._inputNode.value`) + * For instance, a Date object to a localized date. + * @param {*} v - modelValue: can be an Object, Number, String depending on the + * input type(date, number, email etc) + * @param {FormatOptions} opts + * @returns {string} formattedValue + */ formatter(v: unknown, opts?: FormatNumberOptions): string; + + /** + * Converts `.modelValue` to `.serializedValue` + * For instance, a Date object to an iso formatted date string + * @param {?} v - modelValue: can be an Object, Number, String depending on the + * input type(date, number, email etc) + * @returns {string} serializedValue + */ serializer(v: unknown): string; + + /** + * Converts `.serializedValue` to `.modelValue` + * For instance, an iso formatted date string to a Date object + * @param {?} v - modelValue: can be an Object, Number, String depending on the + * input type(date, number, email etc) + * @returns {?} modelValue + */ deserializer(v: string): unknown; + + /** + * Preprocesses the viewValue before it's parsed to a modelValue. Can be used to filter + * invalid input amongst others. + * @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 + */ preprocessor(v: string): string; - formattedValue: string; - serializedValue: string; + + /** + * The view value is the result of the formatter function (when available). + * The result will be stored in the native _inputNode (usually an input[type=text]). + * + * Examples: + * - For a date input, this would be '20/01/1999' (dependent on locale). + * - For a number input, this could be '1,234.56' (a String representation of modelValue + * 1234.56) + * @type {string|undefined} + * @readOnly + */ + formattedValue: string | undefined; + + /** + * The serialized version of the model value. + * This value exists for maximal compatibility with the platform API. + * The serialized value can be an interface in context where data binding is not + * supported and a serialized string needs to be set. + * + * Examples: + * - For a date input, this would be the iso format of a date, e.g. '1999-01-20'. + * - For a number input this would be the String representation of a float ('1234.56' + * instead of 1234.56) + * + * When no parser is available, the value is usually the same as the formattedValue + * (being _inputNode.value) + */ + serializedValue: string | undefined; + + /** + * Event that will trigger formatting (more precise, visual update of the view, so the + * user sees the formatted value) + * Default: 'change' + * @deprecated use _reflectBackOn() + * @protected + */ formatOn: string; + + /** + * Configuration object that will be available inside the formatter function + */ formatOptions: FormatNumberOptions; + + /** + * The view value. Will be delegated to `._inputNode.value` + */ get value(): string; set value(value: string); + /** + * Flag that will be set when user interaction takes place (for instance after an 'input' + * event). Will be added as meta info to the `model-value-changed` event. Depending on + * whether a user is interacting, formatting logic will be handled differently. + */ protected _isHandlingUserInput: boolean; + /** * Whether the user is pasting content. Allows Subclassers to do this in their subclass: * @example * ```js - * _reflectBackFormattedValueToUser() { - * return super._reflectBackFormattedValueToUser() || this._isPasting; + * _reflectBackOn() { + * return super._reflectBackOn() || this._isPasting; * } * ``` */ protected _isPasting: boolean; + + /** + * Responsible for storing all representations(modelValue, serializedValue, formattedValue + * and value) of the input value. Prevents infinite loops, so all value observers can be + * treated like they will only be called once, without indirectly calling other observers. + * (in fact, some are called twice, but the __preventRecursiveTrigger lock prevents the + * second call from having effect). + * @param {{source:'model'|'serialized'|'formatted'|null}} config + * the type of value that triggered this method. It should not be set again, so that its + * observer won't be triggered. Can be: 'model'|'formatted'|'serialized'. + */ protected _calculateValues(opts: { source: 'model' | 'serialized' | 'formatted' | null }): void; protected _onModelValueChanged(arg: { modelValue: unknown }): void; protected _dispatchModelValueChangedEvent(): void; + + /** + * Synchronization from `._inputNode.value` to `LionField` (flow [2]) + * Downwards syncing should only happen for `LionField`.value changes from 'above'. + * This triggers _onModelValueChanged and connects user input + * to the parsing/formatting/serializing loop. + */ protected _syncValueUpwards(): void; protected _reflectBackFormattedValueToUser(): void; - protected _reflectBackFormattedValueDebounced(): void; + private _reflectBackFormattedValueDebounced(): void; + + /** + * 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()` + * @overridable + * @return {boolean} + * @protected + */ protected _reflectBackOn(): boolean; + + /** + * This can be called whenever the view value should be updated. Dependent on component type + * ("input" for or "change" for