Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 93 additions & 10 deletions core/src/components/fab-button/fab-button.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -137,34 +152,102 @@ 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 <form> 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,
rel: this.rel,
target: this.target,
};

if (type !== 'button') {
this.renderHiddenButton();
}

return (
<Host
onClick={this.onClick}
Expand Down
140 changes: 140 additions & 0 deletions core/src/components/fab-button/test/form/fab-button.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';

configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => {
test.describe(title('fab-button: form'), () => {
test('should submit the closest form', async ({ page }) => {
await page.setContent(
`
<form>
<ion-fab-button type="submit">Submit</ion-fab-button>
</form>
`,
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(
`
<form id="myForm"></form>
<ion-fab-button form="myForm" type="submit">Submit</ion-fab-button>
`,
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(
`
<form></form>
<ion-fab-button type="submit">Submit</ion-fab-button>
<script>
const form = document.querySelector('form');
const button = document.querySelector('ion-fab-button');
button.form = form;
</script>
`,
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(
`
<form>
<input type="text" />
<ion-fab-button type="submit">Submit</ion-fab-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 }) => {
await page.setContent(
`
<form>
<input type="text" />
<ion-fab-button type="submit" disabled>Submit</ion-fab-button>
</form>
`,
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(
`
<form>
<input type="text" value="initial" />
<ion-fab-button type="reset">Reset</ion-fab-button>
</form>
`,
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(`<ion-fab-button type="submit" form="missingForm">Submit</ion-fab-button>`, 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.'
);
});
});
});
31 changes: 31 additions & 0 deletions core/src/components/fab-button/test/form/fab-button.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `
<form id="my-form"></form>
<ion-fab-button form="my-form" type="submit">Submit</ion-fab-button>
`,
});

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);
});
});
66 changes: 66 additions & 0 deletions core/src/components/fab-button/test/form/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>FAB Button - Form Submit</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>

<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>FAB Button - Form Submit</ion-title>
</ion-toolbar>
</ion-header>

<ion-content class="ion-padding" id="content">
<p>The FAB button below is inside the form. Clicking it should submit the form.</p>
<form onsubmit="handleSubmit(event)" action="http://httpbin.org/get" method="GET">
<ion-list>
<ion-item>
<ion-input label="Name" name="name" required placeholder="Enter name"></ion-input>
</ion-item>
</ion-list>

<ion-fab vertical="bottom" horizontal="end" slot="fixed">
<ion-fab-button type="submit">
<ion-icon name="cloud-upload-outline"></ion-icon>
</ion-fab-button>
</ion-fab>
</form>

<br /><br />

<p>The FAB button below is outside the form but linked by id. Clicking it should also submit the form.</p>
<form id="outside-form" onsubmit="handleSubmit(event)" action="http://httpbin.org/get" method="GET">
<ion-list>
<ion-item>
<ion-input label="Email" name="email" type="email" required placeholder="Enter email"></ion-input>
</ion-item>
</ion-list>
</form>

<ion-fab-button form="outside-form" type="submit">
<ion-icon name="checkmark-outline"></ion-icon>
</ion-fab-button>
</ion-content>
</ion-app>

<script>
function handleSubmit(event) {
event.preventDefault();
console.log('Form submitted!', event.target);
alert('Form submitted! Check console for details.');
}
</script>
</body>
</html>