Merge branch 'main' into chore-update-next-from-main

This commit is contained in:
Brandy Carney
2024-05-02 16:43:54 -04:00
876 changed files with 4034 additions and 2766 deletions

View File

@@ -220,6 +220,14 @@ export interface IonicConfig {
*/
platform?: PlatformConfig;
/**
* @experimental
* When defined, Ionic will move focus to the appropriate element after each
* page transition. This ensures that users relying on assistive technology
* are informed when a page transition happens.
*/
focusManagerPriority?: FocusManagerPriority[];
/**
* @experimental
* If `true`, the [CloseWatcher API](https://github.com/WICG/close-watcher) will be used to handle
@@ -247,6 +255,8 @@ export interface IonicConfig {
_ce?: (eventName: string, opts: any) => any;
}
type FocusManagerPriority = 'content' | 'heading' | 'banner';
export const setupConfig = (config: IonicConfig) => {
const win = window as any;
const Ionic = win.Ionic;

View File

@@ -0,0 +1,123 @@
import { config } from '@global/config';
import { printIonWarning } from '@utils/logging';
/**
* Moves focus to a specified element. Note that we do not remove the tabindex
* because that can result in an unintentional blur. Non-focusables can't be
* focused, so the body will get focused again.
*/
const moveFocus = (el: HTMLElement) => {
el.tabIndex = -1;
el.focus();
};
/**
* Elements that are hidden using `display: none` should not be focused even if
* they are present in the DOM.
*/
const isVisible = (el: HTMLElement) => {
return el.offsetParent !== null;
};
/**
* The focus controller allows us to manage focus within a view so assistive
* technologies can inform users of changes to the navigation state. Traditional
* native apps have a way of informing assistive technology about a navigation
* state change. Mobile browsers have this too, but only when doing a full page
* load. In a single page app we do not do that, so we need to build this
* integration ourselves.
*/
export const createFocusController = (): FocusController => {
const saveViewFocus = (referenceEl?: HTMLElement) => {
const focusManagerEnabled = config.get('focusManagerPriority', false);
/**
* When going back to a previously visited page focus should typically be moved
* back to the element that was last focused when the user was on this view.
*/
if (focusManagerEnabled) {
const activeEl = document.activeElement;
if (activeEl !== null && referenceEl?.contains(activeEl)) {
activeEl.setAttribute(LAST_FOCUS, 'true');
}
}
};
const setViewFocus = (referenceEl: HTMLElement) => {
const focusManagerPriorities = config.get('focusManagerPriority', false);
/**
* If the focused element is a descendant of the referenceEl then it's possible
* that the app developer manually moved focus, so we do not want to override that.
* This can happen with inputs the are focused when a view transitions in.
*/
if (Array.isArray(focusManagerPriorities) && !referenceEl.contains(document.activeElement)) {
/**
* When going back to a previously visited view focus should always be moved back
* to the element that the user was last focused on when they were on this view.
*/
const lastFocus = referenceEl.querySelector<HTMLElement>(`[${LAST_FOCUS}]`);
if (lastFocus && isVisible(lastFocus)) {
moveFocus(lastFocus);
return;
}
for (const priority of focusManagerPriorities) {
/**
* For each recognized case (excluding the default case) make sure to return
* so that the fallback focus behavior does not run.
*
* We intentionally query for specific roles/semantic elements so that the
* transition manager can work with both Ionic and non-Ionic UI components.
*
* If new selectors are added, be sure to remove the outline ring by adding
* new selectors to rule in core.scss.
*/
switch (priority) {
case 'content':
const content = referenceEl.querySelector<HTMLElement>('main, [role="main"]');
if (content && isVisible(content)) {
moveFocus(content);
return;
}
break;
case 'heading':
const headingOne = referenceEl.querySelector<HTMLElement>('h1, [role="heading"][aria-level="1"]');
if (headingOne && isVisible(headingOne)) {
moveFocus(headingOne);
return;
}
break;
case 'banner':
const header = referenceEl.querySelector<HTMLElement>('header, [role="banner"]');
if (header && isVisible(header)) {
moveFocus(header);
return;
}
break;
default:
printIonWarning(`Unrecognized focus manager priority value ${priority}`);
break;
}
}
/**
* If there is nothing to focus then focus the page so focus at least moves to
* the correct view. The browser will then determine where within the page to
* move focus to.
*/
moveFocus(referenceEl);
}
};
return {
saveViewFocus,
setViewFocus,
};
};
export type FocusController = {
saveViewFocus: (referenceEl?: HTMLElement) => void;
setViewFocus: (referenceEl: HTMLElement) => void;
};
const LAST_FOCUS = 'ion-last-focus';

View File

@@ -0,0 +1,64 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import type { E2ELocator } from '@utils/test/playwright';
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('focus controller: generic components'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/utils/focus-controller/test/generic', config);
});
test('should focus heading', async ({ page }) => {
const goToPageOneButton = page.locator('page-root button.page-one');
const nav = page.locator('ion-nav') as E2ELocator;
const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange');
// Focus heading on Page One
await goToPageOneButton.click();
await ionNavDidChange.next();
const pageOneTitle = page.locator('page-one h1');
await expect(pageOneTitle).toBeFocused();
});
test('should focus banner', async ({ page }) => {
const goToPageThreeButton = page.locator('page-root button.page-three');
const nav = page.locator('ion-nav') as E2ELocator;
const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange');
const pageThreeHeader = page.locator('page-three header');
await goToPageThreeButton.click();
await ionNavDidChange.next();
await expect(pageThreeHeader).toBeFocused();
});
test('should focus content', async ({ page }) => {
const goToPageTwoButton = page.locator('page-root button.page-two');
const nav = page.locator('ion-nav') as E2ELocator;
const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange');
const pageTwoContent = page.locator('page-two main');
await goToPageTwoButton.click();
await ionNavDidChange.next();
await expect(pageTwoContent).toBeFocused();
});
test('should return focus when going back', async ({ page, browserName }) => {
test.skip(browserName === 'webkit', 'Desktop Safari does not consider buttons to be focusable');
const goToPageOneButton = page.locator('page-root button.page-one');
const nav = page.locator('ion-nav') as E2ELocator;
const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange');
const pageOneBackButton = page.locator('page-one ion-back-button');
await goToPageOneButton.click();
await ionNavDidChange.next();
await pageOneBackButton.click();
await ionNavDidChange.next();
await expect(goToPageOneButton).toBeFocused();
});
});
});

