feat(form): set focus to the first erroneous form element on submit
This commit is contained in:
parent
69f9d81bcb
commit
31e079d591
6 changed files with 142 additions and 29 deletions
7
.changeset/plenty-turtles-remember.md
Normal file
7
.changeset/plenty-turtles-remember.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
'@lion/ui': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
BREAKING CHANGE:
|
||||||
|
|
||||||
|
[form] set focus to the first erroneous form element on submit
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue