diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml index fe310a565a..f044cbd1db 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -9,24 +9,35 @@ jobs: runs-on: ubuntu-latest steps: - name: Validate PR title - uses: amannn/action-semantic-pull-request@v5 + if: | + !contains(github.event.pull_request.title, 'release') && + !contains(github.event.pull_request.title, 'chore') + uses: amannn/action-semantic-pull-request@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: # Configure that a scope must always be provided. requireScope: true + # Configure allowed commit types + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + revert + release + chore # Configure additional validation for the subject based on a regex. # This example ensures the subject doesn't start with an uppercase character. subjectPattern: ^(?![A-Z]).+$ - # If `subjectPattern` is configured, you can use this property to - # override the default error message that is shown when the pattern - # doesn't match. The variables `subject` and `title` can be used + # If `subjectPattern` is configured, you can use this property to + # override the default error message that is shown when the pattern + # doesn't match. The variables `subject` and `title` can be used # within the message. subjectPatternError: | The subject "{subject}" found in the pull request title "{title}" didn't match the configured pattern. Please ensure that the subject doesn't start with an uppercase character. - # If the PR contains one of these newline-delimited labels, the - # validation is skipped. If you want to rerun the validation when - # labels change, you might want to use the `labeled` and `unlabeled` - # event triggers in your workflow. - ignoreLabels: | - release diff --git a/CHANGELOG.md b/CHANGELOG.md index a19298210f..144a9ce9cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.6.4](https://github.com/ionic-team/ionic-framework/compare/v8.6.3...v8.6.4) (2025-07-09) + + +### Bug Fixes + +* **modal:** support iOS card view transitions for viewport changes ([#30520](https://github.com/ionic-team/ionic-framework/issues/30520)) ([0fd9e82](https://github.com/ionic-team/ionic-framework/commit/0fd9e824508333a53175d7da5f681fc3126a2394)), closes [#30296](https://github.com/ionic-team/ionic-framework/issues/30296) + + + + + ## [8.6.3](https://github.com/ionic-team/ionic-framework/compare/v8.6.2...v8.6.3) (2025-07-02) diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index bdb1d57042..3c8d4dca3f 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.6.4](https://github.com/ionic-team/ionic-framework/compare/v8.6.3...v8.6.4) (2025-07-09) + + +### Bug Fixes + +* **modal:** support iOS card view transitions for viewport changes ([#30520](https://github.com/ionic-team/ionic-framework/issues/30520)) ([0fd9e82](https://github.com/ionic-team/ionic-framework/commit/0fd9e824508333a53175d7da5f681fc3126a2394)), closes [#30296](https://github.com/ionic-team/ionic-framework/issues/30296) + + + + + ## [8.6.3](https://github.com/ionic-team/ionic-framework/compare/v8.6.2...v8.6.3) (2025-07-02) diff --git a/core/package-lock.json b/core/package-lock.json index e1893b34a0..2af538f7f8 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "8.6.3", + "version": "8.6.4", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/core/package.json b/core/package.json index 912f8add98..6e50a52641 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "8.6.3", + "version": "8.6.4", "description": "Base components for Ionic", "keywords": [ "ionic", diff --git a/core/src/components/item-sliding/test/async/item-sliding.e2e.ts b/core/src/components/item-sliding/test/async/item-sliding.e2e.ts index aa44b11292..ef3f5b8475 100644 --- a/core/src/components/item-sliding/test/async/item-sliding.e2e.ts +++ b/core/src/components/item-sliding/test/async/item-sliding.e2e.ts @@ -1,6 +1,9 @@ import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; +/** + * This behavior does not vary across modes/directions + */ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('item-sliding: async'), () => { test.beforeEach(async ({ page }) => { @@ -35,5 +38,85 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await expect(itemSlidingEl).toHaveClass(/item-sliding-active-slide/); }); + + // NOTE: This test uses the CDN version of Ionic. + // If this test fails, it is likely due to a regression in the published package. + test('should not throw errors when adding multiple items with side="end" using the Ionic CDN', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29499', + }); + + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + page.on('pageerror', (error) => { + errors.push(error.message); + }); + + // This issue only happens when using a CDN version of Ionic + // so we need to use the CDN by passing the `importIonicFromCDN` option + // to setContent. + await page.setContent( + ` + + + Item Sliding + + ADD ITEM + + + + + + + + + `, + { ...config, importIonicFromCDN: true } + ); + + // Click the button enough times to reproduce the issue + const addButton = page.locator('#addItem'); + await addButton.click(); + await addButton.click(); + await addButton.click(); + + await page.waitForChanges(); + + // Check that the items have been added + const items = page.locator('ion-item-sliding'); + expect(await items.count()).toBe(3); + + // Check that no errors have been logged + expect(errors.length).toBe(0); + }); }); }); diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts index 34940062dd..c79e8752e2 100644 --- a/core/src/components/modal/animations/ios.enter.ts +++ b/core/src/components/modal/animations/ios.enter.ts @@ -48,7 +48,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio } if (presentingEl) { - const isMobile = window.innerWidth < 768; + const isPortrait = window.innerWidth < 768; const hasCardModal = presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; const presentingElRoot = getElementRoot(presentingEl); @@ -61,7 +61,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio const bodyEl = document.body; - if (isMobile) { + if (isPortrait) { /** * Fallback for browsers that does not support `max()` (ex: Firefox) * No need to worry about statusbar padding since engines like Gecko diff --git a/core/src/components/modal/animations/ios.leave.ts b/core/src/components/modal/animations/ios.leave.ts index 914652878f..de543acaa5 100644 --- a/core/src/components/modal/animations/ios.leave.ts +++ b/core/src/components/modal/animations/ios.leave.ts @@ -35,7 +35,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio .addAnimation(wrapperAnimation); if (presentingEl) { - const isMobile = window.innerWidth < 768; + const isPortrait = window.innerWidth < 768; const hasCardModal = presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; const presentingElRoot = getElementRoot(presentingEl); @@ -61,7 +61,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio const bodyEl = document.body; - if (isMobile) { + if (isPortrait) { const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; const modalTransform = hasCardModal ? '-10px' : transformOffset; const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; diff --git a/core/src/components/modal/animations/ios.transition.ts b/core/src/components/modal/animations/ios.transition.ts new file mode 100644 index 0000000000..6ce2cd75e1 --- /dev/null +++ b/core/src/components/modal/animations/ios.transition.ts @@ -0,0 +1,198 @@ +import { createAnimation } from '@utils/animation/animation'; +import { getElementRoot } from '@utils/helpers'; + +import type { Animation } from '../../../interface'; +import { SwipeToCloseDefaults } from '../gestures/swipe-to-close'; +import type { ModalAnimationOptions } from '../modal-interface'; + +/** + * Transition animation from portrait view to landscape view + * This handles the case where a card modal is open in portrait view + * and the user switches to landscape view + */ +export const portraitToLandscapeTransition = ( + baseEl: HTMLElement, + opts: ModalAnimationOptions, + duration = 300 +): Animation => { + const { presentingEl } = opts; + + if (!presentingEl) { + // No transition needed for non-card modals + return createAnimation('portrait-to-landscape-transition'); + } + + const presentingElIsCardModal = + presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; + const presentingElRoot = getElementRoot(presentingEl); + const bodyEl = document.body; + + const baseAnimation = createAnimation('portrait-to-landscape-transition') + .addElement(baseEl) + .easing('cubic-bezier(0.32,0.72,0,1)') + .duration(duration); + + const presentingAnimation = createAnimation().beforeStyles({ + transform: 'translateY(0)', + 'transform-origin': 'top center', + overflow: 'hidden', + }); + + if (!presentingElIsCardModal) { + // The presenting element is not a card modal, so we do not + // need to care about layering and modal-specific styles. + const root = getElementRoot(baseEl); + const wrapperAnimation = createAnimation() + .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!) + .fromTo('opacity', '1', '1'); // Keep wrapper visible in landscape + + const backdropAnimation = createAnimation() + .addElement(root.querySelector('ion-backdrop')!) + .fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible + + // Animate presentingEl from portrait state back to normal + const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; + const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; + const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`; + + presentingAnimation + .addElement(presentingEl) + .afterStyles({ + transform: 'translateY(0px) scale(1)', + 'border-radius': '0px', + }) + .beforeAddWrite(() => bodyEl.style.setProperty('background-color', '')) + .fromTo('transform', fromTransform, 'translateY(0px) scale(1)') + .fromTo('filter', 'contrast(0.85)', 'contrast(1)') + .fromTo('border-radius', '10px 10px 0 0', '0px'); + + baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]); + } else { + // The presenting element is a card modal, so we do + // need to care about layering and modal-specific styles. + const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; + const fromTransform = `translateY(-10px) scale(${toPresentingScale})`; + const toTransform = `translateY(-10px) scale(${toPresentingScale})`; + + presentingAnimation + .addElement(presentingElRoot.querySelector('.modal-wrapper')!) + .afterStyles({ + transform: toTransform, + }) + .fromTo('transform', fromTransform, toTransform) + .fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card + + const shadowAnimation = createAnimation() + .addElement(presentingElRoot.querySelector('.modal-shadow')!) + .afterStyles({ + transform: toTransform, + }) + .fromTo('opacity', '0', '0') // Shadow stays hidden in landscape for card modals + .fromTo('transform', fromTransform, toTransform); + + baseAnimation.addAnimation([presentingAnimation, shadowAnimation]); + } + + return baseAnimation; +}; + +/** + * Transition animation from landscape view to portrait view + * This handles the case where a card modal is open in landscape view + * and the user switches to portrait view + */ +export const landscapeToPortraitTransition = ( + baseEl: HTMLElement, + opts: ModalAnimationOptions, + duration = 300 +): Animation => { + const { presentingEl } = opts; + + if (!presentingEl) { + // No transition needed for non-card modals + return createAnimation('landscape-to-portrait-transition'); + } + + const presentingElIsCardModal = + presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; + const presentingElRoot = getElementRoot(presentingEl); + const bodyEl = document.body; + + const baseAnimation = createAnimation('landscape-to-portrait-transition') + .addElement(baseEl) + .easing('cubic-bezier(0.32,0.72,0,1)') + .duration(duration); + + const presentingAnimation = createAnimation().beforeStyles({ + transform: 'translateY(0)', + 'transform-origin': 'top center', + overflow: 'hidden', + }); + + if (!presentingElIsCardModal) { + // The presenting element is not a card modal, so we do not + // need to care about layering and modal-specific styles. + const root = getElementRoot(baseEl); + const wrapperAnimation = createAnimation() + .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!) + .fromTo('opacity', '1', '1'); // Keep wrapper visible + + const backdropAnimation = createAnimation() + .addElement(root.querySelector('ion-backdrop')!) + .fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible + + // Animate presentingEl from normal state to portrait state + const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; + const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; + const toTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`; + + presentingAnimation + .addElement(presentingEl) + .beforeStyles({ + transform: 'translateY(0px) scale(1)', + 'transform-origin': 'top center', + overflow: 'hidden', + }) + .afterStyles({ + transform: toTransform, + 'border-radius': '10px 10px 0 0', + filter: 'contrast(0.85)', + overflow: 'hidden', + 'transform-origin': 'top center', + }) + .beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black')) + .keyframes([ + { offset: 0, transform: 'translateY(0px) scale(1)', filter: 'contrast(1)', borderRadius: '0px' }, + { offset: 0.2, transform: 'translateY(0px) scale(1)', filter: 'contrast(1)', borderRadius: '10px 10px 0 0' }, + { offset: 1, transform: toTransform, filter: 'contrast(0.85)', borderRadius: '10px 10px 0 0' }, + ]); + + baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]); + } else { + // The presenting element is also a card modal, so we need + // to handle layering and modal-specific styles. + const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; + const fromTransform = `translateY(-10px) scale(${toPresentingScale})`; + const toTransform = `translateY(-10px) scale(${toPresentingScale})`; + + presentingAnimation + .addElement(presentingElRoot.querySelector('.modal-wrapper')!) + .afterStyles({ + transform: toTransform, + }) + .fromTo('transform', fromTransform, toTransform) + .fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card + + const shadowAnimation = createAnimation() + .addElement(presentingElRoot.querySelector('.modal-shadow')!) + .afterStyles({ + transform: toTransform, + }) + .fromTo('opacity', '0', '0') // Shadow stays hidden + .fromTo('transform', fromTransform, toTransform); + + baseAnimation.addAnimation([presentingAnimation, shadowAnimation]); + } + + return baseAnimation; +}; diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 909b8fb630..46d176d101 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1,8 +1,8 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Method, Prop, State, Watch, h, writeTask } from '@stencil/core'; +import { Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h, writeTask } from '@stencil/core'; import { findIonContent, printIonContentErrorMsg } from '@utils/content'; import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate'; -import { raf, inheritAttributes, hasLazyBuild } from '@utils/helpers'; +import { raf, inheritAttributes, hasLazyBuild, getElementRoot } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; import { createLockController } from '@utils/lock-controller'; import { printIonWarning } from '@utils/logging'; @@ -37,11 +37,12 @@ import type { OverlayEventDetail } from '../../utils/overlays-interface'; import { iosEnterAnimation } from './animations/ios.enter'; import { iosLeaveAnimation } from './animations/ios.leave'; +import { portraitToLandscapeTransition, landscapeToPortraitTransition } from './animations/ios.transition'; import { mdEnterAnimation } from './animations/md.enter'; import { mdLeaveAnimation } from './animations/md.leave'; import type { MoveSheetToBreakpointOptions } from './gestures/sheet'; import { createSheetGesture } from './gestures/sheet'; -import { createSwipeToCloseGesture } from './gestures/swipe-to-close'; +import { createSwipeToCloseGesture, SwipeToCloseDefaults } from './gestures/swipe-to-close'; import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from './modal-interface'; import { setCardStatusBarDark, setCardStatusBarDefault } from './utils'; @@ -92,6 +93,11 @@ export class Modal implements ComponentInterface, OverlayInterface { // Whether or not modal is being dismissed via gesture private gestureAnimationDismissing = false; + // View transition properties for handling portrait/landscape switches + private currentViewIsPortrait?: boolean; + private viewTransitionAnimation?: Animation; + private resizeTimeout?: any; + lastFocus?: HTMLElement; animation?: Animation; @@ -263,6 +269,19 @@ export class Modal implements ComponentInterface, OverlayInterface { } } + @Listen('resize', { target: 'window' }) + onWindowResize() { + // Only handle resize for iOS card modals when no custom animations are provided + if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) { + return; + } + + clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => { + this.handleViewTransition(); + }, 50); // Debounce to avoid excessive calls during active resizing + } + /** * If `true`, the component passed into `ion-modal` will * automatically be mounted when the modal is created. The @@ -389,6 +408,7 @@ export class Modal implements ComponentInterface, OverlayInterface { disconnectedCallback() { this.triggerController.removeClickListener(); + this.cleanupViewTransitionListener(); } componentWillLoad() { @@ -631,6 +651,9 @@ export class Modal implements ComponentInterface, OverlayInterface { this.initSwipeToClose(); } + // Initialize view transition listener for iOS card modals + this.initViewTransitionListener(); + unlock(); } @@ -830,6 +853,7 @@ export class Modal implements ComponentInterface, OverlayInterface { if (this.gesture) { this.gesture.destroy(); } + this.cleanupViewTransitionListener(); } this.currentBreakpoint = undefined; this.animation = undefined; @@ -993,6 +1017,134 @@ export class Modal implements ComponentInterface, OverlayInterface { } }; + private initViewTransitionListener() { + // Only enable for iOS card modals when no custom animations are provided + if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) { + return; + } + + // Set initial view state + this.currentViewIsPortrait = window.innerWidth < 768; + } + + private handleViewTransition() { + const isPortrait = window.innerWidth < 768; + + // Only transition if view state actually changed + if (this.currentViewIsPortrait === isPortrait) { + return; + } + + // Cancel any ongoing transition animation + if (this.viewTransitionAnimation) { + this.viewTransitionAnimation.destroy(); + this.viewTransitionAnimation = undefined; + } + + const { presentingElement } = this; + if (!presentingElement) { + return; + } + + // Create transition animation + let transitionAnimation: Animation; + if (this.currentViewIsPortrait && !isPortrait) { + // Portrait to landscape transition + transitionAnimation = portraitToLandscapeTransition(this.el, { + presentingEl: presentingElement, + currentBreakpoint: this.currentBreakpoint, + backdropBreakpoint: this.backdropBreakpoint, + expandToScroll: this.expandToScroll, + }); + } else { + // Landscape to portrait transition + transitionAnimation = landscapeToPortraitTransition(this.el, { + presentingEl: presentingElement, + currentBreakpoint: this.currentBreakpoint, + backdropBreakpoint: this.backdropBreakpoint, + expandToScroll: this.expandToScroll, + }); + } + + // Update state and play animation + this.currentViewIsPortrait = isPortrait; + this.viewTransitionAnimation = transitionAnimation; + + transitionAnimation.play().then(() => { + this.viewTransitionAnimation = undefined; + + // After orientation transition, recreate the swipe-to-close gesture + // with updated animation that reflects the new presenting element state + this.reinitSwipeToClose(); + }); + } + + private cleanupViewTransitionListener() { + // Clear any pending resize timeout + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = undefined; + } + + if (this.viewTransitionAnimation) { + this.viewTransitionAnimation.destroy(); + this.viewTransitionAnimation = undefined; + } + } + + private reinitSwipeToClose() { + // Only reinitialize if we have a presenting element and are on iOS + if (getIonMode(this) !== 'ios' || !this.presentingElement) { + return; + } + + // Clean up existing gesture and animation + if (this.gesture) { + this.gesture.destroy(); + this.gesture = undefined; + } + + if (this.animation) { + // Properly end the progress-based animation at initial state before destroying + // to avoid leaving modal in intermediate swipe position + this.animation.progressEnd(0, 0, 0); + this.animation.destroy(); + this.animation = undefined; + } + + // Force the modal back to the correct position or it could end up + // in a weird state after destroying the animation + raf(() => { + this.ensureCorrectModalPosition(); + this.initSwipeToClose(); + }); + } + + private ensureCorrectModalPosition() { + const { el, presentingElement } = this; + const root = getElementRoot(el); + + const wrapperEl = root.querySelector('.modal-wrapper') as HTMLElement | null; + if (wrapperEl) { + wrapperEl.style.transform = 'translateY(0vh)'; + wrapperEl.style.opacity = '1'; + } + + if (presentingElement) { + const isPortrait = window.innerWidth < 768; + + if (isPortrait) { + const transformOffset = !CSS.supports('width', 'max(0px, 1px)') + ? '30px' + : 'max(30px, var(--ion-safe-area-top))'; + const scale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; + presentingElement.style.transform = `translateY(${transformOffset}) scale(${scale})`; + } else { + presentingElement.style.transform = 'translateY(0px) scale(1)'; + } + } + } + render() { const { handle, diff --git a/core/src/utils/test/playwright/page/utils/set-content.ts b/core/src/utils/test/playwright/page/utils/set-content.ts index b6417d5f3b..1231340f59 100644 --- a/core/src/utils/test/playwright/page/utils/set-content.ts +++ b/core/src/utils/test/playwright/page/utils/set-content.ts @@ -1,5 +1,5 @@ import type { Page, TestInfo } from '@playwright/test'; -import type { E2EPageOptions, Mode, Direction, Theme, Palette } from '@utils/test/playwright'; +import type { Direction, E2EPageOptions, Mode, Palette, Theme } from '@utils/test/playwright'; /** * Overwrites the default Playwright page.setContent method. @@ -36,6 +36,30 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o const baseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL; + // The Ionic bundle is included locally by default unless the test + // config passes in the importIonicFromCDN option. This is useful + // when testing with the CDN version of Ionic. + let ionicCSSImports = theme === 'ionic' ? ` + + ` : ` + + `; + let ionicJSImports = ` + + `; + + if (options?.importIonicFromCDN) { + ionicCSSImports = theme === 'ionic' ? ` + + ` : ` + + `; + ionicJSImports = ` + + + `; + } + const output = ` @@ -43,15 +67,11 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o Ionic Playwright Test - ${ - theme === 'ionic' - ? `` - : `` - } + ${ionicCSSImports} ${palette !== 'light' ? `` : ''} - + ${ionicJSImports}