From 47584db751f24f1f486bfd098363ea771eb756a6 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 14:10:24 -0700 Subject: [PATCH 01/13] 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 12329d4a78db403c005bba6c069741beb6495a7f Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 14:10:52 -0700 Subject: [PATCH 02/13] 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 420611ed276f4198fba0958fc23284abbcc4056f Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 14:11:32 -0700 Subject: [PATCH 03/13] 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 1b4d039a21d67539455aa024d7a60f343c805295 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 14:11:45 -0700 Subject: [PATCH 04/13] refactor(title): remove animation-only tokens --- core/api.txt | 2 -- core/src/components/title/title.interface.ts | 10 ------ 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, 82 deletions(-) diff --git a/core/api.txt b/core/api.txt index 300afefcff4..a1f440a37ae 100644 --- a/core/api.txt +++ b/core/api.txt @@ -2827,7 +2827,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-padding-bottom ion-title,css-prop,--ion-title-size-large-padding-end @@ -2835,7 +2834,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 0dc052c2371..908d6c53433 100644 --- a/core/src/components/title/title.interface.ts +++ b/core/src/components/title/title.interface.ts @@ -62,16 +62,6 @@ export type IonTitleRecipe = { large?: IonTitleSizeDefinition & { alignItems?: string; - - inner?: { - width?: string; - }; - - transform?: { - origin?: { - x?: string; - }; - }; }; }; }; diff --git a/core/src/components/title/title.scss b/core/src/components/title/title.scss index 91ba77d2322..3aefeb3d90d 100644 --- a/core/src/components/title/title.scss +++ b/core/src/components/title/title.scss @@ -41,7 +41,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-padding-top: Top padding of the large title * @prop --ion-title-size-large-padding-end: Right padding if direction is left-to-right, and left padding if direction is right-to-left of the large title @@ -49,7 +48,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 @@ -181,39 +179,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 5c1054906b5..5985ee426fd 100644 --- a/core/src/themes/ionic/default.tokens.ts +++ b/core/src/themes/ionic/default.tokens.ts @@ -1085,10 +1085,6 @@ export const defaultTheme: DefaultTheme = { weight: 'var(--ion-font-weight-medium)', }, - inner: { - width: '100%', - }, - line: { height: dynamicFont(global.root, 36), }, @@ -1099,12 +1095,6 @@ export const defaultTheme: DefaultTheme = { bottom: 'var(--ion-spacing-0)', start: 'var(--ion-spacing-lg)', }, - - transform: { - origin: { - x: '50%', - }, - }, }, }, }, diff --git a/core/src/themes/ios/default.tokens.ts b/core/src/themes/ios/default.tokens.ts index a9d23120b72..13d402278cb 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 b1297f36815..e4a2e4b0eee 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 4d74a20d34f3c2b6d458e5ca48717b92c3309ada Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 9 Oct 2026 19:29:29 -0700 Subject: [PATCH 05/13] 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); + }); + }); + }); +}); From 7bf0c176dd61446b217a45e6e73194bd0a6a4c8d Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Mon, 19 Oct 2026 19:13:52 -0700 Subject: [PATCH 06/13] fix(toolbar): watch slot content added after the toolbar loads --- .../test/title-placement/toolbar.e2e.ts | 46 +++++++++++++++++++ core/src/components/toolbar/toolbar.tsx | 14 ++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts b/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts index 3336337ec0f..2079565b8b4 100644 --- a/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts +++ b/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; import { configs, test } from '@utils/test/playwright'; /** @@ -43,3 +44,48 @@ configs({ modes: ['ionic-md'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +/** + * Returns how far the title text is from the center of the toolbar. + */ +const getTitleOffset = (page: E2EPage) => { + return page.locator('ion-toolbar').evaluate((toolbar) => { + const range = document.createRange(); + range.selectNodeContents(toolbar.querySelector('ion-title')!); + const text = range.getBoundingClientRect(); + const box = toolbar.getBoundingClientRect(); + + return Math.abs(text.left + text.width / 2 - (box.left + box.width / 2)); + }); +}; + +/** + * This behavior does not vary across directions. + */ +configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('toolbar: title-placement'), () => { + test('should center the title when buttons are added after the toolbar loads', async ({ page }) => { + await page.setContent( + ` + + + Title + + + `, + config + ); + + const toolbar = page.locator('ion-toolbar'); + await toolbar.evaluate((toolbar) => { + toolbar.insertAdjacentHTML( + 'afterbegin', + 'Long button text' + ); + }); + + await expect(toolbar).toHaveClass(/show-end/); + await expect.poll(() => getTitleOffset(page)).toBeLessThanOrEqual(1); + }); + }); +}); diff --git a/core/src/components/toolbar/toolbar.tsx b/core/src/components/toolbar/toolbar.tsx index fb8f7415774..160671e0fc7 100644 --- a/core/src/components/toolbar/toolbar.tsx +++ b/core/src/components/toolbar/toolbar.tsx @@ -273,7 +273,8 @@ export class Toolbar implements ComponentInterface { /** * 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. + * so a centered title never shows off-center. Runs again on slotchange + * to watch content added later. */ private observeSlotContent() { if (typeof ResizeObserver === 'undefined') { @@ -284,6 +285,9 @@ export class Toolbar implements ComponentInterface { this.slotContentObserver = new ResizeObserver(() => this.measureAndUpdateSlots()); } + // Stops watching content that left the slots + this.slotContentObserver.disconnect(); + // 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)); } @@ -342,13 +346,13 @@ export class Toolbar implements ComponentInterface { >
- this.updateSlotClasses}> - this.updateSlotClasses}> + this.updateSlotClasses()}> + this.updateSlotClasses()}>
- this.updateSlotClasses}> - this.updateSlotClasses}> + this.updateSlotClasses()}> + this.updateSlotClasses()}>
); From 1e8ed95279558afb0ecc80d63fb23efa3d2c4453 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Mon, 19 Oct 2026 19:13:52 -0700 Subject: [PATCH 07/13] fix(toolbar): recreate the slot content observer when the toolbar reconnects --- .../test/title-placement/toolbar.e2e.ts | 32 +++++++++++++++++++ core/src/components/toolbar/toolbar.tsx | 8 +++++ 2 files changed, 40 insertions(+) diff --git a/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts b/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts index 2079565b8b4..2733bcc7b5b 100644 --- a/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts +++ b/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts @@ -87,5 +87,37 @@ configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ title, config } await expect(toolbar).toHaveClass(/show-end/); await expect.poll(() => getTitleOffset(page)).toBeLessThanOrEqual(1); }); + + test('should center the title when buttons resize after the toolbar moves', async ({ page }) => { + await page.setContent( + ` + + + + Back + + Title + + + `, + config + ); + + // Lets the load-time measurement finish, so only the observer can measure again + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve))); + + // Moves the toolbar to a new header + await page.evaluate(() => { + const header = document.createElement('ion-header'); + document.body.appendChild(header); + header.appendChild(document.querySelector('ion-toolbar')!); + }); + + await page.locator('ion-toolbar ion-button').evaluate((button) => { + button.textContent = 'Long button text'; + }); + + await expect.poll(() => getTitleOffset(page)).toBeLessThanOrEqual(1); + }); }); }); diff --git a/core/src/components/toolbar/toolbar.tsx b/core/src/components/toolbar/toolbar.tsx index 160671e0fc7..1c30a663433 100644 --- a/core/src/components/toolbar/toolbar.tsx +++ b/core/src/components/toolbar/toolbar.tsx @@ -86,6 +86,14 @@ export class Toolbar implements ComponentInterface { this.updateSlotWidths(); } + connectedCallback() { + // Recreates the observer when the toolbar is moved in the DOM + const titlePlacement = this.getTitlePlacement(); + if (titlePlacement === 'center') { + this.observeSlotContent(); + } + } + disconnectedCallback() { this.slotContentObserver?.disconnect(); this.slotContentObserver = undefined; From a4f5958ed5c5a51ae047065b996b373fd9ae3bd7 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Mon, 19 Oct 2026 19:13:52 -0700 Subject: [PATCH 08/13] perf(header): only read the title origin for large titles Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- core/src/components/header/header.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/header/header.utils.ts b/core/src/components/header/header.utils.ts index f73a91ce059..89175b6816a 100644 --- a/core/src/components/header/header.utils.ts +++ b/core/src/components/header/header.utils.ts @@ -79,7 +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, + innerTitleOrigin: ionTitleEl && isLargeTitle(ionTitleEl) ? getTitleOrigin(ionTitleEl) : undefined, ionButtonsEl: Array.from(toolbar.querySelectorAll('ion-buttons')), } as ToolbarIndex; }), From 73cecc777d240b7dc5d9809dbabe5867033a7b1c Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Mon, 19 Oct 2026 19:13:52 -0700 Subject: [PATCH 09/13] test(title): cover the backward transition and the copy color and font --- .../title/test/transition/title.e2e.ts | 122 ++++++++++++++---- 1 file changed, 97 insertions(+), 25 deletions(-) diff --git a/core/src/components/title/test/transition/title.e2e.ts b/core/src/components/title/test/transition/title.e2e.ts index bf64942225c..ea5169b92d5 100644 --- a/core/src/components/title/test/transition/title.e2e.ts +++ b/core/src/components/title/test/transition/title.e2e.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; import { configs, test } from '@utils/test/playwright'; /** @@ -13,48 +14,119 @@ const getTextBox = (el: Element) => { return { left, right }; }; +/** + * Pauses the transition once the large title copy starts animating, + * on its first frame or just before its last one. + */ +const pauseTransition = (page: E2EPage, frame: 'first' | 'last') => { + return page.evaluate( + (frame) => + new Promise((resolve) => { + const check = () => { + // A finished copy from an earlier transition stays in the page + const isRunning = (animation: Animation) => animation.playState === 'running'; + const copy = document.querySelector('ion-title.ion-cloned-element'); + if (copy && copy.getAnimations().some(isRunning)) { + document + .getAnimations() + .filter(isRunning) + .forEach((animation) => { + animation.pause(); + + // The last frame finishes the transition, which hides the copy + const endTime = Number(animation.effect!.getComputedTiming().endTime); + animation.currentTime = frame === 'first' ? 0 : endTime - 1; + }); + requestAnimationFrame(() => resolve()); + } else { + requestAnimationFrame(check); + } + }; + check(); + }), + frame + ); +}; + /** * 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'), () => { + // TODO(FW-7878): configs() has no md theme on ios mode, so the URL sets the theme ['ios', 'md', 'ionic'].forEach((theme) => { - test(`should start the large title copy on the title text in the ${theme} theme`, async ({ page }) => { + const goto = async (page: E2EPage) => { await page.goto(`/src/components/title/test/transition?ionic:_testing=false&ionic:theme=${theme}`, config); + await expect(page.locator('page-inbox ion-title[size="large"]')).toHaveClass(/hydrated/); + }; + + test(`should start the large title copy on the title text in the ${theme} theme`, async ({ page }) => { + await goto(page); + const titleBox = await page.locator('page-inbox ion-title[size="large"]').evaluate(getTextBox); + + const transitionPaused = pauseTransition(page, 'first'); + await page.locator('page-inbox button.go[data-back-text="match"]').click(); + await transitionPaused; + + const copyBox = await page.locator('ion-title.ion-cloned-element').evaluate(getTextBox); - 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(); - }) - ); + expect(Math.abs(copyBox.left - titleBox.left)).toBeLessThanOrEqual(1); + expect(Math.abs(copyBox.right - titleBox.right)).toBeLessThanOrEqual(1); + }); + + test(`should end the large title copy on the title text when going back in the ${theme} theme`, async ({ + page, + }) => { + await goto(page); + const titleBox = await page.locator('page-inbox ion-title[size="large"]').evaluate(getTextBox); await page.locator('page-inbox button.go[data-back-text="match"]').click(); - await transitionStarted; + await expect(page.locator('page-inbox')).toHaveClass(/ion-page-hidden/); + + const transitionPaused = pauseTransition(page, 'last'); + await page.locator('page-message ion-back-button').click(); + await transitionPaused; 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); }); + + [ + { label: 'default', color: undefined, hue: undefined }, + { label: 'bold primary', color: 'primary', hue: 'bold' }, + { label: 'subtle primary', color: 'primary', hue: 'subtle' }, + ].forEach(({ label, color, hue }) => { + test(`should give the copy the ${label} title color and font in the ${theme} theme`, async ({ page }) => { + await goto(page); + + const largeTitle = page.locator('page-inbox ion-title[size="large"]'); + await largeTitle.evaluate( + (el: HTMLIonTitleElement, { color, hue }) => { + el.color = color; + el.hue = hue as HTMLIonTitleElement['hue']; + }, + { color, hue } + ); + await page.waitForChanges(); + + const getTextStyles = (el: Element) => { + const { color, fontFamily } = getComputedStyle(el); + return { color, fontFamily }; + }; + const titleStyles = await largeTitle.evaluate(getTextStyles); + + const transitionPaused = pauseTransition(page, 'first'); + await page.locator('page-inbox button.go[data-back-text="match"]').click(); + await transitionPaused; + + const copyStyles = await page.locator('ion-title.ion-cloned-element').evaluate(getTextStyles); + + expect(copyStyles).toEqual(titleStyles); + }); + }); }); }); }); From 6ad5f60fcb249429c11b04eec171f88164d9e813 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Thu, 22 Oct 2026 11:09:13 -0700 Subject: [PATCH 10/13] refactor(transition): merge the back button comment blocks --- core/src/utils/transition/ios.transition.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/core/src/utils/transition/ios.transition.ts b/core/src/utils/transition/ios.transition.ts index 1ece5935092..36c31ffd913 100644 --- a/core/src/utils/transition/ios.transition.ts +++ b/core/src/utils/transition/ios.transition.ts @@ -245,11 +245,10 @@ const animateBackButton = ( * We need to offset the container by the icon dimensions * so that the back button text aligns with the large title * 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. + * large title text but the back button text will not. It + * starts 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) - largeTitleStartShift}px` From 0b4d2c64161c89cd82b5306d3f0ef640b0e39319 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Thu, 22 Oct 2026 11:09:13 -0700 Subject: [PATCH 11/13] fix(header): work out the large title origin when it first scales --- core/src/components/header/header.utils.ts | 14 +++++-- .../src/components/header/test/header.spec.ts | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/core/src/components/header/header.utils.ts b/core/src/components/header/header.utils.ts index 89175b6816a..4b9e0ff826a 100644 --- a/core/src/components/header/header.utils.ts +++ b/core/src/components/header/header.utils.ts @@ -41,11 +41,11 @@ export const cloneElement = (tagName: string) => { * text's start doesn't shift. Centered text grows from the center. * * @internal - * @param ionTitleEl - The title whose alignment and direction set the origin. + * @param textAlign - The title's computed `text-align`. + * @param direction - The title's computed `direction`. * @returns The horizontal `transform-origin` keyword: `left`, `right` or `center`. */ -const getTitleOrigin = (ionTitleEl: HTMLIonTitleElement): string => { - const { textAlign, direction } = getComputedStyle(ionTitleEl); +export const getTitleOrigin = (textAlign: string, direction: string): string => { const rtl = direction === 'rtl'; if (textAlign === 'center' || textAlign === '-webkit-center') { @@ -79,7 +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 && isLargeTitle(ionTitleEl) ? getTitleOrigin(ionTitleEl) : undefined, + innerTitleOrigin: undefined, ionButtonsEl: Array.from(toolbar.querySelectorAll('ion-buttons')), } as ToolbarIndex; }), @@ -267,6 +267,12 @@ export const scaleLargeTitles = (toolbars: ToolbarIndex[] = [], scale = 1, trans return; } + // Read once, when the title is first scaled, so a title that becomes large later still gets one + if (toolbar.innerTitleOrigin === undefined) { + const { textAlign, direction } = getComputedStyle(ionTitle); + toolbar.innerTitleOrigin = getTitleOrigin(textAlign, direction); + } + titleDiv.style.transition = transition ? TRANSITION : ''; titleDiv.style.transformOrigin = `${toolbar.innerTitleOrigin} center`; titleDiv.style.transform = `scale3d(${scale}, ${scale}, 1)`; diff --git a/core/src/components/header/test/header.spec.ts b/core/src/components/header/test/header.spec.ts index 37bd5b65848..6771dcdc79b 100644 --- a/core/src/components/header/test/header.spec.ts +++ b/core/src/components/header/test/header.spec.ts @@ -2,7 +2,9 @@ import { newSpecPage } from '@stencil/core/testing'; import { config } from '../../../global/config'; import { ToolbarTitle } from '../../title/title'; +import { Toolbar } from '../../toolbar/toolbar'; import { Header } from '../header'; +import { createHeaderIndex, getTitleOrigin, scaleLargeTitles } from '../header.utils'; describe('header: condense', () => { afterEach(() => { @@ -41,3 +43,39 @@ describe('header: condense', () => { expect(page.root!.classList.contains('header-collapse-condense-hidden')).toBe(true); }); }); + +describe('header: large title origin', () => { + it.each([ + ['start', 'ltr', 'left'], + ['start', 'rtl', 'right'], + ['end', 'ltr', 'right'], + ['end', 'rtl', 'left'], + ['left', 'ltr', 'left'], + ['left', 'rtl', 'left'], + ['right', 'ltr', 'right'], + ['right', 'rtl', 'right'], + ['center', 'ltr', 'center'], + ['center', 'rtl', 'center'], + ['-webkit-center', 'ltr', 'center'], + ])('should grow a title aligned to %s in %s from the %s', (textAlign, direction, origin) => { + expect(getTitleOrigin(textAlign, direction)).toBe(origin); + }); + + it('should set an origin when the title becomes large after the header loads', async () => { + const page = await newSpecPage({ + components: [Header, Toolbar, ToolbarTitle], + html: `Title`, + }); + + const headerIndex = createHeaderIndex(page.root)!; + const title = page.body.querySelector('ion-title')!; + + title.size = 'large'; + await page.waitForChanges(); + + scaleLargeTitles(headerIndex.toolbars, 1.1); + + const innerTitle = title.shadowRoot!.querySelector('.toolbar-title')!; + expect(innerTitle.style.transformOrigin).not.toContain('undefined'); + }); +}); From c82b5ce38b87b39fbf4b96ca9ac3a9668f6f1684 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Thu, 22 Oct 2026 11:09:13 -0700 Subject: [PATCH 12/13] test(toolbar): note that only the ionic theme supports title placement --- core/src/components/toolbar/test/title-placement/toolbar.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts b/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts index 2733bcc7b5b..593be3cbfd5 100644 --- a/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts +++ b/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts @@ -60,6 +60,7 @@ const getTitleOffset = (page: E2EPage) => { }; /** + * Title placement is only supported by the `ionic` theme. * This behavior does not vary across directions. */ configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ title, config }) => { From c9886e7db7ca304cad246c64c6984573816720a4 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Thu, 22 Oct 2026 11:12:53 -0700 Subject: [PATCH 13/13] test(toolbar): mark the title placement themes as a TODO --- .../components/toolbar/test/title-placement/toolbar.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts b/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts index 593be3cbfd5..d0b1ac4df2a 100644 --- a/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts +++ b/core/src/components/toolbar/test/title-placement/toolbar.e2e.ts @@ -3,7 +3,7 @@ import type { E2EPage } from '@utils/test/playwright'; import { configs, test } from '@utils/test/playwright'; /** - * Title placement is only supported by the `ionic` theme. + * TODO(FW-6864): add ios and md when ion-toolbar titlePlacement works in every theme */ configs({ modes: ['ionic-md'] }).forEach(({ title, screenshot, config }) => { test.describe(title('toolbar: title-placement'), () => { @@ -60,7 +60,7 @@ const getTitleOffset = (page: E2EPage) => { }; /** - * Title placement is only supported by the `ionic` theme. + * TODO(FW-6864): add ios and md when ion-toolbar titlePlacement works in every theme * This behavior does not vary across directions. */ configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ title, config }) => {