View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Focus Manager</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>
<script>
class PageRoot extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<h1>Root</h1>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-nav-link router-direction="forward" component="page-one">
<button class="page-one">Go to Page One</button>
</ion-nav-link>
<ion-nav-link router-direction="forward" component="page-two">
<button class="page-two">Go to Page Two</button>
</ion-nav-link>
<ion-nav-link router-direction="forward" component="page-three">
<button class="page-three">Go to Page Three</button>
</ion-nav-link>
</ion-content>
`;
}
}
class PageOne extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
<h1>Page One</h1>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
Content
</ion-content>
`;
}
}
class PageTwo extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<main class="ion-padding">
Content
</main>
`;
}
}
class PageThree extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<header>
<ion-toolbar>
<ion-buttons slot="start">
<!-- Back button is hidden when not in an ion-header, so default-href makes it visible -->
<ion-back-button default-href="/"></ion-back-button>
</ion-buttons>
</ion-toolbar>
</header>
<ion-content class="ion-padding">
Content
</ion-content>
`;
}
}
customElements.define('page-root', PageRoot);
customElements.define('page-one', PageOne);
customElements.define('page-two', PageTwo);
customElements.define('page-three', PageThree);
window.Ionic = {
config: {
focusManagerPriority: ['heading', 'banner', 'content'],
},
};
</script>
</head>
<body>
<ion-app>
<ion-router>
<ion-route url="/" component="page-root"></ion-route>
<ion-route url="/page-one" component="page-one"></ion-route>
<ion-route url="/page-two" component="page-two"></ion-route>
<ion-route url="/page-three" component="page-three"></ion-route>
</ion-router>
<ion-nav></ion-nav>
</ion-app>
</body>
</html>

View File

