Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
47584db
test(title): add large title transition test page
thetaPC Oct 9, 2026
12329d4
fix(toolbar): center the title before the first paint
thetaPC Oct 9, 2026
420611e
fix(transition): make large title animations independent of the theme
thetaPC Oct 9, 2026
1b4d039
refactor(title): remove animation-only tokens
thetaPC Oct 9, 2026
4d74a20
test(title): check the large title copy starts on the title text
thetaPC Oct 10, 2026
7bf0c17
fix(toolbar): watch slot content added after the toolbar loads
thetaPC Oct 20, 2026
1e8ed95
fix(toolbar): recreate the slot content observer when the toolbar rec…
thetaPC Oct 20, 2026
a4f5958
perf(header): only read the title origin for large titles
thetaPC Oct 20, 2026
73cecc7
test(title): cover the backward transition and the copy color and font
thetaPC Oct 20, 2026
891eafb
Merge remote-tracking branch 'origin/FW-6863-snapshots' into FW-6863-…
thetaPC Oct 20, 2026
bcf2259
Merge remote-tracking branch 'origin/FW-6863' into FW-6863-animations
thetaPC Oct 20, 2026
f2ae0c5
Merge remote-tracking branch 'origin/FW-6863' into FW-6863-animations
thetaPC Oct 22, 2026
0f93da3
Merge remote-tracking branch 'origin/FW-6863' into FW-6863-animations
thetaPC Oct 22, 2026
6ad5f60
refactor(transition): merge the back button comment blocks
thetaPC Oct 22, 2026
0b4d2c6
fix(header): work out the large title origin when it first scales
thetaPC Oct 22, 2026
c82b5ce
test(toolbar): note that only the ionic theme supports title placement
thetaPC Oct 22, 2026
c9886e7
test(toolbar): mark the title placement themes as a TODO
thetaPC Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2826,15 +2826,13 @@ ion-title,css-prop,--ion-title-position-top
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-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
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-medium-font-size
ion-title,css-prop,--ion-title-size-medium-font-weight
ion-title,css-prop,--ion-title-size-medium-line-height
Expand Down
36 changes: 36 additions & 0 deletions core/src/components/header/header.utils.ts
Comment thread
brandyscarney marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface ToolbarIndex {
background: HTMLElement;
ionTitleEl: HTMLIonTitleElement | undefined;
innerTitleEl: HTMLElement;
innerTitleOrigin: string | undefined;
ionButtonsEl: HTMLElement[] | [];
}

Expand All @@ -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 textAlign - The title's computed `text-align`.
* @param direction - The title's computed `direction`.
* @returns The horizontal `transform-origin` keyword: `left`, `right` or `center`.
*/
export const getTitleOrigin = (textAlign: string, direction: string): string => {
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';
};
Comment on lines +39 to +64

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing tests getTitleOrigin automatically. Could you add a spec covering start, end, left, right and center in LTR and RTL? Also, since it’s cached at index creation, setting size="large" later writes undefined center. Would working it out lazily in scaleLargeTitles avoid that?


