feat(toast): implement new shapes (#29936)

Issue number: internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
The shape of the toast component could not be customized.

## What is the new behavior?
- I have introduced a new shape property which determines whether the
toast widget has soft, round or rectangular corners.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
This commit is contained in:
Pedro Lourenço
2024-10-18 18:26:08 +01:00
committed by GitHub
parent 15d6104c6f
commit d8bdf398fc
18 changed files with 199 additions and 6 deletions

View File

@ -2376,6 +2376,7 @@ ion-toast,prop,message,IonicSafeString | string | undefined,undefined,false,fals
ion-toast,prop,mode,"ios" | "md",undefined,false,false
ion-toast,prop,position,"bottom" | "middle" | "top",'bottom',false,false
ion-toast,prop,positionAnchor,HTMLElement | string | undefined,undefined,false,false
ion-toast,prop,shape,"rectangular" | "round" | "soft" | undefined,undefined,false,false
ion-toast,prop,swipeGesture,"vertical" | undefined,undefined,false,false
ion-toast,prop,theme,"ios" | "md" | "ionic",undefined,false,false
ion-toast,prop,translucent,boolean,false,false,false

View File

@ -3771,6 +3771,10 @@ export namespace Components {
* Present the toast overlay after it has been created.
*/
"present": () => Promise<void>;
/**
* Set to `"soft"` for a toast with slightly rounded corners, `"round"` for a toast with fully rounded corners, or `"rectangular"` for a toast without rounded corners. Defaults to `"round"` for the `ionic` theme, undefined for all other themes.
*/
"shape"?: 'soft' | 'round' | 'rectangular';
/**
* If set to 'vertical', the Toast can be dismissed with a swipe gesture. The swipe direction is determined by the value of the `position` property: `top`: The Toast can be swiped up to dismiss. `bottom`: The Toast can be swiped down to dismiss. `middle`: The Toast can be swiped up or down to dismiss.
*/
@ -9161,6 +9165,10 @@ declare namespace LocalJSX {
* The element to anchor the toast's position to. Can be set as a direct reference or the ID of the element. With `position="bottom"`, the toast will sit above the chosen element. With `position="top"`, the toast will sit below the chosen element. With `position="middle"`, the value of `positionAnchor` is ignored.
*/
"positionAnchor"?: HTMLElement | string;
/**
* Set to `"soft"` for a toast with slightly rounded corners, `"round"` for a toast with fully rounded corners, or `"rectangular"` for a toast without rounded corners. Defaults to `"round"` for the `ionic` theme, undefined for all other themes.
*/
"shape"?: 'soft' | 'round' | 'rectangular';
/**
* If set to 'vertical', the Toast can be dismissed with a swipe gesture. The swipe direction is determined by the value of the `position` property: `top`: The Toast can be swiped up to dismiss. `bottom`: The Toast can be swiped down to dismiss. `middle`: The Toast can be swiped up or down to dismiss.
*/

View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Toast - Shape</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<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>
<script type="module">
import { toastController } from '../../../../dist/ionic/index.esm.js';
window.toastController = toastController;
</script>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Toast - Shape</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding" id="content">
(Only available for ionic theme)
<button class="expand" id="default-toast" onclick="openToast({ message: 'Hello, world!', duration: 2000 })">
Default Toast
</button>
<button
class="expand"
id="soft-shape-toast"
onclick="openToast({ message: 'Hello, world!', duration: 2000, shape: 'soft' })"
>
Soft Toast
</button>
<button
class="expand"
id="round-shape-toast"
onclick="openToast({ message: 'Hello, world!', duration: 2000, shape: 'round' })"
>
Round Toast
</button>
<button
class="expand"
id="rect-shape-toast"
onclick="openToast({ message: 'Hello, world!', duration: 2000, shape: 'rectangular' })"
>
Rectangular Toast
</button>
</ion-content>
</ion-app>
<script>
async function openToast(opts) {
const toast = await toastController.create(opts);
await toast.present();
}
</script>
</body>
</html>

View File

@ -0,0 +1,78 @@
import { expect, type Locator } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import type { E2EPage, E2EPageOptions, ScreenshotFn, EventSpy } from '@utils/test/playwright';
class ToastFixture {
readonly page: E2EPage;
private ionToastDidPresent!: EventSpy;
constructor(page: E2EPage) {
this.page = page;
}
async goto(config: E2EPageOptions) {
const { page } = this;
await page.goto(`/src/components/toast/test/shape`, config);
this.ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
}
async openToast(selector: string) {
const { page, ionToastDidPresent } = this;
const button = page.locator(selector);
await button.click();
await ionToastDidPresent.next();
return {
toast: page.locator('ion-toast'),
container: page.locator('ion-toast .toast-container'),
};
}
async screenshot(screenshotModifier: string, screenshot: ScreenshotFn, el?: Locator) {
const { page } = this;
const screenshotString = screenshot(`toast-${screenshotModifier}`);
if (el === undefined) {
await expect(page).toHaveScreenshot(screenshotString);
} else {
await expect(el).toHaveScreenshot(screenshotString);
}
}
}
/**
* This behavior does not vary across directions.
*/
configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ config, screenshot, title }) => {
test.describe(title('toast: shape'), () => {
let toastFixture: ToastFixture;
test.beforeEach(async ({ page }) => {
toastFixture = new ToastFixture(page);
await toastFixture.goto(config);
});
test('should render the default toast', async () => {
await toastFixture.openToast('#default-toast');
await toastFixture.screenshot('shape-round', screenshot);
});
test('should render a soft toast', async () => {
await toastFixture.openToast('#soft-shape-toast');
await toastFixture.screenshot('shape-soft', screenshot);
});
test('should render a round toast', async () => {
await toastFixture.openToast('#round-shape-toast');
await toastFixture.screenshot('shape-round', screenshot);
});
test('should render a rectangular toast', async () => {
await toastFixture.openToast('#rect-shape-toast');
await toastFixture.screenshot('shape-rectangular', screenshot);
});
});
});

