From aefddd0d8c55036feba08c209437bf4b0c314b15 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 14:10:24 -0700 Subject: [PATCH 1/5] test(title): add large title transition test page --- .../title/test/transition/index.html | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 core/src/components/title/test/transition/index.html diff --git a/core/src/components/title/test/transition/index.html b/core/src/components/title/test/transition/index.html new file mode 100644 index 00000000000..6eb0c981559 --- /dev/null +++ b/core/src/components/title/test/transition/index.html @@ -0,0 +1,177 @@ + + + + + Title - Transition + + + + + + + + + + + + + + + + + From a63f83e17673606f39a72cc2545f7619ee8ca670 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 14:10:52 -0700 Subject: [PATCH 2/5] fix(toolbar): center the title before the first paint --- core/src/components/toolbar/toolbar.tsx | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/core/src/components/toolbar/toolbar.tsx b/core/src/components/toolbar/toolbar.tsx index 09ab315e35f..fb8f7415774 100644 --- a/core/src/components/toolbar/toolbar.tsx +++ b/core/src/components/toolbar/toolbar.tsx @@ -38,6 +38,7 @@ export class Toolbar implements ComponentInterface { ]; private readonly showClasses = ['show-start', 'show-end', 'show-primary', 'show-secondary']; private readonly slotSizeVars = ['--start-end-size', '--primary-secondary-size']; + private slotContentObserver?: ResizeObserver; @Element() el!: HTMLIonToolbarElement; @@ -85,6 +86,11 @@ export class Toolbar implements ComponentInterface { this.updateSlotWidths(); } + disconnectedCallback() { + this.slotContentObserver?.disconnect(); + this.slotContentObserver = undefined; + } + @Watch('titlePlacement') titlePlacementChanged() { this.updateSlotClasses(); @@ -212,9 +218,12 @@ export class Toolbar implements ComponentInterface { const titlePlacement = this.getTitlePlacement(); if (titlePlacement !== 'center') { this.removeSlotClasses(); + this.slotContentObserver?.disconnect(); return; } + this.observeSlotContent(); + // Check if slots have content const slots = ['start', 'end', 'primary', 'secondary']; @@ -261,6 +270,24 @@ export class Toolbar implements ComponentInterface { this.updateSlotWidths(); } + /** + * Re-measures the slots when their content changes size, such as a back + * button that renders after the toolbar. The callback runs before paint, + * so a centered title never shows off-center. + */ + private observeSlotContent() { + if (typeof ResizeObserver === 'undefined') { + return; + } + + if (this.slotContentObserver === undefined) { + this.slotContentObserver = new ResizeObserver(() => this.measureAndUpdateSlots()); + } + + // The slotted content, not the slots, so setting the slot widths doesn't re-trigger it + this.el.querySelectorAll(':scope > [slot]').forEach((el) => this.slotContentObserver!.observe(el)); + } + private hasSlotContent(slotName: string): boolean { const slotNode = this.el.shadowRoot?.querySelector(`slot[name="${slotName}"]`) as HTMLSlotElement | null; return !!slotNode && slotNode.assignedNodes().length > 0; From 7f3e9839ccc983b9b3ac7bcd4bfc1676037df246 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 14:11:32 -0700 Subject: [PATCH 3/5] fix(transition): make large title animations independent of the theme --- core/src/components/header/header.utils.ts | 30 ++++++ core/src/utils/transition/ios.transition.ts | 112 +++++++++++++++++--- 2 files changed, 126 insertions(+), 16 deletions(-) diff --git a/core/src/components/header/header.utils.ts b/core/src/components/header/header.utils.ts index 7c07da07609..f73a91ce059 100644 --- a/core/src/components/header/header.utils.ts +++ b/core/src/components/header/header.utils.ts @@ -18,6 +18,7 @@ interface ToolbarIndex { background: HTMLElement; ionTitleEl: HTMLIonTitleElement | undefined; innerTitleEl: HTMLElement; + innerTitleOrigin: string | undefined; ionButtonsEl: HTMLElement[] | []; } @@ -35,6 +36,33 @@ export const cloneElement = (tagName: string) => { return clonedEl; }; +/** + * The large title grows from the edge its text is aligned to, so the + * text's start doesn't shift. Centered text grows from the center. + * + * @internal + * @param ionTitleEl - The title whose alignment and direction set the origin. + * @returns The horizontal `transform-origin` keyword: `left`, `right` or `center`. + */ +const getTitleOrigin = (ionTitleEl: HTMLIonTitleElement): string => { + const { textAlign, direction } = getComputedStyle(ionTitleEl); + const rtl = direction === 'rtl'; + + if (textAlign === 'center' || textAlign === '-webkit-center') { + return 'center'; + } + + if (textAlign === 'left' || textAlign === 'right') { + return textAlign; + } + + if (textAlign === 'end') { + return rtl ? 'left' : 'right'; + } + + return rtl ? 'right' : 'left'; +}; + export const createHeaderIndex = (headerEl: HTMLElement | undefined): HeaderIndex | undefined => { if (!headerEl) { return; @@ -51,6 +79,7 @@ export const createHeaderIndex = (headerEl: HTMLElement | undefined): HeaderInde background: toolbar.shadowRoot!.querySelector('.toolbar-background'), ionTitleEl, innerTitleEl: ionTitleEl ? ionTitleEl.shadowRoot!.querySelector('.toolbar-title') : null, + innerTitleOrigin: ionTitleEl ? getTitleOrigin(ionTitleEl) : undefined, ionButtonsEl: Array.from(toolbar.querySelectorAll('ion-buttons')), } as ToolbarIndex; }), @@ -239,6 +268,7 @@ export const scaleLargeTitles = (toolbars: ToolbarIndex[] = [], scale = 1, trans } titleDiv.style.transition = transition ? TRANSITION : ''; + titleDiv.style.transformOrigin = `${toolbar.innerTitleOrigin} center`; titleDiv.style.transform = `scale3d(${scale}, ${scale}, 1)`; }); }; diff --git a/core/src/utils/transition/ios.transition.ts b/core/src/utils/transition/ios.transition.ts index 4e694fe7ebd..1ece5935092 100644 --- a/core/src/utils/transition/ios.transition.ts +++ b/core/src/utils/transition/ios.transition.ts @@ -15,6 +15,42 @@ export const shadow = (el: T): ShadowRoot | T => { return el.shadowRoot || el; }; +/** + * The scale math needs the box around the title's text. Themes can make the + * text element wider than its text, so it's measured at its content width. + * + * @internal + * @param largeTitleEl - The large title to measure. + * @param rtl - Whether the title is in a right-to-left direction. + * @returns The box of the title's text element at its content width, and how + * far the rendered text sits from that box's start, such as when it's centered. + */ +const getLargeTitleTextBox = (largeTitleEl: HTMLIonTitleElement, rtl: boolean) => { + const textEl = shadow(largeTitleEl).querySelector('.toolbar-title')!; + + /** + * A range measures where the text is drawn. The text element's own box + * can't, since it may be wider than the text, such as when centered. + */ + const range = document.createRange(); + range.selectNodeContents(largeTitleEl); + const text = range.getBoundingClientRect(); + + // Shrink the text element to its text for the scale math, then restore it + const width = textEl.style.width; + textEl.style.width = 'auto'; + const box = textEl.getBoundingClientRect(); + textEl.style.width = width; + + /** + * How far the drawn text sits from where the shrunk box starts: 0 for a + * start-aligned title, more for a centered one. The copies start there. + */ + const startShift = Math.max(0, rtl ? box.right - text.right : text.left - box.left); + + return { box, startShift }; +}; + const getLargeTitle = (refEl: any) => { const tabs = refEl.tagName === 'ION-TABS' ? refEl : refEl.querySelector('ion-tabs'); const query = 'ion-content ion-header:not(.header-collapse-condense-inactive) ion-title.title-size-large'; @@ -80,8 +116,10 @@ const createLargeTitleTransition = ( // Text element not rendered if developers pass text="" to the back button const enteringBackButtonTextBox = enteringBackButtonTextEl?.getBoundingClientRect(); - const leavingLargeTitleTextEl = shadow(leavingLargeTitle).querySelector('.toolbar-title')!; - const leavingLargeTitleTextBox = leavingLargeTitleTextEl.getBoundingClientRect(); + const { box: leavingLargeTitleTextBox, startShift: leavingLargeTitleStartShift } = getLargeTitleTextBox( + leavingLargeTitle, + rtl + ); animateLargeTitle( rootAnimation, @@ -90,6 +128,7 @@ const createLargeTitleTransition = ( leavingLargeTitle, leavingLargeTitleBox, leavingLargeTitleTextBox, + leavingLargeTitleStartShift, enteringBackButtonBox, enteringBackButtonTextEl, enteringBackButtonTextBox @@ -103,7 +142,8 @@ const createLargeTitleTransition = ( enteringBackButtonTextEl, enteringBackButtonTextBox, leavingLargeTitle, - leavingLargeTitleTextBox + leavingLargeTitleTextBox, + leavingLargeTitleStartShift ); } else if (shouldAnimationBackward) { const enteringLargeTitleBox = enteringLargeTitle.getBoundingClientRect(); @@ -114,8 +154,10 @@ const createLargeTitleTransition = ( // Text element not rendered if developers pass text="" to the back button const leavingBackButtonTextBox = leavingBackButtonTextEl?.getBoundingClientRect(); - const enteringLargeTitleTextEl = shadow(enteringLargeTitle).querySelector('.toolbar-title')!; - const enteringLargeTitleTextBox = enteringLargeTitleTextEl.getBoundingClientRect(); + const { box: enteringLargeTitleTextBox, startShift: enteringLargeTitleStartShift } = getLargeTitleTextBox( + enteringLargeTitle, + rtl + ); animateLargeTitle( rootAnimation, @@ -124,6 +166,7 @@ const createLargeTitleTransition = ( enteringLargeTitle, enteringLargeTitleBox, enteringLargeTitleTextBox, + enteringLargeTitleStartShift, leavingBackButtonBox, leavingBackButtonTextEl, leavingBackButtonTextBox @@ -137,7 +180,8 @@ const createLargeTitleTransition = ( leavingBackButtonTextEl, leavingBackButtonTextBox, enteringLargeTitle, - enteringLargeTitleTextBox + enteringLargeTitleTextBox, + enteringLargeTitleStartShift ); } @@ -156,7 +200,8 @@ const animateBackButton = ( backButtonTextEl: HTMLElement | null, backButtonTextBox: DOMRect | undefined, largeTitleEl: HTMLIonTitleElement, - largeTitleTextBox: DOMRect + largeTitleTextBox: DOMRect, + largeTitleStartShift: number ) => { const BACK_BUTTON_START_OFFSET = rtl ? `calc(100% - ${backButtonBox.right + 4}px)` : `${backButtonBox.left - 4}px`; @@ -202,9 +247,13 @@ const animateBackButton = ( * text. Otherwise, the back button icon will align with the * large title text but the back button text will not. */ + /** + * Start where the large title's text is, so both copies move together + * from a title that isn't at the start edge, such as a centered one. + */ const CONTAINER_START_TRANSLATE_X = rtl - ? `${backButtonIconBox.width / 2 - (backButtonIconBox.right - backButtonBox.right)}px` - : `${backButtonBox.left - backButtonIconBox.width / 2}px`; + ? `${backButtonIconBox.width / 2 - (backButtonIconBox.right - backButtonBox.right) - largeTitleStartShift}px` + : `${backButtonBox.left - backButtonIconBox.width / 2 + largeTitleStartShift}px`; const CONTAINER_END_TRANSLATE_X = rtl ? `-${window.innerWidth - backButtonBox.right}px` : `${backButtonBox.left}px`; /** @@ -302,6 +351,9 @@ const animateBackButton = ( position: 'absolute', top: '0px', [CONTAINER_ORIGIN_X]: '0px', + + // The copy moves under the pointer, so it must not show hover styles + 'pointer-events': 'none', }) /** * The write hooks must be set on this animation as it is guaranteed to run. Other @@ -309,11 +361,15 @@ const animateBackButton = ( * has no visible text. */ .beforeAddWrite(() => { - backButtonEl.style.setProperty('display', 'none'); + /** + * Hide the back button without removing it from the layout, + * so a title placed next to it doesn't shift into its space. + */ + backButtonEl.style.setProperty('visibility', 'hidden'); clonedBackButtonEl.style.setProperty(TEXT_ORIGIN_X, BACK_BUTTON_START_OFFSET); }) .afterAddWrite(() => { - backButtonEl.style.setProperty('display', ''); + backButtonEl.style.removeProperty('visibility'); clonedBackButtonEl.style.setProperty('display', 'none'); clonedBackButtonEl.style.removeProperty(TEXT_ORIGIN_X); }) @@ -345,6 +401,7 @@ const animateLargeTitle = ( largeTitleEl: HTMLIonTitleElement, largeTitleBox: DOMRect, largeTitleTextBox: DOMRect, + largeTitleStartShift: number, backButtonBox: DOMRect, backButtonTextEl: HTMLElement | null, backButtonTextBox: DOMRect | undefined @@ -354,7 +411,13 @@ const animateLargeTitle = ( */ const ORIGIN_X = rtl ? 'right' : 'left'; - const TITLE_START_OFFSET = rtl ? `calc(100% - ${largeTitleBox.right}px)` : `${largeTitleBox.left}px`; + /** + * The copy's text sits at the start of its box, so the copy starts where + * the title's text is rendered, such as further in when it's centered. + */ + const TITLE_START_OFFSET = rtl + ? `calc(100% - ${largeTitleBox.right - largeTitleStartShift}px)` + : `${largeTitleBox.left + largeTitleStartShift}px`; /** * The cloned large should align exactly with the @@ -370,9 +433,10 @@ const animateLargeTitle = ( * title and the back button due to padding and font weight. */ const LARGE_TITLE_TRANSLATION_OFFSET = 8; + // The copy already starts further in by the shift, so it travels that much less let END_TRANSLATE_X = rtl - ? `-${window.innerWidth - backButtonBox.right - LARGE_TITLE_TRANSLATION_OFFSET}px` - : `${backButtonBox.x + LARGE_TITLE_TRANSLATION_OFFSET}px`; + ? `${-(window.innerWidth - backButtonBox.right - LARGE_TITLE_TRANSLATION_OFFSET - largeTitleStartShift)}px` + : `${backButtonBox.x + LARGE_TITLE_TRANSLATION_OFFSET - largeTitleStartShift}px`; /** * How much to scale the large title up/down by. @@ -401,8 +465,8 @@ const animateLargeTitle = ( * does not need to be perfect, so approximate values are acceptable here. */ END_TRANSLATE_X = rtl - ? `-${window.innerWidth - backButtonTextBox.right - LARGE_TITLE_TRANSLATION_OFFSET}px` - : `${backButtonTextBox.x - LARGE_TITLE_TRANSLATION_OFFSET}px`; + ? `${-(window.innerWidth - backButtonTextBox.right - LARGE_TITLE_TRANSLATION_OFFSET - largeTitleStartShift)}px` + : `${backButtonTextBox.x - LARGE_TITLE_TRANSLATION_OFFSET - largeTitleStartShift}px`; /** * In the forward direction, the large title should start at its normal size and @@ -461,6 +525,12 @@ const animateLargeTitle = ( const clonedTitleEl = getClonedElement('ion-title')!; const clonedLargeTitleAnimation = createAnimation(); + /** + * The copy sits outside the toolbar, so it loses styles that depend on it, + * such as the inherited color and font. Copy them from the title instead. + */ + const { color, fontFamily, paddingTop, paddingRight, paddingBottom, paddingLeft } = getComputedStyle(largeTitleEl); + clonedTitleEl.innerText = largeTitleEl.innerText; clonedTitleEl.size = largeTitleEl.size; clonedTitleEl.color = largeTitleEl.color; @@ -481,9 +551,19 @@ const animateLargeTitle = ( display: '', position: 'relative', [ORIGIN_X]: TITLE_START_OFFSET, + color, + 'font-family': fontFamily, + 'padding-top': paddingTop, + 'padding-right': paddingRight, + 'padding-bottom': paddingBottom, + 'padding-left': paddingLeft, + 'pointer-events': 'none', }) .beforeAddWrite(() => { largeTitleEl.style.setProperty('opacity', '0'); + + // Scale from the text's start edge, not the edge of a wider text element + shadow(clonedTitleEl).querySelector('.toolbar-title')?.style.setProperty('width', 'auto'); }) .afterAddWrite(() => { largeTitleEl.style.setProperty('opacity', ''); From ed24c47f1600ab71b1013496c2c2ab0578d53c01 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 14:11:45 -0700 Subject: [PATCH 4/5] refactor(title): remove animation-only tokens --- core/api.txt | 2 -- core/src/components/title/title.interface.ts | 11 ------- core/src/components/title/title.scss | 32 -------------------- core/src/themes/ionic/default.tokens.ts | 10 ------ core/src/themes/ios/default.tokens.ts | 18 ----------- core/src/themes/md/default.tokens.ts | 10 ------ 6 files changed, 83 deletions(-) diff --git a/core/api.txt b/core/api.txt index 340dddec0b3..5ae230b8766 100644 --- a/core/api.txt +++ b/core/api.txt @@ -2829,7 +2829,6 @@ ion-title,css-prop,--ion-title-size-large-align-items ion-title,css-prop,--ion-title-size-large-font-size ion-title,css-prop,--ion-title-size-large-font-weight ion-title,css-prop,--ion-title-size-large-height -ion-title,css-prop,--ion-title-size-large-inner-width ion-title,css-prop,--ion-title-size-large-line-height ion-title,css-prop,--ion-title-size-large-min-width ion-title,css-prop,--ion-title-size-large-padding-bottom @@ -2838,7 +2837,6 @@ ion-title,css-prop,--ion-title-size-large-padding-start ion-title,css-prop,--ion-title-size-large-padding-top ion-title,css-prop,--ion-title-size-large-position ion-title,css-prop,--ion-title-size-large-text-align -ion-title,css-prop,--ion-title-size-large-transform-origin-x ion-title,css-prop,--ion-title-size-large-width ion-title,css-prop,--ion-title-size-medium-font-size ion-title,css-prop,--ion-title-size-medium-font-weight diff --git a/core/src/components/title/title.interface.ts b/core/src/components/title/title.interface.ts index 08fce62496e..1ccde7b9e65 100644 --- a/core/src/components/title/title.interface.ts +++ b/core/src/components/title/title.interface.ts @@ -62,18 +62,7 @@ export type IonTitleRecipe = { large?: IonTitleSizeDefinition & { alignItems?: string; - - inner?: { - width?: string; - }; - minWidth?: string; - - transform?: { - origin?: { - x?: string; - }; - }; }; }; }; diff --git a/core/src/components/title/title.scss b/core/src/components/title/title.scss index 5084b7ad87c..19a9d76a41a 100644 --- a/core/src/components/title/title.scss +++ b/core/src/components/title/title.scss @@ -43,7 +43,6 @@ * @prop --ion-title-size-large-font-size: Font size of the large title * @prop --ion-title-size-large-font-weight: Font weight of the large title * @prop --ion-title-size-large-height: Height of the large title - * @prop --ion-title-size-large-inner-width: Width of the large title text, measured by the iOS page transition * @prop --ion-title-size-large-line-height: Line height of the large title * @prop --ion-title-size-large-min-width: Minimum width of the large title * @prop --ion-title-size-large-padding-top: Top padding of the large title @@ -52,7 +51,6 @@ * @prop --ion-title-size-large-padding-start: Left padding if direction is left-to-right, and right padding if direction is right-to-left of the large title * @prop --ion-title-size-large-position: Position of the large title * @prop --ion-title-size-large-text-align: Text alignment of the large title - * @prop --ion-title-size-large-transform-origin-x: Horizontal origin the large title scales from when the header collapses, in the reading direction * @prop --ion-title-size-large-width: Width of the large title * * @prop --ion-title-hue-bold-semantic-default-color: Text color of the `bold` hue when a semantic color is applied @@ -184,39 +182,9 @@ text-align: var(--ion-title-size-large-text-align); } -/** - * FW-7295: the header collapse scales the large title text from this - * origin, and the iOS page transition measures the text's width. - */ -:host(.title-size-large) { - @include mixins.transform-origin(var(--ion-title-size-large-transform-origin-x), center); -} - -:host(.title-size-large) .toolbar-title { - @include mixins.transform-origin(inherit); - - width: var(--ion-title-size-large-inner-width); -} - -/** - * FW-7295: the iOS page transition moves a copy of the large title out of - * its toolbar, so restore the color and font it would have inherited. - */ -:host(.title-size-large.ion-cloned-element) { - color: var(--ion-title-color, var(--ion-text-color, #000)); - - /** - * Same tokens as the title, but a different fallback: the copy sits - * outside the toolbar, so `inherit` would pick up the overlay's font. - */ - font-family: var(--ion-title-font-family, var(--ion-font-family)); -} - // Title: Hue // -------------------------------------------------- -// Keep these after the cloned rule so a colored large title's copy keeps its color - :host(.title-hue-bold.ion-color) { color: var(--ion-title-hue-bold-semantic-default-color); } diff --git a/core/src/themes/ionic/default.tokens.ts b/core/src/themes/ionic/default.tokens.ts index 07094e38407..a796ca0d988 100644 --- a/core/src/themes/ionic/default.tokens.ts +++ b/core/src/themes/ionic/default.tokens.ts @@ -1074,19 +1074,9 @@ export const defaultTheme: DefaultTheme = { weight: 'var(--ion-font-weight-medium)', }, - inner: { - width: '100%', - }, - line: { height: dynamicFont(global.root, 36), }, - - transform: { - origin: { - x: '50%', - }, - }, }, }, }, diff --git a/core/src/themes/ios/default.tokens.ts b/core/src/themes/ios/default.tokens.ts index 05fef74f960..128adb39c4e 100644 --- a/core/src/themes/ios/default.tokens.ts +++ b/core/src/themes/ios/default.tokens.ts @@ -1131,18 +1131,6 @@ export const defaultTheme: DefaultTheme = { weight: 'var(--ion-font-weight-bold)', }, - inner: { - /** - * During a page transition - * if the large title and the back button - * texts match up, the back button should be - * scaled to roughly match the dimensions of - * the large title text. The following line - * ensures that the scale values are accurate. - */ - width: 'auto', - }, - padding: { top: 'var(--ion-spacing-xxxs)', end: 'var(--ion-spacing-md)', @@ -1153,12 +1141,6 @@ export const defaultTheme: DefaultTheme = { text: { align: 'start', }, - - transform: { - origin: { - x: '0%', - }, - }, }, }, }, diff --git a/core/src/themes/md/default.tokens.ts b/core/src/themes/md/default.tokens.ts index 8927debf634..55f8514426f 100644 --- a/core/src/themes/md/default.tokens.ts +++ b/core/src/themes/md/default.tokens.ts @@ -1228,22 +1228,12 @@ export const defaultTheme: DefaultTheme = { weight: 'var(--ion-font-weight-normal)', }, - inner: { - width: '100%', - }, - padding: { top: 'var(--ion-spacing-0)', end: 'var(--ion-spacing-xl)', bottom: 'var(--ion-spacing-0)', start: 'var(--ion-spacing-xl)', }, - - transform: { - origin: { - x: '50%', - }, - }, }, }, }, From 04dc4710c6f3444630b6c165d473812fb8d724c1 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 19:29:29 -0700 Subject: [PATCH 5/5] test(title): check the large title copy starts on the title text --- .../title/test/transition/title.e2e.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 core/src/components/title/test/transition/title.e2e.ts diff --git a/core/src/components/title/test/transition/title.e2e.ts b/core/src/components/title/test/transition/title.e2e.ts new file mode 100644 index 00000000000..bf64942225c --- /dev/null +++ b/core/src/components/title/test/transition/title.e2e.ts @@ -0,0 +1,60 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * Measures where the text is drawn, which differs from the + * title's box when the text is centered. + */ +const getTextBox = (el: Element) => { + const range = document.createRange(); + range.selectNodeContents(el); + const { left, right } = range.getBoundingClientRect(); + + return { left, right }; +}; + +/** + * The large title transition only runs in ios mode, so every theme is + * tested with it. Testing mode turns animations off, so it is disabled. + */ +configs({ modes: ['ios'] }).forEach(({ title, config }) => { + test.describe(title('title: transition'), () => { + ['ios', 'md', 'ionic'].forEach((theme) => { + test(`should start the large title copy on the title text in the ${theme} theme`, async ({ page }) => { + await page.goto(`/src/components/title/test/transition?ionic:_testing=false&ionic:theme=${theme}`, config); + + const largeTitle = page.locator('page-inbox ion-title[size="large"]'); + await expect(largeTitle).toHaveClass(/hydrated/); + const titleBox = await largeTitle.evaluate(getTextBox); + + // Pauses the transition on its first frame once the copy starts animating + const transitionStarted = page.evaluate( + () => + new Promise((resolve) => { + const check = () => { + const copy = document.querySelector('ion-title.ion-cloned-element'); + if (copy && copy.getAnimations().length > 0) { + document.getAnimations().forEach((animation) => { + animation.pause(); + animation.currentTime = 0; + }); + requestAnimationFrame(() => resolve()); + } else { + requestAnimationFrame(check); + } + }; + check(); + }) + ); + + await page.locator('page-inbox button.go[data-back-text="match"]').click(); + await transitionStarted; + + const copyBox = await page.locator('ion-title.ion-cloned-element').evaluate(getTextBox); + + expect(Math.abs(copyBox.left - titleBox.left)).toBeLessThanOrEqual(1); + expect(Math.abs(copyBox.right - titleBox.right)).toBeLessThanOrEqual(1); + }); + }); + }); +});