@@ -0,0 +1,64 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import type { E2ELocator } from '@utils/test/playwright';
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('focus controller: ionic components'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/utils/focus-controller/test/ionic', config);
});
test('should focus heading', async ({ page }) => {
const goToPageOneButton = page.locator('page-root ion-button.page-one');
const nav = page.locator('ion-nav') as E2ELocator;
const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange');
// Focus heading on Page One
await goToPageOneButton.click();
await ionNavDidChange.next();
const pageOneTitle = page.locator('page-one ion-title');
await expect(pageOneTitle).toBeFocused();
});
test('should focus banner', async ({ page }) => {
const goToPageThreeButton = page.locator('page-root ion-button.page-three');
const nav = page.locator('ion-nav') as E2ELocator;
const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange');
const pageThreeHeader = page.locator('page-three ion-header');
await goToPageThreeButton.click();
await ionNavDidChange.next();
await expect(pageThreeHeader).toBeFocused();
});
test('should focus content', async ({ page }) => {
const goToPageTwoButton = page.locator('page-root ion-button.page-two');
const nav = page.locator('ion-nav') as E2ELocator;
const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange');
const pageTwoContent = page.locator('page-two ion-content');
await goToPageTwoButton.click();
await ionNavDidChange.next();
await expect(pageTwoContent).toBeFocused();
});
test('should return focus when going back', async ({ page, browserName }) => {
test.skip(browserName === 'webkit', 'Desktop Safari does not consider buttons to be focusable');
const goToPageOneButton = page.locator('page-root ion-button.page-one');
const nav = page.locator('ion-nav') as E2ELocator;
const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange');
const pageOneBackButton = page.locator('page-one ion-back-button');
await goToPageOneButton.click();
await ionNavDidChange.next();
await pageOneBackButton.click();
await ionNavDidChange.next();
await expect(goToPageOneButton).toBeFocused();
});
});
});

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Focus Manager</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>
<script>
class PageRoot extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title aria-level="1" role="heading">Root</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-nav-link router-direction="forward" component="page-one">
<ion-button class="page-one">Go to Page One</ion-button>
</ion-nav-link>
<ion-nav-link router-direction="forward" component="page-two">
<ion-button class="page-two">Go to Page Two</ion-button>
</ion-nav-link>
<ion-nav-link router-direction="forward" component="page-three">
<ion-button class="page-three">Go to Page Three</ion-button>
</ion-nav-link>
</ion-content>
`;
}
}
class PageOne extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
<ion-title aria-level="1" role="heading">Page One</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
Content
</ion-content>
`;
}
}
class PageTwo extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-content class="ion-padding">
Content
</ion-content>
`;
}
}
class PageThree extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
Content
</ion-content>
`;
}
}
customElements.define('page-root', PageRoot);
customElements.define('page-one', PageOne);
customElements.define('page-two', PageTwo);
customElements.define('page-three', PageThree);
window.Ionic = {
config: {
focusManagerPriority: ['heading', 'banner', 'content'],
},
};
</script>
</head>
<body>
<ion-app>
<ion-router>
<ion-route url="/" component="page-root"></ion-route>
<ion-route url="/page-one" component="page-one"></ion-route>
<ion-route url="/page-two" component="page-two"></ion-route>
<ion-route url="/page-three" component="page-three"></ion-route>
</ion-router>
<ion-nav></ion-nav>
</ion-app>
</body>
</html>

View File

