Conversation
Preserve measured tab count geometry and drag calibration, probe fixes, and in-progress FAB and iPad changes. This is a review snapshot, not a completed parity claim; latest FAB and iPad changes still need full verification.
Playwright test resultsDetails
Failed testschromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for tabs |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
Restore the original iOS 26 public API surface without replacement helpers or hidden registration. Remove implementations, consumer setup, docs, CSS switches, and effect-specific tests. Preserve measurements and existing API/CSS improvements; record verification limits.
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
Remove one-off native measurement infrastructure and raw fixtures. Reduce additional browser coverage from 130 to 49 cases focused on theme-owned contracts. Keep narrow tab boundaries, correct phone/tablet fixture identity, and remove test-only demo registration. Record retention rationale and historical measurement provenance.
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
Reuse main's page-level transition, preserved header blur and fixed back-button clone instead of adjusting the content-only cover. Keep the measured iOS 26 30% retreat and omit Native UI Shell integration. Extend existing navigation regressions without adding test cases.
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
| }, | ||
| "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios26", | ||
| "dependencies": { | ||
| "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#main" |
There was a problem hiding this comment.
🔴 公開パッケージを通常の npm 環境へインストールできない
新しい依存は GitHub の SSH URL を使うため、SSH 鍵のない利用者の npm install が失敗します。認証できても可変な main を解決するため、同じリリースが将来別コードを取得します。
Learn more
このパッケージは公開 npm パッケージですが、新しい実行時依存は git+ssh で取得されます。一般的な CI、コンテナ、利用者端末には GitHub 用 SSH 鍵がなく、依存解決の時点でインストールが停止します。また、公開される package.json は lockfile の固定コミットではなく #main を保持します。そのため、テーマの同じバージョンでもインストール日時により異なる utility コードが入ります。
Example: SSH 鍵を設定していない新規プロジェクトで npm install @rdlabo/ionic-theme-ios26 を実行すると、GitHub SSH 認証で失敗します。認証済み環境でも、utility の main 更新後は以前と異なる依存内容が入り得ます。
Recommended fix: @rdlabo/ionic-theme-utils を npm レジストリへ公開し、互換性を確認した不変の semver バージョンへ依存してください。Git 依存が不可避なら、少なくとも匿名取得可能な HTTPS URLと不変のコミット SHA を指定してください。
Was this helpful? React with 👍 or 👎 to provide feedback.
| export { | ||
| TabBarSearchableType, | ||
| type TabBarSearchableFunction, | ||
| type SearchableEventCache, | ||
| type ElementSizes, | ||
| type ElementReferences, | ||
| } from '@rdlabo/ionic-theme-utils'; |
| // Let the real browser click run first. Only drag-to-another-tab needs a | ||
| // synthetic click when the browser retargets its click to the common bar. | ||
| if (ended.type === 'touch') touchClicks.set(ended.id, endTime + 1000); | ||
| finishPending = () => { | ||
| pending = 0; | ||
| finishPending = undefined; | ||
| if (sequence !== token || pointer !== ended || !enabled()) return; | ||
| const hit = ended.dragged | ||
| ? (dragTarget(event.clientX) ?? ended.target) | ||
| : (doc.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLIonTabButtonElement>('ion-tab-button') ?? null); | ||
| if (!allowed(hit)) return abort(); | ||
| // WKWebView can suppress its compatibility click after a touch mutates | ||
| // the rendered surface, including a long press. Retain Ionic's handler, | ||
| // but deliver one click if none arrived; swallow only its late duplicate. | ||
| if (!ended.clicked) { | ||
| lateClick = { target: hit, until: win.performance.now() + 1000 }; | ||
| hit.click(); | ||
| } |
| import type { Animation } from '@ionic/core'; | ||
| import { createAnimation } from '@ionic/core'; | ||
| import type { TransitionOptions } from './index'; | ||
| import { getIonPageElement } from './index'; | ||
|
|
||
| const DURATION = 540; | ||
| export { shadow }; | ||
|
|
||
| // TODO(FW-2832): types | ||
|
|
||
| const getClonedElement = <T extends HTMLIonBackButtonElement | HTMLIonTitleElement>(tagName: string) => { | ||
| return document.querySelector<T>(`${tagName}.ion-cloned-element`); | ||
| }; | ||
|
|
||
| export const shadow = <T extends Element>(el: T): ShadowRoot | T => { | ||
| return el.shadowRoot || el; | ||
| }; | ||
|
|
||
| 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-large'; | ||
|
|
||
| if (tabs != null) { | ||
| const activeTab = tabs.querySelector('ion-tab:not(.tab-hidden), .ion-page:not(.ion-page-hidden)'); | ||
| return activeTab != null ? activeTab.querySelector(query) : null; | ||
| } | ||
|
|
||
| return refEl.querySelector(query); | ||
| }; | ||
|
|
||
| const getBackButton = (refEl: any, backDirection: boolean) => { | ||
| const tabs = refEl.tagName === 'ION-TABS' ? refEl : refEl.querySelector('ion-tabs'); | ||
| let buttonsList = []; | ||
|
|
||
| if (tabs != null) { | ||
| const activeTab = tabs.querySelector('ion-tab:not(.tab-hidden), .ion-page:not(.ion-page-hidden)'); | ||
| if (activeTab != null) { | ||
| buttonsList = activeTab.querySelectorAll('ion-buttons'); | ||
| } | ||
| } else { | ||
| buttonsList = refEl.querySelectorAll('ion-buttons'); | ||
| } | ||
|
|
||
| for (const buttons of buttonsList) { | ||
| const parentHeader = buttons.closest('ion-header'); | ||
| const activeHeader = parentHeader && !parentHeader.classList.contains('header-collapse-condense-inactive'); | ||
| const backButton = buttons.querySelector('ion-back-button'); | ||
| const buttonsCollapse = buttons.classList.contains('buttons-collapse'); | ||
| const startSlot = buttons.slot === 'start' || buttons.slot === ''; | ||
|
|
||
| if (backButton !== null && startSlot && ((buttonsCollapse && activeHeader && backDirection) || !buttonsCollapse)) { | ||
| return backButton; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| }; | ||
|
|
||
| const createLargeTitleTransition = ( | ||
| rootAnimation: Animation, | ||
| rtl: boolean, | ||
| backDirection: boolean, | ||
| enteringEl: HTMLElement, | ||
| leavingEl: HTMLElement | undefined, | ||
| ) => { | ||
| const enteringBackButton = getBackButton(enteringEl, backDirection); | ||
| const leavingLargeTitle = getLargeTitle(leavingEl); | ||
|
|
||
| const enteringLargeTitle = getLargeTitle(enteringEl); | ||
| const leavingBackButton = getBackButton(leavingEl, backDirection); | ||
|
|
||
| const shouldAnimationForward = enteringBackButton !== null && leavingLargeTitle !== null && !backDirection; | ||
| const shouldAnimationBackward = enteringLargeTitle !== null && leavingBackButton !== null && backDirection; | ||
|
|
||
| if (shouldAnimationForward) { | ||
| const leavingLargeTitleBox = leavingLargeTitle.getBoundingClientRect(); | ||
| const enteringBackButtonBox = enteringBackButton.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(); | ||
|
|
||
| animateLargeTitle( | ||
| rootAnimation, | ||
| rtl, | ||
| backDirection, | ||
| leavingLargeTitle, | ||
| leavingLargeTitleBox, | ||
| leavingLargeTitleTextBox, | ||
| enteringBackButtonBox, | ||
| enteringBackButtonTextEl, | ||
| enteringBackButtonTextBox, | ||
| ); | ||
| // animateBackButton( | ||
| // rootAnimation, | ||
| // rtl, | ||
| // backDirection, | ||
| // enteringBackButton, | ||
| // enteringBackButtonBox, | ||
| // enteringBackButtonTextEl, | ||
| // enteringBackButtonTextBox, | ||
| // leavingLargeTitle, | ||
| // leavingLargeTitleTextBox, | ||
| // ); | ||
| } else if (shouldAnimationBackward) { | ||
| const enteringLargeTitleBox = enteringLargeTitle.getBoundingClientRect(); | ||
| const leavingBackButtonBox = leavingBackButton.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(); | ||
|
|
||
| animateLargeTitle( | ||
| rootAnimation, | ||
| rtl, | ||
| backDirection, | ||
| enteringLargeTitle, | ||
| enteringLargeTitleBox, | ||
| enteringLargeTitleTextBox, | ||
| leavingBackButtonBox, | ||
| leavingBackButtonTextEl, | ||
| leavingBackButtonTextBox, | ||
| ); | ||
| // animateBackButton( | ||
| // rootAnimation, | ||
| // rtl, | ||
| // backDirection, | ||
| // leavingBackButton, | ||
| // leavingBackButtonBox, | ||
| // leavingBackButtonTextEl, | ||
| // leavingBackButtonTextBox, | ||
| // enteringLargeTitle, | ||
| // enteringLargeTitleTextBox, | ||
| // ); | ||
| } | ||
|
|
||
| return { | ||
| forward: shouldAnimationForward, | ||
| backward: shouldAnimationBackward, | ||
| }; | ||
| }; | ||
|
|
||
| const animateBackButton = ( | ||
| rootAnimation: Animation, | ||
| rtl: boolean, | ||
| backDirection: boolean, | ||
| backButtonEl: HTMLIonBackButtonElement, | ||
| backButtonBox: DOMRect, | ||
| backButtonTextEl: HTMLElement | null, | ||
| backButtonTextBox: DOMRect | undefined, | ||
| largeTitleEl: HTMLIonTitleElement, | ||
| largeTitleTextBox: DOMRect, | ||
| ) => { | ||
| const BACK_BUTTON_START_OFFSET = rtl ? `calc(100% - ${backButtonBox.right + 4}px)` : `${backButtonBox.left - 4}px`; | ||
|
|
||
| const TEXT_ORIGIN_X = rtl ? 'right' : 'left'; | ||
| const ICON_ORIGIN_X = rtl ? 'left' : 'right'; | ||
|
|
||
| const CONTAINER_ORIGIN_X = rtl ? 'right' : 'left'; | ||
| let WIDTH_SCALE = 1; | ||
| let HEIGHT_SCALE = 1; | ||
|
|
||
| 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(); | ||
|
|
||
| /** | ||
| * 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. | ||
| */ | ||
| const CONTAINER_START_TRANSLATE_X = rtl | ||
| ? `${backButtonIconBox.width / 2 - (backButtonIconBox.right - backButtonBox.right)}px` | ||
| : `${backButtonBox.left - backButtonIconBox.width / 2}px`; | ||
| const CONTAINER_END_TRANSLATE_X = rtl ? `-${window.innerWidth - backButtonBox.right}px` : `${backButtonBox.left}px`; | ||
|
|
||
| /** | ||
| * Back button container should be | ||
| * aligned to the top of the title container | ||
| * so the texts overlap as the back button | ||
| * text begins to fade in. | ||
| */ | ||
| const CONTAINER_START_TRANSLATE_Y = `${largeTitleTextBox.top}px`; | ||
|
|
||
| /** | ||
| * The cloned back button should align exactly with the | ||
| * real back button on the entering page otherwise there will | ||
| * be a layout shift. | ||
| */ | ||
| const CONTAINER_END_TRANSLATE_Y = `${backButtonBox.top}px`; | ||
|
|
||
| /** | ||
| * In the forward direction, the cloned back button | ||
| * container should translate from over the large title | ||
| * to over the back button. In the backward direction, | ||
| * it should translate from over the back button to over | ||
| * the large title. | ||
| */ | ||
| const FORWARD_CONTAINER_KEYFRAMES = [ | ||
| { offset: 0, transform: `translate3d(${CONTAINER_START_TRANSLATE_X}, ${CONTAINER_START_TRANSLATE_Y}, 0)` }, | ||
| { offset: 1, transform: `translate3d(${CONTAINER_END_TRANSLATE_X}, ${CONTAINER_END_TRANSLATE_Y}, 0)` }, | ||
| ]; | ||
| const BACKWARD_CONTAINER_KEYFRAMES = [ | ||
| { offset: 0, transform: `translate3d(${CONTAINER_END_TRANSLATE_X}, ${CONTAINER_END_TRANSLATE_Y}, 0)` }, | ||
| { offset: 1, transform: `translate3d(${CONTAINER_START_TRANSLATE_X}, ${CONTAINER_START_TRANSLATE_Y}, 0)` }, | ||
| ]; | ||
| const CONTAINER_KEYFRAMES = backDirection ? BACKWARD_CONTAINER_KEYFRAMES : FORWARD_CONTAINER_KEYFRAMES; | ||
|
|
||
| /** | ||
| * In the forward direction, the text in the cloned back button | ||
| * should start to be (roughly) the size of the large title | ||
| * and then scale down to be the size of the actual back button. | ||
| * The text should also translate, but that translate is handled | ||
| * by the container keyframes. | ||
| */ | ||
| const FORWARD_TEXT_KEYFRAMES = [ | ||
| { offset: 0, opacity: 0, transform: TEXT_START_SCALE }, | ||
| { offset: 1, opacity: 1, transform: TEXT_END_SCALE }, | ||
| ]; | ||
| const BACKWARD_TEXT_KEYFRAMES = [ | ||
| { offset: 0, opacity: 1, transform: TEXT_END_SCALE }, | ||
| { offset: 1, opacity: 0, transform: TEXT_START_SCALE }, | ||
| ]; | ||
| const TEXT_KEYFRAMES = backDirection ? BACKWARD_TEXT_KEYFRAMES : FORWARD_TEXT_KEYFRAMES; | ||
|
|
||
| /** | ||
| * The icon should scale in/out in the second | ||
| * half of the animation. The icon should also | ||
| * translate, but that translate is handled by the | ||
| * container keyframes. | ||
| */ | ||
| const FORWARD_ICON_KEYFRAMES = [ | ||
| { offset: 0, opacity: 0, transform: 'scale(0.6)' }, | ||
| { offset: 0.6, opacity: 0, transform: 'scale(0.6)' }, | ||
| { offset: 1, opacity: 1, transform: 'scale(1)' }, | ||
| ]; | ||
| const BACKWARD_ICON_KEYFRAMES = [ | ||
| { offset: 0, opacity: 1, transform: 'scale(1)' }, | ||
| { offset: 0.2, opacity: 0, transform: 'scale(0.6)' }, | ||
| { offset: 1, opacity: 0, transform: 'scale(0.6)' }, | ||
| ]; | ||
| const ICON_KEYFRAMES = backDirection ? BACKWARD_ICON_KEYFRAMES : FORWARD_ICON_KEYFRAMES; | ||
|
|
||
| const enteringBackButtonTextAnimation = createAnimation(); | ||
| const enteringBackButtonIconAnimation = createAnimation(); | ||
| const enteringBackButtonAnimation = createAnimation(); | ||
|
|
||
| const clonedBackButtonEl = getClonedElement<HTMLIonBackButtonElement>('ion-back-button')!; | ||
|
|
||
| const clonedBackButtonTextEl = shadow(clonedBackButtonEl).querySelector('.button-text')!; | ||
| const clonedBackButtonIconEl = shadow(clonedBackButtonEl).querySelector('ion-icon')!; | ||
|
|
||
| clonedBackButtonEl.text = backButtonEl.text; | ||
| clonedBackButtonEl.mode = backButtonEl.mode; | ||
| clonedBackButtonEl.icon = backButtonEl.icon; | ||
| clonedBackButtonEl.color = backButtonEl.color; | ||
| clonedBackButtonEl.disabled = backButtonEl.disabled; | ||
|
|
||
| clonedBackButtonEl.style.setProperty('display', 'block'); | ||
| clonedBackButtonEl.style.setProperty('position', 'fixed'); | ||
|
|
||
| enteringBackButtonIconAnimation.addElement(clonedBackButtonIconEl); | ||
| enteringBackButtonTextAnimation.addElement(clonedBackButtonTextEl); | ||
| enteringBackButtonAnimation.addElement(clonedBackButtonEl); | ||
|
|
||
| enteringBackButtonAnimation | ||
| .beforeStyles({ | ||
| position: 'absolute', | ||
| top: '0px', | ||
| [CONTAINER_ORIGIN_X]: '0px', | ||
| }) | ||
| /** | ||
| * 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); | ||
| }) | ||
| .afterAddWrite(() => { | ||
| backButtonEl.style.setProperty('display', ''); | ||
| 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 | ||
| .beforeStyles({ | ||
| 'transform-origin': `${ICON_ORIGIN_X} center`, | ||
| }) | ||
| .keyframes(ICON_KEYFRAMES); | ||
|
|
||
| rootAnimation.addAnimation([enteringBackButtonTextAnimation, enteringBackButtonIconAnimation, enteringBackButtonAnimation]); | ||
| }; | ||
|
|
||
| const animateLargeTitle = ( | ||
| rootAnimation: Animation, | ||
| rtl: boolean, | ||
| backDirection: boolean, | ||
| largeTitleEl: HTMLIonTitleElement, | ||
| largeTitleBox: DOMRect, | ||
| largeTitleTextBox: DOMRect, | ||
| backButtonBox: DOMRect, | ||
| backButtonTextEl: HTMLElement | null, | ||
| backButtonTextBox: DOMRect | undefined, | ||
| ) => { | ||
| /** | ||
| * The horizontal transform origin for the large title | ||
| */ | ||
| const ORIGIN_X = rtl ? 'right' : 'left'; | ||
|
|
||
| const TITLE_START_OFFSET = rtl ? `calc(100% - ${largeTitleBox.right}px)` : `${largeTitleBox.left}px`; | ||
|
|
||
| /** | ||
| * The cloned large should align exactly with the | ||
| * real large title on the leaving page otherwise there will | ||
| * be a layout shift. | ||
| */ | ||
| const START_TRANSLATE_X = '0px'; | ||
| const START_TRANSLATE_Y = `${largeTitleBox.top}px`; | ||
|
|
||
| /** | ||
| * How much to offset the large title translation by. | ||
| * This accounts for differences in sizing between the large | ||
| * 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`; | ||
|
|
||
| /** | ||
| * How much to scale the large title up/down by. | ||
| */ | ||
| let HEIGHT_SCALE = 0.5; | ||
|
|
||
| /** | ||
| * The large title always starts full size. | ||
| */ | ||
| const START_SCALE = 'scale(1)'; | ||
|
|
||
| /** | ||
| * 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}` }, | ||
| { offset: 0.1, opacity: 0 }, | ||
| { offset: 1, opacity: 1, transform: `translate3d(${START_TRANSLATE_X}, ${START_TRANSLATE_Y}, 0) ${START_SCALE}` }, | ||
| ]; | ||
| const FORWARDS_KEYFRAMES = [ | ||
| { | ||
| offset: 0, | ||
| opacity: 0.99, | ||
| transform: `translate3d(${START_TRANSLATE_X}, ${START_TRANSLATE_Y}, 0) ${START_SCALE}`, | ||
| }, | ||
| { offset: 0.6, opacity: 0 }, | ||
| { offset: 1, opacity: 0, transform: `translate3d(${END_TRANSLATE_X}, ${END_TRANSLATE_Y}, 0) ${END_SCALE}` }, | ||
| ]; | ||
|
|
||
| const KEYFRAMES = backDirection ? BACKWARDS_KEYFRAMES : FORWARDS_KEYFRAMES; | ||
|
|
||
| const clonedTitleEl = getClonedElement<HTMLIonTitleElement>('ion-title')!; | ||
| const clonedLargeTitleAnimation = createAnimation(); | ||
|
|
||
| clonedTitleEl.innerText = largeTitleEl.innerText; | ||
| clonedTitleEl.size = largeTitleEl.size; | ||
| clonedTitleEl.color = largeTitleEl.color; | ||
|
|
||
| clonedLargeTitleAnimation.addElement(clonedTitleEl); | ||
|
|
||
| clonedLargeTitleAnimation | ||
| .beforeStyles({ | ||
| 'transform-origin': `${ORIGIN_X} top`, | ||
|
|
||
| /** | ||
| * Since font size changes will cause | ||
| * the dimension of the large title to change | ||
| * we need to set the cloned title height | ||
| * equal to that of the original large title height. | ||
| */ | ||
| height: `${largeTitleBox.height}px`, | ||
| display: '', | ||
| position: 'relative', | ||
| [ORIGIN_X]: TITLE_START_OFFSET, | ||
| }) | ||
| .beforeAddWrite(() => { | ||
| largeTitleEl.style.setProperty('opacity', '0'); | ||
| }) | ||
| .afterAddWrite(() => { | ||
| largeTitleEl.style.setProperty('opacity', ''); | ||
| clonedTitleEl.style.setProperty('display', 'none'); | ||
| }) | ||
| .keyframes(KEYFRAMES); | ||
|
|
||
| rootAnimation.addAnimation(clonedLargeTitleAnimation); | ||
| }; | ||
|
|
||
| export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptions): Animation => { | ||
| try { | ||
| const EASING = 'cubic-bezier(0.32,0.72,0,1)'; | ||
| const OPACITY = 'opacity'; | ||
| const TRANSFORM = 'transform'; | ||
| const CENTER = '0%'; | ||
| const OFF_OPACITY = 0.8; | ||
|
|
||
| const isRTL = navEl.ownerDocument.dir === 'rtl'; | ||
| const OFF_RIGHT = isRTL ? '-99.5%' : '99.5%'; | ||
| const OFF_LEFT = isRTL ? '33%' : '-33%'; | ||
|
|
||
| const enteringEl = opts.enteringEl; | ||
| const leavingEl = opts.leavingEl; | ||
|
|
||
| const backDirection = opts.direction === 'back'; | ||
| const contentEl = enteringEl.querySelector(':scope > ion-content'); | ||
| const headerEls = enteringEl.querySelectorAll(':scope > ion-header > *:not(ion-toolbar), :scope > ion-footer > *'); | ||
| const enteringToolBarEls = enteringEl.querySelectorAll(':scope > ion-header > ion-toolbar'); | ||
|
|
||
| const rootAnimation = createAnimation(); | ||
| const enteringContentAnimation = createAnimation(); | ||
|
|
||
| rootAnimation | ||
| .addElement(enteringEl) | ||
| .duration((opts.duration ?? 0) || DURATION) | ||
| .easing(opts.easing || EASING) | ||
| .fill('both') | ||
| .beforeRemoveClass('ion-page-invisible'); | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/prefer-optional-chain | ||
| if (leavingEl && navEl !== null && navEl !== undefined) { | ||
| const navDecorAnimation = createAnimation(); | ||
| navDecorAnimation.addElement(navEl); | ||
| rootAnimation.addAnimation(navDecorAnimation); | ||
| } | ||
|
|
||
| if (!contentEl && enteringToolBarEls.length === 0 && headerEls.length === 0) { | ||
| enteringContentAnimation.addElement(enteringEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs')!); // REVIEW | ||
| } else { | ||
| enteringContentAnimation.addElement(contentEl!); // REVIEW | ||
| enteringContentAnimation.addElement(headerEls); | ||
| } | ||
|
|
||
| rootAnimation.addAnimation(enteringContentAnimation); | ||
|
|
||
| if (backDirection) { | ||
| enteringContentAnimation | ||
| .beforeClearStyles([OPACITY]) | ||
| .fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`) | ||
| .fromTo(OPACITY, OFF_OPACITY, 1); | ||
| } else { | ||
| // entering content, forward direction | ||
| enteringContentAnimation.beforeClearStyles([OPACITY]).fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`); | ||
| } | ||
|
|
||
| if (contentEl) { | ||
| const enteringTransitionEffectEl = shadow(contentEl).querySelector('.transition-effect'); | ||
| if (enteringTransitionEffectEl) { | ||
| const enteringTransitionCoverEl = enteringTransitionEffectEl.querySelector('.transition-cover'); | ||
| const enteringTransitionShadowEl = enteringTransitionEffectEl.querySelector('.transition-shadow'); | ||
|
|
||
| const enteringTransitionEffect = createAnimation(); | ||
| const enteringTransitionCover = createAnimation(); | ||
| const enteringTransitionShadow = createAnimation(); | ||
|
|
||
| enteringTransitionEffect | ||
| .addElement(enteringTransitionEffectEl) | ||
| .beforeStyles({ opacity: '1', display: 'block' }) | ||
| .afterStyles({ opacity: '', display: '' }); | ||
|
|
||
| enteringTransitionCover | ||
| .addElement(enteringTransitionCoverEl!) // REVIEW | ||
| .beforeClearStyles([OPACITY]) | ||
| .fromTo(OPACITY, 0, 0.1); | ||
|
|
||
| enteringTransitionShadow | ||
| .addElement(enteringTransitionShadowEl!) // REVIEW | ||
| .beforeClearStyles([OPACITY]) | ||
| .fromTo(OPACITY, 0.03, 0.7); | ||
|
|
||
| enteringTransitionEffect.addAnimation([enteringTransitionCover, enteringTransitionShadow]); | ||
| enteringContentAnimation.addAnimation([enteringTransitionEffect]); | ||
| } | ||
| } | ||
|
|
||
| const enteringContentHasLargeTitle = enteringEl.querySelector('ion-header.header-collapse-condense'); | ||
|
|
||
| const { forward, backward } = createLargeTitleTransition(rootAnimation, isRTL, backDirection, enteringEl, leavingEl); | ||
| enteringToolBarEls.forEach((enteringToolBarEl) => { | ||
| const enteringToolBar = createAnimation(); | ||
| enteringToolBar.addElement(enteringToolBarEl); | ||
| rootAnimation.addAnimation(enteringToolBar); | ||
|
|
||
| const enteringTitle = createAnimation(); | ||
| enteringTitle.addElement(enteringToolBarEl.querySelector('ion-title')!); // REVIEW | ||
|
|
||
| const enteringToolBarButtons = createAnimation(); | ||
| const buttons = Array.from(enteringToolBarEl.querySelectorAll('ion-buttons,[menuToggle]')); | ||
|
|
||
| const parentHeader = enteringToolBarEl.closest('ion-header'); | ||
| const inactiveHeader = parentHeader?.classList.contains('header-collapse-condense-inactive'); | ||
|
|
||
| let buttonsToAnimate; | ||
| if (backDirection) { | ||
| buttonsToAnimate = buttons.filter((button) => { | ||
| const isCollapseButton = button.classList.contains('buttons-collapse'); | ||
| return (isCollapseButton && !inactiveHeader) || !isCollapseButton; | ||
| }); | ||
| } else { | ||
| buttonsToAnimate = buttons.filter((button) => !button.classList.contains('buttons-collapse')); | ||
| } | ||
|
|
||
| enteringToolBarButtons.addElement(buttonsToAnimate); | ||
|
|
||
| const enteringToolBarItems = createAnimation(); | ||
| enteringToolBarItems.addElement(enteringToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])')); | ||
|
|
||
| const enteringToolBarBg = createAnimation(); | ||
| enteringToolBarBg.addElement(shadow(enteringToolBarEl).querySelector('.toolbar-background')!); // REVIEW | ||
|
|
||
| const enteringBackButton = createAnimation(); | ||
| const backButtonEl = enteringToolBarEl.querySelector('ion-back-button'); | ||
|
|
||
| if (backButtonEl) { | ||
| enteringBackButton.addElement(backButtonEl); | ||
| } | ||
|
|
||
| enteringToolBar.addAnimation([enteringTitle, enteringToolBarButtons, enteringToolBarItems, enteringToolBarBg, enteringBackButton]); | ||
| enteringToolBarButtons.fromTo(OPACITY, 0.01, 1); | ||
| enteringToolBarItems.fromTo(OPACITY, 0.01, 1); | ||
|
|
||
| if (backDirection) { | ||
| if (!inactiveHeader) { | ||
| enteringTitle.fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`).fromTo(OPACITY, 0.01, 1); | ||
| } | ||
|
|
||
| enteringToolBarItems.fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`); | ||
|
|
||
| // back direction, entering page has a back button | ||
| enteringBackButton.fromTo(OPACITY, 0.01, 1); | ||
| } else { | ||
| // entering toolbar, forward direction | ||
| if (!enteringContentHasLargeTitle) { | ||
| enteringTitle.fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`).fromTo(OPACITY, 0.01, 1); | ||
| } | ||
|
|
||
| enteringToolBarItems.fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`); | ||
| enteringToolBarBg.beforeClearStyles([OPACITY, 'transform']); | ||
|
|
||
| const translucentHeader = parentHeader?.translucent; | ||
| if (!translucentHeader) { | ||
| enteringToolBarBg.fromTo(OPACITY, 0.01, 'var(--opacity)'); | ||
| } else { | ||
| enteringToolBarBg.fromTo('transform', isRTL ? 'translateX(-100%)' : 'translateX(100%)', 'translateX(0px)'); | ||
| } | ||
|
|
||
| // forward direction, entering page has a back button | ||
| if (!forward) { | ||
| enteringBackButton.fromTo(OPACITY, 0.01, 1); | ||
| } | ||
|
|
||
| if (backButtonEl && !forward) { | ||
| const enteringBackBtnText = createAnimation(); | ||
| enteringBackBtnText | ||
| .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW | ||
| .fromTo(`transform`, isRTL ? 'translateX(-100px)' : 'translateX(100px)', 'translateX(0px)'); | ||
|
|
||
| enteringToolBar.addAnimation(enteringBackBtnText); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| // setup leaving view | ||
| if (leavingEl) { | ||
| const leavingContent = createAnimation(); | ||
| const leavingContentEl = leavingEl.querySelector(':scope > ion-content'); | ||
| const leavingToolBarEls = leavingEl.querySelectorAll(':scope > ion-header > ion-toolbar'); | ||
| const leavingHeaderEls = leavingEl.querySelectorAll(':scope > ion-header > *:not(ion-toolbar), :scope > ion-footer > *'); | ||
|
|
||
| if (!leavingContentEl && leavingToolBarEls.length === 0 && leavingHeaderEls.length === 0) { | ||
| leavingContent.addElement(leavingEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs')!); // REVIEW | ||
| } else { | ||
| leavingContent.addElement(leavingContentEl!); // REVIEW | ||
| leavingContent.addElement(leavingHeaderEls); | ||
| } | ||
|
|
||
| rootAnimation.addAnimation(leavingContent); | ||
|
|
||
| if (backDirection) { | ||
| // leaving content, back direction | ||
| leavingContent | ||
| .beforeClearStyles([OPACITY]) | ||
| .fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)'); | ||
|
|
||
| const leavingPage = getIonPageElement(leavingEl) as HTMLElement; | ||
| rootAnimation.afterAddWrite(() => { | ||
| if (rootAnimation.getDirection() === 'normal') { | ||
| leavingPage.style.setProperty('display', 'none'); | ||
| } | ||
| }); | ||
| } else { | ||
| // leaving content, forward direction | ||
| leavingContent.fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`).fromTo(OPACITY, 1, OFF_OPACITY); | ||
| } | ||
|
|
||
| if (leavingContentEl) { | ||
| const leavingTransitionEffectEl = shadow(leavingContentEl).querySelector('.transition-effect'); | ||
|
|
||
| if (leavingTransitionEffectEl) { | ||
| const leavingTransitionCoverEl = leavingTransitionEffectEl.querySelector('.transition-cover'); | ||
| const leavingTransitionShadowEl = leavingTransitionEffectEl.querySelector('.transition-shadow'); | ||
|
|
||
| const leavingTransitionEffect = createAnimation(); | ||
| const leavingTransitionCover = createAnimation(); | ||
| const leavingTransitionShadow = createAnimation(); | ||
|
|
||
| leavingTransitionEffect | ||
| .addElement(leavingTransitionEffectEl) | ||
| .beforeStyles({ opacity: '1', display: 'block' }) | ||
| .afterStyles({ opacity: '', display: '' }); | ||
|
|
||
| leavingTransitionCover | ||
| .addElement(leavingTransitionCoverEl!) // REVIEW | ||
| .beforeClearStyles([OPACITY]) | ||
| .fromTo(OPACITY, 0.1, 0); | ||
|
|
||
| leavingTransitionShadow | ||
| .addElement(leavingTransitionShadowEl!) // REVIEW | ||
| .beforeClearStyles([OPACITY]) | ||
| .fromTo(OPACITY, 0.7, 0.03); | ||
|
|
||
| leavingTransitionEffect.addAnimation([leavingTransitionCover, leavingTransitionShadow]); | ||
| leavingContent.addAnimation([leavingTransitionEffect]); | ||
| } | ||
| } | ||
|
|
||
| leavingToolBarEls.forEach((leavingToolBarEl) => { | ||
| const leavingToolBar = createAnimation(); | ||
| leavingToolBar.addElement(leavingToolBarEl); | ||
|
|
||
| const leavingTitle = createAnimation(); | ||
| leavingTitle.addElement(leavingToolBarEl.querySelector('ion-title')!); // REVIEW | ||
|
|
||
| const leavingToolBarButtons = createAnimation(); | ||
| const buttons = leavingToolBarEl.querySelectorAll('ion-buttons,[menuToggle]'); | ||
|
|
||
| const parentHeader = leavingToolBarEl.closest('ion-header'); | ||
| const inactiveHeader = parentHeader?.classList.contains('header-collapse-condense-inactive'); | ||
|
|
||
| const buttonsToAnimate = Array.from(buttons).filter((button) => { | ||
| const isCollapseButton = button.classList.contains('buttons-collapse'); | ||
| return (isCollapseButton && !inactiveHeader) || !isCollapseButton; | ||
| }); | ||
|
|
||
| leavingToolBarButtons.addElement(buttonsToAnimate); | ||
|
|
||
| const leavingToolBarItems = createAnimation(); | ||
| const leavingToolBarItemEls = leavingToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])'); | ||
| if (leavingToolBarItemEls.length > 0) { | ||
| leavingToolBarItems.addElement(leavingToolBarItemEls); | ||
| } | ||
|
|
||
| const leavingToolBarBg = createAnimation(); | ||
| leavingToolBarBg.addElement(shadow(leavingToolBarEl).querySelector('.toolbar-background')!); // REVIEW | ||
|
|
||
| const leavingBackButton = createAnimation(); | ||
| const backButtonEl = leavingToolBarEl.querySelector('ion-back-button'); | ||
| if (backButtonEl) { | ||
| leavingBackButton.addElement(backButtonEl); | ||
| } | ||
|
|
||
| leavingToolBar.addAnimation([leavingTitle, leavingToolBarButtons, leavingToolBarItems, leavingBackButton, leavingToolBarBg]); | ||
| rootAnimation.addAnimation(leavingToolBar); | ||
|
|
||
| // fade out leaving toolbar items | ||
| leavingBackButton.fromTo(OPACITY, 0.99, 0); | ||
|
|
||
| leavingToolBarButtons.fromTo(OPACITY, 0.99, 0); | ||
| leavingToolBarItems.fromTo(OPACITY, 0.99, 0); | ||
|
|
||
| if (backDirection) { | ||
| if (!inactiveHeader) { | ||
| // leaving toolbar, back direction | ||
| leavingTitle | ||
| .fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)') | ||
| .fromTo(OPACITY, 0.99, 0); | ||
| } | ||
|
|
||
| leavingToolBarItems.fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)'); | ||
| leavingToolBarBg.beforeClearStyles([OPACITY, 'transform']); | ||
| // leaving toolbar, back direction, and there's no entering toolbar | ||
| // should just slide out, no fading out | ||
| const translucentHeader = parentHeader?.translucent; | ||
| if (!translucentHeader) { | ||
| leavingToolBarBg.fromTo(OPACITY, 'var(--opacity)', 0); | ||
| } else { | ||
| leavingToolBarBg.fromTo('transform', 'translateX(0px)', isRTL ? 'translateX(-100%)' : 'translateX(100%)'); | ||
| } | ||
|
|
||
| if (backButtonEl && !backward) { | ||
| const leavingBackBtnText = createAnimation(); | ||
| leavingBackBtnText | ||
| .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW | ||
| .fromTo('transform', `translateX(${CENTER})`, `translateX(${(isRTL ? -124 : 124) + 'px'})`); | ||
| leavingToolBar.addAnimation(leavingBackBtnText); | ||
| } | ||
| } else { | ||
| // leaving toolbar, forward direction | ||
| if (!inactiveHeader) { | ||
| leavingTitle | ||
| .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) | ||
| .fromTo(OPACITY, 0.99, 0) | ||
| .afterClearStyles([TRANSFORM, OPACITY]); | ||
| } | ||
|
|
||
| leavingToolBarItems | ||
| .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) | ||
| .afterClearStyles([TRANSFORM, OPACITY]); | ||
|
|
||
| leavingBackButton.afterClearStyles([OPACITY]); | ||
| leavingTitle.afterClearStyles([OPACITY]); | ||
| leavingToolBarButtons.afterClearStyles([OPACITY]); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| return rootAnimation; | ||
| } catch (err) { | ||
| throw err; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * The scale of the back button during the animation | ||
| * is computed based on the scale of the large title | ||
| * and vice versa. However, we need to account for slight | ||
| * variations in the size of the large title due to | ||
| * padding and font weight. This value should be used to subtract | ||
| * a small amount from the large title height when computing scales | ||
| * to get more accurate scale results. | ||
| */ | ||
| const LARGE_TITLE_SIZE_OFFSET = 10; | ||
| // UIKit26 retreats the previous page by 30% without moving/scaling its large title independently. | ||
| export const iosTransitionAnimation: (navEl: HTMLElement, opts: TransitionOptions) => Animation = | ||
| createIosTransitionAnimation<TransitionOptions>({ | ||
| offLeftPercent: 30, | ||
| getIonPageElement, | ||
| }); |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
/update-screenshots |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
❌ Failed to update screenshots. Please check the workflow run for details. |
|
/update-screenshots |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
❌ Failed to update screenshots. Please check the workflow run for details. |
|
/update-screenshots |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
❌ Failed to update screenshots. Please check the workflow run for details. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
/update-screenshots |
1 similar comment
|
/update-screenshots |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-169/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
|
❌ Failed to update screenshots. Please check the workflow run for details. |
|
/update-screenshots |
Purpose
Raise the iOS 26 theme's native fidelity using the measurement approach from main/iOS 27, while matching iOS 26 appearance and behavior. Base:
ios26(f187d7f), notmain. This remains a Draft, not a completed-parity claim.Library changes
--ion-color-primary-brightness, package manifests/lockfiles, and the original public API surface. The five added button/overlay APIs and their consumer setup have been removed; there are no replacement APIs or hidden registration.Maintenance correction
23b3554:demo/native-parity; they are not a maintained test suite.demo/e2e/README.md.TabsPage.registerEffectsmethod; tests use the existing page lifecycle.Design alignment with main
--ios-theme-tab-overlappattern. No--_custom properties remain in the theme source.utils/api.scss, retaining iOS 26 values; action-sheet button colors/backgrounds inherit Ionic host properties.Complexity budget: follow main first
src/tab-bar/drag.ts). Use main's bounded velocity stretch and four-keyframe rebound, shared held/drag release, and sparse press/tap keyframes instead of 120 Hz trajectory generation.Routed tab and selected-surface fixes
selectedTabis still stale when the release starts. Like main, pass the clicked tab through the release geometry and final background handoff instead of re-reading selection synchronously.z-index: -1on the tab-bar glass pseudo-element. The selected CSS existed but its host background was painted behind the glass layer.Validation and remaining work