fix(button): submit form when pressing enter key (#27790)

Issue number: resolves #19368 

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?

The form submits when clicking on the `ion-button`. However, users
cannot submit the form when focused on a form element and pressing the
`enter` button. This does not follow the behavior of the native button
on a form.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Form now submits when a form element is focused and the `enter` button
is pressed.
  - It also submits regardless of the amount of form elements present.
- Form will not submit if the `ion-button` is disabled.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

N/A
This commit is contained in:
Maria Hutt
2023-07-14 10:24:53 -07:00
committed by GitHub
parent 16c77ccdc8
commit b78af7598f
2 changed files with 169 additions and 61 deletions

View File

@ -1,5 +1,5 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core'; import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, h } from '@stencil/core'; import { Component, Element, Event, Host, Prop, Watch, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface'; import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, hasShadowDom } from '@utils/helpers'; import { inheritAriaAttributes, hasShadowDom } from '@utils/helpers';
@ -32,6 +32,8 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
private inItem = false; private inItem = false;
private inListHeader = false; private inListHeader = false;
private inToolbar = false; private inToolbar = false;
private formButtonEl: HTMLButtonElement | null = null;
private formEl: HTMLFormElement | null = null;
private inheritedAttributes: Attributes = {}; private inheritedAttributes: Attributes = {};
@Element() el!: HTMLElement; @Element() el!: HTMLElement;
@ -52,6 +54,13 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
* If `true`, the user cannot interact with the button. * If `true`, the user cannot interact with the button.
*/ */
@Prop({ reflect: true }) disabled = false; @Prop({ reflect: true }) disabled = false;
@Watch('disabled')
disabledChanged() {
const { disabled } = this;
if (this.formButtonEl) {
this.formButtonEl.disabled = disabled;
}
}
/** /**
* Set to `"block"` for a full-width button or to `"full"` for a full-width button * Set to `"block"` for a full-width button or to `"full"` for a full-width button
@ -144,6 +153,22 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
*/ */
@Event() ionBlur!: EventEmitter<void>; @Event() ionBlur!: EventEmitter<void>;
connectedCallback(): void {
// Allow form to be submitted through `ion-button`
if (this.type !== 'button' && hasShadowDom(this.el)) {
this.formEl = this.findForm();
if (this.formEl) {
// Create a hidden native button inside of the form
this.formButtonEl = document.createElement('button');
this.formButtonEl.type = this.type;
this.formButtonEl.style.display = 'none';
// Only submit if the button is not disabled.
this.formButtonEl.disabled = this.disabled;
this.formEl.appendChild(this.formButtonEl);
}
}
}
componentWillLoad() { componentWillLoad() {
this.inToolbar = !!this.el.closest('ion-buttons'); this.inToolbar = !!this.el.closest('ion-buttons');
this.inListHeader = !!this.el.closest('ion-list-header'); this.inListHeader = !!this.el.closest('ion-list-header');
@ -177,62 +202,71 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
return form; return form;
} }
if (typeof form === 'string') { if (typeof form === 'string') {
const el = document.getElementById(form); // Check if the string provided is a form id.
const el: HTMLElement | null = document.getElementById(form);
if (el) {
if (el instanceof HTMLFormElement) { if (el instanceof HTMLFormElement) {
return el; return el;
} } else {
} /**
* The developer specified a string for the form attribute, but the
* element with that id is not a form element.
*/
printIonWarning(
`Form with selector: "#${form}" could not be found. Verify that the id is attached to a <form> element.`,
this.el
);
return null; return null;
} }
} else {
/**
* The developer specified a string for the form attribute, but the
* element with that id could not be found in the DOM.
*/
printIonWarning(
`Form with selector: "#${form}" could not be found. Verify that the id is correct and the form is rendered in the DOM.`,
this.el
);
return null;
}
}
if (form !== undefined) {
/**
* The developer specified a HTMLElement for the form attribute,
* but the element is not a HTMLFormElement.
* This will also catch if the developer tries to pass in null
* as the form attribute.
*/
printIonWarning(
`The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.`,
this.el
);
return null;
}
/**
* If the form element is not set, the button may be inside
* of a form element. Query the closest form element to the button.
*/
return this.el.closest('form');
}
private submitForm(ev: Event) {
// this button wants to specifically submit a form
// climb up the dom to see if we're in a <form>
// and if so, then use JS to submit it
if (this.formEl && this.formButtonEl) {
ev.preventDefault();
this.formButtonEl.click();
}
}
private handleClick = (ev: Event) => { private handleClick = (ev: Event) => {
const { el } = this; const { el } = this;
if (this.type === 'button') { if (this.type === 'button') {
openURL(this.href, ev, this.routerDirection, this.routerAnimation); openURL(this.href, ev, this.routerDirection, this.routerAnimation);
} else if (hasShadowDom(el)) { } else if (hasShadowDom(el)) {
// this button wants to specifically submit a form this.submitForm(ev);
// climb up the dom to see if we're in a <form>
// and if so, then use JS to submit it
let formEl = this.findForm();
const { form } = this;
if (!formEl && form !== undefined) {
/**
* The developer specified a form selector for
* the button to submit, but it was not found.
*/
if (typeof form === 'string') {
printIonWarning(
`Form with selector: "#${form}" could not be found. Verify that the id is correct and the form is rendered in the DOM.`,
el
);
} else {
printIonWarning(
`The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.`,
el
);
}
return;
}
if (!formEl) {
/**
* If the form element is not set, the button may be inside
* of a form element. Query the closest form element to the button.
*/
formEl = el.closest('form');
}
if (formEl) {
ev.preventDefault();
const fakeButton = document.createElement('button');
fakeButton.type = this.type;
fakeButton.style.display = 'none';
formEl.appendChild(fakeButton);
fakeButton.click();
fakeButton.remove();
}
} }
}; };

View File

@ -56,19 +56,93 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
expect(submitEvent).toHaveReceivedEvent(); expect(submitEvent).toHaveReceivedEvent();
}); });
test('should submit the closest form by pressing the `enter` button on a form element', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/19368',
});
await page.setContent(
`
<form>
<input type="text" />
<ion-button type="submit">Submit</ion-button>
</form>
`,
config
);
const submitEvent = await page.spyOnEvent('submit');
await page.press('input', 'Enter');
expect(submitEvent).toHaveReceivedEvent();
});
test('should submit the closest form with multiple elements by pressing the `enter` button', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/19368',
});
await page.setContent(
`
<form>
<input type="text" />
<textarea></textarea>
<ion-button type="submit">Submit</ion-button>
</form>
`,
config
);
const submitEvent = await page.spyOnEvent('submit');
await page.press('input', 'Enter');
expect(submitEvent).toHaveReceivedEvent();
});
test('should not submit the closest form when button is disabled', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/19368',
});
await page.setContent(
`
<form>
<input type="text" />
<ion-button type="submit" disabled>Submit</ion-button>
</form>
`,
config
);
const submitEvent = await page.spyOnEvent('submit');
await page.press('input', 'Enter');
expect(submitEvent).not.toHaveReceivedEvent();
});
}); });
test.describe(title('should throw a warning if the form cannot be found'), () => { test.describe(title('should throw a warning if the form cannot be found'), () => {
test('form is a string selector', async ({ page }) => { test('form is a string selector', async ({ page }) => {
await page.setContent(`<ion-button type="submit" form="missingForm">Submit</ion-button>`, config);
const logs: string[] = []; const logs: string[] = [];
page.on('console', (msg) => { page.on('console', (msg) => {
if (msg.type() === 'warning') {
logs.push(msg.text()); logs.push(msg.text());
}
}); });
await page.click('ion-button'); await page.setContent(`<ion-button type="submit" form="missingForm">Submit</ion-button>`, config);
expect(logs.length).toBe(1); expect(logs.length).toBe(1);
expect(logs[0]).toContain( expect(logs[0]).toContain(
@ -77,6 +151,14 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
}); });
test('form is an element reference', async ({ page }) => { test('form is an element reference', async ({ page }) => {
const logs: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'warning') {
logs.push(msg.text());
}
});
await page.setContent( await page.setContent(
` `
<ion-button type="submit">Submit</ion-button> <ion-button type="submit">Submit</ion-button>
@ -90,14 +172,6 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
config config
); );
const logs: string[] = [];
page.on('console', (msg) => {
logs.push(msg.text());
});
await page.click('ion-button');
expect(logs.length).toBe(1); expect(logs.length).toBe(1);
expect(logs[0]).toContain( expect(logs[0]).toContain(
'[Ionic Warning]: The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.' '[Ionic Warning]: The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.'