@@ -199,7 +199,7 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
* behind the sheet should be focusable until
* the backdrop is enabled.
*/
if (lastOverlay.classList.contains('ion-disable-focus-trap')) {
if (lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS)) {
return;
}
@@ -990,3 +990,5 @@ const revealOverlaysToScreenReaders = () => {
}
}
};
export const FOCUS_TRAP_DISABLE_CLASS = 'ion-disable-focus-trap';

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,3 +1,4 @@
import { config } from '@global/config';
import { Build, writeTask } from '@stencil/core';
import {
@@ -8,10 +9,12 @@ import {
} from '../../components/nav/constants';
import type { NavOptions, NavDirection } from '../../components/nav/nav-interface';
import type { Animation, AnimationBuilder } from '../animation/animation-interface';
import { createFocusController } from '../focus-controller';
import { raf } from '../helpers';
const iosTransitionAnimation = () => import('./ios.transition');
const mdTransitionAnimation = () => import('./md.transition');
const focusController = createFocusController();
// TODO(FW-2832): types
@@ -40,6 +43,8 @@ const beforeTransition = (opts: TransitionOptions) => {
const enteringEl = opts.enteringEl;
const leavingEl = opts.leavingEl;
focusController.saveViewFocus(leavingEl);
setZIndex(enteringEl, leavingEl, opts.direction);
if (opts.showGoBack) {
@@ -80,6 +85,8 @@ const afterTransition = (opts: TransitionOptions) => {
leavingEl.classList.remove('ion-page-invisible');
leavingEl.style.removeProperty('pointer-events');
}
focusController.setViewFocus(enteringEl);
};
const getAnimationBuilder = async (opts: TransitionOptions): Promise<AnimationBuilder | undefined> => {
@@ -125,8 +132,13 @@ const animation = async (animationBuilder: AnimationBuilder, opts: TransitionOpt
const noAnimation = async (opts: TransitionOptions): Promise<TransitionResult> => {
const enteringEl = opts.enteringEl;
const leavingEl = opts.leavingEl;
const focusManagerEnabled = config.get('focusManagerPriority', false);
await waitForReady(opts, false);
/**
* If the focus manager is enabled then we need to wait for Ionic components to be
* rendered otherwise the component to focus may not be focused because it is hidden.
*/
await waitForReady(opts, focusManagerEnabled);
fireWillEvents(enteringEl, leavingEl);
fireDidEvents(enteringEl, leavingEl);

View File

@@ -75,8 +75,10 @@ const createLargeTitleTransition = (
const leavingLargeTitleBox = leavingLargeTitle.getBoundingClientRect();
const enteringBackButtonBox = enteringBackButton.getBoundingClientRect();
const enteringBackButtonTextEl = shadow(enteringBackButton).querySelector('.button-text')!;
const enteringBackButtonTextBox = enteringBackButtonTextEl.getBoundingClientRect();
const enteringBackButtonTextEl = shadow(enteringBackButton).querySelector('.button-text');
// 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();
@@ -88,6 +90,7 @@ const createLargeTitleTransition = (
leavingLargeTitle,
leavingLargeTitleBox,
leavingLargeTitleTextBox,
enteringBackButtonBox,
enteringBackButtonTextEl,
enteringBackButtonTextBox
);
@@ -106,8 +109,10 @@ const createLargeTitleTransition = (
const enteringLargeTitleBox = enteringLargeTitle.getBoundingClientRect();
const leavingBackButtonBox = leavingBackButton.getBoundingClientRect();
const leavingBackButtonTextEl = shadow(leavingBackButton).querySelector('.button-text')!;
const leavingBackButtonTextBox = leavingBackButtonTextEl.getBoundingClientRect();
const leavingBackButtonTextEl = shadow(leavingBackButton).querySelector('.button-text');
// 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();
@@ -119,6 +124,7 @@ const createLargeTitleTransition = (
enteringLargeTitle,
enteringLargeTitleBox,
enteringLargeTitleTextBox,
leavingBackButtonBox,
leavingBackButtonTextEl,
leavingBackButtonTextBox
);
@@ -147,8 +153,8 @@ const animateBackButton = (
backDirection: boolean,
backButtonEl: HTMLIonBackButtonElement,
backButtonBox: DOMRect,
backButtonTextEl: HTMLElement,
backButtonTextBox: DOMRect,
backButtonTextEl: HTMLElement | null,
backButtonTextBox: DOMRect | undefined,
largeTitleEl: HTMLIonTitleElement,
largeTitleTextBox: DOMRect
) => {
@@ -158,31 +164,35 @@ const animateBackButton = (
const ICON_ORIGIN_X = rtl ? 'left' : 'right';
const CONTAINER_ORIGIN_X = rtl ? 'right' : 'left';
let WIDTH_SCALE = 1;
let HEIGHT_SCALE = 1;
/**
* When the title and back button texts match
* then they should overlap during the page transition.
* If the texts do not match up then the back button text scale adjusts
* to not perfectly match the large title text otherwise the
* proportions will be incorrect.
* When the texts match we scale both the width and height to account for
* font weight differences between the title and back button.
*/
const doTitleAndButtonTextsMatch = backButtonTextEl.textContent?.trim() === largeTitleEl.textContent?.trim();
const WIDTH_SCALE = largeTitleTextBox.width / backButtonTextBox.width;
/**
* We subtract an offset to account for slight sizing/padding
* differences between the title and the back button.
*/
const HEIGHT_SCALE = (largeTitleTextBox.height - LARGE_TITLE_SIZE_OFFSET) / backButtonTextBox.height;
const TEXT_START_SCALE = doTitleAndButtonTextsMatch
? `scale(${WIDTH_SCALE}, ${HEIGHT_SCALE})`
: `scale(${HEIGHT_SCALE})`;
let TEXT_START_SCALE = `scale(${HEIGHT_SCALE})`;
const TEXT_END_SCALE = 'scale(1)';
if (backButtonTextEl && backButtonTextBox) {
/**
* When the title and back button texts match then they should overlap during the
* page transition. If the texts do not match up then the back button text scale
* adjusts to not perfectly match the large title text otherwise the proportions
* will be incorrect. When the texts match we scale both the width and height to
* account for font weight differences between the title and back button.
*/
const doTitleAndButtonTextsMatch = backButtonTextEl.textContent?.trim() === largeTitleEl.textContent?.trim();
WIDTH_SCALE = largeTitleTextBox.width / backButtonTextBox.width;
/**
* Subtract an offset to account for slight sizing/padding differences between the
* title and the back button.
*/
HEIGHT_SCALE = (largeTitleTextBox.height - LARGE_TITLE_SIZE_OFFSET) / backButtonTextBox.height;
/**
* Even though we set TEXT_START_SCALE to HEIGHT_SCALE above, we potentially need
* to re-compute this here since the HEIGHT_SCALE may have changed.
*/
TEXT_START_SCALE = doTitleAndButtonTextsMatch ? `scale(${WIDTH_SCALE}, ${HEIGHT_SCALE})` : `scale(${HEIGHT_SCALE})`;
}
const backButtonIconEl = shadow(backButtonEl).querySelector('ion-icon')!;
const backButtonIconBox = backButtonIconEl.getBoundingClientRect();
@@ -293,12 +303,11 @@ const animateBackButton = (
top: '0px',
[CONTAINER_ORIGIN_X]: '0px',
})
.keyframes(CONTAINER_KEYFRAMES);
enteringBackButtonTextAnimation
.beforeStyles({
'transform-origin': `${TEXT_ORIGIN_X} top`,
})
/**
* The write hooks must be set on this animation as it is guaranteed to run. Other
* animations such as the back button text animation will not run if the back button
* has no visible text.
*/
.beforeAddWrite(() => {
backButtonEl.style.setProperty('display', 'none');
clonedBackButtonEl.style.setProperty(TEXT_ORIGIN_X, BACK_BUTTON_START_OFFSET);
@@ -308,6 +317,12 @@ const animateBackButton = (
clonedBackButtonEl.style.setProperty('display', 'none');
clonedBackButtonEl.style.removeProperty(TEXT_ORIGIN_X);
})
.keyframes(CONTAINER_KEYFRAMES);
enteringBackButtonTextAnimation
.beforeStyles({
'transform-origin': `${TEXT_ORIGIN_X} top`,
})
.keyframes(TEXT_KEYFRAMES);
enteringBackButtonIconAnimation
@@ -330,8 +345,9 @@ const animateLargeTitle = (
largeTitleEl: HTMLIonTitleElement,
largeTitleBox: DOMRect,
largeTitleTextBox: DOMRect,
backButtonTextEl: HTMLElement,
backButtonTextBox: DOMRect
backButtonBox: DOMRect,
backButtonTextEl: HTMLElement | null,
backButtonTextBox: DOMRect | undefined
) => {
/**
* The horizontal transform origin for the large title
@@ -354,59 +370,76 @@ const animateLargeTitle = (
* title and the back button due to padding and font weight.
*/
const LARGE_TITLE_TRANSLATION_OFFSET = 8;
let END_TRANSLATE_X = rtl
? `-${window.innerWidth - backButtonBox.right - LARGE_TITLE_TRANSLATION_OFFSET}px`
: `${backButtonBox.x + LARGE_TITLE_TRANSLATION_OFFSET}px`;
/**
* The scaled title should (roughly) overlap the back button.
* This ensures that the back button and title overlap during
* the animation. Note that since both elements either fade in
* or fade out over the course of the animation, neither element
* will be fully visible on top of the other. As a result, the overlap
* does not need to be perfect, so approximate values are acceptable here.
* How much to scale the large title up/down by.
*/
const END_TRANSLATE_X = rtl
? `-${window.innerWidth - backButtonTextBox.right - LARGE_TITLE_TRANSLATION_OFFSET}px`
: `${backButtonTextBox.x - LARGE_TITLE_TRANSLATION_OFFSET}px`;
let HEIGHT_SCALE = 0.5;
/**
* The top of the scaled large title
* should match with the top of the
* back button text element.
* We subtract 2px to account for the top padding
* on the large title element.
* The large title always starts full size.
*/
const LARGE_TITLE_TOP_PADDING = 2;
const END_TRANSLATE_Y = `${backButtonTextBox.y - LARGE_TITLE_TOP_PADDING}px`;
/**
* In the forward direction, the large title should start at its
* normal size and then scale down to be (roughly) the size of the
* back button on the other view. In the backward direction, the
* large title should start at (roughly) the size of the back button
* and then scale up to its original size.
*
* Note that since both elements either fade in
* or fade out over the course of the animation, neither element
* will be fully visible on top of the other. As a result, the overlap
* does not need to be perfect, so approximate values are acceptable here.
*/
/**
* When the title and back button texts match
* then they should overlap during the page transition.
* If the texts do not match up then the large title text scale adjusts
* to not perfectly match the back button text otherwise the
* proportions will be incorrect.
* When the texts match we scale both the width and height to account for
* font weight differences between the title and back button.
*/
const doTitleAndButtonTextsMatch = backButtonTextEl.textContent?.trim() === largeTitleEl.textContent?.trim();
const WIDTH_SCALE = backButtonTextBox.width / largeTitleTextBox.width;
const HEIGHT_SCALE = backButtonTextBox.height / (largeTitleTextBox.height - LARGE_TITLE_SIZE_OFFSET);
const START_SCALE = 'scale(1)';
const END_SCALE = doTitleAndButtonTextsMatch ? `scale(${WIDTH_SCALE}, ${HEIGHT_SCALE})` : `scale(${HEIGHT_SCALE})`;
/**
* By default, we don't worry about having the large title scaled to perfectly
* match the back button because we don't know if the back button's text matches
* the large title's text.
*/
let END_SCALE = `scale(${HEIGHT_SCALE})`;
// Text element not rendered if developers pass text="" to the back button
if (backButtonTextEl && backButtonTextBox) {
/**
* The scaled title should (roughly) overlap the back button. This ensures that
* the back button and title overlap during the animation. Note that since both
* elements either fade in or fade out over the course of the animation, neither
* element will be fully visible on top of the other. As a result, the overlap
* 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`;
/**
* In the forward direction, the large title should start at its normal size and
* then scale down to be (roughly) the size of the back button on the other view.
* In the backward direction, the large title should start at (roughly) the size
* of the back button and then scale up to its original size.
* Note that since both elements either fade in or fade out over the course of the
* animation, neither element will be fully visible on top of the other. As a result,
* the overlap does not need to be perfect, so approximate values are acceptable here.
*/
/**
* When the title and back button texts match then they should overlap during the
* page transition. If the texts do not match up then the large title text scale
* adjusts to not perfectly match the back button text otherwise the proportions
* will be incorrect. When the texts match we scale both the width and height to
* account for font weight differences between the title and back button.
*/
const doTitleAndButtonTextsMatch = backButtonTextEl.textContent?.trim() === largeTitleEl.textContent?.trim();
const WIDTH_SCALE = backButtonTextBox.width / largeTitleTextBox.width;
HEIGHT_SCALE = backButtonTextBox.height / (largeTitleTextBox.height - LARGE_TITLE_SIZE_OFFSET);
/**
* Even though we set TEXT_START_SCALE to HEIGHT_SCALE above, we potentially need
* to re-compute this here since the HEIGHT_SCALE may have changed.
*/
END_SCALE = doTitleAndButtonTextsMatch ? `scale(${WIDTH_SCALE}, ${HEIGHT_SCALE})` : `scale(${HEIGHT_SCALE})`;
}
/**
* The midpoints of the back button and the title should align such that the back
* button and title appear to be centered with each other.
*/
const backButtonMidPoint = backButtonBox.top + backButtonBox.height / 2;
const titleMidPoint = (largeTitleBox.height * HEIGHT_SCALE) / 2;
const END_TRANSLATE_Y = `${backButtonMidPoint - titleMidPoint}px`;
const BACKWARDS_KEYFRAMES = [
{ offset: 0, opacity: 0, transform: `translate3d(${END_TRANSLATE_X}, ${END_TRANSLATE_Y}, 0) ${END_SCALE}` },