Skip to content

Commit

Permalink
feat(button): submit from outside of form (#25913)
Browse files Browse the repository at this point in the history
Resolves #21194

Co-authored-by: Sean Perkins <sean@ionic.io>
  • Loading branch information
postnerd and sean-perkins committed Sep 30, 2022
1 parent 9278db2 commit b139838
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 7 deletions.
4 changes: 2 additions & 2 deletions angular/src/directives/proxies.ts
Expand Up @@ -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: '<ng-content></ng-content>',
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;
Expand Down
1 change: 1 addition & 0 deletions core/api.txt
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions core/src/components.d.ts
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
63 changes: 58 additions & 5 deletions core/src/components/button/button.tsx
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 <form>
// 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();
}
Expand Down Expand Up @@ -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';
Expand Down
93 changes: 93 additions & 0 deletions 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(`
<form id="myForm"></form>
<ion-button form="myForm" type="submit">Submit</ion-button>
`);

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(`
<form></form>
<ion-button type="submit">Submit</ion-button>
<script>
const form = document.querySelector('form');
const button = document.querySelector('ion-button');
button.form = form;
</script>
`);

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(`
<form>
<ion-button type="submit">Submit</ion-button>
</form>
`);

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(`<ion-button type="submit" form="missingForm">Submit</ion-button>`);

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(`
<ion-button type="submit">Submit</ion-button>
<script>
const form = document.querySelector('form');
const button = document.querySelector('ion-button');
button.form = form;
</script>
`);

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.'
);
});
});
});
51 changes: 51 additions & 0 deletions core/src/components/button/test/form-reference/index.html
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Button - Form</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>Outside button - Form Submit</ion-title>
</ion-toolbar>
</ion-header>

<ion-content class="ion-padding ion-text-center" id="content">
<form id="outside-submit" onsubmit="return validate(event)" action="http://httpbin.org/get" method="GET">
<div>
<input name="name" required />
</div>
</form>

<ion-button type="submit" form="outside-submit"> Submit Form </ion-button>
</ion-content>
</ion-app>

<script>
document.querySelector('form').addEventListener('submit', (event) => {
console.log('SUBMIT from event', event);
});
function validate(event) {
console.log('SUBMIT from attribute', event);
if (event.target.elements[0].value === 'admin') {
return true;
} else {
console.log('INCORRECT USER, use "admin"');
return false;
}
}
</script>
</body>
</html>
1 change: 1 addition & 0 deletions packages/vue/src/proxies.ts
Expand Up @@ -161,6 +161,7 @@ export const IonButton = /*@__PURE__*/ defineContainer<JSX.IonButton>('ion-butto
'strong',
'target',
'type',
'form',
'ionFocus',
'ionBlur'
]);
Expand Down

0 comments on commit b139838

Please sign in to comment.