Skip to content

Commit 741dd2a

Browse files
Merge pull request #174 from umbraco/feature/formControlMixin-adapt-native-validation
ability to map validaty state from native input to a custom form control
2 parents 760d8bb + 920d216 commit 741dd2a

File tree

4 files changed

+164
-13
lines changed

4 files changed

+164
-13
lines changed

packages/uui-base/lib/mixins/FormControlMixin.ts

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ import { UUIFormControlEvent } from '../events';
55

66
type Constructor<T = {}> = new (...args: any[]) => T;
77

8+
type NativeFormControlElement = HTMLInputElement; // Eventually use a specific interface or list multiple options like appending these types: ... | HTMLTextAreaElement | HTMLSelectElement
9+
810
// TODO: make it possible to define FormDataEntryValue type.
911
export declare abstract class FormControlMixinInterface extends LitElement {
1012
formAssociated: boolean;
1113
get value(): FormDataEntryValue;
1214
set value(newValue: FormDataEntryValue);
1315
name: string;
1416
formResetCallback(): void;
15-
checkValidity: () => boolean;
17+
checkValidity(): boolean;
1618
get validationMessage(): string;
19+
public setCustomValidity(error: string): void;
1720
protected _value: FormDataEntryValue;
1821
protected _internals: any;
1922
protected abstract getFormElement(): HTMLElement | undefined;
@@ -22,6 +25,7 @@ export declare abstract class FormControlMixinInterface extends LitElement {
2225
getMessageMethod: () => String,
2326
checkMethod: () => boolean
2427
) => void;
28+
protected addFormControlElement(element: NativeFormControlElement): void;
2529
pristine: boolean;
2630
required: boolean;
2731
requiredMessage: string;
@@ -46,9 +50,10 @@ type FlagTypes =
4650
| 'badInput'
4751
| 'valid';
4852

53+
// Acceptable as an internal interface/type, BUT if exposed externally this should be turned into a public class in a separate file.
4954
interface Validator {
5055
flagKey: FlagTypes;
51-
getMessage: () => String;
56+
getMessageMethod: () => String;
5257
checkMethod: () => boolean;
5358
}
5459

@@ -147,6 +152,7 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
147152
private _internals: any;
148153
private _form: HTMLFormElement | null = null;
149154
private _validators: Validator[] = [];
155+
private _formCtrlElements: NativeFormControlElement[] = [];
150156

151157
constructor(...args: any[]) {
152158
super(...args);
@@ -208,32 +214,89 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
208214
* );
209215
* @method hasValue
210216
* @param {FlagTypes} flagKey the type of validation.
211-
* @param {method} getMessage method to retrieve relevant message. Is executed every time the validator is re-executed.
217+
* @param {method} getMessageMethod method to retrieve relevant message. Is executed every time the validator is re-executed.
212218
* @param {method} checkMethod method to determine if this validator should invalidate this form control. Return true if this should prevent submission.
213219
*/
214220
protected addValidator(
215221
flagKey: FlagTypes,
216222
getMessageMethod: () => String,
217223
checkMethod: () => boolean
218-
) {
219-
this._validators.push({
224+
): Validator {
225+
const obj = {
220226
flagKey: flagKey,
221-
getMessage: getMessageMethod,
227+
getMessageMethod: getMessageMethod,
222228
checkMethod: checkMethod,
223-
});
229+
};
230+
this._validators.push(obj);
231+
return obj;
232+
}
233+
234+
protected removeValidator(validator: Validator) {
235+
const index = this._validators.indexOf(validator);
236+
if (index !== -1) {
237+
this._validators.splice(index, 1);
238+
}
239+
}
240+
241+
/**
242+
* @method addFormControlElement
243+
* @description Important notice if adding a native form control then ensure that its value and thereby validity is updated when value is changed from the outside.
244+
* @param element {NativeFormControlElement} - element to validate and include as part of this form association.
245+
*/
246+
protected addFormControlElement(element: NativeFormControlElement) {
247+
this._formCtrlElements.push(element);
248+
}
249+
250+
private _customValidityObject?: Validator;
251+
252+
/**
253+
* @method setCustomValidity
254+
* @description Set custom validity state, set to empty string to remove the custom message.
255+
* @param message {string} - The message to be shown
256+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/setCustomValidity|HTMLObjectElement:setCustomValidity}
257+
*/
258+
protected setCustomValidity(message: string | null) {
259+
if (this._customValidityObject) {
260+
this.removeValidator(this._customValidityObject);
261+
}
262+
263+
if (message != null && message !== '') {
264+
this._customValidityObject = this.addValidator(
265+
'customError',
266+
(): string => message,
267+
() => true
268+
);
269+
}
270+
271+
this._runValidators();
224272
}
225273

226274
private _runValidators() {
275+
this._validityState = {};
276+
277+
// Loop through inner native form controls to adapt their validityState.
278+
this._formCtrlElements.forEach(formCtrlEl => {
279+
for (const key in formCtrlEl.validity) {
280+
if (key !== 'valid' && (formCtrlEl.validity as any)[key]) {
281+
(this as any)._validityState[key] = true;
282+
this._internals.setValidity(
283+
(this as any)._validityState,
284+
formCtrlEl.validationMessage,
285+
formCtrlEl
286+
);
287+
}
288+
}
289+
});
290+
291+
// Loop through custom validators, currently its intentional to have them overwritten native validity. but might need to be reconsidered (This current way enables to overwrite with custom messages)
227292
this._validators.forEach(validator => {
228293
if (validator.checkMethod()) {
229294
this._validityState[validator.flagKey] = true;
230295
this._internals.setValidity(
231296
this._validityState,
232-
validator.getMessage(),
297+
validator.getMessageMethod(),
233298
this.getFormElement()
234299
);
235-
} else {
236-
this._validityState[validator.flagKey] = false;
237300
}
238301
});
239302

@@ -275,6 +338,12 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
275338
}
276339

277340
public checkValidity() {
341+
for (const key in this._formCtrlElements) {
342+
if (this._formCtrlElements[key].checkValidity() === false) {
343+
return false;
344+
}
345+
}
346+
278347
return this._internals?.checkValidity();
279348
}
280349

packages/uui-form/lib/uui-form.story.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export const Overview: Story = () => {
8181
<uui-label slot="label" required>Email</uui-label>
8282
<uui-input
8383
name="email"
84-
type="text"
84+
type="email"
8585
label="Email"
8686
required></uui-input>
8787
</uui-form-layout-item>

packages/uui-input/lib/uui-input.element.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins';
22
import { defineElement } from '@umbraco-ui/uui-base/lib/registration';
3-
import { css, html, LitElement } from 'lit';
3+
import { css, html, LitElement, PropertyValueMap } from 'lit';
44
import { property, query } from 'lit/decorators.js';
55

66
import { UUIInputEvent } from './UUIInputEvent';
@@ -246,6 +246,13 @@ export class UUIInputElement extends FormControlMixin(LitElement) {
246246
);
247247
}
248248

249+
protected firstUpdated(
250+
_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
251+
): void {
252+
super.firstUpdated(_changedProperties);
253+
this.addFormControlElement(this._input);
254+
}
255+
249256
/**
250257
* This method enables <label for="..."> to focus the input
251258
*/
@@ -287,6 +294,7 @@ export class UUIInputElement extends FormControlMixin(LitElement) {
287294
placeholder=${this.placeholder}
288295
aria-label=${this.label}
289296
.disabled=${this.disabled}
297+
?required=${this.required}
290298
?readonly=${this.readonly}
291299
@input=${this._onInput}
292300
@change=${this._onChange} />

packages/uui-input/lib/uui-input.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ describe('UuiInput in Form', () => {
186186
});
187187
});
188188

