((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
+ await bar.evaluate((el) => {
+ el.style.top = '420px';
+ });
+ const first = bar.locator('ion-tab-button[tab="one"]');
+ const rect = (await first.boundingBox())!;
+ expect(rect.y).toBeGreaterThanOrEqual(0);
+ await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
+ await page.mouse.down();
+ await expect(bar).toHaveClass(/ios26-animated/);
+ // Compare centers, not top-left corners: a held lens is larger than its cell.
+ const offset = await bar.evaluate((el) => {
+ const lens = Array.from(document.querySelectorAll('body > ion-tab-button.ion-cloned-element')).find(
+ (node) => getComputedStyle(node).display !== 'none',
+ )!;
+ const a = lens.getBoundingClientRect();
+ const b = el.querySelector('ion-tab-button')!.getBoundingClientRect();
+ return {
+ position: getComputedStyle(lens).position,
+ dx: a.x + a.width / 2 - b.x - b.width / 2,
+ dy: a.y + a.height / 2 - b.y - b.height / 2,
+ };
+ });
+ expect(offset.position).toBe('fixed');
+ expect(Math.abs(offset.dx)).toBeLessThan(1);
+ expect(Math.abs(offset.dy)).toBeLessThan(1);
+ await page.evaluate(() => window.scrollTo(0, 400));
+ await expect(bar).not.toHaveClass(/ios26-animated/);
+ await page.mouse.move(8, 8);
+ await page.mouse.up();
+ });
+
+ test('duplicate registerEffect is a no-op', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const host = document.querySelector('app-tabs');
+ const ng = (window as unknown as { ng?: { getComponent?: (el: Element) => TabsCmp } }).ng;
+ const cmp = host && ng?.getComponent ? ng.getComponent(host) : null;
+ if (!cmp?.registeredGestures || !cmp.ionViewDidEnter) {
+ return { ok: false as const, reason: 'tabs component unavailable' };
+ }
+ const beforeClones = document.querySelectorAll('body > ion-tab-button.ion-cloned-element').length;
+ const beforeGestures = cmp.registeredGestures.length;
+ const beforeClass = document.querySelector('ion-tab-bar')?.classList.contains('ios26-enable-gesture') ?? false;
+ cmp.ionViewDidEnter();
+ const afterClones = document.querySelectorAll('body > ion-tab-button.ion-cloned-element').length;
+ const afterGestures = cmp.registeredGestures.length;
+ const afterClass = document.querySelector('ion-tab-bar')?.classList.contains('ios26-enable-gesture') ?? false;
+ return { ok: true as const, beforeClones, afterClones, beforeGestures, afterGestures, beforeClass, afterClass };
+ });
+ expect(result.ok, 'reason' in result ? result.reason : '').toBe(true);
+ if (!result.ok) return;
+ expect(result.beforeClass).toBe(true);
+ expect(result.afterClass).toBe(true);
+ expect(result.afterClones).toBe(result.beforeClones);
+ // ionViewDidEnter pushes only when register returns a handle; duplicate must not add another.
+ expect(result.afterGestures).toBe(result.beforeGestures);
+ });
+
+ test('disabled tab does not take selection', async ({ page }) => {
+ // Tear down the shell bar so the fixture owns the only gesture under test.
+ await page.evaluate(() => {
+ const host = document.querySelector('app-tabs');
+ const ng = (window as unknown as { ng?: { getComponent?: (el: Element) => TabsCmp } }).ng;
+ const cmp = host && ng?.getComponent ? ng.getComponent(host) : null;
+ cmp?.registeredGestures?.forEach((gesture) => gesture.destroy());
+ if (cmp?.registeredGestures) cmp.registeredGestures.length = 0;
+ });
+ const bar = await appendFixtureBar(page, 'ios26-tab-lifecycle-disabled', { disabledSecond: true });
+ const registered = await registerViaTabs(page, '#ios26-tab-lifecycle-disabled');
+ expect(registered.ok, 'reason' in registered ? registered.reason : '').toBe(true);
+ expect(registered.registered).toBe(true);
+ await expect(bar).toHaveClass(/ios26-enable-gesture/);
+ const first = bar.locator('ion-tab-button[tab="one"]');
+ const disabled = bar.locator('ion-tab-button[tab="two"]');
+ await disabled.click({ force: true });
+ await expect(first).toHaveClass(/tab-selected/);
+ await expect(disabled).not.toHaveClass(/tab-selected/);
+ await expect(bar.locator('.ion-activated')).toHaveCount(0);
+ });
+
+ test('opt-out and reduced-motion skip registration', async ({ page }) => {
+ for (const optOut of ['ios-theme-disabled', 'ios26-disabled'] as const) {
+ const bar = await appendFixtureBar(page, `ios26-tab-lifecycle-${optOut}`, { optOutClass: optOut });
+ const registered = await registerViaTabs(page, `#ios26-tab-lifecycle-${optOut}`);
+ expect(registered.ok, 'reason' in registered ? registered.reason : '').toBe(true);
+ expect(registered.registered).toBe(false);
+ await expect(bar).not.toHaveClass(/ios26-enable-gesture/);
+ }
+
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ const reduced = await appendFixtureBar(page, 'ios26-tab-lifecycle-reduced');
+ const registered = await registerViaTabs(page, '#ios26-tab-lifecycle-reduced');
+ expect(registered.ok, 'reason' in registered ? registered.reason : '').toBe(true);
+ expect(registered.registered).toBe(false);
+ await expect(reduced).not.toHaveClass(/ios26-enable-gesture/);
+ });
+
+ test('destroy during press prevents later commit callbacks', async ({ page }) => {
+ const bar = page.locator('ion-tab-bar#tab-bar-bottom');
+ const first = bar.locator('ion-tab-button[tab="index"]');
+ const second = bar.locator('ion-tab-button[tab="docs"]');
+ await expect(first).toHaveClass(/tab-selected/);
+ await second.evaluate((el) => {
+ el.dataset['clicks'] = '0';
+ el.addEventListener('click', () => {
+ el.dataset['clicks'] = String(Number(el.dataset['clicks'] ?? '0') + 1);
+ });
+ });
+ const box = (await second.boundingBox())!;
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await expect(bar).toHaveClass(/ios26-animated/);
+ await page.evaluate(() => {
+ const host = document.querySelector('app-tabs');
+ const ng = (window as unknown as { ng?: { getComponent?: (el: Element) => TabsCmp } }).ng;
+ const cmp = host && ng?.getComponent ? ng.getComponent(host) : null;
+ cmp?.registeredGestures?.forEach((gesture) => gesture.destroy());
+ if (cmp?.registeredGestures) cmp.registeredGestures.length = 0;
+ });
+ await page.mouse.move(8, 8);
+ await page.mouse.up();
+ await page.waitForTimeout(400);
+ await expect(first).toHaveClass(/tab-selected/);
+ await expect(second).not.toHaveClass(/tab-selected/);
+ await expect(second).toHaveAttribute('data-clicks', '0');
+ await expect(bar).not.toHaveClass(/ios26-enable-gesture|ios26-animated/);
+ await expect(clones(page)).toHaveCount(0);
+ });
+});
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/accordion-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/accordion-dark.png
index cb7b5a66..787e4e6e 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/accordion-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/accordion-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/accordion.png b/demo/e2e/screenshot.spec.ts-snapshots/accordion.png
index bc42e418..a11057de 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/accordion.png and b/demo/e2e/screenshot.spec.ts-snapshots/accordion.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-all-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-all-dark.png
index a3a0761f..0b82944f 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-all-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-all-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-all.png b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-all.png
index 31885656..1233cdfc 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-all.png and b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-all.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-button-only-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-button-only-dark.png
index b7011704..c55f12d7 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-button-only-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-button-only-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-button-only.png b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-button-only.png
index 61b27fc2..68e1fb5f 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-button-only.png and b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-button-only.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-no-cancel-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-no-cancel-dark.png
index 50c730d2..dd80781a 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-no-cancel-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-no-cancel-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-no-cancel.png b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-no-cancel.png
index 6f156050..908f0e30 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-no-cancel.png and b/demo/e2e/screenshot.spec.ts-snapshots/action-sheet-no-cancel.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/alert-all-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/alert-all-dark.png
index 32eec037..6619c212 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/alert-all-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/alert-all-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/alert-all.png b/demo/e2e/screenshot.spec.ts-snapshots/alert-all.png
index fb0f6bce..7ebaf756 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/alert-all.png and b/demo/e2e/screenshot.spec.ts-snapshots/alert-all.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/alert-button-only-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/alert-button-only-dark.png
index 07db3778..35375383 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/alert-button-only-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/alert-button-only-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/alert-button-only.png b/demo/e2e/screenshot.spec.ts-snapshots/alert-button-only.png
index 1a625fa5..f04df691 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/alert-button-only.png and b/demo/e2e/screenshot.spec.ts-snapshots/alert-button-only.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/alert-no-cancel-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/alert-no-cancel-dark.png
index 0f4e0763..0e71b291 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/alert-no-cancel-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/alert-no-cancel-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/alert-no-cancel.png b/demo/e2e/screenshot.spec.ts-snapshots/alert-no-cancel.png
index b04f0cc9..bea3db29 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/alert-no-cancel.png and b/demo/e2e/screenshot.spec.ts-snapshots/alert-no-cancel.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/alert-remove-app-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/alert-remove-app-dark.png
index 6cf225e3..4914e252 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/alert-remove-app-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/alert-remove-app-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/alert-remove-app.png b/demo/e2e/screenshot.spec.ts-snapshots/alert-remove-app.png
index fd6ed2d9..e8d3a032 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/alert-remove-app.png and b/demo/e2e/screenshot.spec.ts-snapshots/alert-remove-app.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/breadcrumbs-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/breadcrumbs-dark.png
index 845f8f7a..5f01eac2 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/breadcrumbs-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/breadcrumbs-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/breadcrumbs.png b/demo/e2e/screenshot.spec.ts-snapshots/breadcrumbs.png
index 178a72c3..572c73f8 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/breadcrumbs.png and b/demo/e2e/screenshot.spec.ts-snapshots/breadcrumbs.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/button-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/button-dark.png
index 360aefce..bb5f3772 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/button-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/button-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/button.png b/demo/e2e/screenshot.spec.ts-snapshots/button.png
index 68b0aef6..1ce4f4b8 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/button.png and b/demo/e2e/screenshot.spec.ts-snapshots/button.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/card-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/card-dark.png
index 8afafabc..893c26d9 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/card-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/card-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/card.png b/demo/e2e/screenshot.spec.ts-snapshots/card.png
index 2047488a..00f716fb 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/card.png and b/demo/e2e/screenshot.spec.ts-snapshots/card.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/checkbox-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/checkbox-dark.png
index e4c6a055..65093b8c 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/checkbox-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/checkbox-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/checkbox.png b/demo/e2e/screenshot.spec.ts-snapshots/checkbox.png
index 19b51bbc..47bc83f9 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/checkbox.png and b/demo/e2e/screenshot.spec.ts-snapshots/checkbox.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/chip-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/chip-dark.png
index a9d7d646..560c748b 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/chip-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/chip-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/chip.png b/demo/e2e/screenshot.spec.ts-snapshots/chip.png
index 79eb2126..8de77df8 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/chip.png and b/demo/e2e/screenshot.spec.ts-snapshots/chip.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/date-and-time-pickers-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/date-and-time-pickers-dark.png
index ce08878e..1ae0d74c 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/date-and-time-pickers-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/date-and-time-pickers-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/date-and-time-pickers.png b/demo/e2e/screenshot.spec.ts-snapshots/date-and-time-pickers.png
index 5bcf2798..87de3afe 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/date-and-time-pickers.png and b/demo/e2e/screenshot.spec.ts-snapshots/date-and-time-pickers.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-dark.png
index b3c897cd..7feedfcd 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-fixed-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-fixed-dark.png
index 7ab1e745..fb831441 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-fixed-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-fixed-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-fixed.png b/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-fixed.png
index eb49da4c..050563e5 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-fixed.png and b/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button-fixed.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button.png b/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button.png
index c90ed75d..518db539 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button.png and b/demo/e2e/screenshot.spec.ts-snapshots/floating-action-button.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/inputs-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/inputs-dark.png
index fec10493..39770f81 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/inputs-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/inputs-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/inputs.png b/demo/e2e/screenshot.spec.ts-snapshots/inputs.png
index 89f87f85..00d5a339 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/inputs.png and b/demo/e2e/screenshot.spec.ts-snapshots/inputs.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/item-list-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/item-list-dark.png
index a36a906a..ca307845 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/item-list-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/item-list-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/item-list.png b/demo/e2e/screenshot.spec.ts-snapshots/item-list.png
index dedb00c8..68ebb906 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/item-list.png and b/demo/e2e/screenshot.spec.ts-snapshots/item-list.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/modal-card-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/modal-card-dark.png
index b29e8b59..2824de16 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/modal-card-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/modal-card-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/modal-card.png b/demo/e2e/screenshot.spec.ts-snapshots/modal-card.png
index e3dbb424..3246df04 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/modal-card.png and b/demo/e2e/screenshot.spec.ts-snapshots/modal-card.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/modal-normal-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/modal-normal-dark.png
index c2aafcf5..d19e68e0 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/modal-normal-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/modal-normal-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/modal-normal.png b/demo/e2e/screenshot.spec.ts-snapshots/modal-normal.png
index 31e58b97..a5998b69 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/modal-normal.png and b/demo/e2e/screenshot.spec.ts-snapshots/modal-normal.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/modal-sheet-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/modal-sheet-dark.png
index 3a78471a..174cdacd 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/modal-sheet-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/modal-sheet-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/modal-sheet.png b/demo/e2e/screenshot.spec.ts-snapshots/modal-sheet.png
index 5adc3b52..d80f94b2 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/modal-sheet.png and b/demo/e2e/screenshot.spec.ts-snapshots/modal-sheet.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/popover-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/popover-dark.png
index 45e8c332..eca126c5 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/popover-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/popover-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/popover.png b/demo/e2e/screenshot.spec.ts-snapshots/popover.png
index a31d49a1..9654a6bb 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/popover.png and b/demo/e2e/screenshot.spec.ts-snapshots/popover.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-icon-only-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-icon-only-dark.png
index 6bebdde3..8dd526fe 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-icon-only-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-icon-only-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-icon-only.png b/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-icon-only.png
index 59351998..71c3c0b7 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-icon-only.png and b/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-icon-only.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-message-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-message-dark.png
index 3bb18d8e..d9da98fd 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-message-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-message-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-message.png b/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-message.png
index 25adfe08..57bec3f3 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-message.png and b/demo/e2e/screenshot.spec.ts-snapshots/progress-indicators-message.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/radio-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/radio-dark.png
index 09e1d99e..2a42d3a9 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/radio-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/radio-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/radio.png b/demo/e2e/screenshot.spec.ts-snapshots/radio.png
index fd2b89b1..9d188302 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/radio.png and b/demo/e2e/screenshot.spec.ts-snapshots/radio.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/range-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/range-dark.png
index ebca6992..581e4a3c 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/range-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/range-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/range.png b/demo/e2e/screenshot.spec.ts-snapshots/range.png
index d12e3178..e2b1611a 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/range.png and b/demo/e2e/screenshot.spec.ts-snapshots/range.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/reorder-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/reorder-dark.png
index 44877ae9..a4d70711 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/reorder-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/reorder-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/reorder.png b/demo/e2e/screenshot.spec.ts-snapshots/reorder.png
index 0138c5ab..076ec328 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/reorder.png and b/demo/e2e/screenshot.spec.ts-snapshots/reorder.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/searchbar-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/searchbar-dark.png
index 6aecedf2..0a23b54f 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/searchbar-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/searchbar-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/searchbar.png b/demo/e2e/screenshot.spec.ts-snapshots/searchbar.png
index 93b047e7..32f6e918 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/searchbar.png and b/demo/e2e/screenshot.spec.ts-snapshots/searchbar.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/segment-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/segment-dark.png
index 7d9d1b4e..a215c10c 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/segment-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/segment-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/segment.png b/demo/e2e/screenshot.spec.ts-snapshots/segment.png
index b4378f55..bdb65d90 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/segment.png and b/demo/e2e/screenshot.spec.ts-snapshots/segment.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/select-action-sheet-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/select-action-sheet-dark.png
index 4d4246b5..00141b35 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/select-action-sheet-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/select-action-sheet-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/select-action-sheet.png b/demo/e2e/screenshot.spec.ts-snapshots/select-action-sheet.png
index 1e4cb94f..47bb76df 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/select-action-sheet.png and b/demo/e2e/screenshot.spec.ts-snapshots/select-action-sheet.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/select-alert-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/select-alert-dark.png
index 95d77fcf..e3e64789 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/select-alert-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/select-alert-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/select-alert.png b/demo/e2e/screenshot.spec.ts-snapshots/select-alert.png
index 9d108634..05935c09 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/select-alert.png and b/demo/e2e/screenshot.spec.ts-snapshots/select-alert.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/select-modal-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/select-modal-dark.png
index ddfe6592..bfa43427 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/select-modal-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/select-modal-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/select-modal.png b/demo/e2e/screenshot.spec.ts-snapshots/select-modal.png
index f8ff5bf1..f6526232 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/select-modal.png and b/demo/e2e/screenshot.spec.ts-snapshots/select-modal.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/select-popover-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/select-popover-dark.png
index a836d713..ddde7555 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/select-popover-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/select-popover-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/select-popover.png b/demo/e2e/screenshot.spec.ts-snapshots/select-popover.png
index 58f6d13d..ada1ab1b 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/select-popover.png and b/demo/e2e/screenshot.spec.ts-snapshots/select-popover.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/tabs-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/tabs-dark.png
index eac44f88..7da755e9 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/tabs-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/tabs-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/tabs.png b/demo/e2e/screenshot.spec.ts-snapshots/tabs.png
index c4e9aeb1..11e31bc8 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/tabs.png and b/demo/e2e/screenshot.spec.ts-snapshots/tabs.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-anchor-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-anchor-dark.png
index 45b72769..9abcf7c4 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-anchor-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-anchor-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-anchor.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-anchor.png
index 626509bd..fa69254d 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-anchor.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-anchor.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-bottom-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-bottom-dark.png
index 36504629..3605b2f6 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-bottom-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-bottom-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-bottom.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-bottom.png
index 072a3f20..8483816c 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-bottom.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-bottom.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-danger-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-danger-dark.png
index e6307a9b..98f15724 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-danger-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-danger-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-danger.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-danger.png
index 26af070c..c6d1b2e6 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-danger.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-danger.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-middle-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-middle-dark.png
index 89732d11..1acf7d5a 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-middle-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-middle-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-middle.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-middle.png
index abc0ab9a..1930c7e1 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-middle.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-middle.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-primary-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-primary-dark.png
index 812cab0b..7b04104e 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-primary-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-primary-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-primary.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-primary.png
index b2499f50..d7128699 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-primary.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-primary.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-success-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-success-dark.png
index ebddd3a0..42454aad 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-success-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-success-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-success.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-success.png
index a2d5a674..43e2c8f4 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-success.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-success.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-top-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-top-dark.png
index afe4a509..ca98d686 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-top-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-top-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-top.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-top.png
index 24d38dee..9523cce2 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-top.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-top.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-warning-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-warning-dark.png
index f9c1bf5f..e7339157 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-warning-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-warning-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toast-warning.png b/demo/e2e/screenshot.spec.ts-snapshots/toast-warning.png
index 98ef943d..a325a6a6 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toast-warning.png and b/demo/e2e/screenshot.spec.ts-snapshots/toast-warning.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toggle-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toggle-dark.png
index 577e0b0e..30f5d398 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toggle-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toggle-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toggle.png b/demo/e2e/screenshot.spec.ts-snapshots/toggle.png
index 0a787cdf..afe70c26 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toggle.png and b/demo/e2e/screenshot.spec.ts-snapshots/toggle.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toolbar-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/toolbar-dark.png
index 0bd8ab6e..6b2aebc1 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toolbar-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/toolbar-dark.png differ
diff --git a/demo/e2e/screenshot.spec.ts-snapshots/toolbar.png b/demo/e2e/screenshot.spec.ts-snapshots/toolbar.png
index a254789c..477237bd 100644
Binary files a/demo/e2e/screenshot.spec.ts-snapshots/toolbar.png and b/demo/e2e/screenshot.spec.ts-snapshots/toolbar.png differ
diff --git a/demo/e2e/toggle.spec.ts b/demo/e2e/toggle.spec.ts
new file mode 100644
index 00000000..198aed61
--- /dev/null
+++ b/demo/e2e/toggle.spec.ts
@@ -0,0 +1,78 @@
+import { expect, test } from '@playwright/test';
+
+test.use({ viewport: { width: 402, height: 874 }, hasTouch: true });
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/main/index/toggle');
+});
+
+for (const inItem of [false, true]) {
+ test(`short tap shows and releases the glass effect (${inItem ? 'item' : 'standalone'})`, async ({ page }) => {
+ const toggle = page.locator(inItem ? 'ion-toggle[color="success"]' : '.section-example ion-toggle').first();
+ const handle = toggle.locator('[part="handle"]');
+ const track = toggle.locator('[part="track"]');
+ await track.scrollIntoViewIfNeeded();
+ await toggle.evaluate((element) => {
+ element.dataset['changes'] = '0';
+ element.addEventListener('ionChange', () => {
+ element.dataset['changes'] = String(Number(element.dataset['changes']) + 1);
+ });
+ });
+ const rect = (await track.boundingBox())!;
+ await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
+ await page.mouse.down();
+ // No drag and no artificial long-press delay.
+ await expect(toggle).not.toHaveClass(/toggle-activated/);
+ await page.mouse.up();
+ await expect.poll(() => handle.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(30);
+ await expect(toggle).toHaveAttribute('aria-checked', 'true');
+ await expect(toggle).toHaveAttribute('data-changes', '1');
+ await expect.poll(() => handle.evaluate((element) => Math.round(element.getBoundingClientRect().height))).toBe(24);
+ await track.tap();
+ await expect(toggle).toHaveAttribute('aria-checked', 'false');
+ await expect(toggle).toHaveAttribute('data-changes', '2');
+ });
+}
+
+test('reduced motion removes the release transition', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ const toggle = page.locator('.section-example ion-toggle').first();
+ await expect(toggle.locator('[part="handle"]')).toHaveCSS('transition-duration', '0s');
+ await toggle.locator('[part="track"]').tap();
+ await expect(toggle).toHaveAttribute('aria-checked', 'true');
+ expect((await toggle.locator('[part="handle"]').boundingBox())!.height).toBe(24);
+});
+
+test('custom handle shadow remains overridable', async ({ page }) => {
+ const toggle = page.locator('.section-example ion-toggle').first();
+ await toggle.evaluate((element) => element.style.setProperty('--handle-box-shadow', 'none'));
+ await expect(toggle.locator('[part="handle"]')).toHaveCSS('box-shadow', 'none');
+});
+
+test('public radius and handle transition remain overridable', async ({ page }) => {
+ const toggle = page.locator('.section-example ion-toggle').first();
+ await toggle.evaluate((element) => {
+ element.style.setProperty('--border-radius', '8px');
+ element.style.setProperty('--handle-transition', 'none');
+ });
+ await expect(toggle.locator('[part="track"]')).toHaveCSS('border-radius', '8px');
+ await expect(toggle.locator('[part="handle"]')).toHaveCSS('transition-duration', '0s');
+});
+
+test('checked toggle uses its Ionic palette color', async ({ page }) => {
+ const toggle = page.locator('.section-example ion-toggle').first();
+ await toggle.evaluate((el) => {
+ el.setAttribute('color', 'danger');
+ (el as HTMLIonToggleElement).checked = true;
+ });
+ await expect(toggle).toHaveClass(/ion-color-danger/);
+ const expected = await toggle.evaluate((el) => {
+ const probe = document.createElement('span');
+ probe.style.color = getComputedStyle(el).getPropertyValue('--ion-color-base');
+ el.append(probe);
+ const color = getComputedStyle(probe).color;
+ probe.remove();
+ return color;
+ });
+ await expect(toggle.locator('[part="track"]')).toHaveCSS('background-color', expected);
+});
diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts
index 02892146..5b1a3f3f 100644
--- a/demo/playwright.config.ts
+++ b/demo/playwright.config.ts
@@ -7,6 +7,7 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
+ workers: process.env['CI'] ? 4 : undefined,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
reporter: 'html',
diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts
index c314d784..12cb6142 100644
--- a/demo/src/app/docs/docs-content.generated.ts
+++ b/demo/src/app/docs/docs-content.generated.ts
@@ -1,3 +1,3 @@
// Generated from docs/special-markup.md. Do not edit directly.
export const docsContentHtml =
- 'Special markup and classes \nMost Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.
\nPrimary submit buttons \nSolid primary submit buttons use --ion-color-primary-brightness for their foreground and border treatment. Define a value with sufficient contrast for your primary color.
\n:root { \n --ion-color-primary-brightness : #96feff ; \n} \n\n< ion-button type = "submit" color = "primary" >Submit</ ion-button > \n< ion-button class = "button-submit" fill = "solid" color = "primary" >Continue</ ion-button > \n\n\nPreview \nSubmit \nContinue \n \n\nUse .button-submit when the button needs the same treatment but cannot use type="submit".
\nTwo-line inset list items \nPlace an unslotted ion-label immediately alongside an unslotted ion-note to render a two-line item. When using the iOS-style inset-list background, wrap the items in ion-item-group; keep ion-list-header outside the group.
\n< ion-list inset = "true" > \n < ion-list-header > \n < ion-label >Connections</ ion-label > \n </ ion-list-header > \n < ion-item-group > \n < ion-item > \n < ion-label >Network & internet</ ion-label > \n < ion-note >Mobile, Wi-Fi, hotspot</ ion-note > \n </ ion-item > \n </ ion-item-group > \n</ ion-list > \n\n\nPreview \n\n \n Connections \n \n \n \n Network & internet \n Mobile, Wi-Fi, hotspot \n \n \n \n \n\nUse slot="end" on ion-note when you want the standard trailing-note layout instead.
\nInset-list section headers \nAdd .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.
\nThis is an introductory group. Place regular list items in a separate ion-item-group that follows it.
\n< ion-list inset = "true" > \n < ion-item-group class = "item-group-header" > \n < ion-item > \n < ion-label > \n < ion-icon name = "list" style = "background: var(--ion-color-primary)" ></ ion-icon > \n < h2 >Lists</ h2 > \n < ion-text >Inset-list examples</ ion-text > \n </ ion-label > \n </ ion-item > \n </ ion-item-group > \n < ion-item-group > \n < ion-item >< ion-label >First item</ ion-label ></ ion-item > \n </ ion-item-group > \n</ ion-list > \n\n\nPreview \n\n \n \n First item \n \n \n \n\nFull-width segments \nAdd .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.
\n< ion-segment class = "segment-expand" value = "new" > \n < ion-segment-button value = "new" >< ion-label >New</ ion-label ></ ion-segment-button > \n < ion-segment-button value = "replied" >< ion-label >Replied</ ion-label ></ ion-segment-button > \n</ ion-segment > \n\n\nPreview \n\n New \n Replied \n \n \n\nClassic search bar in a condense header \nThe theme gives iOS search bars the iOS 26 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.
\nPlace it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.
\nThe example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.
\n< div class = "ion-page" > \n < ion-header translucent = "true" > \n < ion-toolbar color = "light" > \n < ion-title >Search</ ion-title > \n </ ion-toolbar > \n </ ion-header > \n < ion-content color = "light" fullscreen = "true" > \n < ion-header collapse = "condense" > \n < ion-toolbar color = "light" > \n < ion-title size = "large" >Search</ ion-title > \n </ ion-toolbar > \n < ion-toolbar color = "light" > \n < ion-searchbar class = "searchbar-classic" placeholder = "Filter results" ></ ion-searchbar > \n </ ion-toolbar > \n </ ion-header > \n < ion-list inset = "true" > \n < ion-item-group > \n < ion-item >< ion-label >Recent item 1</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 2</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 3</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 4</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 5</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 6</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 7</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 8</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 9</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 10</ ion-label ></ ion-item > \n </ ion-item-group > \n </ ion-list > \n </ ion-content > \n</ div > \n\n\nPreview \n\n \n \n Search \n \n \n \n \n \n Search \n \n \n \n \n \n \n \n Recent item 1 \n Recent item 2 \n Recent item 3 \n Recent item 4 \n Recent item 5 \n Recent item 6 \n Recent item 7 \n Recent item 8 \n Recent item 9 \n Recent item 10 \n \n \n \n
\n \n\nThe .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.
\nSearch-bar toolbars \nAdd .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.
\n< ion-toolbar class = "toolbar-searchbar" > \n < ion-buttons slot = "start" > \n < ion-button >Cancel</ ion-button > \n </ ion-buttons > \n < ion-searchbar ></ ion-searchbar > \n</ ion-toolbar > \n\n\nPreview \n\n \n Cancel \n \n \n \n \n\nOpting out \nAdd .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.
\n< ion-button >iOS 26 theme</ ion-button > < ion-button class = "ios-theme-disabled" >Standard Ionic button</ ion-button > \n\n\nPreview \niOS 26 theme Standard Ionic button \n \n\nFor the background model behind inset lists, see Using ion-item-group .
\nios26-disabled remains supported as a deprecated alias for ios-theme-disabled.
\n';
+ 'Special markup and classes \nMost Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.
\nPrimary submit buttons \nSolid primary submit buttons use --ion-color-primary-brightness for their foreground and border treatment. Define a value with sufficient contrast for your primary color.
\n:root { \n --ion-color-primary-brightness : #96feff ; \n} \n\n< ion-button type = "submit" color = "primary" >Submit</ ion-button > \n< ion-button class = "button-submit" fill = "solid" color = "primary" >Continue</ ion-button > \n\n\nPreview \nSubmit \nContinue \n \n\nUse .button-submit when the button needs the same treatment but cannot use type="submit".
\nAlert and action-sheet actions \nFor the prominent action in an alert, use role: 'preferred'. It uses the primary\nbackground and contrast color; it does not change the submit-button brightness\npalette. Keep actions in the intended reading and keyboard order:
\nconst buttons = [ \n { text: \'Cancel\', role: \'cancel\' }, \n { text: \'OK\', role: \'preferred\', handler: () => confirm() }, \n]; \n\nAction sheets use Ionic's existing role: 'selected' for this emphasis. Other\nactions stay neutral or destructive. The theme follows the centered, unanchored\nUIAlertController presentation measured on iOS 26.1/26.5. Use ion-popover for\nan anchored menu. ios-theme-disabled / ios26-disabled retain the original\nIonic presentation. Long content remains scrollable. Optional measured animation\nbuilders are described in Experimental animation ;\nCSS alone does not replace Ionic's enter/leave animations.
\nTwo-line inset list items \nPlace an unslotted ion-label immediately alongside an unslotted ion-note to render a two-line item. When using the iOS-style inset-list background, wrap the items in ion-item-group; keep ion-list-header outside the group.
\n< ion-list inset = "true" > \n < ion-list-header > \n < ion-label >Connections</ ion-label > \n </ ion-list-header > \n < ion-item-group > \n < ion-item > \n < ion-label >Network & internet</ ion-label > \n < ion-note >Mobile, Wi-Fi, hotspot</ ion-note > \n </ ion-item > \n </ ion-item-group > \n</ ion-list > \n\n\nPreview \n\n \n Connections \n \n \n \n Network & internet \n Mobile, Wi-Fi, hotspot \n \n \n \n \n\nUse slot="end" on ion-note when you want the standard trailing-note layout instead.
\nInset-list section headers \nAdd .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.
\nThis is an introductory group. Place regular list items in a separate ion-item-group that follows it.
\n< ion-list inset = "true" > \n < ion-item-group class = "item-group-header" > \n < ion-item > \n < ion-label > \n < ion-icon name = "list" style = "background: var(--ion-color-primary)" ></ ion-icon > \n < h2 >Lists</ h2 > \n < ion-text >Inset-list examples</ ion-text > \n </ ion-label > \n </ ion-item > \n </ ion-item-group > \n < ion-item-group > \n < ion-item >< ion-label >First item</ ion-label ></ ion-item > \n </ ion-item-group > \n</ ion-list > \n\n\nPreview \n\n \n \n First item \n \n \n \n\nFull-width segments \nAdd .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.
\n< ion-segment class = "segment-expand" value = "new" > \n < ion-segment-button value = "new" >< ion-label >New</ ion-label ></ ion-segment-button > \n < ion-segment-button value = "replied" >< ion-label >Replied</ ion-label ></ ion-segment-button > \n</ ion-segment > \n\n\nPreview \n\n New \n Replied \n \n \n\nClassic search bar in a condense header \nThe theme gives iOS search bars the iOS 26 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.
\nPlace it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.
\nThe example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.
\n< div class = "ion-page" > \n < ion-header translucent = "true" > \n < ion-toolbar color = "light" > \n < ion-title >Search</ ion-title > \n </ ion-toolbar > \n </ ion-header > \n < ion-content color = "light" fullscreen = "true" > \n < ion-header collapse = "condense" > \n < ion-toolbar color = "light" > \n < ion-title size = "large" >Search</ ion-title > \n </ ion-toolbar > \n < ion-toolbar color = "light" > \n < ion-searchbar class = "searchbar-classic" placeholder = "Filter results" ></ ion-searchbar > \n </ ion-toolbar > \n </ ion-header > \n < ion-list inset = "true" > \n < ion-item-group > \n < ion-item >< ion-label >Recent item 1</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 2</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 3</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 4</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 5</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 6</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 7</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 8</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 9</ ion-label ></ ion-item > \n < ion-item >< ion-label >Recent item 10</ ion-label ></ ion-item > \n </ ion-item-group > \n </ ion-list > \n </ ion-content > \n</ div > \n\n\nPreview \n\n \n \n Search \n \n \n \n \n \n Search \n \n \n \n \n \n \n \n Recent item 1 \n Recent item 2 \n Recent item 3 \n Recent item 4 \n Recent item 5 \n Recent item 6 \n Recent item 7 \n Recent item 8 \n Recent item 9 \n Recent item 10 \n \n \n \n
\n \n\nThe .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.
\nSearch-bar toolbars \nAdd .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.
\n< ion-toolbar class = "toolbar-searchbar" > \n < ion-buttons slot = "start" > \n < ion-button >Cancel</ ion-button > \n </ ion-buttons > \n < ion-searchbar ></ ion-searchbar > \n</ ion-toolbar > \n\n\nPreview \n\n \n Cancel \n \n \n \n \n\nOpting out \nAdd .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.
\n< ion-button >iOS 26 theme</ ion-button > < ion-button class = "ios-theme-disabled" >Standard Ionic button</ ion-button > \n\n\nPreview \niOS 26 theme Standard Ionic button \n \n\nFor the background model behind inset lists, see Using ion-item-group .
\nios26-disabled remains supported as a deprecated alias for ios-theme-disabled.
\n';
diff --git a/demo/src/app/index/pages/button/button.page.html b/demo/src/app/index/pages/button/button.page.html
index 7f00de66..c5d926ae 100644
--- a/demo/src/app/index/pages/button/button.page.html
+++ b/demo/src/app/index/pages/button/button.page.html
@@ -4,6 +4,9 @@
button
+
+ Push
+
diff --git a/demo/src/app/index/pages/button/button.page.ts b/demo/src/app/index/pages/button/button.page.ts
index d99fca41..097f3db2 100644
--- a/demo/src/app/index/pages/button/button.page.ts
+++ b/demo/src/app/index/pages/button/button.page.ts
@@ -1,4 +1,4 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
@@ -17,6 +17,7 @@ import {
IonText,
IonTitle,
IonToolbar,
+ NavController,
} from '@demo/ionic';
@Component({
@@ -44,7 +45,12 @@ import {
],
})
export class ButtonPage implements OnInit {
+ readonly navCtrl = inject(NavController);
constructor() {}
ngOnInit() {}
+
+ navigateTo() {
+ return this.navCtrl.navigateForward('/main/index/action-sheet');
+ }
}
diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts
index 10cf5bc9..4898077e 100644
--- a/demo/src/app/tabs/tabs.page.ts
+++ b/demo/src/app/tabs/tabs.page.ts
@@ -45,13 +45,15 @@ export class TabsPage implements OnInit, ViewDidEnter, ViewDidLeave {
}
ionViewDidEnter() {
- const registerGesture = registerTabBarEffect(document.querySelector('ion-tab-bar')!);
- if (registerGesture) {
- this.registeredGestures.push(registerGesture);
+ for (const target of this.#el.nativeElement.querySelectorAll('ion-tab-bar')) {
+ const registerGesture = registerTabBarEffect(target);
+ if (registerGesture) {
+ this.registeredGestures.push(registerGesture);
+ }
}
}
ionViewDidLeave() {
- this.registeredGestures.forEach((gesture) => gesture.destroy());
+ this.registeredGestures.splice(0).forEach((gesture) => gesture.destroy());
}
}
diff --git a/demo/src/global.scss b/demo/src/global.scss
index d7bf2204..99174fee 100644
--- a/demo/src/global.scss
+++ b/demo/src/global.scss
@@ -69,7 +69,7 @@ html.ionic-v8 {
--padding-bottom: 8px;
&:is(.textarea-label-placement-stacked, .textarea-label-placement-floating) {
- block-size: 68px;
+ block-size: 74px;
}
&.textarea-fill-outline .textarea-wrapper {
diff --git a/docs/special-markup.md b/docs/special-markup.md
index ce7f7ba1..04377a4e 100644
--- a/docs/special-markup.md
+++ b/docs/special-markup.md
@@ -23,6 +23,27 @@ Solid primary submit buttons use `--ion-color-primary-brightness` for their fore
Use `.button-submit` when the button needs the same treatment but cannot use `type="submit"`.
+## Alert and action-sheet actions
+
+For the prominent action in an alert, use `role: 'preferred'`. It uses the primary
+background and contrast color; it does not change the submit-button brightness
+palette. Keep actions in the intended reading and keyboard order:
+
+```ts
+const buttons = [
+ { text: 'Cancel', role: 'cancel' },
+ { text: 'OK', role: 'preferred', handler: () => confirm() },
+];
+```
+
+Action sheets use Ionic's existing `role: 'selected'` for this emphasis. Other
+actions stay neutral or destructive. The theme follows the centered, unanchored
+`UIAlertController` presentation measured on iOS 26.1/26.5. Use `ion-popover` for
+an anchored menu. `ios-theme-disabled` / `ios26-disabled` retain the original
+Ionic presentation. Long content remains scrollable. Optional measured animation
+builders are described in [Experimental animation](./experimental-animation.md);
+CSS alone does not replace Ionic's enter/leave animations.
+
## Two-line inset list items
Place an unslotted `ion-label` immediately alongside an unslotted `ion-note` to render a two-line item. When using the iOS-style inset-list background, wrap the items in `ion-item-group`; keep `ion-list-header` outside the group.
diff --git a/package-lock.json b/package-lock.json
index 5da4bb7f..4e1e273f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "@rdlabo/ionic-theme-ios26",
"version": "9.2.0",
"license": "MIT",
+ "dependencies": {
+ "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#main"
+ },
"devDependencies": {
"@ionic/angular": "^9.0.0",
"husky": "^9.1.7",
@@ -2199,6 +2202,14 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@rdlabo/ionic-theme-utils": {
+ "version": "0.1.0",
+ "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#2e94a70d45fea28aec0e49407ebaffdfbc7c17f7",
+ "license": "MIT",
+ "peerDependencies": {
+ "@ionic/core": ">=8.8.1 <10"
+ }
+ },
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz",
diff --git a/package.json b/package.json
index d5a5b3ac..68961b37 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
],
"scripts": {
"build": "npm run build:css && npm run build:ts",
- "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map",
+ "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map --pkg-importer=node",
"build:ts": "tsc",
"build:demo": "npm run build && cd demo && npm install && npm run build -- --configuration=production",
"lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"",
@@ -47,6 +47,9 @@
"url": "https://github.com/rdlabo-dev/ionic-theme-ios27/issues"
},
"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"
+ },
"devDependencies": {
"@ionic/angular": "^9.0.0",
"husky": "^9.1.7",
diff --git a/src/index.ts b/src/index.ts
index b3b000c6..729aa99a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,33 +1,9 @@
-import { registeredEffect } from './sheets-of-glass/interfaces';
-import { registerEffect } from './sheets-of-glass';
export * from './sheets-of-glass/interfaces';
export { iosEnterAnimation as popoverEnterAnimation } from './popover/animations/ios.enter';
export { iosLeaveAnimation as popoverLeaveAnimation } from './popover/animations/ios.leave';
export * from './tab-bar-searchable';
export * from './transition/ios.transition';
-export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffect | undefined => {
- return registerEffect(targetElement, 'ion-tab-button', 'tab-selected', {
- small: 'scale(1.1, 1)',
- medium: 'scale(1.2)',
- large: 'scale(1.3)',
- xlarge: 'scale(1.15, 1.4)',
- });
-};
+export { registerSegmentEffect } from './segment';
-export const registerSegmentEffect = (targetElement: HTMLElement): registeredEffect | undefined => {
- const scale = !targetElement.classList.contains('segment-expand')
- ? {
- small: 'scale(1.35)',
- medium: 'scale(1.45)',
- large: 'scale(1.55)',
- xlarge: 'scale(1.55, 1.65)',
- }
- : {
- small: 'scale(1.02, 1.35)',
- medium: 'scale(1.03, 1.45)',
- large: 'scale(1.04, 1.55)',
- xlarge: 'scale(1.05, 1.65)',
- };
- return registerEffect(targetElement, 'ion-segment-button', 'segment-button-checked', scale);
-};
+export { registerTabBarEffect } from './tab-bar';
diff --git a/src/popover/animations/ios.enter.ts b/src/popover/animations/ios.enter.ts
index e1965d36..2d40fb1f 100644
--- a/src/popover/animations/ios.enter.ts
+++ b/src/popover/animations/ios.enter.ts
@@ -1,16 +1,21 @@
import { createAnimation } from '@ionic/core';
import type { Animation } from '@ionic/core';
+import {
+ calculateWindowAdjustment,
+ createCalloutSurface,
+ getPopoverDimensions,
+ getPopoverPosition,
+ POPOVER_IOS_BODY_MARGIN,
+} from '@rdlabo/ionic-theme-utils';
import { getElementRoot } from '../../utils';
-import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition } from '../utils';
const POPOVER_IOS_BODY_PADDING = 5;
-export const POPOVER_IOS_BODY_MARGIN = 8;
/**
* iOS Popover Enter Animation
*/
// TODO(FW-2832): types
-export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => {
+export const iosEnterAnimation = (baseEl: HTMLElement, opts: any = {}): Animation => {
const { event: ev, size, trigger, reference, side, align } = opts;
const doc = baseEl.ownerDocument as any;
const isRTL = doc.dir === 'rtl';
@@ -19,12 +24,42 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const root = getElementRoot(baseEl);
const contentEl = root.querySelector('.popover-content') as HTMLElement;
+ const arrowEl = root.querySelector('.popover-arrow');
const referenceSizeEl = trigger || ev?.detail?.ionShadowTarget || ev?.target;
+ const anchorBounds = referenceSizeEl?.getBoundingClientRect();
+ // Overlays live outside the page, but must stay in the pane that opened them.
+ let paneAnchor = referenceSizeEl as HTMLElement | undefined;
+ let pane: HTMLElement | null = null;
+ while (paneAnchor && !pane) {
+ pane = paneAnchor.closest('ion-content, .ion-page, ion-menu');
+ paneAnchor = (paneAnchor.getRootNode() as ShadowRoot).host as HTMLElement | undefined;
+ }
+ const paneBounds = pane?.getBoundingClientRect();
+ const scroll = pane?.shadowRoot?.querySelector('[part="scroll"]');
+ const scrollStyle = scroll ? getComputedStyle(scroll) : null;
+ let paneLeft = Math.max(0, (paneBounds?.left ?? 0) + (parseFloat(scrollStyle?.paddingLeft ?? '0') || 0));
+ let paneRight = Math.min(bodyWidth, (paneBounds?.right ?? bodyWidth) - (parseFloat(scrollStyle?.paddingRight ?? '0') || 0));
+ // Toolbars may be siblings of ion-content; exclude the visible menu there too.
+ const splitPane = pane?.closest('ion-split-pane.split-pane-visible');
+ if (splitPane && pane?.closest('.split-pane-main')) {
+ for (const menu of Array.from(splitPane.querySelectorAll(':scope > ion-menu.menu-pane-visible'))) {
+ const rect = menu.getBoundingClientRect();
+ if (rect.left <= paneLeft && rect.right > paneLeft) paneLeft = rect.right;
+ if (rect.right >= paneRight && rect.left < paneRight) paneRight = rect.left;
+ }
+ }
+ const paneMargin = size === 'cover' ? 0 : POPOVER_IOS_BODY_MARGIN;
+ const availableWidth = Math.max(0, paneRight - paneLeft - paneMargin * 2);
+ if (pane && availableWidth > 0 && contentEl.getBoundingClientRect().width > availableWidth) {
+ contentEl.dataset['previousMaxWidth'] = contentEl.style.maxWidth;
+ contentEl.dataset['previousMaxWidthPriority'] = contentEl.style.getPropertyPriority('max-width');
+ contentEl.style.maxWidth = `${availableWidth}px`;
+ }
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl);
const isReplace = ((): boolean => {
- if (!['ion-button', 'ion-buttons'].includes(referenceSizeEl.localName)) {
+ if (reference === 'event' || !referenceSizeEl || !['ion-button', 'ion-buttons'].includes(referenceSizeEl.localName)) {
return false;
}
if (referenceSizeEl.matches('.ios-theme-disabled, .ios26-disabled')) {
@@ -42,10 +77,22 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const results = getPopoverPosition(isRTL, contentWidth, contentHeight, reference, side, align, defaultPosition, trigger, ev);
+ // Use the same reference for placement, the callout and the animation origin.
+ const anchor = reference === 'event' ? results.referenceCoordinates : anchorBounds;
+
const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
const margin = size === 'cover' ? 0 : POPOVER_IOS_BODY_MARGIN;
- const { originX, originY, top, left, bottom, checkSafeAreaLeft, checkSafeAreaRight, addPopoverBottomClass } = calculateWindowAdjustment(
+ const {
+ originX,
+ originY,
+ top,
+ left: windowLeft,
+ bottom,
+ checkSafeAreaLeft,
+ checkSafeAreaRight,
+ addPopoverBottomClass,
+ } = calculateWindowAdjustment(
side,
results.top,
results.left,
@@ -58,14 +105,59 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
results.originX,
results.originY,
results.referenceCoordinates,
- referenceSizeEl.getBoundingClientRect(),
+ referenceSizeEl?.getBoundingClientRect(),
isReplace,
);
+ // A replacing surface grows inward from the button's edge, not its center.
+ const preferredLeft =
+ isReplace && anchorBounds
+ ? anchorBounds.left + anchorBounds.width / 2 <= (paneLeft + paneRight) / 2
+ ? anchorBounds.left
+ : anchorBounds.right - contentWidth
+ : windowLeft;
+ const left = pane ? Math.max(paneLeft + paneMargin, Math.min(paneRight - paneMargin - contentWidth, preferredLeft)) : preferredLeft;
+ const physicalSide = side === 'start' ? (isRTL ? 'right' : 'left') : side === 'end' ? (isRTL ? 'left' : 'right') : side;
+ const horizontal = physicalSide === 'left' || physicalSide === 'right';
+ const above = addPopoverBottomClass || physicalSide === 'top';
+ const hasCallout = !!arrowEl && !isReplace && !!anchor && size !== 'cover' && bottom === undefined;
+ const surfaceTop = hasCallout && !horizontal ? top + (above ? -5 : 5) : top;
+ const contentOrigin = anchor
+ ? `${anchor.left + anchor.width / 2 - left}px ${anchor.top + anchor.height / 2 - surfaceTop}px`
+ : `${originX} ${originY}`;
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
const contentAnimation = createAnimation();
const targetAnimation = createAnimation();
+ const arrowAnimation = createAnimation();
+ const surfaceAnimation = createAnimation();
+ if (arrowEl) {
+ arrowAnimation.addElement(arrowEl).delay(300).duration(200).fromTo('opacity', 0, 1);
+ }
+ if (hasCallout && anchor) {
+ const length = horizontal ? contentHeight : contentWidth;
+ const inset = Math.min(48, length / 2);
+ const center = Math.max(
+ inset,
+ Math.min(length - inset, horizontal ? anchor.top + anchor.height / 2 - top : anchor.left + anchor.width / 2 - left),
+ );
+ const arrowSide = horizontal ? (physicalSide === 'left' ? 'right' : 'left') : above ? 'bottom' : 'top';
+ const layers = createCalloutSurface(root, contentWidth, contentHeight, arrowSide, center);
+ baseEl.classList.add('ios-theme-callout');
+ for (const layer of layers) {
+ layer.style.left = `calc(${left - 32}px + var(--offset-x, 0))`;
+ layer.style.top = `calc(${surfaceTop - 32}px + var(--offset-y, 0))`;
+ const [originLeft, originTop] = contentOrigin.split(' ').map(parseFloat);
+ layer.style.transformOrigin = `${originLeft + 32}px ${originTop + 32}px`;
+ }
+ surfaceAnimation
+ .addElement(layers)
+ .delay(100)
+ .duration(400)
+ .easing('cubic-bezier(0, 1, 0.22, 1)')
+ .fromTo('transform', 'scale(0)', 'scale(1)')
+ .fromTo('opacity', 0.01, 1);
+ }
backdropAnimation
.delay(100)
@@ -86,12 +178,12 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
.delay(100)
.duration(400)
.addElement(root.querySelector('.popover-content')!)
- .beforeStyles({ 'transform-origin': `${originY} ${originX}` })
+ .beforeStyles({ 'transform-origin': contentOrigin })
.beforeAddWrite(() => {
/**
* 'transformOrigin' use for leave animation.
*/
- root.querySelector('.popover-content')!.dataset['transformOrigin'] = `${originY} ${originX}`;
+ root.querySelector('.popover-content')!.dataset['transformOrigin'] = contentOrigin;
})
.fromTo('transform', 'scale(0)', 'scale(1)')
.fromTo('opacity', 0.01, 1);
@@ -102,7 +194,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
.duration(200)
.addElement(referenceSizeEl)
.beforeStyles({ 'transform-origin': `${originY} ${originX}` })
- .beforeAddClass('ios26-replace-element')
+ .beforeAddClass('ios-theme-replace-element')
.fromTo('transform', 'scale(1)', 'scale(1.05)')
.fromTo('opacity', 1, 0);
}
@@ -113,6 +205,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
.duration(100)
.beforeAddWrite(() => {
if (size === 'cover') {
+ baseEl.dataset['iosThemePreviousWidth'] = baseEl.style.getPropertyValue('--width');
+ baseEl.dataset['iosThemePreviousWidthPriority'] = baseEl.style.getPropertyPriority('--width');
baseEl.style.setProperty('--width', `${contentWidth}px`);
}
@@ -129,16 +223,42 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
let leftValue = `${left}px`;
- if (checkSafeAreaLeft) {
+ if (checkSafeAreaLeft && !pane) {
leftValue = `${left}px${safeAreaLeft}`;
}
- if (checkSafeAreaRight) {
+ if (checkSafeAreaRight && !pane) {
leftValue = `${left}px${safeAreaRight}`;
}
- contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
+ contentEl.style.setProperty('top', `calc(${surfaceTop}px + var(--offset-y, 0))`);
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
- contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
+ contentEl.style.setProperty('transform-origin', contentOrigin);
+
+ // Morphing buttons replace their anchor; ordinary anchored popovers point to it.
+ if (arrowEl) {
+ arrowEl.style.display = 'none';
+ if (hasCallout && anchor) {
+ const inset = Math.min(48, (horizontal ? contentHeight : contentWidth) / 2);
+ const clamp = (value: number, length: number) => Math.max(inset, Math.min(length - inset, value));
+ let arrowLeft: number;
+ let arrowTop: number;
+ let rotation: number;
+ if (horizontal) {
+ arrowLeft = physicalSide === 'left' ? left + contentWidth - 10 : left - 24;
+ arrowTop = top + clamp(anchor.top + anchor.height / 2 - top, contentHeight) - 7;
+ rotation = physicalSide === 'left' ? 90 : -90;
+ } else {
+ arrowLeft = left + clamp(anchor.left + anchor.width / 2 - left, contentWidth) - 17;
+ arrowTop = above ? surfaceTop + contentHeight - 1 : surfaceTop - 13;
+ rotation = above ? 180 : 0;
+ }
+ arrowEl.style.setProperty('display', 'block');
+ arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`);
+ arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`);
+ arrowEl.style.setProperty('bottom', 'auto');
+ arrowEl.style.setProperty('transform', `rotate(${rotation}deg)`);
+ }
+ }
})
- .addAnimation([backdropAnimation, contentAnimation, targetAnimation]);
+ .addAnimation([backdropAnimation, contentAnimation, targetAnimation, arrowAnimation, surfaceAnimation]);
};
diff --git a/src/popover/animations/ios.leave.ts b/src/popover/animations/ios.leave.ts
index e71b7a92..d466954e 100644
--- a/src/popover/animations/ios.leave.ts
+++ b/src/popover/animations/ios.leave.ts
@@ -15,9 +15,15 @@ export const iosLeaveAnimation = (baseEl: HTMLElement): Animation => {
const backdropAnimation = createAnimation();
const contentAnimation = createAnimation();
const targetAnimation = createAnimation();
+ const surfaceAnimation = createAnimation()
+ .addElement(Array.from(root.querySelectorAll('.ios-theme-callout-layer')))
+ .duration(400)
+ .easing('ease')
+ .fromTo('opacity', 0.99, 0)
+ .fromTo('transform', 'scale(1)', 'scale(0)');
const doc = baseEl.ownerDocument as any;
- const replaceElement = doc.querySelector('.ios26-replace-element') as HTMLElement | null;
+ const replaceElement = doc.querySelector('.ios-theme-replace-element') as HTMLElement | null;
if (replaceElement) {
const ratio = contentEl.getBoundingClientRect().width / contentEl.getBoundingClientRect().height;
@@ -27,7 +33,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement): Animation => {
.addElement(replaceElement)
.delay(100)
.duration(300)
- .afterRemoveClass('ios26-replace-element')
+ .afterRemoveClass('ios-theme-replace-element')
.fromTo('transform', `scale(${scale})`, 'scale(1)')
.fromTo('opacity', 0, 0.9);
}
@@ -57,20 +63,37 @@ export const iosLeaveAnimation = (baseEl: HTMLElement): Animation => {
return baseAnimation
.easing('ease')
.afterAddWrite(() => {
- baseEl.style.removeProperty('--width');
+ if (baseEl.dataset['iosThemePreviousWidth'] !== undefined) {
+ baseEl.style.setProperty('--width', baseEl.dataset['iosThemePreviousWidth'], baseEl.dataset['iosThemePreviousWidthPriority'] ?? '');
+ delete baseEl.dataset['iosThemePreviousWidth'];
+ delete baseEl.dataset['iosThemePreviousWidthPriority'];
+ }
baseEl.classList.remove('popover-bottom');
+ baseEl.classList.remove('ios-theme-callout');
+ root.querySelectorAll('.ios-theme-callout-layer').forEach((layer) => layer.remove());
contentEl.style.removeProperty('top');
contentEl.style.removeProperty('left');
contentEl.style.removeProperty('bottom');
contentEl.style.removeProperty('transform-origin');
+ if (contentEl.dataset['previousMaxWidth'] !== undefined) {
+ contentEl.style.setProperty(
+ 'max-width',
+ contentEl.dataset['previousMaxWidth'],
+ contentEl.dataset['previousMaxWidthPriority'] ?? '',
+ );
+ delete contentEl.dataset['previousMaxWidth'];
+ delete contentEl.dataset['previousMaxWidthPriority'];
+ }
if (arrowEl) {
arrowEl.style.removeProperty('top');
arrowEl.style.removeProperty('left');
arrowEl.style.removeProperty('display');
+ arrowEl.style.removeProperty('bottom');
+ arrowEl.style.removeProperty('transform');
}
})
.duration(300)
- .addAnimation([backdropAnimation, contentAnimation, targetAnimation]);
+ .addAnimation([backdropAnimation, contentAnimation, targetAnimation, surfaceAnimation]);
};
diff --git a/src/popover/popover-interface.ts b/src/popover/popover-interface.ts
deleted file mode 100644
index b633da3f..00000000
--- a/src/popover/popover-interface.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Mode, OverlayInterface } from '@ionic/core';
-
-export interface PopoverInterface extends OverlayInterface {
- present: (event?: MouseEvent | TouchEvent | PointerEvent) => Promise;
-}
-
-export interface PopoverOptions {
- component: T;
- componentProps?: ComponentProps;
- showBackdrop?: boolean;
- backdropDismiss?: boolean;
- translucent?: boolean;
- cssClass?: string | string[];
- event?: Event;
- delegate?: FrameworkDelegate;
- animated?: boolean;
- focusTrap?: boolean;
-
- mode?: Mode;
- keyboardClose?: boolean;
- id?: string;
- htmlAttributes?: { [key: string]: any };
-
- enterAnimation?: AnimationBuilder;
- leaveAnimation?: AnimationBuilder;
-
- size?: PopoverSize;
- dismissOnSelect?: boolean;
- reference?: PositionReference;
- side?: PositionSide;
- alignment?: PositionAlign;
- arrow?: boolean;
-
- trigger?: string;
- triggerAction?: string;
-}
-
-export type PopoverSize = 'cover' | 'auto';
-
-export type TriggerAction = 'click' | 'hover' | 'context-menu';
-
-export type PositionReference = 'trigger' | 'event';
-export type PositionSide = 'top' | 'right' | 'bottom' | 'left' | 'start' | 'end';
-export type PositionAlign = 'start' | 'center' | 'end';
diff --git a/src/popover/utils.ts b/src/popover/utils.ts
deleted file mode 100644
index e38fe13b..00000000
--- a/src/popover/utils.ts
+++ /dev/null
@@ -1,696 +0,0 @@
-import { getElementRoot, raf } from '../utils';
-
-import type { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from './popover-interface';
-import { POPOVER_IOS_BODY_MARGIN } from './animations/ios.enter';
-
-interface InteractionCallback {
- eventName: string;
- callback: (ev: any) => void; // TODO(FW-2832): type
-}
-
-export interface ReferenceCoordinates {
- top: number;
- left: number;
- width: number;
- height: number;
-}
-
-interface PopoverPosition {
- top: number;
- left: number;
- referenceCoordinates?: ReferenceCoordinates;
- originX: string;
- originY: string;
-}
-
-export interface PopoverStyles {
- top: number;
- left: number;
- bottom?: number;
- originX: string;
- originY: string;
- checkSafeAreaLeft: boolean;
- checkSafeAreaRight: boolean;
- addPopoverBottomClass: boolean;
-}
-
-/**
- * Returns the recommended dimensions of the popover
- * that takes into account whether or not the width
- * should match the trigger width.
- */
-export const getPopoverDimensions = (size: PopoverSize, contentEl: HTMLElement, triggerEl?: HTMLElement) => {
- const contentDimentions = contentEl.getBoundingClientRect();
- const contentHeight = contentDimentions.height;
- let contentWidth = contentDimentions.width;
-
- if (size === 'cover' && triggerEl) {
- const triggerDimensions = triggerEl.getBoundingClientRect();
- contentWidth = triggerDimensions.width;
- }
-
- return {
- contentWidth,
- contentHeight,
- };
-};
-
-export const configureDismissInteraction = (
- triggerEl: HTMLElement,
- triggerAction: TriggerAction,
- popoverEl: HTMLIonPopoverElement,
- parentPopoverEl: HTMLIonPopoverElement,
-) => {
- let dismissCallbacks: InteractionCallback[] = [];
- const root = getElementRoot(parentPopoverEl);
- const parentContentEl = root.querySelector('.popover-content') as HTMLElement;
-
- switch (triggerAction) {
- case 'hover':
- dismissCallbacks = [
- {
- /**
- * Do not use mouseover here
- * as this will causes the event to
- * be dispatched on each underlying
- * element rather than on the popover
- * content as a whole.
- */
- eventName: 'mouseenter',
- callback: (ev: MouseEvent) => {
- /**
- * Do not dismiss the popover is we
- * are hovering over its trigger.
- * This would be easier if we used mouseover
- * but this would cause the event to be dispatched
- * more often than we would like, potentially
- * causing performance issues.
- */
- const element = document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null;
- if (element === triggerEl) {
- return;
- }
-
- popoverEl.dismiss(undefined, undefined, false);
- },
- },
- ];
- break;
- case 'context-menu':
- case 'click':
- default:
- dismissCallbacks = [
- {
- eventName: 'click',
- callback: (ev: MouseEvent) => {
- /**
- * Do not dismiss the popover is we
- * are hovering over its trigger.
- */
- const target = ev.target as HTMLElement;
- const closestTrigger = target.closest('[data-ion-popover-trigger]');
- if (closestTrigger === triggerEl) {
- /**
- * stopPropagation here so if the
- * popover has dismissOnSelect="true"
- * the popover does not dismiss since
- * we just clicked a trigger element.
- */
- ev.stopPropagation();
- return;
- }
-
- popoverEl.dismiss(undefined, undefined, false);
- },
- },
- ];
- break;
- }
-
- dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.addEventListener(eventName, callback));
-
- return () => {
- dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.removeEventListener(eventName, callback));
- };
-};
-
-/**
- * Configures the triggerEl to respond
- * to user interaction based upon the triggerAction
- * prop that devs have defined.
- */
-export const configureTriggerInteraction = (triggerEl: HTMLElement, triggerAction: TriggerAction, popoverEl: HTMLIonPopoverElement) => {
- let triggerCallbacks: InteractionCallback[] = [];
-
- /**
- * Based upon the kind of trigger interaction
- * the user wants, we setup the correct event
- * listeners.
- */
- switch (triggerAction) {
- case 'hover':
- let hoverTimeout: ReturnType | undefined;
-
- triggerCallbacks = [
- {
- eventName: 'mouseenter',
- callback: async (ev: Event) => {
- ev.stopPropagation();
-
- if (hoverTimeout) {
- clearTimeout(hoverTimeout);
- }
-
- /**
- * Hovering over a trigger should not
- * immediately open the next popover.
- */
- hoverTimeout = setTimeout(() => {
- raf(() => {
- popoverEl.presentFromTrigger(ev);
- hoverTimeout = undefined;
- });
- }, 100);
- },
- },
- {
- eventName: 'mouseleave',
- callback: (ev: MouseEvent) => {
- if (hoverTimeout) {
- clearTimeout(hoverTimeout);
- }
-
- /**
- * If mouse is over another popover
- * that is not this popover then we should
- * close this popover.
- */
- const target = ev.relatedTarget as HTMLElement | null;
- if (!target) {
- return;
- }
-
- if (target.closest('ion-popover') !== popoverEl) {
- popoverEl.dismiss(undefined, undefined, false);
- }
- },
- },
- {
- /**
- * stopPropagation here prevents the popover
- * from dismissing when dismiss-on-select="true".
- */
- eventName: 'click',
- callback: (ev: Event) => ev.stopPropagation(),
- },
- {
- eventName: 'ionPopoverActivateTrigger',
- callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true),
- },
- ];
-
- break;
- case 'context-menu':
- triggerCallbacks = [
- {
- eventName: 'contextmenu',
- callback: (ev: Event) => {
- /**
- * Prevents the platform context
- * menu from appearing.
- */
- ev.preventDefault();
- popoverEl.presentFromTrigger(ev);
- },
- },
- {
- eventName: 'click',
- callback: (ev: Event) => ev.stopPropagation(),
- },
- {
- eventName: 'ionPopoverActivateTrigger',
- callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true),
- },
- ];
-
- break;
- case 'click':
- default:
- triggerCallbacks = [
- {
- /**
- * Do not do a stopPropagation() here
- * because if you had two click triggers
- * then clicking the first trigger and then
- * clicking the second trigger would not cause
- * the first popover to dismiss.
- */
- eventName: 'click',
- callback: (ev: Event) => popoverEl.presentFromTrigger(ev),
- },
- {
- eventName: 'ionPopoverActivateTrigger',
- callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true),
- },
- ];
- break;
- }
-
- triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.addEventListener(eventName, callback));
- triggerEl.setAttribute('data-ion-popover-trigger', 'true');
-
- return () => {
- triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.removeEventListener(eventName, callback));
- triggerEl.removeAttribute('data-ion-popover-trigger');
- };
-};
-
-/**
- * Returns the index of an ion-item in an array of ion-items.
- */
-export const getIndexOfItem = (items: HTMLIonItemElement[], item: HTMLElement | null) => {
- if (!item || item.tagName !== 'ION-ITEM') {
- return -1;
- }
-
- return items.findIndex((el) => el === item);
-};
-
-/**
- * Given an array of elements and a currently focused ion-item
- * returns the next ion-item relative to the focused one or
- * undefined.
- */
-export const getNextItem = (items: HTMLIonItemElement[], currentItem: HTMLElement | null) => {
- const currentItemIndex = getIndexOfItem(items, currentItem);
- return items[currentItemIndex + 1];
-};
-
-/**
- * Given an array of elements and a currently focused ion-item
- * returns the previous ion-item relative to the focused one or
- * undefined.
- */
-export const getPrevItem = (items: HTMLIonItemElement[], currentItem: HTMLElement | null) => {
- const currentItemIndex = getIndexOfItem(items, currentItem);
- return items[currentItemIndex - 1];
-};
-
-/** Focus the internal button of the ion-item */
-const focusItem = (item: HTMLIonItemElement) => {
- const root = getElementRoot(item);
- const button = root.querySelector('button');
-
- if (button) {
- raf(() => button.focus());
- }
-};
-
-/**
- * Positions a popover by taking into account
- * the reference point, preferred side, alignment
- * and viewport dimensions.
- */
-export const getPopoverPosition = (
- isRTL: boolean,
- contentWidth: number,
- contentHeight: number,
- reference: PositionReference,
- side: PositionSide,
- align: PositionAlign,
- defaultPosition: PopoverPosition,
- triggerEl?: HTMLElement,
- event?: MouseEvent | CustomEvent,
-): PopoverPosition => {
- let referenceCoordinates = {
- top: 0,
- left: 0,
- width: 0,
- height: 0,
- };
-
- /**
- * Calculate position relative to the
- * x-y coordinates in the event that
- * was passed in
- */
- switch (reference) {
- case 'event':
- if (!event) {
- return defaultPosition;
- }
-
- const mouseEv = event as MouseEvent;
-
- referenceCoordinates = {
- top: mouseEv.clientY,
- left: mouseEv.clientX,
- width: 1,
- height: 1,
- };
-
- break;
-
- /**
- * Calculate position relative to the bounding
- * box on either the trigger element
- * specified via the `trigger` prop or
- * the target specified on the event
- * that was passed in.
- */
- case 'trigger':
- default:
- const customEv = event as CustomEvent;
-
- /**
- * ionShadowTarget is used when we need to align the
- * popover with an element inside of the shadow root
- * of an Ionic component. Ex: Presenting a popover
- * by clicking on the collapsed indicator inside
- * of `ion-breadcrumb` and centering it relative
- * to the indicator rather than `ion-breadcrumb`
- * as a whole.
- */
- const actualTriggerEl = (triggerEl || customEv?.detail?.ionShadowTarget || customEv?.target) as HTMLElement | null;
- if (!actualTriggerEl) {
- return defaultPosition;
- }
- const triggerBoundingBox = actualTriggerEl.getBoundingClientRect();
- referenceCoordinates = {
- top: triggerBoundingBox.top,
- left: triggerBoundingBox.left,
- width: triggerBoundingBox.width,
- height: triggerBoundingBox.height,
- };
-
- break;
- }
-
- /**
- * Get top/left offset that would allow
- * popover to be positioned on the
- * preferred side of the reference.
- */
- const coordinates = calculatePopoverSide(side, referenceCoordinates, contentWidth, contentHeight, isRTL);
-
- /**
- * Get the top/left adjustments that
- * would allow the popover content
- * to have the correct alignment.
- */
- const alignedCoordinates = calculatePopoverAlign(align, side, referenceCoordinates, contentWidth, contentHeight);
-
- const top = coordinates.top + alignedCoordinates.top;
- const left = coordinates.left + alignedCoordinates.left;
-
- const { originX, originY } = calculatePopoverOrigin(side, align, isRTL);
-
- return { top, left, referenceCoordinates, originX, originY };
-};
-
-/**
- * Determines the transform-origin
- * of the popover animation so that it
- * is in line with what the side and alignment
- * prop values are. Currently only used
- * with the MD animation.
- */
-const calculatePopoverOrigin = (side: PositionSide, align: PositionAlign, isRTL: boolean) => {
- switch (side) {
- case 'top':
- return { originX: getOriginXAlignment(align), originY: 'bottom' };
- case 'bottom':
- return { originX: getOriginXAlignment(align), originY: 'top' };
- case 'left':
- return { originX: 'right', originY: getOriginYAlignment(align) };
- case 'right':
- return { originX: 'left', originY: getOriginYAlignment(align) };
- case 'start':
- return { originX: isRTL ? 'left' : 'right', originY: getOriginYAlignment(align) };
- case 'end':
- return { originX: isRTL ? 'right' : 'left', originY: getOriginYAlignment(align) };
- }
-};
-
-const getOriginXAlignment = (align: PositionAlign) => {
- switch (align) {
- case 'start':
- return 'left';
- case 'center':
- return 'center';
- case 'end':
- return 'right';
- }
-};
-
-const getOriginYAlignment = (align: PositionAlign) => {
- switch (align) {
- case 'start':
- return 'top';
- case 'center':
- return 'center';
- case 'end':
- return 'bottom';
- }
-};
-
-/**
- * Calculates the required top/left
- * values needed to position the popover
- * content on the side specified in the
- * `side` prop.
- */
-const calculatePopoverSide = (
- side: PositionSide,
- triggerBoundingBox: ReferenceCoordinates,
- contentWidth: number,
- contentHeight: number,
- isRTL: boolean,
-) => {
- const sideLeft = {
- top: triggerBoundingBox.top,
- left: triggerBoundingBox.left - contentWidth,
- };
- const sideRight = {
- top: triggerBoundingBox.top,
- left: triggerBoundingBox.left + triggerBoundingBox.width,
- };
-
- switch (side) {
- case 'top':
- return {
- top: triggerBoundingBox.top - contentHeight,
- left: triggerBoundingBox.left,
- };
- case 'right':
- return sideRight;
- case 'bottom':
- return {
- top: triggerBoundingBox.top + triggerBoundingBox.height,
- left: triggerBoundingBox.left,
- };
- case 'left':
- return sideLeft;
- case 'start':
- return isRTL ? sideRight : sideLeft;
- case 'end':
- return isRTL ? sideLeft : sideRight;
- }
-};
-
-/**
- * Calculates the required top/left
- * offset values needed to provide the
- * correct alignment regardless while taking
- * into account the side the popover is on.
- */
-const calculatePopoverAlign = (
- align: PositionAlign,
- side: PositionSide,
- triggerBoundingBox: ReferenceCoordinates,
- contentWidth: number,
- contentHeight: number,
-) => {
- switch (align) {
- case 'center':
- return calculatePopoverCenterAlign(side, triggerBoundingBox, contentWidth, contentHeight);
- case 'end':
- return calculatePopoverEndAlign(side, triggerBoundingBox, contentWidth, contentHeight);
- case 'start':
- default:
- return { top: 0, left: 0 };
- }
-};
-
-/**
- * Calculate the end alignment for
- * the popover. If side is on the x-axis
- * then the align values refer to the top
- * and bottom margins of the content.
- * If side is on the y-axis then the
- * align values refer to the left and right
- * margins of the content.
- */
-const calculatePopoverEndAlign = (
- side: PositionSide,
- triggerBoundingBox: ReferenceCoordinates,
- contentWidth: number,
- contentHeight: number,
-) => {
- switch (side) {
- case 'start':
- case 'end':
- case 'left':
- case 'right':
- return {
- top: -(contentHeight - triggerBoundingBox.height),
- left: 0,
- };
- case 'top':
- case 'bottom':
- default:
- return {
- top: 0,
- left: -(contentWidth - triggerBoundingBox.width),
- };
- }
-};
-
-/**
- * Calculate the center alignment for
- * the popover. If side is on the x-axis
- * then the align values refer to the top
- * and bottom margins of the content.
- * If side is on the y-axis then the
- * align values refer to the left and right
- * margins of the content.
- */
-const calculatePopoverCenterAlign = (
- side: PositionSide,
- triggerBoundingBox: ReferenceCoordinates,
- contentWidth: number,
- contentHeight: number,
-) => {
- switch (side) {
- case 'start':
- case 'end':
- case 'left':
- case 'right':
- return {
- top: -(contentHeight / 2 - triggerBoundingBox.height / 2),
- left: 0,
- };
- case 'top':
- case 'bottom':
- default:
- return {
- top: 0,
- left: -(contentWidth / 2 - triggerBoundingBox.width / 2),
- };
- }
-};
-
-/**
- * Adjusts popover positioning coordinates
- * such that popover does not appear offscreen
- * or overlapping safe area bounds.
- */
-export const calculateWindowAdjustment = (
- side: PositionSide,
- coordTop: number,
- coordLeft: number,
- bodyPadding: number,
- bodyWidth: number,
- bodyHeight: number,
- contentWidth: number,
- contentHeight: number,
- safeAreaMargin: number,
- contentOriginX: string,
- contentOriginY: string,
- triggerCoordinates?: ReferenceCoordinates,
- eventElementRect?: DOMRect,
- isReplace: boolean = false,
-): PopoverStyles => {
- const triggerTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height : bodyHeight / 2 - contentHeight / 2;
- const triggerHeight = triggerCoordinates ? triggerCoordinates.height : 0;
- let left = coordLeft;
- let top = !isReplace ? coordTop + POPOVER_IOS_BODY_MARGIN : coordTop - triggerHeight;
- let bottom;
- let originX = contentOriginX;
- let originY = contentOriginY;
- let checkSafeAreaLeft = false;
- let checkSafeAreaRight = false;
- let addPopoverBottomClass = false;
-
- /**
- * Adjust popover so it does not
- * go off the left of the screen.
- */
- if (left < bodyPadding + safeAreaMargin) {
- left = !eventElementRect ? bodyPadding : eventElementRect.left;
- if (left === 0) {
- left = safeAreaMargin;
- }
- checkSafeAreaLeft = true;
- originX = 'left';
- /**
- * Adjust popover so it does not
- * go off the right of the screen.
- */
- } else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) {
- checkSafeAreaRight = true;
- left = !eventElementRect ? bodyWidth - contentWidth - bodyPadding : eventElementRect.right - contentWidth;
- if (left + contentWidth === bodyWidth) {
- left = left - safeAreaMargin;
- }
- originX = 'right';
- }
-
- /**
- * Adjust popover so it does not
- * go off the top of the screen.
- * If popover is on the left or the right of
- * the trigger, then we should not adjust top
- * margins.
- */
- const compareTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height / 2 : bodyHeight / 2 - contentHeight / 2;
- if (compareTop > bodyHeight / 2 && (side === 'top' || side === 'bottom')) {
- if (triggerTop - contentHeight > 0) {
- /**
- * While we strive to align the popover with the trigger
- * on smaller screens this is not always possible. As a result,
- * we adjust the popover up so that it does not hang
- * off the bottom of the screen. However, we do not want to move
- * the popover up so much that it goes off the top of the screen.
- *
- * We chose 12 here so that the popover position looks a bit nicer as
- * it is not right up against the edge of the screen.
- */
- if (!isReplace) {
- top = Math.max(12, triggerTop - contentHeight - triggerHeight) - POPOVER_IOS_BODY_MARGIN;
- } else {
- top = Math.max(12, triggerTop - contentHeight);
- }
- originY = 'bottom';
- addPopoverBottomClass = true;
-
- /**
- * If not enough room for popover to appear
- * above trigger, then cut it off.
- */
- } else {
- bottom = bodyPadding;
- }
- }
-
- return {
- top,
- left,
- bottom,
- originX,
- originY,
- checkSafeAreaLeft,
- checkSafeAreaRight,
- addPopoverBottomClass,
- };
-};
diff --git a/src/segment/index.ts b/src/segment/index.ts
new file mode 100644
index 00000000..2a432268
--- /dev/null
+++ b/src/segment/index.ts
@@ -0,0 +1,346 @@
+import type { registeredEffect } from '../sheets-of-glass/interfaces';
+
+/** Local shell gate — no native-integration module on this branch. */
+const isNativeUIShell = (element: HTMLElement) => element.hasAttribute('data-native-ui-shell');
+
+interface LensRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+// iOS 26.5 ShellSegment, 31pt content control (FSpkxf), relative to pointerup.
+// [time seconds, normalized center, width additive pt, height additive pt].
+// Keep the recorded times: fitting an arbitrary temporal shift hides input latency.
+const selectionFrames = [
+ [0, 0, 0, 0],
+ [0.0331, 0.0081, 0.466, 0.311],
+ [0.0665, 0.1482, 6.775, 4.516],
+ [0.0998, 0.3559, 14.018, 8.811],
+ [0.1331, 0.5514, 20.831, 11.071],
+ [0.1665, 0.7046, 27.26, 11.464],
+ [0.1998, 0.8124, 32.711, 10.906],
+ [0.2665, 0.9309, 35.042, 9.581],
+ [0.3331, 0.9873, 13.895, 4.267],
+ [0.4001, 1.0135, -1.786, 2.911],
+ [0.5165, 1.0196, -9.478, 2.608],
+ [0.5998, 1.0089, -4.653, 1.286],
+ [0.6998, 1, 0, 0],
+ [0.7998, 0.9984, 0.912, -0.252],
+ [0.9, 1, 0, 0],
+];
+
+// Candidate release deformation (not measured for iOS 26.5).
+const releaseFrames = [
+ [0, 1, 1],
+ [0.033, 1.01, 0.81],
+ [0.067, 0.7, 0.205],
+ [0.1, 0.51, -0.042],
+ [0.133, 0.43, -0.101],
+ [0.167, 0.189, -0.075],
+ [0.2, 0.043, 0.027],
+ [0.267, -0.154, 0.196],
+ [0.333, -0.183, 0.236],
+ [0.4, -0.123, 0.159],
+ [0.5, -0.038, 0.05],
+ [0.6, 0.005, -0.007],
+ [0.7, 0.008, -0.01],
+ [0.8, 0, 0],
+];
+
+/** Visual-only enhancement: Ionic retains ownership of selection, gestures and events. */
+export const registerSegmentEffect = (targetElement: HTMLElement): registeredEffect | undefined => {
+ const segment = targetElement as HTMLIonSegmentElement;
+ const doc = segment.ownerDocument;
+ const win = doc.defaultView;
+ if (!segment.classList.contains('ios') || !win || segment.matches('.ios26-enable-gesture, .ios-theme-disabled, .ios26-disabled'))
+ return undefined;
+ const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)');
+ if (reducedMotion.matches) return undefined;
+ const lens = doc.createElement('div');
+ lens.className = 'ios26-segment-lens';
+ lens.setAttribute('aria-hidden', 'true');
+ const edge = doc.createElement('div');
+ edge.className = 'ios26-segment-edge';
+ lens.append(edge);
+ segment.append(lens);
+ segment.classList.add('ios26-enable-gesture');
+ const listeners = new AbortController();
+ let animation: Animation | undefined;
+ let handoff: Animation[] = [];
+ let destroyed = false;
+ let pendingEnd = 0;
+ let surfaceWidth = 1;
+ let surfaceColor = 'transparent';
+ let pointer:
+ | { id: number; startX: number; startedAt: number; from: LensRect; button: HTMLElement; selected: boolean; moved: boolean }
+ | undefined;
+ const buttons = () => Array.from(segment.querySelectorAll('ion-segment-button'));
+ const selected = () => buttons().find((button) => button.value === segment.value);
+
+ const rect = (button: HTMLElement): LensRect => {
+ const outer = segment.getBoundingClientRect();
+ const box = button.getBoundingClientRect();
+ const sx = outer.width / segment.offsetWidth || 1;
+ const sy = outer.height / segment.offsetHeight || 1;
+ return {
+ x: (box.left + box.width / 2 - outer.left) / sx + segment.scrollLeft,
+ y: (box.top + box.height / 2 - outer.top) / sy,
+ width: Math.max(1, button.clientWidth - 4),
+ height: button.clientHeight,
+ };
+ };
+ const currentRect = (): LensRect => {
+ const outer = segment.getBoundingClientRect();
+ const box = lens.getBoundingClientRect();
+ const sx = outer.width / segment.offsetWidth || 1;
+ const sy = outer.height / segment.offsetHeight || 1;
+ return {
+ x: (box.left + box.width / 2 - outer.left) / sx + segment.scrollLeft,
+ y: (box.top + box.height / 2 - outer.top) / sy,
+ width: box.width / sx,
+ height: box.height / sy,
+ };
+ };
+ // Animate dimensions separately so the glass border and shadow are not scaled.
+ const frame = (box: LensRect): Keyframe => ({
+ transform: `translate3d(${box.x - box.width / 2}px, ${box.y - box.height / 2}px, 0)`,
+ width: `${box.width}px`,
+ height: `${box.height}px`,
+ backgroundColor: `color-mix(in srgb, ${surfaceColor} ${100 - Math.max(0, Math.min(1, (box.width - surfaceWidth) / 24)) * 90}%, transparent)`,
+ });
+ const hide = () => {
+ animation?.cancel();
+ animation = undefined;
+ lens.hidden = true;
+ segment.classList.remove('ios26-animated');
+ handoff.forEach((effect) => effect.cancel());
+ handoff = [];
+ };
+ lens.hidden = true;
+ const play = (frames: Keyframe[], duration: number, hold = false, startTime = win.performance.now()) => {
+ animation?.cancel();
+ handoff.forEach((effect) => effect.cancel());
+ handoff = [];
+ segment.classList.add('ios26-animated');
+ lens.hidden = false;
+ const running = lens.animate(frames, { duration, easing: 'linear', fill: 'forwards' });
+ // Keep the event's timeline origin even when rendering starts a frame later.
+ running.startTime = startTime;
+ animation = running;
+ const restingWidth = hold ? surfaceWidth : parseFloat(String(frames[frames.length - 1]['width']));
+ // No raised edge at either resting endpoint; reveal it with the lens deformation.
+ handoff = [
+ edge.animate(
+ frames.map((sample) => ({
+ offset: sample.offset,
+ opacity: Math.max(
+ 0,
+ Math.min(1, (parseFloat(String(sample['width'])) - (surfaceWidth + (restingWidth - surfaceWidth) * (sample.offset ?? 0))) / 24),
+ ),
+ })),
+ { duration, fill: 'forwards' },
+ ),
+ ];
+ const indicator = selected()?.shadowRoot?.querySelector('[part="indicator"]');
+ if (!hold && indicator) {
+ // Blend into Ionic's actual selected surface, including custom colors/shadows.
+ // Fading the whole lens also removes its reflective edge before it is detached.
+ const offset = Math.max(0, 1 - 200 / duration);
+ handoff.push(
+ lens.animate([{ opacity: 1 }, { opacity: 1, offset }, { opacity: 0 }], { duration, fill: 'forwards' }),
+ indicator.animate([{ opacity: 0 }, { opacity: 0, offset }, { opacity: 1 }], { duration, fill: 'forwards' }),
+ );
+ }
+ handoff.forEach((effect) => (effect.startTime = startTime));
+ if (!hold)
+ void running.finished.then(
+ () => {
+ if (animation === running) hide();
+ },
+ () => {},
+ );
+ };
+ const settle = (from: LensRect, to: LensRect, changed: boolean, startTime: number) => {
+ const toolbar = segment.classList.contains('in-toolbar') && !segment.classList.contains('segment-expand');
+ const samples = changed ? selectionFrames : releaseFrames.map(([time, width, height]) => [time, 1 - width, 0, 0, width, height]);
+ const duration = samples[samples.length - 1][0];
+ const frames = samples.map(([time, position, width, height, remainingWidth, remainingHeight]) => {
+ const remaining = 1 - position;
+ const box = {
+ x: from.x + (to.x - from.x) * position,
+ y: from.y + (to.y - from.y) * position,
+ width: Math.max(1, to.width + (from.width - to.width) * (changed ? remaining : remainingWidth) + (changed ? width : 0)),
+ height: Math.max(
+ 1,
+ to.height +
+ (from.height - to.height) * (changed ? remaining : remainingHeight) +
+ (changed ? height * (toolbar ? 1 / 1.1 : 1) : 0),
+ ),
+ };
+ return { ...frame(box), offset: time / duration };
+ });
+ play(frames, duration * 1000, false, startTime);
+ };
+ const releasePressed = (from: LensRect, to: LensRect) => {
+ // Candidate selected-item release curve (not measured for iOS 26.5).
+ const samples = [
+ [0, 1],
+ [0.033, 0.78],
+ [0.067, 0.49],
+ [0.1, 0.28],
+ [0.133, 0.157],
+ [0.167, 0.089],
+ [0.2, 0.054],
+ [0.233, 0.034],
+ [0.267, 0.009],
+ [0.317, 0],
+ [0.43, -0.013],
+ [0.65, 0],
+ ];
+ play(
+ samples.map(([time, remaining]) => ({
+ ...frame({
+ ...to,
+ width: to.width + (from.width - to.width) * remaining,
+ height: to.height + (from.height - to.height) * remaining,
+ }),
+ offset: time / 0.65,
+ })),
+ 650,
+ );
+ };
+ const down = (event: PointerEvent) => {
+ if (pointer || event.button !== 0 || segment.disabled || reducedMotion.matches || isNativeUIShell(segment)) return;
+ const button = (event.target as Element).closest('ion-segment-button');
+ const old = selected();
+ if (!button || button.disabled || !old) return;
+ win.cancelAnimationFrame(pendingEnd);
+ const rest = rect(old);
+ surfaceWidth = rest.width;
+ const style = win.getComputedStyle(old);
+ surfaceColor = style.getPropertyValue('--indicator-color') || 'transparent';
+ lens.style.borderRadius = style.getPropertyValue('--border-radius');
+ const interrupted = !!animation;
+ const from = interrupted ? currentRect() : rest;
+ hide();
+ pointer = {
+ id: event.pointerId,
+ startX: event.clientX,
+ startedAt: win.performance.now(),
+ from: rest,
+ button: old,
+ selected: old === button,
+ moved: false,
+ };
+ if (pointer.selected) {
+ // Candidate selected-item press curve (not measured for iOS 26.5).
+ const samples = [
+ [0, 0],
+ [0.033, 0.133],
+ [0.067, 0.43],
+ [0.1, 0.67],
+ [0.133, 0.83],
+ [0.167, 0.92],
+ [0.2, 0.97],
+ [0.233, 1],
+ ];
+ play(
+ samples.map(([time, expansion]) => ({
+ ...frame({
+ ...from,
+ width: from.width + (rest.width + 24 - from.width) * expansion,
+ height: from.height + (rest.height + 16 - from.height) * expansion,
+ }),
+ offset: time / 0.233,
+ })),
+ 233,
+ true,
+ );
+ } else if (interrupted) {
+ play([frame(from), frame(from)], 1, true);
+ }
+ };
+ const move = (event: PointerEvent) => {
+ if (!pointer || pointer.id !== event.pointerId || !pointer.selected || !segment.swipeGesture || segment.scrollable) return;
+ const dx = (event.clientX - pointer.startX) / (segment.getBoundingClientRect().width / segment.offsetWidth || 1);
+ if (Math.abs(dx) < 3 && !pointer.moved) return;
+ pointer.moved = true;
+ const centers = buttons()
+ .filter((button) => !button.disabled)
+ .map((button) => rect(button).x);
+ const box = {
+ ...pointer.from,
+ x: Math.max(Math.min(...centers), Math.min(Math.max(...centers), pointer.from.x + dx)),
+ width: pointer.from.width + 24,
+ height: pointer.from.height + 16,
+ };
+ animation?.cancel();
+ animation = undefined;
+ Object.assign(lens.style, frame(box));
+ };
+ const end = (event: PointerEvent) => {
+ if (!pointer || pointer.id !== event.pointerId) return;
+ const releasedAt = win.performance.now();
+ const state = pointer;
+ const from = !lens.hidden ? currentRect() : state.from;
+ pointer = undefined;
+ // Capture runs before Ionic's pointer-end handler. Read its committed value afterwards.
+ pendingEnd = win.requestAnimationFrame(() => {
+ pendingEnd = 0;
+ if (destroyed || pointer) return;
+ const next = selected();
+ if (!next || reducedMotion.matches || event.type === 'pointercancel') {
+ hide();
+ return;
+ }
+ const changed = next !== state.button;
+ // Ionic has committed selection: use the destination's public appearance
+ // for the moving surface as well as the final indicator.
+ const nextStyle = win.getComputedStyle(next);
+ surfaceColor = nextStyle.getPropertyValue('--indicator-color') || 'transparent';
+ lens.style.borderRadius = nextStyle.getPropertyValue('--border-radius');
+ if (!changed && !state.moved && animation) {
+ // A short selected-item tap completes its press before returning, as UIKit does.
+ const pressing = animation;
+ void pressing.finished.then(
+ () => {
+ if (!destroyed && !pointer && animation === pressing) releasePressed(currentRect(), rect(next));
+ },
+ () => {},
+ );
+ } else if (changed || !lens.hidden) settle(from, rect(next), changed && !state.moved, releasedAt);
+ });
+ };
+ const abort = () => {
+ win.cancelAnimationFrame(pendingEnd);
+ pendingEnd = 0;
+ pointer = undefined;
+ hide();
+ };
+ segment.addEventListener('pointerdown', down, { capture: true, signal: listeners.signal });
+ doc.addEventListener('pointermove', move, { capture: true, signal: listeners.signal });
+ doc.addEventListener('pointerup', end, { capture: true, signal: listeners.signal });
+ doc.addEventListener('pointercancel', end, { capture: true, signal: listeners.signal });
+ win.addEventListener('blur', abort, { signal: listeners.signal });
+ win.addEventListener('resize', abort, { signal: listeners.signal });
+ segment.addEventListener(
+ 'ionSelect',
+ () => {
+ if (!pointer && !pendingEnd) abort();
+ },
+ { signal: listeners.signal },
+ );
+ reducedMotion.addEventListener('change', abort, { signal: listeners.signal });
+ segment.addEventListener('nativeUIShellChange', abort, { signal: listeners.signal });
+ return {
+ destroy: () => {
+ destroyed = true;
+ listeners.abort();
+ abort();
+ segment.classList.remove('ios26-enable-gesture');
+ lens.remove();
+ },
+ };
+};
diff --git a/src/styles/components/ion-action-sheet.scss b/src/styles/components/ion-action-sheet.scss
index b4e8510a..394b0ba7 100644
--- a/src/styles/components/ion-action-sheet.scss
+++ b/src/styles/components/ion-action-sheet.scss
@@ -1,61 +1,91 @@
@use '../utils/api';
ion-action-sheet.ios:not(.ios-theme-disabled, .ios26-disabled) {
- --backdrop-opacity: 0.2;
- --color: var(--ion-color-step-600, var(--ion-text-color-step-400, #666666));
+ @include api.glass-overlay-variables;
+ --backdrop-opacity: 0.2066;
+ --max-width: min(320px, calc(100vw - 32px));
+ --color: var(--ion-text-color, #000);
+ --button-background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.12);
+ --button-color: var(--ion-text-color, #000);
+ // iOS 26.1/26.5: without a source anchor UIKit uses a centered dialog.
+ // Anchored menus are represented by ion-popover, not a bottom sheet.
+ justify-content: center;
+ &:not(.overlay-hidden) {
+ display: flex;
+ }
+ flex-direction: column;
+ box-sizing: border-box;
+ padding-top: var(--ion-safe-area-top, env(safe-area-inset-top, 0px));
+ padding-bottom: var(--ion-safe-area-bottom, env(safe-area-inset-bottom, 0px));
.action-sheet-wrapper {
+ position: relative;
+ margin: 0 auto;
padding-bottom: 0;
- bottom: var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom));
+ bottom: auto;
}
.action-sheet-container {
- @include api.glass-background-overlay;
- transition: transform 200ms ease;
- &:has(.ion-activated) {
- transform: scale(1.016);
- @include api.glass-background-overlay-activated;
- }
- margin: 0 12px;
- border-radius: 32px;
+ @include api.glass-overlay-surface;
+ border-radius: 34px;
+ outline: none;
+ margin: 0;
+ padding: 0 16px 16px;
.action-sheet-title,
.action-sheet-has-sub-title {
background: none;
}
.action-sheet-title {
- padding-top: 8px;
+ padding: 22px 14px 20.6667px;
+ text-align: start;
+ font-size: max(17px, 1rem);
+ line-height: max(20.3333px, 1.196078rem);
+ font-weight: 600;
}
- .action-sheet-has-sub-title {
+ .action-sheet-sub-title {
+ padding: 0;
+ margin-top: 7.3333px;
+ font-size: max(15px, 0.882353rem);
+ line-height: max(18px, 1.058823rem);
+ font-weight: 400;
+ color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.55);
}
.action-sheet-group {
background: transparent;
+ border-radius: 0;
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
button {
- @include api.glass-background-overlay-button-variable;
&::after {
content: none;
}
- &:not(.action-sheet-destructive):not(.action-sheet-selected) {
- --button-color: var(--ion-text-color, #000);
- --button-color-hover: var(--ion-text-color, #000);
- --button-color-activated: var(--ion-text-color, #000);
- }
&.action-sheet-destructive {
--button-color: var(--ion-color-danger, #c5000f);
--button-color-hover: var(--ion-color-danger, #c5000f);
}
- font-weight: 550;
+ &.action-sheet-selected {
+ --button-background: var(--ion-color-primary, #0289ff);
+ --button-color: var(--ion-color-primary-contrast, #fff);
+ --button-color-selected: var(--ion-color-primary-contrast, #fff);
+ --button-color-hover: var(--ion-color-primary-contrast, #fff);
+ --button-color-activated: var(--ion-color-primary-contrast, #fff);
+ font-weight: 600;
+ }
+ font-weight: 500;
&.action-sheet-cancel {
- margin-top: 12px;
- font-weight: 550;
+ margin-top: 8px;
+ font-weight: 500;
}
- border-radius: 32px;
+ border-radius: 24px;
padding: 0 14px;
- min-height: 52px;
+ min-height: 48px;
span.action-sheet-button-inner {
- font-size: 1rem;
+ font-size: max(17px, 1rem);
+ line-height: max(20.3333px, 1.196078rem);
ion-icon {
font-size: 1.2rem;
}
diff --git a/src/styles/components/ion-alert.scss b/src/styles/components/ion-alert.scss
index 1fc4cddc..c48baf70 100644
--- a/src/styles/components/ion-alert.scss
+++ b/src/styles/components/ion-alert.scss
@@ -1,35 +1,40 @@
@use '../utils/api';
ion-alert.ios:not(.ios-theme-disabled, .ios26-disabled) {
- --min-width: 280px;
- --backdrop-opacity: 0.2;
-
- // --max-width: clamp(270px, 16.875rem, 324px);
- --max-width: 322px;
-
- transition: transform 200ms ease;
- &:has(.ion-activated) {
- transform: scale(1.016);
- }
+ @include api.glass-overlay-variables;
+ --min-width: min(280px, calc(100vw - 32px));
+ --max-width: min(320px, calc(100vw - 32px));
+ --backdrop-opacity: 0.2066;
+ // UIKit centers within the safe area, not the full-screen rectangle.
+ padding-top: var(--ion-safe-area-top, env(safe-area-inset-top, 0px));
+ padding-bottom: var(--ion-safe-area-bottom, env(safe-area-inset-bottom, 0px));
.alert-wrapper {
- @include api.glass-background-overlay;
- &:has(.ion-activated) {
- @include api.glass-background-overlay-activated;
+ @include api.glass-overlay-surface;
+ border-radius: 34px;
+ outline: none;
+ .alert-head {
+ padding: 22px 30px 7px;
+ text-align: start;
}
- border-radius: 32px;
-
- .alert-head,
+ .alert-title {
+ margin: 0;
+ font-size: max(17px, 1rem);
+ line-height: max(20.3333px, 1.196078rem);
+ font-weight: 600;
+ }
+ .alert-sub-title,
.alert-message {
- text-align: left;
- margin-left: 14px;
- margin-right: 14px;
+ font-size: max(15px, 0.882353rem);
+ line-height: max(19px, 1.117647rem);
+ color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.55);
}
-
.alert-message {
- font-size: 0.98rem;
- line-height: 1.2rem;
- color: var(--ion-color-step-600, var(--ion-text-color-step-400, #666666));
+ padding: 0 30px 21px;
+ text-align: start;
+ &:empty {
+ padding: 0 0 12px;
+ }
}
.alert-radio-group {
@@ -41,26 +46,33 @@ ion-alert.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
.alert-button-group {
- padding: 0 8px 12px;
- gap: 8px 0;
+ padding: 0 16px 16px;
+ gap: 8px;
.alert-button {
@include api.glass-background-overlay-button;
+ color: var(--ion-text-color, #000);
border: none;
- border-radius: 32px;
- margin: 0 6px;
- min-width: calc(50% - 12px);
- // height: max(44px, 2.75rem);
- height: 48px;
-
- &:not(.alert-button-role-destructive) {
- color: var(--ion-text-color, #000);
+ border-radius: 24px;
+ margin: 0;
+ min-width: calc(50% - 4px);
+ min-height: 48px;
+ height: auto;
+ padding: 12px;
+ font-size: max(17px, 1rem);
+ line-height: max(20.3333px, 1.196078rem);
+ font-weight: 500;
+ .alert-button-inner {
+ height: auto;
+ min-height: 0;
+ }
+ &.alert-button-role-destructive {
+ color: var(--ion-color-danger, #ff383c);
+ }
+ &.alert-button-role-preferred {
+ @include api.glass-overlay-preferred-button;
+ font-weight: 600;
}
- font-weight: 550;
}
}
-
- button.action-sheet-cancel {
- margin-top: 12px;
- }
}
}
diff --git a/src/styles/components/ion-button.scss b/src/styles/components/ion-button.scss
index e69e07c1..e2c1c9b5 100644
--- a/src/styles/components/ion-button.scss
+++ b/src/styles/components/ion-button.scss
@@ -1,4 +1,5 @@
@use '../utils/api';
+@use '../utils/glass';
/**
* Note: ion-back-button should not put inner ion-button.
@@ -130,9 +131,36 @@ $scaleup-large-icon-only: 1.22;
height: 44px;
padding: 2px;
}
+
+ // Single plain UINavigationBar action, measured alongside the 26.5 header.
+ &:has(> ion-button.button-clear:only-child:not(.button-small, .button-large, .ios-theme-disabled, .ios26-disabled)) {
+ border: none;
+ border-radius: 22px;
+ backdrop-filter: blur(8px) saturate(250%);
+ // Keep the soft shadow within the toolbar's vertical clearance.
+ @include glass.light-shadow($geometry: 0 2px 8px -2px);
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ border-radius: inherit;
+ box-sizing: border-box;
+ @include glass.light-rim;
+ }
+ > ion-button.button-clear:not(.button-small, .button-large, .button-has-icon-only) {
+ --padding-start: 16px;
+ --padding-end: 16px;
+ &::part(native) {
+ min-height: 44px;
+ line-height: max(20.3333px, 1.196078rem);
+ }
+ }
+ }
}
@mixin theme-button($is-back-button: false) {
+ font-weight: 400;
max-height: inherit;
z-index: 0;
&.ion-activated {
@@ -165,7 +193,7 @@ $scaleup-large-icon-only: 1.22;
// button size default
&:not(.button-small):not(.button-large) {
- font-size: 1.05rem;
+ font-size: max(17px, 1rem);
&:not(.button-has-icon-only):not(.back-button-has-icon-only) {
--padding-bottom: 0;
--padding-end: 12px;
@@ -317,9 +345,13 @@ $scaleup-large-icon-only: 1.22;
--background-hover: transparent;
--background-activated: transparent;
@if not $is-back-button {
- --box-shadow:
- inset 0 0 8px 0 rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.2),
- 0 0 10px 0 rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.82);
+ @include glass.light-shadow(--box-shadow, 0 2px 8px -2px);
+ --border-width: 0.5px;
+ --border-style: solid;
+ --border-color: rgba(var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb)), 1) transparent;
+ @supports (background-clip: border-area) {
+ --border-color: transparent;
+ }
}
&:not(.button-has-icon-only):not(.back-button-has-icon-only) {
@@ -329,7 +361,12 @@ $scaleup-large-icon-only: 1.22;
}
&::part(native) {
- @include api.glass-background($include-background: false, $include-box-shadow: $is-back-button);
+ backdrop-filter: blur(8px) saturate(250%);
+ // Back buttons expose no border/box-shadow properties; regular buttons do.
+ @include glass.light-rim($include-border: $is-back-button);
+ @if $is-back-button {
+ @include glass.light-shadow($geometry: 0 2px 8px -2px);
+ }
}
&:not(.button-disabled) {
diff --git a/src/styles/components/ion-card.scss b/src/styles/components/ion-card.scss
index ee549c5c..6fa7dd5b 100644
--- a/src/styles/components/ion-card.scss
+++ b/src/styles/components/ion-card.scss
@@ -1,7 +1,7 @@
ion-card.ios:not(.ios-theme-disabled, .ios26-disabled) {
border-radius: 24px;
- ion-list ion-item {
+ ion-list:not(.ios-theme-disabled, .ios26-disabled) ion-item:not(.ios-theme-disabled, .ios26-disabled) {
--padding-start: 0;
}
}
diff --git a/src/styles/components/ion-fab.scss b/src/styles/components/ion-fab.scss
index 43cfc38f..ec187c3c 100644
--- a/src/styles/components/ion-fab.scss
+++ b/src/styles/components/ion-fab.scss
@@ -1,20 +1,25 @@
@use '../utils/api';
+@use '../utils/glass';
$scaleup-small-icon-only: 1.18;
$scaleup-default-icon-only: 1.2;
$scaleup-large-icon-only: 1.12;
ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled) {
- --transform-value: 2.5px;
+ --transform-value: 3px;
&:has(> ion-fab-button.fab-button-small) {
--transform-value: 4px;
}
- ion-fab-button {
+ ion-fab-button:not(.ios-theme-disabled, .ios26-disabled) {
--background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72);
- --box-shadow:
- inset 0 0 8px 0 rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.2),
- 0 0 10px 0 rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.82);
+ @include glass.light-shadow(--box-shadow);
+ --border-width: 0.5px;
+ --border-style: solid;
+ --border-color: rgba(var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb)), 1) transparent;
+ @supports (background-clip: border-area) {
+ --border-color: transparent;
+ }
--transition:
var(--ios-theme-activated-transition-duration, var(--ios26-activated-transition-duration)) transform
cubic-bezier(0.25, 1.11, 0.78, 1.59),
@@ -25,11 +30,8 @@ ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled) {
--color: rgb(var(--ion-text-color-rgb, 0, 0, 0));
- /**
- * 60px + border0.5px + 0.5px
- */
- width: 61px;
- height: 61px;
+ width: 62px;
+ height: 62px;
&.fab-button-in-list,
&.fab-button-small {
@@ -61,7 +63,9 @@ ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
&::part(native) {
- @include api.glass-background($include-background: false, $include-box-shadow: false);
+ @include api.glass-background($include-background: false, $include-box-shadow: false, $include-border: false);
+ backdrop-filter: blur(8px) saturate(250%);
+ @include glass.light-rim($include-border: false);
// The previous background shorthand reset Ionic's padding-box default.
// There is no CSS custom property for background-clip, so preserve it via the Part.
background-clip: border-box;
diff --git a/src/styles/components/ion-list.scss b/src/styles/components/ion-list.scss
index 2b457e81..e5cbc412 100644
--- a/src/styles/components/ion-list.scss
+++ b/src/styles/components/ion-list.scss
@@ -1,7 +1,9 @@
-@use '../utils/structured-list';
+@use 'pkg:@rdlabo/ionic-theme-utils/styles/structured-list';
+
+$ios26-disabled: '.ios-theme-disabled, .ios26-disabled';
ion-list.ios:not(.ios-theme-disabled, .ios26-disabled) {
- @include structured-list.support-text;
+ @include structured-list.support-text($disabled-selector: $ios26-disabled);
ion-item {
--background-hover-opacity: 0;
--background-focused-opacity: 0;
@@ -9,7 +11,9 @@ ion-list.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) {
- @include structured-list.layout(20px, 24px);
+ // Native iOS 26.5 insetGrouped: 20pt outside, 28pt corners, 53pt rows.
+ @include structured-list.layout(20px, 28px, $disabled-selector: $ios26-disabled);
+ margin-inline: calc(20px + var(--ion-safe-area-left, 0px)) calc(20px + var(--ion-safe-area-right, 0px));
ion-radio-group,
ion-reorder-group {
@@ -21,16 +25,23 @@ ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) {
padding-inline-end: calc(var(--ion-safe-area-right, 0) + 20px);
}
- > :is(#{structured-list.$groups}) {
- ion-item {
+ > :is(#{structured-list.$groups}):not(.ios-theme-disabled, .ios26-disabled) {
+ ion-item:not(.ios-theme-disabled, .ios26-disabled) {
// To draw lines inside
--inner-padding-end: 0;
&::part(native) {
- padding-right: calc(var(--ion-safe-area-right, 0px) + 18px);
+ padding-inline-end: 16px;
}
- --min-height: 52px;
+ --min-height: 53px;
+ font-size: max(17px, 1rem);
+ line-height: max(20.3333px, 1.196078rem);
+
+ > ion-label:not([slot], .ios-theme-disabled, .ios26-disabled) {
+ font-size: inherit;
+ line-height: inherit;
+ }
& > ion-input[labelplacement='floating'] {
transition: transform 200ms ease;
@@ -42,3 +53,10 @@ ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
}
}
+
+// UIKit's first inset group starts directly below the expanded large title.
+ion-content.ios:not(.ios-theme-disabled, .ios26-disabled)
+ > ion-header.header-collapse-condense:not(.ios-theme-disabled, .ios26-disabled)
+ + ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) {
+ margin-top: 0;
+}
diff --git a/src/styles/components/ion-popover.scss b/src/styles/components/ion-popover.scss
index ff287872..95a6e590 100644
--- a/src/styles/components/ion-popover.scss
+++ b/src/styles/components/ion-popover.scss
@@ -1,22 +1,66 @@
@use '../utils/api';
ion-popover.ios:not(.ios-theme-disabled, .ios26-disabled) {
- --backdrop-opacity: 0.2;
- --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72);
+ @include api.glass-overlay-variables;
+ --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.42);
+ --backdrop-opacity: 0;
--box-shadow:
- inset 0 0 8px 0 rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.2),
- 0 0 10px 0 rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.82);
+ inset 0 0.5px 1px rgba(var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb)), 0.6),
+ 0 4px 16px color-mix(in srgb, var(--ion-box-shadow-color, #000) 12%, transparent);
+
&::part(arrow) {
display: none;
- ::after {
- //@include api.glass-background;
+ width: 34px;
+ height: 14px;
+
+ &::after {
+ width: 100%;
+ height: 100%;
+ top: 0;
+ inset-inline-start: 0;
+ border-radius: 0;
+ transform: none;
+ // Rounded tip and concave shoulders join the popover's straight edge.
+ clip-path: path('M 0 14 L 0 13 C 3 13 4.5 12 6.5 10 L 14.5 1.5 C 16 -0.5 18 -0.5 19.5 1.5 L 27.5 10 C 29.5 12 31 13 34 13 L 34 14 Z');
+ backdrop-filter: blur(12px) saturate(115%) brightness(1.35);
+ -webkit-backdrop-filter: blur(12px) saturate(115%) brightness(1.35);
}
}
+
&::part(content) {
- @include api.glass-background($include-background: false, $include-box-shadow: false);
- border-radius: 24px;
+ @include api.glass-overlay-surface;
+ backdrop-filter: blur(12px) saturate(115%) brightness(1.35);
+ -webkit-backdrop-filter: blur(12px) saturate(115%) brightness(1.35);
+ border-radius: 34px;
padding: 0;
}
+
+ &.ios-theme-callout::part(content) {
+ background: transparent;
+ border-color: transparent;
+ box-shadow: none;
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
+ }
+
+ &.ios-theme-callout::part(arrow)::after {
+ display: none;
+ }
+
+ &::part(callout-glass) {
+ background: var(--background);
+ backdrop-filter: blur(12px) saturate(115%) brightness(1.35);
+ -webkit-backdrop-filter: blur(12px) saturate(115%) brightness(1.35);
+ }
+
+ &::part(callout-border) {
+ background: color-mix(in srgb, var(--border-color) 60%, transparent);
+ }
+
+ &::part(callout-shadow) {
+ background: color-mix(in srgb, var(--ion-box-shadow-color, #000) 8%, transparent);
+ }
+
ion-select-popover {
ion-list {
background: transparent;
diff --git a/src/styles/components/ion-range.scss b/src/styles/components/ion-range.scss
index 823ea48f..1b6ac777 100644
--- a/src/styles/components/ion-range.scss
+++ b/src/styles/components/ion-range.scss
@@ -1,14 +1,21 @@
@use '../utils/api';
@mixin scaled-transform($translate-x) {
- transform: scale(1.56, 1.47) translateX(calc(#{$translate-x} * -0.1)) translateZ(0);
- -webkit-transform: scale(1.56, 1.47) translateX(calc(#{$translate-x} * -0.1)) translateZ(0);
+ transform: translateX($translate-x) scale(1.55);
}
ion-range.ios:not(.ios-theme-disabled, .ios26-disabled) {
- --knob-size: 20px;
- --knob-width: 38px;
+ // iOS 26.5 UISlider, measured independently of the Native UI Shell.
+ --knob-size: 24px;
+ --knob-width: 37px;
--knob-border-radius: 24px;
+ --bar-height: 6px;
+ --bar-border-radius: 3px;
+ --bar-background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.1);
+
+ &.ion-color {
+ --bar-background-active: var(--ion-color-base);
+ }
&.range-label-placement-start::part(label) {
margin-inline: 0 24px;
@@ -28,35 +35,34 @@ ion-range.ios:not(.ios-theme-disabled, .ios26-disabled) {
&::part(knob) {
width: var(--knob-width);
- margin-inline-start: -8px;
+ margin-inline-start: calc((var(--knob-size) - var(--knob-width)) / 2);
transition:
- transform 300ms ease,
- margin 300ms ease,
+ transform 500ms linear(0, 0.38 15%, 0.83 30%, 1.06 50%, 1.02 75%, 1),
box-shadow 200ms ease;
}
- &:not(.range-dual-knobs).range-pressed {
+ &:not(.range-dual-knobs):not(.range-disabled):is(.range-pressed, :active) {
--knob-background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.1);
--knob-box-shadow: 0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 4px rgba(0, 0, 0, 0.05), inset 0.4px 0.4px 1px 0 var(--bar-background-active);
&::part(knob) {
@include api.glass-background(0.1, 0, 120%, $include-background: false, $include-box-shadow: false, $include-border: false);
@include scaled-transform(0px);
+ transition-timing-function: linear(0, 0.37 20%, 0.96 40%, 1.1 60%, 1), ease;
+ transition-duration: 250ms, 200ms;
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ }
}
&.range-value-min {
- --knob-box-shadow: 0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 4px rgba(0, 0, 0, 0.05);
-
&::part(knob) {
- @include scaled-transform(calc(var(--knob-width) * -2));
+ @include scaled-transform(calc(var(--knob-width) * 0.55 / 2));
}
}
&.range-value-max {
- --knob-box-shadow:
- 0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 4px rgba(0, 0, 0, 0.05), inset 0.4px 0.4px 1px 0.2px var(--bar-background-active);
-
&::part(knob) {
- @include scaled-transform(calc(var(--knob-width) * 2));
+ @include scaled-transform(calc(var(--knob-width) * -0.55 / 2));
}
}
}
@@ -65,26 +71,28 @@ ion-range.ios:not(.ios-theme-disabled, .ios26-disabled) {
&.range-pressed-a::part(knob-a),
&.range-pressed-b::part(knob-b) {
@include api.glass-background(0.1, 0, 120%, $include-border: false);
- @include scaled-transform(0px);
+ // Ionic exposes the pressed dual thumb but not its individual endpoint.
+ // Keep its horizontal extent stable even when both endpoints coincide.
+ transform: scaleY(1.55);
box-shadow:
0 0.5px 4px rgba(0, 0, 0, 0.12),
0 6px 4px rgba(0, 0, 0, 0.05),
inset 0.4px 0.4px 1px 0 var(--bar-background-active);
}
+ }
- &.range-value-min.range-pressed-lower::part(knob-lower) {
- @include scaled-transform(calc(var(--knob-width) * -2));
- box-shadow:
- 0 0.5px 4px rgba(0, 0, 0, 0.12),
- 0 6px 4px rgba(0, 0, 0, 0.05);
+ &:dir(rtl):not(.range-dual-knobs):not(.range-disabled):is(.range-pressed, :active) {
+ &.range-value-min::part(knob) {
+ @include scaled-transform(calc(var(--knob-width) * -0.55 / 2));
+ }
+ &.range-value-max::part(knob) {
+ @include scaled-transform(calc(var(--knob-width) * 0.55 / 2));
}
+ }
- &.range-value-max.range-pressed-upper::part(knob-upper) {
- @include scaled-transform(calc(var(--knob-width) * 2));
- box-shadow:
- 0 0.5px 4px rgba(0, 0, 0, 0.12),
- 0 6px 4px rgba(0, 0, 0, 0.05),
- inset 0.4px 0.4px 1px 0.2px var(--bar-background-active);
+ @media (prefers-reduced-motion: reduce) {
+ &::part(knob) {
+ transition: none;
}
}
}
diff --git a/src/styles/components/ion-searchbar.scss b/src/styles/components/ion-searchbar.scss
index b97a83c9..dbb81605 100644
--- a/src/styles/components/ion-searchbar.scss
+++ b/src/styles/components/ion-searchbar.scss
@@ -1,4 +1,4 @@
-@use '../utils/api';
+@use '../utils/glass';
ion-modal.ios {
ion-searchbar:not(.ios-theme-disabled, .ios26-disabled) {
@@ -12,32 +12,50 @@ ion-modal.ios {
}
ion-searchbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(.searchbar-classic) {
- min-height: 60px;
- padding: 0;
+ // iOS 26.5 minimal UISearchBar: 56pt host, 44pt field, 8pt inline inset.
+ --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72);
+ --border-radius: 22px;
+ --icon-color: var(--ion-text-color, #000);
+ --placeholder-opacity: 0.55;
+ @include glass.light-shadow('--box-shadow');
+ min-height: 56px;
+ padding: 6px 8px;
ion-icon.searchbar-search-icon {
inset-inline-start: 12px;
+ margin-inline-start: 0 !important;
+ width: 22px;
+ opacity: 1;
}
.searchbar-input-container {
- margin: 0 8px 0 16px;
+ margin: 0;
align-self: center;
justify-self: center;
input.searchbar-input {
min-height: 44px;
- @include api.glass-background;
- border-radius: 20px;
- padding-inline-start: 2.4rem;
+ height: 44px;
+ background: var(--background);
+ box-shadow: var(--box-shadow);
+ backdrop-filter: blur(8px) saturate(250%);
+ @include glass.light-rim;
+ border-radius: var(--border-radius);
+ padding-inline-start: 39.6667px;
+ font-size: 1.0625rem;
+ transition: none;
}
.searchbar-clear-button {
- padding-inline-end: 2rem;
+ // Unlike the iOS 27 candidate, this iOS 26 reference keeps clear inside.
+ padding: 0;
+ width: 44px;
+ inset-inline-end: 0;
}
}
// Ionic writes a physical inline padding while the placeholder is centered.
// Match before hydration as well, so Ionic's `transition: all` cannot animate through the inline value.
&:not(.searchbar-left-aligned) .searchbar-input-container input.searchbar-input {
- padding-inline-start: 2.4rem !important;
+ padding-inline-start: 39.6667px !important;
}
}
diff --git a/src/styles/components/ion-segment.scss b/src/styles/components/ion-segment.scss
index cea204bc..305db27e 100644
--- a/src/styles/components/ion-segment.scss
+++ b/src/styles/components/ion-segment.scss
@@ -1,20 +1,27 @@
@use '../utils/api';
ion-segment.ios:not(.ios-theme-disabled, .ios26-disabled) {
- @include api.glass-background;
- min-height: 48px;
- border-radius: 25px;
-
- transition: transform var(--ios-theme-activated-transition-duration, var(--ios26-activated-transition-duration)) ease-out;
-
- // will-change is optimized to only apply when needed for performance
- &.segment-activated,
- &:has(ion-segment-button.ion-activated) {
- will-change: transform;
+ --border-radius: 15.5px;
+ min-height: 31px;
+ &:not(.segment-scrollable) {
+ overflow: visible;
+ &.ios26-enable-gesture {
+ contain: layout style;
+ }
}
+ border-radius: var(--border-radius);
+
+ // UIKit uses a larger control in navigation bars, but not in content.
+ // iOS 26.5 ShellSegment host 320×48 is stable — no forced root scale; lens alone deforms.
+ &.in-toolbar:not(.segment-expand) {
+ --border-radius: 24px;
+ min-height: 48px;
- &:not(.segment-activated):not(:has(ion-segment-button.ion-activated)) {
- will-change: auto;
+ ion-segment-button {
+ min-height: 44px;
+ margin: 2px 0;
+ font-size: 14.5px;
+ }
}
ion-segment-button:is(.in-toolbar-color, .in-segment-color)::part(indicator-background) {
@@ -22,129 +29,122 @@ ion-segment.ios:not(.ios-theme-disabled, .ios26-disabled) {
background: var(--indicator-color);
}
+ &.ion-color ion-segment-button.in-segment-color {
+ &.segment-button-checked::part(native) {
+ color: var(--color-checked);
+ }
+ }
+
&.in-toolbar-color:not(.in-segment-color) {
ion-segment-button:not(.segment-button-checked)::part(native) {
color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 1);
}
}
- &:not(.ios26-enable-gesture).segment-activated {
- transform: scale(1.1) translateZ(0);
- -webkit-transform: scale(1.1) translateZ(0);
- ion-segment-button {
- --indicator-color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0);
- --indicator-transform: scale(1.1) translateZ(0);
- transition: transform 100ms ease-out;
- &.segment-button-checked::part(native) {
- transform: scale(1.08) translateZ(0);
- -webkit-transform: scale(1.08) translateZ(0);
- }
- &::part(indicator-background) {
- position: relative;
- z-index: 1;
- transform-origin: center center;
- }
+ // The indicator grows independently; the label has no additional scale.
+ &:not(.ios26-enable-gesture, .segment-disabled):is(.segment-activated, :has(ion-segment-button:is(:active, .ion-activated))) {
+ ion-segment-button.segment-button-checked:not(.segment-button-disabled)::part(indicator-background) {
+ scale: 1.35 1.4;
+ background: transparent;
+ @include api.glass-background($opacity: 1, $blur: 0, $saturate: 104%, $include-border: false);
}
}
- &.ios26-enable-gesture {
- &:has(ion-segment-button.ion-activated) {
- transform: scale(1.1) translateZ(0);
- -webkit-transform: scale(1.1) translateZ(0);
- ion-segment-button {
- --indicator-transform: scale(1.1) translateZ(0);
- transition: transform 100ms ease-out;
- &.segment-button-checked::part(native) {
- transform: scale(1.08) translateZ(0);
- -webkit-transform: scale(1.08) translateZ(0);
- }
- &::part(indicator-background) {
- position: relative;
- z-index: 1;
- transform-origin: center center;
- }
- }
- }
- &.ios26-animated {
- ion-segment-button {
- --indicator-color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0);
- }
+ &.ios26-enable-gesture.ios26-animated ion-segment-button {
+ z-index: 2;
+ &::part(indicator) {
+ opacity: 0;
}
}
+ .ios26-segment-lens {
+ box-sizing: border-box;
+ position: absolute;
+ top: 0;
+ left: 0;
+ border-radius: 999px;
+ pointer-events: none;
+ z-index: 1;
+ }
+
+ .ios26-segment-edge {
+ @include api.glass-background($include-background: false, $include-box-shadow: false, $blur: 0, $saturate: 104%);
+ box-shadow:
+ inset 0 2px 3px rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.06),
+ inset 0 -1px 2px rgba(255, 255, 255, 0.8),
+ 0 0 0 0.5px rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.06),
+ 0 2px 5px rgba(0, 0, 0, 0.06);
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
+ }
+
&.segment-expand {
- min-height: 24px;
- width: calc(100% - var(--ion-safe-area-left, 0) - var(--ion-safe-area-left, 0) - 24px);
-
- &.segment-activated {
- transform: scale(1);
- ion-segment-button.segment-button-checked::part(native) {
- transform: scale(1);
- }
- }
+ width: calc(100% - var(--ion-safe-area-left, 0px) - var(--ion-safe-area-right, 0px) - 24px);
+ }
- ion-segment-button {
- min-height: 24px;
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ &.in-toolbar:not(.segment-expand) {
+ transform: none;
+ transition: none;
}
+ }
+}
- &.ios26-enable-gesture {
- &:has(ion-segment-button.ion-activated) {
- transform: scale(1) translateZ(0);
- -webkit-transform: scale(1) translateZ(0);
- ion-segment-button {
- --indicator-transform: scale(1) translateZ(0);
- transition: none;
- &.segment-button-checked::part(native) {
- transform: scale(1) translateZ(0);
- -webkit-transform: scale(1) translateZ(0);
- }
- &::part(indicator-background) {
- position: relative;
- z-index: 1;
- transform-origin: center center;
- }
- }
- }
- &.ios26-animated {
- ion-segment-button {
- --indicator-color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0);
- }
- }
- }
+// Let the optional lens extend past the toolbar without changing its layout or hit area.
+ion-toolbar.ios:has(> ion-segment.ios26-enable-gesture:not(.segment-scrollable, .ios-theme-disabled, .ios26-disabled)) {
+ contain: layout style;
+ &::part(container) {
+ contain: layout style;
+ overflow: visible;
}
}
ion-segment-button.ios:not(.ios-theme-disabled, .ios26-disabled) {
--border-width: 0;
--indicator-box-shadow: none;
- --indicator-color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.06);
- --ion-color-base: var(--ion-text-color, #000);
--padding-start: 8px;
--padding-end: 8px;
- min-width: 60px;
- margin: 3px 2px;
- font-size: 14.5px;
+ min-width: 65px;
+ min-height: 27px;
+ margin: 2px 0;
+ font-size: calc(13rem / 16);
+ font-weight: 500;
+ line-height: normal;
+
+ &.segment-button-disabled {
+ opacity: 0.2;
+ }
&::part(indicator-background) {
- border-radius: 25px;
- transition: background 0.2s ease;
+ transition:
+ var(--indicator-transition),
+ scale 250ms ease-out,
+ background 200ms ease;
}
- &.ion-cloned-element {
- &::part(native) {
- border-radius: 25px;
- @include api.glass-background($opacity: 1, $blur: 0, $saturate: 104%);
- background: transparent;
- height: 100%;
- }
- color: var(--color-selected, var(--ion-color-primary, #0054e9));
- pointer-events: none;
- position: absolute;
- left: 0;
- top: -3.25px;
- transform-origin: center center;
- & > * {
- visibility: hidden;
+ @media (prefers-reduced-motion: reduce) {
+ &::part(indicator-background) {
+ scale: 1 !important;
+ transition: none;
}
}
}
+
+// Public customization properties remain overridable by ordinary app selectors.
+// The resting track is a flat system fill; only the moving lens has a glass rim.
+:where(ion-segment.ios:not(.ios-theme-disabled, .ios26-disabled)) {
+ --background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.06);
+}
+:where(ion-segment.ios:not(.ios-theme-disabled, .ios26-disabled) ion-segment-button) {
+ --indicator-color: var(--ion-background-color, #fff);
+}
+:where(ion-segment-button.ios:not(.ios-theme-disabled, .ios26-disabled)) {
+ --border-radius: 999px;
+}
+:where(ion-segment.ios:not(.ios-theme-disabled, .ios26-disabled).ion-color ion-segment-button.in-segment-color) {
+ --indicator-color: var(--ion-color-base);
+ --color-checked: var(--ion-color-contrast);
+}
diff --git a/src/styles/components/ion-tabs.scss b/src/styles/components/ion-tabs.scss
index 53b37eed..d3b53ce3 100644
--- a/src/styles/components/ion-tabs.scss
+++ b/src/styles/components/ion-tabs.scss
@@ -1,10 +1,35 @@
@use '../utils/api';
+@mixin tablet-tabs {
+ --ios-theme-tab-overlap: 16px;
+ @each $count, $width in (4: 336px, 5: 414px) {
+ &:has(> ion-tab-button:nth-child(#{$count}):last-child) {
+ max-width: $width - 8px;
+ }
+ }
+}
+
ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
- @include api.glass-background;
+ // UIKit's platter is 62pt, including its 4pt selection inset. Put the
+ // physical-pixel rim on a separate surface so it cannot alter layout.
+ border: 0;
+ --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72);
+ background: transparent;
+ contain: size layout style;
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
+ z-index: -1;
+ @include api.glass-background($blur: 20px, $saturate: 180%, $include-background: false);
+ background: var(--background);
+ box-shadow: 0 2px 26px rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.55);
+ }
z-index: 2;
- border-radius: 40px;
+ border-radius: 31px;
--color: rgb(var(--ion-text-color-rgb, 0, 0, 0));
&[slot='top'] {
@@ -22,16 +47,60 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
/**
* 100% - margin-left - margin-right - right-fab - margin-left-fab
*/
- width: calc(100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-left, 0px)) - 60px - 12px);
+ width: calc(100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 60px - 12px);
max-width: 474px;
- min-height: 56px;
+ &:not(:has(ion-tab-button:nth-child(4))) {
+ max-width: 266px;
+ }
+ &:has(> ion-tab-button:only-child) {
+ max-width: 94px;
+ }
+ &:has(> ion-tab-button:nth-child(2):last-child) {
+ max-width: 180px;
+ }
+ min-height: 54px;
+ height: 54px;
&:has(:nth-child(5)) {
- width: calc(100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-left, 0px)));
+ width: calc(100% - calc(25px + var(--ion-safe-area-left, 0px)) - calc(25px + var(--ion-safe-area-right, 0px)));
max-width: 546px;
}
- padding: 2px 2px;
+ padding: 4px;
+ // Use main's shared overlap layout; avoid width-specific native breakpoints.
+ --ios-theme-tab-overlap: 8px;
+ &:where(:has(> ion-tab-button:nth-child(4):last-child)) {
+ --ios-theme-tab-overlap: 13px;
+ }
+ > ion-tab-button:not(.ios-theme-disabled, .ios26-disabled) + ion-tab-button:not(.ios-theme-disabled, .ios26-disabled) {
+ margin-inline-start: calc(-1 * var(--ios-theme-tab-overlap));
+ }
+ // iPad compact-height/icon-top UITabBar uses16pt overlap and natural caps,
+ // including narrow multitasking windows. Desktop previews use the roomy
+ // fallback; a real iPhone in landscape must not be mistaken for an iPad.
+ :where(.plt-ipad) & {
+ @include tablet-tabs;
+ }
+ @media (min-width: 768px) {
+ :where(html:not(.plt-iphone)) & {
+ @include tablet-tabs;
+ }
+ }
transition: transform var(--ios-theme-activated-transition-duration, var(--ios26-activated-transition-duration)) ease-out;
+ &.ios26-enable-gesture {
+ transition: none;
+ touch-action: pinch-zoom;
+ &:has(ion-tab-button.ion-activated) {
+ transform: none;
+ -webkit-transform: none;
+ }
+ > ion-tab-button:not(.ios-theme-disabled, .ios26-disabled) {
+ transform: none;
+ transition: none;
+ }
+ .ios26-tab-preview {
+ color: var(--color-selected);
+ }
+ }
// will-change is optimized to only apply when needed for performance
&:has(ion-tab-button.ion-activated) {
@@ -54,8 +123,8 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
* effectが有効な場合、effectでスタイリングするため不要
*/
&.ios26-enable-gesture.ios26-animated {
- ion-tab-button.tab-selected::part(native) {
- background: rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0);
+ ion-tab-button.tab-selected {
+ background: transparent;
}
ion-tab-button.ion-activated {
ion-label,
@@ -67,6 +136,18 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
ion-tab-button.ios:not(.ios-theme-disabled, .ios26-disabled) {
+ font-size: 0.5882352941rem;
+ font-weight: 500;
+ &:not(.ion-cloned-element):not(:has(ion-icon)) {
+ justify-content: flex-end;
+ --padding-bottom: 7px;
+ }
+ ion-label {
+ font-size: inherit;
+ font-weight: inherit;
+ line-height: 12px;
+ margin: 0;
+ }
ion-icon {
font-size: 26px;
}
@@ -76,13 +157,13 @@ ion-tab-button.ios:not(.ios-theme-disabled, .ios26-disabled) {
filter var(--ios-theme-activated-transition-duration, var(--ios26-activated-transition-duration)) ease,
color var(--ios-theme-activated-transition-duration, var(--ios26-activated-transition-duration)) ease;
}
- background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0);
+ --background: transparent;
+ border-radius: 27px;
height: auto;
transition: transform var(--ios-theme-activated-transition-duration, var(--ios26-activated-transition-duration)) ease;
&::part(native) {
overflow: visible;
- min-height: 56px;
- border-radius: 32px;
+ min-height: 54px;
}
&.ion-activated {
@@ -96,10 +177,11 @@ ion-tab-button.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
&.tab-selected {
- &::part(native) {
- background: rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.095);
- transition: background var(--ios-theme-activated-transition-duration, var(--ios26-activated-transition-duration)) ease;
- }
+ --background: rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.077);
+ font-weight: 600;
+ transition:
+ transform var(--ios-theme-activated-transition-duration, var(--ios26-activated-transition-duration)) ease,
+ background var(--ios-theme-activated-transition-duration, var(--ios26-activated-transition-duration)) ease;
}
&.ion-cloned-element {
@@ -115,4 +197,36 @@ ion-tab-button.ios:not(.ios-theme-disabled, .ios26-disabled) {
visibility: hidden;
}
}
+ &.ios26-tab-lens {
+ position: fixed;
+ z-index: 9999;
+ margin: 0;
+ padding: 0;
+ min-width: 0;
+ min-height: 0;
+ max-width: none;
+ flex: none;
+ border-radius: 999px;
+ transition: none;
+ contain: layout style;
+ &::part(native) {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ border-radius: inherit;
+ box-sizing: border-box;
+ }
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled),
+ ion-tab-button.ios:not(.ios-theme-disabled, .ios26-disabled) {
+ transition: none;
+ &.ion-activated,
+ &:has(ion-tab-button.ion-activated) {
+ transform: none;
+ -webkit-transform: none;
+ }
+ }
}
diff --git a/src/styles/components/ion-toggle.scss b/src/styles/components/ion-toggle.scss
index a6edd60a..f0445b29 100644
--- a/src/styles/components/ion-toggle.scss
+++ b/src/styles/components/ion-toggle.scss
@@ -1,64 +1,135 @@
-@use '../utils/api';
-
-/**
- * Adjustments have been made to prevent the right edge from being cut off within the ion-item.
- * When the native-inner part be added to the ion-item, it will be removed.
- */
-ion-item.ios:not(.ios-theme-disabled, .ios26-disabled) ion-toggle.ios:not(.ios-theme-disabled, .ios26-disabled) {
- &.toggle-checked.toggle-activated::part(track) {
- transform-origin: left;
- transform: scaleX(0.894) translateZ(0);
- -webkit-transform: scaleX(0.894) translateZ(0);
- }
- &.toggle-activated::part(handle) {
- transform: scale(1.4, 1.6) translateX(calc(38px * -0.15)) translateZ(0);
- -webkit-transform: scale(1.4, 1.6) translateX(calc(38px * -0.15)) translateZ(0);
- }
-}
-
+// Keep Ionic's native handle positioning when the lens CSS is unavailable.
ion-toggle.ios:not(.ios-theme-disabled, .ios26-disabled) {
- --handle-width: 38px;
+ --handle-width: 37px;
--handle-height: 24px;
--handle-max-width: none;
--handle-max-height: none;
- &.ion-color {
- --track-background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.23);
- }
- &:not(.ion-color) {
- --track-background: rgba(var(--ion-color-contrast-rgb, var(--ion-text-color-rgb, 0, 0, 0)), 0.23);
- }
+ --border-radius: 14px;
+ --track-background-checked: var(--ion-color-base, #34c759);
&::part(track) {
- overflow: visible;
- width: 64px;
+ width: 63px;
height: 28px;
- transition: transform 280ms ease;
- }
- &.toggle-activated:not(.toggle-checked)::part(handle) {
- transform: translateX(-4px) scale(1.4, 1.6) translateZ(0);
- -webkit-transform: translateX(-4px) scale(1.4, 1.6) translateZ(0);
}
- &.toggle-activated.toggle-checked::part(handle) {
- transform: scale(1.4, 1.6) translateZ(0);
+}
+
+@supports (scale: 1) and (translate: 0) and (width: calc(sin(1rad) * 1px)) and (background: color-mix(in srgb, white, transparent)) and
+ (transition-timing-function: linear(0, 1)) {
+ // Interpolated state keeps a tap's lens animation alive after :active disappears.
+ @property --ios26-toggle-change {
+ syntax: '';
+ inherits: true;
+ initial-value: 0;
}
- &.toggle-activated::part(handle) {
- transform: scale(1.4, 1.6) translateZ(0);
- -webkit-transform: scale(1.4, 1.6) translateZ(0);
- --handle-transition: transform 280ms, width 120ms ease-in-out 80ms, left 110ms ease-in-out 80ms, right 110ms ease-in-out 80ms;
- @include api.glass-background($opacity: 0.1, $blur: 0.5px, $saturate: 120%, $include-background: false, $include-box-shadow: false);
+ @property --ios26-toggle-press {
+ syntax: '';
+ inherits: true;
+ initial-value: 0;
}
- &.toggle-activated {
- --handle-background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.1);
- --handle-background-checked: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.1);
- --handle-box-shadow: inset 0 0 8px 0 var(--track-background);
+ @property --ios26-toggle-held {
+ syntax: '';
+ inherits: true;
+ initial-value: 0;
}
- &.ion-color.toggle-checked.toggle-activated {
+
+ ion-toggle.ios:not(.ios-theme-disabled, .ios26-disabled) {
--handle-box-shadow:
- inset 0 8px 8px -8px var(--ion-color-base), inset 8px 0 8px -8px var(--track-background),
- inset 8px 0 8px -8px var(--track-background), inset -8px 0 8px -8px var(--ion-color-base);
+ inset 0 0 0 0.5px rgba(0, 0, 0, calc(var(--ios26-toggle-glass) * 0.25)),
+ inset 0 5px 3px -4px rgba(255, 255, 255, calc(var(--ios26-toggle-glass) * 0.8)),
+ inset 0 -5px 3px -4px rgba(255, 255, 255, calc(var(--ios26-toggle-glass) * 0.8)),
+ 0 5px 8px rgba(0, 0, 0, calc(var(--ios26-toggle-glass) * 0.18));
+ &.ion-color {
+ --track-background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.23);
+ }
+ &:not(.ion-color) {
+ --track-background: rgba(var(--ion-color-contrast-rgb, var(--ion-text-color-rgb, 0, 0, 0)), 0.23);
+ }
+ // iOS 26.5 UISwitch: resting 37 x 24pt; held 58 x 38.333pt.
+ --ios26-toggle-release: linear(
+ 0,
+ 0.092 4.7%,
+ 0.272 9.4%,
+ 0.459 14.3%,
+ 0.621 19%,
+ 0.802 26.1%,
+ 0.923 33.3%,
+ 1.047 47.6%,
+ 1.0666 57.1%,
+ 1.0635 61.9%,
+ 1.034 69%,
+ 1.0175 83.3%,
+ 1
+ );
+ --handle-transition: transform 700ms var(--ios26-toggle-release);
+ --ios26-toggle-change: 0;
+ --ios26-toggle-press: 0;
+ --ios26-toggle-held: 0;
+ --ios26-toggle-progress: calc(1 - var(--ios26-toggle-change));
+ --ios26-toggle-pulse: calc(sin(var(--ios26-toggle-progress) * 180deg) * (1 - var(--ios26-toggle-held)));
+ // Blend the press and checked-state pulse instead of switching between them.
+ --ios26-toggle-expansion: calc(var(--ios26-toggle-press) + var(--ios26-toggle-pulse) * max(0, 1.145 - var(--ios26-toggle-press)));
+ --ios26-toggle-expansion-y: calc(var(--ios26-toggle-press) + var(--ios26-toggle-pulse) * max(0, 1.122 - var(--ios26-toggle-press)));
+ --ios26-toggle-glass: clamp(0, calc(var(--ios26-toggle-expansion) * 2), 1);
+ transition:
+ --ios26-toggle-change 300ms ease-out,
+ --ios26-toggle-press 700ms var(--ios26-toggle-release) calc((1 - var(--ios26-toggle-held)) * 60ms),
+ --ios26-toggle-held 0s 700ms;
+
+ &.toggle-checked {
+ --ios26-toggle-change: 1;
+ --ios26-toggle-progress: var(--ios26-toggle-change);
+ }
+
+ // Ionic's toggle-activated only covers dragging; :active also covers a tap.
+ &:is(.toggle-activated, :active):not(.toggle-disabled) {
+ --ios26-toggle-press: 1;
+ --ios26-toggle-held: 1;
+ transition:
+ --ios26-toggle-change 300ms ease-out,
+ --ios26-toggle-press 250ms linear(0, 0.15 10%, 0.37 20%, 0.72 30%, 0.96 40%, 1.122 50%, 1.126 60%, 1.09 70%, 1.034 80%, 1),
+ --ios26-toggle-held 0s 120ms;
+ }
+
+ &::part(track) {
+ overflow: visible;
+ }
+
+ &::part(handle) {
+ // Separate translation from scale so the checked offset is not scaled.
+ transform: none;
+ translate: 0;
+ scale: calc(1 + (21 / 37) * var(--ios26-toggle-expansion)) calc(1 + (14.333333 / 24) * var(--ios26-toggle-expansion-y));
+ background: color-mix(in srgb, var(--handle-background) calc(100% - var(--ios26-toggle-glass) * 90%), transparent);
+ backdrop-filter: blur(calc(var(--ios26-toggle-glass) * 0.5px)) saturate(calc(100% + var(--ios26-toggle-glass) * 20%));
+ transition: var(--handle-transition);
+ transition-property: translate;
+ }
+
+ &.toggle-checked::part(handle) {
+ translate: calc(var(--handle-spacing) * -2);
+ background: color-mix(in srgb, var(--handle-background-checked) calc(100% - var(--ios26-toggle-glass) * 90%), transparent);
+ }
+ &.toggle-checked.toggle-rtl::part(handle) {
+ translate: calc(var(--handle-spacing) * 2);
+ }
+
+ &.toggle-disabled {
+ --ios26-toggle-expansion: 0;
+ --ios26-toggle-expansion-y: 0;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ --handle-transition: none;
+ --ios26-toggle-expansion: 0;
+ --ios26-toggle-expansion-y: 0;
+ transition: none;
+ &::part(handle) {
+ transition: none;
+ }
+ }
}
- &.toggle-checked.toggle-activated {
- --handle-box-shadow:
- inset 0 8px 8px -8px var(--track-background-checked), inset 8px 0 8px -8px var(--track-background),
- inset 8px 0 8px -8px var(--track-background), inset -8px 0 8px -8px var(--track-background-checked);
+
+ // Reserve the lens overhang inside the slotted content's clipping boundary.
+ ion-item.ios:not(.ios-theme-disabled, .ios26-disabled) ion-toggle.ios:not(.ios-theme-disabled, .ios26-disabled) {
+ padding-inline-end: 10px;
}
}
diff --git a/src/styles/components/ion-toolbar.scss b/src/styles/components/ion-toolbar.scss
index df774a46..eca24fdd 100644
--- a/src/styles/components/ion-toolbar.scss
+++ b/src/styles/components/ion-toolbar.scss
@@ -26,6 +26,31 @@ ion-toolbar.ios:not(.toolbar-title-large):not(.ios-theme-disabled, .ios26-disabl
--min-height: 68px;
}
+// iOS 26.5 UINavigationBar: content begins 54pt below the top safe area;
+// a 44pt glass button sits at the safe-area edge. Search/segment/modal toolbars
+// have distinct layouts and are deliberately excluded from this measurement.
+ion-header.ios:not(.ios-theme-disabled, .ios26-disabled):not(.header-collapse-condense):not(:where(ion-modal *))
+ > ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(.toolbar-title-large):not(.toolbar-searchbar):not(
+ :has(ion-segment)
+ ):first-of-type {
+ padding-top: max(calc(var(--ion-safe-area-top, 0px) - 10px), 6px);
+ --min-height: 64px;
+}
+
+ion-header.ios.header-collapse-condense:not(.ios-theme-disabled, .ios26-disabled)
+ > ion-toolbar.ios.toolbar-title-large:not(.ios-theme-disabled, .ios26-disabled) {
+ --min-height: 52px;
+ --padding-top: 2px;
+ --padding-bottom: 2px;
+}
+
+ion-title.ios:not(.title-small, .ios-theme-disabled, .ios26-disabled) {
+ font-size: max(17px, 1rem);
+ &.title-large {
+ font-size: clamp(34px, 2rem, 61.2px);
+ }
+}
+
ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled).toolbar-searchbar {
*[slot='start'],
*[slot='end'] {
diff --git a/src/styles/md-ion-list-inset.scss b/src/styles/md-ion-list-inset.scss
index 371a4474..42f6e1c9 100644
--- a/src/styles/md-ion-list-inset.scss
+++ b/src/styles/md-ion-list-inset.scss
@@ -1,10 +1,12 @@
-@use 'utils/structured-list';
+@use 'pkg:@rdlabo/ionic-theme-utils/styles/structured-list';
+
+$ios26-disabled: '.ios-theme-disabled, .ios26-disabled';
ion-list.md {
- @include structured-list.support-text;
+ @include structured-list.support-text($disabled-selector: $ios26-disabled);
&.list-inset {
- @include structured-list.layout(8px, 16px);
+ @include structured-list.layout(8px, 16px, $disabled-selector: $ios26-disabled);
:is(#{structured-list.$groups}) ion-item {
& > ion-input[labelplacement='floating'] {
transition: transform 200ms ease;
diff --git a/src/styles/utils/_glass.scss b/src/styles/utils/_glass.scss
new file mode 100644
index 00000000..572653a4
--- /dev/null
+++ b/src/styles/utils/_glass.scss
@@ -0,0 +1,45 @@
+// Calibrated against iOS 26.5 UIKit Glass on systemGroupedBackground.
+// Keep the legacy color hooks; these are material recipes, not new public APIs.
+@mixin light-shadow($property: box-shadow, $geometry: 0 8px 28px) {
+ #{$property}: $geometry rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.68);
+}
+
+@mixin light-rim($include-border: true) {
+ $light: var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb));
+ @if $include-border {
+ border: 0.5px solid rgba($light, 1);
+ border-inline-color: transparent;
+ }
+ @supports (background-clip: border-area) {
+ @if $include-border {
+ border-color: transparent;
+ }
+ background-image:
+ linear-gradient(180deg, rgb($light), transparent 45%, transparent 55%, rgb($light)),
+ linear-gradient(0deg, rgba($light, 0.5), transparent 0.5px), linear-gradient(transparent, transparent);
+ background-origin: border-box, padding-box, border-box;
+ background-clip: border-area, padding-box, border-box;
+ }
+}
+
+@mixin dark-rim($include-border: true) {
+ $light: var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb));
+ @if $include-border {
+ border: 0.5px solid rgba($light, 0.58);
+ border-inline-color: transparent;
+ }
+ @supports (background-clip: border-area) {
+ @if $include-border {
+ border-color: transparent;
+ }
+ background-image:
+ linear-gradient(180deg, rgba($light, 0.58), transparent 35%, transparent 65%, rgba($light, 0.58)),
+ linear-gradient(0deg, rgba($light, 0.8), transparent 0.5px), linear-gradient(transparent, transparent);
+ background-origin: border-box, padding-box, border-box;
+ background-clip: border-area, padding-box, border-box;
+ }
+}
+
+@mixin dark-fill($property: background-color) {
+ #{$property}: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.3);
+}
diff --git a/src/styles/utils/api.scss b/src/styles/utils/api.scss
index e38f88a3..2985f396 100644
--- a/src/styles/utils/api.scss
+++ b/src/styles/utils/api.scss
@@ -42,7 +42,8 @@
$blur: 7px,
$saturate: 180%,
$include-background: true,
- $include-box-shadow: true
+ $include-box-shadow: true,
+ $include-border: true
) {
@if $include-background {
background: rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), $opacity);
@@ -51,10 +52,12 @@
@if $include-box-shadow {
box-shadow: inset 0 0 16px 0 rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.55);
}
- border-top: 0.8px solid rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.8);
- border-right: 0.8px solid rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.4);
- border-bottom: 0.8px solid rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.8);
- border-left: 0.8px solid rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.6);
+ @if $include-border {
+ border-top: 0.8px solid rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.8);
+ border-right: 0.8px solid rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.4);
+ border-bottom: 0.8px solid rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.8);
+ border-left: 0.8px solid rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.6);
+ }
// Hardware acceleration optimization for glass effects
transform: translateZ(0);
@@ -71,11 +74,11 @@
@include glass-background($opacity: 0.6667, $blur: 8px);
}
-@mixin glass-background-overlay-button {
+@mixin glass-background-overlay-button($opacity: 0.12) {
transition: background 0.2s ease;
- background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.065);
+ background: rgba(var(--ion-text-color-rgb, 0, 0, 0), $opacity);
&.ion-activated {
- background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.022);
+ background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.06);
}
}
@@ -85,3 +88,26 @@
--button-background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.022);
}
}
+
+// Shared material for presented surfaces; values are calibrated to iOS 26.
+@mixin glass-overlay-variables {
+ --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.7);
+ --border-width: 0;
+ --border-style: none;
+ --border-color: transparent;
+ --box-shadow:
+ inset 0 0 0 0.5px rgba(var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb)), 0.8),
+ 0 12px 28px rgba(0, 0, 0, 0.17);
+}
+
+@mixin glass-overlay-surface {
+ background: var(--background);
+ border: var(--border-width) var(--border-style) var(--border-color);
+ box-shadow: var(--box-shadow);
+ backdrop-filter: blur(20px) saturate(180%);
+}
+
+@mixin glass-overlay-preferred-button {
+ background: var(--ion-color-primary, #0289ff);
+ color: var(--ion-color-primary-contrast, #fff);
+}
diff --git a/src/styles/utils/dark/ion-button.scss b/src/styles/utils/dark/ion-button.scss
index 0b93d994..1534edea 100644
--- a/src/styles/utils/dark/ion-button.scss
+++ b/src/styles/utils/dark/ion-button.scss
@@ -1,6 +1,14 @@
@use '../api';
+@use '../glass';
@mixin theme-dark-buttons {
+ &:has(> ion-button.button-clear:only-child:not(.button-small, .button-large, .ios-theme-disabled, .ios26-disabled)) {
+ @include glass.dark-fill;
+ box-shadow: none;
+ &::before {
+ @include glass.dark-rim;
+ }
+ }
&:has(.ion-activated) {
@include api.glass-background-button-activated-dark;
}
@@ -27,17 +35,42 @@
}
@mixin theme-dark-button($is-back-button: false) {
+ &:not(.button-solid):not(.button-outline):not(.button-clear) {
+ @include glass.dark-fill(--background);
+ --box-shadow: none;
+ @if not $is-back-button {
+ --border-color: rgba(var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb)), 0.58) transparent;
+ @supports (background-clip: border-area) {
+ --border-color: transparent;
+ }
+ }
+ &::part(native) {
+ @include glass.dark-rim($include-border: $is-back-button);
+ @if $is-back-button {
+ box-shadow: none;
+ }
+ }
+ }
&.ion-activated:not(.button-solid):not(.button-outline):not(.button-clear) {
--background: rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.56);
--color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 1);
@if not $is-back-button {
--box-shadow: inset 0 0 16px 0 rgba(var(--ios-theme-glass-box-shadow-color-rgb, var(--ios26-glass-box-shadow-color-rgb)), 0.55);
+ --border-width: 0.8px;
+ --border-color: rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.8)
+ rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.4)
+ rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.8)
+ rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.6);
}
&.ion-color {
--color: rgba(var(--ion-color-base-rgb, var(--ion-text-color-rgb, 0, 0, 0)), 1);
}
&::part(native) {
- @include api.glass-background-button-activated-dark($include-background: false, $include-box-shadow: $is-back-button);
+ @include api.glass-background-button-activated-dark(
+ $include-background: false,
+ $include-box-shadow: $is-back-button,
+ $include-border: $is-back-button
+ );
}
ion-icon {
color: rgb(255, 255, 255);
diff --git a/src/styles/utils/dark/ion-fab.scss b/src/styles/utils/dark/ion-fab.scss
index ad7607ad..ffceb131 100644
--- a/src/styles/utils/dark/ion-fab.scss
+++ b/src/styles/utils/dark/ion-fab.scss
@@ -1,8 +1,17 @@
-@use '../api';
+@use '../glass';
@mixin ion-fab {
ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled) {
- ion-fab-button {
+ ion-fab-button:not(.ios-theme-disabled, .ios26-disabled) {
+ @include glass.dark-fill(--background);
+ --box-shadow: none;
+ --border-color: rgba(var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb)), 0.58) transparent;
+ @supports (background-clip: border-area) {
+ --border-color: transparent;
+ }
+ &::part(native) {
+ @include glass.dark-rim($include-border: false);
+ }
&.ion-activated {
--color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 1);
diff --git a/src/styles/utils/dark/ion-overlay.scss b/src/styles/utils/dark/ion-overlay.scss
new file mode 100644
index 00000000..25936a6d
--- /dev/null
+++ b/src/styles/utils/dark/ion-overlay.scss
@@ -0,0 +1,25 @@
+@use '../api';
+
+@mixin ion-overlay {
+ ion-alert.ios:not(.ios-theme-disabled, .ios26-disabled),
+ ion-action-sheet.ios:not(.ios-theme-disabled, .ios26-disabled) {
+ --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.24);
+ }
+ ion-action-sheet.ios:not(.ios-theme-disabled, .ios26-disabled) {
+ --button-background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.11);
+ .action-sheet-sub-title {
+ color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.6);
+ }
+ }
+ ion-alert.ios:not(.ios-theme-disabled, .ios26-disabled) {
+ .alert-wrapper {
+ .alert-sub-title,
+ .alert-message {
+ color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.6);
+ }
+ .alert-button-group .alert-button:not(.alert-button-role-preferred) {
+ @include api.glass-background-overlay-button($opacity: 0.11);
+ }
+ }
+ }
+}
diff --git a/src/styles/utils/dark/ion-searchbar.scss b/src/styles/utils/dark/ion-searchbar.scss
new file mode 100644
index 00000000..637b9455
--- /dev/null
+++ b/src/styles/utils/dark/ion-searchbar.scss
@@ -0,0 +1,12 @@
+@use '../glass';
+
+@mixin ion-searchbar {
+ ion-searchbar.ios:not(.ios-theme-disabled, .ios26-disabled, .searchbar-classic) {
+ @include glass.dark-fill('--background');
+ --box-shadow: none;
+ --placeholder-opacity: 0.6;
+ .searchbar-input-container input.searchbar-input {
+ @include glass.dark-rim;
+ }
+ }
+}
diff --git a/src/styles/utils/dark/ion-segment.scss b/src/styles/utils/dark/ion-segment.scss
index 3887001c..6ad29fe4 100644
--- a/src/styles/utils/dark/ion-segment.scss
+++ b/src/styles/utils/dark/ion-segment.scss
@@ -1,13 +1,33 @@
@mixin ion-segment {
ion-segment.ios:not(.ios-theme-disabled, .ios26-disabled) {
- }
- ion-segment-button.ios:not(.ios-theme-disabled, .ios26-disabled) {
- &.ion-cloned-element {
- &::part(native) {
- --ios26-glass-border-color-rgb: 120, 120, 120;
- --ios-theme-glass-border-color-rgb: var(--ios26-glass-border-color-rgb);
- box-shadow: inset 0 0 8px 0 rgba(255, 255, 255, 0.2);
+ ion-segment-button.segment-button-disabled {
+ opacity: 0.15;
+ }
+ // Local dark edge — no glass-lens-dark API on this branch.
+ .ios26-segment-edge {
+ $surface: linear-gradient(180deg, rgba(0, 0, 0, 0.2), transparent 18%, transparent 82%, rgba(0, 0, 0, 0.2));
+ --ios26-glass-border-color-rgb: 12, 12, 12;
+ border-color: rgba(var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb)), 0.85);
+ background-color: transparent;
+ background-image: $surface;
+ background-clip: padding-box;
+ box-shadow:
+ inset 0 0 0 0.5px rgba(0, 0, 0, 0.65),
+ inset 0 1px 1px rgba(0, 0, 0, 0.4),
+ inset 0 -1px 1px rgba(0, 0, 0, 0.4);
+ @supports (background-clip: border-area) {
+ border-color: transparent;
+ background-image:
+ linear-gradient(180deg, rgba(100, 140, 190, 0.28), rgba(0, 0, 0, 0.85) 12%, rgba(0, 0, 0, 0.85) 88%, rgba(140, 110, 65, 0.28)),
+ $surface;
+ background-clip: border-area, padding-box;
}
}
}
+ :where(ion-segment.ios:not(.ios-theme-disabled, .ios26-disabled)) {
+ --background: rgba(118, 118, 128, 0.24);
+ }
+ :where(ion-segment.ios:not(.ios-theme-disabled, .ios26-disabled, .ion-color) ion-segment-button:not(.ion-color, .in-segment-color)) {
+ --indicator-color: #5a5a5f;
+ }
}
diff --git a/src/styles/utils/dark/ion-tabs.scss b/src/styles/utils/dark/ion-tabs.scss
index 0022c3b2..beddc8fb 100644
--- a/src/styles/utils/dark/ion-tabs.scss
+++ b/src/styles/utils/dark/ion-tabs.scss
@@ -2,8 +2,17 @@
@mixin ion-tabs {
ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
+ --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.3);
+ &::before {
+ @include api.glass-background($blur: 20px, $saturate: 180%, $include-background: false);
+ border-color: rgba(var(--ios-theme-glass-border-color-rgb, var(--ios26-glass-border-color-rgb)), 0.6);
+ box-shadow: none;
+ }
}
ion-tab-button.ios:not(.ios-theme-disabled, .ios26-disabled) {
+ &.tab-selected {
+ --background: rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb)), 0.145);
+ }
&.ion-activated {
ion-label,
ion-icon {
diff --git a/src/styles/utils/structured-list.scss b/src/styles/utils/structured-list.scss
deleted file mode 100644
index 178f9463..00000000
--- a/src/styles/utils/structured-list.scss
+++ /dev/null
@@ -1,170 +0,0 @@
-$groups: ion-item-group, ion-reorder-group, ion-accordion-group, ion-radio-group;
-
-@mixin support-text() {
- ion-item {
- &:has(ion-input.input-fill-outline),
- &:has(ion-textarea.textarea-fill-outline),
- &:has(ion-select:is([helpertext]:not([helpertext='']), [errortext]:not([errortext='']))),
- &:has(ion-input .input-bottom > :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty))),
- &:has(ion-textarea .textarea-bottom > :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty))) {
- --inner-border-width: 0;
- --inner-padding-bottom: 4px;
- }
-
- ion-input .input-bottom:not(:has(> :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty)))),
- ion-textarea .textarea-bottom:not(:has(> :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty)))) {
- display: none;
- }
- }
-}
-
-@mixin layout($outer-note-inset, $group-border-radius) {
- background: transparent;
-
- ion-list-header {
- display: flex;
- align-items: center;
-
- ion-label {
- font-size: 0.9rem;
- margin: 8px 4px 4px;
-
- &:not(.ion-color) {
- color: var(--ion-color-medium-tint);
- }
- }
-
- ion-button {
- margin-top: 8px;
- }
-
- ion-note {
- font-size: 0.9rem;
- margin: 8px 16px 4px 0;
- }
- }
-
- ion-item-group.item-group-header {
- & > ion-item > ion-label {
- width: 100%;
- text-align: center;
- padding: 32px 16px 16px;
- transform: translateX(5px);
-
- /**
- * ion-header.header-collapse-condenseを使って、スクロールによってHeaderを変化させるトリック
- */
- ion-header.header-collapse-condense > ion-toolbar {
- --min-height: 0;
- visibility: hidden;
- }
-
- ion-icon {
- font-size: 2.8rem;
- border-radius: 20%;
- padding: 6px;
-
- &:not(.ion-color) {
- color: #ffffff;
- }
- }
-
- h2 {
- font-size: 1.2rem;
- font-weight: bold;
- margin-top: 8px;
- }
-
- ion-text {
- font-size: 0.9rem;
- }
- }
- }
-
- > :is(#{$groups}) {
- display: block;
- border-radius: $group-border-radius;
- overflow: hidden;
-
- &:has(> .radio-group-top) {
- display: flex;
- flex-direction: column;
- border-radius: 0;
- overflow: visible;
-
- > .radio-group-top {
- order: 1;
- font-size: 0.9rem;
- padding-block-start: 8px;
- }
-
- > ion-item:first-of-type {
- --border-radius: #{$group-border-radius} #{$group-border-radius} 0 0;
- }
-
- > ion-item:last-of-type {
- --border-radius: 0 0 #{$group-border-radius} #{$group-border-radius};
- }
- }
-
- &:not(:first-of-type) {
- margin-top: 16px;
- }
-
- ion-item {
- &:has(> ion-label:not([slot])):has(> ion-note:not([slot])) {
- &::part(container) {
- flex-direction: column;
- justify-content: center;
- padding-top: 14px;
- padding-bottom: 14px;
- min-height: 64px;
- }
- & > ion-label,
- & > ion-note {
- width: auto;
- align-self: flex-start;
- }
- & > ion-label {
- margin: 0;
- font-weight: 500;
- font-size: 1.15rem;
- line-height: 0.9rem;
- }
- & > ion-note {
- font-size: 0.85rem;
- }
- }
-
- ion-text[slot='end'] {
- padding-left: 8px;
- }
-
- &.item-disabled {
- --detail-icon-opacity: 0.1;
- opacity: 1;
-
- & > * {
- opacity: 0.4;
- }
- }
-
- ion-button[slot='end'] {
- &.ion-align-self-end {
- transform: translateY(-7px);
- }
- ion-icon[slot='icon-only'] {
- font-size: 1.2rem;
- transform: translateY(4px);
- }
- }
- }
- }
-
- & > ion-note {
- --color: var(--ion-color-medium-tint);
- font-size: 0.9rem;
- display: block;
- margin: 8px calc(var(--ion-safe-area-right, 0) + $outer-note-inset) 8px calc(var(--ion-safe-area-left, 0) + $outer-note-inset);
- }
-}
diff --git a/src/styles/utils/theme-dark.scss b/src/styles/utils/theme-dark.scss
index c162c761..5c32e564 100644
--- a/src/styles/utils/theme-dark.scss
+++ b/src/styles/utils/theme-dark.scss
@@ -6,6 +6,10 @@
@use 'dark/ion-tabs' as *;
@forward 'dark/ion-segment';
@use 'dark/ion-segment' as *;
+@forward 'dark/ion-searchbar';
+@use 'dark/ion-searchbar' as *;
+@forward 'dark/ion-overlay';
+@use 'dark/ion-overlay' as *;
@mixin default-variables {
//--ios26-glass-background-rgb: 35, 35, 35;
@@ -22,4 +26,6 @@
@include ion-fab;
@include ion-tabs;
@include ion-segment;
+ @include ion-searchbar;
+ @include ion-overlay;
}
diff --git a/src/styles/utils/translucent.scss b/src/styles/utils/translucent.scss
index f5ee3682..2d13f66f 100644
--- a/src/styles/utils/translucent.scss
+++ b/src/styles/utils/translucent.scss
@@ -35,8 +35,8 @@ ion-header.ios:not(.ios-theme-disabled, .ios26-disabled).header-collapse-condens
transform: translateY(8px);
}
-ion-header.ios:not(.ios-theme-disabled, .ios26-disabled):not(.header-transitioning).header-translucent::before,
-ion-header.ios:not(.ios-theme-disabled, .ios26-disabled):not(.header-transitioning).header-translucent::after,
+ion-header.ios:not(.ios-theme-disabled, .ios26-disabled).header-translucent::before,
+ion-header.ios:not(.ios-theme-disabled, .ios26-disabled).header-translucent::after,
ion-content.ios:not(.ios-theme-disabled, .ios26-disabled).content-fullscreen::part(background)::before,
ion-content.ios:not(.ios-theme-disabled, .ios26-disabled).content-fullscreen::part(background)::after {
content: '';
diff --git a/src/tab-bar-searchable/animations/enter.ts b/src/tab-bar-searchable/animations/enter.ts
index a2e45143..0fedd804 100644
--- a/src/tab-bar-searchable/animations/enter.ts
+++ b/src/tab-bar-searchable/animations/enter.ts
@@ -1,7 +1,6 @@
-import { ElementReferences, ElementSizes } from '../interfaces';
import { Animation, createAnimation } from '@ionic/core';
+import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION, type ElementReferences, type ElementSizes } from '@rdlabo/ionic-theme-utils';
import { cloneElement } from '../../utils';
-import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION } from '../utils';
export const createEffectAnimation = (references: ElementReferences, sizes: ElementSizes): Animation => {
const effectElement = cloneElement('ion-icon');
@@ -56,6 +55,7 @@ export const createTabBarAnimation = (ionTabBar: HTMLElement, references: Elemen
return createAnimation()
.addElement(ionTabBar)
.beforeAddWrite(() => {
+ ionTabBar.style.transformOrigin = 'left center';
ionTabBar.querySelectorAll('ion-tab-button').forEach((element: HTMLElement) => {
element.style.transition = OPACITY_TRANSITION;
element.style.opacity = '0';
diff --git a/src/tab-bar-searchable/animations/leave.ts b/src/tab-bar-searchable/animations/leave.ts
index da1d34a7..bdfa20f2 100644
--- a/src/tab-bar-searchable/animations/leave.ts
+++ b/src/tab-bar-searchable/animations/leave.ts
@@ -1,7 +1,6 @@
-import { ElementReferences, ElementSizes } from '../interfaces';
import { Animation, createAnimation } from '@ionic/core';
+import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION, type ElementReferences, type ElementSizes } from '@rdlabo/ionic-theme-utils';
import { cloneElement } from '../../utils';
-import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION } from '../utils';
export const createReverseEffectAnimation = (
references: ElementReferences,
@@ -59,13 +58,14 @@ export const createReverseTabBarAnimation = (ionTabBar: HTMLElement, references:
return createAnimation()
.addElement(ionTabBar)
.beforeAddWrite(() => {
+ ionTabBar.style.transformOrigin = 'left center';
ionTabBar.style.pointerEvents = 'auto';
ionTabBar.querySelectorAll('ion-tab-button').forEach((element: HTMLElement) => {
element.style.transition = OPACITY_TRANSITION;
element.style.opacity = '1';
});
})
- .afterClearStyles(['transform', 'opacity'])
+ .afterClearStyles(['transform', 'opacity', 'transform-origin'])
.fromTo(
'transform',
`scale(${sizes.closeButton.width / sizes.tabBar.width}, ${sizes.closeButton.height / sizes.tabBar.height})`,
diff --git a/src/tab-bar-searchable/index.ts b/src/tab-bar-searchable/index.ts
index 84e00cd1..4e881fd3 100644
--- a/src/tab-bar-searchable/index.ts
+++ b/src/tab-bar-searchable/index.ts
@@ -6,8 +6,10 @@ import {
getElementReferences,
getElementSizes,
throwErrorByFailedClickElement,
-} from './utils';
-import { SearchableEventCache, TabBarSearchableFunction, TabBarSearchableType } from './interfaces';
+ type SearchableEventCache,
+ type TabBarSearchableFunction,
+ TabBarSearchableType,
+} from '@rdlabo/ionic-theme-utils';
import {
createCloseButtonsAnimation,
createEffectAnimation,
@@ -23,7 +25,13 @@ import {
createReverseTabBarAnimation,
} from './animations/leave';
-export * from './interfaces';
+export {
+ TabBarSearchableType,
+ type TabBarSearchableFunction,
+ type SearchableEventCache,
+ type ElementSizes,
+ type ElementReferences,
+} from '@rdlabo/ionic-theme-utils';
/**
*
@@ -55,7 +63,6 @@ export const attachTabBarSearchable = (
// Initialize
ionFooter.style.pointerEvents = 'none';
ionFooter.style.opacity = '0';
- ionTabBar.style.transformOrigin = 'left center';
// Saved Params
let searchableEventCache: SearchableEventCache | undefined;
diff --git a/src/tab-bar-searchable/interfaces.ts b/src/tab-bar-searchable/interfaces.ts
deleted file mode 100644
index effaf012..00000000
--- a/src/tab-bar-searchable/interfaces.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-export enum TabBarSearchableType {
- Enter = 'enter',
- Leave = 'leave',
-}
-
-export type TabBarSearchableFunction = (event: Event, type: TabBarSearchableType) => Promise;
-
-export interface SearchableEventCache {
- elementSizes: ElementSizes;
- colorSelected: string;
-}
-
-// DOM要素とサイズ情報の型定義
-export interface ElementSizes {
- tabBar: { width: number; height: number };
- closeButton: { width: number; height: number };
- fabButton: { width: number; height: number };
- searchContainer: { width: number; height: number };
- selectedTabButtonIcon: { width: number; height: number; top: number; left: number };
-}
-
-export interface ElementReferences {
- searchContainer: HTMLElement;
- closeButtons: HTMLElement;
- selectedTabButton: HTMLElement;
- selectedTabButtonIcon: HTMLElement;
- closeButtonIcon: HTMLElement;
-}
diff --git a/src/tab-bar-searchable/utils.ts b/src/tab-bar-searchable/utils.ts
deleted file mode 100644
index e4f53631..00000000
--- a/src/tab-bar-searchable/utils.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { ElementReferences, ElementSizes } from './interfaces';
-
-export const ANIMATION_DURATION = 400;
-export const ANIMATION_DELAY_BASE = 140;
-export const ANIMATION_DELAY_CLOSE_BUTTONS = 240;
-export const ANIMATION_EASING = 'cubic-bezier(0, 1, 0.22, 1)';
-export const OPACITY_TRANSITION = 'opacity 140ms ease';
-
-export const throwErrorByFailedClickElement = (selector: string): Error => {
- return new Error('Expected click element to be inside `' + selector + '`');
-};
-
-export const throwErrorByFailedExistElement = (selector: string): Error => {
- return new Error('Expected element `' + selector + '` to exist');
-};
-
-export const getElement = (docs: HTMLElement, selector: string): HTMLElement => {
- const el = docs.querySelector(selector);
- if (!el) {
- throw throwErrorByFailedClickElement(selector);
- }
- return el;
-};
-
-export const getElementReferences = (ionTabBar: HTMLElement, ionFooter: HTMLElement): ElementReferences => {
- const searchContainer = getElement(ionFooter, 'ion-searchbar .searchbar-input-container');
- const closeButtons = getElement(ionFooter, 'ion-buttons[slot=start]');
- const selectedTabButton = ionTabBar.querySelector('ion-tab-button.tab-selected');
- const selectedTabButtonIcon = selectedTabButton?.querySelector('ion-icon');
- const closeButtonIcon = closeButtons.querySelector('ion-icon');
-
- if (!selectedTabButton) {
- throw throwErrorByFailedExistElement('ion-tab-button.tab-selected');
- }
-
- if (!selectedTabButtonIcon) {
- throw throwErrorByFailedExistElement('ion-tab-button.tab-selected ion-icon');
- }
-
- if (!closeButtonIcon) {
- throw throwErrorByFailedExistElement('ion-buttons[slot=start] ion-button ion-icon');
- }
-
- return {
- searchContainer,
- closeButtons,
- selectedTabButton,
- selectedTabButtonIcon,
- closeButtonIcon,
- };
-};
-
-/**
- * 各要素のサイズ情報を取得
- */
-export const getElementSizes = (ionTabBar: HTMLElement, ionFabButton: HTMLElement, references: ElementReferences): ElementSizes => {
- const tabBarRect = ionTabBar.getBoundingClientRect();
- const fabButtonRect = ionFabButton.getBoundingClientRect();
- const closeButtonRect = references.closeButtons.getBoundingClientRect();
- const searchContainerRect = references.searchContainer.getBoundingClientRect();
- const selectedTabButtonIconRect = references.selectedTabButtonIcon!.getBoundingClientRect();
-
- return {
- tabBar: { width: tabBarRect.width, height: tabBarRect.height },
- closeButton: { width: closeButtonRect.width, height: closeButtonRect.height },
- fabButton: { width: fabButtonRect.width, height: fabButtonRect.height },
- searchContainer: { width: searchContainerRect.width, height: searchContainerRect.height },
- selectedTabButtonIcon: {
- width: selectedTabButtonIconRect.width,
- height: selectedTabButtonIconRect.height,
- top: selectedTabButtonIconRect.top,
- left: selectedTabButtonIconRect.left,
- },
- };
-};
diff --git a/src/tab-bar/index.ts b/src/tab-bar/index.ts
new file mode 100644
index 00000000..9bc83964
--- /dev/null
+++ b/src/tab-bar/index.ts
@@ -0,0 +1,411 @@
+import type { registeredEffect } from '../sheets-of-glass/interfaces';
+import { release, sample, shortTransfer, transfer } from './motion';
+
+interface Box {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+interface Surface extends Box {
+ platter: number;
+}
+
+/** Optional visual enhancement. Ionic owns tab-selected, routing and click events. */
+export const registerTabBarEffect = (bar: HTMLElement): registeredEffect | undefined => {
+ const doc = bar.ownerDocument;
+ const win = doc.defaultView;
+ if (!win || !bar.classList.contains('ios') || bar.matches('.ios26-enable-gesture, .ios-theme-disabled, .ios26-disabled')) return;
+ const reduced = win.matchMedia('(prefers-reduced-motion: reduce)');
+ if (reduced.matches) return;
+ const lens = doc.createElement('ion-tab-button');
+ lens.mode = 'ios';
+ lens.className = 'ios ion-cloned-element ios26-tab-lens';
+ lens.setAttribute('aria-hidden', 'true');
+ lens.tabIndex = -1;
+ lens.inert = true;
+ lens.style.display = 'none';
+ doc.body.append(lens);
+ bar.classList.add('ios26-enable-gesture');
+ const listeners = new AbortController();
+ let animations: Animation[] = [];
+ let destroyed = false;
+ let pending = 0;
+ let finishPending: (() => void) | undefined;
+ let sequence = 0;
+ let lateClick: { target: HTMLElement; until: number } | undefined;
+ const touchClicks = new Map();
+ let base = { x: 0, y: 0, width: 1, height: 1 };
+ let baseTransform = 'none';
+ let viewport = { x: 0, y: 0, sx: 1, sy: 1 };
+ let color = 'transparent';
+ let pointer:
+ | {
+ id: number;
+ type: string;
+ time: number;
+ x: number;
+ y: number;
+ from: Box;
+ to: Box;
+ target: HTMLIonTabButtonElement;
+ dragged: boolean;
+ clicked: boolean;
+ lastX: number;
+ lastTime: number;
+ }
+ | undefined;
+ const buttons = () => Array.from(bar.querySelectorAll('ion-tab-button'));
+ const allowed = (button: HTMLIonTabButtonElement | null): button is HTMLIonTabButtonElement =>
+ !!button &&
+ button.closest('ion-tab-bar') === bar &&
+ !button.disabled &&
+ !button.matches('.tab-disabled, .ios-theme-disabled, .ios26-disabled');
+ const enabled = () =>
+ !destroyed && bar.isConnected && !reduced.matches && !bar.matches('[data-native-ui-shell], .ios-theme-disabled, .ios26-disabled');
+ const selected = () =>
+ buttons().find((button) => button.tab === (bar as HTMLIonTabBarElement).selectedTab) ??
+ buttons().find((button) => button.classList.contains('tab-selected'));
+ const box = (element: HTMLElement): Box => {
+ const rect = (element.shadowRoot?.querySelector('[part="native"]') ?? element).getBoundingClientRect();
+ const outer = bar.getBoundingClientRect();
+ const scale = outer.width / base.width;
+ return {
+ x: (rect.x + rect.width / 2 - outer.x) / scale,
+ y: (rect.y + rect.height / 2 - outer.y) / scale,
+ width: rect.width / scale,
+ height: rect.height / scale,
+ };
+ };
+ const current = (): Surface => {
+ const rect = lens.getBoundingClientRect();
+ const outer = bar.getBoundingClientRect();
+ const scale = outer.width / base.width;
+ return {
+ x: (rect.x + rect.width / 2 - outer.x) / scale,
+ y: (rect.y + rect.height / 2 - outer.y) / scale,
+ width: rect.width / scale,
+ height: rect.height / scale,
+ platter: outer.width - base.width,
+ };
+ };
+ const cancelAnimations = () => {
+ animations.forEach((animation) => animation.cancel());
+ animations = [];
+ };
+ const hide = () => {
+ cancelAnimations();
+ lens.style.display = 'none';
+ bar.classList.remove('ios26-animated');
+ buttons().forEach((button) => button.classList.remove('ios26-tab-preview', 'ion-activated'));
+ };
+ const abort = () => {
+ if (pointer?.type === 'touch') touchClicks.set(pointer.id, win.performance.now() + 1000);
+ sequence++;
+ win.clearTimeout(pending);
+ pending = 0;
+ finishPending = undefined;
+ pointer = undefined;
+ hide();
+ };
+ const play = (states: (Surface & { offset: number })[], duration: number, start: number, target?: HTMLIonTabButtonElement) => {
+ cancelAnimations();
+ lens.style.display = 'block';
+ bar.classList.add('ios26-animated');
+ const frames = states.map((state) => {
+ const scale = 1 + state.platter / base.width;
+ const width = state.width * scale;
+ const height = state.height * scale;
+ return {
+ offset: state.offset,
+ transform: `translate3d(${(base.x + base.width / 2 + (state.x - base.width / 2) * scale - width / 2 - viewport.x) / viewport.sx}px, ${(base.y + base.height / 2 + (state.y - base.height / 2) * scale - height / 2 - viewport.y) / viewport.sy}px, 0)`,
+ width: `${width / viewport.sx}px`,
+ height: `${height / viewport.sy}px`,
+ };
+ });
+ // performance.now() can be slightly ahead of document.timeline.currentTime.
+ // Backwards fill prevents a zero-sized lens before that first paint (also
+ // important when replacing a drag animation several times in one frame).
+ const options: KeyframeAnimationOptions = { duration, fill: 'both', easing: 'linear' };
+ animations = [
+ lens.animate(frames, options),
+ bar.animate(
+ states.map((state) => ({
+ offset: state.offset,
+ transform: `${baseTransform === 'none' ? '' : baseTransform} scale(${1 + state.platter / base.width})`,
+ })),
+ options,
+ ),
+ ];
+ if (target) {
+ // Blend into the actual selected surface, without modifying Ionic's state.
+ const offset = Math.max(0, 1 - 120 / duration);
+ animations.push(lens.animate([{ opacity: 1 }, { opacity: 1, offset }, { opacity: 0 }], options));
+ animations.push(
+ target.animate(
+ [{ backgroundColor: 'transparent' }, { backgroundColor: 'transparent', offset }, { backgroundColor: color }],
+ options,
+ ),
+ );
+ }
+ animations.forEach((animation) => (animation.startTime = start));
+ if (target) {
+ const running = animations[0];
+ void running.finished.then(
+ () => {
+ if (animations[0] === running) hide();
+ },
+ () => {},
+ );
+ }
+ };
+ const states = (duration: number, at: (seconds: number) => Surface) => {
+ const times = [0, 33, 67, 100, 133, 167, 200, 267, 333, 400, 467, 600, 733, 900, 1100];
+ return [...times.filter((time) => time < duration), duration].map((time) => ({ ...at(time / 1000), offset: time / duration }));
+ };
+ const measured = (from: Box, to: Box, values: number[], source: 'left' | 'right' = 'left'): Surface => {
+ let [progress, width, height, platter] = values;
+ // UIKit26's left/right transfer deformation is asymmetric. These are
+ // physical directions, so RTL needs no logical-direction inversion.
+ if ((to.x > from.x && source === 'left') || (to.x < from.x && source === 'right')) [width, height] = [height, width];
+ return {
+ x: from.x + (to.x - from.x) * progress,
+ y: from.y + (to.y - from.y) * progress,
+ width: from.width + (to.width - from.width) * progress + width,
+ height: from.height + (to.height - from.height) * progress + height,
+ platter,
+ };
+ };
+ const preview = (button: HTMLIonTabButtonElement) => {
+ buttons().forEach((candidate) => candidate.classList.toggle('ios26-tab-preview', candidate === button));
+ };
+ const dragTarget = (x: number) => {
+ const rect = bar.getBoundingClientRect();
+ const target = doc.elementFromPoint(x, rect.y + rect.height / 2)?.closest('ion-tab-button');
+ return target?.closest('ion-tab-bar') === bar ? target : undefined;
+ };
+ const down = (event: PointerEvent) => {
+ // A queued second input can precede setTimeout(0), especially when the UI
+ // thread was busy. Finish the released session before testing pointer ownership.
+ if (finishPending) {
+ win.clearTimeout(pending);
+ finishPending();
+ }
+ if (!enabled() || pointer || event.button !== 0 || !event.isPrimary) return;
+ const target = (event.target as Element).closest('ion-tab-button');
+ if (!allowed(target)) return;
+ const time = win.performance.now();
+ for (const [id, until] of touchClicks) if (until < time) touchClicks.delete(id);
+ if (event.pointerType === 'touch') touchClicks.set(event.pointerId, Infinity);
+ lateClick = undefined;
+ // Capture an interrupted animation before clearing it; do not jump back to
+ // the previous tab when a second press arrives during release.
+ const interrupted = bar.classList.contains('ios26-animated') ? current() : undefined;
+ abort();
+ const rect = bar.getBoundingClientRect();
+ base = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
+ baseTransform = win.getComputedStyle(bar).transform;
+ // Ionic may transform body, making it the fixed-position containing block.
+ // Measure that coordinate system rather than assuming it is the viewport.
+ Object.assign(lens.style, { display: 'block', width: '1px', height: '1px', transform: 'none' });
+ const origin = lens.getBoundingClientRect();
+ viewport = { x: origin.x, y: origin.y, sx: origin.width || 1, sy: origin.height || 1 };
+ const from = box(selected() ?? target);
+ const to = box(target);
+ color = win.getComputedStyle(selected() ?? target).backgroundColor;
+ lens.style.background = color;
+ pointer = {
+ id: event.pointerId,
+ type: event.pointerType,
+ time,
+ x: event.clientX,
+ y: event.clientY,
+ from,
+ to,
+ target,
+ dragged: false,
+ clicked: false,
+ lastX: event.clientX,
+ lastTime: time,
+ };
+ preview(target);
+ const inPlace = selected() === target;
+ play(
+ states(1100, (t) => {
+ if (interrupted && t === 0) return interrupted;
+ const values = sample(transfer, t);
+ // main approximates an in-place press with one critically damped expansion.
+ if (inPlace) values[1] = values[2] = 16 * (1 - Math.exp(-18 * t) * (1 + 18 * t));
+ return measured(from, to, values);
+ }),
+ 1100,
+ time,
+ );
+ };
+ const move = (event: PointerEvent) => {
+ if (!pointer || pointer.id !== event.pointerId) return;
+ if (!enabled()) return abort();
+ // UIKit26 keeps tracking across the bar even when the finger leaves vertically.
+ const target = dragTarget(event.clientX) ?? pointer.target;
+ if (!allowed(target)) return;
+ const time = win.performance.now();
+ const delta = event.clientX - pointer.x;
+ if (!pointer.dragged && Math.hypot(delta, event.clientY - pointer.y) < 3) return;
+ pointer.dragged = true;
+ pointer.target = target;
+ pointer.to = box(target);
+ preview(target);
+ const available = buttons()
+ .filter(allowed)
+ .map(box)
+ .map((item) => item.x);
+ // Match main's bounded velocity stretch and four-keyframe rebound.
+ // Do not retain or integrate the history of pointer events.
+ const velocity = (event.clientX - pointer.lastX) / Math.max(1, time - pointer.lastTime);
+ pointer.lastX = event.clientX;
+ pointer.lastTime = time;
+ const from = current();
+ const outer = bar.getBoundingClientRect();
+ const x = Math.max(Math.min(...available), Math.min(Math.max(...available), (event.clientX - outer.x) / (outer.width / base.width)));
+ const stretch = Math.min(16, 32 * velocity * velocity);
+ const rebound = Math.max(stretch, from.width - pointer.to.width - 16) * 0.8;
+ const targetBox = { ...pointer.to, x, platter: 14.14 };
+ play(
+ [
+ { ...from, x, offset: 0 },
+ { ...targetBox, width: targetBox.width + 16 + stretch, height: targetBox.height + 16 - stretch, offset: 0.2 },
+ { ...targetBox, width: targetBox.width + 16 - rebound, height: targetBox.height + 16 + rebound, offset: 0.44 },
+ { ...targetBox, width: targetBox.width + 16, height: targetBox.height + 16, offset: 1 },
+ ],
+ 500,
+ time,
+ );
+ };
+ const up = (event: PointerEvent) => {
+ const ended = pointer;
+ if (!ended || ended.id !== event.pointerId) return;
+ if (!enabled()) return abort();
+ const endTime = win.performance.now();
+ const token = sequence;
+ // 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('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();
+ }
+ // Routing updates selectedTab asynchronously. Like main, animate toward
+ // the clicked tab, not the previous selection still exposed by Ionic.
+ const from = current();
+ const to = box(hit);
+ pointer = undefined;
+ buttons().forEach((button) => button.classList.remove('ios26-tab-preview', 'ion-activated'));
+ const elapsed = (endTime - ended.time) / 1000;
+ if (!ended.dragged && ended.from.x !== ended.to.x && elapsed < 0.18 && hit === ended.target) {
+ const duration = Math.max(1, (1.12 - elapsed) * 1000);
+ play(
+ states(duration, (t) => {
+ if (t === 0) return from;
+ return measured(ended.from, to, sample(shortTransfer, elapsed + t), 'right');
+ }),
+ duration,
+ endTime,
+ hit,
+ );
+ } else {
+ play(
+ states(550, (t) => {
+ const [remaining] = sample(release, t);
+ return {
+ x: to.x + (from.x - to.x) * remaining,
+ y: to.y + (from.y - to.y) * remaining,
+ width: to.width + (from.width - to.width) * remaining,
+ height: to.height + (from.height - to.height) * remaining,
+ platter: from.platter * remaining,
+ };
+ }),
+ 550,
+ endTime,
+ hit,
+ );
+ }
+ };
+ pending = win.setTimeout(finishPending, 0);
+ };
+ bar.addEventListener('pointerdown', down, { signal: listeners.signal });
+ bar.addEventListener(
+ 'click',
+ (event) => {
+ // Touch always uses the single post-pointerup Ionic click above. Native
+ // compatibility clicks may be delayed until after another gesture has
+ // started, so retain each touch identity across intervening pointerdowns.
+ const touchUntil = touchClicks.get((event as PointerEvent).pointerId) ?? 0;
+ if (event.isTrusted && event.detail > 0 && (event as PointerEvent).pointerType === 'touch' && touchUntil > win.performance.now()) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ return;
+ }
+ if (
+ lateClick &&
+ event.isTrusted &&
+ event.detail > 0 &&
+ win.performance.now() < lateClick.until &&
+ (event.target as Element).closest('ion-tab-button') === lateClick.target
+ ) {
+ lateClick = undefined;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ return;
+ }
+ if (pointer?.dragged && (event.target as Element).closest('ion-tab-button') !== pointer.target) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ return;
+ }
+ if (pointer && (event.target as Element).closest('ion-tab-button')) pointer.clicked = true;
+ },
+ { capture: true, signal: listeners.signal },
+ );
+ doc.addEventListener('pointermove', move, { signal: listeners.signal });
+ doc.addEventListener('pointerup', up, { signal: listeners.signal });
+ doc.addEventListener(
+ 'pointercancel',
+ (event) => {
+ if (pointer?.id === event.pointerId) abort();
+ },
+ { signal: listeners.signal },
+ );
+ win.addEventListener('blur', abort, { signal: listeners.signal });
+ // Scrolling/resize invalidates viewport geometry; ordinary Ionic selection is
+ // unaffected. Never leave a body-owned lens behind on a detached page.
+ doc.addEventListener('scroll', abort, { capture: true, passive: true, signal: listeners.signal });
+ win.addEventListener('resize', abort, { signal: listeners.signal });
+ reduced.addEventListener('change', abort, { signal: listeners.signal });
+ const observer = new MutationObserver(() => {
+ if (!enabled() && (pointer || bar.classList.contains('ios26-animated'))) abort();
+ });
+ observer.observe(bar, { attributes: true, attributeFilter: ['data-native-ui-shell', 'class'] });
+ return {
+ destroy: () => {
+ if (destroyed) return;
+ destroyed = true;
+ observer.disconnect();
+ listeners.abort();
+ abort();
+ touchClicks.clear();
+ lens.remove();
+ bar.classList.remove('ios26-enable-gesture');
+ },
+ };
+};
diff --git a/src/tab-bar/motion.ts b/src/tab-bar/motion.ts
new file mode 100644
index 00000000..27a79f1c
--- /dev/null
+++ b/src/tab-bar/motion.ts
@@ -0,0 +1,58 @@
+// Like main, use a small set of press/tap keyframes, not a recording replay.
+// iOS 26 values: seconds, center progress, extra width/height, platter width.
+export const transfer = [
+ [0, 0, 0, 0, 0],
+ [0.034, 0.03, 1.095, 1.095, 0.701],
+ [0.067, 0.199, 5.731, 5.79, 4.536],
+ [0.101, 0.409, 9.342, 10.52, 8.995],
+ [0.134, 0.596, 10.206, 14.935, 12.428],
+ [0.167, 0.739, 8.802, 18.94, 14.417],
+ [0.2, 0.839, 6.715, 22.184, 15.2],
+ [0.267, 0.946, 6.057, 24.612, 14.927],
+ [0.334, 0.99, 10.908, 20.102, 14.284],
+ [0.4, 1.009, 17.23, 13.982, 14.138],
+ [0.467, 1.017, 21.102, 10.249, 14.138],
+ [0.567, 1.014, 21.73, 9.837, 14.138],
+ [0.667, 1.009, 19.791, 11.968, 14.138],
+ [1.1, 1, 16, 16, 14.138],
+];
+
+export const shortTransfer = [
+ [0, 0, 0, 0, 0],
+ [0.033, 0.028, 1.02, 1.02, 0.649],
+ [0.068, 0.195, 5.688, 5.638, 4.443],
+ [0.1, 0.405, 10.377, 9.328, 8.369],
+ [0.133, 0.593, 14.821, 10.33, 8.69],
+ [0.167, 0.737, 19.077, 9.129, 6.791],
+ [0.2, 0.836, 22.649, 7.127, 4.238],
+ [0.235, 0.901, 24.767, 5.801, 1.964],
+ [0.267, 0.943, 24.024, 5.338, 0.367],
+ [0.3, 0.971, 17.189, 3.079, -0.514],
+ [0.367, 1.003, 3.936, 2.288, -0.792],
+ [0.434, 1.014, -3.357, 4.162, -0.343],
+ [0.5, 1.016, -5.948, 4.795, 0],
+ [0.601, 1.01, -4.54, 3.716, 0],
+ [0.701, 1.005, -2.096, 1.719, 0],
+ [0.801, 1.001, -0.565, 0.462, 0],
+ [0.901, 1, 0.015, -0.013, 0],
+ [1.1, 1, 0, 0, 0],
+];
+
+export const sample = (frames: number[][], time: number): number[] => {
+ const after = frames.findIndex((frame) => frame[0] >= time);
+ if (after <= 0) return frames[after === 0 ? 0 : frames.length - 1].slice(1);
+ const a = frames[after - 1];
+ const b = frames[after];
+ const p = (time - a[0]) / (b[0] - a[0]);
+ return a.slice(1).map((value, index) => value + (b[index + 1] - value) * p);
+};
+
+// iOS 26 release keyframes, shared by held presses and drags.
+export const release = [
+ [0, 1],
+ [0.1, 0.38],
+ [0.2, 0.06],
+ [0.3, -0.02],
+ [0.4, 0.01],
+ [0.55, 0],
+];
diff --git a/src/transition/ios.transition.ts b/src/transition/ios.transition.ts
index c99ab4a1..3bf066f8 100644
--- a/src/transition/ios.transition.ts
+++ b/src/transition/ios.transition.ts
@@ -1,834 +1,12 @@
+import { createIosTransitionAnimation, shadow } from '@rdlabo/ionic-theme-utils';
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 = (tagName: string) => {
- return document.querySelector(`${tagName}.ion-cloned-element`);
-};
-
-export const shadow = (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('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('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;
+export const iosTransitionAnimation: (navEl: HTMLElement, opts: TransitionOptions) => Animation =
+ createIosTransitionAnimation({
+ offLeftPercent: 30,
+ getIonPageElement,
+ });
diff --git a/src/utils.ts b/src/utils.ts
index 4033ac90..126f620c 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -18,10 +18,12 @@ export const raf = (h: FrameRequestCallback) => {
return setTimeout(h);
};
-export const cloneElement = (tagName: string): HTMLElement => {
- const getCachedEl = document.querySelector(`${tagName}.ion-cloned-element`);
- if (getCachedEl !== null) {
- return getCachedEl as HTMLElement;
+export const cloneElement = (tagName: string, useCache: boolean = true): HTMLElement => {
+ if (useCache) {
+ const cachedElement = document.querySelector(`${tagName}.ion-cloned-element`);
+ if (cachedElement !== null) {
+ return cachedElement as HTMLElement;
+ }
}
const clonedEl = document.createElement(tagName) as HTMLElement;