diff --git a/.github/workflows/e2e-main.yml b/.github/workflows/e2e-main.yml index 4d97a331..b15a458e 100644 --- a/.github/workflows/e2e-main.yml +++ b/.github/workflows/e2e-main.yml @@ -7,7 +7,7 @@ on: - ios26 jobs: test: - timeout-minutes: 10 + timeout-minutes: 20 runs-on: ubuntu-latest strategy: fail-fast: false @@ -45,7 +45,12 @@ jobs: working-directory: './demo' - name: Run Playwright tests - run: npm run test:e2e + run: | + if [ "${{ matrix.ionic-major }}" = "8" ]; then + npm run test:e2e -- e2e/screenshot.spec.ts + else + npm run test:e2e + fi working-directory: ./demo env: IONIC_MAJOR: ${{ matrix.ionic-major }} diff --git a/.github/workflows/e2e-pull_request.yml b/.github/workflows/e2e-pull_request.yml index a8ffcecc..aa017a0f 100644 --- a/.github/workflows/e2e-pull_request.yml +++ b/.github/workflows/e2e-pull_request.yml @@ -13,7 +13,7 @@ permissions: jobs: test: if: github.event.action != 'closed' - timeout-minutes: 10 + timeout-minutes: 20 runs-on: ubuntu-latest permissions: contents: read @@ -53,7 +53,12 @@ jobs: working-directory: './demo' - name: Run Playwright tests - run: PLAYWRIGHT_JSON_OUTPUT_NAME=e2e/screenshot.spec.ts-ionic${{ matrix.ionic-major }}.json npm run test:e2e -- --reporter=json,html + run: | + if [ "${{ matrix.ionic-major }}" = "8" ]; then + PLAYWRIGHT_JSON_OUTPUT_NAME=e2e/screenshot.spec.ts-ionic8.json npm run test:e2e -- e2e/screenshot.spec.ts --reporter=json,html + else + PLAYWRIGHT_JSON_OUTPUT_NAME=e2e/screenshot.spec.ts-ionic9.json npm run test:e2e -- --reporter=json,html + fi working-directory: ./demo env: IONIC_MAJOR: ${{ matrix.ionic-major }} diff --git a/.prettierignore b/.prettierignore index 8197e113..5953dfea 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,5 @@ node_modules dist www +demo/playwright-report +demo/test-results diff --git a/demo/e2e/README.md b/demo/e2e/README.md new file mode 100644 index 00000000..07cac510 --- /dev/null +++ b/demo/e2e/README.md @@ -0,0 +1,29 @@ +# Theme regression tests + +Use the existing Playwright command: `npm --prefix demo run test:e2e`. +The existing CI matrix runs against actual Ionic 8 and 9 packages. + +`screenshot.spec.ts` owns static light/dark appearance. The additional tests +protect behavior introduced or overridden by this theme, not Ionic itself: + +| Spec | Regression worth maintaining | +| --- | --- | +| `ios26-submit-brightness` | Public brightness colors, legacy alias, submit markup, disabled styling and Ionic glass-button overrides | +| `ios26-child-optout` | Nested selectors leaking theme styles into opted-out children | +| `ios26-popover-position` | Theme positioning losing event coordinates, clipping content or retaining temporary widths | +| `ios26-segment-parity` | Selection effects duplicating events/lenses or ignoring reduced motion/custom styles | +| `ios26-tab-lifecycle` | 1–5 tab layout (five without FAB), narrow-width containment/tablet caps, background overrides, click ownership and cleanup | +| `ios26-range-parity` | Enlarged thumbs moving past LTR/RTL endpoints or deforming the inactive dual thumb | +| `ios26-navigation` | Title/header movement, full-height dimming without page transparency or leftover layers, and pop restoring the original scroll position | +| `toggle` | Short-tap effects not settling, or CSS overriding reduced motion/public styling | + +Do not duplicate screenshot coverage with lists of CSS constants or re-test +Ionic's unmodified behavior. Prefer relations (containment, unchanged value, +single event, restored style) over implementation-specific animation samples. +Keep numeric layout cases for layout safety, not fractional native discontinuities. + +One-off native probes, copied Shell sources, raw frame/width dumps and their +runner/reporting stack were removed. Their last snapshot is commit `23b3554` +(`demo/native-parity`); it is historical evidence, not another maintained suite. +Tab sizing follows main's shared overlap layout instead of replaying the native width table. +Passing browser regressions does not establish native pixel or motion parity. diff --git a/demo/e2e/ios26-child-optout.spec.ts b/demo/e2e/ios26-child-optout.spec.ts new file mode 100644 index 00000000..0473d54e --- /dev/null +++ b/demo/e2e/ios26-child-optout.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; + +// Compare opted-out children with an unthemed sibling, not Ionic's current px values. +// Structural selectors are identical in light/dark, so do not multiply that axis. +for (const optOut of ['ios-theme-disabled', 'ios26-disabled']) { + test(`${optOut} isolates card item/list padding from themed siblings`, async ({ page }) => { + await page.goto('/main/index/card'); + await expect(page.locator('app-card ion-card').first()).toHaveClass(/hydrated/); + await page.evaluate((cls) => { + document.querySelector('ion-app')!.insertAdjacentHTML( + 'beforeend', + ` +
+ + + Themed + Opted out + + Opted out list + + Baseline +
`, + ); + }, optOut); + await expect(page.locator('#optout-fixture ion-item:not(.hydrated)')).toHaveCount(0); + const padding = (id: string) => page.locator(`#${id} [part="native"]`).evaluate((el) => getComputedStyle(el).paddingInlineStart); + const baseline = await padding('baseline'); + expect(await padding('themed')).not.toBe(baseline); + expect(await padding('opted-item')).toBe(baseline); + expect(await padding('opted-list-item')).toBe(baseline); + }); + + test(`${optOut} isolates stacked list layout and typography from themed siblings`, async ({ page }) => { + await page.goto('/main/index/item-list'); + await expect(page.locator('app-item-list ion-list').first()).toHaveClass(/hydrated/); + await page.evaluate((cls) => { + document.querySelector('ion-app')!.insertAdjacentHTML( + 'beforeend', + ` +
+ + Themed + Opted out + + LabelNote + LabelNote + + ThemedOpted out + + + Baseline + LabelNote + Baseline + +
`, + ); + }, optOut); + await expect(page.locator('#optout-fixture :is(ion-item,ion-label,ion-note):not(.hydrated)')).toHaveCount(0); + const styles = (id: string) => + page.locator(`#${id}`).evaluate((el) => { + const style = getComputedStyle(el); + return { fontSize: style.fontSize, display: style.display }; + }); + const direction = (id: string) => page.locator(`#${id} [part="container"]`).evaluate((el) => getComputedStyle(el).flexDirection); + expect(await direction('themed')).toBe('column'); + expect(await direction('opted')).toBe(await direction('baseline')); + expect(await direction('opted')).not.toBe('column'); + for (const suffix of ['header', 'note']) { + expect(await styles(`opted-${suffix}`)).toEqual(await styles(`baseline-${suffix}`)); + expect(await styles(`themed-${suffix}`)).not.toEqual(await styles(`baseline-${suffix}`)); + } + }); +} diff --git a/demo/e2e/ios26-navigation.spec.ts b/demo/e2e/ios26-navigation.spec.ts new file mode 100644 index 00000000..5c2586b9 --- /dev/null +++ b/demo/e2e/ios26-navigation.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; + +test.use({ viewport: { width: 402, height: 874 } }); + +for (const [length, scrollTop] of [ + ['short', 0], + ['long', 0], + ['long', 24], + ['long', 100], +] as const) { + test(`push preserves title geometry and pop restores scroll (${length}, ${scrollTop})`, async ({ page }) => { + await page.goto('/main/index'); + const source = page.locator('index-page'); + const content = source.locator('ion-content'); + const title = content.locator('ion-title.title-large'); + await expect(title).toBeAttached(); + await content.evaluate( + async (el, { length, scrollTop }) => { + if (length === 'short') { + el.querySelectorAll('ion-item').forEach((item) => { + if (item.textContent?.trim() !== 'button') item.remove(); + }); + el.querySelectorAll('ion-list').forEach((list) => { + if (!list.querySelector('ion-item')) list.remove(); + }); + } + await (el as HTMLIonContentElement).scrollToPoint(0, scrollTop, 0); + }, + { length, scrollTop }, + ); + await expect + .poll(() => content.evaluate(async (el) => (await (el as HTMLIonContentElement).getScrollElement()).scrollTop)) + .toBe(scrollTop); + const before = (await title.boundingBox())!; + // Pause the actual routed transition, rather than testing a copy of its keyframes. + await source.evaluate((el) => { + const content = el.querySelector('ion-content')!; + el.addEventListener( + 'ionViewWillLeave', + () => { + const hold = () => { + const animations = document.getAnimations(); + if (!animations.some((animation) => (animation.effect as KeyframeEffect).target === el)) { + requestAnimationFrame(hold); + return; + } + animations.forEach((animation) => { + animation.pause(); + animation.currentTime = Number(animation.effect!.getTiming().duration) * 0.15; + }); + content.dataset['motionHeld'] = 'true'; + }; + requestAnimationFrame(hold); + }, + { once: true }, + ); + }); + await source + .locator('ion-item') + .filter({ has: page.getByText('button', { exact: true }) }) + .click(); + await expect(content).toHaveAttribute('data-motion-held', 'true'); + const during = (await title.boundingBox())!; + expect(during.x).toBeLessThan(before.x); + expect(during.y).toBeCloseTo(before.y, 1); + expect(during.height).toBeCloseTo(before.height, 1); + await expect(title).toHaveCSS('opacity', '1'); + await expect(source).toHaveCSS('opacity', '1'); + await expect(page.locator('ion-title.ion-cloned-element')).toBeHidden(); + const shade = page.locator('.ios-transition-shade'); + const shadeBounds = (await shade.boundingBox())!; + const topBounds = (await page.locator('app-button').boundingBox())!; + expect(shadeBounds.y).toBeCloseTo(topBounds.y, 1); + expect(shadeBounds.height).toBeCloseTo(topBounds.height, 1); + expect(shadeBounds.x).toBe(0); + const edgeBounds = await shade.evaluate((el) => el.nextElementSibling!.getBoundingClientRect().toJSON()); + expect(edgeBounds.x + edgeBounds.width).toBeCloseTo(topBounds.x, 1); + const dimming = await shade.evaluate((el) => Number(getComputedStyle(el).opacity)); + expect(dimming).toBeGreaterThan(0); + expect(dimming).toBeLessThan(1); + for (const view of [source, page.locator('app-button')]) { + const header = view.locator(':scope > ion-header'); + expect((await header.boundingBox())!.x).toBeCloseTo((await view.locator(':scope > ion-content').boundingBox())!.x, 1); + expect(await header.evaluate((el) => getComputedStyle(el, '::after').content)).not.toBe('none'); + await expect(view.locator(':scope > ion-content .transition-effect')).toBeHidden(); + } + await page.evaluate(() => document.getAnimations().forEach((animation) => animation.play())); + await expect(source).toHaveClass(/ion-page-hidden/); + await expect(shade).toHaveCount(0); + expect(await page.locator('app-button').evaluate((el) => (el as HTMLElement).style.clipPath)).toBe(''); + await expect(page.locator('ion-back-button.ion-cloned-element')).toBeHidden(); + await page.locator('app-button > ion-header ion-back-button').click(); + await expect(source).not.toHaveClass(/ion-page-hidden/); + await expect.poll(async () => (await title.boundingBox())!.x).toBeCloseTo(before.x, 1); + await expect + .poll(() => content.evaluate(async (el) => (await (el as HTMLIonContentElement).getScrollElement()).scrollTop)) + .toBe(scrollTop); + expect((await title.boundingBox())!.y).toBeCloseTo(before.y, 1); + await expect(shade).toHaveCount(0); + await expect(page.locator('ion-back-button.ion-cloned-element')).toBeHidden(); + expect(await source.evaluate((el) => (el as HTMLElement).style.boxShadow)).toBe(''); + }); +} diff --git a/demo/e2e/ios26-popover-position.spec.ts b/demo/e2e/ios26-popover-position.spec.ts new file mode 100644 index 00000000..8a1ed2e3 --- /dev/null +++ b/demo/e2e/ios26-popover-position.spec.ts @@ -0,0 +1,168 @@ +import { expect, test } from '@playwright/test'; + +test('popover can be presented without a trigger', async ({ page }) => { + await page.goto('/main/index/popover'); + await page.waitForSelector('ion-popover.hydrated', { state: 'attached' }); + const result = await page.evaluate(async () => { + const popover = document.createElement('ion-popover') as any; + popover.component = document.createElement('div'); + popover.component.textContent = 'Unanchored content'; + document.body.append(popover); + await popover.present(); + const content = popover.shadowRoot.querySelector('.popover-content') as HTMLElement; + const origin = getComputedStyle(content).transformOrigin.split(' ').map(parseFloat); + const visible = popover.presented; + await popover.dismiss(); + popover.remove(); + return { visible, originX: origin[0], originY: origin[1] }; + }); + expect(result.visible).toBe(true); + expect(Number.isFinite(result.originX)).toBe(true); + expect(Number.isFinite(result.originY)).toBe(true); +}); + +for (const side of ['top', 'bottom', 'left', 'right'] as const) { + test(`event reference on ion-button keeps click coordinates for ${side} and does not replace`, async ({ page }) => { + await page.setViewportSize({ width: 1210, height: 834 }); + await page.goto('/main/index/popover'); + await page.waitForSelector('ion-popover.hydrated', { state: 'attached' }); + const result = await page.evaluate(async (placement) => { + const anchor = document.createElement('ion-button'); + anchor.style.cssText = 'position:fixed;left:500px;top:300px;width:200px;height:120px'; + anchor.textContent = 'Open'; + document.body.append(anchor); + const popover = document.createElement('ion-popover') as any; + popover.component = document.createElement('div'); + popover.component.textContent = 'Content'; + popover.style.cssText = '--width:240px;--height:180px'; + popover.reference = 'event'; + popover.event = { target: anchor, clientX: 520, clientY: 320 }; + popover.side = placement; + document.body.append(popover); + await popover.present(); + const content = popover.shadowRoot.querySelector('.popover-content') as HTMLElement; + const rect = content.getBoundingClientRect(); + const origin = getComputedStyle(content).transformOrigin.split(' ').map(parseFloat); + const result = { + originX: rect.left + origin[0], + originY: rect.top + origin[1], + replacing: anchor.classList.contains('ios-theme-replace-element'), + }; + await popover.dismiss(); + popover.remove(); + anchor.remove(); + return result; + }, side); + expect(result.replacing).toBe(false); + expect(Number.isFinite(result.originX)).toBe(true); + expect(Number.isFinite(result.originY)).toBe(true); + expect(Math.abs(result.originX - 520.5)).toBeLessThan(1); + expect(Math.abs(result.originY - 320.5)).toBeLessThan(1); + }); +} + +for (const width of [390, 1210]) { + test(`popover stays in its content pane at viewport width ${width}`, async ({ page }) => { + await page.setViewportSize({ width, height: 834 }); + await page.goto('/main/index/popover'); + await page.waitForSelector('ion-popover.hydrated', { state: 'attached' }); + for (const edge of ['left', 'right'] as const) { + const result = await page.evaluate(async (side) => { + const pane = document.querySelector('app-popover ion-content') ?? document.querySelector('ion-router-outlet ion-content'); + if (!pane) throw new Error('Content pane missing'); + const anchor = document.createElement('ion-button'); + anchor.textContent = 'Open'; + const scroll = pane.shadowRoot?.querySelector('[part="scroll"]'); + const inset = scroll ? getComputedStyle(scroll)[side === 'left' ? 'paddingLeft' : 'paddingRight'] : '0px'; + anchor.style.cssText = `position:absolute;top:120px;${side}:${inset};width:60px`; + pane.append(anchor); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + const anchorRect = anchor.getBoundingClientRect(); + const paneRect = pane.getBoundingClientRect(); + const popover = document.createElement('ion-popover') as any; + popover.component = document.createElement('div'); + popover.component.textContent = 'Content'; + popover.event = { target: anchor }; + popover.style.cssText = '--width:240px'; + document.body.append(popover); + await popover.present(); + const content = popover.shadowRoot.querySelector('[part="content"]') as HTMLElement; + const rect = content.getBoundingClientRect(); + const origin = parseFloat(getComputedStyle(content).transformOrigin); + const result = { + left: rect.left, + right: rect.right, + paneLeft: paneRect.left, + paneRight: paneRect.right, + origin: rect.left + origin, + originParts: getComputedStyle(content).transformOrigin.split(' ').map(parseFloat), + anchorCenter: anchorRect.left + anchorRect.width / 2, + }; + await popover.dismiss(); + popover.remove(); + anchor.remove(); + return result; + }, edge); + expect(result.left).toBeGreaterThanOrEqual(result.paneLeft + 7.5); + expect(result.right).toBeLessThanOrEqual(result.paneRight - 7.5); + expect(Number.isFinite(result.originParts[0])).toBe(true); + expect(Number.isFinite(result.originParts[1])).toBe(true); + expect(Math.abs(result.origin - result.anchorCenter)).toBeLessThan(1); + } + }); +} + +test('temporary maxWidth is restored after dismiss and reopen', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 834 }); + await page.goto('/main/index/popover'); + await page.waitForSelector('ion-popover.hydrated', { state: 'attached' }); + const result = await page.evaluate(async () => { + const pane = document.querySelector('app-popover ion-content') ?? document.querySelector('ion-router-outlet ion-content'); + if (!pane) throw new Error('Content pane missing'); + const anchor = document.createElement('button'); + anchor.style.cssText = 'position:absolute;top:120px;left:8px;width:40px;height:40px'; + pane.append(anchor); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + const popover = document.createElement('ion-popover') as any; + popover.component = document.createElement('div'); + popover.component.textContent = 'Wide content that must shrink to the pane'; + popover.event = { target: anchor }; + popover.style.cssText = '--width:520px'; + document.body.append(popover); + + await popover.present(); + const content = popover.shadowRoot.querySelector('.popover-content') as HTMLElement; + const whileOpen = { + maxWidth: content.style.maxWidth, + previous: content.dataset['previousMaxWidth'], + }; + + await popover.dismiss(); + const afterDismiss = { + maxWidth: content.style.maxWidth, + previous: content.dataset['previousMaxWidth'], + width: popover.style.getPropertyValue('--width'), + }; + + document.body.append(popover); + await popover.present(); + const reopenedContent = popover.shadowRoot.querySelector('.popover-content') as HTMLElement; + const onReopen = { + maxWidth: reopenedContent.style.maxWidth, + previous: reopenedContent.dataset['previousMaxWidth'], + }; + await popover.dismiss(); + popover.remove(); + anchor.remove(); + return { whileOpen, afterDismiss, onReopen }; + }); + + expect(result.whileOpen.maxWidth).toMatch(/px$/); + expect(result.whileOpen.previous).toBeDefined(); + expect(result.afterDismiss.maxWidth).toBe(result.whileOpen.previous); + expect(result.afterDismiss.previous).toBeUndefined(); + expect(result.afterDismiss.width).toBe('520px'); + expect(result.onReopen.maxWidth).toMatch(/px$/); + expect(result.onReopen.previous).toBeDefined(); +}); diff --git a/demo/e2e/ios26-range-parity.spec.ts b/demo/e2e/ios26-range-parity.spec.ts new file mode 100644 index 00000000..3f15b4f7 --- /dev/null +++ b/demo/e2e/ios26-range-parity.spec.ts @@ -0,0 +1,140 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; + +/** Theme resting knob: `--knob-width` × `--knob-size` (see `src/styles/components/ion-range.scss`). */ +const REST_W = 37; +const REST_H = 24; +/** Single-thumb press uses `scale(1.55)` → 37×1.55 / 24×1.55. */ +const HELD_W = 37 * 1.55; +const HELD_H = 24 * 1.55; + +test.use({ viewport: { width: 402, height: 874 } }); + +const appendFixtures = async (page: Page, markup: string) => { + await page.evaluate((html) => { + document.querySelector('#ios26-range-parity-fixture')?.remove(); + const app = document.querySelector('ion-app'); + if (!app) { + throw new Error('ion-app not found'); + } + const wrap = document.createElement('div'); + wrap.id = 'ios26-range-parity-fixture'; + wrap.style.cssText = 'position:fixed;top:96px;left:16px;right:16px;z-index:10000;padding:12px;background:rgba(255,255,255,0.96);'; + wrap.innerHTML = html; + app.appendChild(wrap); + }, markup); +}; + +const waitRangeReady = async (range: Locator) => { + await expect(range).toBeVisible(); + await range.evaluate(async (el) => { + const host = el as HTMLElement & { componentOnReady?: () => Promise }; + await host.componentOnReady?.(); + }); + await expect.poll(async () => range.evaluate((el) => el.classList.contains('hydrated'))).toBe(true); + await range.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); +}; + +const knob = (range: Locator, part = 'knob') => range.locator(`[part~="${part}"]`).first(); + +const knobBox = async (range: Locator, part = 'knob') => { + const box = await knob(range, part).boundingBox(); + if (!box) { + throw new Error(`knob part "${part}" has no bounding box`); + } + return box; +}; + +const pointerDownOnKnob = async (page: Page, range: Locator, part = 'knob') => { + // Hit the visual knob (not injected `.range-pressed`). Host `:active` drives single-thumb CSS. + const box = await knobBox(range, part); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); +}; + +test.describe('iOS26 ion-range parity', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/main/index/range', { waitUntil: 'networkidle' }); + // Demo page ships list + `.section-example` ranges; fixtures stay isolated on `ion-app`. + await expect(page.getByRole('slider', { name: 'Range with ticks', exact: true })).toBeVisible(); + }); + + for (const dir of ['ltr', 'rtl'] as const) { + for (const edge of ['min', 'max'] as const) { + test(`${dir} ${edge} enlarged thumb keeps resting outer edge within 1px`, async ({ page }) => { + const value = edge === 'min' ? 0 : 100; + await appendFixtures( + page, + ``, + ); + const range = page.locator('#edge'); + await waitRangeReady(range); + await expect(range).toHaveClass(edge === 'min' ? /range-value-min/ : /range-value-max/); + + const rest = await knobBox(range); + const restOuter = (() => { + // Physical outer edge that endpoint translate is meant to pin. + if (dir === 'ltr') { + return edge === 'min' ? rest.x : rest.x + rest.width; + } + return edge === 'min' ? rest.x + rest.width : rest.x; + })(); + + await pointerDownOnKnob(page, range); + await expect.poll(async () => (await knobBox(range)).width).toBeCloseTo(HELD_W, 1); + const held = await knobBox(range); + const heldOuter = (() => { + if (dir === 'ltr') { + return edge === 'min' ? held.x : held.x + held.width; + } + return edge === 'min' ? held.x + held.width : held.x; + })(); + expect(Math.abs(heldOuter - restOuter)).toBeLessThanOrEqual(1); + await page.mouse.up(); + }); + } + } + + test('dual-knob press expands only the active thumb vertically; width stays 37 (co-located endpoints)', async ({ page }) => { + await appendFixtures( + page, + ``, + ); + const range = page.locator('#dual'); + await waitRangeReady(range); + // Co-located endpoints (Ionic default dual ratios are both 0). + await range.evaluate((el) => { + (el as HTMLIonRangeElement).value = { lower: 0, upper: 0 }; + }); + await range.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + + const restA = await knobBox(range, 'knob-a'); + const restB = await knobBox(range, 'knob-b'); + expect(restA.width).toBeCloseTo(REST_W, 1); + expect(restB.width).toBeCloseTo(REST_W, 1); + + // Dual styles key off Ionic `.range-pressed-*` (not `:active`). Gesture threshold is 10px. + const start = await knobBox(range, 'knob-a'); + await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); + await page.mouse.down(); + await page.mouse.move(start.x + start.width / 2 + 12, start.y + start.height / 2, { steps: 2 }); + // The threshold-crossing event starts the gesture; the next move selects + // the active knob. Keep both endpoints close to their coincident origin. + await page.mouse.move(start.x + start.width / 2 + 14, start.y + start.height / 2); + + await expect(range).toHaveClass(/range-pressed/); + await expect + .poll(async () => Math.max((await knobBox(range, 'knob-a')).height, (await knobBox(range, 'knob-b')).height)) + .toBeCloseTo(HELD_H, 1); + + const heldA = await knobBox(range, 'knob-a'); + const heldB = await knobBox(range, 'knob-b'); + expect(heldA.width).toBeCloseTo(REST_W, 1); + expect(heldB.width).toBeCloseTo(REST_W, 1); + + const heights = [heldA.height, heldB.height].sort((a, b) => a - b); + expect(heights[0]).toBeCloseTo(REST_H, 1); + expect(heights[1]).toBeCloseTo(HELD_H, 1); + + await page.mouse.up(); + }); +}); diff --git a/demo/e2e/ios26-segment-parity.spec.ts b/demo/e2e/ios26-segment-parity.spec.ts new file mode 100644 index 00000000..7031b523 --- /dev/null +++ b/demo/e2e/ios26-segment-parity.spec.ts @@ -0,0 +1,124 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; + +test.use({ viewport: { width: 402, height: 874 }, hasTouch: true }); + +const trackIonChange = async (segment: Locator) => { + await segment.evaluate((el) => { + el.dataset['changes'] = '0'; + el.addEventListener('ionChange', () => { + el.dataset['changes'] = String(Number(el.dataset['changes'] ?? '0') + 1); + }); + }); +}; + +test.describe('iOS26 ion-segment candidate', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/main/index/segment', { waitUntil: 'networkidle' }); + const toolbar = page.locator('app-segment ion-header ion-segment').first(); + await expect(toolbar).toHaveClass(/hydrated/); + await expect(toolbar).toHaveClass(/ios26-enable-gesture/); + }); + + test('tap changes value exactly once per direction', async ({ page }) => { + const segment = page.locator('app-segment ion-header ion-segment').first(); + await trackIonChange(segment); + await segment.locator('ion-segment-button[value="segment"]').tap(); + await expect.poll(async () => segment.evaluate((el) => (el as HTMLIonSegmentElement).value)).toBe('segment'); + await expect(segment).toHaveAttribute('data-changes', '1'); + await segment.locator('ion-segment-button[value="default"]').tap(); + await expect.poll(async () => segment.evaluate((el) => (el as HTMLIonSegmentElement).value)).toBe('default'); + await expect(segment).toHaveAttribute('data-changes', '2'); + }); + + test('dragging keeps Ionic selection events', async ({ page }) => { + const segment = page.locator('app-segment ion-header ion-segment').first(); + await trackIonChange(segment); + const tabs = segment.getByRole('tab'); + const start = (await tabs.first().boundingBox())!; + const end = (await tabs.last().boundingBox())!; + await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); + await page.mouse.down(); + await page.waitForTimeout(250); + for (let step = 1; step <= 5; step++) { + const x = start.x + start.width / 2 + ((end.x + end.width / 2 - start.x - start.width / 2) * step) / 5; + await page.mouse.move(x, end.y + end.height / 2); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + } + await page.mouse.up(); + await expect.poll(async () => segment.evaluate((el) => (el as HTMLIonSegmentElement).value)).toBe('segment'); + await expect(segment).toHaveAttribute('data-changes', '1'); + await expect(segment.locator('ion-segment-button').last()).toHaveClass(/segment-button-checked/); + }); + + test('reduced motion skips lens registration', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.reload({ waitUntil: 'networkidle' }); + const segment = page.locator('app-segment ion-header ion-segment').first(); + await expect(segment).toHaveClass(/hydrated/); + await expect(segment).not.toHaveClass(/ios26-enable-gesture/); + expect(await segment.locator('.ios26-segment-lens').count()).toBe(0); + await segment.locator('ion-segment-button[value="segment"]').tap(); + await expect.poll(async () => segment.evaluate((el) => (el as HTMLIonSegmentElement).value)).toBe('segment'); + }); + + test('destroy and re-register do not duplicate lenses', async ({ page }) => { + const result = await page.evaluate(() => { + const host = document.querySelector('app-segment'); + const ng = ( + window as unknown as { + ng?: { getComponent?: (el: Element) => { registeredGestures: { destroy: () => void }[]; ionViewDidEnter: () => void } }; + } + ).ng; + if (!host || !ng?.getComponent) { + return { ok: false as const, reason: 'ng.getComponent unavailable' }; + } + const cmp = ng.getComponent(host); + if (!cmp?.registeredGestures || !cmp.ionViewDidEnter) { + return { ok: false as const, reason: 'segment page component unavailable' }; + } + const count = () => host.querySelectorAll('.ios26-segment-lens').length; + const before = count(); + cmp.registeredGestures.forEach((gesture) => gesture.destroy()); + cmp.registeredGestures.length = 0; + const afterDestroy = count(); + cmp.ionViewDidEnter(); + const afterRegister = count(); + cmp.ionViewDidEnter(); + const afterDuplicateAttempt = count(); + cmp.registeredGestures.forEach((gesture) => gesture.destroy()); + cmp.registeredGestures.length = 0; + cmp.ionViewDidEnter(); + const afterReregister = count(); + return { + ok: true as const, + before, + afterDestroy, + afterRegister, + afterDuplicateAttempt, + afterReregister, + segments: host.querySelectorAll('ion-segment').length, + }; + }); + expect(result.ok, 'reason' in result ? result.reason : '').toBe(true); + if (!result.ok) return; + expect(result.before).toBeGreaterThan(0); + expect(result.afterDestroy).toBe(0); + expect(result.afterRegister).toBe(result.segments); + expect(result.afterDuplicateAttempt).toBe(result.afterRegister); + expect(result.afterReregister).toBe(result.afterRegister); + }); + + test('stylesheet overrides apply to track and indicator', async ({ page }) => { + await page.addStyleTag({ + content: ` + ion-segment { --background: rgb(12, 34, 56); } + ion-segment-button { --indicator-color: rgb(210, 30, 40); --border-radius: 8px; } + `, + }); + const segment = page.locator('app-segment ion-header ion-segment').first(); + await expect(segment).toHaveCSS('background-color', 'rgb(12, 34, 56)'); + const tab = segment.locator('ion-segment-button').first(); + await expect(tab.locator('[part="indicator-background"]')).toHaveCSS('background-color', 'rgb(210, 30, 40)'); + await expect(tab.locator('[part="indicator-background"]')).toHaveCSS('border-radius', '8px'); + }); +}); diff --git a/demo/e2e/ios26-submit-brightness.spec.ts b/demo/e2e/ios26-submit-brightness.spec.ts new file mode 100644 index 00000000..5b8e677c --- /dev/null +++ b/demo/e2e/ios26-submit-brightness.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; + +// Public iOS26 color contract; not a copy of the theme's default palette. +for (const dark of [false, true]) { + test(`glass buttons retain Ionic background, border and shadow overrides (${dark ? 'dark' : 'light'})`, async ({ page }) => { + await page.goto('/main/index/button'); + await page.evaluate((dark) => { + document.documentElement.classList.toggle('ion-palette-dark', dark); + document + .querySelector('ion-app')! + .insertAdjacentHTML( + 'beforeend', + '
Glass+
', + ); + }, dark); + for (const button of await page.locator('#glass-overrides ion-button, #glass-overrides ion-fab-button').all()) { + await expect(button).toHaveClass(/hydrated/); + await button.evaluate((el) => { + (el as HTMLElement).style.cssText = + '--background:rgb(30, 60, 90);--border-width:3px;--border-style:solid;--border-color:rgb(90, 60, 30);--box-shadow:none;'; + }); + const native = button.locator('[part="native"]'); + await expect(native).toHaveCSS('background-color', 'rgb(30, 60, 90)'); + await expect(native).toHaveCSS('border-top-width', '3px'); + await expect(native).toHaveCSS('border-top-color', 'rgb(90, 60, 30)'); + await expect(native).toHaveCSS('box-shadow', 'none'); + await button.evaluate((el) => el.classList.add('ion-activated')); + await expect(native).toHaveCSS('border-top-width', '3px'); + await expect(native).toHaveCSS('border-top-color', 'rgb(90, 60, 30)'); + await expect(native).toHaveCSS('background-color', 'rgb(30, 60, 90)'); + await expect(native).toHaveCSS('box-shadow', 'none'); + } + }); + + test(`submit brightness respects both markup forms and disabled state (${dark ? 'dark' : 'light'})`, async ({ page }) => { + await page.goto('/main/index/button'); + await expect(page.locator('app-button ion-button').first()).toHaveClass(/hydrated/); + await page.evaluate((dark) => { + document.documentElement.classList.toggle('ion-palette-dark', dark); + document.documentElement.style.setProperty('--ion-color-primary-brightness', 'rgb(120, 220, 180)'); + const fixture = document.createElement('div'); + fixture.id = 'brightness'; + fixture.innerHTML = ['type="submit"', 'type="button" class="button-submit"'] + .flatMap((attrs) => + ['', 'color="primary"'].map( + (color) => + `EnabledDisabled`, + ), + ) + .join(''); + document.querySelector('ion-app')!.append(fixture); + }, dark); + const buttons = page.locator('#brightness ion-button'); + await expect(buttons).toHaveCount(8); + await expect(page.locator('#brightness ion-button:not(.hydrated)')).toHaveCount(0); + for (const button of await buttons.all()) { + const native = button.locator('[part="native"]'); + if (await button.evaluate((el) => (el as HTMLIonButtonElement).disabled)) { + await expect(native).not.toHaveCSS('color', 'rgb(120, 220, 180)'); + await expect(native).not.toHaveCSS('border-top-color', 'rgb(120, 220, 180)'); + } else { + await expect(native).toHaveCSS('color', 'rgb(120, 220, 180)'); + await expect(native).toHaveCSS('border-top-color', 'rgb(120, 220, 180)'); + expect(parseFloat(await native.evaluate((el) => getComputedStyle(el).borderTopWidth))).toBeGreaterThan(0); + } + } + }); + + test(`non-primary brightness accepts direct and legacy RGB overrides (${dark ? 'dark' : 'light'})`, async ({ page }) => { + await page.goto('/main/index/button'); + await expect(page.locator('app-button ion-button').first()).toHaveClass(/hydrated/); + await page.evaluate((dark) => { + document.documentElement.classList.toggle('ion-palette-dark', dark); + document.documentElement.style.setProperty('--ion-color-secondary-brightness-rgb', '120, 220, 180'); + document.documentElement.style.setProperty('--ion-color-danger-brightness', 'rgb(240, 170, 90)'); + document + .querySelector('ion-app')! + .insertAdjacentHTML( + 'beforeend', + 'Legacy' + + 'Direct', + ); + }, dark); + for (const [id, color] of [ + ['legacy-brightness', 'rgb(120, 220, 180)'], + ['direct-brightness', 'rgb(240, 170, 90)'], + ]) { + const button = page.locator(`#${id}`); + await expect(button).toHaveClass(/hydrated/); + await expect(button.locator('[part="native"]')).toHaveCSS('color', color); + await expect(button.locator('[part="native"]')).toHaveCSS('border-top-color', color); + } + }); +} diff --git a/demo/e2e/ios26-tab-lifecycle.spec.ts b/demo/e2e/ios26-tab-lifecycle.spec.ts new file mode 100644 index 00000000..2439121a --- /dev/null +++ b/demo/e2e/ios26-tab-lifecycle.spec.ts @@ -0,0 +1,487 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; + +test.use({ viewport: { width: 402, height: 874 }, hasTouch: true }); + +type TabsCmp = { + registeredGestures: { destroy: () => void }[]; + ionViewDidEnter: () => void; +}; + +const clones = (page: Page) => page.locator('body > ion-tab-button.ion-cloned-element'); + +const waitHydrated = async (root: Locator) => { + await expect(root).toBeVisible(); + await root.evaluate(async (el) => { + const nodes = [el, ...Array.from(el.querySelectorAll('*'))] as Array Promise }>; + await Promise.all(nodes.map((node) => node.componentOnReady?.() ?? Promise.resolve())); + }); + await expect.poll(async () => root.evaluate((el) => el.classList.contains('hydrated'))).toBe(true); +}; + +const getTabsComponent = async (page: Page) => { + return page.evaluate(() => { + const host = document.querySelector('app-tabs'); + const ng = (window as unknown as { ng?: { getComponent?: (el: Element) => TabsCmp } }).ng; + if (!host || !ng?.getComponent) return null; + return !!ng.getComponent(host); + }); +}; + +/** Exercise the existing page lifecycle, without a test-only registration API. */ +const registerViaTabs = async (page: Page, selector: string) => { + return page.evaluate((sel) => { + 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?.ionViewDidEnter || !cmp.registeredGestures) { + return { ok: false as const, reason: 'tabs component unavailable', registered: false as const }; + } + const el = document.querySelector(sel); + if (!el) return { ok: false as const, reason: 'target missing', registered: false as const }; + const before = cmp.registeredGestures.length; + cmp.ionViewDidEnter(); + const after = cmp.registeredGestures.length; + if (after <= before) { + return { ok: true as const, registered: false as const }; + } + return { ok: true as const, registered: true as const, index: after - 1 }; + }, selector); +}; + +const destroyRegisteredAt = async (page: Page, index: number) => { + await page.evaluate((i) => { + 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; + const handle = cmp?.registeredGestures?.[i]; + handle?.destroy(); + if (cmp?.registeredGestures && i >= 0 && i < cmp.registeredGestures.length) { + cmp.registeredGestures.splice(i, 1); + } + }, index); +}; + +const appendFixtureBar = async ( + page: Page, + id: string, + opts?: { disabledSecond?: boolean; optOutClass?: string; count?: number; withFab?: boolean }, +) => { + await page.evaluate( + ({ barId, disabledSecond, optOutClass, count, withFab, cssDriven }) => { + document.querySelector(`#${barId}`)?.remove(); + const app = document.querySelector('app-tabs')!; + const bar = document.createElement('ion-tab-bar') as HTMLElement & { selectedTab?: string }; + bar.id = barId; + bar.classList.add('ios'); + bar.setAttribute('mode', 'ios'); + bar.style.cssText = 'position:fixed;left:12px;right:12px;top:120px;z-index:10000;'; + if (cssDriven) { + bar.slot = 'bottom'; + bar.style.cssText = 'position:fixed;top:120px;bottom:auto;z-index:10000;'; + } + if (optOutClass) bar.classList.add(optOutClass); + bar.innerHTML = ['one', 'two', 'three', 'four', 'five'] + .slice(0, count) + .map((tab, index) => { + const disabled = disabledSecond && index === 1; + return `${tab[0].toUpperCase() + tab.slice(1)}`; + }) + .join(''); + bar.selectedTab = 'one'; + bar.addEventListener('ionTabButtonClick', ((event: CustomEvent<{ tab: string }>) => { + bar.selectedTab = event.detail.tab; + }) as EventListener); + app.appendChild(bar); + if (withFab) { + const fab = document.createElement('ion-fab'); + fab.id = `${barId}-fab`; + fab.setAttribute('mode', 'ios'); + fab.setAttribute('vertical', 'bottom'); + fab.setAttribute('horizontal', 'end'); + fab.style.cssText = 'position:fixed;top:120px;bottom:auto;z-index:10001;'; + fab.innerHTML = ''; + app.append(fab); + } + }, + { + barId: id, + disabledSecond: !!opts?.disabledSecond, + optOutClass: opts?.optOutClass ?? '', + count: opts?.count ?? 3, + withFab: !!opts?.withFab, + cssDriven: opts?.count !== undefined, + }, + ); + const bar = page.locator(`#${id}`); + await waitHydrated(bar); + if (opts?.withFab) await waitHydrated(page.locator(`#${id}-fab`)); + return bar; +}; + +test.describe('iOS26 tab gesture lifecycle', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/main/index', { waitUntil: 'networkidle' }); + // Desktop Safari with touch is detected as iPad by Ionic. The 402pt fixture + // represents an iPhone; select the intended platform before comparing sizes. + await page.evaluate(() => { + document.documentElement.classList.remove('plt-ipad'); + document.documentElement.classList.add('plt-iphone'); + }); + const bar = page.locator('ion-tab-bar#tab-bar-bottom'); + await waitHydrated(bar); + await expect(bar).toHaveClass(/ios26-enable-gesture/); + await expect(clones(page)).toHaveCount(1); + expect(await getTabsComponent(page)).toBe(true); + }); + + test('Ionic background overrides survive selection effects', async ({ page }) => { + const bar = await appendFixtureBar(page, 'tab-background'); + await registerViaTabs(page, '#tab-background'); + const buttons = bar.locator('ion-tab-button'); + await bar.evaluate((el) => { + el.style.setProperty('--background', 'rgb(30, 60, 90)'); + el.querySelectorAll('ion-tab-button').forEach((button) => button.style.setProperty('--background', 'rgb(90, 60, 30)')); + }); + for (const dark of [false, true]) { + await page.evaluate((dark) => document.documentElement.classList.toggle('ion-palette-dark', dark), dark); + expect(await bar.evaluate((el) => getComputedStyle(el, '::before').backgroundColor)).toBe('rgb(30, 60, 90)'); + await buttons.nth(dark ? 0 : 1).tap(); + await expect(bar).not.toHaveClass(/ios26-animated/); + await expect(bar.locator('.tab-selected')).toHaveCSS('background-color', 'rgb(90, 60, 30)'); + } + }); + + // Native cell overlap is intentional. FAB placement comes from production CSS. + for (const count of [1, 2, 3, 4, 5]) { + test(`${count} tabs ${count < 5 ? 'with FAB' : 'without FAB'} retain geometry and selection`, async ({ page }) => { + const id = `ios26-tabs-${count}`; + const bar = await appendFixtureBar(page, id, { count, withFab: count < 5 }); + const registration = await registerViaTabs(page, `#${id}`); + expect(registration.registered).toBe(true); + const buttons = bar.locator('ion-tab-button'); + await expect(buttons).toHaveCount(count); + const outer = (await bar.boundingBox())!; + expect(outer.height).toBeCloseTo(62, 1); + expect(outer.width).toBeCloseTo([102, 188, 274, 302, 360][count - 1], 1); + const boxes = await Promise.all(Array.from({ length: count }, (_, index) => buttons.nth(index).boundingBox())); + for (let index = 0; index < count; index++) { + const box = boxes[index]!; + expect(box.x).toBeGreaterThanOrEqual(outer.x + 3.9); + expect(box.x + box.width).toBeLessThanOrEqual(outer.x + outer.width - 3.9); + expect(box.height).toBeCloseTo(54, 1); + if (index) expect(box.x).toBeGreaterThan(boxes[index - 1]!.x); + } + if (count < 5) { + const fab = (await page.locator(`#${id}-fab`).boundingBox())!; + expect(outer.x + outer.width).toBeLessThanOrEqual(fab.x); + } else { + await expect(page.locator(`#${id}-fab`)).toHaveCount(0); + } + await buttons.last().tap(); + await expect(buttons.last()).toHaveClass(/tab-selected/); + await expect(bar.locator('.tab-selected')).toHaveCount(1); + await expect(bar).not.toHaveClass(/ios26-animated/); + await expect(bar.locator('.ion-activated, .ios26-tab-preview')).toHaveCount(0); + // UIKit26 keeps the held lens alive above the bar, including a single tab. + await expect(bar).toHaveCSS('touch-action', 'pinch-zoom'); + const last = boxes[count - 1]!; + const first = boxes[0]!; + await page.mouse.move(last.x + last.width / 2, last.y + last.height / 2); + await page.mouse.down(); + await page.mouse.move(last.x + last.width / 2, last.y - 45, { steps: 6 }); + await expect(bar).toHaveClass(/ios26-animated/); + await page.mouse.move(first.x + first.width / 2, first.y - 45, { steps: 12 }); + await expect(buttons.first()).toHaveClass(/ios26-tab-preview/); + await expect(buttons.last()).toHaveClass(/tab-selected/); + await page.mouse.up(); + await expect(buttons.first()).toHaveClass(/tab-selected/); + await expect(bar).not.toHaveClass(/ios26-animated/); + if (registration.registered && 'index' in registration) await destroyRegisteredAt(page, registration.index); + await expect(clones(page)).toHaveCount(1); + }); + } + + // Layout safety across sizes matters; fractional native width discontinuities do not. + for (const ipad of [false, true]) { + for (const count of [4, 5]) { + test(`${ipad ? 'iPad' : 'iPhone'} ${count}-tab cells fit narrow and roomy bars`, async ({ page }) => { + await page.evaluate((ipad) => { + document.documentElement.classList.toggle('plt-ipad', ipad); + document.documentElement.classList.toggle('plt-iphone', !ipad); + }, ipad); + const bar = await appendFixtureBar(page, 'tab-width', { count }); + for (const width of [218, 360]) { + await bar.evaluate((el, width) => { + el.style.width = `${width - 8}px`; + el.style.maxWidth = 'none'; + }, width); + const outer = (await bar.boundingBox())!; + const cells = await Promise.all((await bar.locator('ion-tab-button').all()).map((button) => button.boundingBox())); + for (const [index, cell] of cells.entries()) { + expect(cell!.width).toBeGreaterThanOrEqual(44); + expect(cell!.width).toBeCloseTo(cells[0]!.width, 1); + expect(cell!.x).toBeGreaterThanOrEqual(outer.x + 3.9); + expect(cell!.x + cell!.width).toBeLessThanOrEqual(outer.x + outer.width - 3.9); + if (index) expect(cell!.x).toBeGreaterThan(cells[index - 1]!.x); + } + } + if (ipad) { + await bar.evaluate((el) => { + el.style.width = '500px'; + el.style.removeProperty('max-width'); + }); + expect((await bar.boundingBox())!.width).toBe(count === 4 ? 336 : 414); + } + }); + } + } + + test('pointercancel restores selection and never commits a click', 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(() => { + document.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, cancelable: true, pointerId: 1 })); + }); + // Release away from the tab so Playwright does not synthesize a click on the cancelled target. + await page.mouse.move(8, 8); + await page.mouse.up(); + await page.waitForTimeout(250); + await expect(first).toHaveClass(/tab-selected/); + await expect(second).not.toHaveClass(/tab-selected/); + await expect(second).toHaveAttribute('data-clicks', '0'); + await expect(bar.locator('.ion-activated')).toHaveCount(0); + await expect(bar).not.toHaveClass(/ios26-animated/); + await expect(clones(page)).toHaveCSS('display', 'none'); + }); + + test('real routing settles the lens at the clicked tab, not the previous selection', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + const target = page.locator('#tab-bar-bottom ion-tab-button[tab="docs"]'); + await target.click(); + await expect(page).toHaveURL(/\/main\/docs/); + const end = await target.evaluate(async (target) => { + const bar = target.parentElement!; + const lens = document.querySelector('body > ion-tab-button.ion-cloned-element')!; + const animations = [...lens.getAnimations(), ...bar.getAnimations()]; + if (!animations.length) throw new Error('Expected the tab selection animation'); + // Inspect convergence without sleeping through it or replaying native frame samples. + for (const animation of animations) { + animation.pause(); + animation.currentTime = Number(animation.effect!.getComputedTiming().duration) - 1; + } + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + const a = lens.getBoundingClientRect(); + const b = target.getBoundingClientRect(); + animations.forEach((animation) => animation.play()); + return { dx: a.x + a.width / 2 - b.x - b.width / 2, width: b.width }; + }); + expect(Math.abs(end.dx)).toBeLessThan(end.width / 4); + await expect(target).toHaveClass(/tab-selected/); + await expect(page.locator('#tab-bar-bottom')).not.toHaveClass(/ios26-animated/); + }); + + test('real touch taps commit once per touch, including a rapid second tap', async ({ page }) => { + const bar = await appendFixtureBar(page, 'ios26-tab-touch'); + expect((await registerViaTabs(page, '#ios26-tab-touch')).registered).toBe(true); + await bar.evaluate((el) => { + el.dataset['changes'] = '0'; + el.addEventListener('ionTabButtonClick', () => { + el.dataset['changes'] = String(Number(el.dataset['changes']) + 1); + }); + }); + for (const value of ['two', 'three']) { + const target = bar.locator(`ion-tab-button[tab="${value}"]`); + const rect = (await target.boundingBox())!; + await page.touchscreen.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); + await expect(target).toHaveClass(/tab-selected/); + } + await expect(bar).toHaveAttribute('data-changes', '2'); + await expect(bar).not.toHaveClass(/ios26-animated/); + await expect(bar).toHaveAttribute('data-changes', '2'); + }); + + test('drag selects its destination once and keeps Ionic selection during the drag', async ({ page }) => { + const bar = await appendFixtureBar(page, 'ios26-tab-drag'); + expect((await registerViaTabs(page, '#ios26-tab-drag')).registered).toBe(true); + await bar.evaluate((el) => { + el.dataset['changes'] = '0'; + el.addEventListener('ionTabButtonClick', () => { + el.dataset['changes'] = String(Number(el.dataset['changes']) + 1); + }); + }); + const first = bar.locator('ion-tab-button[tab="one"]'); + const last = bar.locator('ion-tab-button[tab="three"]'); + const a = (await first.boundingBox())!; + const b = (await last.boundingBox())!; + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2 - 100, { steps: 12 }); + await expect(last).toHaveClass(/ios26-tab-preview/); + await expect + .poll(async () => { + const lens = (await clones(page).last().boundingBox())!; + return lens.x + lens.width / 2; + }) + .toBeGreaterThan(b.x); + await expect(first).toHaveClass(/tab-selected/); + await expect(bar).toHaveAttribute('data-changes', '0'); + await page.mouse.up(); + await expect(last).toHaveClass(/tab-selected/); + await expect(bar).toHaveAttribute('data-changes', '1'); + await expect(bar).not.toHaveClass(/ios26-animated/); + await expect(bar).toHaveAttribute('data-changes', '1'); + }); + + test('body lens uses viewport coordinates after scrolling and disappears on further scroll', async ({ page }) => { + const bar = await appendFixtureBar(page, 'ios26-tab-scroll'); + expect((await registerViaTabs(page, '#ios26-tab-scroll')).registered).toBe(true); + // ion-app establishes a containing block. This fixture specifically tests a + // viewport-fixed bar, so move it outside that transformed app ancestor. + await bar.evaluate((el) => document.body.append(el)); + await page.evaluate(() => { + document.documentElement.style.cssText += ';overflow:auto;height:auto;'; + document.body.style.cssText += ';overflow:auto;height:2000px;position:static;'; + window.scrollTo(0, 300); + }); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + await page.evaluate(() => new Promise((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

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid 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
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Two-line inset list items

\n

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.

\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 &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

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

\n

This 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
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .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
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

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

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The 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
\n
Preview
\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\n

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

\n

Search-bar toolbars

\n

Add .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
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .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
\n
Preview
\niOS 26 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n

ios26-disabled remains supported as a deprecated alias for ios-theme-disabled.

\n'; + '

Special markup and classes

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid 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
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Alert and action-sheet actions

\n

For 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:

\n
const buttons = [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'OK\', role: \'preferred\', handler: () => confirm() },\n];
\n\n

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

\n

Two-line inset list items

\n

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.

\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 &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

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

\n

This 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
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .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
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

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

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The 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
\n
Preview
\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\n

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

\n

Search-bar toolbars

\n

Add .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
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .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
\n
Preview
\niOS 26 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n

ios26-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;