mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
Issue number: internal --------- ## What is the current behavior? The `shape` property defaults to `"soft"` for `ios` and `"round"` for the `md` and `ionic` themes. **Default button size**: | Property Value | CSS Value (`ionic`) | CSS Value (`ios`) | CSS Value (`md`) | | --------------- | ------ | ----------- | ----------- | | `undefined` | `8px` | `14px` | `4px` | | `"round"` | `999px` | unsupported | unsupported | | `"rectangular"` | `0px` | unsupported | unsupported | **Large button size**: | Property Value | CSS Value (`ionic`) | CSS Value (`ios`) | CSS Value (`md`) | | --------------- | ------ | ----------- | ----------- | | `undefined` | `8px` | `16px` | `4px` | **Small button size**: | Property Value | CSS Value (`ionic`) | CSS Value (`ios`) | CSS Value (`md`) | | --------------- | ------ | ----------- | ----------- | | `undefined` | `4px` | `6px` | `4px` | ## What is the new behavior? The `shape` property defaults to `undefined` which evaluates to the "Soft" shape for all themes. **Default button size**: | Property Value | CSS Value (`ionic`) | CSS Value (`ios`) | CSS Value (`md`) | | --------------- | ------- | ------ | -------- | | `"soft"` | `8px` | `6px` | `4px` | | `"round"` | `999px` | `999px` | `999px` | | `"rectangular"` | `0px` | `0px` | `0px` | **Large button size**: | Property Value | CSS Value (`ionic`) | CSS Value (`ios`) | CSS Value (`md`) | | --------------- | ------ | ----------- | ----------- | | `soft` | `8px` | `8px` | `4px` | **Small button size**: | Property Value | CSS Value (`ionic`) | CSS Value (`ios`) | CSS Value (`md`) | | --------------- | ------ | ----------- | ----------- | | `soft` | `4px` | `4px` | `4px` | - Adds support for the `"soft"` shape in the `ionic` theme using the existing values for `undefined` - Adds support for the `"rectangular"` and `"round"` shapes in `ios` and `md` using `0px` and `999px` border radius - Sets the default shape property to `"round"` for the `ionic` and `md` themes and `"soft"` for `ios` and updates the `border-radius` to apply to the shape classes instead of `:host` - Updates the `"soft"` shape border radius for `ios` in the various sizes to match the buttons created in SwiftUI (their "rounded" is our "soft"):  - Fixed the icon only buttons in the `"ionic"` theme so that they are not styled based on the size while always following the proper aspect ratio. This was broken for the default size due to an incorrect padding value & I made it so we don't have to specify the padding for each size: | Before | After | | ---| ---| |  |  | ## Does this introduce a breaking change? - [x] Yes - [ ] No BREAKING CHANGE: The `border-radius` of the `ios` and `md` button now defaults to `6px` and `999px` instead of `14px` and `4px`, respectively, in accordance with the iOS and Material Design 3 guidelines. To revert to the previous appearance, set the `shape` to `"soft"` for `md` and override the `--border-radius` CSS variable for `ios` to `14px`, or set it to a different value entirely. --------- Co-authored-by: ionitron <hi@ionicframework.com>
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
|
import { Component, Element, Event, Host, Prop, Watch, State, h } from '@stencil/core';
|
|
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';
|
|
|
|
import { getIonTheme } from '../../global/ionic-global';
|
|
import type { AnimationBuilder, Color } from '../../interface';
|
|
import type { RouterDirection } from '../router/utils/interface';
|
|
|
|
/**
|
|
* @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component.
|
|
* @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component.
|
|
*
|
|
* @slot - Content is placed between the named slots if provided without a slot.
|
|
* @slot icon-only - Should be used on an icon in a button that has no text.
|
|
* @slot start - Content is placed to the left of the button text in LTR, and to the right in RTL.
|
|
* @slot end - Content is placed to the right of the button text in LTR, and to the left in RTL.
|
|
*
|
|
* @part native - The native HTML button or anchor element that wraps all child elements.
|
|
*/
|
|
@Component({
|
|
tag: 'ion-button',
|
|
styleUrls: {
|
|
ios: 'button.ios.scss',
|
|
md: 'button.md.scss',
|
|
ionic: 'button.ionic.scss',
|
|
},
|
|
shadow: true,
|
|
})
|
|
export class Button implements ComponentInterface, AnchorInterface, ButtonInterface {
|
|
private inItem = false;
|
|
private inListHeader = false;
|
|
private inToolbar = false;
|
|
private formButtonEl: HTMLButtonElement | null = null;
|
|
private formEl: HTMLFormElement | null = null;
|
|
private inheritedAttributes: Attributes = {};
|
|
|
|
@Element() el!: HTMLElement;
|
|
|
|
/**
|
|
* If `true`, the button only has an icon.
|
|
*/
|
|
@State() isCircle: boolean = false;
|
|
|
|
/**
|
|
* The color to use from your application's color palette.
|
|
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
|
|
* For more information on colors, see [theming](/docs/theming/basics).
|
|
*/
|
|
@Prop({ reflect: true }) color?: Color;
|
|
|
|
/**
|
|
* The type of button.
|
|
*/
|
|
@Prop({ mutable: true }) buttonType = 'button';
|
|
|
|
/**
|
|
* If `true`, the user cannot interact with the button.
|
|
*/
|
|
@Prop({ reflect: true }) disabled = false;
|
|
@Watch('disabled')
|
|
disabledChanged() {
|
|
const { disabled } = this;
|
|
if (this.formButtonEl) {
|
|
this.formButtonEl.disabled = disabled;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set to `"block"` for a full-width button or to `"full"` for a full-width button
|
|
* with square corners and no left or right borders.
|
|
*/
|
|
@Prop({ reflect: true }) expand?: 'full' | 'block';
|
|
|
|
/**
|
|
* 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"`.
|
|
*/
|
|
@Prop({ reflect: true, mutable: true }) fill?: 'clear' | 'outline' | 'solid' | 'default';
|
|
|
|
/**
|
|
* When using a router, it specifies the transition direction when navigating to
|
|
* another page using `href`.
|
|
*/
|
|
@Prop() routerDirection: RouterDirection = 'forward';
|
|
|
|
/**
|
|
* When using a router, it specifies the transition animation when navigating to
|
|
* another page using `href`.
|
|
*/
|
|
@Prop() routerAnimation: AnimationBuilder | undefined;
|
|
|
|
/**
|
|
* This attribute instructs browsers to download a URL instead of navigating to
|
|
* it, so the user will be prompted to save it as a local file. If the attribute
|
|
* has a value, it is used as the pre-filled file name in the Save prompt
|
|
* (the user can still change the file name if they want).
|
|
*/
|
|
@Prop() download: string | undefined;
|
|
|
|
/**
|
|
* Contains a URL or a URL fragment that the hyperlink points to.
|
|
* If this property is set, an anchor tag will be rendered.
|
|
*/
|
|
@Prop() href: string | undefined;
|
|
|
|
/**
|
|
* Specifies the relationship of the target object to the link object.
|
|
* The value is a space-separated list of [link types](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types).
|
|
*/
|
|
@Prop() rel: string | undefined;
|
|
|
|
/**
|
|
* Set to `"soft"` for a button with slightly rounded corners, `"round"` for a button with fully
|
|
* rounded corners, or `"rectangular"` for a button without rounded corners.
|
|
* Defaults to `"soft"` for the `"ios"` theme and `"round"` for all other themes.
|
|
*/
|
|
@Prop({ reflect: true }) shape?: 'soft' | 'round' | 'rectangular';
|
|
|
|
/**
|
|
* Set to `"small"` for a button with less height and padding, to `"default"`
|
|
* for a button with the default height and padding, or to `"large"` for a button
|
|
* with more height and padding. By default the size is unset, unless the button
|
|
* is inside of an item, where the size is `"small"` by default. Set the size to
|
|
* `"default"` inside of an item to make it a standard size button.
|
|
*/
|
|
@Prop({ reflect: true }) size?: 'xsmall' | 'small' | 'default' | 'large' | 'xlarge';
|
|
|
|
/**
|
|
* If `true`, activates a button with a heavier font weight.
|
|
*/
|
|
@Prop() strong = false;
|
|
|
|
/**
|
|
* Specifies where to display the linked URL.
|
|
* Only applies when an `href` is provided.
|
|
* Special keywords: `"_blank"`, `"_self"`, `"_parent"`, `"_top"`.
|
|
*/
|
|
@Prop() target: string | undefined;
|
|
|
|
/**
|
|
* The type of the 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.
|
|
*/
|
|
@Event() ionFocus!: EventEmitter<void>;
|
|
|
|
/**
|
|
* Emitted when the button loses focus.
|
|
*/
|
|
@Event() ionBlur!: EventEmitter<void>;
|
|
|
|
/**
|
|
* This is responsible for rendering a hidden native
|
|
* button element inside the associated form. This allows
|
|
* users to submit a form by pressing "Enter" when a text
|
|
* field inside of the form is focused. The native button
|
|
* rendered inside of `ion-button` is in the Shadow DOM
|
|
* and therefore does not participate in form submission
|
|
* which is why the following code is necessary.
|
|
*/
|
|
private renderHiddenButton() {
|
|
const formEl = (this.formEl = this.findForm());
|
|
if (formEl) {
|
|
const { formButtonEl } = this;
|
|
|
|
/**
|
|
* If the form already has a rendered form button
|
|
* then do not append a new one again.
|
|
*/
|
|
if (formButtonEl !== null && formEl.contains(formButtonEl)) {
|
|
return;
|
|
}
|
|
|
|
// Create a hidden native button inside of the form
|
|
const newFormButtonEl = (this.formButtonEl = document.createElement('button'));
|
|
newFormButtonEl.type = this.type;
|
|
newFormButtonEl.style.display = 'none';
|
|
// Only submit if the button is not disabled.
|
|
newFormButtonEl.disabled = this.disabled;
|
|
|
|
formEl.appendChild(newFormButtonEl);
|
|
}
|
|
}
|
|
|
|
componentWillLoad() {
|
|
this.inToolbar = !!this.el.closest('ion-buttons');
|
|
this.inListHeader = !!this.el.closest('ion-list-header');
|
|
this.inItem = !!this.el.closest('ion-item') || !!this.el.closest('ion-item-divider');
|
|
this.inheritedAttributes = inheritAriaAttributes(this.el);
|
|
}
|
|
|
|
private get hasIconOnly() {
|
|
return !!this.el.querySelector('[slot="icon-only"]');
|
|
}
|
|
|
|
private get rippleType() {
|
|
const hasClearFill = this.fill === undefined || this.fill === 'clear';
|
|
|
|
// If the button is in a toolbar, has a clear fill (which is the default)
|
|
// and only has an icon we use the unbounded "circular" ripple effect
|
|
if (hasClearFill && this.hasIconOnly && this.inToolbar) {
|
|
return 'unbounded';
|
|
}
|
|
|
|
return 'bounded';
|
|
}
|
|
|
|
/**
|
|
* Set the shape based on the theme
|
|
*/
|
|
private getShape(): string {
|
|
const theme = getIonTheme(this);
|
|
const { shape } = this;
|
|
|
|
if (shape === undefined) {
|
|
return theme === 'ios' ? 'soft' : 'round';
|
|
}
|
|
|
|
return shape;
|
|
}
|
|
|
|
/**
|
|
* Disable the "xsmall" and "xlarge" sizes if the theme is "ios" or "md"
|
|
*/
|
|
private getSize(): string | undefined {
|
|
const theme = getIonTheme(this);
|
|
const { size } = this;
|
|
|
|
if (size === undefined && this.inItem) {
|
|
return 'small';
|
|
}
|
|
|
|
if ((theme === 'ios' || theme === 'md') && (size === 'xsmall' || size === 'xlarge')) {
|
|
return undefined;
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
/**
|
|
* 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') {
|
|
// Check if the string provided is a form id.
|
|
const el: HTMLElement | null = document.getElementById(form);
|
|
if (el) {
|
|
if (el instanceof HTMLFormElement) {
|
|
return el;
|
|
} else {
|
|
/**
|
|
* The developer specified a string for the form attribute, but the
|
|
* element with that id is not a form element.
|
|
*/
|
|
printIonWarning(
|
|
`Form with selector: "#${form}" could not be found. Verify that the id is attached to a <form> element.`,
|
|
this.el
|
|
);
|
|
return null;
|
|
}
|
|
} else {
|
|
/**
|
|
* The developer specified a string for the form attribute, but the
|
|
* element with that id could not be found in the DOM.
|
|
*/
|
|
printIonWarning(
|
|
`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) {
|
|
/**
|
|
* The developer specified a HTMLElement for the form attribute,
|
|
* but the element is not a HTMLFormElement.
|
|
* This will also catch if the developer tries to pass in null
|
|
* as the form attribute.
|
|
*/
|
|
printIonWarning(
|
|
`The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.`,
|
|
this.el
|
|
);
|
|
return null;
|
|
}
|
|
/**
|
|
* If the form element is not set, the button may be inside
|
|
* of a form element. Query the closest form element to the button.
|
|
*/
|
|
return this.el.closest('form');
|
|
}
|
|
|
|
private submitForm(ev: Event) {
|
|
// 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
|
|
if (this.formEl && this.formButtonEl) {
|
|
ev.preventDefault();
|
|
|
|
this.formButtonEl.click();
|
|
}
|
|
}
|
|
|
|
private handleClick = (ev: Event) => {
|
|
const { el } = this;
|
|
if (this.type === 'button') {
|
|
openURL(this.href, ev, this.routerDirection, this.routerAnimation);
|
|
} else if (hasShadowDom(el)) {
|
|
this.submitForm(ev);
|
|
}
|
|
};
|
|
|
|
private onFocus = () => {
|
|
this.ionFocus.emit();
|
|
};
|
|
|
|
private onBlur = () => {
|
|
this.ionBlur.emit();
|
|
};
|
|
|
|
private slotChanged = () => {
|
|
/**
|
|
* Ensures that the 'has-icon-only' class is properly added
|
|
* or removed from `ion-button` when manipulating the
|
|
* `icon-only` slot.
|
|
*
|
|
* Without this, the 'has-icon-only' class is only checked
|
|
* or added when `ion-button` component first renders.
|
|
*/
|
|
this.isCircle = this.hasIconOnly;
|
|
};
|
|
|
|
render() {
|
|
const { buttonType, type, disabled, rel, target, href, color, expand, hasIconOnly, strong, inheritedAttributes } =
|
|
this;
|
|
|
|
const theme = getIonTheme(this);
|
|
const size = this.getSize();
|
|
const shape = this.getShape();
|
|
const TagType = href === undefined ? 'button' : ('a' as any);
|
|
const attrs =
|
|
TagType === 'button'
|
|
? { type }
|
|
: {
|
|
download: this.download,
|
|
href,
|
|
rel,
|
|
target,
|
|
};
|
|
let fill = this.fill;
|
|
/**
|
|
* We check both undefined and null to
|
|
* work around https://github.com/ionic-team/stencil/issues/3586.
|
|
*/
|
|
if (fill == null) {
|
|
fill = this.inToolbar || this.inListHeader ? 'clear' : 'solid';
|
|
}
|
|
|
|
/**
|
|
* We call renderHiddenButton in the render function to account
|
|
* for any properties being set async. For example, changing the
|
|
* "type" prop from "button" to "submit" after the component has
|
|
* loaded would warrant the hidden button being added to the
|
|
* associated form.
|
|
*/
|
|
{
|
|
type !== 'button' && this.renderHiddenButton();
|
|
}
|
|
|
|
return (
|
|
<Host
|
|
onClick={this.handleClick}
|
|
aria-disabled={disabled ? 'true' : null}
|
|
class={createColorClasses(color, {
|
|
[theme]: true,
|
|
[buttonType]: true,
|
|
[`${buttonType}-${expand}`]: expand !== undefined,
|
|
[`${buttonType}-${size}`]: size !== undefined,
|
|
[`${buttonType}-${shape}`]: true,
|
|
[`${buttonType}-${fill}`]: true,
|
|
[`${buttonType}-strong`]: strong,
|
|
'in-toolbar': hostContext('ion-toolbar', this.el),
|
|
'in-toolbar-color': hostContext('ion-toolbar[color]', this.el),
|
|
'in-buttons': hostContext('ion-buttons', this.el),
|
|
'button-has-icon-only': hasIconOnly,
|
|
'button-disabled': disabled,
|
|
'ion-activatable': true,
|
|
'ion-focusable': true,
|
|
})}
|
|
>
|
|
<TagType
|
|
{...attrs}
|
|
class="button-native"
|
|
part="native"
|
|
disabled={disabled}
|
|
onFocus={this.onFocus}
|
|
onBlur={this.onBlur}
|
|
{...inheritedAttributes}
|
|
>
|
|
<span class="button-inner">
|
|
<slot name="icon-only" onSlotchange={this.slotChanged}></slot>
|
|
<slot name="start"></slot>
|
|
<slot></slot>
|
|
<slot name="end"></slot>
|
|
</span>
|
|
{theme === 'md' && <ion-ripple-effect type={this.rippleType}></ion-ripple-effect>}
|
|
</TagType>
|
|
</Host>
|
|
);
|
|
}
|
|
}
|