Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions core/src/components/header/header.utils.ts
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 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;
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: ionTitleEl ? getTitleOrigin(ionTitleEl) : undefined,
ionButtonsEl: Array.from(toolbar.querySelectorAll('ion-buttons')),
} as ToolbarIndex;
}),
Expand Down Expand Up @@ -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)`;
});
};
Expand Down
177 changes: 177 additions & 0 deletions core/src/components/title/test/transition/index.html
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>
60 changes: 60 additions & 0 deletions core/src/components/title/test/transition/title.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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);
});
});
});
});
11 changes: 0 additions & 11 deletions core/src/components/title/title.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,7 @@ export type IonTitleRecipe = {

large?: IonTitleSizeDefinition & {
alignItems?: string;

inner?: {
width?: string;
};

minWidth?: string;

transform?: {
origin?: {
x?: string;
};
};
};
};
};
Expand Down
32 changes: 0 additions & 32 deletions core/src/components/title/title.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading