diff --git a/core/src/components/fab-button/fab-button.tsx b/core/src/components/fab-button/fab-button.tsx index 2a7a4a6198a..2fe225aa2e9 100755 --- a/core/src/components/fab-button/fab-button.tsx +++ b/core/src/components/fab-button/fab-button.tsx @@ -1,8 +1,9 @@ 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 { inheritAriaAttributes } from '@utils/helpers'; +import { inheritAriaAttributes, hasShadowDom } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; +import { printIonWarning } from '@utils/logging'; import { createColorClasses, hostContext, openURL } from '@utils/theme'; import { close } from 'ionicons/icons'; @@ -26,6 +27,8 @@ import type { RouterDirection } from '../router/utils/interface'; }) export class FabButton implements ComponentInterface, AnchorInterface, ButtonInterface { private fab: HTMLIonFabElement | null = null; + private formButtonEl: HTMLButtonElement | null = null; + private formEl: HTMLFormElement | null = null; private inheritedAttributes: Attributes = {}; @Element() el!: HTMLElement; @@ -46,6 +49,13 @@ export class FabButton implements ComponentInterface, AnchorInterface, ButtonInt * If `true`, the user cannot interact with the fab button. */ @Prop() disabled = false; + @Watch('disabled') + disabledChanged() { + const { disabled } = this; + if (this.formButtonEl) { + this.formButtonEl.disabled = disabled; + } + } /** * This attribute instructs browsers to download a URL instead of navigating to @@ -103,6 +113,11 @@ export class FabButton implements ComponentInterface, AnchorInterface, ButtonInt */ @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; + /** * The size of the button. Set this to `small` in order to have a mini fab button. */ @@ -137,27 +152,91 @@ export class FabButton implements ComponentInterface, AnchorInterface, ButtonInt this.ionBlur.emit(); }; - private onClick = () => { - const { fab } = this; - if (!fab) { - return; + private onClick = (ev: Event) => { + const { el, fab } = this; + if (this.type !== 'button' && hasShadowDom(el)) { + this.submitForm(ev); + } + if (fab) { + fab.toggle(); } - - fab.toggle(); }; + /** + * Renders a hidden native button inside the associated form so that pressing + * Enter on a form field or clicking this component triggers form submission. + * The shadow DOM button does not participate in form submission natively, + * which is why this workaround is necessary. + */ + private renderHiddenButton() { + const formEl = (this.formEl = this.findForm()); + if (formEl) { + const { formButtonEl } = this; + if (formButtonEl !== null && formEl.contains(formButtonEl)) { + return; + } + const newFormButtonEl = (this.formButtonEl = document.createElement('button')); + newFormButtonEl.type = this.type; + newFormButtonEl.style.display = 'none'; + newFormButtonEl.disabled = this.disabled; + formEl.appendChild(newFormButtonEl); + } + } + + private findForm(): HTMLFormElement | null { + const { form } = this; + if (form instanceof HTMLFormElement) { + return form; + } + if (typeof form === 'string') { + const el: HTMLElement | null = document.getElementById(form); + if (el) { + if (el instanceof HTMLFormElement) { + return el; + } else { + printIonWarning( + `[ion-fab-button] - Form with selector: "#${form}" could not be found. Verify that the id is attached to a
element.`, + this.el + ); + return null; + } + } else { + printIonWarning( + `[ion-fab-button] - 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) { + printIonWarning( + `[ion-fab-button] - The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.`, + this.el + ); + return null; + } + return this.el.closest('form'); + } + + private submitForm(ev: Event) { + if (this.formEl && this.formButtonEl) { + ev.preventDefault(); + this.formButtonEl.click(); + } + } + componentWillLoad() { this.inheritedAttributes = inheritAriaAttributes(this.el); } render() { - const { el, disabled, color, href, activated, show, translucent, size, inheritedAttributes } = this; + const { el, disabled, color, href, activated, show, translucent, size, inheritedAttributes, type } = this; const inList = hostContext('ion-fab-list', el); const mode = getIonMode(this); const TagType = href === undefined ? 'button' : ('a' as any); const attrs = TagType === 'button' - ? { type: this.type } + ? { type } : { download: this.download, href, @@ -165,6 +244,10 @@ export class FabButton implements ComponentInterface, AnchorInterface, ButtonInt target: this.target, }; + if (type !== 'button') { + this.renderHiddenButton(); + } + return ( { + test.describe(title('fab-button: form'), () => { + test('should submit the closest form', async ({ page }) => { + await page.setContent( + ` + + Submit + + `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should submit the form by id', async ({ page }) => { + await page.setContent( + ` +
+ Submit + `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should submit the form by reference', async ({ page }) => { + await page.setContent( + ` +
+ Submit + + `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should submit the closest form by pressing the `enter` key on a form input', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/18550', + }); + + await page.setContent( + ` +
+ + Submit +
+ `, + 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 }) => { + await page.setContent( + ` +
+ + Submit +
+ `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.press('input', 'Enter'); + + expect(submitEvent).not.toHaveReceivedEvent(); + }); + + test('should reset the form', async ({ page }) => { + await page.setContent( + ` +
+ + Reset +
+ `, + config + ); + + const input = page.locator('input'); + await input.fill('changed'); + expect(await input.inputValue()).toBe('changed'); + + await page.click('ion-fab-button'); + + expect(await input.inputValue()).toBe('initial'); + }); + }); + + test.describe(title('should throw a warning if the form cannot be found'), () => { + test('form is a string selector', async ({ page }) => { + const logs: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'warning') { + logs.push(msg.text()); + } + }); + + await page.setContent(`Submit`, config); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain( + '[Ionic Warning]: [ion-fab-button] - Form with selector: "#missingForm" could not be found. Verify that the id is correct and the form is rendered in the DOM.' + ); + }); + }); +}); diff --git a/core/src/components/fab-button/test/form/fab-button.spec.ts b/core/src/components/fab-button/test/form/fab-button.spec.ts new file mode 100644 index 00000000000..48c0c527a82 --- /dev/null +++ b/core/src/components/fab-button/test/form/fab-button.spec.ts @@ -0,0 +1,31 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { FabButton } from '../../fab-button'; + +describe('FabButton: Hidden Form Button', () => { + it('should not add multiple buttons to the form on re-render', async () => { + const page = await newSpecPage({ + components: [FabButton], + html: ` +
+ Submit + `, + }); + + const getButtons = () => { + return page.body.querySelectorAll('form button'); + }; + + const fabButton = page.body.querySelector('ion-fab-button')!; + + await page.waitForChanges(); + + expect(getButtons().length).toEqual(1); + + // Re-render the component by changing a prop + fabButton.color = 'danger'; + await page.waitForChanges(); + + expect(getButtons().length).toEqual(1); + }); +}); diff --git a/core/src/components/fab-button/test/form/index.html b/core/src/components/fab-button/test/form/index.html new file mode 100644 index 00000000000..d64c19bc41a --- /dev/null +++ b/core/src/components/fab-button/test/form/index.html @@ -0,0 +1,66 @@ + + + + + FAB Button - Form Submit + + + + + + + + + + + + + FAB Button - Form Submit + + + + +

The FAB button below is inside the form. Clicking it should submit the form.

+
+ + + + + + + + + + + +
+ +

+ +

The FAB button below is outside the form but linked by id. Clicking it should also submit the form.

+
+ + + + + +
+ + + + +
+
+ + + +