View File

@ -6,7 +6,6 @@
:host {
--background: #{globals.$ionic-color-neutral-1200};
--border-radius: #{globals.$ionic-border-radius-400};
--box-shadow: #{globals.$ionic-elevation-400};
--button-color: #{globals.$ionic-color-base-white};
--color: #{globals.$ionic-color-base-white};
@ -36,6 +35,21 @@
@include globals.padding(globals.$ionic-space-300, globals.$ionic-space-400);
}
// Toast Shapes
// --------------------------------------------------
:host(.toast-shape-soft) {
--border-radius: #{globals.$ionic-border-radius-200};
}
:host(.toast-shape-round) {
--border-radius: #{globals.$ionic-border-radius-400};
}
:host(.toast-shape-rectangular) {
--border-radius: #{globals.$ionic-border-radius-0};
}
// Toast Header
// --------------------------------------------------

View File

@ -172,6 +172,15 @@ export class Toast implements ComponentInterface, OverlayInterface {
*/
@Prop() positionAnchor?: HTMLElement | string;
/**
* Set to `"soft"` for a toast with slightly rounded corners,
* `"round"` for a toast with fully rounded corners, or `"rectangular"`
* for a toast without rounded corners.
*
* Defaults to `"round"` for the `ionic` theme, undefined for all other themes.
*/
@Prop() shape?: 'soft' | 'round' | 'rectangular';
/**
* An array of buttons for the toast.
*/
@ -484,6 +493,21 @@ export class Toast implements ComponentInterface, OverlayInterface {
return buttons;
}
private getShape(): string | undefined {
const { shape } = this;
// TODO(ROU-11300): Remove theme check when shapes are defined for all themes.
if (getIonTheme(this) !== 'ionic') {
return undefined;
}
if (shape === undefined) {
return 'round';
}
return shape;
}
/**
* Returns the element specified by the positionAnchor prop,
* or undefined if prop's value is an ID string and the element
@ -696,6 +720,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
const startButtons = allButtons.filter((b) => b.side === 'start');
const endButtons = allButtons.filter((b) => b.side !== 'start');
const theme = getIonTheme(this);
const shape = this.getShape();
const wrapperClass = {
'toast-wrapper': true,
[`toast-${this.position}`]: true,
@ -725,6 +750,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
...getClassMap(this.cssClass),
'overlay-hidden': true,
'toast-translucent': this.translucent,
[`toast-shape-${shape}`]: shape !== undefined,
})}
onIonToastWillDismiss={this.dispatchCancelHandler}
>

View File

@ -2341,7 +2341,7 @@ export declare interface IonTitle extends Components.IonTitle {}
@ProxyCmp({
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'theme', 'translucent', 'trigger'],
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'shape', 'swipeGesture', 'theme', 'translucent', 'trigger'],
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
})
@Component({
@ -2349,7 +2349,7 @@ export declare interface IonTitle extends Components.IonTitle {}
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'theme', 'translucent', 'trigger'],
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'shape', 'swipeGesture', 'theme', 'translucent', 'trigger'],
})
export class IonToast {
protected el: HTMLElement;

View File

@ -2092,7 +2092,7 @@ export declare interface IonTitle extends Components.IonTitle {}
@ProxyCmp({
defineCustomElementFn: defineIonToast,
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'theme', 'translucent', 'trigger'],
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'shape', 'swipeGesture', 'theme', 'translucent', 'trigger'],
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
})
@Component({
@ -2100,7 +2100,7 @@ export declare interface IonTitle extends Components.IonTitle {}
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'theme', 'translucent', 'trigger'],
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'shape', 'swipeGesture', 'theme', 'translucent', 'trigger'],
standalone: true
})
export class IonToast {

View File

@ -25,7 +25,7 @@ export const IonLoading = /*@__PURE__*/ defineOverlayContainer<JSX.IonLoading>('
export const IonPickerLegacy = /*@__PURE__*/ defineOverlayContainer<JSX.IonPickerLegacy>('ion-picker-legacy', defineIonPickerLegacyCustomElement, ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'htmlAttributes', 'isOpen', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop', 'theme', 'trigger']);
export const IonToast = /*@__PURE__*/ defineOverlayContainer<JSX.IonToast>('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'theme', 'translucent', 'trigger']);
export const IonToast = /*@__PURE__*/ defineOverlayContainer<JSX.IonToast>('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'shape', 'swipeGesture', 'theme', 'translucent', 'trigger']);
export const IonModal = /*@__PURE__*/ defineOverlayContainer<JSX.IonModal>('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'focusTrap', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'shape', 'showBackdrop', 'theme', 'trigger'], true);