Skip to content

Calibrate iOS 26 theme against native UI measurements - #169

Open
rdlabo wants to merge 22 commits into
ios26from
review/ios26-native-parity
Open

rdlabo wants to merge 22 commits into
ios26from
review/ios26-native-parity

Conversation

@rdlabo

@rdlabo rdlabo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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), not main. This remains a Draft, not a completed-parity claim.

Library changes

  • Correct control geometry, glass styling, and motion within existing CSS/APIs.
  • Support 1–5 tabs, with no FAB for five tabs, using main's shared-overlap layout and compact/tablet sizing. iOS 26 measurements inform the defaults, but fractional native width discontinuities are deliberately not reproduced.
  • Preserve --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

  • Remove the one-off Swift probes, copied native Shell implementation, recovered experiments, raw frame/width dumps, work log, report generators and dedicated test runner/configuration. The measurement tree alone was 10,682 lines. Historical sources remain recoverable at 23b3554:demo/native-parity; they are not a maintained test suite.
  • Reduce additional browser cases from 130 in 11 specs to 49 in 7 specs, under the existing Playwright setup and CI matrix. Static appearance remains owned by the existing screenshot suite; no screenshot baselines were changed.
  • Retain only theme-owned regression coverage: brightness/opt-out compatibility, popover placement/style restoration, selection-effect event ownership/cleanup, tab counts/layout boundaries, range endpoint/dual-thumb behavior, and toggle settling/reduced-motion/customization. Reasons are in demo/e2e/README.md.
  • Remove the test-only TabsPage.registerEffects method; tests use the existing page lifecycle.
  • Replace recorded tab-width oracle tables with narrow/roomy containment and tablet-cap checks. Fixtures explicitly select the intended phone/tablet idiom; they no longer require exact fractional native breakpoints.

Design alignment with main

  • Prefer Ionic CSS properties for button/FAB borders (including dark pressed state), tab-bar and selected-tab backgrounds. Parts remain for effects Ionic does not expose, such as glass refraction and back-button borders.
  • Remove the cell-width and selected-opacity indirections. Tab layout now uses main's --ios-theme-tab-overlap pattern. No --_ custom properties remain in the theme source.
  • Remove the overlay-only Sass file and three opacity variables. Reuse the same shared-material approach as main in utils/api.scss, retaining iOS 26 values; action-sheet button colors/backgrounds inherit Ionic host properties.
  • Revert unused changes in the old generic gesture implementation and clone utility to the ios26 base. Remove duplicate toggle declarations.
  • Keep the same 15 public exports. Add only three focused CSS-override regression cases to the existing specs (52 cases at this stage; 53 after the routed-tab regression below); no new test files/framework or screenshot baselines.

