Repository navigation
fix(transition): make large title animations work in every theme #31559
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
47584db
12329d4
420611e
1b4d039
4d74a20
7bf0c17
1e8ed95
a4f5958
73cecc7
891eafb
bcf2259
f2ae0c5
0f93da3
6ad5f60
0b4d2c6
c82b5ce
c9886e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing tests |
||
|
|
||
| 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: undefined, | ||
| ionButtonsEl: Array.from(toolbar.querySelectorAll('ion-buttons')), | ||
| } as ToolbarIndex; | ||
| }), | ||
|
|
@@ -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)`; | ||
| }); | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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: `<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'); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
This passes on an empty string too, so it wouldn't catch |
||||||
| }); | ||||||
| }); | ||||||
|
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> |
Uh oh!
There was an error while loading. Please reload this page.