diff --git a/angular/src/directives/proxies.ts b/angular/src/directives/proxies.ts index 94efa9acff..e0a36eb59a 100644 --- a/angular/src/directives/proxies.ts +++ b/angular/src/directives/proxies.ts @@ -239,13 +239,13 @@ export declare interface IonButton extends Components.IonButton { @ProxyCmp({ defineCustomElementFn: undefined, - inputs: ['buttonType', 'color', 'disabled', 'download', 'expand', 'fill', 'href', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'shape', 'size', 'strong', 'target', 'type'] + inputs: ['buttonType', 'color', 'disabled', 'download', 'expand', 'fill', 'form', 'href', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'shape', 'size', 'strong', 'target', 'type'] }) @Component({ selector: 'ion-button', changeDetection: ChangeDetectionStrategy.OnPush, template: '', - inputs: ['buttonType', 'color', 'disabled', 'download', 'expand', 'fill', 'href', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'shape', 'size', 'strong', 'target', 'type'] + inputs: ['buttonType', 'color', 'disabled', 'download', 'expand', 'fill', 'form', 'href', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'shape', 'size', 'strong', 'target', 'type'] }) export class IonButton { protected el: HTMLElement; diff --git a/core/api.txt b/core/api.txt index 7d56c5064d..56db72b84a 100644 --- a/core/api.txt +++ b/core/api.txt @@ -203,6 +203,7 @@ ion-button,prop,disabled,boolean,false,false,true ion-button,prop,download,string | undefined,undefined,false,false ion-button,prop,expand,"block" | "full" | undefined,undefined,false,true ion-button,prop,fill,"clear" | "default" | "outline" | "solid" | undefined,undefined,false,true +ion-button,prop,form,HTMLFormElement | string | undefined,undefined,false,false ion-button,prop,href,string | undefined,undefined,false,false ion-button,prop,mode,"ios" | "md",undefined,false,false ion-button,prop,rel,string | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 74d69eeaa2..dcb41e77fa 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -379,6 +379,10 @@ export namespace Components { * Set to `"clear"` for a transparent button that resembles a flat button, to `"outline"` for a transparent button with a border, or to `"solid"` for a button with a filled background. The default fill is `"solid"` except inside of a toolbar, where the default is `"clear"`. */ "fill"?: 'clear' | 'outline' | 'solid' | 'default'; + /** + * The HTML form element or form element id. Used to submit a form when the button is not a child of the form. + */ + "form"?: string | HTMLFormElement; /** * Contains a URL or a URL fragment that the hyperlink points to. If this property is set, an anchor tag will be rendered. */ @@ -4343,6 +4347,10 @@ declare namespace LocalJSX { * Set to `"clear"` for a transparent button that resembles a flat button, to `"outline"` for a transparent button with a border, or to `"solid"` for a button with a filled background. The default fill is `"solid"` except inside of a toolbar, where the default is `"clear"`. */ "fill"?: 'clear' | 'outline' | 'solid' | 'default'; + /** + * The HTML form element or form element id. Used to submit a form when the button is not a child of the form. + */ + "form"?: string | HTMLFormElement; /** * Contains a URL or a URL fragment that the hyperlink points to. If this property is set, an anchor tag will be rendered. */ diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index d131adb857..060d9002ee 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -6,6 +6,7 @@ import type { AnimationBuilder, Color, RouterDirection } from '../../interface'; import type { AnchorInterface, ButtonInterface } from '../../utils/element-interface'; import type { Attributes } from '../../utils/helpers'; import { inheritAriaAttributes, hasShadowDom } from '../../utils/helpers'; +import { printIonWarning } from '../../utils/logging'; import { createColorClasses, hostContext, openURL } from '../../utils/theme'; /** @@ -127,6 +128,11 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf */ @Prop() type: 'submit' | 'reset' | 'button' = 'button'; + /** + * The HTML form element or form element id. Used to submit a form when the button is not a child of the form. + */ + @Prop() form?: string | HTMLFormElement; + /** * Emitted when the button has focus. */ @@ -160,21 +166,69 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf return 'bounded'; } + /** + * Finds the form element based on the provided `form` selector + * or element reference provided. + */ + private findForm(): HTMLFormElement | null { + const { form } = this; + if (form instanceof HTMLFormElement) { + return form; + } + if (typeof form === 'string') { + const el = document.getElementById(form); + if (el instanceof HTMLFormElement) { + return el; + } + } + return null; + } + private handleClick = (ev: Event) => { + const { el } = this; if (this.type === 'button') { openURL(this.href, ev, this.routerDirection, this.routerAnimation); - } else if (hasShadowDom(this.el)) { + } else if (hasShadowDom(el)) { // this button wants to specifically submit a form // climb up the dom to see if we're in a
// and if so, then use JS to submit it - const form = this.el.closest('form'); - if (form) { + 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'; - form.appendChild(fakeButton); + formEl.appendChild(fakeButton); fakeButton.click(); fakeButton.remove(); } @@ -217,7 +271,6 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf rel, target, }; - let fill = this.fill; if (fill === undefined) { fill = this.inToolbar || this.inListHeader ? 'clear' : 'solid'; diff --git a/core/src/components/button/test/form-reference/button.e2e.ts b/core/src/components/button/test/form-reference/button.e2e.ts new file mode 100644 index 0000000000..739c9de947 --- /dev/null +++ b/core/src/components/button/test/form-reference/button.e2e.ts @@ -0,0 +1,93 @@ +import { expect } from '@playwright/test'; +import { test } from '@utils/test/playwright'; + +test.describe('button: form', () => { + test('should submit the form by id', async ({ page }) => { + await page.setContent(` +
+ Submit + `); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should submit the form by reference', async ({ page }) => { + await page.setContent(` +
+ Submit + + `); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should submit the closest form', async ({ page }) => { + await page.setContent(` +
+ Submit +
+ `); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test.describe('should throw a warning if the form cannot be found', () => { + test('form is a string selector', async ({ page }) => { + await page.setContent(`Submit`); + + const logs: string[] = []; + + page.on('console', (msg) => { + logs.push(msg.text()); + }); + + await page.click('ion-button'); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain( + '[Ionic Warning]: Form with selector: "#missingForm" could not be found. Verify that the id is correct and the form is rendered in the DOM.' + ); + }); + + test('form is an element reference', async ({ page }) => { + await page.setContent(` + Submit + + `); + + const logs: string[] = []; + + page.on('console', (msg) => { + logs.push(msg.text()); + }); + + await page.click('ion-button'); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain( + '[Ionic Warning]: The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.' + ); + }); + }); +}); diff --git a/core/src/components/button/test/form-reference/index.html b/core/src/components/button/test/form-reference/index.html new file mode 100644 index 0000000000..5cfdf1aea6 --- /dev/null +++ b/core/src/components/button/test/form-reference/index.html @@ -0,0 +1,51 @@ + + + + + Button - Form + + + + + + + + + + + + + Outside button - Form Submit + + + + +
+
+ +
+
+ + Submit Form +
+
+ + + + diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 9b683a7dc2..efd8d274f4 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -161,6 +161,7 @@ export const IonButton = /*@__PURE__*/ defineContainer('ion-butto 'strong', 'target', 'type', + 'form', 'ionFocus', 'ionBlur' ]);