feat(form): set focus to the first erroneous form element on submit

This commit is contained in:
gerjanvangeest 2024-01-30 10:19:09 +01:00 committed by Thijs Louisse
parent 69f9d81bcb
commit 31e079d591
6 changed files with 142 additions and 29 deletions

View file

@ -0,0 +1,7 @@
---
'@lion/ui': minor
---
BREAKING CHANGE:
[form] set focus to the first erroneous form element on submit

View file

@ -11,14 +11,25 @@ A web component that enhances the functionality of the native `form` component.
It is designed to interact with (instances of) the [form controls](../../fundamentals/systems/form/overview.md). It is designed to interact with (instances of) the [form controls](../../fundamentals/systems/form/overview.md).
```js preview-story ```js preview-story
export const main = () => html` export const main = () => {
<lion-form> const submitHandler = ev => {
<form> const formData = ev.target.serializedValue;
<lion-input name="firstName" label="First Name" .modelValue=${'Foo'}></lion-input> console.log('formData', formData);
<lion-input name="lastName" label="Last Name" .modelValue=${'Bar'}></lion-input> fetch('/api/foo/', {
</form> method: 'POST',
</lion-form> body: JSON.stringify(formData),
`; });
};
return html`
<lion-form @submit=${submitHandler}>
<form @submit=${ev => ev.preventDefault()}>
<lion-input name="firstName" label="First Name" .modelValue=${'Foo'}></lion-input>
<lion-input name="lastName" label="Last Name" .modelValue=${'Bar'}></lion-input>
<button>Submit</button>
</form>
</lion-form>
`;
};
``` ```
## Features ## Features

View file