Complexity budget: follow main first

  • Retain separate tab rim rendering, shared glass recipes and toggle interpolation: main already uses those approaches.
  • Remove the iOS 26-only drag history, convolution/deformation model and velocity-derived release solver (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.
  • Reduce tab motion data/helpers from 174 to 58 lines. Remove the separate selected-press/left-tap recordings; retain iOS 26 timing/deformation defaults with simple approximations at main's level of complexity.
  • Remove container-query tab-cell breakpoints. Keep 1–5 tabs, five without FAB, and tablet caps; compact-width measurements may differ because main's shared-overlap approach takes priority.
  • Replace the toolbar-only segment motion table with the same shared-table/height-factor approach as main. Drop range endpoint-specific shadow micro-adjustments; retain endpoint containment and RTL/dual-thumb behavior.
  • Keep click ownership, cancellation, opt-out/reduced-motion and teardown protections: they prevent behavioral defects rather than tune individual pixels. No public JS API additions or new test infrastructure.

Routed tab and selected-surface fixes

  • Reproduce the actual Angular navigation bug: clicking Docs routes correctly, but the lens returns to Index because selectedTab is 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.
  • Restore main's z-index: -1 on the tab-bar glass pseudo-element. The selected CSS existed but its host background was painted behind the glass layer.
  • Add one actual-routing convergence regression to the existing tab spec, not another synchronous selection fixture. Library code is reduced rather than expanded.

Validation and remaining work

  • After the routing fix: 53/53 Chromium + 53/53 WebKit, 106 total. After the final one-line layering fix: 38/38 tab cases across both browsers. Library build, changed-file formatting and diff checks pass. Production demo build passed in the preceding simplification commit; existing warnings remain. This is not a fresh Ionic 8/9 package matrix or a full screenshot run.
  • Actual demo screenshots confirm that the selected background is visible in light/dark Chromium and WebKit; the Docs lens now converges at Docs rather than Index.
  • A one-off check of the actual demo Alert/Action Sheet confirmed background/shadow and action-sheet button overrides in light/dark Chromium and WebKit; iOS 26 surface opacity (0.7/0.24) and 34px radius remain unchanged. This check is not another maintained spec.
  • The earlier test-pruning commit does not change production theme source; the subsequent design-alignment correction does. Removed experiments and older passing totals are not evidence of current whole-theme parity.
  • Native iOS 26.1/26.5 and iPad measurements informed the theme, but full visual/motion acceptance remains incomplete: FAB material, icon/label alignment, navigation, remaining held/drag/release behavior, searchable tabs and full bridge/handoff coverage. Physical iOS 26 validation remains unavailable.

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.
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Playwright test results

failed  88 failed
passed  59 passed

Details

stats  147 tests across 10 suites
duration  8 minutes, 39 seconds
commit  aa6e4c4
info  This detailed result covers Ionic 9 only. Ionic 8 runs against the same screenshots in a separate matrix job; check the workflow run for both results. To update the screenshots, comment with /update-screenshots.

Failed tests

chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for tabs
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for inputs
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for alert:no-cancel
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toast:primary
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toast:warning
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for alert:remove-app
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toast:top
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for alert:button-only
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for modal:sheet
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toast:success
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toast:middle
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for floating-action-button-fixed
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toast:danger
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for button
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for progress-indicators:icon-only
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for accordion
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for progress-indicators:message
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toast:anchor
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for checkbox
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for select:alert
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for segment
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for select:modal
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for select:action-sheet
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for searchbar
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toast:bottom
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for alert:all
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for action-sheet:no-cancel
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for item-list
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for card
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for action-sheet:button-only
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toolbar
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for modal:card
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for floating-action-button
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for popover
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for action-sheet:all
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for range
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for radio
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for modal:normal
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for date-and-time-pickers
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for select:popover
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for breadcrumbs
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for reorder
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for toggle
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for chip
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for tabs
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for inputs
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for alert:no-cancel
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toast:primary
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toast:warning
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for alert:remove-app
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toast:top
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for alert:button-only
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for modal:sheet
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toast:success
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toast:middle
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for floating-action-button-fixed
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toast:danger
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for button
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for progress-indicators:icon-only
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for accordion
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for progress-indicators:message
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toast:anchor
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for checkbox
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for select:alert
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for segment
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for select:modal
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for select:action-sheet
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for searchbar
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toast:bottom
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for alert:all
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for action-sheet:no-cancel
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for item-list
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for card
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for action-sheet:button-only
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toolbar
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for modal:card
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for floating-action-button
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for popover
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for action-sheet:all
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for range
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for radio
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for modal:normal
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for date-and-time-pickers
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for select:popover
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for breadcrumbs
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for reorder
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for toggle
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for chip

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

github-actions Bot added a commit that referenced this pull request Sep 15, 2026
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.
@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.
@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.
@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

Devin Review

Comment thread package.json
},
"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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 公開パッケージを通常の 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 を指定してください。

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +28 to +34
export {
TabBarSearchableType,
type TabBarSearchableFunction,
type SearchableEventCache,
type ElementSizes,
type ElementReferences,
} from '@rdlabo/ionic-theme-utils';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 公開 API の実体が外部パッケージへ移動

公開型と enum は外部 utility から再エクスポートされます。生成宣言、enum の同一性、旧サブパス利用者との互換性を公開済み成果物と比較してください。

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/tab-bar/index.ts
Comment on lines +290 to +307
// 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();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 タブ効果が touch click を再配送

効果は trusted touch click を停止し、タイマーから合成 click を送ります。実機で遅延 click、pointer ID 再利用、支援入力との相互作用を確認してください。

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 2 to +13
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,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 中核ロジックの外部移管は別途レビューが必要

popover、遷移、list Sass、searchable-tab helper が別リポジトリ実装へ置換されています。固定した依存コミットの差分と Ionic 8/9 契約を併せてレビューしてください。

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

github-actions Bot added a commit that referenced this pull request Sep 15, 2026
@rdlabo rdlabo changed the title [WIP] Calibrate iOS 26 theme against native UI measurements Calibrate iOS 26 theme against native UI measurements Sep 15, 2026
@rdlabo

rdlabo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

/update-screenshots

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Failed to update screenshots.

Please check the workflow run for details.

@rdlabo

rdlabo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

/update-screenshots

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Failed to update screenshots.

Please check the workflow run for details.

@rdlabo

rdlabo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

/update-screenshots

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Failed to update screenshots.

Please check the workflow run for details.

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

github-actions Bot added a commit that referenced this pull request Sep 15, 2026
@rdlabo

rdlabo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

/update-screenshots

1 similar comment
@rdlabo

rdlabo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

/update-screenshots

@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

github-actions Bot added a commit that referenced this pull request Sep 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📊 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.

github-actions Bot added a commit that referenced this pull request Sep 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

❌ Failed to update screenshots.

Please check the workflow run for details.

@rdlabo

rdlabo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

/update-screenshots

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant