@lion/ui
Version:
A package of extendable web components
1,027 lines (933 loc) • 37.7 kB
JavaScript
/* eslint-disable class-methods-use-this, camelcase, no-param-reassign, max-classes-per-file */
import { SlotMixin, DisabledMixin } from '@lion/ui/core.js';
import { dedupeMixin } from '@open-wc/dedupe-mixin';
// TODO: make form-core independent from localize
import { getLocalizeManager } from '@lion/ui/localize-no-side-effects.js';
import { ScopedElementsMixin } from '../../../core/src/ScopedElementsMixin.js';
import { AsyncQueue } from '../utils/AsyncQueue.js';
import { pascalCase } from '../utils/pascalCase.js';
import { SyncUpdatableMixin } from '../utils/SyncUpdatableMixin.js';
import { LionValidationFeedback } from './LionValidationFeedback.js';
import { Unparseable } from './Unparseable.js';
import { FormControlMixin } from '../FormControlMixin.js';
// eslint-disable-next-line no-unused-vars
import { ResultValidator as MetaValidator } from './ResultValidator.js';
// eslint-disable-next-line no-unused-vars
import { Validator } from './Validator.js';
// TODO: [v1] make all @readOnly => @readonly and actually make sure those values cannot be set
/**
* @typedef {import('../../types/validate/ValidateMixinTypes.js').ValidateMixin} ValidateMixin
* @typedef {import('../../types/validate/ValidateMixinTypes.js').ValidationType} ValidationType
* @typedef {import('../../types/validate/ValidateMixinTypes.js').ValidateHost} ValidateHost
* @typedef {import('../../types/validate/ValidateMixinTypes.js').OperationMode} OperationMode
* @typedef {import('../../types/validate/index.js').ValidatorOutcome} ValidatorOutcome
* @typedef {typeof import('../../types/validate/ValidateMixinTypes.js').ValidateHost} ValidateHostConstructor
* @typedef {{validator:Validator; outcome:boolean|string}} ValidationResultEntry
* @typedef {{[type:string]: {[validatorName:string]:boolean|string}}} ValidationStates
*/
/**
* @param {any[]} array1
* @param {any[]} array2
*/
function arrayDiff(array1 = [], array2 = []) {
return array1.filter(x => !array2.includes(x)).concat(array2.filter(x => !array1.includes(x)));
}
/**
* When the modelValue can't be created by FormatMixin.parser, still allow all validators
* to give valuable feedback to the user based on the current viewValue.
* @param {any} modelValue
*/
function getValueForValidators(modelValue) {
return modelValue instanceof Unparseable ? modelValue.viewValue : modelValue;
}
/**
* 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}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('lit').LitElement>} superclass
*/
export const ValidateMixinImplementation = superclass =>
// @ts-ignore https://github.com/microsoft/TypeScript/issues/36821#issuecomment-588375051
class extends FormControlMixin(
SyncUpdatableMixin(DisabledMixin(SlotMixin(ScopedElementsMixin(superclass)))),
) {
static get scopedElements() {
return {
// @ts-ignore
...super.scopedElements,
'lion-validation-feedback': LionValidationFeedback,
};
}
static get properties() {
return {
validators: { attribute: false },
hasFeedbackFor: { attribute: false },
shouldShowFeedbackFor: { attribute: false },
showsFeedbackFor: {
type: Array,
attribute: 'shows-feedback-for',
reflect: true,
converter: {
fromAttribute: /** @param {string} value */ value => value.split(','),
toAttribute: /** @param {[]} value */ value => value.join(','),
},
},
validationStates: { attribute: false },
isPending: {
type: Boolean,
attribute: 'is-pending',
reflect: true,
},
defaultValidators: { attribute: false },
_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'];
}
/**
* Types of input interaction of the FormControl (for instance 'enter'|'select'|'upload')
* @overridable
* @type {OperationMode}
*/
get operationMode() {
return 'enter';
}
/**
* Adds "._feedbackNode" as described below
* @public
*/
get slots() {
/**
* FIXME: Ugly workaround https://github.com/microsoft/TypeScript/issues/40110
* @callback getScopedTagName
* @param {string} tagName
* @returns {string}
*
* @typedef {Object} ScopedElementsObj
* @property {getScopedTagName} getScopedTagName
*/
return {
...super.slots,
feedback: () => {
const feedbackEl = this.createScopedElement('lion-validation-feedback');
feedbackEl.setAttribute('data-tag-name', 'lion-validation-feedback');
return feedbackEl;
},
};
}
/**
* Combination of validators provided by Application Developer and the default validators
* @type {(Validator | MetaValidator)[]}
* @protected
*/
get _allValidators() {
return [...this.validators, ...this.defaultValidators];
}
constructor() {
super();
/**
* 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 = [];
/**
* 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 = [];
// TODO: [v1] make this fully private (prefix __)?
/**
* 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 {ValidationStates}
*/
this.validationStates = {};
/**
* Flag indicating whether async validation is pending.
* Creates attribute [is-pending] as a styling hook
* @type {boolean}
*/
this.isPending = false;
/**
* Used by Application Developers to add Validators to a FormControl.
* @example
* ```html
* <form-control .validators="${[new Required(), new MinLength(4, {type: 'warning'})]}">
* </form-control>
* ```
* @type {Validator[]}
*/
this.validators = [];
/**
* 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
* @configurable
* @protected
*/
this._visibleMessagesAmount = 1;
/**
* @type {ValidationResultEntry[]}
* @private
*/
this.__syncValidationResult = [];
/**
* @type {ValidationResultEntry[]}
* @private
*/
this.__asyncValidationResult = [];
/**
* Aggregated result from sync Validators, async Validators and MetaValidators
* @type {ValidationResultEntry[]}
* @private
*/
this.__validationResult = [];
/**
* @type {ValidationResultEntry[]}
* @private
*/
this.__prevValidationResult = [];
/**
* The shown validation result depends on the visibility of the feedback messages
* @type {ValidationResultEntry[]}
* @private
*/
this.__prevShownValidationResult = [];
/**
* The updated children validity affects the validity of the parent. Helper to recompute
* validity of parent FormGroup
* @private
*/
this.__childModelValueChanged = false;
/** @protected */
this._onValidatorUpdated = this._onValidatorUpdated.bind(this);
/** @protected */
this._updateFeedbackComponent = this._updateFeedbackComponent.bind(this);
}
connectedCallback() {
super.connectedCallback();
const localizeManager = getLocalizeManager();
localizeManager.addEventListener('localeChanged', this._updateFeedbackComponent);
}
disconnectedCallback() {
super.disconnectedCallback();
const localizeManager = getLocalizeManager();
localizeManager.removeEventListener('localeChanged', this._updateFeedbackComponent);
}
/**
* @param {import('lit').PropertyValues} changedProperties
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__isValidateInitialized = true;
this.validate();
if (this._repropagationRole !== 'child') {
this.addEventListener('model-value-changed', () => {
this.__childModelValueChanged = true;
});
}
}
/**
* @param {string} name
* @param {?} oldValue
*/
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
if (name === 'validators') {
// trigger validation (ideally only for the new or changed validator)
this.__setupValidators();
this.validate({ clearCurrentResult: true });
} else if (name === 'modelValue') {
this.validate({ clearCurrentResult: true });
}
if (
[
'touched',
'dirty',
'prefilled',
'focused',
'submitted',
'hasFeedbackFor',
'filled',
].includes(name)
) {
this._updateShouldShowFeedbackFor();
}
if (name === 'showsFeedbackFor') {
// This can't be reflected asynchronously in Safari
// Screen reader output should be in sync with visibility of error messages
if (this._inputNode) {
this._inputNode.setAttribute('aria-invalid', `${this._hasFeedbackVisibleFor('error')}`);
// this._inputNode.setCustomValidity(this._validationMessage || '');
}
const diff = arrayDiff(this.showsFeedbackFor, oldValue);
if (diff.length > 0) {
this.dispatchEvent(new Event(`showsFeedbackForChanged`, { bubbles: true }));
}
diff.forEach(type => {
this.dispatchEvent(
new Event(`showsFeedbackFor${pascalCase(type)}Changed`, { bubbles: true }),
);
});
}
if (name === 'shouldShowFeedbackFor') {
const diff = arrayDiff(this.shouldShowFeedbackFor, oldValue);
if (diff.length > 0) {
this.dispatchEvent(new Event(`shouldShowFeedbackForChanged`, { bubbles: true }));
}
}
}
/**
* Triggered by:
* - modelValue change
* - change in the 'validators' array
* - change in the config of an individual Validator
*
* Three situations are handled:
* - 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).
* - 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'.
* - 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 MetaValidators. After steps a1, a2, or a3 are finished, the holistic
* MetaValidators (evaluating the total result of the 'regular' (a1, a2 and a3) validators)
* will be run...
*
* 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
*/
async validate({ clearCurrentResult = false } = {}) {
/**
* Allows Application Developer to wait for (async) validation
* @example
* ```js
* await el.validateComplete;
* ```
* @type {Promise<boolean>}
*/
this.validateComplete = new Promise(resolve => {
this.__validateCompleteResolve = resolve;
});
if (this.disabled) {
this.__clearValidationResults();
this.__finishValidationPass();
this._updateFeedbackComponent();
return;
}
// We don't validate before firstUpdated has run
if (!this.__isValidateInitialized) {
return;
}
this.__prevValidationResult = this.__validationResult;
if (clearCurrentResult) {
// Clear ('invalidate') all pending and existing validation results.
// This is needed because we have async (pending) validators whose results
// need to be merged with those of sync validators and vice versa.
this.__clearValidationResults();
}
await this.__executeValidators();
}
/**
* @param {Validator} validator
* @returns {boolean}
*/
#isInstanceOfRequiredValidator(validator) {
// walk prototype and check if validatorName is 'Required'
let validatorLvlInPrototype = validator;
while (validatorLvlInPrototype) {
const ctor = /** @type {typeof Validator} */ (validatorLvlInPrototype.constructor);
if (ctor.validatorName === 'Required') return true;
validatorLvlInPrototype = Object.getPrototypeOf(validatorLvlInPrototype);
}
return false;
}
/**
* @desc step a1-3 + b (as explained in `validate()`)
*/
async __executeValidators() {
const value = getValueForValidators(this.modelValue);
/**
* Handle the 'mutually exclusive' Required validator:
*
* About the Required Validator:
* - the validity is dependent on the formControls' modelValue and therefore determined
* by the formControl._isEmpty method. Basically, the Required Validator is a means
* to trigger formControl._isEmpty().
*
* About the empty state:
* - when _isEmpty returns true, the modelValue is considered empty. This means we need to stop
* validation here, because all other Validators' execute functions assume the
* value is not empty (there would be nothing to validate).
*
*/
const isEmpty = this.__isEmpty(value);
this.__syncValidationResult = [];
/**
* So we can have the following scenarios:
* - we're empty
* - we have a single value (we are an 'endpoint') => we want to halt execution, because validation only makes sense when we have a value
* - 1. we have Required => we fill .__syncValidationResult and finish validation, because we have a value to validate
* - 2. we don't have Required => we stop validation
* - we have a group of values (object/array) => we want to continue execution, because the constellation of the object (even though the individual endpoints are empty) might be interesting for validation.
* - 3. we have Required => we fill .__syncValidationResult and continue validation (so we can add more to .__syncValidationResult)
* - 4. we don't have Required => we continue validation
* - we're not empty
* - we have a single value or group of values
* - 5. we may have Required, but if we have it will not be 'active' => we continue execution, because we have a value to validate
*/
if (isEmpty) {
const hasSingleValue = !(/** @type {* & ValidateHost} */ (this)._isFormOrFieldset);
const requiredValidator = this._allValidators.find(
v => /** @type {typeof Validator} */ (v.constructor)?.validatorName === 'Required',
);
if (requiredValidator) {
this.__syncValidationResult = [{ validator: requiredValidator, outcome: true }];
}
if (hasSingleValue) {
this.__finishValidationPass({
syncValidationResult: this.__syncValidationResult,
});
return;
}
}
const metaValidators = /** @type {MetaValidator[]} */ [];
const syncValidators = /** @type {Validator[]} */ [];
const asyncValidators = /** @type {Validator[]} */ [];
for (const v of this._allValidators) {
if (/** @type {MetaValidator} */ (v)?.executeOnResults) {
metaValidators.push(/** @type {MetaValidator} */ (v));
} else if (this.#isInstanceOfRequiredValidator(v)) {
// Required validator was already handled
} else if (/** @type {typeof Validator} */ (v.constructor).async) {
asyncValidators.push(v);
} else {
syncValidators.push(v);
}
}
const hasAsync = Boolean(asyncValidators.length);
/**
* 2. Synchronous validators
*/
this.__syncValidationResult = [
// This could eventually contain the Required Validator
...this.__syncValidationResult,
...this.__executeSyncValidators(syncValidators, value),
];
// Finish the first (synchronous) pass
this.__finishValidationPass({
syncValidationResult: this.__syncValidationResult,
metaValidators,
});
/**
* 3. Asynchronous validators
*/
if (hasAsync) {
// Add a hint in the ui that we're waiting for async validation
this.isPending = true;
this.__asyncValidationResult = await this.__executeAsyncValidators(asyncValidators, value);
this.isPending = false;
// Now finish the second (asynchronous) pass (including both sync and async results)
this.__finishValidationPass({
syncValidationResult: this.__syncValidationResult,
asyncValidationResult: this.__asyncValidationResult,
metaValidators,
});
this.__validateCompleteResolve?.(true);
} else {
this.__validateCompleteResolve?.(true);
}
}
/**
* step a2 (as explained in `validate()`): calls `__finishValidationPass`
* @param {Validator[]} syncValidators
* @param {unknown} value
* @private
*/
__executeSyncValidators(syncValidators, value) {
return syncValidators
.map(v => ({
validator: v,
// TODO: fix this type - ts things this is not a FormControlHost?
// @ts-ignore
outcome: /** @type {boolean|string} */ (v.execute(value, v.param, { node: this })),
}))
.filter(v => Boolean(v.outcome));
}
/**
* step a3 (as explained in `validate()`), calls __finishValidationPass
* @param {Validator[]} asyncValidators all Validators except required and MetaValidators
* @param {?} value
* @private
*/
async __executeAsyncValidators(asyncValidators, value) {
const outcomePromises = asyncValidators.map(v => v.execute(value, v.param, { node: this }));
const asyncExecutionResults = await Promise.all(outcomePromises);
return asyncExecutionResults
.map((r, i) => ({
validator: asyncValidators[i],
outcome: /** @type {boolean|string} */ (asyncExecutionResults[i]),
}))
.filter(v => Boolean(v.outcome));
}
/**
* step b (as explained in `validate()`), called by __finishValidationPass
* @param {{validator: Validator;outcome: boolean | string;}[]} regularValidationResult result of steps 1-3
* @param {MetaValidator[]} metaValidators
* @private
*/
__executeMetaValidators(regularValidationResult, metaValidators) {
if (!metaValidators.length) {
return [];
}
// If empty, do not show the ResultValidation message (e.g. Correct!)
if (this._isEmpty(this.modelValue)) {
this.__prevShownValidationResult = [];
return [];
}
// Map everything to Validator[] for backwards compatibility
return metaValidators
.map(v => ({
validator: v,
outcome: /** @type {boolean|string} */ (
v.executeOnResults({
regularValidationResult: regularValidationResult.map(entry => entry.validator),
prevValidationResult: this.__prevValidationResult.map(entry => entry.validator),
prevShownValidationResult: this.__prevShownValidationResult.map(
entry => entry.validator,
),
})
),
}))
.filter(v => Boolean(v.outcome));
}
/**
* A 'pass' is a single run of the validation process, which will be triggered in these cases:
* - on clear or disable
* - on sync validation
* - on async validation (can depend on server response)
*
* This method finishes a pass by adding the properties to the instance:
* - validationStates
* - hasFeedbackFor
*
* It sends a private event validate-performed, which is received by parent Formgroups.
*
* @param {object} options
* @param {ValidationResultEntry[]} [options.syncValidationResult]
* @param {ValidationResultEntry[]} [options.asyncValidationResult]
* @param {MetaValidator[]} [options.metaValidators] MetaValidators to be executed
* @private
* If not, we have nothing left to wait for.
*/
__finishValidationPass({
syncValidationResult = [],
asyncValidationResult = [],
metaValidators = [],
} = {}) {
const syncAndAsyncOutcome = [...syncValidationResult, ...asyncValidationResult];
// if we have any MetaValidators left, now is the time to run them...
const metaOutCome = /** @type {ValidationResultEntry[]} */ (
this.__executeMetaValidators(syncAndAsyncOutcome, metaValidators)
);
this.__validationResult = [...metaOutCome, ...syncAndAsyncOutcome];
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
/** @type {ValidationStates} */
const validationStates = ctor.validationTypes.reduce(
(acc, type) => ({ ...acc, [type]: {} }),
{},
);
for (const { validator, outcome } of this.__validationResult) {
if (!validationStates[validator.type]) {
validationStates[validator.type] = {};
}
const vCtor = /** @type {typeof Validator} */ (validator.constructor);
validationStates[validator.type][vCtor.validatorName] = outcome;
}
this.validationStates = validationStates;
this.hasFeedbackFor = [
...new Set(this.__validationResult.map(({ validator }) => validator.type)),
];
/** private event that should be listened to by LionFieldSet */
this.dispatchEvent(new Event('validate-performed', { bubbles: true }));
}
/**
* @private
*/
__clearValidationResults() {
this.__syncValidationResult = [];
this.__asyncValidationResult = [];
}
/**
* @param {Event|CustomEvent} e
* @protected
*/
_onValidatorUpdated(e) {
if (e.type === 'param-changed' || e.type === 'config-changed') {
this.validate();
}
}
/**
* @private
*/
__setupValidators() {
const events = ['param-changed', 'config-changed'];
for (const validatorToCleanup of this.__prevValidators || []) {
for (const eventToCleanup of events) {
validatorToCleanup.removeEventListener?.(eventToCleanup, this._onValidatorUpdated);
}
validatorToCleanup.onFormControlDisconnect(
/** @type {import('../../types/FormControlMixinTypes.js').FormControlHost} */ (
/** @type {unknown} */ (this)
),
);
}
for (const validatorToSetup of this._allValidators) {
// disable dot notation to avoid the renaming for the prop during build/minification
const validatorCtor = /** @type {typeof Validator} */ (validatorToSetup.constructor);
// eslint-disable-next-line dot-notation
if (validatorCtor['_$isValidator$'] === undefined) {
// throws in constructor are not visible to end user so we do both
const errorType = Array.isArray(validatorToSetup) ? 'array' : typeof validatorToSetup;
const errorMessage = `Validators array only accepts class instances of Validator. Type "${errorType}" found. This may be caused by having multiple installations of "@lion/ui/form-core.js".`;
// eslint-disable-next-line no-console
console.error(errorMessage, this);
throw new Error(errorMessage);
}
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
const vCtor = /** @type {typeof Validator} */ (validatorToSetup.constructor);
if (ctor.validationTypes.indexOf(validatorToSetup.type) === -1) {
// throws in constructor are not visible to end user so we do both
const errorMessage = `This component does not support the validator type "${validatorToSetup.type}" used in "${vCtor.validatorName}". You may change your validators type or add it to the components "static get validationTypes() {}".`;
// eslint-disable-next-line no-console
console.error(errorMessage, this);
throw new Error(errorMessage);
}
/**
* Updated the code to fix issue #1607 to sync the calendar date with validators params
* Here _onValidatorUpdated is responsible for responding to the event
*/
for (const eventToSetup of events) {
validatorToSetup.addEventListener?.(eventToSetup, e => {
// @ts-ignore for making validator param dynamic
this._onValidatorUpdated(e, { validator: validatorToSetup });
});
}
validatorToSetup.onFormControlConnect(this);
}
this.__prevValidators = this._allValidators;
}
/**
* Helper method for the mutually exclusive Required Validator
* @param {?} v
* @private
*/
__isEmpty(v) {
if (typeof this._isEmpty === 'function') {
return this._isEmpty(v);
}
return (
this.modelValue === null || typeof this.modelValue === 'undefined' || this.modelValue === ''
);
}
// ------------------------------------------------------------------------------------------
// -- Feedback specifics --------------------------------------------------------------------
// ------------------------------------------------------------------------------------------
/**
* @typedef {object} FeedbackMessage
* @property {string | Node} message this
* @property {string} type will be 'error' for messages from default Validators. Could be
* 'warning', 'info' etc. for Validators with custom types. Needed as a directive for
* feedbackNode how to render a message of a certain type
* @property {number} visibilityDuration duration in ms for how long the message should be shown
* @property {Validator} [validator] when the message is directly coupled to a Validator
* (in most cases), this property is filled. When a message is not coupled to a Validator
* (in case of success feedback which is based on a diff or current and previous validation
* results), this property can be left empty.
*/
/**
* @param {ValidationResultEntry[]} validationResults list of objects having a .getMessage method
* @return {Promise.<FeedbackMessage[]>}
* @private
*/
async __getFeedbackMessages(validationResults) {
let fieldName = await this.fieldName;
return Promise.all(
validationResults.map(async ({ validator, outcome }) => {
if (validator.config.fieldName) {
fieldName = await validator.config.fieldName;
}
// @ts-ignore [allow-protected]
const message = await validator._getMessage({
modelValue: this.modelValue,
formControl: this,
fieldName,
outcome,
});
return {
message,
type: validator.type,
validator,
visibilityDuration: validator.config?.visibilityDuration || 3000,
};
}),
);
}
/**
* Responsible for retrieving messages from Validators and
* (delegation of) rendering them.
*
* For `._feedbackNode` (extension of LionValidationFeedback):
* - retrieve messages from highest prio Validators
* - provide the result to custom feedback node and let the
* custom node decide on their renderings
*
* In both cases:
* - we compute the 'show' flag (like 'hasErrorVisible') for all types
* - we set the customValidity message of the highest prio Validator
* - we set aria-invalid="true" in case hasErrorVisible is true
* @protected
*/
_updateFeedbackComponent() {
window.clearTimeout(this.removeMessage);
const { _feedbackNode } = this;
if (!_feedbackNode) {
return;
}
if (!this.__feedbackQueue) {
this.__feedbackQueue = new AsyncQueue();
}
if (this.showsFeedbackFor.length > 0) {
this.__feedbackQueue.add(async () => {
/** @type {Validator[]} */
const prioritizedValidators = this._prioritizeAndFilterFeedback({
validationResult: this.__validationResult.map(entry => entry.validator),
});
this.__prioritizedResult = prioritizedValidators
.map(v => {
const found = /** @type {ValidationResultEntry} */ (
this.__validationResult.find(r => v === r.validator)
);
return found;
})
.filter(Boolean);
if (this.__prioritizedResult.length > 0) {
this.__prevShownValidationResult = this.__prioritizedResult;
}
const messageMap = await this.__getFeedbackMessages(this.__prioritizedResult);
_feedbackNode.feedbackData = messageMap || [];
if (
messageMap?.[0] &&
messageMap[0].type === 'success' &&
messageMap[0].visibilityDuration !== Infinity
) {
this.removeMessage = window.setTimeout(() => {
_feedbackNode.removeAttribute('type');
/** @type {FeedbackMessage[]} */
_feedbackNode.feedbackData = [];
}, messageMap[0].visibilityDuration);
}
});
} else {
this.__feedbackQueue.add(async () => {
_feedbackNode.feedbackData = [];
});
}
this.feedbackComplete = this.__feedbackQueue.complete;
}
/**
* 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
* @param {object} meta meta info (interaction states etc)
* @protected
*/
// eslint-disable-next-line no-unused-vars
_showFeedbackConditionFor(type, meta) {
return true;
}
/**
* Allows Subclassers to add meta info for feedbackCondition
* @configurable
*/
get _feedbackConditionMeta() {
return { modelValue: this.modelValue, el: this };
}
/**
* Allows the Application Developer to specify when a feedback message should be shown
* @example
* ```js
* feedbackCondition(type, meta, defaultCondition) {
* if (type === 'info') {
* return true;
* } else if (type === 'prefilledOnly') {
* return meta.prefilled;
* }
* return defaultCondition(type, meta);
* }
* ```
* @overridable
* @param {string} type could be 'error', 'warning', 'info', 'success' or any other custom
* Validator type
* @param {object} meta meta info (interaction states etc)
* @param {((type: string, meta: object) => boolean)} currentCondition this is the _showFeedbackConditionFor
* that can be used if a developer wants to override for a certain type, but wants to fallback
* for other types
* @returns {boolean}
*/
feedbackCondition(
type,
meta = this._feedbackConditionMeta,
currentCondition = this._showFeedbackConditionFor.bind(this),
) {
return currentCondition(type, meta);
}
/**
* Used to translate `.hasFeedbackFor` and `.shouldShowFeedbackFor` to `.showsFeedbackFor`
* @param {string} type
* @protected
*/
_hasFeedbackVisibleFor(type) {
return this.hasFeedbackFor?.includes(type) && this.shouldShowFeedbackFor?.includes(type);
}
/**
* @param {import('lit').PropertyValues} changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (
changedProperties.has('shouldShowFeedbackFor') ||
changedProperties.has('hasFeedbackFor')
) {
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
// Necessary typecast because types aren't smart enough to understand that we filter out undefined
this.showsFeedbackFor = /** @type {string[]} */ (
ctor.validationTypes
.map(type => (this._hasFeedbackVisibleFor(type) ? type : undefined))
.filter(Boolean)
);
this._updateFeedbackComponent();
}
if (changedProperties.has('__childModelValueChanged') && this.__childModelValueChanged) {
this.validate({ clearCurrentResult: true });
this.__childModelValueChanged = false;
}
if (changedProperties.has('validationStates')) {
const prevStates = /** @type {{[key: string]: object;}} */ (
changedProperties.get('validationStates')
);
if (prevStates) {
Object.entries(this.validationStates).forEach(([type, feedbackObj]) => {
if (
prevStates[type] &&
JSON.stringify(feedbackObj) !== JSON.stringify(prevStates[type])
) {
this.dispatchEvent(new CustomEvent(`${type}StateChanged`, { detail: feedbackObj }));
}
});
}
}
}
/**
* @protected
*/
_updateShouldShowFeedbackFor() {
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
// Necessary typecast because types aren't smart enough to understand that we filter out undefined
const newShouldShowFeedbackFor = /** @type {string[]} */ (
ctor.validationTypes
.map(type =>
this.feedbackCondition(
type,
this._feedbackConditionMeta,
this._showFeedbackConditionFor.bind(this),
)
? type
: undefined,
)
.filter(Boolean)
);
if (JSON.stringify(this.shouldShowFeedbackFor) !== JSON.stringify(newShouldShowFeedbackFor)) {
this.shouldShowFeedbackFor = newShouldShowFeedbackFor;
}
}
/**
* 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 end user
* @protected
*/
_prioritizeAndFilterFeedback({ validationResult }) {
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
const types = ctor.validationTypes;
// Sort all validators based on the type provided.
const res = validationResult
.filter(v =>
this.feedbackCondition(
v.type,
this._feedbackConditionMeta,
this._showFeedbackConditionFor.bind(this),
),
)
.sort((a, b) => types.indexOf(a.type) - types.indexOf(b.type));
return res.slice(0, this._visibleMessagesAmount);
}
};
export const ValidateMixin = dedupeMixin(ValidateMixinImplementation);