diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 1fddfeb7fa0..d98254053da 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,7 +15,7 @@ jobs: security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + - uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: javascript - - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + - uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index c50200accd0..a1615d78b77 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. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **angular:** import tab proxies directly in standalone tabs ([#31596](https://github.com/ionic-team/ionic-framework/issues/31596)) ([cb963f8](https://github.com/ionic-team/ionic-framework/commit/cb963f886ee08ce3abf25d6cb4e33c5cc3ac0003)), closes [#31594](https://github.com/ionic-team/ionic-framework/issues/31594) +* **datetime:** discard unconfirmed selection on cancel ([#31558](https://github.com/ionic-team/ionic-framework/issues/31558)) ([cecd3af](https://github.com/ionic-team/ionic-framework/commit/cecd3afdaea91a2e5f4ada6cd4aaedf6f436e6c9)), closes [#30777](https://github.com/ionic-team/ionic-framework/issues/30777) [#27975](https://github.com/ionic-team/ionic-framework/issues/27975) [#27976](https://github.com/ionic-team/ionic-framework/issues/27976) +* **input, select, textarea:** prevent floating label from overflowing while it floats ([#31567](https://github.com/ionic-team/ionic-framework/issues/31567)) ([ecd6f5a](https://github.com/ionic-team/ionic-framework/commit/ecd6f5a7c0ed987f4357b7a0e3954d9291be170d)) +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) +* **vue-router:** keep back working after replacing a page in tabs ([#31583](https://github.com/ionic-team/ionic-framework/issues/31583)) ([86390f6](https://github.com/ionic-team/ionic-framework/commit/86390f6deab23b58498c3b9ae782f9e0fc5a4e29)), closes [#30617](https://github.com/ionic-team/ionic-framework/issues/30617) [#31142](https://github.com/ionic-team/ionic-framework/issues/31142) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 84405878558..57a6c7960d3 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **datetime:** discard unconfirmed selection on cancel ([#31558](https://github.com/ionic-team/ionic-framework/issues/31558)) ([cecd3af](https://github.com/ionic-team/ionic-framework/commit/cecd3afdaea91a2e5f4ada6cd4aaedf6f436e6c9)), closes [#30777](https://github.com/ionic-team/ionic-framework/issues/30777) [#27975](https://github.com/ionic-team/ionic-framework/issues/27975) [#27976](https://github.com/ionic-team/ionic-framework/issues/27976) +* **input, select, textarea:** prevent floating label from overflowing while it floats ([#31567](https://github.com/ionic-team/ionic-framework/issues/31567)) ([ecd6f5a](https://github.com/ionic-team/ionic-framework/commit/ecd6f5a7c0ed987f4357b7a0e3954d9291be170d)) +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/core/package-lock.json b/core/package-lock.json index 14b7aad5b32..9efcb746452 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/core", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -29,7 +29,7 @@ "@stencil/angular-output-target": "^1.4.1", "@stencil/react-output-target": "^1.6.2", "@stencil/sass": "^3.0.9", - "@stencil/vue-output-target": "0.14.2", + "@stencil/vue-output-target": "0.14.6", "@types/jest": "^29.5.6", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -1829,13 +1829,13 @@ } }, "node_modules/@stencil/angular-output-target": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@stencil/angular-output-target/-/angular-output-target-1.4.1.tgz", - "integrity": "sha512-sy+k9B5jcT6hpaVU9n+/ZUjz3qVr9PAdPgy2c3LFQouW1aFSa3Ng0O+Ia3wk7LMeL3vDetCn2X+p2ewdHGc1vA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@stencil/angular-output-target/-/angular-output-target-1.5.2.tgz", + "integrity": "sha512-QzVH3d26mHgEbyVoFOzE5vAOKWSBl7t7VV2npK0LhF0JtdorYqEVWkV7sRyV80j/xodptB50wbWZZ5hQA1VVwg==", "dev": true, "license": "MIT", "peerDependencies": { - "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-alpha.17 <5.0.0-next.0" + "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-beta.18" } }, "node_modules/@stencil/core": { @@ -1903,13 +1903,13 @@ } }, "node_modules/@stencil/vue-output-target": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/@stencil/vue-output-target/-/vue-output-target-0.14.2.tgz", - "integrity": "sha512-KF09dlRXvieXYEW7MnvnUoAWnQKm3JLori9UiB4xoTdJQeI987nRx5N5zzci+L3j4Ta+932D6+pJuWvHZJb+jA==", + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/@stencil/vue-output-target/-/vue-output-target-0.14.6.tgz", + "integrity": "sha512-IkgRf6UjoZg83CWguSxiRMqNLIRI29C0SBDj3m6OuB5ShVDqt2idV+kROdHhzR9DQFEPbKZqp7XNJIfrYN1zhg==", "dev": true, "license": "MIT", "peerDependencies": { - "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-alpha.17 <5.0.0-next.0", + "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-beta.18", "vue": "^3.4.38", "vue-router": "^4.5.0 || ^5.0.0" }, diff --git a/core/package.json b/core/package.json index 6554c74ccd2..51a57c9994b 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "9.0.8", + "version": "9.0.9", "description": "Base components for Ionic", "engines": { "node": ">= 16" @@ -86,7 +86,7 @@ "@stencil/angular-output-target": "^1.4.1", "@stencil/react-output-target": "^1.6.2", "@stencil/sass": "^3.0.9", - "@stencil/vue-output-target": "0.14.2", + "@stencil/vue-output-target": "0.14.6", "@types/jest": "^29.5.6", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/core/src/components.d.ts b/core/src/components.d.ts index cf24c5fcd4f..8ea80823481 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -392,7 +392,7 @@ export namespace Components { */ "tappable": boolean; /** - * If `true`, the backdrop will be visible. + * If `true`, the backdrop will be visible. This property controls whether or not the backdrop's default background is shown. It does not control whether or not the backdrop blocks interaction, and it does not override a custom background set on `ion-backdrop`. * @default true */ "visible": boolean; @@ -912,7 +912,7 @@ export namespace Components { } interface IonDatetime { /** - * Emits the ionCancel event and optionally closes the popover or modal that the datetime was presented in. + * Emits the ionCancel event, discards any unconfirmed selection, and optionally closes the popover or modal that the datetime was presented in. * @param closeOverlay If `true`, closes the parent overlay. Defaults to `false`. */ "cancel": (closeOverlay?: boolean) => Promise; @@ -5512,7 +5512,7 @@ declare namespace LocalJSX { */ "tappable"?: boolean; /** - * If `true`, the backdrop will be visible. + * If `true`, the backdrop will be visible. This property controls whether or not the backdrop's default background is shown. It does not control whether or not the backdrop blocks interaction, and it does not override a custom background set on `ion-backdrop`. * @default true */ "visible"?: boolean; diff --git a/core/src/components/backdrop/backdrop.tsx b/core/src/components/backdrop/backdrop.tsx index 9468f02526c..8a3ff04c837 100644 --- a/core/src/components/backdrop/backdrop.tsx +++ b/core/src/components/backdrop/backdrop.tsx @@ -14,6 +14,10 @@ import { getIonMode } from '../../global/ionic-global'; export class Backdrop implements ComponentInterface { /** * If `true`, the backdrop will be visible. + * This property controls whether or not the backdrop's + * default background is shown. It does not control whether + * or not the backdrop blocks interaction, and it does not + * override a custom background set on `ion-backdrop`. */ @Prop() visible = true; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index e9bb5d47086..4851f70df70 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -129,6 +129,14 @@ export class Datetime implements ComponentInterface { private destroyCalendarListener?: () => void; private destroyKeyboardMO?: () => void; + private destroyOverlayPresentListener?: () => void; + /** + * Set when the user selects a value, cleared when it's confirmed + * or `processValue` runs (`reset()`, or `value` being set to a + * non-empty value). The overlay present listener only discards a + * selection while this is set. + */ + private hasUnconfirmedSelection = false; // TODO(FW-2832): types (DatetimeParts causes some errors that need untangling) private minParts?: any; @@ -565,6 +573,8 @@ export class Datetime implements ComponentInterface { } } + this.hasUnconfirmedSelection = false; + if (closeOverlay) { this.closeParentOverlay(CONFIRM_ROLE); } @@ -583,9 +593,10 @@ export class Datetime implements ComponentInterface { } /** - * Emits the ionCancel event and - * optionally closes the popover - * or modal that the datetime was + * Emits the ionCancel event, discards + * any unconfirmed selection, and + * optionally closes the popover or + * modal that the datetime was * presented in. * * @param closeOverlay If `true`, closes the parent overlay. Defaults to `false`. @@ -594,8 +605,12 @@ export class Datetime implements ComponentInterface { async cancel(closeOverlay = false) { this.ionCancel.emit(); - if (closeOverlay) { - this.closeParentOverlay(CANCEL_ROLE); + /** + * When a parent overlay closes, the present listener discards + * the selection on the next open. Otherwise discard it now. + */ + if (!closeOverlay || !this.closeParentOverlay(CANCEL_ROLE)) { + this.processValue(this.value); } } @@ -665,6 +680,8 @@ export class Datetime implements ComponentInterface { if (popoverOrModal) { popoverOrModal.dismiss(undefined, role); } + + return popoverOrModal !== null; }; private setWorkingParts = (parts: DatetimeParts) => { @@ -682,6 +699,8 @@ export class Datetime implements ComponentInterface { return; } + this.hasUnconfirmedSelection = true; + const { multiple, minParts, maxParts, activeParts } = this; /** @@ -1102,6 +1121,7 @@ export class Datetime implements ComponentInterface { this.loadTimeout = setTimeout(() => { this.ensureReadyIfVisible(); }, 100); + this.initializeOverlayPresentListener(); } disconnectedCallback() { @@ -1110,6 +1130,39 @@ export class Datetime implements ComponentInterface { this.clearFocusVisible = undefined; } this.loadTimeoutCleanup(); + + if (this.destroyOverlayPresentListener) { + this.destroyOverlayPresentListener(); + this.destroyOverlayPresentListener = undefined; + } + } + + /** + * A datetime in a modal or popover stays mounted between + * presentations, so an unconfirmed selection would still show when + * it reopens. Discarding on present rather than dismiss lets app + * code that runs on dismiss still confirm or reset the selection, + * whatever order its listeners were added in. + */ + private initializeOverlayPresentListener() { + const overlay = this.el.closest('ion-modal, ion-popover'); + if (!overlay) { + return; + } + + const eventName = overlay.tagName === 'ION-MODAL' ? 'ionModalWillPresent' : 'ionPopoverWillPresent'; + const onPresent = (ev: Event) => { + // Ignore nested overlays and skip when there's nothing to discard. + if (ev.target !== overlay || !this.hasUnconfirmedSelection) { + return; + } + + // The overlay is still hidden, so skip the month animation. + this.processValue(this.value, false); + }; + + overlay.addEventListener(eventName, onPresent); + this.destroyOverlayPresentListener = () => overlay.removeEventListener(eventName, onPresent); } /** @@ -1363,7 +1416,9 @@ export class Datetime implements ComponentInterface { }); } - private processValue = (value?: string | string[] | null) => { + private processValue = (value?: string | string[] | null, animated = true) => { + this.hasUnconfirmedSelection = false; + const hasValue = value !== null && value !== undefined && value !== '' && (!Array.isArray(value) || value.length > 0); const valueToProcess = hasValue ? parseDate(value) : this.defaultParts; @@ -1435,13 +1490,14 @@ export class Datetime implements ComponentInterface { const bodyIsVisible = el.classList.contains('datetime-ready'); const { isGridStyle, showMonthAndYear } = this; - if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) { + if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear && animated) { /** * Only animate if: * 1. We're using grid style (wheel style pickers should just jump to new value) * 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to) * 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example) * 4. The month/year picker is not open (since you wouldn't see the animation anyway) + * 5. The caller did not opt out (e.g. the parent overlay is still hidden) */ this.animateToDate(targetValue); } else { diff --git a/core/src/components/datetime/test/cancel/datetime.e2e.ts b/core/src/components/datetime/test/cancel/datetime.e2e.ts new file mode 100644 index 00000000000..f6b68dc6b2c --- /dev/null +++ b/core/src/components/datetime/test/cancel/datetime.e2e.ts @@ -0,0 +1,327 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +const getDay = (datetime: Locator, day: number, month = 5) => + datetime.locator(`.calendar-day[data-day="${day}"][data-month="${month}"]`); + +const expectSelectedDay = async (datetime: Locator, selected: number, unselected: number) => { + await expect(getDay(datetime, selected)).toHaveClass(/calendar-day-active/); + await expect(getDay(datetime, unselected)).not.toHaveClass(/calendar-day-active/); +}; + +const openDatetime = async (page: E2EPage, didPresent: string) => { + const ionDidPresent = await page.spyOnEvent(didPresent); + await page.locator('ion-datetime-button #date-button').click(); + await ionDidPresent.next(); + await page.locator('ion-datetime.datetime-ready').waitFor(); +}; + +const dismissDatetime = async (page: E2EPage, didDismiss: string, dismiss: () => Promise) => { + const ionDidDismiss = await page.spyOnEvent(didDismiss); + await dismiss(); + await ionDidDismiss.next(); +}; + +const dismissWithRole = (page: E2EPage, tag: string, role: string) => + page + .locator(tag) + .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement, role: string) => el.dismiss(undefined, role), role); + +const confirmOnDismiss = (page: E2EPage, tag: string, didDismiss: string) => + page + .locator(tag) + .evaluate( + (el: HTMLElement, eventName: string) => + el.addEventListener(eventName, () => el.querySelector('ion-datetime')!.confirm()), + didDismiss + ); + +const overlays = [ + { tag: 'ion-modal', didPresent: 'ionModalDidPresent', didDismiss: 'ionModalDidDismiss' }, + { tag: 'ion-popover', didPresent: 'ionPopoverDidPresent', didDismiss: 'ionPopoverDidDismiss' }, +]; + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { + test.describe(title('datetime: cancel'), () => { + overlays.forEach(({ tag, didPresent, didDismiss }) => { + test.describe(`in an ${tag}`, () => { + let datetime: Locator; + + test.beforeEach(async ({ page }) => { + await page.setContent( + ` + + <${tag}> + + + `, + config + ); + + datetime = page.locator('ion-datetime'); + }); + + test('should show the confirmed date when reopened after cancelling a new selection', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed date when reopened after dismissing without confirming', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed month when reopened after cancelling a selection in another month', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + const monthYear = datetime.locator('.calendar-month-year-toggle'); + const nextMonthButton = datetime.locator('.calendar-next-prev ion-button').nth(1); + + await openDatetime(page, didPresent); + await nextMonthButton.click(); + await expect(monthYear).toContainText('June 2022'); + await getDay(datetime, 10, 6).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + await openDatetime(page, didPresent); + + await expect(monthYear).toContainText('May 2022'); + await expect(getDay(datetime, 3)).toHaveClass(/calendar-day-active/); + + await nextMonthButton.click(); + await expect(monthYear).toContainText('June 2022'); + await expect(getDay(datetime, 10, 6)).not.toHaveClass(/calendar-day-active/); + }); + + test('should keep the new date when reopened after confirming a selection', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#confirm-button').click()); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should commit the selection when a dismiss handler confirms it after a custom role', async ({ page }) => { + await confirmOnDismiss(page, tag, didDismiss); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'my-custom-role')); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should commit the selection when confirmed after awaiting onDidDismiss with a custom role', async ({ + page, + }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + + await page.locator(tag).evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => { + el.onDidDismiss().then(() => el.querySelector('ion-datetime')!.confirm()); + }); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'my-custom-role')); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should keep the selection set by reset() after cancelling', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-05-20')); + await openDatetime(page, didPresent); + + await expectSelectedDay(datetime, 20, 3); + }); + + test('should keep the selection set by reset() before dismissing without confirming', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-05-20')); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await openDatetime(page, didPresent); + + await expectSelectedDay(datetime, 20, 3); + }); + + test('should show the value set after dismissing without confirming', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2022-05-20')); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-20'); + await expectSelectedDay(datetime, 20, 10); + }); + + test('should clear the selection when the value is cleared after dismissing without confirming', async ({ + page, + }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = null)); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', null); + await expect(datetime.locator('.calendar-day-active')).toHaveCount(0); + }); + + /** + * Cancel defers discarding the selection until the overlay + * presents again, so an app that confirms on every dismiss + * commits the cancelled selection. This is intended. + */ + test('should commit a cancelled selection when a dismiss handler confirms it', async ({ page }) => { + await confirmOnDismiss(page, tag, didDismiss); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + }); + }); + + test('should restore the confirmed date when the cancel button is clicked on an inline datetime', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await page.setContent( + '', + config + ); + + const datetime = page.locator('ion-datetime'); + await page.locator('ion-datetime.datetime-ready').waitFor(); + + await getDay(datetime, 10).click(); + await datetime.locator('#cancel-button').click(); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed date in the wheel picker when reopened after cancelling', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await page.setContent( + ` + + + + + `, + config + ); + + const datetime = page.locator('ion-datetime'); + const dayOptions = datetime.locator('.day-column ion-picker-column-option'); + const activeDay = datetime.locator('.day-column ion-picker-column-option.option-active'); + + await openDatetime(page, 'ionModalDidPresent'); + await dayOptions.filter({ hasText: /^10$/ }).click(); + await expect(activeDay).toHaveText('10'); + await dismissDatetime(page, 'ionModalDidDismiss', () => datetime.locator('#cancel-button').click()); + await openDatetime(page, 'ionModalDidPresent'); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expect(activeDay).toHaveText('3'); + }); + + test('should keep the selection when the time picker inside a popover is dismissed', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); + const parentPopover = page.locator('#parent-popover'); + const datetime = page.locator('ion-datetime'); + + await parentPopover.evaluate((el: HTMLIonPopoverElement) => el.present()); + await ionPopoverDidPresent.next(); + await page.locator('ion-datetime.datetime-ready').waitFor(); + + await getDay(datetime, 10).click(); + + /** + * The time picker opens its own popover. Dismissing it + * shouldn't discard the selection in the parent popover. + */ + await datetime.locator('.time-body').click(); + await ionPopoverDidPresent.next(); + await page.keyboard.press('Escape'); + await ionPopoverDidDismiss.next(); + await page.waitForChanges(); + + await expect(parentPopover).not.toHaveClass(/overlay-hidden/); + await expectSelectedDay(datetime, 10, 3); + }); + }); +}); diff --git a/core/src/components/input/input.md.outline.scss b/core/src/components/input/input.md.outline.scss index d6cda674e58..bbd4360f8e1 100644 --- a/core/src/components/input/input.md.outline.scss +++ b/core/src/components/input/input.md.outline.scss @@ -11,13 +11,22 @@ /** * The measured width of the start container, as a negative offset - * in LTR and a positive one in RTL. Written to the host by the - * start container controller, so renaming it here requires renaming + * in LTR and a positive one in RTL. Written to the host by the start + * and end container controller, so renaming it here requires renaming * it there too. Declared with a default so the label is positioned * correctly before the first measurement runs. */ --internal-start-container-adjustment: 0px; + /** + * The measured combined width of the start and end containers. + * Written to the host by the start and end container controller, so + * renaming it here requires renaming it there too. Declared with a + * default so the floating label falls back to the width of the + * control before the first measurement runs. + */ + --internal-start-end-container-width: 0px; + min-height: 56px; } @@ -74,11 +83,12 @@ } /** - * The resting label is positioned relative to the control so it can size to - * the space between the start and end slots. When floating, the label needs - * to escape the control and position itself relative to the wrapper instead. + * The label is positioned relative to the control whether it is resting + * over the input or floating in the notch, so its max-width always resolves + * against the same width. This lets the max-width animate between the + * resting and floating values without jumping. */ -:host(.input-fill-outline:not(.label-floating)) .input-control { +:host(.input-fill-outline) .input-control { position: relative; } @@ -89,9 +99,8 @@ position: absolute; /** - * While resting this is the width of the text input, which already - * excludes the start and end slots. Once the label floats the - * containing block becomes the wrapper and the rule below takes over. + * This is the width of the text input, which already + * excludes the start and end slots. */ max-width: 100%; } @@ -113,10 +122,11 @@ @include margin(0); /** - * Label text should not extend - * beyond the bounds of the input. + * Label text should not extend beyond the bounds of the input. + * The control excludes the start and end slots, but the notch spans + * the full width of the outline, so their width is added back. */ - max-width: calc((100% - var(--padding-start) - var(--padding-end) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); + max-width: calc((100% + var(--internal-start-end-container-width) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); } // Input Fill: Outline Outline Container diff --git a/core/src/components/input/input.scss b/core/src/components/input/input.scss index f5d02058228..dbf93e7cfe3 100644 --- a/core/src/components/input/input.scss +++ b/core/src/components/input/input.scss @@ -466,7 +466,11 @@ */ max-width: 200px; - transition: color 150ms cubic-bezier(.4, 0, .2, 1), transform 150ms cubic-bezier(.4, 0, .2, 1); + /** + * The max-width animates with the transform so the label does not + * reveal its full floating width while it is still at full size. + */ + transition: color 150ms #{$form-control-label-transition-easing}, transform 150ms #{$form-control-label-transition-easing}, max-width 150ms #{$form-control-label-transition-easing}; /** * This ensures that double tapping this text @@ -481,8 +485,8 @@ * disable the label transition to prevent the floating label * from animating as its position is adjusted. * - * This class is applied by the start container controller, so - * renaming it here requires renaming it there too. + * This class is applied by the start and end container controller, + * so renaming it here requires renaming it there too. */ :host(.skip-label-transition) .label-text-wrapper { transition: none; diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 2dc919e41b1..9e8103432e6 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -13,11 +13,11 @@ import { forceUpdate, h, } from '@stencil/core'; -import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartEndContainerController } from '@utils/forms'; import { createClickController, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; @@ -59,8 +59,9 @@ export class Input implements ComponentInterface { private slotMutationController?: SlotMutationController; private notchController?: NotchController; private notchSpacerEl: HTMLElement | undefined; - private startContainerController?: StartContainerController; + private startEndContainerController?: StartEndContainerController; private startContainerEl: HTMLElement | undefined; + private endContainerEl: HTMLElement | undefined; private clickController?: ClickController; private originalIonInput?: EventEmitter; @@ -412,7 +413,7 @@ export class Input implements ComponentInterface { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); this.setSlottedLabelId(); forceUpdate(this); @@ -425,15 +426,16 @@ export class Input implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonMode(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); this.clickController = createClickController(el, () => this.nativeInput); @@ -482,7 +484,7 @@ export class Input implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } disconnectedCallback() { @@ -504,9 +506,9 @@ export class Input implements ComponentInterface { this.notchController = undefined; } - if (this.startContainerController) { - this.startContainerController.destroy(); - this.startContainerController = undefined; + if (this.startEndContainerController) { + this.startEndContainerController.destroy(); + this.startEndContainerController = undefined; } // Clean up validation observer to prevent memory leaks @@ -946,7 +948,7 @@ export class Input implements ComponentInterface { /> -
+
(this.endContainerEl = el)}> {this.clearInput && !readonly && !disabled && (
-
+
(this.endContainerEl = el)}> {/** * The icon is rendered in the end container when the * select has a floating or stacked label so it is diff --git a/core/src/components/select/test/slot/select.e2e.ts b/core/src/components/select/test/slot/select.e2e.ts index 3f12c760117..dae11c5a79c 100644 --- a/core/src/components/select/test/slot/select.e2e.ts +++ b/core/src/components/select/test/slot/select.e2e.ts @@ -513,6 +513,90 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co expect(box!.width).toBeGreaterThanOrEqual(twoCharacterWidth); }); }); + + /** + * The label truncates beside the wide slot and has room for its full + * text once it floats into the notch. It must not reveal that full text + * while it is still at its resting size, or it overflows the select + * partway through the transition. + */ + test('should keep the label inside the select while it floats above a wide end slot', async ({ page }) => { + /** + * The label is longer than the select, so revealing all of it at its + * resting size pushes it past the border, not only over the slot. + */ + await page.setContent( + ` + + ${endSlot} + 100 + + `, + config + ); + + const select = page.locator('ion-select'); + + /** + * The first measurement of the start container disables the label + * transition for a frame. Wait for it to finish so the label is not + * floated while its transitions are turned off. + */ + await expect(select).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(select).not.toHaveClass(/skip-label-transition/); + + /** + * Pauses the label's transitions and steps through them so every + * point of the animation is checked regardless of frame timing. + */ + const overflow = await select.evaluate(async (el: HTMLIonSelectElement) => { + const label = el.shadowRoot!.querySelector('.label-text-wrapper')!; + const wrapper = el.shadowRoot!.querySelector('.select-wrapper')!; + const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); + + /** + * The class that starts the transitions is added on the next render. + * A mutation observer runs right after that render, before the next + * frame, so the transitions are caught at their start. Waiting for a + * frame or using `page.waitForChanges()` can let the 150ms transitions + * finish on a slow machine, at which point the browser discards them + * and there is nothing to step through. + */ + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (el.classList.contains('label-floating')) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(el, { attributeFilter: ['class'] }); + + el.value = '100'; + }); + + // Without this a label that already finished floating would pass + const transitions = label.getAnimations(); + if (transitions.length === 0) { + throw new Error('The label did not transition'); + } + transitions.forEach((transition) => transition.pause()); + + // Step through the 150ms label transition in 15ms increments + const overflows = Array.from({ length: 11 }, (_, step) => { + transitions.forEach((transition) => (transition.currentTime = step * 15)); + return label.getBoundingClientRect().right - maxRight; + }); + + return Math.max(...overflows); + }); + + expect(overflow).toBeLessThanOrEqual(1); + }); }); }); diff --git a/core/src/components/textarea/test/slot/textarea.e2e.ts b/core/src/components/textarea/test/slot/textarea.e2e.ts index 87c15eca153..14e30727d1b 100644 --- a/core/src/components/textarea/test/slot/textarea.e2e.ts +++ b/core/src/components/textarea/test/slot/textarea.e2e.ts @@ -343,6 +343,89 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co expect(box!.width).toBeGreaterThanOrEqual(twoCharacterWidth); }); }); + + /** + * The label truncates beside the wide slot and has room for its full + * text once it floats into the notch. It must not reveal that full text + * while it is still at its resting size, or it overflows the textarea + * partway through the transition. + */ + test('should keep the label inside the textarea while it floats above a wide end slot', async ({ page }) => { + /** + * The label is longer than the textarea, so revealing all of it at its + * resting size pushes it past the border, not only over the slot. + */ + await page.setContent( + ` + + ${endSlot} + + `, + config + ); + + const textarea = page.locator('ion-textarea'); + + /** + * The first measurement of the start container disables the label + * transition for a frame. Wait for it to finish so the label is not + * floated while its transitions are turned off. + */ + await expect(textarea).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(textarea).not.toHaveClass(/skip-label-transition/); + + /** + * Pauses the label's transitions and steps through them so every + * point of the animation is checked regardless of frame timing. + */ + const overflow = await textarea.evaluate(async (el: HTMLIonTextareaElement) => { + const label = el.querySelector('.label-text-wrapper')!; + const wrapper = el.querySelector('.textarea-wrapper')!; + const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); + + /** + * The class that starts the transitions is added on the next render. + * A mutation observer runs right after that render, before the next + * frame, so the transitions are caught at their start. Waiting for a + * frame or using `page.waitForChanges()` can let the 150ms transitions + * finish on a slow machine, at which point the browser discards them + * and there is nothing to step through. + */ + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (el.classList.contains('label-floating')) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(el, { attributeFilter: ['class'] }); + + el.value = '100'; + }); + + // Without this a label that already finished floating would pass + const transitions = label.getAnimations(); + if (transitions.length === 0) { + throw new Error('The label did not transition'); + } + transitions.forEach((transition) => transition.pause()); + + // Step through the 150ms label transition in 15ms increments + const overflows = Array.from({ length: 11 }, (_, step) => { + transitions.forEach((transition) => (transition.currentTime = step * 15)); + return label.getBoundingClientRect().right - maxRight; + }); + + return Math.max(...overflows); + }); + + expect(overflow).toBeLessThanOrEqual(1); + }); }); }); diff --git a/core/src/components/textarea/textarea.md.outline.scss b/core/src/components/textarea/textarea.md.outline.scss index 6452987a2b2..00d79728edd 100644 --- a/core/src/components/textarea/textarea.md.outline.scss +++ b/core/src/components/textarea/textarea.md.outline.scss @@ -11,12 +11,21 @@ /** * The measured width of the start container, as a negative offset - * in LTR and a positive one in RTL. Written to the host by the - * start container controller, so renaming it here requires renaming + * in LTR and a positive one in RTL. Written to the host by the start + * and end container controller, so renaming it here requires renaming * it there too. Declared with a default so the label is positioned * correctly before the first measurement runs. */ --internal-start-container-adjustment: 0px; + + /** + * The measured combined width of the start and end containers. + * Written to the host by the start and end container controller, so + * renaming it here requires renaming it there too. Declared with a + * default so the floating label falls back to the width of the + * control before the first measurement runs. + */ + --internal-start-end-container-width: 0px; } :host(.textarea-fill-outline.textarea-shape-round) { @@ -72,11 +81,12 @@ } /** - * The resting label is positioned relative to the control so it can size to - * the space between the start and end slots. When floating, the label needs - * to escape the control and position itself relative to the wrapper instead. + * The label is positioned relative to the control whether it is resting + * over the textarea or floating in the notch, so its max-width always resolves + * against the same width. This lets the max-width animate between the + * resting and floating values without jumping. */ -:host(.textarea-fill-outline:not(.label-floating)) .textarea-control { +:host(.textarea-fill-outline) .textarea-control { position: relative; } @@ -87,9 +97,8 @@ position: absolute; /** - * While resting this is the width of the textarea, which already - * excludes the start and end slots. Once the label floats the - * containing block becomes the wrapper and the rule below takes over. + * This is the width of the textarea, which already + * excludes the start and end slots. */ max-width: 100%; } @@ -111,12 +120,11 @@ @include margin(0); /** - * Label text should not extend - * beyond the bounds of the textarea. + * Label text should not extend beyond the bounds of the textarea. + * The control excludes the start and end slots, but the notch spans + * the full width of the outline, so their width is added back. */ - max-width: calc( - (100% - var(--padding-start) - var(--padding-end) - #{$textarea-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale} - ); + max-width: calc((100% + var(--internal-start-end-container-width) - #{$textarea-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); } /** diff --git a/core/src/components/textarea/textarea.scss b/core/src/components/textarea/textarea.scss index 7db3f21df17..84ad244d67f 100644 --- a/core/src/components/textarea/textarea.scss +++ b/core/src/components/textarea/textarea.scss @@ -496,7 +496,11 @@ */ max-width: 200px; - transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + /** + * The max-width animates with the transform so the label does not + * reveal its full floating width while it is still at full size. + */ + transition: color 150ms #{$form-control-label-transition-easing}, transform 150ms #{$form-control-label-transition-easing}, max-width 150ms #{$form-control-label-transition-easing}; /** * This ensures that double tapping this text @@ -511,8 +515,8 @@ * disable the label transition to prevent the floating label * from animating as its position is adjusted. * - * This class is applied by the start container controller, so - * renaming it here requires renaming it there too. + * This class is applied by the start and end container controller, + * so renaming it here requires renaming it there too. */ :host(.skip-label-transition) .label-text-wrapper { transition: none; diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index c893a652144..297b5cdebc3 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -14,11 +14,11 @@ import { h, writeTask, } from '@stencil/core'; -import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartEndContainerController } from '@utils/forms'; import { createClickController, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; @@ -68,8 +68,9 @@ export class Textarea implements ComponentInterface { private notchController?: NotchController; private notchSpacerEl: HTMLElement | undefined; - private startContainerController?: StartContainerController; + private startEndContainerController?: StartEndContainerController; private startContainerEl: HTMLElement | undefined; + private endContainerEl: HTMLElement | undefined; private clickController?: ClickController; /** @@ -343,7 +344,7 @@ export class Textarea implements ComponentInterface { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); forceUpdate(this); }); @@ -354,15 +355,16 @@ export class Textarea implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonMode(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); this.clickController = createClickController(el, () => this.nativeInput); @@ -415,9 +417,9 @@ export class Textarea implements ComponentInterface { this.notchController = undefined; } - if (this.startContainerController) { - this.startContainerController.destroy(); - this.startContainerController = undefined; + if (this.startEndContainerController) { + this.startEndContainerController.destroy(); + this.startEndContainerController = undefined; } // Clean up validation observer to prevent memory leaks @@ -441,7 +443,7 @@ export class Textarea implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } /** @@ -815,7 +817,7 @@ export class Textarea implements ComponentInterface {
-
+
(this.endContainerEl = el)}>
{shouldRenderHighlight &&
} diff --git a/core/src/themes/ionic.globals.scss b/core/src/themes/ionic.globals.scss index 1d5789607f1..ae1e28cb7e7 100644 --- a/core/src/themes/ionic.globals.scss +++ b/core/src/themes/ionic.globals.scss @@ -53,6 +53,10 @@ $form-control-min-width: 2ch; /// iOS does not have a floating label design spec, so we standardize on 0.75. $form-control-label-stacked-scale: 0.75; +// Easing for the color, transform, and max-width transitions of a form +// control label as it moves between resting and floating. +$form-control-label-transition-easing: cubic-bezier(.4, 0, .2, 1); + // Z-Index // -------------------------------------------------- diff --git a/core/src/utils/forms/index.ts b/core/src/utils/forms/index.ts index 31732efac24..ef17d70a583 100644 --- a/core/src/utils/forms/index.ts +++ b/core/src/utils/forms/index.ts @@ -1,5 +1,5 @@ export * from './notch-controller'; -export * from './start-container-controller'; +export * from './start-end-container-controller'; export * from './compare-with-utils'; export * from './item-multiple-inputs'; export * from './click-controller'; diff --git a/core/src/utils/forms/start-container-controller.ts b/core/src/utils/forms/start-container-controller.ts deleted file mode 100644 index 3038f470162..00000000000 --- a/core/src/utils/forms/start-container-controller.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { raf } from '@utils/helpers'; -import { isRTL } from '@utils/rtl'; - -/** - * Applied to the host while the label is being repositioned. The - * component stylesheets style this class, so it must stay in sync with - * the `skip-label-transition` selector in those files. Renaming it here - * requires renaming it there too. - */ -export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition'; - -/** - * Set on the host as an inline style. The component stylesheets declare - * this property and read it with `var()`, so it must stay in sync with - * the `--internal-start-container-adjustment` references in those files. - * Renaming it here requires renaming it there too. - */ -export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment'; - -/** - * A utility to measure and update the start container width adjustment - * for form components (input, textarea, select) with `fill="outline"`. - * This adjustment compensates for the start container width when - * positioning floating/stacked labels within the outline border. - * - * Measurement is deferred with raf() to avoid forcing layout during - * component updates, and a ResizeObserver watches for dynamic width - * changes (e.g., when web fonts load or async content changes). - * - * @internal - * @param el - The host element (ion-input, ion-textarea, or ion-select). - * @param getStartContainer - A callback that returns the start container element. - * @param shouldApplyAdjustment - A callback that determines whether the adjustment should be applied. - */ -export const createStartContainerController = ( - el: HTMLElement, - getStartContainer: () => HTMLElement | undefined, - shouldApplyAdjustment: () => boolean -): StartContainerController => { - let resizeObserver: ResizeObserver | undefined; - let observedContainer: HTMLElement | undefined; - let measurementRaf: number | undefined; - let transitionRaf: number | undefined; - let appliedAdjustment: string | undefined; - - const calculateAdjustment = (): string => { - const startSlot = getStartContainer(); - if (!startSlot || !shouldApplyAdjustment()) { - return ''; - } - - /* - * Round to one decimal place so fractional widths produce a stable - * label offset when the start container is added, removed, or resized. - */ - const startContainerWidth = startSlot.getBoundingClientRect().width; - const roundedWidth = Math.round(startContainerWidth * 10) / 10; - const sign = isRTL(el) ? '' : '-'; - return roundedWidth ? `${sign}${roundedWidth}px` : '0px'; - }; - - const scheduleAdjustmentUpdate = () => { - // Return if there is already a measurement queued to avoid a flicker - if (measurementRaf !== undefined) { - return; - } - - /* - * Defer the measurement until the next animation frame so layout - * is measured after the current update has been applied. - */ - measurementRaf = raf(() => { - measurementRaf = undefined; - - const adjustment = calculateAdjustment(); - - // Only suppress the label transition when the label has to move - if (adjustment !== appliedAdjustment) { - // Discard a removal queued by an earlier change - if (transitionRaf !== undefined) { - cancelAnimationFrame(transitionRaf); - } - - el.classList.add(SKIP_LABEL_TRANSITION_CLASS); - el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment); - appliedAdjustment = adjustment; - - // Re-enable the transition once the new offset has been painted - transitionRaf = raf(() => { - transitionRaf = undefined; - el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); - }); - } - - setupResizeObserver(); - }); - }; - - const disconnectResizeObserver = () => { - if (resizeObserver) { - resizeObserver.disconnect(); - resizeObserver = undefined; - observedContainer = undefined; - } - }; - - const setupResizeObserver = () => { - const startSlot = getStartContainer(); - - // Stop watching once the adjustment no longer applies - if (!startSlot || !shouldApplyAdjustment()) { - disconnectResizeObserver(); - return; - } - - // Return when prerendering or already watching this container - if (typeof ResizeObserver !== 'function' || (resizeObserver && observedContainer === startSlot)) { - return; - } - - disconnectResizeObserver(); - - resizeObserver = new ResizeObserver(() => { - scheduleAdjustmentUpdate(); - }); - - observedContainer = startSlot; - resizeObserver.observe(startSlot); - }; - - const calculateStartContainerWidth = () => { - scheduleAdjustmentUpdate(); - }; - - const destroy = () => { - disconnectResizeObserver(); - - if (measurementRaf !== undefined) { - cancelAnimationFrame(measurementRaf); - measurementRaf = undefined; - } - - // Tearing down mid-toggle would leave the transition disabled - if (transitionRaf !== undefined) { - cancelAnimationFrame(transitionRaf); - transitionRaf = undefined; - el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); - } - - // Re-apply from scratch if the element is connected again - appliedAdjustment = undefined; - }; - - return { - calculateStartContainerWidth, - destroy, - }; -}; - -export type StartContainerController = { - calculateStartContainerWidth: () => void; - destroy: () => void; -}; diff --git a/core/src/utils/forms/start-end-container-controller.ts b/core/src/utils/forms/start-end-container-controller.ts new file mode 100644 index 00000000000..9a297dbf388 --- /dev/null +++ b/core/src/utils/forms/start-end-container-controller.ts @@ -0,0 +1,205 @@ +import { raf } from '@utils/helpers'; +import { isRTL } from '@utils/rtl'; + +/** + * Applied to the host while the label is being repositioned. The + * component stylesheets style this class, so it must stay in sync with + * the `skip-label-transition` selector in those files. Renaming it here + * requires renaming it there too. + */ +export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition'; + +/** + * Set on the host as an inline style. The component stylesheets declare + * this property and read it with `var()`, so it must stay in sync with + * the `--internal-start-container-adjustment` references in those files. + * Renaming it here requires renaming it there too. + */ +export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment'; + +/** + * Set on the host as an inline style. The component stylesheets declare + * this property and read it with `var()`, so it must stay in sync with + * the `--internal-start-end-container-width` references in those files. + * Renaming it here requires renaming it there too. + */ +export const START_END_CONTAINER_WIDTH_VAR = '--internal-start-end-container-width'; + +/** + * A utility to measure the start and end containers of form components + * (input, textarea, select) with `fill="outline"` and write the results + * to the host. The start container width offsets floating/stacked labels + * so they line up with the outline border. The combined start and end + * container width lets the floating label size itself to the full width + * of the outline while still being positioned relative to the control. + * + * Measurement is deferred with raf() to avoid forcing layout during + * component updates, and a ResizeObserver watches for dynamic width + * changes (e.g., when web fonts load or async content changes). + * + * @internal + * @param el - The host element (ion-input, ion-textarea, or ion-select). + * @param getStartContainer - A callback that returns the start container element. + * @param getEndContainer - A callback that returns the end container element. + * @param shouldMeasure - A callback that determines whether the containers should be measured. + */ +export const createStartEndContainerController = ( + el: HTMLElement, + getStartContainer: () => HTMLElement | undefined, + getEndContainer: () => HTMLElement | undefined, + shouldMeasure: () => boolean +): StartEndContainerController => { + let resizeObserver: ResizeObserver | undefined; + let observedStartContainer: HTMLElement | undefined; + let observedEndContainer: HTMLElement | undefined; + let measurementRaf: number | undefined; + let transitionRaf: number | undefined; + let appliedAdjustment: string | undefined; + let appliedStartEndWidth: string | undefined; + + /* + * Round to one decimal place so fractional widths produce a stable + * label offset and width when a container is added, removed, or resized. + */ + const roundWidth = (width: number) => Math.round(width * 10) / 10; + + const measureContainers = (): { adjustment: string; startEndWidth: string } => { + const startContainer = getStartContainer(); + if (!startContainer || !shouldMeasure()) { + return { adjustment: '', startEndWidth: '' }; + } + + const startContainerWidth = roundWidth(startContainer.getBoundingClientRect().width); + const endContainerWidth = roundWidth(getEndContainer()?.getBoundingClientRect().width ?? 0); + const sign = isRTL(el) ? '' : '-'; + + return { + adjustment: startContainerWidth ? `${sign}${startContainerWidth}px` : '0px', + startEndWidth: `${roundWidth(startContainerWidth + endContainerWidth)}px`, + }; + }; + + const scheduleMeasurement = () => { + // Return if there is already a measurement queued to avoid a flicker + if (measurementRaf !== undefined) { + return; + } + + /* + * Defer the measurement until the next animation frame so layout + * is measured after the current update has been applied. + */ + measurementRaf = raf(() => { + measurementRaf = undefined; + + const { adjustment, startEndWidth } = measureContainers(); + + /* + * The width only changes how much of the label fits, so it can + * animate. The transition is not suppressed here because the end + * container can change size while the label is floating, such as + * when async content loads, and that should not cut the float short. + */ + if (startEndWidth !== appliedStartEndWidth) { + el.style.setProperty(START_END_CONTAINER_WIDTH_VAR, startEndWidth); + appliedStartEndWidth = startEndWidth; + } + + // Only suppress the label transition when the label has to move + if (adjustment !== appliedAdjustment) { + // Discard a removal queued by an earlier change + if (transitionRaf !== undefined) { + cancelAnimationFrame(transitionRaf); + } + + el.classList.add(SKIP_LABEL_TRANSITION_CLASS); + el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment); + appliedAdjustment = adjustment; + + // Re-enable the transition once the new offset has been painted + transitionRaf = raf(() => { + transitionRaf = undefined; + el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); + }); + } + + setupResizeObserver(); + }); + }; + + const disconnectResizeObserver = () => { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = undefined; + observedStartContainer = undefined; + observedEndContainer = undefined; + } + }; + + const setupResizeObserver = () => { + const startContainer = getStartContainer(); + const endContainer = getEndContainer(); + + // Stop watching once the measurements no longer apply + if (!startContainer || !shouldMeasure()) { + disconnectResizeObserver(); + return; + } + + // Return when prerendering or already watching these containers + if ( + typeof ResizeObserver !== 'function' || + (resizeObserver && observedStartContainer === startContainer && observedEndContainer === endContainer) + ) { + return; + } + + disconnectResizeObserver(); + + resizeObserver = new ResizeObserver(() => { + scheduleMeasurement(); + }); + + observedStartContainer = startContainer; + resizeObserver.observe(startContainer); + + if (endContainer) { + observedEndContainer = endContainer; + resizeObserver.observe(endContainer); + } + }; + + const calculateContainerWidths = () => { + scheduleMeasurement(); + }; + + const destroy = () => { + disconnectResizeObserver(); + + if (measurementRaf !== undefined) { + cancelAnimationFrame(measurementRaf); + measurementRaf = undefined; + } + + // Tearing down mid-toggle would leave the transition disabled + if (transitionRaf !== undefined) { + cancelAnimationFrame(transitionRaf); + transitionRaf = undefined; + el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); + } + + // Re-apply from scratch if the element is connected again + appliedAdjustment = undefined; + appliedStartEndWidth = undefined; + }; + + return { + calculateContainerWidths, + destroy, + }; +}; + +export type StartEndContainerController = { + calculateContainerWidths: () => void; + destroy: () => void; +}; diff --git a/core/src/utils/forms/test/start-container-controller.spec.ts b/core/src/utils/forms/test/start-end-container-controller.spec.ts similarity index 71% rename from core/src/utils/forms/test/start-container-controller.spec.ts rename to core/src/utils/forms/test/start-end-container-controller.spec.ts index e5c474356ba..cbe34c685b9 100644 --- a/core/src/utils/forms/test/start-container-controller.spec.ts +++ b/core/src/utils/forms/test/start-end-container-controller.spec.ts @@ -1,9 +1,10 @@ -import type { StartContainerController } from '../start-container-controller'; +import type { StartEndContainerController } from '../start-end-container-controller'; import { SKIP_LABEL_TRANSITION_CLASS, START_CONTAINER_ADJUSTMENT_VAR, - createStartContainerController, -} from '../start-container-controller'; + START_END_CONTAINER_WIDTH_VAR, + createStartEndContainerController, +} from '../start-end-container-controller'; /** * The controller defers every measurement to an animation frame, so the tests @@ -91,18 +92,25 @@ const restoreGlobals = () => { globalOverrides.clear(); }; -describe('Start Container Controller', () => { +describe('Start and End Container Controller', () => { let host: HTMLElement; let startContainer: HTMLElement; - let controller: StartContainerController; + let endContainer: HTMLElement; + let controller: StartEndContainerController; let shouldApply: boolean; const setStartWidth = (width: number) => { startContainer.getBoundingClientRect = () => ({ width } as DOMRect); }; + const setEndWidth = (width: number) => { + endContainer.getBoundingClientRect = () => ({ width } as DOMRect); + }; + const getAdjustment = () => host.style.getPropertyValue(START_CONTAINER_ADJUSTMENT_VAR); + const getStartEndWidth = () => host.style.getPropertyValue(START_END_CONTAINER_WIDTH_VAR); + const hasSkipClass = () => host.classList.contains(SKIP_LABEL_TRANSITION_CLASS); /** @@ -110,7 +118,7 @@ describe('Start Container Controller', () => { * measurement frame and the follow-up transition frame run. */ const measure = () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); flushFrames(); }; @@ -127,14 +135,18 @@ describe('Start Container Controller', () => { host = document.createElement('div'); startContainer = document.createElement('div'); + endContainer = document.createElement('div'); host.appendChild(startContainer); + host.appendChild(endContainer); document.body.appendChild(host); setStartWidth(32); + setEndWidth(0); - controller = createStartContainerController( + controller = createStartEndContainerController( host, () => startContainer, + () => endContainer, () => shouldApply ); }); @@ -161,7 +173,7 @@ describe('Start Container Controller', () => { }); it('should not measure until the next frame', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); expect(getAdjustment()).toBe(''); }); @@ -173,8 +185,8 @@ describe('Start Container Controller', () => { * the offset into the next frame, leaving one painted frame where the * content has changed but the label has not moved. */ - requestAnimationFrame(() => controller.calculateStartContainerWidth()); - controller.calculateStartContainerWidth(); + requestAnimationFrame(() => controller.calculateContainerWidths()); + controller.calculateContainerWidths(); flushFrames(); @@ -206,11 +218,47 @@ describe('Start Container Controller', () => { expect(getAdjustment()).toBe('32px'); }); + + it('should set the width to the combined start and end container widths', () => { + setEndWidth(48); + + measure(); + + expect(getStartEndWidth()).toBe('80px'); + }); + + it('should not negate the start and end width in rtl', () => { + host.dir = 'rtl'; + setEndWidth(48); + + measure(); + + expect(getStartEndWidth()).toBe('80px'); + }); + + it('should round the combined start and end width to one decimal place', () => { + setStartWidth(32.06); + setEndWidth(48.02); + + measure(); + + expect(getStartEndWidth()).toBe('80.1px'); + }); + + it('should clear the start and end width when it no longer applies', () => { + measure(); + expect(getStartEndWidth()).toBe('32px'); + + shouldApply = false; + measure(); + + expect(getStartEndWidth()).toBe(''); + }); }); describe('label transition', () => { it('should suppress the transition for one frame when the offset changes', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(hasSkipClass()).toBe(true); @@ -222,14 +270,29 @@ describe('Start Container Controller', () => { it('should not suppress the transition when the offset is unchanged', () => { measure(); - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(hasSkipClass()).toBe(false); }); + it('should not suppress the transition when only the end container changes', () => { + /** + * The end container can resize while the label is floating. Cutting + * the transition short would snap the label into place. + */ + measure(); + + setEndWidth(48); + controller.calculateContainerWidths(); + flushFrames(); + + expect(getStartEndWidth()).toBe('80px'); + expect(hasSkipClass()).toBe(false); + }); + it('should keep the transition suppressed while consecutive changes are applied', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); /** @@ -237,7 +300,7 @@ describe('Start Container Controller', () => { * frame that applies the second one. */ setStartWidth(64); - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(getAdjustment()).toBe('-64px'); @@ -246,18 +309,18 @@ describe('Start Container Controller', () => { }); describe('resize observer', () => { - it('should observe the start container after measuring', () => { + it('should observe the start and end containers after measuring', () => { measure(); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); - it('should not rebind the observer when the container is unchanged', () => { + it('should not rebind the observer when the containers are unchanged', () => { measure(); measure(); expect(MockResizeObserver.instances).toHaveLength(1); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); it('should rebind when the start container is replaced', () => { @@ -269,7 +332,20 @@ describe('Start Container Controller', () => { measure(); expect(firstObserver?.disconnected).toBe(true); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); + }); + + it('should rebind when the end container is replaced', () => { + measure(); + const firstObserver = activeObserver(); + + endContainer = document.createElement('div'); + setEndWidth(48); + measure(); + + expect(firstObserver?.disconnected).toBe(true); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); + expect(getStartEndWidth()).toBe('80px'); }); it('should re-measure when the container resizes', () => { @@ -282,6 +358,16 @@ describe('Start Container Controller', () => { expect(getAdjustment()).toBe('-64px'); }); + it('should re-measure when the end container resizes', () => { + measure(); + + setEndWidth(48); + activeObserver()?.callback(); + flushFrames(); + + expect(getStartEndWidth()).toBe('80px'); + }); + it('should disconnect when the adjustment stops applying', () => { measure(); const observer = activeObserver(); @@ -302,7 +388,7 @@ describe('Start Container Controller', () => { measure(); expect(getAdjustment()).toBe('-32px'); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); }); @@ -317,7 +403,7 @@ describe('Start Container Controller', () => { }); it('should not measure after a pending frame is cancelled', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); controller.destroy(); flushFrames(); @@ -326,7 +412,7 @@ describe('Start Container Controller', () => { }); it('should not leave the transition suppressed when torn down mid-toggle', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); // The class is applied, but the frame that removes it has not run yet flushFrames(); @@ -341,15 +427,16 @@ describe('Start Container Controller', () => { measure(); controller.destroy(); - controller = createStartContainerController( + controller = createStartEndContainerController( host, () => startContainer, + () => endContainer, () => shouldApply ); measure(); expect(getAdjustment()).toBe('-32px'); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); }); }); diff --git a/core/src/utils/transition/index.ts b/core/src/utils/transition/index.ts index e74e7318c82..e9d6598a6e9 100644 --- a/core/src/utils/transition/index.ts +++ b/core/src/utils/transition/index.ts @@ -282,6 +282,19 @@ export const setPageHidden = (el: HTMLElement, hidden: boolean) => { } }; +/** + * Blocks clicks on `el` during a swipe to go back without animations, + * so releasing the pointer doesn't click whatever is under it. Returns + * a function that unblocks it after the release click. + */ +export const blockSwipeClicks = (el: HTMLElement) => { + el.style.setProperty('pointer-events', 'none'); + + return () => { + setTimeout(() => el.style.removeProperty('pointer-events')); + }; +}; + const setZIndex = ( enteringEl: HTMLElement | undefined, leavingEl: HTMLElement | undefined, diff --git a/lerna.json b/lerna.json index 1647d110ed2..b55d0f68f59 100644 --- a/lerna.json +++ b/lerna.json @@ -10,5 +10,5 @@ "packages/vue", "packages/vue-router" ], - "version": "9.0.8" + "version": "9.0.9" } \ No newline at end of file diff --git a/packages/angular-server/CHANGELOG.md b/packages/angular-server/CHANGELOG.md index 663785a3391..28845030c40 100644 --- a/packages/angular-server/CHANGELOG.md +++ b/packages/angular-server/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +**Note:** Version bump only for package @ionic/angular-server + + + + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) **Note:** Version bump only for package @ionic/angular-server diff --git a/packages/angular-server/package-lock.json b/packages/angular-server/package-lock.json index 782ce2983b0..db1c8718df5 100644 --- a/packages/angular-server/package-lock.json +++ b/packages/angular-server/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular-server", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular-server", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8" + "@ionic/core": "^9.0.9" }, "devDependencies": { "@angular/animations": "^22.0.0", @@ -1097,9 +1097,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", diff --git a/packages/angular-server/package.json b/packages/angular-server/package.json index 59d85c56385..0f16a94182d 100644 --- a/packages/angular-server/package.json +++ b/packages/angular-server/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular-server", - "version": "9.0.8", + "version": "9.0.9", "description": "Angular SSR Module for Ionic", "keywords": [ "ionic", @@ -78,6 +78,6 @@ }, "prettier": "@ionic/prettier-config", "dependencies": { - "@ionic/core": "^9.0.8" + "@ionic/core": "^9.0.9" } } diff --git a/packages/angular/.prettierignore b/packages/angular/.prettierignore index daee675330e..3fbe2f57cab 100644 --- a/packages/angular/.prettierignore +++ b/packages/angular/.prettierignore @@ -5,3 +5,4 @@ proxies.ts src/lazy/directives/proxies-list.ts src/standalone/directives/ion-*.ts **/*/angular-component-lib/utils.ts +**/*/angular-component-lib/boolean-attribute.ts diff --git a/packages/angular/CHANGELOG.md b/packages/angular/CHANGELOG.md index 6b4baf79bc7..1d3e8740831 100644 --- a/packages/angular/CHANGELOG.md +++ b/packages/angular/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **angular:** import tab proxies directly in standalone tabs ([#31596](https://github.com/ionic-team/ionic-framework/issues/31596)) ([cb963f8](https://github.com/ionic-team/ionic-framework/commit/cb963f886ee08ce3abf25d6cb4e33c5cc3ac0003)), closes [#31594](https://github.com/ionic-team/ionic-framework/issues/31594) +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/packages/angular/eslint.config.js b/packages/angular/eslint.config.js index b5f03c846a2..a3a5fe9c179 100644 --- a/packages/angular/eslint.config.js +++ b/packages/angular/eslint.config.js @@ -20,7 +20,16 @@ const tsConfigs = [...compat.extends('@ionic/eslint-config/recommended'), ...ang module.exports = [ { - ignores: ['dist/**', 'build/**', 'scripts/**', '**/proxies.ts', 'test/**', 'src/standalone/directives/ion-*.ts'], + ignores: [ + 'dist/**', + 'build/**', + 'scripts/**', + '**/proxies.ts', + // Generated by @stencil/angular-output-target alongside the proxies + '**/angular-component-lib/boolean-attribute.ts', + 'test/**', + 'src/standalone/directives/ion-*.ts', + ], }, ...tsConfigs, { diff --git a/packages/angular/package-lock.json b/packages/angular/package-lock.json index 3798985e293..61484bc67cb 100644 --- a/packages/angular/package-lock.json +++ b/packages/angular/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" @@ -1580,9 +1580,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", diff --git a/packages/angular/package.json b/packages/angular/package.json index 61dab86e93a..820f510ed44 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular", - "version": "9.0.8", + "version": "9.0.9", "description": "Angular specific wrappers for @ionic/core", "keywords": [ "ionic", @@ -168,7 +168,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" diff --git a/packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts b/packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts new file mode 100644 index 00000000000..c116dd5bc7f --- /dev/null +++ b/packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Transforms a value to a boolean so that boolean properties can be set by attribute presence, + * e.g. `` instead of ``. + * + * Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare + * attribute) becomes `true` and `'false'` becomes `false`. + * + * Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than + * coerced to `false`. Components frequently treat them as a state distinct from `false`: + * + * ```tsx + * // `undefined` means "decide based on the mode", which is not the same as `false` + * const showDetail = detail !== undefined ? detail : mode === 'ios'; + * + * // a strict comparison also behaves differently for `null` than it does for `false` + * const showHandle = handle !== false; + * ``` + * + * Both values reach inputs routinely in Angular templates, from the `async` pipe before its + * first emission and from form control values, so coercing them would change the behavior of + * bindings that work today. + * + * This is implemented here rather than imported from `@angular/core` so that consumers on + * Angular versions without `booleanAttribute` are unaffected by this file being generated. + * + * The parameter type is what Angular derives `ngAcceptInputType_*` from, so it decides which + * template bindings compile. Widening it to `unknown` would let any expression through. + * + * Declared as a function rather than an arrow constant because Angular has to resolve input + * transforms statically when compiling a library in partial compilation mode. + * + * This lives in its own file, separate from `utils.ts`, so that it carries no runtime imports. + * That keeps it independently type-checkable without pulling `rxjs` in for `proxyOutputs`. + */ +export function nullableBooleanAttribute(value: boolean | string | null | undefined): boolean | null | undefined { + if (value === null || value === undefined) { + return value; + } + return typeof value === 'boolean' ? value : value !== 'false'; +} diff --git a/packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts b/packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts new file mode 100644 index 00000000000..c116dd5bc7f --- /dev/null +++ b/packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Transforms a value to a boolean so that boolean properties can be set by attribute presence, + * e.g. `` instead of ``. + * + * Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare + * attribute) becomes `true` and `'false'` becomes `false`. + * + * Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than + * coerced to `false`. Components frequently treat them as a state distinct from `false`: + * + * ```tsx + * // `undefined` means "decide based on the mode", which is not the same as `false` + * const showDetail = detail !== undefined ? detail : mode === 'ios'; + * + * // a strict comparison also behaves differently for `null` than it does for `false` + * const showHandle = handle !== false; + * ``` + * + * Both values reach inputs routinely in Angular templates, from the `async` pipe before its + * first emission and from form control values, so coercing them would change the behavior of + * bindings that work today. + * + * This is implemented here rather than imported from `@angular/core` so that consumers on + * Angular versions without `booleanAttribute` are unaffected by this file being generated. + * + * The parameter type is what Angular derives `ngAcceptInputType_*` from, so it decides which + * template bindings compile. Widening it to `unknown` would let any expression through. + * + * Declared as a function rather than an arrow constant because Angular has to resolve input + * transforms statically when compiling a library in partial compilation mode. + * + * This lives in its own file, separate from `utils.ts`, so that it carries no runtime imports. + * That keeps it independently type-checkable without pulling `rxjs` in for `proxyOutputs`. + */ +export function nullableBooleanAttribute(value: boolean | string | null | undefined): boolean | null | undefined { + if (value === null || value === undefined) { + return value; + } + return typeof value === 'boolean' ? value : value !== 'false'; +} diff --git a/packages/angular/src/standalone/navigation/tabs.ts b/packages/angular/src/standalone/navigation/tabs.ts index 486e7afe64b..ccce63094c7 100644 --- a/packages/angular/src/standalone/navigation/tabs.ts +++ b/packages/angular/src/standalone/navigation/tabs.ts @@ -2,7 +2,8 @@ import { NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, ContentChild, ContentChildren, ViewChild, QueryList } from '@angular/core'; import { IonTabs as IonTabsBase } from '@ionic/angular/common'; -import { IonTabBar, IonTab } from '../directives/proxies'; +import { IonTab } from '../directives/ion-tab'; +import { IonTabBar } from '../directives/ion-tab-bar'; import { IonRouterOutlet } from './router-outlet'; diff --git a/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts b/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts new file mode 100644 index 00000000000..dd2365ae92b --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts @@ -0,0 +1,123 @@ +import { expect, test, type Page } from '@playwright/test'; +import { ionSwipeToGoBack } from '../../utils/drag-utils'; +import { ionPageVisible, ionPageHidden, ionPageDoesNotExist } from '../../utils/test-utils'; + +/** + * Starts a swipe to go back and lets go partway across, so the user + * should stay on the current page. + */ +const abandonSwipeBack = async (page: Page) => { + const viewport = page.viewportSize()!; + const y = viewport.height / 2; + + await page.mouse.move(5, y); + await page.mouse.down(); + await page.mouse.move(35, y, { steps: 5 }); + await page.mouse.move(65, y, { steps: 5 }); + + // A quick drag is released with enough velocity to complete the swipe, so slow down before letting go. + for (let i = 1; i <= 3; i++) { + await page.mouse.move(65 + i, y); + await page.waitForTimeout(50); + } + + await page.mouse.up(); + + // Let a back transition started by the swipe settle before asserting. + await page.waitForTimeout(500); +}; + +test.describe('Swipe Animated Disabled', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/standalone/swipe-animated-disabled?ionic:mode=ios'); + }); + + test('should not fire enter lifecycle events on the previous page when an abandoned swipe starts on a non-animated outlet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await abandonSwipeBack(page); + + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + }); + + test('should go back when a swipe completes on a non-animated outlet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await ionPageDoesNotExist(page, 'app-swipe-animated-disabled-details'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('2'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('2'); + }); + + test('should swipe back once the outlet becomes animated', async ({ page }) => { + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + await page.locator('#toggle-animated').click(); + await expect(page.locator('#animated-value')).toHaveText('true'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await ionPageDoesNotExist(page, 'app-swipe-animated-disabled-details'); + }); +}); + +test.describe('Swipe Animated Disabled: global config', () => { + test.beforeEach(async ({ page }) => { + // Testing mode sets the global `animated` config to false. + await page.goto('/standalone/swipe-animated-disabled?ionic:mode=ios&ionic:_testing=true'); + }); + + test('should not fire enter lifecycle events on the previous page when an abandoned swipe starts with animations disabled globally', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + // Animate the outlet so the global config is the only thing disabling animations. + await page.locator('#toggle-animated').click(); + await expect(page.locator('#animated-value')).toHaveText('true'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await abandonSwipeBack(page); + + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + }); +}); diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts index 1e9349293fc..37a2bae6314 100644 --- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts +++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts @@ -109,6 +109,14 @@ export const routes: Routes = [ { path: 'details', loadComponent: () => import('../swipe-gesture-disabled/swipe-gesture-disabled-details.component').then(c => c.SwipeGestureDisabledDetailsComponent) } ] }, + { + path: 'swipe-animated-disabled', + loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled.component').then(c => c.SwipeAnimatedDisabledComponent), + children: [ + { path: '', loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled-main.component').then(c => c.SwipeAnimatedDisabledMainComponent) }, + { path: 'details', loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled-details.component').then(c => c.SwipeAnimatedDisabledDetailsComponent) } + ] + }, { path: 'validation', children: [ diff --git a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html index 738a3f335ca..9c48d60f5fb 100644 --- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html @@ -104,6 +104,11 @@ Swipe Gesture Disabled Test + + + Swipe Animated Disabled Test + + Tabs Search Params Test diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts new file mode 100644 index 00000000000..668487e26eb --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { IonContent } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled-details', + template: ` + +
Details (animated disabled)
+
+ `, + standalone: true, + imports: [IonContent] +}) +export class SwipeAnimatedDisabledDetailsComponent {} diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts new file mode 100644 index 00000000000..06d030fb709 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts @@ -0,0 +1,30 @@ +import { Component, signal } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { IonContent, IonItem, IonLabel, IonRouterLink } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled-main', + template: ` + + + Details + +

ionViewWillEnter: {{ willEnter() }}

+

ionViewDidEnter: {{ didEnter() }}

+
+ `, + standalone: true, + imports: [IonContent, IonItem, IonLabel, IonRouterLink, RouterModule] +}) +export class SwipeAnimatedDisabledMainComponent { + willEnter = signal(0); + didEnter = signal(0); + + ionViewWillEnter() { + this.willEnter.update((n) => n + 1); + } + + ionViewDidEnter() { + this.didEnter.update((n) => n + 1); + } +} diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts new file mode 100644 index 00000000000..1c00d0444bb --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts @@ -0,0 +1,29 @@ +import { Component, signal } from '@angular/core'; +import { IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled', + template: ` + + + + + + Swipe Animated Disabled + + + animated: {{ animated() }} + + + + + + + + `, + standalone: true, + imports: [IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar] +}) +export class SwipeAnimatedDisabledComponent { + animated = signal(false); +} diff --git a/packages/docs/CHANGELOG.md b/packages/docs/CHANGELOG.md index 153c73a6acd..fd259332472 100644 --- a/packages/docs/CHANGELOG.md +++ b/packages/docs/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +**Note:** Version bump only for package @ionic/docs + + + + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) **Note:** Version bump only for package @ionic/docs diff --git a/packages/docs/package-lock.json b/packages/docs/package-lock.json index 8016c922474..86294ffba1a 100644 --- a/packages/docs/package-lock.json +++ b/packages/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/docs", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/docs", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT" } } diff --git a/packages/docs/package.json b/packages/docs/package.json index 43724875634..ca1de91c4d7 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/docs", - "version": "9.0.8", + "version": "9.0.9", "description": "Pre-packaged API documentation for the Ionic docs.", "main": "core.json", "types": "core.d.ts", diff --git a/packages/migrate/package-lock.json b/packages/migrate/package-lock.json index b2ec542f166..81569b6c43b 100644 --- a/packages/migrate/package-lock.json +++ b/packages/migrate/package-lock.json @@ -980,14 +980,14 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", - "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.4", + "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, @@ -1008,9 +1008,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", - "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { @@ -1509,14 +1509,14 @@ } }, "node_modules/vitest": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", - "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.4", + "@vitest/mocker": "5.0.5", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -1542,12 +1542,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.4", - "@vitest/browser-preview": "5.0.4", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.4", - "@vitest/coverage-v8": "5.0.4", - "@vitest/ui": "5.0.4", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 2cd31e9b24c..c9243f09c62 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json index bf202eb5d01..13246f43bfb 100644 --- a/packages/react-router/package-lock.json +++ b/packages/react-router/package-lock.json @@ -1,16 +1,16 @@ { "name": "@ionic/react-router", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react-router", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8", - "@ionic/react": "^9.0.8", + "@ionic/core": "^9.0.9", + "@ionic/react": "^9.0.9", "tslib": "*" }, "devDependencies": { @@ -257,9 +257,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -297,12 +297,12 @@ } }, "node_modules/@ionic/react": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.8.tgz", - "integrity": "sha512-8N9l+SA2VdcpX6lT9C6bH7brdHfekdbbRmjvXOU4kl86KHdVeqirkkt63iyLWm6VoEKLc2OuAfy4tjUSnlv0rw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.9.tgz", + "integrity": "sha512-bnfGMECWnaFNIuljq1Z18YmQUu8Tk9UhPPLqkbKBmzO9MTKPqOGTrcKWOuYaPGHELTQLVENYHt43igpIhWpHVg==", "license": "MIT", "dependencies": { - "@ionic/core": "9.0.8", + "@ionic/core": "9.0.9", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 744ea00df1c..6865c5be649 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react-router", - "version": "9.0.8", + "version": "9.0.9", "description": "React Router wrapper for @ionic/react", "keywords": [ "ionic", @@ -37,8 +37,8 @@ "dist/" ], "dependencies": { - "@ionic/core": "^9.0.8", - "@ionic/react": "^9.0.8", + "@ionic/core": "^9.0.9", + "@ionic/react": "^9.0.9", "tslib": "*" }, "peerDependencies": { diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 6848c4941bb..456f14f2b8c 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -1460,7 +1460,7 @@ export class StackManager extends React.PureComponent { }; const onStart = async () => { - // Core only calls onEnd when a progress animation exists, so with animations off a + // Core skips onEnd when an animated swipe ends without a progress animation, so a // previous gesture can leave its mark behind. Drop any stale one before marking. this.clearSwipeRevealedView(); @@ -1494,9 +1494,9 @@ export class StackManager extends React.PureComponent { // When the gesture starts, kick off a transition controlled via swipe gesture if (enteringViewItem && leavingViewItem) { - // Without a leaving view there is no progress animation, and core only calls - // swipeHandler.onEnd when one exists, so nothing would clear the mark until the - // next transition. + // Without a leaving view there is no progress animation, and core skips + // swipeHandler.onEnd for an animated swipe without one, so nothing would clear the + // mark until the next transition. markSwipeRevealed(enteringViewItem); this.swipeRevealedViewItem = enteringViewItem; diff --git a/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts index f9539df0e94..8817177ce4d 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts @@ -1,13 +1,12 @@ import { test, expect } from '@playwright/test'; -import { ionPageVisible, ionPageHidden, ionPageDoesNotExist, ionNav } from './utils/test-utils'; +import { ionPageVisible, ionPageHidden, ionPageDoesNotExist, ionNav, withTestingMode } from './utils/test-utils'; import { ionSwipeToGoBack } from './utils/drag-utils'; const IOS_MODE = 'ionic:mode=ios'; test.describe('Swipe To Go Back', () => { /* - This spec tests that swipe to go back works. - Animations must be enabled (no ionic:_testing=true) for gesture tests. + This spec tests that swipe to go back works with animations enabled (no ionic:_testing=true). */ test('should swipe and abort', async ({ page }) => { @@ -218,3 +217,46 @@ test.describe('Swipe To Go Back', () => { await ionPageHidden(page, 'disabled-main'); }); }); + +test.describe('Swipe To Go Back without animations', () => { + /* + Testing mode disables animations, so these cover swipe to go back without them. + */ + + test('should swipe and abort', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await page.goto(withTestingMode(`/swipe-to-go-back?${IOS_MODE}`)); + await ionPageVisible(page, 'main'); + + await ionNav(page, 'ion-item', 'Details'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + + await ionSwipeToGoBack(page, false, 'ion-router-outlet#swipe-to-go-back'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + await expect(page).toHaveURL(/\/swipe-to-go-back\/details/); + }); + + test('should swipe and go back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await page.goto(withTestingMode(`/swipe-to-go-back?${IOS_MODE}`)); + await ionPageVisible(page, 'main'); + + await ionNav(page, 'ion-item', 'Details'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + + await ionSwipeToGoBack(page, true, 'ion-router-outlet#swipe-to-go-back'); + await ionPageVisible(page, 'main'); + await expect(page).toHaveURL(/\/swipe-to-go-back(\?|$)/); + }); +}); diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 64cb657b909..55fe936d73b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +**Note:** Version bump only for package @ionic/react + + + + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index 492e5364495..da52bb1b7a7 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/react", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" @@ -891,9 +891,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", diff --git a/packages/react/package.json b/packages/react/package.json index 06287958443..c0452ad4ac5 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react", - "version": "9.0.8", + "version": "9.0.9", "description": "React specific wrapper for @ionic/core", "keywords": [ "ionic", @@ -41,7 +41,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" diff --git a/packages/vue-router/CHANGELOG.md b/packages/vue-router/CHANGELOG.md index 94277b93959..4878913d2c7 100644 --- a/packages/vue-router/CHANGELOG.md +++ b/packages/vue-router/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **vue-router:** keep back working after replacing a page in tabs ([#31583](https://github.com/ionic-team/ionic-framework/issues/31583)) ([86390f6](https://github.com/ionic-team/ionic-framework/commit/86390f6deab23b58498c3b9ae782f9e0fc5a4e29)), closes [#30617](https://github.com/ionic-team/ionic-framework/issues/30617) [#31142](https://github.com/ionic-team/ionic-framework/issues/31142) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) **Note:** Version bump only for package @ionic/vue-router diff --git a/packages/vue-router/package-lock.json b/packages/vue-router/package-lock.json index fa054cbd403..8990d894e7f 100644 --- a/packages/vue-router/package-lock.json +++ b/packages/vue-router/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/vue-router", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/vue-router", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/vue": "^9.0.8" + "@ionic/vue": "^9.0.9" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -342,9 +342,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -382,12 +382,12 @@ } }, "node_modules/@ionic/vue": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-9.0.8.tgz", - "integrity": "sha512-SpHWvKU2a3t2x03cFQt5mLNBbxuPsNtwiJvOhzfZdmEfnyj7bN0zJkevwToiGyYdmBh7kWuuq4wrUohdtzEwgA==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-9.0.9.tgz", + "integrity": "sha512-ASaDjaWfV/Rwt5GyNNOsvKD49VddzoxGi3rl5y/p7lN1+1JB6pkFiTK4BphmPLBI+zVO3S61PO0rVPl3GeXi5Q==", "license": "MIT", "dependencies": { - "@ionic/core": "9.0.8", + "@ionic/core": "9.0.9", "@stencil/vue-output-target": "0.14.2", "ionicons": "^8.0.13" } diff --git a/packages/vue-router/package.json b/packages/vue-router/package.json index 8b1640b106e..0ca060e90bd 100644 --- a/packages/vue-router/package.json +++ b/packages/vue-router/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/vue-router", - "version": "9.0.8", + "version": "9.0.9", "description": "Vue Router integration for @ionic/vue", "scripts": { "eslint": "eslint src", @@ -44,7 +44,7 @@ }, "homepage": "https://github.com/ionic-team/ionic-framework#readme", "dependencies": { - "@ionic/vue": "^9.0.8" + "@ionic/vue": "^9.0.9" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index 365b8454b40..95443549c9e 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -475,9 +475,15 @@ export const createIonRouter = ( currentHistoryPosition ); } else if (incomingRouteParams.routerAction === "pop") { + /** + * Back can skip several entries, e.g. router.go(-3) after a + * tab switch, so use the delta to find the page we left. A + * back done with router.replace has no delta and keeps the + * one-step lookup. + */ leavingLocationInfo = locationHistory.current( initialHistoryPosition, - currentHistoryPosition + 1 + currentHistoryPosition - (delta ?? -1) ); /** @@ -709,7 +715,12 @@ export const createIonRouter = ( routeInfo.lastPathname = currentRouteInfo?.pathname || routeInfo.lastPathname; - routeInfo.pushedByRoute = pushedByRoute; + /** + * A root replace resets the stack, so keeping pushedByRoute + * would show a back button with nowhere to go. + */ + routeInfo.pushedByRoute = + routeInfo.routerDirection === "root" ? undefined : pushedByRoute; /** * Prefer the direction/animation the caller specified on * the navigate call; fall back to the leaving route's diff --git a/packages/vue/CHANGELOG.md b/packages/vue/CHANGELOG.md index 02fcc0bd61a..6b4ed13adf4 100644 --- a/packages/vue/CHANGELOG.md +++ b/packages/vue/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) +* **vue-router:** keep back working after replacing a page in tabs ([#31583](https://github.com/ionic-team/ionic-framework/issues/31583)) ([86390f6](https://github.com/ionic-team/ionic-framework/commit/86390f6deab23b58498c3b9ae782f9e0fc5a4e29)), closes [#30617](https://github.com/ionic-team/ionic-framework/issues/30617) [#31142](https://github.com/ionic-team/ionic-framework/issues/31142) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/packages/vue/package-lock.json b/packages/vue/package-lock.json index 52b0e5a1cd1..94354eac003 100644 --- a/packages/vue/package-lock.json +++ b/packages/vue/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/vue", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/vue", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "@stencil/vue-output-target": "0.14.2", "ionicons": "^8.0.13" }, @@ -346,9 +346,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", diff --git a/packages/vue/package.json b/packages/vue/package.json index dbd2839b29e..a319f2dc4d9 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/vue", - "version": "9.0.8", + "version": "9.0.9", "description": "Vue specific wrapper for @ionic/core", "scripts": { "eslint": "eslint src", @@ -78,7 +78,7 @@ } }, "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "@stencil/vue-output-target": "0.14.2", "ionicons": "^8.0.13" }, diff --git a/packages/vue/src/hooks/router.ts b/packages/vue/src/hooks/router.ts index ed6af2a66b3..6c50c702c77 100644 --- a/packages/vue/src/hooks/router.ts +++ b/packages/vue/src/hooks/router.ts @@ -44,7 +44,7 @@ export const useIonRouter = (): UseIonRouterResult => { navigate(location, "forward", "push", routerAnimation); const replace = (location: any, routerAnimation?: AnimationBuilder) => - navigate(location, "root", "replace", routerAnimation); + navigate(location, "none", "replace", routerAnimation); const back = (routerAnimation?: AnimationBuilder) => goBack(routerAnimation); diff --git a/packages/vue/test/base/src/router/index.ts b/packages/vue/test/base/src/router/index.ts index ce5e63de52e..8d094cc9b18 100644 --- a/packages/vue/test/base/src/router/index.ts +++ b/packages/vue/test/base/src/router/index.ts @@ -200,6 +200,10 @@ const routes: Array = [ path: '/tabs-basic', component: () => import('@/views/TabsBasic.vue') }, + { + path: '/tabs-standalone', + component: () => import('@/views/TabsStandalone.vue') + }, { path: '/tabs-search-params/', component: () => import('@/views/tabs-search-params/TabsSearchParams.vue'), diff --git a/packages/vue/test/base/src/views/Home.vue b/packages/vue/test/base/src/views/Home.vue index b8fbfdf04e7..505d5295bb0 100644 --- a/packages/vue/test/base/src/views/Home.vue +++ b/packages/vue/test/base/src/views/Home.vue @@ -53,6 +53,9 @@ Tabs with Basic Navigation + + Tab Bar without Tabs + Tabs with Similar Route Prefixes diff --git a/packages/vue/test/base/src/views/TabsStandalone.vue b/packages/vue/test/base/src/views/TabsStandalone.vue new file mode 100644 index 00000000000..0415ff555f0 --- /dev/null +++ b/packages/vue/test/base/src/views/TabsStandalone.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts b/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts index e70a1427de4..92024433f12 100644 --- a/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts +++ b/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts @@ -44,7 +44,13 @@ test.describe('useIonRouter.navigate with routerAction="replace"', () => { expect(await readCurrentDirection(page)).toBe('forward'); }); - test('default useIonRouter.replace() keeps direction "root"', async ({ page }) => { + // The default replace keeps the stack beneath the replaced page. + test('default useIonRouter.replace() should use direction "none"', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30617', + }); + await page.goto('/'); await ionPageVisible(page, 'home'); @@ -52,6 +58,6 @@ test.describe('useIonRouter.navigate with routerAction="replace"', () => { await page.evaluate(() => (window as any).debugIonRouter.replace('/routing')); await ionPageVisible(page, 'routing'); - expect(await readCurrentDirection(page)).toBe('root'); + expect(await readCurrentDirection(page)).toBe('none'); }); }); diff --git a/packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts b/packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts new file mode 100644 index 00000000000..3c78fa49149 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from './utils/test-base'; +import { ionPageVisible, ionPageHidden, ionSwipeToGoBack } from './utils/test-utils'; + +/** + * The test fixture disables animations, so these cover swipe to go back without them. + */ +test.describe('Swipe to go back without animations', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?ionic:mode=ios'); + await ionPageVisible(page, 'home'); + + await page.locator('ion-item#routing').click({ force: true }); + await ionPageVisible(page, 'routing'); + await ionPageHidden(page, 'home'); + }); + + test('should go back when the swipe completes', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'home'); + await expect(page).toHaveURL(/\/\?/); + }); + + test('should stay on the current page when the swipe is abandoned', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionSwipeToGoBack(page, false); + + await ionPageVisible(page, 'routing'); + await ionPageHidden(page, 'home'); + await expect(page).toHaveURL(/\/routing/); + }); +}); diff --git a/packages/vue/test/base/tests/e2e/playwright/tab-bar-standalone.spec.ts b/packages/vue/test/base/tests/e2e/playwright/tab-bar-standalone.spec.ts new file mode 100644 index 00000000000..c4076db86a8 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/tab-bar-standalone.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from './utils/test-base'; +import { ionPageVisible, tabClick } from './utils/test-utils'; + +/** + * https://github.com/ionic-team/ionic-framework/issues/30361 + * ion-tab-bar used without ion-tabs or ion-router-outlet, with the app + * navigating by method from the tab buttons. + */ +test.describe('Tab Bar: standalone', () => { + test('should switch tabs on click without errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warning') { + errors.push(msg.text()); + } + }); + + await page.goto('/tabs-standalone'); + await ionPageVisible(page, 'tabs-standalone'); + + const tab1 = page.locator('ion-tab-button#tab-button-tab1'); + const tab2 = page.locator('ion-tab-button#tab-button-tab2'); + + await expect(tab1).toHaveClass(/tab-selected/); + await expect(page.locator('#active-tab')).toHaveText('tab1'); + + await tabClick(page, 'tab2'); + + await expect(tab2).toHaveClass(/tab-selected/); + await expect(tab1).not.toHaveClass(/tab-selected/); + await expect(page.locator('#active-tab')).toHaveText('tab2'); + // With no router outlet, a tab button must not change the URL itself. + await expect(page).toHaveURL(/\/tabs-standalone(\?|$)/); + + await tabClick(page, 'tab1'); + + await expect(tab1).toHaveClass(/tab-selected/); + await expect(tab2).not.toHaveClass(/tab-selected/); + await expect(page.locator('#active-tab')).toHaveText('tab1'); + + expect(errors).toEqual([]); + }); + + test('should navigate by method from a tab button', async ({ page }) => { + await page.goto('/tabs-standalone'); + await ionPageVisible(page, 'tabs-standalone'); + + await tabClick(page, 'routing'); + + await ionPageVisible(page, 'routing'); + await expect(page).toHaveURL(/\/routing(\?|$)/); + }); +}); diff --git a/packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts b/packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts new file mode 100644 index 00000000000..0042e35e099 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from './utils/test-base'; +import { ionBackClick, ionPageVisible, ionPageDoesNotExist, ionRouterNavigate, ionRouterReplace, routerPush, tabClick } from './utils/test-utils'; + +/** + * Replacing a page in a tab stack should keep the pages beneath it. + * + * @see https://github.com/ionic-team/ionic-framework/issues/30617 + */ +test.describe('Tabs: replacing a page inside a tab stack', () => { + test('should return to the tab root on back after a tab switch and a forward replace', async ({ page }) => { + await page.goto('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + await routerPush(page, '/tabs/tab1/view1'); + await ionPageVisible(page, 'tab1view1'); + + await tabClick(page, 'tab2'); + await ionPageVisible(page, 'tab2'); + + await tabClick(page, 'tab1'); + await ionPageVisible(page, 'tab1view1'); + + await ionRouterNavigate(page, '/tabs/tab1/view2', 'forward', 'replace'); + await ionPageVisible(page, 'tab1view2'); + + await ionBackClick(page, 'tab1view2'); + + await expect.poll(() => new URL(page.url()).pathname).toBe('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + await ionPageDoesNotExist(page, 'tab1view2'); + }); + + test('should return to the tab root on back after useIonRouter.replace', async ({ page }) => { + await page.goto('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + await routerPush(page, '/tabs/tab1/view1'); + await ionPageVisible(page, 'tab1view1'); + + await ionRouterReplace(page, '/tabs/tab1/view2'); + await ionPageVisible(page, 'tab1view2'); + + await ionBackClick(page, 'tab1view2'); + + await expect.poll(() => new URL(page.url()).pathname).toBe('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + await ionPageDoesNotExist(page, 'tab1view2'); + }); + + // A root replace resets the stack, so there's nothing to go back to. + test('should not show a back button after a root replace', async ({ page }) => { + await page.goto('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + await routerPush(page, '/tabs/tab1/view1'); + await ionPageVisible(page, 'tab1view1'); + + await ionRouterNavigate(page, '/tabs/tab1/view2', 'root', 'replace'); + await ionPageVisible(page, 'tab1view2'); + + await expect(page.locator('.ion-page[data-pageid="tab1view2"] ion-back-button')).toBeHidden(); + }); +}); diff --git a/packages/vue/test/base/tests/unit/router-outlet.spec.ts b/packages/vue/test/base/tests/unit/router-outlet.spec.ts index 9c3b544faab..507c29c04f5 100644 --- a/packages/vue/test/base/tests/unit/router-outlet.spec.ts +++ b/packages/vue/test/base/tests/unit/router-outlet.spec.ts @@ -80,7 +80,7 @@ describe('Routing', () => { expect.anything(), expect.objectContaining({ // "root" and "none" both resolve to duration=0 via hasRootDirection. - direction: "root", + direction: "none", duration: 0, animationBuilder: undefined }) @@ -142,7 +142,7 @@ describe('Routing', () => { expect.anything(), expect.anything(), expect.objectContaining({ - direction: "root", + direction: "none", duration: undefined, animationBuilder: animation })