feat(modal): add shape prop & styling for ionic theme (#29853)

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?
<!-- Please describe the current behavior that you are modifying. -->

No shape prop or ionic theme styling

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Adds `shape` prop to `ion-modal`
- Adds styling for shape in ionic theme

## 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:
Tanner Reits
2024-09-11 22:30:12 -04:00
committed by GitHub
parent 1841b59e44
commit 60e6b3143e
31 changed files with 290 additions and 2 deletions

View File

@ -1395,6 +1395,7 @@ ion-modal,prop,keyboardClose,boolean,true,false,false
ion-modal,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false ion-modal,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-modal,prop,mode,"ios" | "md",undefined,false,false ion-modal,prop,mode,"ios" | "md",undefined,false,false
ion-modal,prop,presentingElement,HTMLElement | undefined,undefined,false,false ion-modal,prop,presentingElement,HTMLElement | undefined,undefined,false,false
ion-modal,prop,shape,"rectangular" | "round" | "soft" | undefined,undefined,false,false
ion-modal,prop,showBackdrop,boolean,true,false,false ion-modal,prop,showBackdrop,boolean,true,false,false
ion-modal,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-modal,prop,theme,"ios" | "md" | "ionic",undefined,false,false
ion-modal,prop,trigger,string | undefined,undefined,false,false ion-modal,prop,trigger,string | undefined,undefined,false,false

View File

@ -2097,6 +2097,10 @@ export namespace Components {
* Move a sheet style modal to a specific breakpoint. The breakpoint value must be a value defined in your `breakpoints` array. * Move a sheet style modal to a specific breakpoint. The breakpoint value must be a value defined in your `breakpoints` array.
*/ */
"setCurrentBreakpoint": (breakpoint: number) => Promise<void>; "setCurrentBreakpoint": (breakpoint: number) => Promise<void>;
/**
* Set to `"soft"` for a modal with slightly rounded corners, `"round"` for a modal with fully rounded corners, or `"rectangular"` for a modal without rounded corners. Defaults to `"round"` for the `ionic` theme, undefined for all other themes.
*/
"shape"?: 'soft' | 'round' | 'rectangular';
/** /**
* If `true`, a backdrop will be displayed behind the modal. This property controls whether or not the backdrop darkens the screen when the modal is presented. It does not control whether or not the backdrop is active or present in the DOM. * If `true`, a backdrop will be displayed behind the modal. This property controls whether or not the backdrop darkens the screen when the modal is presented. It does not control whether or not the backdrop is active or present in the DOM.
*/ */
@ -7431,6 +7435,10 @@ declare namespace LocalJSX {
* The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode. * The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode.
*/ */
"presentingElement"?: HTMLElement; "presentingElement"?: HTMLElement;
/**
* Set to `"soft"` for a modal with slightly rounded corners, `"round"` for a modal with fully rounded corners, or `"rectangular"` for a modal without rounded corners. Defaults to `"round"` for the `ionic` theme, undefined for all other themes.
*/
"shape"?: 'soft' | 'round' | 'rectangular';
/** /**
* If `true`, a backdrop will be displayed behind the modal. This property controls whether or not the backdrop darkens the screen when the modal is presented. It does not control whether or not the backdrop is active or present in the DOM. * If `true`, a backdrop will be displayed behind the modal. This property controls whether or not the backdrop darkens the screen when the modal is presented. It does not control whether or not the backdrop is active or present in the DOM.
*/ */

View File

@ -0,0 +1,19 @@
@use "../../themes/ionic/ionic.globals.scss" as globals;
@import "./modal";
// Ionic Modal
// --------------------------------------------------
// Shape
// -------------------------------------
:host(.modal-round) {
--border-radius: #{globals.$ionic-border-radius-1000};
}
:host(.modal-soft) {
--border-radius: #{globals.$ionic-border-radius-400};
}
:host(.modal-rectangular) {
--border-radius: #{globals.$ionic-border-radius-0};
}

View File

@ -62,7 +62,7 @@ import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
styleUrls: { styleUrls: {
ios: 'modal.ios.scss', ios: 'modal.ios.scss',
md: 'modal.md.scss', md: 'modal.md.scss',
ionic: 'modal.md.scss', ionic: 'modal.ionic.scss',
}, },
shadow: true, shadow: true,
}) })
@ -291,6 +291,15 @@ export class Modal implements ComponentInterface, OverlayInterface {
*/ */
@Prop() canDismiss: boolean | ((data?: any, role?: string) => Promise<boolean>) = true; @Prop() canDismiss: boolean | ((data?: any, role?: string) => Promise<boolean>) = true;
/**
* Set to `"soft"` for a modal with slightly rounded corners,
* `"round"` for a modal with fully rounded corners, or `"rectangular"`
* for a modal without rounded corners.
*
* Defaults to `"round"` for the `ionic` theme, undefined for all other themes.
*/
@Prop() shape?: 'soft' | 'round' | 'rectangular';
/** /**
* Emitted after the modal has presented. * Emitted after the modal has presented.
*/ */
@ -888,6 +897,22 @@ export class Modal implements ComponentInterface, OverlayInterface {
return true; return true;
} }
private getShape(): string | undefined {
const theme = getIonTheme(this);
const { shape } = this;
// TODO(ROU-11167): Remove theme check when shapes are defined for all themes.
if (theme !== 'ionic') {
return undefined;
}
if (shape === undefined) {
return 'round';
}
return shape;
}
private onHandleClick = () => { private onHandleClick = () => {
const { sheetTransition, handleBehavior } = this; const { sheetTransition, handleBehavior } = this;
if (handleBehavior !== 'cycle' || sheetTransition !== undefined) { if (handleBehavior !== 'cycle' || sheetTransition !== undefined) {
@ -936,6 +961,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
const theme = getIonTheme(this); const theme = getIonTheme(this);
const isCardModal = presentingElement !== undefined && theme === 'ios'; const isCardModal = presentingElement !== undefined && theme === 'ios';
const isHandleCycle = handleBehavior === 'cycle'; const isHandleCycle = handleBehavior === 'cycle';
const shape = this.getShape();
return ( return (
<Host <Host
@ -950,6 +976,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
['modal-default']: !isCardModal && !isSheetModal, ['modal-default']: !isCardModal && !isSheetModal,
[`modal-card`]: isCardModal, [`modal-card`]: isCardModal,
[`modal-sheet`]: isSheetModal, [`modal-sheet`]: isSheetModal,
[`modal-${shape}`]: shape !== undefined,
'overlay-hidden': true, 'overlay-hidden': true,
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false, [FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false,
...getClassMap(this.cssClass), ...getClassMap(this.cssClass),

View File

@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Modal - Shape</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>
<style>
ion-modal {
--box-shadow: 0px 0px 30px 10px rgba(0, 0, 0, 0.1);
}
.container {
margin-bottom: 20px;
}
</style>
</head>
<script type="module">
import { modalController } from '../../../../dist/ionic/index.esm.js';
window.modalController = modalController;
</script>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Modal - Shape</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding" id="content" no-bounce>
<div class="container">
<h1>Sheet</h1>
<button id="sheet-modal-default" onclick="presentSheetModal()">Present Sheet Modal (Default)</button>
<button id="sheet-modal-round" onclick="presentSheetModal({ shape: 'round' })">
Present Sheet Modal (Round)
</button>
<button id="sheet-modal-soft" onclick="presentSheetModal({ shape: 'soft' })">
Present Sheet Modal (Soft)
</button>
<button id="sheet-modal-rectangular" onclick="presentSheetModal({ shape: 'rectangular' })">
Present Sheet Modal (Rectangular)
</button>
</div>
<div class="container">
<h1>Card</h1>
<button id="card-modal-default" onclick="presentCardModal()">Present Card Modal (Default)</button>
<button id="card-modal-round" onclick="presentCardModal({ shape: 'round' })">
Present Card Modal (Round)
</button>
<button id="card-modal-soft" onclick="presentCardModal({ shape: 'soft' })">Present Card Modal (Soft)</button>
<button id="card-modal-rectangular" onclick="presentCardModal({ shape: 'rectangular' })">
Present Card Modal (Rectangular)
</button>
</div>
</ion-content>
</ion-app>
</body>
<script>
function renderContent() {
let items = '';
for (var i = 0; i < 25; i++) {
items += `<ion-item>Item ${i}</ion-item>`;
}
return items;
}
async function presentSheetModal(options) {
const modal = await createSheetModal(options);
await modal.present();
await modal.onDidDismiss();
modal.remove();
}
async function createSheetModal(options) {
// create component to open
const element = document.createElement('div');
element.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title>Super Modal</ion-title>
<ion-buttons slot="end">
<ion-button class="dismiss">Dismiss Modal</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
${renderContent()}
</ion-list>
</ion-content>
`;
let extraOptions = {
initialBreakpoint: 0.75,
breakpoints: [0, 0.25, 0.5, 0.75, 1],
};
if (options) {
extraOptions = {
...extraOptions,
...options,
};
}
// present the modal
const modalElement = Object.assign(document.createElement('ion-modal'), {
component: element,
...extraOptions,
});
// listen for close event
const button = element.querySelector('ion-button');
button.addEventListener('click', () => {
modalElement.dismiss();
});
document.body.appendChild(modalElement);
return modalElement;
}
async function presentCardModal(opts) {
const modal = await createCardModal(document.querySelectorAll('.ion-page')[1], opts);
await modal.present();
}
async function createCardModal(presentingEl, opts) {
// create component to open
const element = document.createElement('div');
element.innerHTML = `
<ion-header id="modal-header">
<ion-toolbar>
<ion-buttons slot="end">
<ion-button class="dismiss">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<div class="content-wrapper">${renderContent()}</div>
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
`;
// listen for close event
const button = element.querySelector('ion-button.dismiss');
button.addEventListener('click', () => {
modalController.dismiss();
});
// present the modal
const modalElement = await modalController.create({
presentingElement: presentingEl,
component: element,
...opts,
});
return modalElement;
}
</script>
</html>

View File

@ -0,0 +1,57 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not vary across directions.
*/
configs({ directions: ['ltr'], modes: ['ionic-md'] }).forEach(({ config, screenshot, title }) => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/modal/test/shape', config);
});
test.describe(title('modal: shape'), () => {
test.describe('sheet', () => {
['default', 'round', 'soft', 'rectangular'].forEach((shape) => {
test(`${shape} - should not have visual regressions`, async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click(`#sheet-modal-${shape}`);
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-shape-sheet-${shape}`), {
/**
* Animations must be enabled to capture the screenshot.
* By default, animations are disabled with toHaveScreenshot,
* and when capturing the screenshot will call animation.finish().
* This will cause the modal to close and the screenshot capture
* to be invalid.
*/
animations: 'allow',
});
});
});
});
test.describe('card', () => {
['default', 'round', 'soft', 'rectangular'].forEach((shape) => {
test(`${shape} - should not have visual regressions`, async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click(`#card-modal-${shape}`);
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-shape-card-${shape}`), {
/**
* Animations must be enabled to capture the screenshot.
* By default, animations are disabled with toHaveScreenshot,
* and when capturing the screenshot will call animation.finish().
* This will cause the popover to close and the screenshot capture
* to be invalid.
*/
animations: 'allow',
});
});
});
});
});
});

View File

@ -27,7 +27,7 @@ export const IonPickerLegacy = /*@__PURE__*/ defineOverlayContainer<JSX.IonPicke
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', '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', 'showBackdrop', 'theme', 'trigger'], true); 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);
export const IonPopover = /*@__PURE__*/ defineOverlayContainer<JSX.IonPopover>('ion-popover', defineIonPopoverCustomElement, ['alignment', 'animated', 'arrow', 'backdropDismiss', 'component', 'componentProps', 'dismissOnSelect', 'enterAnimation', 'event', 'focusTrap', 'htmlAttributes', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'reference', 'showBackdrop', 'side', 'size', 'theme', 'translucent', 'trigger', 'triggerAction']); export const IonPopover = /*@__PURE__*/ defineOverlayContainer<JSX.IonPopover>('ion-popover', defineIonPopoverCustomElement, ['alignment', 'animated', 'arrow', 'backdropDismiss', 'component', 'componentProps', 'dismissOnSelect', 'enterAnimation', 'event', 'focusTrap', 'htmlAttributes', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'reference', 'showBackdrop', 'side', 'size', 'theme', 'translucent', 'trigger', 'triggerAction']);