In some cases you may find yourself wanting to create a custom form element using the FormElementMixin
that uses other existing custom form elements internally. An example of this would be a name input component that has two internal d2l-input-text
components; one for the first name and one for the last name.
Because custom form elements support validation and form submission, we need to make sure nested custom form elements are connected properly. Scenarios that need to be handled include:
- Validating the parent correctly causes its nested custom form elements to be validated
- Setting
novalidate
on the parent correctly setsnovalidate
on its nested custom form elements - The parent form element hides its validation tooltip if it causes overlap with a nested custom form element's tooltip
- The form submission value for the parent correctly includes all nested custom form elements' submission values
Properties:
Property | Type | Description |
---|---|---|
childErrors |
read-only, Map<FormElementMixin(HTMLElement), String[]> |
A map containing all nested form elements with visible validation errors and the list of errors associated with them. This can be used to hide validation errors in the parent component if they would cause conflicts with nested custom form elements. |
force-invalid |
Boolean, default: false |
Force invalid can be used on nested custom form elements to force them to be styled as invalid if their parent is invalid. |
novalidate |
Boolean, default: false |
This must be passed down to nested custom form elements to ensure they won't get validated if the parent has validation disabled. |
Methods:
async validate()
: Validate must be overridden and modified to callvalidate
for all nested custom form elements and return the aggregated list of errors. This ensures that validating the parent will validate all nested components.- Note: It is important that the aggregated errors are returned with nested validation errors ordered first and parent validation errors last.
1. Create a basic custom form element:
First we can start by defining a custom form element based on the basic FormElementMixin
documentation. This custom form element contains two internal d2l-input-text
custom form elements.
import { FormElementMixin } from '@brightspace-ui/core/form/form-element-mixin.js';
// Use the FormElementMixin
class MyNestingFormElement extends FormElementMixin(LitElement) {
static get properties() {
return {
_firstName: { type: String },
_lastName: { type: String }
};
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this._onBlur = this._onBlur.bind(this);
this.addEventListener('blur', this._onBlur);
}
render() {
// Conditionally render our validation error message if there is one
const tooltip = this.validationError
? html`<d2l-tooltip align="start" state="error">${this.validationError}</d2l-tooltip>`
: null;
return html`
<d2l-input-text label="First Name" @input=${this._onFirstNameInput} id="first-name"></d2l-input-text>
<d2l-input-text label="Last Name" @input=${this._onLastNameInput} id="last-name"></d2l-input-text>
${tooltip}
`;
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('_firstName') || changedProperties.has('_lastName')) {
// Setting the form value each time either value changes
// Note: Each key is prefixed with this.name to avoid collisions if this control is used multiple times in a single form
this.setFormValue({
[`${this.name}-firstName`]: this._firstName,
[`${this.name}-lastName`]: this._lastName,
});
// Since the user is in the middle of editing, false is passed because we only want to update the existing error message
this.requestValidate(false);
}
}
_onBlur() {
// true is passed because we only want to show new errors when the user has finished editing
this.requestValidate(true);
}
_onFirstNameInput(e) {
this._firstName = e.target.value;
}
_onLastNameInput(e) {
this._lastName = e.target.value;
}
}
customElements.define('my-nesting-form-element', MyNestingFormElement);
2. Use the text-input's form validation:
We will use d2l-input-text
's minlength
validation attribute to validate our values.
render() {
const tooltip = this.validationError
? html`<d2l-tooltip align="start" state="error">${this.validationError}</d2l-tooltip>`
: null;
// Add minlength of 4 to each
return html`
<d2l-input-text minlength="4" label="First Name" @input=${this._onFirstNameInput} id="first-name"></d2l-input-text>
<d2l-input-text minlength="4" label="Last Name" @input=${this._onLastNameInput} id="last-name"></d2l-input-text>
${tooltip}
`;
}
3. Add validation logic to our parent custom form element:
We will add a requirement that both the first and last name must start with the same letter using setValidity()
.
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('_firstName') || changedProperties.has('_lastName')) {
this.setFormValue({
[`${this.name}-firstName`]: this._firstName,
[`${this.name}-lastName`]: this._lastName,
});
// Set the badInput flag if they don't start with the same character.
this.setValidity({
badInput: this._firstName && this._lastName && this._firstName[0] !== this._lastName[0]
});
this.requestValidate(false);
}
}
4. Add a custom validation message for our bad input error:
To ensure that users have the ability to fix our custom validation error, we will add an accompanying custom validation message.
get validationMessage() {
if (this.validity.badInput) {
return 'First name and last name must start with the same letter.';
}
// Don't forget to call super.validationMessage to provide a default error message.
return super.validationMessage;
}
5. Prevent tooltip overlap:
Now that we have the validation logic for the parent and nested custom form elements, we need to make sure their validation tooltips don't overlap. To do this we will hide the tooltip using the childErrors
Map
which will tell us if any of the nested form elements are currently displaying validation errors.
render() {
// Only show the tooltip if there are no childErrors
const tooltip = this.validationError && this.childErrors.size === 0
? html`<d2l-tooltip align="start" state="error">${this.validationError}</d2l-tooltip>`
: null;
return html`
<d2l-input-text minlength="4" label="First Name" @input=${this._onFirstNameInput} id="first-name"></d2l-input-text>
<d2l-input-text minlength="4" label="Last Name" @input=${this._onLastNameInput} id="last-name"></d2l-input-text>
${tooltip}
`;
}
6: Adding invalid styling for the nested custom form elements:
Now that the tooltips no longer overlap, we want to ensure the nested input-text
elements look invalid even if only the parent is actually invalid. To do this, we will use the forceInvalid
property to force the nested elements to look invalid when the parent is invalid.
render() {
const tooltip = this.validationError && this.childErrors.size === 0
? html`<d2l-tooltip align="start" state="error">${this.validationError}</d2l-tooltip>`
: null;
// Set forceInvalid for both nested form elements
return html`
<d2l-input-text .forceInvalid=${this.invalid} label="First Name" minlength="4" @input=${this._onFirstNameInput} id="first-name"></d2l-input-text>
<d2l-input-text .forceInvalid=${this.invalid} label="Last Name" minlength="4" @input=${this._onLastNameInput} id="last-name"></d2l-input-text>
${tooltip}
`;
}
7. Support novalidate:
Custom form elements allow validation to be disabled. To support this, we need to pass noValidate
down to the nested custom form elements.
render() {
const tooltip = this.validationError && this.childErrors.size === 0
? html`<d2l-tooltip align="start" state="error">${this.validationError}</d2l-tooltip>`
: null;
// Pass up novalidate to nested elements
return html`
<d2l-input-text ?novalidate=${this.noValidate} .forceInvalid=${this.invalid} label="First Name" minlength="4" @input=${this._onFirstNameInput} id="first-name"></d2l-input-text>
<d2l-input-text ?novalidate=${this.noValidate} .forceInvalid=${this.invalid} label="Last Name" minlength="4" @input=${this._onLastNameInput} id="last-name"></d2l-input-text>
${tooltip}
`;
}
8. Support Validate:
The last step is to ensure that calling validate on the parent will result in the nested components being validated. This is not required for self-validation but is important to ensure your custom form element works properly when used inside a d2l-form
or d2l-form-native
.
async validate() {
const firstNameInput = this.shadowRoot.querySelector('#first-name');
const lastNameInput = this.shadowRoot.querySelector('#last-name');
const errors = await Promise.all([firstNameInput.validate(), lastNameInput.validate(), super.validate()]);
// It is important that the errors are ordered with nested form elements first and the parent component last
return [...errors[0], ...errors[1], ...errors[2]];
}
Looking for an enhancement not listed here? Create a GitHub issue!