@ -10,32 +10,28 @@ import '@lion/ui/define/lion-form.js';
## Submit & Reset ## Submit & Reset
To submit a form, use a regular button (or `LionButtonSubmit`) somewhere inside the native `<form>`. To submit a form, use a regular `<button>` (or `<lion-button-submit>`) somewhere inside the native `<form>`.
Then, add a `submit` handler on the `lion-form`. Then, add a `submit` handler on the `<lion-form>`.
You can use this event to do your own (pre-)submit logic, like getting the serialized form data and sending it to a backend API. You can use this event to do your own (pre-)submit logic, like getting the serialized form data and sending it to a backend API.
Another example is checking if the form has errors, and focusing the first field with an error. Another example is checking if the form has errors, and focusing the first field with an error.
To fire a submit from JavaScript, select the `lion-form` element and call `.submit()`. To fire a submit from JavaScript, select the `<lion-form>` element and call `.submit()`.
```js preview-story ```js preview-story
export const formSubmit = () => { export const formSubmit = () => {
loadDefaultFeedbackMessages(); loadDefaultFeedbackMessages();
const submitHandler = ev => { const submitHandler = ev => {
if (ev.target.hasFeedbackFor.includes('error')) {
const firstFormElWithError = ev.target.formElements.find(el =>
el.hasFeedbackFor.includes('error'),
);
firstFormElWithError.focus();
return;
}
const formData = ev.target.serializedValue; const formData = ev.target.serializedValue;
fetch('/api/foo/', { console.log('formData', formData);
method: 'POST', if (!ev.target.hasFeedbackFor?.includes('error')) {
body: JSON.stringify(formData), fetch('/api/foo/', {
}); method: 'POST',
body: JSON.stringify(formData),
});
}
}; };
const submitViaJS = ev => { const submitViaJS = ev => {
// Call submit on the lion-form element, in your own code you should use // Call submit on the lion-form element, in your own code you should use

View file

@ -30,6 +30,8 @@ import '@lion/ui/define/lion-select.js';
import '@lion/ui/define/lion-select-rich.js'; import '@lion/ui/define/lion-select-rich.js';
import '@lion/ui/define/lion-switch.js'; import '@lion/ui/define/lion-switch.js';
import '@lion/ui/define/lion-textarea.js'; import '@lion/ui/define/lion-textarea.js';
import '@lion/ui/define/lion-button-submit.js';
import '@lion/ui/define/lion-button-reset.js';
import { MinLength, Required } from '@lion/ui/form-core.js'; import { MinLength, Required } from '@lion/ui/form-core.js';
import { loadDefaultFeedbackMessages } from '@lion/ui/validate-messages.js'; import { loadDefaultFeedbackMessages } from '@lion/ui/validate-messages.js';
``` ```
@ -39,7 +41,6 @@ import { loadDefaultFeedbackMessages } from '@lion/ui/validate-messages.js';
```js preview-story ```js preview-story
export const main = () => { export const main = () => {
loadDefaultFeedbackMessages(); loadDefaultFeedbackMessages();
Required.getMessage = () => 'Please enter a value';
return html` return html`
<lion-form> <lion-form>
<form> <form>
@ -47,11 +48,13 @@ export const main = () => {
<lion-input <lion-input
name="firstName" name="firstName"
label="First Name" label="First Name"
.fieldName="${'first name'}"
.validators="${[new Required()]}" .validators="${[new Required()]}"
></lion-input> ></lion-input>
<lion-input <lion-input
name="lastName" name="lastName"
label="Last Name" label="Last Name"
.fieldName="${'last name'}"
.validators="${[new Required()]}" .validators="${[new Required()]}"
></lion-input> ></lion-input>
</lion-fieldset> </lion-fieldset>
@ -70,6 +73,7 @@ export const main = () => {
<lion-textarea <lion-textarea
name="bio" name="bio"
label="Biography" label="Biography"
.fieldName="${'value'}"
.validators="${[new Required(), new MinLength(10)]}" .validators="${[new Required(), new MinLength(10)]}"
help-text="Please enter at least 10 characters" help-text="Please enter at least 10 characters"
></lion-textarea> ></lion-textarea>
@ -82,6 +86,7 @@ export const main = () => {
label="What do you like?" label="What do you like?"
name="checkers" name="checkers"
.validators="${[new Required()]}" .validators="${[new Required()]}"
.fieldName="${'value'}"
> >
<lion-checkbox .choiceValue=${'foo'} label="I like foo"></lion-checkbox> <lion-checkbox .choiceValue=${'foo'} label="I like foo"></lion-checkbox>
<lion-checkbox .choiceValue=${'bar'} label="I like bar"></lion-checkbox> <lion-checkbox .choiceValue=${'bar'} label="I like bar"></lion-checkbox>
@ -90,6 +95,7 @@ export const main = () => {
<lion-radio-group <lion-radio-group
name="dinosaurs" name="dinosaurs"
label="Favorite dinosaur" label="Favorite dinosaur"
.fieldName="${'dinosaur'}"
.validators="${[new Required()]}" .validators="${[new Required()]}"
> >
<lion-radio .choiceValue=${'allosaurus'} label="allosaurus"></lion-radio> <lion-radio .choiceValue=${'allosaurus'} label="allosaurus"></lion-radio>
@ -136,18 +142,23 @@ export const main = () => {
label="Input range" label="Input range"
></lion-input-range> ></lion-input-range>
<lion-checkbox-group <lion-checkbox-group
.multipleChoice="${false}"
name="terms" name="terms"
.validators="${[new Required()]}" .validators="${[
new Required('', {
getMessage: () => `Please accept our terms.`,
}),
]}"
> >
<lion-checkbox label="I blindly accept all terms and conditions"></lion-checkbox> <lion-checkbox
.choiceValue="${'true'}"
label="I blindly accept all terms and conditions"
></lion-checkbox>
</lion-checkbox-group> </lion-checkbox-group>
<lion-switch name="notifications" label="Notifications"></lion-switch> <lion-switch name="notifications" label="Notifications"></lion-switch>
<lion-input-stepper max="5" min="0" name="rsvp"> <lion-input-stepper max="5" min="0" name="rsvp">
<label slot="label">RSVP</label> <label slot="label">RSVP</label>
<div slot="help-text">Max. 5 guests</div> <div slot="help-text">Max. 5 guests</div>
</lion-input-stepper> </lion-input-stepper>
<lion-textarea name="comments" label="Comments"></lion-textarea>
<div class="buttons"> <div class="buttons">
<lion-button-submit>Submit</lion-button-submit> <lion-button-submit>Submit</lion-button-submit>
<lion-button-reset <lion-button-reset

View file

@ -1,5 +1,9 @@
import { LionFieldset } from '@lion/ui/fieldset.js'; import { LionFieldset } from '@lion/ui/fieldset.js';
/**
* @typedef {import('../../form-core/types/registration/FormRegistrarMixinTypes.js').FormRegistrarHost} FormRegistrarHost
*/
const throwFormNodeError = () => { const throwFormNodeError = () => {
throw new Error( throw new Error(
'No form node found. Did you put a <form> element inside your custom-form element?', 'No form node found. Did you put a <form> element inside your custom-form element?',
@ -57,6 +61,10 @@ export class LionForm extends LionFieldset {
ev.stopPropagation(); ev.stopPropagation();
this.submitGroup(); this.submitGroup();
this.dispatchEvent(new Event('submit', { bubbles: true })); this.dispatchEvent(new Event('submit', { bubbles: true }));
if (this.hasFeedbackFor?.includes('error')) {
this._setFocusOnFirstErroneousFormElement(this);
}
} }
reset() { reset() {
@ -78,6 +86,22 @@ export class LionForm extends LionFieldset {
this.dispatchEvent(new Event('reset', { bubbles: true })); this.dispatchEvent(new Event('reset', { bubbles: true }));
} }
/**
* @param {FormRegistrarHost} element
* @protected
*/
_setFocusOnFirstErroneousFormElement(element) {
const firstFormElWithError =
element.formElements.find(child => child.hasFeedbackFor.includes('error')) ||
element.formElements[0];
if (firstFormElWithError.formElements?.length > 0) {
this._setFocusOnFirstErroneousFormElement(firstFormElWithError);
} else {
firstFormElWithError._focusableNode.focus();
}
}
/** @private */ /** @private */
__registerEventsForLionForm() { __registerEventsForLionForm() {
this._formNode.addEventListener('submit', this._submit); this._formNode.addEventListener('submit', this._submit);

View file

@ -1,6 +1,6 @@
import { LionFieldset } from '@lion/ui/fieldset.js'; import { LionFieldset } from '@lion/ui/fieldset.js';
import '@lion/ui/define/lion-fieldset.js'; import '@lion/ui/define/lion-fieldset.js';
import { LionField } from '@lion/ui/form-core.js'; import { LionField, Required } from '@lion/ui/form-core.js';
import '@lion/ui/define/lion-field.js'; import '@lion/ui/define/lion-field.js';
import '@lion/ui/define/lion-validation-feedback.js'; import '@lion/ui/define/lion-validation-feedback.js';
@ -191,7 +191,7 @@ describe('<lion-form>', () => {
const el = await fixture(html` const el = await fixture(html`
<lion-form> <lion-form>
<form> <form>
<button type="reset">submit</button> <button type="reset">reset</button>
</form> </form>
</lion-form> </lion-form>
`); `);
@ -202,4 +202,68 @@ describe('<lion-form>', () => {
expect(dispatchSpy.args[0][0].type).to.equal('reset'); expect(dispatchSpy.args[0][0].type).to.equal('reset');
expect(internalHandlerSpy).to.be.calledBefore(dispatchSpy); expect(internalHandlerSpy).to.be.calledBefore(dispatchSpy);
}); });
it('sets focus on submit to the first erroneous form element', async () => {
const el = await fixture(html`
<lion-form>
<form>
<${childTag} name="firstName" .modelValue=${'Foo'} .validators=${[
new Required(),
]}></${childTag}>
<${childTag} name="lastName" .validators=${[new Required()]}></${childTag}>
<button type="submit">submit</button>
</form>
</lion-form>
`);
const button = /** @type {HTMLButtonElement} */ (el.querySelector('button'));
const dispatchSpy = spy(el, 'dispatchEvent');
button.click();
expect(dispatchSpy.args[0][0].type).to.equal('submit');
// @ts-ignore [allow-protected] in test
expect(document.activeElement).to.equal(el.formElements[1]._inputNode);
});
it('sets focus on submit to the first erroneous form element with a fieldset', async () => {
const el = await fixture(html`
<lion-form>
<form>
<lion-fieldset name="name">
<${childTag} name="firstName" .modelValue=${'Foo'} .validators=${[
new Required(),
]}></${childTag}>
<${childTag} name="lastName" .validators=${[new Required()]}></${childTag}>
</lion-fieldset>
<button type="submit">submit</button>
</form>
</lion-form>
`);
const button = /** @type {HTMLButtonElement} */ (el.querySelector('button'));
const dispatchSpy = spy(el, 'dispatchEvent');
button.click();
expect(dispatchSpy.args[0][0].type).to.equal('submit');
const fieldset = el.formElements[0];
// @ts-ignore [allow-protected] in test
expect(document.activeElement).to.equal(fieldset.formElements[1]._inputNode);
});
it('sets focus on submit to the first form element within a erroneous fieldset', async () => {
const el = await fixture(html`
<lion-form>
<form>
<lion-fieldset name="name" .validators=${[new Required()]}>
<${childTag} name="firstName"></${childTag}>
<${childTag} name="lastName"></${childTag}>
</lion-fieldset>
<button type="submit">submit</button>
</form>
</lion-form>
`);
const button = /** @type {HTMLButtonElement} */ (el.querySelector('button'));
const dispatchSpy = spy(el, 'dispatchEvent');
button.click();
expect(dispatchSpy.args[0][0].type).to.equal('submit');
const fieldset = el.formElements[0];
// @ts-ignore [allow-protected] in test
expect(document.activeElement).to.equal(fieldset.formElements[0]._inputNode);
});
}); });