mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-17 18:54:11 +08:00
feat(button): submit from outside of form (#25913)
Resolves #21194 Co-authored-by: Sean Perkins <sean@ionic.io>
This commit is contained in:
@ -239,13 +239,13 @@ export declare interface IonButton extends Components.IonButton {
|
|||||||
|
|
||||||
@ProxyCmp({
|
@ProxyCmp({
|
||||||
defineCustomElementFn: undefined,
|
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({
|
@Component({
|
||||||
selector: 'ion-button',
|
selector: 'ion-button',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: '<ng-content></ng-content>',
|
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 {
|
export class IonButton {
|
||||||
protected el: HTMLElement;
|
protected el: HTMLElement;
|
||||||
|
@ -203,6 +203,7 @@ ion-button,prop,disabled,boolean,false,false,true
|
|||||||
ion-button,prop,download,string | undefined,undefined,false,false
|
ion-button,prop,download,string | undefined,undefined,false,false
|
||||||
ion-button,prop,expand,"block" | "full" | undefined,undefined,false,true
|
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,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,href,string | undefined,undefined,false,false
|
||||||
ion-button,prop,mode,"ios" | "md",undefined,false,false
|
ion-button,prop,mode,"ios" | "md",undefined,false,false
|
||||||
ion-button,prop,rel,string | undefined,undefined,false,false
|
ion-button,prop,rel,string | undefined,undefined,false,false
|
||||||
|
8
core/src/components.d.ts
vendored
8
core/src/components.d.ts
vendored
@ -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"`.
|
* 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';
|
"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.
|
* 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"`.
|
* 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';
|
"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.
|
* Contains a URL or a URL fragment that the hyperlink points to. If this property is set, an anchor tag will be rendered.
|
||||||
*/
|
*/
|
||||||
|
@ -6,6 +6,7 @@ import type { AnimationBuilder, Color, RouterDirection } from '../../interface';
|
|||||||
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';
|
||||||
|
import { printIonWarning } from '../../utils/logging';
|
||||||
import { createColorClasses, hostContext, openURL } from '../../utils/theme';
|
import { createColorClasses, hostContext, openURL } from '../../utils/theme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -127,6 +128,11 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
|
|||||||
*/
|
*/
|
||||||
@Prop() type: 'submit' | 'reset' | 'button' = 'button';
|
@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.
|
* Emitted when the button has focus.
|
||||||
*/
|
*/
|
||||||
@ -160,21 +166,69 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
|
|||||||
return 'bounded';
|
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) => {
|
private handleClick = (ev: Event) => {
|
||||||
|
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(this.el)) {
|
} else if (hasShadowDom(el)) {
|
||||||
// this button wants to specifically submit a form
|
// this button wants to specifically submit a form
|
||||||
// climb up the dom to see if we're in a <form>
|
// climb up the dom to see if we're in a <form>
|
||||||
// and if so, then use JS to submit it
|
// and if so, then use JS to submit it
|
||||||
const form = this.el.closest('form');
|
let formEl = this.findForm();
|
||||||
if (form) {
|
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();
|
ev.preventDefault();
|
||||||
|
|
||||||
const fakeButton = document.createElement('button');
|
const fakeButton = document.createElement('button');
|
||||||
fakeButton.type = this.type;
|
fakeButton.type = this.type;
|
||||||
fakeButton.style.display = 'none';
|
fakeButton.style.display = 'none';
|
||||||
form.appendChild(fakeButton);
|
formEl.appendChild(fakeButton);
|
||||||
fakeButton.click();
|
fakeButton.click();
|
||||||
fakeButton.remove();
|
fakeButton.remove();
|
||||||
}
|
}
|
||||||
@ -217,7 +271,6 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
|
|||||||
rel,
|
rel,
|
||||||
target,
|
target,
|
||||||
};
|
};
|
||||||
|
|
||||||
let fill = this.fill;
|
let fill = this.fill;
|
||||||
if (fill === undefined) {
|
if (fill === undefined) {
|
||||||
fill = this.inToolbar || this.inListHeader ? 'clear' : 'solid';
|
fill = this.inToolbar || this.inListHeader ? 'clear' : 'solid';
|
||||||
|
93
core/src/components/button/test/form-reference/button.e2e.ts
Normal file
93
core/src/components/button/test/form-reference/button.e2e.ts
Normal file
@ -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
core/src/components/button/test/form-reference/index.html
Normal file
51
core/src/components/button/test/form-reference/index.html
Normal file
@ -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>
|
@ -161,6 +161,7 @@ export const IonButton = /*@__PURE__*/ defineContainer<JSX.IonButton>('ion-butto
|
|||||||
'strong',
|
'strong',
|
||||||
'target',
|
'target',
|
||||||
'type',
|
'type',
|
||||||
|
'form',
|
||||||
'ionFocus',
|
'ionFocus',
|
||||||
'ionBlur'
|
'ionBlur'
|
||||||
]);
|
]);
|
||||||
|
Reference in New Issue
Block a user