export const createHeaderIndex = (headerEl: HTMLElement | undefined): HeaderIndex | undefined => {
if (!headerEl) {
return;
Expand All @@ -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: undefined,
ionButtonsEl: Array.from(toolbar.querySelectorAll('ion-buttons')),
} as ToolbarIndex;
}),
Expand Down Expand Up @@ -238,7 +267,14 @@ 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)`;
});
};
Expand Down
38 changes: 38 additions & 0 deletions core/src/components/header/test/header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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: `<ion-header><ion-toolbar><ion-title>Title</ion-title></ion-toolbar></ion-header>`,
});

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<HTMLElement>('.toolbar-title')!;
expect(innerTitle.style.transformOrigin).not.toContain('undefined');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expect(innerTitle.style.transformOrigin).not.toContain('undefined');
expect(innerTitle.style.transformOrigin).toBe('left center');

This passes on an empty string too, so it wouldn't catch scaleLargeTitles skipping the write. Mock-doc has no textAlign, so it's always left center here, and this version fails both with the write removed and with the old undefined center caching.

});
});
177 changes: 177 additions & 0 deletions core/src/components/title/test/transition/index.html
Comment thread
brandyscarney marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Title - Transition</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>
</head>

<body>
<ion-app>
<ion-nav root="page-inbox"></ion-nav>
</ion-app>

<script>
const SHORT_TITLE = 'Inbox';
const LONG_TITLE = 'Inbox with a name long enough to truncate';

/**
* A large title page. Used on its own, inside tabs and inside a modal,
* so every scenario runs the same transition.
*/
class PageInbox extends HTMLElement {
connectedCallback() {
const inModal = this.closest('ion-modal') !== null;
const isTopLevel = !inModal && this.closest('ion-tabs') === null;

this.innerHTML = `
<ion-header translucent="true">
<ion-toolbar>
<ion-title>${SHORT_TITLE}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content fullscreen="true">
<ion-header scroll-effect="condense">
<ion-toolbar>
<ion-title size="large">${SHORT_TITLE}</ion-title>
</ion-toolbar>
</ion-header>
<div class="ion-padding controls">
<button class="go" data-back-text="match">Go (matching back text)</button>
<button class="go" data-back-text="Back">Go (different back text)</button>
<button class="go" data-back-text="">Go (no back text)</button>
<button class="cycle-color">Cycle title color</button>
<button class="toggle-long">Toggle long title</button>
${inModal ? '<button class="close-modal">Close modal</button>' : ''}
${
isTopLevel
? `<button class="open-modal">Open in modal</button>
<button class="open-tabs">Open in tabs</button>`
: ''
}
</div>
</ion-content>
`;

const largeTitle = this.querySelector('ion-title[size="large"]');

this.querySelectorAll('.go').forEach((button) => {
button.addEventListener('click', () => {
/**
* The back button text sets which part of the transition runs:
* matching text scales width and height, different text scales
* height only, and no text skips the text scaling.
*/
const { backText } = button.dataset;
this.closest('ion-nav').push('page-message', {
backText: backText === 'match' ? largeTitle.textContent : backText,
});
});
});

// Cycles none, primary, then primary with the subtle hue
this.querySelector('.cycle-color').addEventListener('click', () => {
if (!largeTitle.color) {
largeTitle.color = 'primary';
largeTitle.hue = 'bold';
} else if (largeTitle.hue === 'bold') {
largeTitle.hue = 'subtle';
} else {
largeTitle.color = undefined;
largeTitle.hue = undefined;
}
});

this.querySelector('.toggle-long').addEventListener('click', () => {
largeTitle.textContent = largeTitle.textContent === SHORT_TITLE ? LONG_TITLE : SHORT_TITLE;
});

if (inModal) {
this.querySelector('.close-modal').addEventListener('click', () => {
this.closest('ion-modal').dismiss();
});
}

if (isTopLevel) {
this.querySelector('.open-modal').addEventListener('click', () => {
const modal = document.createElement('ion-modal');
modal.component = 'ion-nav';
modal.componentProps = { root: 'page-inbox' };
modal.addEventListener('ionModalDidDismiss', () => modal.remove());
document.body.appendChild(modal);
modal.present();
});

this.querySelector('.open-tabs').addEventListener('click', () => {
this.closest('ion-nav').push('page-tabs');
});
}
}
}

class PageMessage extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
<ion-title>Message</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>Go back to see the back button grow into the large title.</p>
</ion-content>
`;

/**
* The md and ionic themes hide the default back button text,
* so it's always set explicitly.
*/
this.querySelector('ion-back-button').text = this.backText ?? SHORT_TITLE;
}
}

/**
* The transition finds the large title differently when the leaving
* or entering page is ion-tabs, so the inbox also runs inside a tab.
*/
class PageTabs extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-tabs>
<ion-tab tab="inbox">
<page-inbox class="ion-page"></page-inbox>
</ion-tab>
<ion-tab-bar slot="bottom">
<ion-tab-button tab="inbox">
<ion-label>Inbox</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-tabs>
`;
}
}

customElements.define('page-inbox', PageInbox);
customElements.define('page-message', PageMessage);
customElements.define('page-tabs', PageTabs);
</script>
</body>
</html>
Loading
Loading