From 713429ac80f75139853f3dcd7da4a5836c285831 Mon Sep 17 00:00:00 2001
From: Robin Van Roy <46923671+thatdudemanguy@users.noreply.github.com>
Date: Thu, 7 Aug 2025 17:20:34 +0200
Subject: [PATCH] feat: adds display options configurability to the success
validation
---
.changeset/serious-toys-hug.md | 5 +
docs/fundamentals/systems/form/validate.md | 18 ++-
.../src/validate/LionValidationFeedback.js | 18 ++-
.../form-core/src/validate/ValidateMixin.js | 26 +++-
.../ValidateMixinFeedbackPart.suite.js | 119 ++++++++++++++++++
.../validate/lion-validation-feedback.test.js | 22 ----
.../form-core/types/validate/validate.ts | 1 +
7 files changed, 173 insertions(+), 36 deletions(-)
create mode 100644 .changeset/serious-toys-hug.md
diff --git a/.changeset/serious-toys-hug.md b/.changeset/serious-toys-hug.md
new file mode 100644
index 000000000..72577322e
--- /dev/null
+++ b/.changeset/serious-toys-hug.md
@@ -0,0 +1,5 @@
+---
+'@lion/ui': minor
+---
+
+adds configuration options to the success message validation
diff --git a/docs/fundamentals/systems/form/validate.md b/docs/fundamentals/systems/form/validate.md
index fdcc0a313..1b617d999 100644
--- a/docs/fundamentals/systems/form/validate.md
+++ b/docs/fundamentals/systems/form/validate.md
@@ -684,10 +684,26 @@ export const validationTypes = () => {
};
```
+### Success
+
Success validators work a bit differently. Their success state is defined by the lack of a previously existing erroneous state (which can be an error or warning state).
So, an error validator going from invalid (true) state to invalid(false) state, will trigger the success validator.
+By default the success message will disappear, both the duration and the removal can be configured by passing `displayOptions` to the validator configuration:
+
+The `duration` property overwrites the default 3000ms timer.
+
+```js
+new DefaultSuccess({}, { visibilityDuration: 4000 });
+```
+
+`Infinity` can be added to the `duration` property to allow the success message to stay on screen, like the behaviour of the other validation types' messages.
+
+```js
+new DefaultSuccess({}, visibilityDuration: Infinity });
+```
+
## Asynchronous validation
By default, all validations are run synchronously. However, for instance when validation can only take place on server level, asynchronous validation will be needed.
@@ -873,7 +889,7 @@ export const backendValidation = () => {
## Multiple field validation
-When validation is dependent on muliple fields, two approaches can be considered:
+When validation is dependent on multiple fields, two approaches can be considered:
- Fieldset validation
- Validators with knowledge about context
diff --git a/packages/ui/components/form-core/src/validate/LionValidationFeedback.js b/packages/ui/components/form-core/src/validate/LionValidationFeedback.js
index f58bdeecd..743f79bbc 100644
--- a/packages/ui/components/form-core/src/validate/LionValidationFeedback.js
+++ b/packages/ui/components/form-core/src/validate/LionValidationFeedback.js
@@ -48,6 +48,14 @@ export class LionValidationFeedback extends LocalizeMixin(LitElement) {
];
}
+ constructor() {
+ super();
+ /**
+ * @type {FeedbackMessage[] | undefined}
+ */
+ this.feedbackData = undefined;
+ }
+
/**
* @overridable
* @param {Object} opts
@@ -69,16 +77,6 @@ export class LionValidationFeedback extends LocalizeMixin(LitElement) {
if (this.feedbackData && this.feedbackData[0]) {
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');
- /** @type {FeedbackMessage[]} */
- this.feedbackData = [];
- }, 3000);
- }
} else if (this.currentType !== 'success') {
this.removeAttribute('type');
}
diff --git a/packages/ui/components/form-core/src/validate/ValidateMixin.js b/packages/ui/components/form-core/src/validate/ValidateMixin.js
index ae5ed857f..37095e497 100644
--- a/packages/ui/components/form-core/src/validate/ValidateMixin.js
+++ b/packages/ui/components/form-core/src/validate/ValidateMixin.js
@@ -590,7 +590,7 @@ export const ValidateMixinImplementation = superclass =>
return [];
}
- // If empty, do not show the ResulValidation message (e.g. Correct!)
+ // If empty, do not show the ResultValidation message (e.g. Correct!)
if (this._isEmpty(this.modelValue)) {
this.__prevShownValidationResult = [];
return [];
@@ -619,7 +619,7 @@ export const ValidateMixinImplementation = superclass =>
* - on sync validation
* - on async validation (can depend on server response)
*
- * This method inishes a pass by adding the properties to the instance:
+ * This method finishes a pass by adding the properties to the instance:
* - validationStates
* - hasFeedbackFor
*
@@ -763,6 +763,7 @@ export const ValidateMixinImplementation = superclass =>
* @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
@@ -788,7 +789,12 @@ export const ValidateMixinImplementation = superclass =>
fieldName,
outcome,
});
- return { message, type: validator.type, validator };
+ return {
+ message,
+ type: validator.type,
+ validator,
+ visibilityDuration: validator.config?.visibilityDuration || 3000,
+ };
}),
);
}
@@ -809,6 +815,8 @@ export const ValidateMixinImplementation = superclass =>
* @protected
*/
_updateFeedbackComponent() {
+ window.clearTimeout(this.removeMessage);
+
const { _feedbackNode } = this;
if (!_feedbackNode) {
return;
@@ -840,6 +848,18 @@ export const ValidateMixinImplementation = superclass =>
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 () => {
diff --git a/packages/ui/components/form-core/test-suites/ValidateMixinFeedbackPart.suite.js b/packages/ui/components/form-core/test-suites/ValidateMixinFeedbackPart.suite.js
index 48c51537b..2884bc1ba 100644
--- a/packages/ui/components/form-core/test-suites/ValidateMixinFeedbackPart.suite.js
+++ b/packages/ui/components/form-core/test-suites/ValidateMixinFeedbackPart.suite.js
@@ -349,6 +349,125 @@ export function runValidateMixinFeedbackPart() {
expect(_feedbackNode.feedbackData?.[0].message).to.equal('Nachricht für MinLength');
});
+ it('shows success message and clears after 3s', async () => {
+ class ValidateElementCustomTypes extends ValidateMixin(LitElement) {
+ static get validationTypes() {
+ return ['error', 'success'];
+ }
+ }
+ const clock = sinon.useFakeTimers();
+ const elTagString = defineCE(ValidateElementCustomTypes);
+ const elTag = unsafeStatic(elTagString);
+ const el = /** @type {ValidateElementCustomTypes} */ (
+ await fixture(html`
+ <${elTag}
+ .submitted=${true}
+ .validators=${[
+ new MinLength(3),
+ new DefaultSuccess(null, { getMessage: () => 'This is a success message' }),
+ ]}
+ >${lightDom}${elTag}>
+ `)
+ );
+ const { _feedbackNode } = getFormControlMembers(el);
+
+ el.modelValue = 'a';
+ await el.updateComplete;
+ await el.feedbackComplete;
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('Message for MinLength');
+
+ el.modelValue = 'abcd';
+ await el.updateComplete;
+ await el.feedbackComplete;
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message');
+ clock.tick(2900);
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message');
+ clock.tick(200);
+ expect(_feedbackNode.feedbackData).to.be.empty;
+ });
+
+ it('shows success message and clears after configured time', async () => {
+ class ValidateElementCustomTypes extends ValidateMixin(LitElement) {
+ static get validationTypes() {
+ return ['error', 'success'];
+ }
+ }
+ const clock = sinon.useFakeTimers();
+ const elTagString = defineCE(ValidateElementCustomTypes);
+ const elTag = unsafeStatic(elTagString);
+ const el = /** @type {ValidateElementCustomTypes} */ (
+ await fixture(html`
+ <${elTag}
+ .submitted=${true}
+ .validators=${[
+ new MinLength(3),
+ new DefaultSuccess(null, {
+ getMessage: () => 'This is a success message',
+ visibilityDuration: 6000,
+ }),
+ ]}
+ >${lightDom}${elTag}>
+ `)
+ );
+ const { _feedbackNode } = getFormControlMembers(el);
+
+ el.modelValue = 'a';
+ await el.updateComplete;
+ await el.feedbackComplete;
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('Message for MinLength');
+
+ el.modelValue = 'abcd';
+ await el.updateComplete;
+ await el.feedbackComplete;
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message');
+ clock.tick(5900);
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message');
+ clock.tick(200);
+ expect(_feedbackNode.feedbackData).to.be.empty;
+ });
+
+ it('shows success message and stays persistent', async () => {
+ class ValidateElementCustomTypes extends ValidateMixin(LitElement) {
+ static get validationTypes() {
+ return ['error', 'success'];
+ }
+ }
+ const clock = sinon.useFakeTimers();
+ const elTagString = defineCE(ValidateElementCustomTypes);
+ const elTag = unsafeStatic(elTagString);
+ const el = /** @type {ValidateElementCustomTypes} */ (
+ await fixture(html`
+ <${elTag}
+ .submitted=${true}
+ .validators=${[
+ new MinLength(3),
+ new DefaultSuccess(null, {
+ getMessage: () => 'This is a success message',
+ visibilityDuration: Infinity,
+ }),
+ ]}
+ >${lightDom}${elTag}>
+ `)
+ );
+ const { _feedbackNode } = getFormControlMembers(el);
+
+ el.modelValue = 'a';
+ await el.updateComplete;
+ await el.feedbackComplete;
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('Message for MinLength');
+
+ el.modelValue = 'abcd';
+ await el.updateComplete;
+ await el.feedbackComplete;
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message');
+ clock.tick(2900);
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message');
+ clock.tick(200);
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message');
+ clock.tick(20000);
+ expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message');
+ });
+
it('shows success message after fixing an error', async () => {
class ValidateElementCustomTypes extends ValidateMixin(LitElement) {
static get validationTypes() {
diff --git a/packages/ui/components/form-core/test/validate/lion-validation-feedback.test.js b/packages/ui/components/form-core/test/validate/lion-validation-feedback.test.js
index f03316078..464cba4e3 100644
--- a/packages/ui/components/form-core/test/validate/lion-validation-feedback.test.js
+++ b/packages/ui/components/form-core/test/validate/lion-validation-feedback.test.js
@@ -33,28 +33,6 @@ describe('lion-validation-feedback', () => {
expect(el.getAttribute('type')).to.equal('warning');
});
- it('success message clears after 3s', async () => {
- const el = /** @type {LionValidationFeedback} */ (
- await fixture(html``)
- );
-
- const clock = sinon.useFakeTimers();
-
- el.feedbackData = [{ message: 'hello', type: 'success', validator: new AlwaysValid() }];
- await el.updateComplete;
- expect(el.getAttribute('type')).to.equal('success');
-
- clock.tick(2900);
-
- expect(el.getAttribute('type')).to.equal('success');
-
- clock.tick(200);
-
- expect(el).to.not.have.attribute('type');
-
- clock.restore();
- });
-
it('does not clear error messages', async () => {
const el = /** @type {LionValidationFeedback} */ (
await fixture(html``)
diff --git a/packages/ui/components/form-core/types/validate/validate.ts b/packages/ui/components/form-core/types/validate/validate.ts
index 663536dcf..f125e9d53 100644
--- a/packages/ui/components/form-core/types/validate/validate.ts
+++ b/packages/ui/components/form-core/types/validate/validate.ts
@@ -31,6 +31,7 @@ export type ValidatorConfig = {
type?: ValidationType;
node?: FormControlHost;
fieldName?: string | Promise;
+ visibilityDuration?: number;
};
/**