189-
describe('custom error', () => {
189+
describe('custom error though attributes', () => {
190190
beforeEach(async () => {
191191
element.setAttribute('error', 'true');
192192
await elementUpdated(element);
@@ -212,5 +212,79 @@ describe('UuiInput in Form', () => {
212212
expect(formElement.checkValidity()).to.be.true;
213213
});
214214
});
215+
216+
describe('custom error through setCustomValidity', () => {
217+
it('sets element to invalid when it sets custom validity', async () => {
218+
const validationMessage = 'custom error';
219+
element.setCustomValidity(validationMessage);
220+
expect(element.checkValidity()).to.be.false;
221+
expect(element.validationMessage).to.equal(validationMessage);
222+
});
223+
224+
it('sets the form to invalid when value is empty', async () => {
225+
element.setCustomValidity('custom error');
226+
expect(formElement.checkValidity()).to.be.false;
227+
});
228+
229+
it('sets element to valid when it sets custom validity to an empty string', async () => {
230+
const validationMessage = '';
231+
element.setCustomValidity(validationMessage);
232+
expect(element.checkValidity()).to.be.true;
233+
expect(element.validationMessage).to.equal(validationMessage);
234+
});
235+
236+
it('sets the form to valid when it doesnt have custom validity', async () => {
237+
element.setCustomValidity('');
238+
expect(formElement.checkValidity()).to.be.true;
239+
});
240+
});
241+
});
242+
243+
describe('native validation', () => {
244+
describe('email', () => {
245+
beforeEach(async () => {
246+
element.setAttribute('type', 'email');
247+
await elementUpdated(element);
248+
});
249+
250+
it('sets element to valid when value is empty', async () => {
251+
expect(element.checkValidity()).to.be.true;
252+
});
253+
254+
it('email element is invalid when it has a none compliant value', async () => {
255+
element.value = 'new value';
256+
await elementUpdated(element);
257+
expect(element.checkValidity()).to.be.false;
258+
});
259+
260+
it('email element is valid when it has a email value', async () => {
261+
element.value = '[email protected]';
262+
await elementUpdated(element);
263+
expect(element.checkValidity()).to.be.true;
264+
});
265+
});
266+
267+
describe('url', () => {
268+
beforeEach(async () => {
269+
element.setAttribute('type', 'url');
270+
await elementUpdated(element);
271+
});
272+
273+
it('sets element to valid when value is empty', async () => {
274+
expect(element.checkValidity()).to.be.true;
275+
});
276+
277+
it('url element is invalid when it has a none compliant value', async () => {
278+
element.value = 'new value';
279+
await elementUpdated(element);
280+
expect(element.checkValidity()).to.be.false;
281+
});
282+
283+
it('url element is valid when it has a email value', async () => {
284+
element.value = 'http://umbraco.com';
285+
await elementUpdated(element);
286+
expect(element.checkValidity()).to.be.true;
287+
});
288+
});
215289
});
216290
});

0 commit comments

Comments
 (0)