diff --git a/.gitignore b/.gitignore index 8d10bdc4..05902379 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ dist/ +/integration/ionic-conference-app/ # Generated by @rdlabo/ionic-theme-utils before Sass compilation. /src/styles/utils/structured-list.scss diff --git a/README.md b/README.md index e55599a9..4b7aa3cd 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,10 @@ Use this markup to preview the inset grouped list look. For the list structure t ## Optional setups +### Support iPhone Duo without the iOS 27 theme + +Import only `@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css`, add `ios-theme-vertical-bars` to `ion-app`, and call `enableVerticalControlArea()` from `@rdlabo/ionic-theme-ios27/vertical-bars` at startup. This uses Ionic's standard appearance outside the Vertical Control Area; on supported iOS, only controls moved into that area are projected natively. To read only the hinge posture — for example to adapt an `ion-split-pane` — subscribe to the plugin's `deviceLayoutChange` event without starting any runtime. See [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) for the complete setup. + ### Use only the iOS 27 theme Install only `@rdlabo/ionic-theme-ios27` and import its styles unconditionally in your global stylesheet: @@ -207,6 +211,7 @@ For Ionic 9 Angular, import `isPlatform` and `provideIonicAngular` from `@ionic/ - [ESLint](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/eslint) — check list structure with ESLint rules. - [Features](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/features) — CSS variables, Liquid Glass, selective imports, and dark mode. - [Native UI Shell (Experimental)](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell) — project supported Ionic controls, text, and icons into UIKit. +- [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) — vertical system rail, hinge posture, and split-pane layout; usable without the theme or the shell. - [Animation](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/animation) — tab, segment, and searchable effects. - [Migration from iOS 26](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/migration) — upgrade an existing app, including stylesheet, class, and CSS variable changes. - [iOS 26 migration history](https://docs.rdlabo.dev/projects/ionic-theme-ios26/docs/migration) — earlier major-version changes for the previous package. diff --git a/demo/e2e/adaptive-tab-bar.spec.ts b/demo/e2e/adaptive-tab-bar.spec.ts new file mode 100644 index 00000000..c9722edd --- /dev/null +++ b/demo/e2e/adaptive-tab-bar.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; + +const enableVerticalBars = async (page: import('@playwright/test').Page) => { + const toggle = page.getByText('iPhone Duo Mode').locator('..').locator('ion-toggle'); + await toggle.click(); + await expect(page.locator('ion-app')).toHaveClass(/ios-theme-vertical-bars/); +}; + +test('verticalBars mode moves tabs into the right rail and reveals labels while dragging', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index'); + await enableVerticalBars(page); + + const bar = page.locator('#tab-bar-bottom'); + const buttons = bar.locator('ion-tab-button'); + await expect.poll(async () => (await bar.boundingBox())?.x).toBeGreaterThan(620); + const barBox = (await bar.boundingBox())!; + expect(barBox.width).toBeCloseTo(50, 0); + await expect(buttons.first().locator('ion-label')).toHaveCSS('position', 'absolute'); + await expect(buttons.nth(1).locator('ion-label')).toHaveCSS('position', 'absolute'); + + const selectedBox = (await buttons.first().boundingBox())!; + const targetBox = (await buttons.nth(1).boundingBox())!; + await page.mouse.move(selectedBox.x + selectedBox.width / 2, selectedBox.y + selectedBox.height / 2); + await page.mouse.down(); + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 4 }); + await expect.poll(() => buttons.first().evaluate((element) => element.matches(':active'))).toBe(true); + + await expect(page).toHaveScreenshot('vertical-bars-tab-drag-labels.png', { animations: 'disabled' }); + await page.mouse.up(); + + const overlayDirection = await page.evaluate(async () => { + const menu = document.querySelector('ion-menu')!; + const tabs = document.createElement('ion-tabs'); + const overlayBar = document.createElement('ion-tab-bar'); + overlayBar.mode = 'ios'; + tabs.append(overlayBar); + menu.append(tabs); + await customElements.whenDefined('ion-tab-bar'); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + const direction = getComputedStyle(overlayBar).flexDirection; + tabs.remove(); + return direction; + }); + expect(overlayDirection).toBe('row'); +}); + +test('manual classes place the Web rail on the left in Chrome', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index'); + await page.locator('ion-app').evaluate((root) => root.classList.add('ios-theme-vertical-bars', 'ios-theme-vertical-bars-left')); + + const bar = page.locator('#tab-bar-bottom'); + await expect.poll(async () => (await bar.boundingBox())?.x).toBeLessThan(35); + await expect(bar).toHaveScreenshot('vertical-bars-left.png', { animations: 'disabled' }); +}); diff --git a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png new file mode 100644 index 00000000..d3b4f8dd Binary files /dev/null and b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png differ diff --git a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-tab-drag-labels.png b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-tab-drag-labels.png new file mode 100644 index 00000000..6c47da04 Binary files /dev/null and b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-tab-drag-labels.png differ diff --git a/demo/e2e/animation.spec.ts b/demo/e2e/animation.spec.ts index 229bed64..3f398472 100644 --- a/demo/e2e/animation.spec.ts +++ b/demo/e2e/animation.spec.ts @@ -1,10 +1,11 @@ import { expect, test, type Page } from '@playwright/test'; +import type { AnimationCall } from './native-shell-mock'; const installAnimationObserver = async (page: Page) => { await page.addInitScript(() => { const originalAnimate = Element.prototype.animate; - (window as any).__IONIC_ANIMATION_CALLS__ = []; + document.__IONIC_ANIMATION_CALLS__ = []; Element.prototype.animate = function (keyframes, options) { const animation = originalAnimate.call(this, keyframes, options); const properties = Array.isArray(keyframes) @@ -16,7 +17,7 @@ const installAnimationObserver = async (page: Page) => { : Object.keys(keyframes ?? {}).filter((key) => !['offset', 'easing', 'composite'].includes(key)); const duration = typeof options === 'number' ? options : typeof options?.duration === 'number' ? options.duration : 0; - (window as any).__IONIC_ANIMATION_CALLS__.push({ + document.__IONIC_ANIMATION_CALLS__!.push({ animation, duration, properties, @@ -30,13 +31,13 @@ const installAnimationObserver = async (page: Page) => { }; const clearAnimationCalls = async (page: Page) => { - await page.evaluate(() => ((window as any).__IONIC_ANIMATION_CALLS__ = [])); + await page.evaluate(() => (document.__IONIC_ANIMATION_CALLS__ = [])); }; const hasRunningAnimation = (page: Page, targetClass?: string) => { return page.evaluate((expectedClass) => { - return (window as any).__IONIC_ANIMATION_CALLS__.some( - (call: { animation: Animation; duration: number; properties: string[]; targetClass: string }) => + return document.__IONIC_ANIMATION_CALLS__!.some( + (call: AnimationCall) => call.animation.playState === 'running' && call.duration > 0 && call.properties.includes('transform') && @@ -47,8 +48,8 @@ const hasRunningAnimation = (page: Page, targetClass?: string) => { const hasAnimationCall = (page: Page, targetClass: string) => { return page.evaluate((expectedClass) => { - return (window as any).__IONIC_ANIMATION_CALLS__.some( - (call: { duration: number; properties: string[]; targetClass: string }) => + return document.__IONIC_ANIMATION_CALLS__!.some( + (call: AnimationCall) => call.duration > 0 && call.properties.includes('transform') && call.targetClass.split(' ').includes(expectedClass), ); }, targetClass); @@ -104,6 +105,17 @@ test.describe('Animation Tests', () => { await expect(shade).toHaveCount(0); }); + test('verticalBars page transition keeps its shade outside the control rail', async ({ page }) => { + await page.goto('/main/index', { waitUntil: 'networkidle' }); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await page.getByRole('button', { name: 'button', exact: true }).click(); + const shade = page.locator('.ios-transition-shade'); + await expect(shade).toBeVisible(); + await page.evaluate(() => document.getAnimations().forEach((animation) => animation.pause())); + await expect(shade).toHaveCSS('clip-path', 'inset(0px 80px 0px 0px)'); + await page.evaluate(() => document.getAnimations().forEach((animation) => animation.play())); + }); + test('runs and completes the iOS popover animations', async ({ page }) => { await page.goto('/main/index/popover', { waitUntil: 'networkidle' }); await clearAnimationCalls(page); diff --git a/demo/e2e/card-opt-out.spec.ts b/demo/e2e/card-opt-out.spec.ts index 175564f8..9b4d0083 100644 --- a/demo/e2e/card-opt-out.spec.ts +++ b/demo/e2e/card-opt-out.spec.ts @@ -18,7 +18,11 @@ for (const disabled of ['ios-theme-disabled', 'ios26-disabled']) { return card; }; const cards = [create(false), create(true)]; - await Promise.all(cards.flatMap((card) => [card, ...card.querySelectorAll('*')]).map((el: any) => el.componentOnReady?.())); + await Promise.all( + cards + .flatMap((card) => [card, ...card.querySelectorAll('*')]) + .map((el: Element & { componentOnReady?: () => Promise }) => el.componentOnReady?.()), + ); const result = cards.map((card) => [...card.querySelectorAll(tag)].map((el) => { const s = getComputedStyle(el); diff --git a/demo/e2e/glass.spec.ts b/demo/e2e/glass.spec.ts deleted file mode 100644 index 65d2bd1e..00000000 --- a/demo/e2e/glass.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('fullscreen page chrome is transparent or frosted according to translucent', async ({ page }) => { - await page.goto('/main/album'); - const content = page.locator('app-album-page > ion-content'); - const header = page.locator('app-album-page > ion-header'); - const footer = page.locator('app-album-page > ion-footer'); - const headerToolbar = header.locator(':scope > ion-toolbar'); - const footerToolbar = footer.locator(':scope > ion-toolbar'); - - await expect(header).toHaveClass(/header-translucent/); - await expect(headerToolbar).toHaveCSS('--background', 'transparent'); - expect(await header.evaluate((element) => getComputedStyle(element, '::after').backdropFilter)).toBe('blur(2px)'); - - await expect(footer).toHaveClass(/footer-translucent/); - expect(await footer.evaluate((element) => getComputedStyle(element, '::before').backdropFilter)).toBe('blur(8px)'); - expect(await footer.evaluate((element) => getComputedStyle(element, '::before').backgroundColor)).not.toBe('rgba(0, 0, 0, 0)'); - expect(await footer.evaluate((element) => getComputedStyle(element, '::after').backdropFilter)).toBe('blur(2px)'); - await expect(footerToolbar).toHaveCSS('--border-width', /^0?\.5px 0 0$/); - - await footer.evaluate((element: HTMLIonFooterElement) => (element.translucent = false)); - await header.evaluate((element: HTMLIonHeaderElement) => (element.translucent = false)); - await expect(header).not.toHaveClass(/header-translucent/); - await expect(footer).not.toHaveClass(/footer-translucent/); - await expect(headerToolbar).toHaveCSS('--background', 'transparent'); - await expect(footerToolbar).toHaveCSS('--background', 'transparent'); - expect(await header.evaluate((element) => getComputedStyle(element, '::after').content)).toBe('none'); - expect(await footer.evaluate((element) => getComputedStyle(element, '::after').content)).toBe('none'); - - await content.evaluate((element: HTMLIonContentElement) => (element.fullscreen = false)); - await expect(content).not.toHaveClass(/content-fullscreen/); - await expect(headerToolbar).not.toHaveCSS('--background', 'transparent'); - await expect(footerToolbar).not.toHaveCSS('--background', 'transparent'); - - await footer.evaluate((element: HTMLIonFooterElement) => (element.translucent = true)); - await expect(footer).toHaveClass(/footer-translucent/); - expect(await footer.evaluate((element) => getComputedStyle(element, '::after').content)).toBe('none'); -}); - -test('dark resting glass applies the directional rim to standalone and grouped controls', async ({ page }) => { - await page.goto('/main/index/native-ui-shell'); - await page.evaluate(() => document.documentElement.classList.add('ion-palette-dark')); - const button = page.locator('app-native-ui-shell ion-button[type="submit"]'); - const surface = button.locator('[part="native"]'); - await expect(surface).toHaveCSS('border-left-color', 'rgba(0, 0, 0, 0)'); - await expect(surface).toHaveCSS('border-right-color', 'rgba(0, 0, 0, 0)'); - await expect(surface).toHaveCSS('background-color', 'rgba(62, 62, 62, 0.5)'); - const back = page.locator('app-native-ui-shell ion-back-button').locator('[part="native"]'); - await expect(back).toHaveCSS('border-left-color', 'rgba(0, 0, 0, 0)'); - await expect(back).toHaveCSS('background-color', 'rgba(62, 62, 62, 0.5)'); - const group = page.locator('app-native-ui-shell ion-buttons[data-glass-group]'); - await expect(group).toHaveCSS('border-left-color', 'rgba(0, 0, 0, 0)'); - await expect(group).toHaveCSS('border-right-color', 'rgba(0, 0, 0, 0)'); -}); - -for (const dark of [false, true]) { - test(`Glass preserves Ionic background and shadow overrides in ${dark ? 'dark' : 'light'} mode`, async ({ page }) => { - await page.goto('/main/index/native-ui-shell'); - await page.evaluate((enabled) => document.documentElement.classList.toggle('ion-palette-dark', enabled), dark); - const tabs = page.locator('ion-tab-bar'); - await expect(tabs).toHaveClass(/hydrated/); - const tabFrame = await tabs.boundingBox(); - await tabs.evaluate((el) => el.style.setProperty('--background', 'rgb(12, 34, 56)')); - expect(await tabs.evaluate((el) => getComputedStyle(el, '::before').backgroundColor)).toBe('rgb(12, 34, 56)'); - expect(await tabs.boundingBox()).toEqual(tabFrame); - for (const selector of ['app-native-ui-shell ion-button[type="submit"]', 'app-native-ui-shell ion-back-button']) { - const control = page.locator(selector); - await control.evaluate((el) => { - el.style.setProperty('--background', 'rgb(12, 34, 56)'); - el.style.setProperty('--box-shadow', '0 0 2px rgb(12, 34, 56)'); - }); - await expect(control.locator('[part="native"]')).toHaveCSS('background-color', 'rgb(12, 34, 56)'); - await expect(control.locator('[part="native"]')).toHaveCSS('box-shadow', 'rgb(12, 34, 56) 0px 0px 2px 0px'); - } - await page.goto('/main/index/floating-action-button-fixed'); - await page.evaluate((enabled) => document.documentElement.classList.toggle('ion-palette-dark', enabled), dark); - const fab = page.locator('ion-fab-button').first(); - await fab.evaluate((el) => el.style.setProperty('--box-shadow', 'none')); - await expect(fab.locator('[part="native"]')).toHaveCSS('box-shadow', 'none'); - }); -} diff --git a/demo/e2e/native-shell-mock.ts b/demo/e2e/native-shell-mock.ts new file mode 100644 index 00000000..8c943987 --- /dev/null +++ b/demo/e2e/native-shell-mock.ts @@ -0,0 +1,77 @@ +import type { NativeUIShellHandle, NativeUIShellSuspension, ShellControl, ShellSnapshot } from '../../src/native/definitions'; + +/** + * State carried by the fake native plugin that `mockNative` installs. + * + * The init script intercepts the `window.Capacitor` assignment during + * `@capacitor/core` initialisation and patches `registerPlugin`, so the app's + * `registerPlugin('IonicNativeUIShell')` receives the mock + * object itself. Specs reach it the same way the application does: + * + * Capacitor.registerPlugin('IonicNativeUIShell') + */ +export interface ShellMockCore { + /** update()/clear() snapshots received by the plugin. */ + updates: ShellSnapshot[]; + /** Monotonic sequence counter for emitted events. */ + sequence: number; + /** Listener registry mirroring Capacitor's WebPlugin semantics. */ + listeners: Record void)[]>; + /** Emits a native event to every registered listener. */ + notifyListeners(eventName: string, data: unknown): void; +} + +export interface AnimationCall { + animation: Animation; + duration: number; + properties: string[]; + targetClass: string; + targetTag: string; +} + +/** Test probes attached to the demo's element instead of `window`. */ +export interface TestAppElement extends HTMLElement { + nativeUIShell?: NativeUIShellHandle; + verticalBarsLease?: NativeUIShellSuspension; + verticalBarsBackButtonReads?: number; + verticalBarsBackCloneMoved?: boolean; + fabClicks?: number; + fabRetired?: boolean; + menuExtra?: boolean; + retiredSearchbar?: HTMLIonSearchbarElement; + searchEvents?: [string, unknown][]; + placement?: { + element: Element; + parent: HTMLElement | null; + next: ChildNode | null; + slot: string; + zone: Element; + }; + searchPlacement?: { + page: HTMLElement; + footer: Element; + fab: Element; + buttons: Element; + toolbar: Element; + }; +} + +declare global { + const Capacitor: { + getPlatform(): string; + registerPlugin(name: string, implementations?: Record): T; + Plugins: Record; + [key: string]: unknown; + }; + + interface Window { + Capacitor?: typeof Capacitor; + CapacitorCustomPlatform?: { name: string }; + } + + interface Document { + /** Set by e2e init scripts to force the app into its animation-free mode. */ + IONIC_E2E_TESTING?: boolean; + __IONIC_ANIMATION_CALLS__?: AnimationCall[]; + } +} diff --git a/demo/e2e/native-ui-shell-edge.spec.ts b/demo/e2e/native-ui-shell-edge.spec.ts index e1a49b06..70d795de 100644 --- a/demo/e2e/native-ui-shell-edge.spec.ts +++ b/demo/e2e/native-ui-shell-edge.spec.ts @@ -1,78 +1,128 @@ import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; import { buildSync } from 'esbuild'; +import type { NativeUIShellComponent, ShellControl, ShellItem, ShellSnapshot } from '../../src/native/definitions'; +import type { ShellMockCore } from './native-shell-mock'; + +interface GeometryEvidence { + id: string; + frame: number; + visibleFrame: number; + backReleases: number; + retirements: { frame: number; visibleFrame: number; visible: boolean; backProjected: boolean }[]; +} + +interface ShellMock extends ShellMockCore { + rejectKind?: NativeUIShellComponent; + /** Controls UIKit would still show; a rejected kind is omitted like the real host. */ + rendered: Map; + holdAcknowledgement: boolean; + /** Resolves the held update() acknowledgement. */ + acknowledge?: () => void; + onUpdate?: (snapshot: { controls: ShellControl[] }) => void; + evidence?: GeometryEvidence; +} const mockNative = async (page: Page) => { await page.addInitScript(() => { - const state = { - updates: [] as any[], + const mock = { + updates: [] as ShellSnapshot[], sequence: 0, - rejectKind: undefined as string | undefined, - rendered: new Map(), + rejectKind: undefined as NativeUIShellComponent | undefined, + rendered: new Map(), holdAcknowledgement: false, acknowledge: undefined as (() => void) | undefined, - onUpdate: undefined as ((snapshot: any) => void) | undefined, - activate: (_event: any) => {}, - metrics: (_event: any) => {}, + onUpdate: undefined as ((snapshot: { controls: ShellControl[] }) => void) | undefined, + evidence: undefined as GeometryEvidence | undefined, + listeners: {} as Record void)[]>, + addListener(eventName: string, callback: (event: never) => void) { + const listeners = (this.listeners[eventName] ??= []); + listeners.push(callback); + return Promise.resolve({ remove: async () => listeners.splice(listeners.indexOf(callback), 1) }); + }, + async removeAllListeners() { + this.listeners = {}; + }, + notifyListeners(eventName: string, data: unknown) { + for (const listener of this.listeners[eventName] ?? []) listener(data as never); + }, + async configure() { + return { supported: true }; + }, + async getWebViewMetrics() { + return { radius: 0 }; + }, + async getDeviceLayout() { + return { + placement: { edge: 'right' as const, inset: 84 }, + hingeStatus: 'unavailable' as const, + webViewMetrics: { radius: 0 }, + }; + }, + async startDeviceLayoutMonitoring() {}, + async stopDeviceLayoutMonitoring() {}, + async update(options: ShellSnapshot) { + this.updates.push(options); + return this.settle(options); + }, + async clear(options: { revision: number }) { + this.updates.push({ revision: options.revision, viewportWidth: 0, controls: [] }); + return this.settle({ ...options, controls: [] }); + }, + async settle(options: { revision?: number; controls: ShellControl[] }) { + const controls = options.controls; + const rejectedControls = controls.filter((control) => control.kind === this.rejectKind).map((control) => control.id); + const ids = new Set(controls.map((control) => control.id)); + for (const id of this.rendered.keys()) if (!ids.has(id)) this.rendered.delete(id); + for (const control of controls) { + // UIKit retains an existing cover until a later snapshot retires its id. + if (control.kind !== this.rejectKind) this.rendered.set(control.id, control); + } + this.onUpdate?.({ ...options, controls }); + if (this.holdAcknowledgement) await new Promise((resolve) => (this.acknowledge = resolve)); + return { revision: options.revision, rejectedControls }; + }, }; - Object.assign(window, { - __nativeUIShell: state, - CapacitorCustomPlatform: { name: 'ios' }, - Capacitor: { - PluginHeaders: [ - { - name: 'IonicNativeUIShell', - methods: [ - { name: 'configure', rtype: 'promise' }, - { name: 'getWebViewMetrics', rtype: 'promise' }, - { name: 'update', rtype: 'promise' }, - { name: 'clear', rtype: 'promise' }, - { name: 'addListener' }, - { name: 'removeListener' }, - ], - }, - ], - nativePromise: async (_plugin: string, method: string, options: any) => { - if (method === 'configure') return { supported: true }; - if (method === 'getWebViewMetrics') return { radius: 0 }; - state.updates.push(method === 'clear' ? { ...options, controls: [] } : options); - const controls = method === 'clear' ? [] : options.controls; - const rejectedControls = controls.filter((control: any) => control.kind === state.rejectKind).map((control: any) => control.id); - const ids = new Set(controls.map((control: any) => control.id)); - for (const id of state.rendered.keys()) if (!ids.has(id)) state.rendered.delete(id); - for (const control of controls) { - // UIKit retains an existing cover until a later snapshot retires its id. - if (control.kind !== state.rejectKind) state.rendered.set(control.id, control); - } - state.onUpdate?.({ ...options, controls }); - if (state.holdAcknowledgement) await new Promise((resolve) => (state.acknowledge = resolve)); - return { - revision: options.revision, - rejectedControls, - }; - }, - nativeCallback: (_plugin: string, method: string, options: any, callback: (event: any) => void) => { - if (method === 'addListener' && options.eventName === 'activate') state.activate = callback; - if (method === 'addListener' && options.eventName === 'webViewMetricsChange') state.metrics = callback; - return 'shell-listener'; - }, + + window.CapacitorCustomPlatform = { name: 'ios' }; + // Substitute the mock as the plugin implementation when @capacitor/core + // initialises its global, before the app registers 'IonicNativeUIShell'. + let capacitor: { registerPlugin: (name: string, implementations?: Record) => unknown } | undefined; + Object.defineProperty(window, 'Capacitor', { + configurable: true, + get: () => capacitor, + set: (instance) => { + const registerPlugin = instance.registerPlugin; + instance.registerPlugin = (name: string, implementations?: Record) => + name === 'IonicNativeUIShell' ? mock : registerPlugin(name, implementations); + capacitor = instance; }, }); }); }; const latestControl = (page: Page, kind: string) => - page.evaluate((kind) => (window as any).__nativeUIShell.updates.at(-1)?.controls.find((c: any) => c.kind === kind), kind); + page.evaluate( + (kind) => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((c: ShellControl) => c.kind === kind), + kind, + ); const activate = (page: Page, kind: string, label?: string, count = 1) => page.evaluate( ({ kind, label, count }) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const control = snapshot.controls.find((c: any) => c.kind === kind); - const item = label ? control.items.find((i: any) => i.label === label) : control.items[0]; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const item = label + ? snapshot.controls + .filter((c: ShellControl) => c.kind === kind) + .flatMap((c: ShellControl) => c.items) + .find((i) => i.label === label) + : snapshot.controls.find((c: ShellControl) => c.kind === kind)!.items[0]; for (let index = 0; index < count; index++) { - state.activate({ id: item.id, revision: snapshot.revision, sequence: ++state.sequence }); + state.notifyListeners('activate', { id: item!.id, revision: snapshot.revision, sequence: ++state.sequence }); } }, { kind, label, count }, @@ -82,14 +132,14 @@ const tabState = (page: Page) => page.locator('ion-tab-bar').evaluate((bar) => ({ frame: bar.getBoundingClientRect().toJSON(), role: bar.getAttribute('role'), - items: Array.from(bar.querySelectorAll('ion-tab-button:not(.ion-cloned-element)')).map((button: any) => ({ - label: button.querySelector('ion-label')?.textContent.trim(), + items: Array.from(bar.querySelectorAll('ion-tab-button:not(.ion-cloned-element)')).map((button) => ({ + label: button.querySelector('ion-label')?.textContent?.trim(), frame: button.getBoundingClientRect().toJSON(), selected: button.selected, disabled: button.disabled, - role: button.shadowRoot.querySelector('[part=native]')?.getAttribute('role'), - ariaSelected: button.shadowRoot.querySelector('[part=native]')?.getAttribute('aria-selected'), - color: getComputedStyle(button.shadowRoot.querySelector('[part=native]')).color, + role: button.shadowRoot?.querySelector('[part=native]')?.getAttribute('role'), + ariaSelected: button.shadowRoot?.querySelector('[part=native]')?.getAttribute('aria-selected'), + color: getComputedStyle(button.shadowRoot!.querySelector('[part=native]')!).color, })), })); @@ -128,18 +178,20 @@ for (const direction of ['ltr', 'rtl']) { await expect(page).toHaveURL(`/main/${label.toLowerCase()}`); await expect(web).toHaveURL(`/main/${label.toLowerCase()}`); await expect - .poll(async () => (await latestControl(page, 'ion-tab-bar'))?.items.filter((i: any) => i.selected).map((i: any) => i.label)) + .poll(async () => + (await latestControl(page, 'ion-tab-bar'))?.items.filter((i: ShellItem) => i.selected).map((i: ShellItem) => i.label), + ) .toEqual([label]); // A real Web click runs the press animation; compare after both settle. await web.locator('ion-tab-bar').evaluate(async (bar) => { await Promise.all(bar.getAnimations({ subtree: true }).map((animation) => animation.finished.catch(() => {}))); }); await expect.poll(async () => JSON.stringify(await tabState(page)) === JSON.stringify(await tabState(web))).toBe(true); - const native = await latestControl(page, 'ion-tab-bar'); + const native = (await latestControl(page, 'ion-tab-bar'))!; const actual = await tabState(page); const reference = await tabState(web); expect(actual).toEqual(reference); - for (const key of ['x', 'y', 'width', 'height']) expect(native[key]).toBeCloseTo(reference.frame[key], 1); + for (const key of ['x', 'y', 'width', 'height'] as const) expect(native[key]).toBeCloseTo(reference.frame[key], 1); expect(native.rtl).toBe(direction === 'rtl'); for (const [index, item] of native.items.entries()) { const dom = reference.items[index]; @@ -198,11 +250,11 @@ for (const fixed of [false, true]) { } await expect.poll(async () => JSON.stringify(await appearance(page)) === JSON.stringify(await appearance(web))).toBe(true); if (fixed) { - const control = await latestControl(page, 'ion-back-button'); + const control = (await latestControl(page, 'ion-back-button'))!; const original = await appearance(web); expect(control.items[0].label).toBe(original.label); expect(control.items[0].accessibilityLabel).toBe(original.accessibilityLabel); - for (const key of ['x', 'y', 'width', 'height']) expect(control[key]).toBeCloseTo(original.frame[key], 1); + for (const key of ['x', 'y', 'width', 'height'] as const) expect(control[key]).toBeCloseTo(original.frame[key], 1); } }; const clickBack = async (p: Page, count = 1) => { @@ -212,11 +264,14 @@ for (const fixed of [false, true]) { for (let i = 0; i < count; i++) button.click(); }, count); }; + // With fixed native headers the toolbar button is projected, so tap it through the native activation path. + const clickPush = (p: Page) => + p === page && fixed ? activate(p, 'ion-button', 'Push') : p.getByRole('button', { name: 'Push', exact: true }).click(); for (let cycle = 0; cycle < 3; cycle++) { await Promise.all([page, web].map((p) => p.getByRole('button', { name: 'button', exact: true }).click())); await Promise.all([page, web].map((p) => expect(p).toHaveURL('/main/index/button'))); await checkBack(); - await Promise.all([page, web].map((p) => p.getByRole('button', { name: 'Push', exact: true }).click())); + await Promise.all([page, web].map((p) => clickPush(p))); await Promise.all([page, web].map((p) => expect(p).toHaveURL('/main/index/action-sheet'))); await checkBack(); await Promise.all([page, web].map((p) => clickBack(p, cycle === 2 ? 3 : 1))); @@ -237,10 +292,10 @@ test('RTL native back icon mirrors the Web chevron', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); await expect(page.locator('app-native-ui-shell ion-back-button')).toHaveAttribute('data-native-ui-shell', ''); - const ltr = (await latestControl(page, 'ion-back-button')).items[0].icon; + const ltr = (await latestControl(page, 'ion-back-button'))!.items[0].icon!; await page.evaluate(() => (document.documentElement.dir = 'rtl')); await expect.poll(async () => (await latestControl(page, 'ion-back-button'))?.rtl).toBe(true); - const rtl = (await latestControl(page, 'ion-back-button')).items[0].icon; + const rtl = (await latestControl(page, 'ion-back-button'))!.items[0].icon!; expect(rtl).not.toBe(ltr); const error = await page.evaluate( async ({ ltr, rtl }) => { @@ -327,27 +382,27 @@ test('native geometry rejection paints Web before retirement and retries after l await expect(bar).toHaveAttribute('data-native-ui-shell', ''); await expect(back).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); const bar = document.querySelector('ion-tab-bar')!; const back = document.querySelector('app-native-ui-shell ion-back-button')!; - const id = state.updates.at(-1).controls.find((control: any) => control.kind === 'ion-tab-bar').id; + const id = state.updates.at(-1)!.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')!.id; state.evidence = { id, frame: 0, visibleFrame: -1, backReleases: 0, retirements: [] }; const tick = () => { - state.evidence.frame++; - if (!back.hasAttribute('data-native-ui-shell')) state.evidence.backReleases++; + state.evidence!.frame++; + if (!back.hasAttribute('data-native-ui-shell')) state.evidence!.backReleases++; requestAnimationFrame(tick); }; requestAnimationFrame(tick); bar.addEventListener('nativeUIShellChange', () => { if (!bar.hasAttribute('data-native-ui-shell') && getComputedStyle(bar).visibility === 'visible') { - state.evidence.visibleFrame = state.evidence.frame; + state.evidence!.visibleFrame = state.evidence!.frame; } }); - state.onUpdate = (snapshot: any) => { - if (!snapshot.controls.some((control: any) => control.id === id)) { - state.evidence.retirements.push({ - frame: state.evidence.frame, - visibleFrame: state.evidence.visibleFrame, + state.onUpdate = (snapshot: { controls: ShellControl[] }) => { + if (!snapshot.controls.some((control: ShellControl) => control.id === id)) { + state.evidence!.retirements.push({ + frame: state.evidence!.frame, + visibleFrame: state.evidence!.visibleFrame, visible: getComputedStyle(bar).visibility === 'visible', backProjected: back.hasAttribute('data-native-ui-shell'), }); @@ -357,31 +412,31 @@ test('native geometry rejection paints Web before retirement and retries after l state.holdAcknowledgement = true; }); await bar.evaluate((bar) => (bar.style.width = '300px')); - await expect.poll(() => page.evaluate(() => !!(window as any).__nativeUIShell.acknowledge)).toBe(true); + await expect.poll(() => page.evaluate(() => !!Capacitor.registerPlugin('IonicNativeUIShell').acknowledge)).toBe(true); await expect(bar).toHaveAttribute('data-native-ui-shell', ''); expect( await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - return state.rendered.has(state.evidence.id); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + return state.rendered.has(state.evidence!.id); }), ).toBe(true); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); state.holdAcknowledgement = false; - state.acknowledge(); + state.acknowledge!(); }); await expect(bar).not.toHaveAttribute('data-native-ui-shell'); await expect(bar).toHaveCSS('visibility', 'visible'); await expect(bar).not.toHaveAttribute('aria-hidden'); await expect(back).toHaveAttribute('data-native-ui-shell', ''); await expect.poll(() => latestControl(page, 'ion-tab-bar')).toBeUndefined(); - const retirement = await page.evaluate(() => (window as any).__nativeUIShell.evidence.retirements[0]); + const retirement = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').evidence!.retirements[0]); expect(retirement.visible).toBe(true); expect(retirement.backProjected).toBe(true); expect(retirement.visibleFrame).toBeGreaterThanOrEqual(0); expect(retirement.frame - retirement.visibleFrame).toBeGreaterThanOrEqual(2); const counts = await page.evaluate(async () => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); // Let the restoration's mutation notification settle, then re-read identical data. for (let frame = 0; frame < 4; frame++) await new Promise(requestAnimationFrame); const before = state.updates.length; @@ -392,24 +447,24 @@ test('native geometry rejection paints Web before retirement and retries after l return { before, after: state.updates.length }; }); expect(counts.after).toBe(counts.before); - await page.evaluate(() => ((window as any).__nativeUIShell.rejectKind = undefined)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').rejectKind = undefined)); // Recovery is driven by a new DOM layout, without recreating the runtime. await bar.evaluate((bar) => bar.style.removeProperty('width')); await expect(bar).toHaveAttribute('data-native-ui-shell', ''); await expect(back).toHaveAttribute('data-native-ui-shell', ''); - expect(await page.evaluate(() => (window as any).__nativeUIShell.updates.length)).toBeGreaterThan(counts.after); + expect(await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length)).toBeGreaterThan(counts.after); await page.evaluate(() => { - (window as any).__nativeUIShell.rejectKind = 'ion-tab-bar'; + Capacitor.registerPlugin('IonicNativeUIShell').rejectKind = 'ion-tab-bar'; window.dispatchEvent(new Event('nativeUIShellRefresh')); }); await expect(bar).not.toHaveAttribute('data-native-ui-shell'); await expect.poll(() => latestControl(page, 'ion-tab-bar')).toBeUndefined(); await page.evaluate(() => { - (window as any).__nativeUIShell.rejectKind = undefined; + Capacitor.registerPlugin('IonicNativeUIShell').rejectKind = undefined; window.dispatchEvent(new Event('nativeUIShellRefresh')); }); await expect(bar).toHaveAttribute('data-native-ui-shell', ''); - expect(await page.evaluate(() => (window as any).__nativeUIShell.evidence.backReleases)).toBe(0); + expect(await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').evidence!.backReleases)).toBe(0); }); test('native refresh during a pending rejection retries after the stale acknowledgement', async ({ page }) => { @@ -421,27 +476,27 @@ test('native refresh during a pending rejection retries after the stale acknowle await expect(bar).toHaveAttribute('data-native-ui-shell', ''); await expect(back).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); state.rejectKind = 'ion-tab-bar'; state.holdAcknowledgement = true; }); await bar.evaluate((bar) => (bar.style.width = '300px')); - await expect.poll(() => page.evaluate(() => !!(window as any).__nativeUIShell.acknowledge)).toBe(true); + await expect.poll(() => page.evaluate(() => !!Capacitor.registerPlugin('IonicNativeUIShell').acknowledge)).toBe(true); const pending = await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; // UIKit now has usable geometry, but its earlier rejection has not reached JS. state.rejectKind = undefined; window.dispatchEvent(new Event('nativeUIShellRefresh')); state.holdAcknowledgement = false; - state.acknowledge(); - return { revision: snapshot.revision, id: snapshot.controls.find((control: any) => control.kind === 'ion-tab-bar').id }; + state.acknowledge!(); + return { revision: snapshot.revision, id: snapshot.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')!.id }; }); await expect .poll(() => page.evaluate(({ revision, id }) => { - const snapshot = (window as any).__nativeUIShell.updates.at(-1); - return snapshot.revision > revision && snapshot.controls.some((control: any) => control.id === id); + const snapshot = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!; + return snapshot.revision > revision && snapshot.controls.some((control: ShellControl) => control.id === id); }, pending), ) .toBe(true); @@ -473,7 +528,7 @@ for (const path of ['/main/index', '/main/album']) { } await page.locator('ion-tab-bar').evaluate((bar, variant) => { bar.setAttribute('color', 'light'); - const buttons = [...bar.querySelectorAll('ion-tab-button:not(.ion-cloned-element)')]; + const buttons = [...bar.querySelectorAll('ion-tab-button:not(.ion-cloned-element)')]; buttons.forEach((button) => button.setAttribute('aria-label', button.textContent!.trim())); if (variant === 'icon-only') buttons.forEach((button) => button.querySelector('ion-label')?.remove()); if (variant === 'label-only') buttons.forEach((button) => button.querySelector('ion-icon')?.remove()); @@ -494,14 +549,14 @@ for (const path of ['/main/index', '/main/album']) { }, variant); const current = () => latestControl(page, 'ion-tab-bar'); if (variant === 'icon-only') { - await expect.poll(async () => (await current())?.items.every((item: any) => item.label === '' && !!item.icon)).toBe(true); - expect((await current()).items.every((item: any) => !!item.accessibilityLabel)).toBe(true); + await expect.poll(async () => (await current())?.items.every((item: ShellItem) => item.label === '' && !!item.icon)).toBe(true); + expect((await current())!.items.every((item: ShellItem) => !!item.accessibilityLabel)).toBe(true); } else if (variant === 'label-only') { - await expect.poll(async () => (await current())?.items.every((item: any) => !!item.label && !item.icon)).toBe(true); + await expect.poll(async () => (await current())?.items.every((item: ShellItem) => !!item.label && !item.icon)).toBe(true); } else { await expect(page.locator('ion-badge').first()).toHaveClass(/hydrated/); await expect - .poll(async () => (await current())?.items.map((item: any) => item.badge?.value ?? null)) + .poll(async () => (await current())?.items.map((item: ShellItem) => item.badge?.value ?? null)) .toEqual([null, null, '47', null]); // Ionic iOS hides an empty badge; a visible empty badge maps to a notification dot. await page @@ -517,7 +572,7 @@ for (const path of ['/main/index', '/main/album']) { return { color: style.backgroundColor, textColor: style.color }; }), ); - const snapshot = await current(); + const snapshot = (await current())!; expect(colors.every(({ color }) => color !== 'transparent' && color !== 'rgba(0, 0, 0, 0)')).toBe(true); expect(snapshot.items[0].badge).toEqual({ value: '', ...colors[0] }); expect(snapshot.items[2].badge).toEqual({ value: '47', ...colors[1] }); @@ -545,10 +600,10 @@ for (const path of ['/main/index', '/main/album']) { await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-shell', ''); // Selection stays owned by the actual Ionic tab button, including icon-only tabs. await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const item = snapshot.controls.find((control: any) => control.kind === 'ion-tab-bar').items[1]; - state.activate({ id: item.id, revision: snapshot.revision, sequence: ++state.sequence }); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const item = snapshot.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')!.items[1]; + state.notifyListeners('activate', { id: item.id, revision: snapshot.revision, sequence: ++state.sequence }); }); await expect(page).toHaveURL('/main/docs'); await expect.poll(async () => (await current())?.items[1].selected).toBe(true); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 0338a496..a3852449 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -3,82 +3,125 @@ import type { Page } from '@playwright/test'; import { compile, NodePackageImporter } from 'sass'; import { resolve } from 'node:path'; import * as overlayTypes from '../src/app/overlay-types'; +import type { ShellControl, ShellItem, ShellSnapshot } from '../../src/native/definitions'; +import type { ShellMockCore, TestAppElement } from './native-shell-mock'; const importer = new NodePackageImporter(resolve(__dirname, '../../')); -const mockNative = async (page: Page, fail = false) => { - await page.addInitScript((fail) => { - const state = { - updates: [] as any[], +interface ShellMock extends ShellMockCore { + delay: number; + hang: boolean; + rejectInactiveSearch: boolean; + rejectAllSearch: boolean; + rejectControlLabel: string; + configuredWith?: { verticalBarsOnly?: boolean }; + lateSearch?: () => void; + tabRetirements: number; + retirementDetails: { path: string; tabs: string }[]; +} + +const mockNative = async (page: Page, fail = false, verticalBars = true) => { + const script = ([fail, verticalBars]: readonly [boolean, boolean]) => { + const mock = { + updates: [] as ShellSnapshot[], sequence: 0, delay: 0, hang: false, rejectInactiveSearch: false, rejectAllSearch: false, - activate: (_event: any) => {}, - search: (_event: any) => {}, - metrics: (_event: any) => {}, + rejectControlLabel: '', + configuredWith: undefined as { verticalBarsOnly?: boolean } | undefined, + lateSearch: undefined as (() => void) | undefined, + tabRetirements: 0, + retirementDetails: [] as { path: string; tabs: string }[], + listeners: {} as Record void)[]>, + addListener(eventName: string, callback: (event: never) => void) { + const listeners = (this.listeners[eventName] ??= []); + listeners.push(callback); + return Promise.resolve({ remove: async () => listeners.splice(listeners.indexOf(callback), 1) }); + }, + async removeAllListeners() { + this.listeners = {}; + }, + notifyListeners(eventName: string, data: unknown) { + for (const listener of this.listeners[eventName] ?? []) listener(data as never); + }, + async configure(options: { verticalBarsOnly?: boolean }) { + this.configuredWith = options; + return { supported: true, verticalBars }; + }, + async getWebViewMetrics() { + return { radius: 0 }; + }, + async getDeviceLayout() { + return { + placement: { edge: verticalBars ? ('right' as const) : null, inset: verticalBars ? 84 : 0 }, + hingeStatus: 'unavailable' as const, + webViewMetrics: { radius: 0 }, + }; + }, + async startDeviceLayoutMonitoring() {}, + async stopDeviceLayoutMonitoring() {}, + async update(options: ShellSnapshot) { + this.updates.push(options); + if (this.hang) await new Promise(() => {}); + return this.rejections(options); + }, + async clear(options: { revision: number }) { + this.updates.push({ revision: options.revision, viewportWidth: 0, controls: [] }); + return this.rejections(options); + }, + async rejections(options: { revision?: number; controls?: ShellControl[] }) { + if (this.delay) await new Promise((resolve) => setTimeout(resolve, this.delay)); + if (fail && options.controls?.length) throw new Error('Test native failure'); + return { + revision: options.revision, + rejectedSearches: + this.rejectInactiveSearch || this.rejectAllSearch + ? options.controls + ?.filter((control) => control.search && (this.rejectAllSearch || control.search.available === false)) + .map((control) => control.id) + : [], + rejectedControls: this.rejectControlLabel + ? options.controls + ?.filter((control) => control.items.some((item) => item.accessibilityLabel === this.rejectControlLabel)) + .map((control) => control.id) + : [], + }; + }, }; - Object.assign(window, { - __nativeUIShell: state, - CapacitorCustomPlatform: { name: 'ios' }, - Capacitor: { - PluginHeaders: [ - { - name: 'IonicNativeUIShell', - methods: [ - { name: 'configure', rtype: 'promise' }, - { name: 'getWebViewMetrics', rtype: 'promise' }, - { name: 'update', rtype: 'promise' }, - { name: 'clear', rtype: 'promise' }, - { name: 'addListener' }, - { name: 'removeListener' }, - ], - }, - ], - nativePromise: async (_plugin: string, method: string, options: any) => { - if (method === 'configure') return { supported: true }; - if (method === 'getWebViewMetrics') return { radius: 0 }; - state.updates.push(method === 'clear' ? { ...options, controls: [] } : options); - if (state.hang && method === 'update') await new Promise(() => {}); - if (state.delay) await new Promise((resolve) => setTimeout(resolve, state.delay)); - if (fail && method === 'update' && options.controls.length) throw new Error('Test native failure'); - return { - revision: options.revision, - rejectedSearches: - state.rejectInactiveSearch || state.rejectAllSearch - ? options.controls - ?.filter((control: any) => control.search && (state.rejectAllSearch || control.search.available === false)) - .map((control: any) => control.id) - : [], - }; - }, - nativeCallback: (_plugin: string, method: string, options: any, callback: (event: any) => void) => { - if (method === 'addListener') { - if (options.eventName === 'search') state.search = callback; - else if (options.eventName === 'activate') state.activate = callback; - else if (options.eventName === 'webViewMetricsChange') state.metrics = callback; - } - return 'shell-listener'; - }, + + window.CapacitorCustomPlatform = { name: 'ios' }; + // Substitute the mock as the plugin implementation when @capacitor/core + // initialises its global, before the app registers 'IonicNativeUIShell'. + let capacitor: { registerPlugin: (name: string, implementations?: Record) => unknown } | undefined; + Object.defineProperty(window, 'Capacitor', { + configurable: true, + get: () => capacitor, + set: (instance) => { + const registerPlugin = instance.registerPlugin; + instance.registerPlugin = (name: string, implementations?: Record) => + name === 'IonicNativeUIShell' ? mock : registerPlugin(name, implementations); + capacitor = instance; }, }); - }, fail); + }; + await page.addInitScript(script, [fail, verticalBars] as const); }; const activate = (page: Page, label: string, duplicate = false) => page.evaluate( ({ label, duplicate }) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.findLast((value: any) => - value.controls.some((control: any) => control.items.some((item: any) => item.label === label || item.accessibilityLabel === label)), - ); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.findLast((value) => + value.controls.some((control) => control.items.some((item) => item.label === label || item.accessibilityLabel === label)), + )!; const item = snapshot.controls - .flatMap((control: any) => control.items) - .find((item: any) => item.label === label || item.accessibilityLabel === label); + .flatMap((control) => control.items) + .find((item) => item.label === label || item.accessibilityLabel === label)!; const event = { id: item.id, revision: snapshot.revision, sequence: ++state.sequence }; - state.activate(event); - if (duplicate) state.activate(event); + state.notifyListeners('activate', event); + if (duplicate) state.notifyListeners('activate', event); }, { label, duplicate }, ); @@ -93,22 +136,22 @@ test('FAB keeps a complete native batch across staggered lists and measures each const state = () => page.evaluate(() => { const fab = document.querySelector('ion-fab[horizontal=center]')!; - const controls = (window as any).__nativeUIShell.updates.at(-1).controls; - return controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 7); + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.controls; + return controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7)!; }); const closed = await state(); - expect(closed.items.filter((i: any) => i.visible)).toHaveLength(1); - expect(closed.items.every((i: any) => i.icon && i.closeIcon)).toBe(true); + expect(closed.items.filter((i: ShellItem) => i.visible)).toHaveLength(1); + expect(closed.items.every((i: ShellItem) => i.icon && i.closeIcon)).toBe(true); await fab.locator(':scope > ion-fab-button').evaluate((b: HTMLIonFabButtonElement) => b.click()); - await expect.poll(async () => (await state()).items.filter((i: any) => i.visible).length).toBe(7); + await expect.poll(async () => (await state()).items.filter((i: ShellItem) => i.visible).length).toBe(7); const opened = await state(); - expect(opened.items.map((i: any) => i.id)).toEqual(closed.items.map((i: any) => i.id)); + expect(opened.items.map((i: ShellItem) => i.id)).toEqual(closed.items.map((i: ShellItem) => i.id)); const rects = await fab.locator('ion-fab-button').evaluateAll((buttons) => buttons.map((b) => b.getBoundingClientRect().toJSON())); for (let index = 0; index < rects.length; index++) { - for (const key of ['x', 'y', 'width', 'height']) expect(opened.items[index][key]).toBeCloseTo(rects[index][key], 1); + for (const key of ['x', 'y', 'width', 'height'] as const) expect(opened.items[index][key]).toBeCloseTo(rects[index][key], 1); } await fab.evaluate((f: HTMLIonFabElement) => f.close()); - await expect.poll(async () => (await state()).items.filter((i: any) => i.visible).length).toBe(1); + await expect.poll(async () => (await state()).items.filter((i: ShellItem) => i.visible).length).toBe(1); await expect(fab).toHaveAttribute('data-native-ui-shell', ''); }); @@ -118,26 +161,27 @@ test('FAB activation stays with Ionic and rejects hidden, disabled and duplicate const fab = page.locator('ion-fab[horizontal=center]'); await expect(fab).toHaveAttribute('data-native-ui-shell', ''); await fab.evaluate((element) => { - (window as any).__fabClicks = 0; - element.querySelector('ion-fab-list ion-fab-button')!.addEventListener('click', () => (window as any).__fabClicks++); + const app = document.querySelector('ion-app') as TestAppElement; + app.fabClicks = 0; + element.querySelector('ion-fab-list ion-fab-button')!.addEventListener('click', () => (app.fabClicks = (app.fabClicks ?? 0) + 1)); }); await activate(page, 'Up action'); - expect(await page.evaluate(() => (window as any).__fabClicks)).toBe(0); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).fabClicks)).toBe(0); await activate(page, 'Center FAB actions', true); await expect.poll(() => fab.evaluate((element: HTMLIonFabElement) => element.activated)).toBe(true); await expect .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 7) - .items.filter((i: any) => i.visible).length, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7)! + .items.filter((i: ShellItem) => i.visible).length, ), ) .toBe(7); await activate(page, 'Up action', true); - expect(await page.evaluate(() => (window as any).__fabClicks)).toBe(1); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).fabClicks)).toBe(1); await expect.poll(() => fab.evaluate((element: HTMLIonFabElement) => element.activated)).toBe(false); await fab.locator(':scope > ion-fab-button').evaluate((element: HTMLIonFabButtonElement) => (element.disabled = true)); await activate(page, 'Center FAB actions'); @@ -168,7 +212,7 @@ test('FAB reverses during stagger, keeps its cover, and ignores late hidden acti expect(evidence.covered).toBe(true); expect(evidence.active).toBe(false); await expect(fab).toHaveAttribute('data-native-ui-shell', ''); - await page.evaluate(() => ((window as any).__nativeUIShell.delay = 120)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').delay = 120)); await fab.evaluate((element: HTMLIonFabElement) => (element.activated = true)); await fab.evaluate((element: HTMLIonFabElement) => { element.style.display = 'none'; @@ -202,8 +246,9 @@ test('FAB restores excluded groups and follows icon, list and theme changes', as .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 6)?.items - .length, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 6)?.items.length, ), ) .toBe(6); @@ -270,10 +315,10 @@ test('FAB uses measured positions after RTL and viewport changes', async ({ page await expect .poll(() => page.evaluate(() => { - const control = (window as any).__nativeUIShell.updates - .at(-1) - .controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 7); - if (!control || control.rtl !== (document.documentElement.dir === 'rtl') || control.items.some((i: any) => !i.visible)) + const control = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7); + if (!control || control.rtl !== (document.documentElement.dir === 'rtl') || control.items.some((i: ShellItem) => !i.visible)) return false; const buttons = Array.from(document.querySelector('ion-fab[horizontal=center]')!.querySelectorAll('ion-fab-button')); return buttons.every((b, index) => { @@ -286,10 +331,11 @@ test('FAB uses measured positions after RTL and viewport changes', async ({ page .toBe(true); const icon = await page.evaluate( () => - (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 7).items[2] - .icon, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7)!.items[2].icon, ); - if (direction === 'rtl') rtlIcon = icon; + if (direction === 'rtl') rtlIcon = icon!; else expect(icon).not.toBe(rtlIcon); } } @@ -310,9 +356,11 @@ test('unsupported search morph releases and restores a native fixed-slot FAB int await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((c: any) => c.kind === 'ion-fab' && c.items.some((i: any) => i.accessibilityLabel === 'Search albums')), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some( + (c: ShellControl) => c.kind === 'ion-fab' && c.items.some((i: ShellItem) => i.accessibilityLabel === 'Search albums'), + ), ), ) .toBe(true); @@ -324,6 +372,454 @@ test('unsupported search morph releases and restores a native fixed-slot FAB int await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-shell', ''); }); +test('verticalBars tabs request native adaptive rail placement', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index'); + const app = page.locator('ion-app'); + const bar = page.locator('ion-tab-bar'); + await expect(bar).toHaveAttribute('data-native-ui-shell', ''); + + await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + await expect(bar).toHaveAttribute('data-native-ui-shell', ''); + await expect + .poll(() => + page.evaluate(() => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.kind === 'ion-tab-bar' && control.placement === 'vertical-bars'), + ), + ) + .toBe(true); + + await app.evaluate((element) => element.classList.remove('ios-theme-vertical-bars')); + await expect(bar).toHaveAttribute('data-native-ui-shell', ''); + await expect(bar).toHaveClass(/ios27-enable-gesture/); +}); + +test('standalone Vertical Control Area never snapshots ordinary Native UI Shell controls', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell?verticalBarsOnly=1'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + await expect + .poll(() => + page.evaluate(() => { + const { configuredWith, updates } = Capacitor.registerPlugin('IonicNativeUIShell'); + const controls = updates.at(-1)?.controls ?? []; + return ( + configuredWith?.verticalBarsOnly === true && + controls.length > 0 && + updates + .flatMap((update: ShellSnapshot) => update.controls) + .every((control: ShellControl) => control.placement === 'vertical-bars') + ); + }), + ) + .toBe(true); +}); + +test('verticalBars back navigation and toolbar slots request native rail placement', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + const app = page.locator('ion-app'); + const source = page.locator('app-native-ui-shell ion-back-button').first(); + const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await source.evaluate((element) => { + const second = document.createElement('ion-back-button'); + second.setAttribute('default-href', '/main/index'); + second.setAttribute('data-second-back', ''); + element.closest('ion-toolbar')?.append(second); + }); + const secondBack = page.locator('ion-back-button[data-second-back]'); + await expect(secondBack).toHaveClass(/hydrated/); + + await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + await expect(projection).toHaveCount(0); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect(secondBack).toBeVisible(); + await expect(secondBack).not.toHaveAttribute('data-native-ui-shell', ''); + await expect + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.filter((control: ShellControl) => control.kind === 'ion-back-button').length, + ), + ) + .toBe(1); + await expect + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some( + (control: ShellControl) => + control.kind === 'ion-back-button' && control.placement === 'vertical-bars' && control.toolbarSlot === undefined, + ) && + !Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.label === 'Cancel')), + ), + ) + .toBe(true); + await expect + .poll(() => + page.evaluate(() => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some( + (control: ShellControl) => + control.kind === 'ion-button' && + control.placement === 'vertical-bars' && + control.toolbarSlot === 'end' && + control.items.some((item: ShellItem) => item.accessibilityLabel === 'Save'), + ), + ), + ) + .toBe(true); + await expect + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((control: ShellControl) => control.kind === 'ion-menu-button')?.toolbarSlot, + ), + ) + .toBe('start'); + + await app.evaluate((element) => element.classList.remove('ios-theme-vertical-bars')); + await expect(projection).toHaveCount(0); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); +}); + +test('native verticalBars toolbar returns with Index after a pushed page', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const hasIndexActions = () => + page.evaluate(() => { + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.controls; + return controls.some( + (control: ShellControl) => + control.placement === 'vertical-bars' && control.items.some((item: ShellItem) => item.accessibilityLabel === 'GitHub'), + ); + }); + await expect.poll(hasIndexActions).toBe(true); + await page.getByRole('button', { name: 'button', exact: true }).click(); + await expect(page).toHaveURL(/\/main\/index\/button$/); + await expect + .poll(() => + page.evaluate(() => { + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + return !!snapshot.controls.find((control: ShellControl) => control.kind === 'ion-back-button')?.items[0]; + }), + ) + .toBe(true); + await page.evaluate(() => { + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const back = snapshot.controls.find((control: ShellControl) => control.kind === 'ion-back-button')!.items[0]; + state.notifyListeners('activate', { id: back.id, revision: snapshot.revision, sequence: ++state.sequence }); + }); + await expect(page).toHaveURL(/\/main\/index$/); + await expect.poll(hasIndexActions).toBe(true); +}); + +test('native verticalBars actions follow WillEnter and stay enabled during navigation', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const source = page.locator('app-native-ui-shell ion-back-button'); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + const routedPage = page.locator('app-native-ui-shell.ion-page'); + + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true }))); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + await page.waitForTimeout(150); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + + await routedPage.evaluate((element) => { + element.classList.add('ion-page-invisible'); + element.style.pointerEvents = 'none'; + element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true })); + }); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + const saveDisabled = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.findLast((update: ShellSnapshot) => + update.controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.accessibilityLabel === 'Save')), + )! + .controls.flatMap((control: ShellControl) => control.items) + .find((item: ShellItem) => item.accessibilityLabel === 'Save')!.disabled, + ); + await expect.poll(saveDisabled).toBe(false); + await page.addStyleTag({ content: '.author-no-pointer { pointer-events: none }' }); + const save = page.locator('app-native-ui-shell ion-button[type=submit]'); + for (const target of [save, save.locator('xpath=..'), save.locator('xpath=../..')]) { + await target.evaluate((element) => element.classList.add('author-no-pointer')); + await expect.poll(saveDisabled).toBe(true); + await target.evaluate((element) => element.classList.remove('author-no-pointer')); + await expect.poll(saveDisabled).toBe(false); + } + await activate(page, 'Save'); + await expect(page.locator('app-native-ui-shell [data-save-count]')).toHaveText('1'); + await routedPage.evaluate((element) => { + element.classList.remove('ion-page-invisible'); + element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true })); + }); + await expect.poll(saveDisabled).toBe(false); + await save.evaluate((element) => element.classList.add('author-no-pointer')); + await expect.poll(saveDisabled).toBe(true); + await save.evaluate((element) => element.classList.remove('author-no-pointer')); + await routedPage.evaluate((element) => (element.style.pointerEvents = '')); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeVerticalBarsTransitionCanceled', { bubbles: true }))); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); +}); + +test('verticalBars toolbar sources are hidden before ownership and restored with their lifecycle', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const source = page.locator('app-native-ui-shell ion-button[type=submit]'); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect(source).toHaveClass(/ios-theme-native-ui-shell-prehidden/); + + const prehidden = await source.evaluate((element) => { + element.removeAttribute('data-native-ui-shell'); + const style = getComputedStyle(element); + return { position: style.position, visibility: style.visibility }; + }); + expect(prehidden).toEqual({ position: 'absolute', visibility: 'hidden' }); + + const earlyBack = await page.locator('app-native-ui-shell ion-back-button').evaluate((element) => { + element.removeAttribute('data-native-ui-shell'); + element.classList.remove('ios-theme-native-ui-shell-prehidden'); + const style = getComputedStyle(element); + return { position: style.position, visibility: style.visibility }; + }); + expect(earlyBack).toEqual({ position: 'absolute', visibility: 'hidden' }); + + const customBack = page.locator('app-native-ui-shell ion-back-button'); + await customBack.evaluate((element: HTMLIonBackButtonElement) => (element.color = 'primary')); + await expect(customBack).not.toHaveAttribute('data-native-ui-shell', ''); + await expect(customBack).toHaveCSS('visibility', 'visible'); + const lateAction = page.locator('app-native-ui-shell ion-button[data-late-action]'); + await page + .locator('app-native-ui-shell ion-toolbar') + .first() + .evaluate((toolbar) => { + const button = document.createElement('ion-button'); + button.setAttribute('data-late-action', ''); + button.setAttribute('aria-label', 'Late action'); + button.innerHTML = ''; + toolbar.append(button); + }); + await expect(lateAction).toHaveAttribute('data-native-ui-shell', ''); + + await page.addStyleTag({ content: '.author-hidden { visibility: hidden !important }' }); + await source.evaluate((element) => element.classList.add('author-hidden')); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + await source.evaluate((element) => element.classList.remove('author-hidden')); + await expect(source).toHaveAttribute('data-native-ui-shell-vertical-bars-member', ''); + await expect(source).toHaveClass(/ios-theme-native-ui-shell-prehidden/); + + await source.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); + await expect(source).toHaveCSS('visibility', 'visible'); + + const lateBackInitially = await page.locator('ion-app').evaluate((root) => { + const header = document.createElement('ion-header'); + header.innerHTML = ''; + root.append(header); + const back = header.querySelector('ion-back-button')!; + back.setAttribute('data-late-back', ''); + return back.classList.contains('ios-theme-vertical-bars-back-web-owned'); + }); + expect(lateBackInitially).toBe(false); + const lateBack = page.locator('ion-back-button[data-late-back]'); + await expect(lateBack).toHaveClass(/ios-theme-native-ui-shell-prehidden/); + await expect(lateBack).toHaveAttribute('data-native-ui-shell', ''); + await page.waitForTimeout(1600); // Past the unhydrated readiness timeout. + await expect(lateBack).toHaveAttribute('data-native-ui-shell', ''); + await expect(lateBack).not.toHaveClass(/ios-theme-vertical-bars-back-web-owned/); + await lateBack.evaluate((element) => element.closest('ion-header')?.remove()); + + await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.destroy()); + await expect(source).not.toHaveClass(/ios-theme-native-ui-shell-prehidden/); + await expect(source).toHaveCSS('visibility', 'visible'); +}); + +test('rejected verticalBars control returns to an operable Web source', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const save = page.locator('app-native-ui-shell ion-button[type=submit]'); + await expect(save).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + Capacitor.registerPlugin('IonicNativeUIShell').rejectControlLabel = 'Save'; + window.dispatchEvent(new Event('nativeUIShellRefresh')); + }); + await expect(save).not.toHaveAttribute('data-native-ui-shell', ''); + await expect(save).not.toHaveClass(/ios-theme-native-ui-shell-prehidden/); + await expect(save).toHaveCSS('visibility', 'visible'); + await save.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); +}); + +test('verticalBars rail remains native while its Ionic menu is open', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-menu-button').evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = false)); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const menu = page.locator('ion-menu'); + const menuSource = page.locator('app-native-ui-shell ion-menu-button').locator('..'); + const backSource = page.locator('app-native-ui-shell ion-back-button'); + const saveSource = page.locator('app-native-ui-shell ion-button[type=submit]'); + const cancelSource = page.locator('app-native-ui-shell ion-button').filter({ hasText: 'Cancel' }); + const actionGroup = page.locator('app-native-ui-shell ion-buttons[data-glass-group]'); + const tabs = page.locator('ion-tab-bar'); + + await actionGroup.evaluate((element) => { + const cancel = document.createElement('ion-button') as HTMLIonButtonElement; + cancel.classList.add('ios'); + cancel.fill = 'clear'; + cancel.textContent = 'Cancel mixed action'; + element.prepend(cancel); + }); + const mixedCancel = actionGroup.locator('ion-button').filter({ hasText: 'Cancel mixed action' }); + const mixedIcons = actionGroup.locator('ion-button').filter({ has: page.locator('ion-icon') }); + await expect(actionGroup).not.toHaveAttribute('data-native-ui-shell'); + await expect(mixedIcons.nth(0)).toHaveAttribute('data-native-ui-shell', ''); + await expect(mixedIcons.nth(1)).toHaveAttribute('data-native-ui-shell', ''); + await expect(mixedCancel).not.toHaveAttribute('data-native-ui-shell'); + await expect(mixedCancel).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => { + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)?.controls ?? []; + const demoActions = (control: ShellControl) => + control.items.filter((item: ShellItem) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)).length; + return { + groups: controls.filter((control: ShellControl) => control.kind === 'ion-buttons' && demoActions(control) === 2).length, + individuals: controls.filter((control: ShellControl) => control.kind === 'ion-button' && demoActions(control) > 0).length, + }; + }), + ) + .toEqual({ groups: 1, individuals: 0 }); + const nativeDisabled = () => + page.evaluate(() => { + const items = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.flatMap((control: ShellControl) => control.items); + return { + save: items.find((item: ShellItem) => item.accessibilityLabel === 'Save')?.disabled, + actions: items + .filter((item: ShellItem) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)) + .map((item: ShellItem) => item.disabled), + }; + }); + await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); + await activate(page, 'menu'); + await expect(menu).toHaveClass(/show-menu/); + await expect.poll(nativeDisabled).toEqual({ save: false, actions: [true, true] }); + await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); + await expect.poll(nativeDisabled).toEqual({ save: false, actions: [false, false] }); + await mixedCancel.evaluate((element) => element.remove()); + await expect(actionGroup).toHaveAttribute('data-native-ui-shell', ''); + await saveSource.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); + await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); + await expect.poll(nativeDisabled).toEqual({ save: true, actions: [true, true] }); + await expect(menu).toHaveClass(/show-menu/); + await activate(page, 'Save'); + await expect(page.locator('[data-save-count]')).toHaveText('0'); + await saveSource.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); + await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); + await expect.poll(nativeDisabled).toEqual({ save: false, actions: [false, false] }); + await expect.poll(() => cancelSource.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe('none'); + for (const source of [menuSource, backSource, saveSource, tabs]) await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect(cancelSource).toBeVisible(); + await activate(page, 'Save'); + await expect(page.locator('[data-save-count]')).toHaveText('1'); + // Placement is selected when this page enters, not re-evaluated from later content. + await cancelSource.evaluate((element) => { + element.setAttribute('data-morphed-cancel', ''); + element.textContent = ''; + const icon = document.createElement('ion-icon'); + icon.setAttribute('slot', 'icon-only'); + icon.setAttribute('name', 'checkmark'); + element.append(icon); + }); + const morphedCancel = page.locator('app-native-ui-shell ion-button[data-morphed-cancel]'); + await expect(morphedCancel).not.toHaveAttribute('data-native-ui-shell', ''); + await expect(morphedCancel).not.toHaveClass(/ios-theme-native-ui-shell-prehidden/); + await expect(morphedCancel).toBeVisible(); +}); + +test('verticalBars controls stay operable on Web when the native side rail is unavailable', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockNative(page, false, false); + await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-menu-button').evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = false)); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + + const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + const menuProjection = page.locator('ion-app > ion-menu-button.ios-theme-vertical-bars-toolbar-projection'); + const saveProjection = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Save]'); + await expect(projection).toBeVisible(); + await expect(menuProjection).toBeVisible(); + await expect(saveProjection).toBeVisible(); + await expect(page.locator('ion-tab-bar')).toBeVisible(); + await page.addStyleTag({ content: '.author-hidden { visibility: hidden !important }' }); + const saveSource = page.locator('app-native-ui-shell ion-button[type=submit]'); + await saveSource.evaluate((element) => element.classList.add('author-hidden')); + await expect(saveProjection).toHaveCount(0); + await saveSource.evaluate((element) => element.classList.remove('author-hidden')); + await expect(saveProjection).toBeVisible(); + await expect + .poll(() => page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().projected)) + .toBeGreaterThan(0); + await expect + .poll(() => + page.evaluate(() => + Capacitor.registerPlugin('IonicNativeUIShell').updates.every((snapshot: ShellSnapshot) => + snapshot.controls.every((control: ShellControl) => control.placement !== 'vertical-bars'), + ), + ), + ) + .toBe(true); + + await menuProjection.click(); + await expect(page.locator('ion-menu')).toHaveClass(/show-menu/); + await expect(projection).toBeVisible(); + await expect(menuProjection).toBeVisible(); + await expect(saveProjection).toBeVisible(); + await expect(page.locator('ion-tab-bar')).toBeVisible(); + await saveProjection.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); + + await page.evaluate(() => { + const outlet = document.querySelector('ion-tabs ion-router-outlet')!; + (document.querySelector('ion-app') as TestAppElement).verticalBarsBackCloneMoved = false; + new MutationObserver(() => { + if (outlet.querySelector(':scope > ion-back-button.ion-cloned-element')) + (document.querySelector('ion-app') as TestAppElement).verticalBarsBackCloneMoved = true; + }).observe(outlet, { childList: true }); + }); + await projection.click(); + await expect(page).toHaveURL(/\/main\/index$/); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).verticalBarsBackCloneMoved)).toBe(false); +}); + test('native click preserves external form submit, disabled, and duplicate protection', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); @@ -336,22 +832,29 @@ test('native click preserves external form submit, disabled, and duplicate prote await expect(button).toHaveJSProperty('disabled', true); await activate(page, 'Save'); await expect(page.locator('[data-save-count]')).toHaveText('1'); - await expect.poll(() => page.evaluate(() => (window as any).nativeUIShell.getStatus().projected)).toBeGreaterThan(0); + await expect + .poll(() => page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().projected)) + .toBeGreaterThan(0); await page.waitForTimeout(200); - const count = await page.evaluate(() => (window as any).__nativeUIShell.updates.length); + const count = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length); await page.waitForTimeout(250); - expect(await page.evaluate(() => (window as any).__nativeUIShell.updates.length)).toBe(count); + expect(await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length)).toBe(count); }); -test('ancestor display/theme aliases and non-glass fills restore Web', async ({ page }) => { +test('ancestor display, element opt-out aliases and non-glass fills restore Web', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); const button = page.locator('app-native-ui-shell ion-button[type=submit]'); + const toolbar = button.locator('xpath=ancestor::ion-toolbar[1]'); await expect(button).toHaveAttribute('data-native-ui-shell', ''); - for (const toggle of ['Parent hidden', 'Theme disabled']) { - await page.getByRole('button', { name: `${toggle}: false`, exact: true }).click(); + await page.getByRole('button', { name: 'Parent hidden: false', exact: true }).click(); + await expect(button).not.toHaveAttribute('data-native-ui-shell'); + await page.getByRole('button', { name: 'Parent hidden: true', exact: true }).click(); + await expect(button).toHaveAttribute('data-native-ui-shell', ''); + for (const name of ['ios-theme-disabled', 'ios26-disabled', 'ionic-theme-disabled']) { + await toolbar.evaluate((element, name) => element.classList.add(name), name); await expect(button).not.toHaveAttribute('data-native-ui-shell'); - await page.getByRole('button', { name: `${toggle}: true`, exact: true }).click(); + await toolbar.evaluate((element, name) => element.classList.remove(name), name); await expect(button).toHaveAttribute('data-native-ui-shell', ''); } for (const name of ['ios-theme-disabled', 'ios26-disabled', 'ionic-theme-disabled']) { @@ -360,6 +863,11 @@ test('ancestor display/theme aliases and non-glass fills restore Web', async ({ await button.evaluate((element, name) => element.classList.remove(name), name); await expect(button).toHaveAttribute('data-native-ui-shell', ''); } + await page + .locator('app-native-ui-shell ion-buttons[slot=end] ion-button') + .filter({ hasText: 'Cancel' }) + .evaluate((element) => element.remove()); + await expect(button).toHaveAttribute('data-native-ui-shell', ''); for (const fill of ['clear', 'solid', 'outline']) { await page.getByRole('button', { name: `fill: ${fill}`, exact: true }).click(); await expect(button).not.toHaveAttribute('data-native-ui-shell'); @@ -437,7 +945,13 @@ test('shell opt-out cannot be bypassed by searchable tab integration', async ({ await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); const tabs = page.locator('ion-tab-bar'); - const search = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); + const search = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); await expect.poll(async () => (await search())?.trigger.accessibilityLabel).toBe('Search'); for (const target of [ footer, @@ -467,22 +981,28 @@ test('shell opt-out retires active search without losing input or accepting late await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); const bar = footer.locator('ion-searchbar'); - const search = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); + const search = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - state.activate({ - id: snapshot.controls.find((c: any) => c.search).search.trigger.id, + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + state.notifyListeners('activate', { + id: snapshot.controls.find((c: ShellControl) => c.search)!.search!.trigger.id, revision: snapshot.revision, sequence: ++state.sequence, }); }); await expect.poll(async () => (await search())?.active).toBe(true); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; const event = { id: search.id, valueVersion: search.valueVersion, @@ -491,14 +1011,14 @@ test('shell opt-out retires active search without losing input or accepting late value: '日本', composing: false, }; - state.search({ ...event, sequence: ++state.sequence }); - state.lateSearch = () => state.search({ ...event, value: 'stale', sequence: ++state.sequence }); + state.notifyListeners('search', { ...event, sequence: ++state.sequence }); + state.lateSearch = () => state.notifyListeners('search', { ...event, value: 'stale', sequence: ++state.sequence }); }); await expect(bar).toHaveJSProperty('value', '日本'); await bar.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); await expect(footer).not.toHaveAttribute('data-native-ui-shell'); await expect.poll(search).toBeUndefined(); - await page.evaluate(() => (window as any).__nativeUIShell.lateSearch()); + await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').lateSearch!()); await expect(bar).toHaveJSProperty('value', '日本'); await bar.evaluate((element) => element.classList.remove('ios-theme-shell-disabled')); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); @@ -530,7 +1050,7 @@ test('modal suspension, tab hiding and destroy restore ownership', async ({ page await page.getByRole('button', { name: 'Open modal', exact: true }).click(); await expect(page.locator('[data-native-ui-shell]')).toHaveCount(0); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1))) + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!)) .toMatchObject({ controls: [], transitionDuration: 0 }); await page.getByRole('button', { name: 'Close modal', exact: true }).click(); await expect(button).toHaveAttribute('data-native-ui-shell', ''); @@ -538,7 +1058,7 @@ test('modal suspension, tab hiding and destroy restore ownership', async ({ page await expect(tabs).not.toHaveAttribute('data-native-ui-shell'); await tabs.evaluate((element) => element.style.removeProperty('display')); await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); - await page.evaluate(() => (window as any).nativeUIShell.destroy()); + await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.destroy()); await expect(page.locator('[data-native-ui-shell]')).toHaveCount(0); await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.locator('[data-save-count]')).toHaveText('1'); @@ -549,7 +1069,7 @@ test('delayed response cannot reclaim a hidden source', async ({ page }) => { await page.goto('/main/index/native-ui-shell'); const button = page.locator('app-native-ui-shell ion-button[type=submit]'); await expect(button).toHaveAttribute('data-native-ui-shell', ''); - await page.evaluate(() => ((window as any).__nativeUIShell.delay = 150)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').delay = 150)); await button.evaluate((element) => (element.querySelector('[data-label]')!.textContent = '変更')); await page.getByRole('button', { name: 'Parent hidden: false', exact: true }).click(); await expect(button).not.toHaveAttribute('data-native-ui-shell'); @@ -558,9 +1078,9 @@ test('delayed response cannot reclaim a hidden source', async ({ page }) => { await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((control: any) => control.items.some((item: any) => item.label === '変更')), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.label === '変更')), ), ) .toBe(false); @@ -573,23 +1093,33 @@ test('native refresh during a pending acknowledgement resends unchanged controls await page.locator('ion-tab-bar').evaluate((element) => element.remove()); await expect(page.locator('app-native-ui-shell ion-button[type=submit]')).toHaveAttribute('data-native-ui-shell', ''); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.some((c: any) => c.kind === 'ion-tab-bar'))) + .poll(() => + page.evaluate(() => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((c: ShellControl) => c.kind === 'ion-tab-bar'), + ), + ) .toBe(false); const revision = await page.evaluate(() => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); state.delay = 500; window.dispatchEvent(new Event('nativeUIShellRefresh')); - return state.updates.at(-1).revision; + return state.updates.at(-1)!.revision; }); - await expect.poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).revision)).toBeGreaterThan(revision); + await expect + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.revision)) + .toBeGreaterThan(revision); const pending = await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; window.dispatchEvent(new Event('nativeUIShellRefresh')); return snapshot; }); - await expect.poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).revision)).toBeGreaterThan(pending.revision); - const refreshed = await page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1)); + await expect + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.revision)) + .toBeGreaterThan(pending.revision); + const refreshed = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!); expect(refreshed.controls).toEqual(pending.controls); await expect(page.locator('app-native-ui-shell ion-button[type=submit]')).toHaveAttribute('data-native-ui-shell', ''); }); @@ -597,7 +1127,9 @@ test('native refresh during a pending acknowledgement resends unchanged controls test('native failure leaves Web form usable', async ({ page }) => { await mockNative(page, true); await page.goto('/main/index/native-ui-shell'); - await expect.poll(() => page.evaluate(() => (window as any).nativeUIShell?.getStatus().state)).toBe('stopped'); + await expect + .poll(() => page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell?.getStatus().state)) + .toBe('web'); await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.locator('[data-save-count]')).toHaveText('1'); }); @@ -619,19 +1151,21 @@ test('100 hide/show cycles leave stable ownership and destroy stops updates', as await page.goto('/main/index/native-ui-shell'); const button = page.locator('app-native-ui-shell ion-button[type=submit]'); await expect(button).toHaveAttribute('data-native-ui-shell', ''); - const initial = await page.evaluate(() => (window as any).nativeUIShell.getStatus().projected); + const initial = await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().projected); for (let index = 0; index < 100; index++) { await button.evaluate((element) => (element.parentElement!.hidden = true)); await expect(button).not.toHaveAttribute('data-native-ui-shell'); await button.evaluate((element) => (element.parentElement!.hidden = false)); await expect(button).toHaveAttribute('data-native-ui-shell', ''); } - expect(await page.evaluate(() => (window as any).nativeUIShell.getStatus().projected)).toBe(initial); - await page.evaluate(() => (window as any).nativeUIShell.destroy()); - const updates = await page.evaluate(() => (window as any).__nativeUIShell.updates.length); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().projected)).toBe( + initial, + ); + await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.destroy()); + const updates = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length); await button.evaluate((element) => element.setAttribute('fill', 'clear')); await page.waitForTimeout(300); - expect(await page.evaluate(() => (window as any).__nativeUIShell.updates.length)).toBe(updates); + expect(await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length)).toBe(updates); await expect(page.locator('[data-native-ui-shell]')).toHaveCount(0); }); @@ -642,8 +1176,10 @@ test('ion-icon SVGs project and update when their name changes', async ({ page } await expect(button).toHaveAttribute('data-native-ui-shell', ''); const nativeIcon = () => button.evaluate(() => { - const snapshot = (window as any).__nativeUIShell.updates.at(-1); - return snapshot.controls.flatMap((control: any) => control.items).find((item: any) => item.accessibilityLabel === 'Save')?.icon; + const snapshot = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!; + return snapshot.controls + .flatMap((control: ShellControl) => control.items) + .find((item: ShellItem) => item.accessibilityLabel === 'Save')?.icon; }); await expect.poll(nativeIcon).toMatch(/^iVBOR/); const original = await nativeIcon(); @@ -662,19 +1198,33 @@ test('clear ion-buttons share one glass surface and keep independent actions', a const github = group.locator('ion-button').nth(0); const refresh = group.locator('ion-button').nth(1); const projectedGroup = () => - page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.find( - (control: any) => control.kind === 'ion-buttons' && control.items.some((item: any) => item.accessibilityLabel === 'GitHub'), - ), + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find( + (control: ShellControl) => + control.kind === 'ion-buttons' && control.items.some((item: ShellItem) => item.accessibilityLabel === 'GitHub'), + )!, ); await expect(group).toHaveAttribute('data-native-ui-shell', ''); await expect(group.locator('[data-native-ui-shell]')).toHaveCount(0); await expect(github.locator('button')).toHaveCSS('visibility', 'hidden'); const native = await projectedGroup(); expect(native.items).toHaveLength(2); - expect(native.items.map((item: any) => item.accessibilityLabel)).toEqual(['GitHub', 'Refresh']); + expect(native.items.map((item: ShellItem) => item.accessibilityLabel)).toEqual(['GitHub', 'Refresh']); + const githubName = () => + page.evaluate( + (id) => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((control: ShellControl) => control.id === id)?.items[0]?.accessibilityLabel, + native.id, + ); + await github.locator('ion-icon').evaluate((icon) => icon.setAttribute('aria-hidden', 'true')); + await expect.poll(githubName).not.toBe('GitHub'); + await github.locator('ion-icon').evaluate((icon) => icon.removeAttribute('aria-hidden')); + await expect.poll(githubName).toBe('GitHub'); for (const item of native.items) expect(item.icon).toMatch(/^iVBOR/); await activate(page, 'GitHub', true); await expect(page.locator('ion-title').filter({ hasText: 'Actions:' })).toHaveText('Actions: 1 / 0'); @@ -721,6 +1271,28 @@ test('clear ion-buttons share one glass surface and keep independent actions', a await expect(github.locator('button')).toHaveCSS('visibility', 'visible'); }); +test('theme-disabled ion-buttons project eligible buttons independently', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.locator('[data-glass-group]').evaluate((group) => { + group.classList.add('ionic-theme-disabled'); + group.querySelectorAll('ion-button').forEach((button) => (button.fill = 'default')); + }); + await expect + .poll(() => + page.evaluate(() => { + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)?.controls ?? []; + const isDemoAction = (control: ShellControl) => + control.items.some((item: ShellItem) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)); + return { + buttons: controls.filter((control: ShellControl) => control.kind === 'ion-button' && isDemoAction(control)).length, + groups: controls.filter((control: ShellControl) => control.kind === 'ion-buttons' && isDemoAction(control)).length, + }; + }), + ) + .toEqual({ buttons: 2, groups: 0 }); +}); + test('all demo pages keep projection consistent through consecutive navigation', async ({ page }) => { test.setTimeout(240000); await page.setViewportSize({ width: 440, height: 956 }); @@ -749,11 +1321,12 @@ test('all demo pages keep projection consistent through consecutive navigation', await expect .poll(() => page.evaluate(() => { - const status = (window as any).nativeUIShell.getStatus(); - const snapshot = (window as any).__nativeUIShell.updates.at(-1); + const status = (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus(); + const snapshot = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!; return ( status.state !== 'stopped' && - status.projected === snapshot?.controls.reduce((count: number, control: any) => count + (control.search?.available ? 3 : 1), 0) + status.projected === + snapshot?.controls.reduce((count: number, control: ShellControl) => count + (control.search?.available ? 3 : 1), 0) ); }), ) @@ -777,7 +1350,9 @@ test('all demo pages keep projection consistent through consecutive navigation', const back = async () => { await settled(); const hasNativeBack = await page.evaluate(() => - (window as any).__nativeUIShell.updates.at(-1).controls.some((control: any) => control.kind === 'ion-back-button'), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.kind === 'ion-back-button'), ); if (hasNativeBack) await activate(page, 'back'); else @@ -855,7 +1430,9 @@ test('demo overlay variants retire native controls and restore them after dismis await expect(page.locator('[data-native-ui-shell]')).toHaveCount(0); await overlay.evaluate((element: HTMLElement & { dismiss(): Promise }) => element.dismiss()); await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-shell', ''); - expect(await page.evaluate(() => (window as any).nativeUIShell.getStatus().state)).not.toBe('stopped'); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().state)).not.toBe( + 'stopped', + ); }); } } @@ -869,7 +1446,7 @@ test('shared tabs stay native throughout navigation and delayed page updates', a const tabs = page.locator('ion-tab-bar'); await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); await tabs.evaluate((element) => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); state.updates = []; state.delay = 100; state.tabRetirements = 0; @@ -889,9 +1466,9 @@ test('shared tabs stay native throughout navigation and delayed page updates', a await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - ?.controls.some((control: any) => control.items.some((item: any) => item.label === 'Index pending')), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + ?.controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.label === 'Index pending')), ), ) .toBe(true); @@ -911,9 +1488,9 @@ test('shared tabs stay native throughout navigation and delayed page updates', a await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((control: any) => control.items.some((item: any) => item.label === '更新中')), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.label === '更新中')), ), ) .toBe(true); @@ -929,10 +1506,10 @@ test('shared tabs stay native throughout navigation and delayed page updates', a } await page.waitForTimeout(300); const state = await page.evaluate(() => ({ - retirements: (window as any).__nativeUIShell.tabRetirements, - details: (window as any).__nativeUIShell.retirementDetails, - ids: (window as any).__nativeUIShell.updates.map( - (snapshot: any) => snapshot.controls.find((control: any) => control.kind === 'ion-tab-bar')?.id, + retirements: Capacitor.registerPlugin('IonicNativeUIShell').tabRetirements, + details: Capacitor.registerPlugin('IonicNativeUIShell').retirementDetails, + ids: Capacitor.registerPlugin('IonicNativeUIShell').updates.map( + (snapshot: ShellSnapshot) => snapshot.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')?.id, ), })); expect(state.retirements, JSON.stringify(state.details)).toBe(0); @@ -951,7 +1528,7 @@ test('native tabs carry position anchors for both slots and text directions', as for (const position of ['start', 'center', 'end']) { await tabs.evaluate( (element, state) => { - element.dir = state.direction; + (element as HTMLElement).dir = state.direction; element.slot = state.slot; element.classList.remove('tab-bar-position-start', 'tab-bar-position-center', 'tab-bar-position-end'); element.classList.add(`tab-bar-position-${state.position}`); @@ -963,13 +1540,17 @@ test('native tabs carry position anchors for both slots and text directions', as .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-tab-bar') - ?.tabBarAnchor, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')?.tabBarAnchor, ), ) .toEqual({ x, y: slot === 'bottom' ? 1 : 0 }); - const native = await page.evaluate(() => - (window as any).__nativeUIShell.updates.at(-1).controls.find((control: any) => control.kind === 'ion-tab-bar'), + const native = await page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')!, ); const dom = await tabs.boundingBox(); expect(native.x).toBeCloseTo(dom!.x, 1); @@ -986,9 +1567,12 @@ test('tab icons use selection tint only for text-colored SVGs', async ({ page }) await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); const items = () => page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-tab-bar')?.items, + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')?.items, ); - await expect.poll(async () => (await items())?.every((item: any) => item.iconTemplate)).toBe(true); + await expect.poll(async () => (await items())?.every((item: ShellItem) => item.iconTemplate)).toBe(true); await tabs .locator('ion-icon') .first() @@ -997,7 +1581,7 @@ test('tab icons use selection tint only for text-colored SVGs', async ({ page }) ''; }); await expect.poll(async () => (await items())?.[0]?.iconTemplate).toBe(false); - expect((await items()).slice(1).every((item: any) => item.iconTemplate)).toBe(true); + expect((await items())!.slice(1).every((item: ShellItem) => item.iconTemplate)).toBe(true); }); test('menu button toggles its Ionic menu and follows autoHide, disabled and split pane', async ({ page }) => { @@ -1006,14 +1590,16 @@ test('menu button toggles its Ionic menu and follows autoHide, disabled and spli await page.goto('/main/index/native-ui-shell'); const menu = page.locator('ion-menu'); const button = page.locator('app-native-ui-shell ion-menu-button'); + await button.evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = true)); const surface = button.locator('..'); await expect(surface).toHaveAttribute('data-native-ui-shell', ''); await expect .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-menu-button')?.items[0].icon?.length ?? - 0, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-menu-button')?.items[0].icon?.length ?? 0, ), ) .toBeGreaterThan(0); @@ -1037,7 +1623,10 @@ test('menu button toggles its Ionic menu and follows autoHide, disabled and spli await expect .poll(() => page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-menu-button')?.items[0].disabled, + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-menu-button')?.items[0].disabled, ), ) .toBe(true); @@ -1081,7 +1670,10 @@ test('menu button projects slot icons and shared glass, restoring excluded surfa const surface = button.locator('..'); await expect(surface).toHaveAttribute('data-native-ui-shell', ''); const original = await page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-menu-button').items[0].icon, + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-menu-button')!.items[0].icon, ); await button.evaluate((element) => { element.innerHTML = ''; @@ -1089,7 +1681,10 @@ test('menu button projects slot icons and shared glass, restoring excluded surfa await expect .poll(() => page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-menu-button')?.items[0].icon, + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-menu-button')?.items[0].icon, ), ) .not.toBe(original); @@ -1099,7 +1694,7 @@ test('menu button projects slot icons and shared glass, restoring excluded surfa extra.fill = 'clear'; extra.textContent = 'Extra'; extra.addEventListener('click', () => { - (window as any).__menuExtra = true; + (document.querySelector('ion-app') as TestAppElement).menuExtra = true; }); element.append(extra); }); @@ -1107,14 +1702,15 @@ test('menu button projects slot icons and shared glass, restoring excluded surfa .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.find((c: any) => c.kind === 'ion-buttons' && c.items.some((i: any) => i.label === 'Extra'))?.items.length, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-buttons' && c.items.some((i: ShellItem) => i.label === 'Extra'))?.items + .length, ), ) .toBe(2); await activate(page, 'Extra'); - await expect.poll(() => page.evaluate(() => (window as any).__menuExtra)).toBe(true); + await expect.poll(() => page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).menuExtra)).toBe(true); await activate(page, 'menu'); await expect(page.locator('ion-menu')).toHaveClass(/show-menu/); await expect.poll(() => page.locator('ion-menu').evaluate((element: HTMLIonMenuElement) => element.isOpen())).toBe(true); @@ -1158,39 +1754,45 @@ test('replacing a projected searchbar retires old input and binds the new field' await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); const bar = footer.locator('ion-searchbar'); - const config = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); + const config = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await bar.evaluate((element: HTMLIonSearchbarElement) => element.setFocus()); await expect.poll(async () => (await config())?.active).toBe(true); const previous = await config(); await bar.evaluate((old: HTMLIonSearchbarElement) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; const event = { id: search.id, revision: snapshot.revision, valueVersion: search.valueVersion, phase: 'input', composing: false }; - state.search({ ...event, value: 'old field', sequence: ++state.sequence }); + state.notifyListeners('search', { ...event, value: 'old field', sequence: ++state.sequence }); const replacement = document.createElement('ion-searchbar'); for (const attribute of old.attributes) replacement.setAttribute(attribute.name, attribute.value); replacement.value = 'application initial value'; - (window as any).__retiredSearchbar = old; + (document.querySelector('ion-app') as TestAppElement).retiredSearchbar = old; old.replaceWith(replacement); // Arrive before the observer can retire the old state: DOM identity must reject it. - state.search({ ...event, value: 'stale input', sequence: ++state.sequence }); + state.notifyListeners('search', { ...event, value: 'stale input', sequence: ++state.sequence }); }); - expect(await page.evaluate(() => (window as any).__retiredSearchbar.value)).toBe('old field'); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).retiredSearchbar!.value)).toBe('old field'); await expect .poll(async () => { const current = await config(); - return !!current && current.id !== previous.id && !current.active && current.value === 'application initial value'; + return !!current && current.id !== previous!.id && !current.active && current.value === 'application initial value'; }) .toBe(true); await bar.evaluate((element: HTMLIonSearchbarElement) => element.setFocus()); await expect.poll(async () => (await config())?.focused).toBe(true); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; - state.search({ + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; + state.notifyListeners('search', { id: search.id, revision: snapshot.revision, valueVersion: search.valueVersion, @@ -1206,7 +1808,7 @@ test('replacing a projected searchbar retires old input and binds the new field' await expect .poll(async () => { const current = await config(); - return !!current && current.id !== session.id && !current.active && current.value === 'replacement input'; + return !!current && current.id !== session!.id && !current.active && current.value === 'replacement input'; }) .toBe(true); }); @@ -1218,15 +1820,21 @@ test('native search compensates keyboard tab hiding while respecting application await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); const tabBar = page.locator('ion-tab-bar'); - const config = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); + const config = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); await page.addStyleTag({ content: 'ion-tab-bar.application-hidden { display: none !important; }' }); for (const mode of ['inline', 'class']) { await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - state.activate({ - id: snapshot.controls.find((c: any) => c.search).search.trigger.id, + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + state.notifyListeners('activate', { + id: snapshot.controls.find((c: ShellControl) => c.search)!.search!.trigger.id, revision: snapshot.revision, sequence: ++state.sequence, }); @@ -1240,10 +1848,10 @@ test('native search compensates keyboard tab hiding while respecting application await expect(tabBar).not.toHaveClass(/tab-bar-hidden/); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; - state.search({ + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; + state.notifyListeners('search', { id: search.id, revision: snapshot.revision, valueVersion: search.valueVersion, @@ -1279,34 +1887,46 @@ test('search group keeps its covers, forwards Ionic events and preserves text on await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); - const config = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); - expect((await config()).active).toBe(false); + const config = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); + expect((await config())?.active).toBe(false); await footer.evaluate((element) => { const bar = element.querySelector('ion-searchbar')!; - (window as any).__searchEvents = []; + (document.querySelector('ion-app') as TestAppElement).searchEvents = []; for (const name of ['ionFocus', 'ionInput', 'ionBlur', 'ionChange', 'ionClear', 'ionCancel']) { - bar.addEventListener(name, (event: any) => (window as any).__searchEvents.push([name, event.detail?.value])); + bar.addEventListener(name, (event: Event) => + (document.querySelector('ion-app') as TestAppElement).searchEvents!.push([name, (event as CustomEvent).detail?.value]), + ); } }); const action = async (close = false) => page.evaluate((close) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; - state.activate({ id: close ? search.closeId : search.trigger.id, revision: snapshot.revision, sequence: ++state.sequence }); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; + state.notifyListeners('activate', { + id: close ? search.closeId : search.trigger.id, + revision: snapshot.revision, + sequence: ++state.sequence, + }); }, close); await action(); - await expect.poll(async () => (await config()).active).toBe(true); + await expect.poll(async () => (await config())?.active).toBe(true); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); expect(page.url()).toContain('/main/album'); const nativeEvent = async (phase: string, value: string, composing = false) => page.evaluate( ({ phase, value, composing }) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - state.search({ - id: snapshot.controls.find((c: any) => c.search).search.id, - valueVersion: snapshot.controls.find((c: any) => c.search).search.valueVersion, + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + state.notifyListeners('search', { + id: snapshot.controls.find((c: ShellControl) => c.search)!.search!.id, + valueVersion: snapshot.controls.find((c: ShellControl) => c.search)!.search!.valueVersion, revision: snapshot.revision, sequence: ++state.sequence, phase, @@ -1322,9 +1942,9 @@ test('search group keeps its covers, forwards Ionic events and preserves text on await nativeEvent('input', '日本', false); await expect(footer.locator('ion-searchbar')).toHaveJSProperty('value', '日本'); await action(true); - await expect.poll(async () => (await config()).active).toBe(false); + await expect.poll(async () => (await config())?.active).toBe(false); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); - expect(await page.evaluate(() => (window as any).__searchEvents.map((e: any) => e[0]))).toEqual([ + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).searchEvents!.map((e) => e[0]))).toEqual([ 'ionFocus', 'ionInput', 'ionInput', @@ -1332,16 +1952,20 @@ test('search group keeps its covers, forwards Ionic events and preserves text on 'ionChange', ]); await action(); - await expect.poll(async () => (await config()).active).toBe(true); - expect((await config()).value).toBe('日本'); + await expect.poll(async () => (await config())?.active).toBe(true); + expect((await config())?.value).toBe('日本'); await footer.locator('ion-searchbar').evaluate((bar: HTMLIonSearchbarElement) => { bar.value = 'external'; }); - await expect.poll(async () => (await config()).value).toBe('external'); + await expect.poll(async () => (await config())?.value).toBe('external'); await nativeEvent('focus', 'external'); await nativeEvent('clear', 'external'); - await expect.poll(async () => (await config()).value).toBe(''); - expect(await page.evaluate(() => (window as any).__searchEvents.filter((e: any) => e[0] === 'ionClear').length)).toBe(1); + await expect.poll(async () => (await config())?.value).toBe(''); + expect( + await page.evaluate( + () => (document.querySelector('ion-app') as TestAppElement).searchEvents!.filter((e) => e[0] === 'ionClear').length, + ), + ).toBe(1); }); test('search retirement keeps the value, rejects late input and allows a fresh Web session', async ({ page }) => { @@ -1353,7 +1977,14 @@ test('search retirement keeps the value, rejects late input and allows a fresh W await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.locator('app-album-page ion-fab-button').evaluate((button: HTMLElement) => button.click()); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(true); await footer.locator('ion-searchbar').evaluate((bar: HTMLIonSearchbarElement) => { bar.value = 'retained'; @@ -1361,11 +1992,11 @@ test('search retirement keeps the value, rejects late input and allows a fresh W await footer.evaluate((element) => element.classList.add('ionic-theme-disabled')); await expect(footer).not.toHaveAttribute('data-native-ui-shell'); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.findLast((s: any) => s.controls.some((c: any) => c.search?.active)); - state.search({ - id: snapshot.controls.find((c: any) => c.search).search.id, - valueVersion: snapshot.controls.find((c: any) => c.search).search.valueVersion, + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.findLast((s: ShellSnapshot) => s.controls.some((c: ShellControl) => c.search?.active))!; + state.notifyListeners('search', { + id: snapshot.controls.find((c: ShellControl) => c.search)!.search!.id, + valueVersion: snapshot.controls.find((c: ShellControl) => c.search)!.search!.valueVersion, revision: snapshot.revision, sequence: ++state.sequence, phase: 'input', @@ -1384,7 +2015,14 @@ test('search retirement keeps the value, rejects late input and allows a fresh W await footer.evaluate((element) => element.classList.remove('ionic-theme-disabled')); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(false); expect(errors).toEqual([]); }); @@ -1434,13 +2072,20 @@ test('a Leave sent before native Enter completes remains the final state', async const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); await page.evaluate(() => { - (window as any).__nativeUIShell.delay = 200; + Capacitor.registerPlugin('IonicNativeUIShell').delay = 200; (document.querySelector('app-album-page ion-fab-button') as HTMLElement).click(); (document.querySelector('app-album-page ion-footer ion-button') as HTMLElement).click(); }); await page.waitForTimeout(600); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(false); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); expect(errors).toEqual([]); @@ -1455,14 +2100,21 @@ test('app normalization wins over old native input while blur still terminates f await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.locator('app-album-page ion-fab-button').evaluate((button: HTMLElement) => button.click()); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(true); await footer.locator('ion-searchbar').evaluate((bar: HTMLIonSearchbarElement) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; const emit = (phase: string, value: string) => - state.search({ + state.notifyListeners('search', { id: search.id, revision: snapshot.revision, sequence: ++state.sequence, @@ -1485,7 +2137,14 @@ test('app normalization wins over old native input while blur still terminates f }); await expect(footer.locator('ion-searchbar')).toHaveJSProperty('value', 'NORMALIZED'); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.focused)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.focused, + ), + ) .toBe(false); }); @@ -1499,13 +2158,13 @@ test('a lost search bridge releases Enter and keeps the current value in Web', a await page.evaluate(() => { const page = document.querySelector('app-album-page')!; (page.querySelector('ion-searchbar') as HTMLIonSearchbarElement).value = 'bridge retained'; - (window as any).__nativeUIShell.hang = true; + Capacitor.registerPlugin('IonicNativeUIShell').hang = true; (page.querySelector('ion-fab-button') as HTMLElement).click(); }); await expect(footer).not.toHaveAttribute('data-native-ui-shell', { timeout: 10000 }); await expect(footer).toHaveCSS('opacity', '1'); await expect(footer.locator('ion-searchbar')).toHaveJSProperty('value', 'bridge retained'); - expect(await page.evaluate(() => (window as any).nativeUIShell.getStatus().state)).toBe('stopped'); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().state)).toBe('web'); await footer.locator('ion-button').click(); await expect(footer).toHaveCSS('opacity', '0'); }); @@ -1516,15 +2175,17 @@ test('a retained FAB accepts a second click while its current native update awai const fab = page.locator('ion-fab[horizontal=center]'); await expect(fab).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - (window as any).__nativeUIShell.delay = 500; + Capacitor.registerPlugin('IonicNativeUIShell').delay = 500; }); await activate(page, 'Center FAB actions'); await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((c: any) => c.kind === 'ion-fab' && c.items.length === 7 && c.items.filter((i: any) => i.visible).length > 1), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some( + (c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7 && c.items.filter((i: ShellItem) => i.visible).length > 1, + ), ), ) .toBe(true); @@ -1545,23 +2206,23 @@ test('FAB keeps its cover while Stencil show and the rendered host class catch u // Hold the real Stencil intermediate state long enough to exercise a bridge update. Object.defineProperty(child, 'show', { configurable: true, get: () => true }); child.classList.remove('fab-button-show'); - (window as any).__fabRetired = false; + (document.querySelector('ion-app') as TestAppElement).fabRetired = false; element.addEventListener('nativeUIShellChange', () => { - if (!element.hasAttribute('data-native-ui-shell')) (window as any).__fabRetired = true; + if (!element.hasAttribute('data-native-ui-shell')) (document.querySelector('ion-app') as TestAppElement).fabRetired = true; }); }); await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((c: any) => c.kind === 'ion-fab' && c.items.length === 7 && c.items[1].visible === false), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7 && c.items[1].visible === false), ), ) .toBe(true); await activate(page, 'Center FAB actions'); await expect(fab).toHaveJSProperty('activated', false); - expect(await page.evaluate(() => (window as any).__fabRetired)).toBe(false); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).fabRetired)).toBe(false); }); test('native resume retires search even when WebKit visibility stayed visible', async ({ page }) => { @@ -1575,7 +2236,14 @@ test('native resume retires search even when WebKit visibility stayed visible', }); await page.evaluate(() => window.dispatchEvent(Object.assign(new Event('nativeUIShellRefresh'), { retireSearch: true }))); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(false); await expect(footer.locator('ion-searchbar')).toHaveJSProperty('value', 'background'); }); @@ -1620,14 +2288,23 @@ for (const [kind, selector] of [ const source = page.locator(`app-native-ui-shell ${selector}`); await expect(source).toHaveAttribute('data-native-ui-shell', ''); const id = await page.evaluate( - (kind) => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === kind).id, + (kind) => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === kind)!.id, kind, ); await source.evaluate((element) => { const zone = document.createElement('div'); zone.style.cssText = 'position:fixed;top:330px;left:12px;width:300px;height:140px;z-index:200'; element.closest('app-native-ui-shell')!.append(zone); - (window as any).__placement = { element, parent: element.parentElement, next: element.nextSibling, slot: element.slot, zone }; + (document.querySelector('ion-app') as TestAppElement).placement = { + element, + parent: element.parentElement, + next: element.nextSibling, + slot: element.slot, + zone, + }; }); for (const markup of [ '', @@ -1638,29 +2315,37 @@ for (const [kind, selector] of [ '', ]) { await page.evaluate((markup) => { - const { element, zone } = (window as any).__placement; + const { element, zone } = (document.querySelector('ion-app') as TestAppElement).placement!; // These destinations have no toolbar start slot; keep the Web control assigned. element.slot = ''; zone.innerHTML = markup; - zone.querySelector('[data-target]').append(element); + zone.querySelector('[data-target]')!.append(element); }, markup); await expect(source).not.toHaveAttribute('data-native-ui-shell'); await expect - .poll(() => page.evaluate((id) => (window as any).__nativeUIShell.updates.at(-1).controls.some((c: any) => c.id === id), id)) + .poll(() => + page.evaluate( + (id) => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((c: ShellControl) => c.id === id), + id, + ), + ) .toBe(false); await expect(source).toHaveCSS('visibility', 'visible'); await page.evaluate(() => { - const { element, parent, next, slot } = (window as any).__placement; - parent.insertBefore(element, next); + const { element, parent, next, slot } = (document.querySelector('ion-app') as TestAppElement).placement!; + parent!.insertBefore(element, next); element.slot = slot; }); await expect(source, `restored from ${markup}`).toHaveAttribute('data-native-ui-shell', ''); } // A footer toolbar is supported too; do not accidentally restrict this to headers. await page.evaluate(() => { - const { element, zone } = (window as any).__placement; + const { element, zone } = (document.querySelector('ion-app') as TestAppElement).placement!; zone.innerHTML = ''; - zone.querySelector('ion-toolbar').append(element); + zone.querySelector('ion-toolbar')!.append(element); }); await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); @@ -1698,11 +2383,11 @@ test('search registration cannot bypass fixed placement restrictions', async ({ const page = footer.parentElement!; const fab = page.querySelector('ion-fab')!; const buttons = footer.querySelector('ion-buttons')!; - (window as any).__searchPlacement = { page, footer, fab, buttons, toolbar: buttons.parentElement }; + (document.querySelector('ion-app') as TestAppElement).searchPlacement = { page, footer, fab, buttons, toolbar: buttons.parentElement! }; }); for (const placement of ['fab-slot', 'fab-wrapper', 'back-outside', 'footer-scroll']) { await page.evaluate((placement) => { - const { page, footer, fab, buttons } = (window as any).__searchPlacement; + const { page, footer, fab, buttons } = (document.querySelector('ion-app') as TestAppElement).searchPlacement!; if (placement === 'fab-slot') fab.removeAttribute('slot'); if (placement === 'fab-wrapper') { const wrapper = document.createElement('div'); @@ -1711,16 +2396,22 @@ test('search registration cannot bypass fixed placement restrictions', async ({ wrapper.append(fab); } if (placement === 'back-outside') footer.append(buttons); - if (placement === 'footer-scroll') page.querySelector('ion-content').append(footer); + if (placement === 'footer-scroll') page.querySelector('ion-content')!.append(footer); }, placement); await expect(footer).not.toHaveAttribute('data-native-ui-shell'); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.some((c: any) => c.search?.available))) + .poll(() => + page.evaluate(() => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((c: ShellControl) => c.search?.available), + ), + ) .toBe(false); await page.evaluate((placement) => { - const { page, footer, fab, buttons, toolbar } = (window as any).__searchPlacement; + const { page, footer, fab, buttons, toolbar } = (document.querySelector('ion-app') as TestAppElement).searchPlacement!; if (placement === 'fab-slot') fab.setAttribute('slot', 'fixed'); - if (placement === 'fab-wrapper') fab.parentElement.replaceWith(fab); + if (placement === 'fab-wrapper') fab.parentElement!.replaceWith(fab); if (placement === 'back-outside') toolbar.prepend(buttons); if (placement === 'footer-scroll') page.append(footer); }, placement); @@ -1763,8 +2454,10 @@ for (const theme of ['light', 'class', 'system', 'always'] as const) { await expect .poll(() => page.evaluate(() => { - const controls = (window as any).__nativeUIShell.updates.at(-1).controls; - return ['ion-tab-bar', 'ion-fab'].map((kind) => controls.filter((c: any) => c.kind === kind).map((c: any) => c.dark)); + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.controls; + return ['ion-tab-bar', 'ion-fab'].map((kind) => + controls.filter((c: ShellControl) => c.kind === kind).map((c: ShellControl) => c.dark), + ); }), ) .toEqual([[expected], [expected, expected, expected, expected]]); @@ -1816,7 +2509,7 @@ test('reduced motion hands off without a crossfade', async ({ page }) => { const segment = page.locator('app-native-ui-shell ion-segment'); await expect(segment).toHaveAttribute('data-native-ui-shell', ''); await expect(segment).not.toHaveAttribute('data-native-ui-shell-fading'); - const duration = await page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).transitionDuration); + const duration = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.transitionDuration); expect(duration).toBe(0); await segment.evaluate((el) => el.classList.add('ios-theme-shell-disabled')); await expect(segment).not.toHaveAttribute('data-native-ui-shell'); @@ -1828,17 +2521,18 @@ test('tab switches hand off without a crossfade', async ({ page }) => { await page.goto('/main/index/native-ui-shell'); const segment = page.locator('app-native-ui-shell ion-segment'); await expect(segment).toHaveAttribute('data-native-ui-shell', ''); - const before = await page.evaluate(() => (window as any).__nativeUIShell.updates.length); + const before = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length); await activate(page, 'Docs'); await expect(page).toHaveURL(/\/main\/docs/); await expect .poll(() => page.evaluate( (start) => - (window as any).__nativeUIShell.updates - .slice(start) + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.slice(start) .some( - (update: any) => update.transitionDuration === 0 && !update.controls.some((control: any) => control.kind === 'ion-segment'), + (update: ShellSnapshot) => + update.transitionDuration === 0 && !update.controls.some((control: ShellControl) => control.kind === 'ion-segment'), ), before, ), @@ -1886,8 +2580,9 @@ for (const direction of ['ltr', 'rtl']) { await expect .poll(() => page.evaluate(() => { - const item = (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-button') - ?.items[0]; + const item = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((control: ShellControl) => control.kind === 'ion-button')?.items[0]; return item && [item.imagePadding, item.contentInsetLeading, item.contentInsetTrailing]; }), ) @@ -1915,8 +2610,9 @@ for (const direction of ['ltr', 'rtl']) { await expect .poll(() => page.evaluate(() => { - const item = (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-back-button') - ?.items[0]; + const item = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((control: ShellControl) => control.kind === 'ion-back-button')?.items[0]; return item && [item.imagePadding, item.contentInsetLeading, item.contentInsetTrailing]; }), ) @@ -1930,13 +2626,15 @@ test('rejected cached search is replaced by ordinary native tabs after navigatio await page.route('https://picsum.photos/**', (route) => route.abort()); await page.goto('/main/album'); await expect(page.locator('app-album-page ion-footer')).toHaveAttribute('data-native-ui-shell', ''); - await page.evaluate(() => ((window as any).__nativeUIShell.rejectInactiveSearch = true)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').rejectInactiveSearch = true)); await activate(page, 'Index'); await expect(page).toHaveURL('/main/index'); await expect .poll(() => page.evaluate(() => { - const control = (window as any).__nativeUIShell.updates.at(-1)?.controls.find((c: any) => c.kind === 'ion-tab-bar'); + const control = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((c: ShellControl) => c.kind === 'ion-tab-bar'); return !!control && !control.search; }), ) @@ -1956,22 +2654,22 @@ for (const invalidated of [false, true]) { await expect(button).toHaveAttribute('data-native-ui-shell', ''); await button.evaluate((el) => (el.parentElement!.hidden = true)); await expect(button).not.toHaveAttribute('data-native-ui-shell'); - await page.evaluate(() => ((window as any).__nativeUIShell.delay = 1000)); - const previous = await page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).revision); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').delay = 1000)); + const previous = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.revision); await button.evaluate((el) => (el.parentElement!.hidden = false)); await expect .poll(() => page.evaluate((previous) => { - const snapshot = (window as any).__nativeUIShell.updates.at(-1); - return snapshot.revision > previous && snapshot.controls.some((c: any) => c.kind === 'ion-button'); + const snapshot = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!; + return snapshot.revision > previous && snapshot.controls.some((c: ShellControl) => c.kind === 'ion-button'); }, previous), ) .toBe(true); await expect(button).not.toHaveAttribute('data-native-ui-shell'); await activate(page, (await button.locator('[data-label]').textContent()) as string, true); await expect(page.locator('[data-save-count]')).toHaveText('0'); - if (invalidated) await button.evaluate((el: any) => (el.disabled = true)); - await page.evaluate(() => ((window as any).__nativeUIShell.delay = 0)); + if (invalidated) await button.evaluate((el: HTMLIonButtonElement) => (el.disabled = true)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').delay = 0)); if (invalidated) { await page.waitForTimeout(1200); await expect(page.locator('[data-save-count]')).toHaveText('0'); @@ -1988,14 +2686,14 @@ test('rejected search retries when tab content changes without resizing', async await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); - await page.evaluate(() => ((window as any).__nativeUIShell.rejectAllSearch = true)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').rejectAllSearch = true)); await page .locator('ion-tab-button ion-label') .first() .evaluate((el) => (el.textContent = 'Rejected')); await expect(footer).not.toHaveAttribute('data-native-ui-shell'); const before = await page.locator('ion-tab-bar').boundingBox(); - await page.evaluate(() => ((window as any).__nativeUIShell.rejectAllSearch = false)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').rejectAllSearch = false)); await page .locator('ion-tab-button ion-label') .first() diff --git a/demo/e2e/popover.spec.ts b/demo/e2e/popover.spec.ts index a7d6e513..263e5998 100644 --- a/demo/e2e/popover.spec.ts +++ b/demo/e2e/popover.spec.ts @@ -1,5 +1,14 @@ import { expect, test } from '@playwright/test'; +/** Element the spec instruments to capture the bounds the animation measured. */ +interface MeasuredTrigger extends HTMLElement { + presentationBounds?: DOMRect; + restoreMeasurement?: () => void; +} + +/** `presented` exists on the component class but not its public element interface. */ +type PopoverProbe = HTMLIonPopoverElement & { presented: boolean }; + test('ordinary anchors show an arrow and morphing buttons do not', async ({ page }) => { await page.goto('/main/index/popover'); // Reopen the ordinary trigger to verify callout cleanup after a morphing popover. @@ -13,7 +22,7 @@ test('ordinary anchors show an arrow and morphing buttons do not', async ({ page } else { await expect(arrow).toBeVisible(); } - await popover.evaluate(async (el: any) => el.dismiss()); + await popover.evaluate(async (el: HTMLIonPopoverElement) => el.dismiss()); await expect(popover).toBeHidden(); } }); @@ -22,7 +31,7 @@ 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; + const popover = document.createElement('ion-popover') as PopoverProbe; popover.component = document.createElement('div'); popover.component.textContent = 'Unanchored content'; document.body.append(popover); @@ -35,7 +44,7 @@ test('popover can be presented without a trigger', async ({ page }) => { expect(result).toBe(true); }); -for (const side of ['top', 'bottom', 'left', 'right']) { +for (const side of ['top', 'bottom', 'left', 'right'] as const) { test(`arrow stays visible for ${side} placement`, async ({ page }) => { await page.setViewportSize({ width: 1210, height: 834 }); await page.goto('/main/index/popover'); @@ -44,7 +53,7 @@ for (const side of ['top', 'bottom', 'left', 'right']) { const anchor = document.createElement('button'); anchor.style.cssText = 'position:fixed;left:540px;top:350px;width:80px;height:44px'; document.body.append(anchor); - const popover = document.createElement('ion-popover') as any; + const popover = document.createElement('ion-popover') as PopoverProbe; popover.component = document.createElement('div'); popover.component.textContent = 'Content'; popover.style.cssText = '--width:240px;--height:180px'; @@ -52,7 +61,7 @@ for (const side of ['top', 'bottom', 'left', 'right']) { popover.side = side; document.body.append(popover); await popover.present(); - const arrow = popover.shadowRoot.querySelector('[part="arrow"]'); + const arrow = popover.shadowRoot!.querySelector('[part="arrow"]')!; const rect = arrow.getBoundingClientRect(); const result = { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; await popover.dismiss(); @@ -87,14 +96,14 @@ for (const width of [390, 1210]) { await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); const anchorRect = anchor.getBoundingClientRect(); const paneRect = pane.getBoundingClientRect(); - const popover = document.createElement('ion-popover') as any; + const popover = document.createElement('ion-popover') as PopoverProbe; 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"]'); + const content = popover.shadowRoot!.querySelector('[part="content"]')!; const rect = content.getBoundingClientRect(); const origin = parseFloat(getComputedStyle(content).transformOrigin); const result = { @@ -132,7 +141,7 @@ for (const width of [390, 1210]) { await trigger.scrollIntoViewIfNeeded(); // The pressed button can grow before presentation; compare the surface with // the visual bounds read by the animation, rather than the resting button. - await trigger.evaluate((el: any) => { + await trigger.evaluate((el: MeasuredTrigger) => { const measure = el.getBoundingClientRect; el.getBoundingClientRect = () => { const rect = measure.call(el); @@ -149,29 +158,29 @@ for (const width of [390, 1210]) { await expect(popover).toBeVisible(); await expect .poll(() => - popover.evaluate((el: any) => - el.shadowRoot - .querySelector('[part="content"]') + popover.evaluate((el: HTMLIonPopoverElement) => + el + .shadowRoot!.querySelector('[part="content"]')! .getAnimations() .every((a: Animation) => a.playState === 'finished'), ), ) .toBe(true); - const before = await trigger.evaluate((el: any) => { - const rect = el.presentationBounds; - el.restoreMeasurement(); + const before = await trigger.evaluate((el: MeasuredTrigger) => { + const rect = el.presentationBounds!; + el.restoreMeasurement!(); delete el.presentationBounds; return { x: rect.x, width: rect.width }; }); - const geometry = await popover.evaluate((el: any) => { - const trigger = document.getElementById(el.trigger)!; + const geometry = await popover.evaluate((el: HTMLIonPopoverElement) => { + const trigger = document.getElementById(el.trigger!)!; const pane = trigger.closest('ion-content')!; const scroll = pane.shadowRoot!.querySelector('[part="scroll"]')!; const style = getComputedStyle(scroll); const paneRect = pane.getBoundingClientRect(); - const content = el.shadowRoot.querySelector('[part="content"]'); + const content = el.shadowRoot!.querySelector('[part="content"]')!; const rect = content.getBoundingClientRect(); - const arrow = el.shadowRoot.querySelector('[part="arrow"]'); + const arrow = el.shadowRoot!.querySelector('[part="arrow"]')!; const arrowRect = arrow.getBoundingClientRect(); const anchor = trigger.getBoundingClientRect(); return { @@ -205,14 +214,14 @@ for (const width of [390, 1210]) { 1.5, ); } - await popover.evaluate(async (el: any) => el.dismiss()); + await popover.evaluate(async (el: HTMLIonPopoverElement) => el.dismiss()); await expect(popover).toBeHidden(); } }); } for (const tag of ['button', 'ion-button']) { - for (const side of ['top', 'bottom', 'left', 'right']) { + for (const side of ['top', 'bottom', 'left', 'right'] as const) { test(`event reference points to the click on ${tag} for ${side} placement`, async ({ page }) => { await page.setViewportSize({ width: 1210, height: 834 }); await page.goto('/main/index/popover'); @@ -223,7 +232,7 @@ for (const tag of ['button', '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; + const popover = document.createElement('ion-popover') as PopoverProbe; popover.component = document.createElement('div'); popover.component.textContent = 'Content'; popover.style.cssText = '--width:240px;--height:180px'; @@ -232,12 +241,12 @@ for (const tag of ['button', 'ion-button']) { popover.side = side; document.body.append(popover); await popover.present(); - const root = popover.shadowRoot; - const content = root.querySelector('.popover-content'); + const root = popover.shadowRoot!; + const content = root.querySelector('.popover-content')!; const rect = content.getBoundingClientRect(); - const arrow = root.querySelector('[part="arrow"]').getBoundingClientRect(); + const arrow = root.querySelector('[part="arrow"]')!.getBoundingClientRect(); const origin = getComputedStyle(content).transformOrigin.split(' ').map(parseFloat); - const layer = root.querySelector('[part="callout-glass"]'); + const layer = root.querySelector('[part="callout-glass"]')!; const layerRect = layer.getBoundingClientRect(); const layerOrigin = getComputedStyle(layer).transformOrigin.split(' ').map(parseFloat); const horizontal = side === 'left' || side === 'right'; diff --git a/demo/e2e/range-interaction.spec.ts b/demo/e2e/range-interaction.spec.ts index 7357fa3d..4c223f42 100644 --- a/demo/e2e/range-interaction.spec.ts +++ b/demo/e2e/range-interaction.spec.ts @@ -13,7 +13,7 @@ for (const direction of ['ltr', 'rtl']) { const range = page.locator('#range-probe'); await expect(range).toHaveClass(/hydrated/); await range.evaluate( - (el: any, { direction, endpoint }) => { + (el: HTMLIonRangeElement, { direction, endpoint }) => { el.dir = direction; el.min = 0; el.max = 100; @@ -33,7 +33,7 @@ for (const direction of ['ltr', 'rtl']) { await page.mouse.move(endX, initial.y + initial.height / 2, { steps: 8 }); await expect(range).toHaveClass(/range-pressed/); await expect.poll(() => knob.evaluate((el) => el.getBoundingClientRect().width)).toBeCloseTo(initial.width, 1); - await expect.poll(() => range.evaluate((el: any) => el.value)).toEqual({ lower: endpoint, upper: endpoint }); + await expect.poll(() => range.evaluate((el: HTMLIonRangeElement) => el.value)).toEqual({ lower: endpoint, upper: endpoint }); await page.mouse.up(); }); } @@ -47,7 +47,7 @@ for (const direction of ['ltr', 'rtl']) { }); const range = page.locator('#range-probe'); await expect(range).toHaveClass(/hydrated/); - await range.evaluate((el: any, direction) => { + await range.evaluate((el: HTMLIonRangeElement, direction) => { el.dir = direction; el.min = 0; el.max = 100; diff --git a/demo/e2e/rtl.spec.ts b/demo/e2e/rtl.spec.ts deleted file mode 100644 index 7c574bce..00000000 --- a/demo/e2e/rtl.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect, test } from '@playwright/test'; - -for (const direction of ['ltr', 'rtl'] as const) { - test(`${direction} mirrors logical spacing with asymmetric safe areas`, async ({ page }) => { - await page.goto('/main/index', { waitUntil: 'networkidle' }); - await page.evaluate((dir) => { - const fixture = document.createElement('div'); - fixture.id = 'rtl-probe'; - fixture.dir = dir; - fixture.style.cssText = - 'position:fixed;inset:0;z-index:99999;--ion-safe-area-left:20px;--ion-safe-area-right:8px;--ios-theme-menu-width:0px'; - - const fab = document.createElement('ion-fab'); - fab.id = 'fab-probe'; - fab.mode = 'ios'; - fab.className = 'fab-horizontal-start'; - fab.innerHTML = '+'; - - const tabs = document.createElement('ion-tab-bar'); - tabs.id = 'tabs-probe'; - tabs.mode = 'ios'; - tabs.slot = 'bottom'; - tabs.className = 'tab-bar-position-start'; - tabs.innerHTML = 'OneTwo'; - - const list = document.createElement('ion-list'); - list.id = 'list-probe'; - list.mode = 'ios'; - list.className = 'list-inset'; - list.innerHTML = - '
Supporting text
Item
Footer'; - - const toolbar = document.createElement('ion-toolbar'); - toolbar.id = 'toolbar-probe'; - toolbar.mode = 'ios'; - toolbar.className = 'toolbar-searchbar'; - toolbar.innerHTML = 'Action'; - - fixture.append(fab, tabs, list, toolbar); - document.body.append(fixture); - }, direction); - - await expect(page.locator('#fab-probe')).toHaveClass(/hydrated/); - await expect(page.locator('#tabs-probe')).toHaveClass(/hydrated/); - await expect(page.locator('#list-probe')).toHaveClass(/hydrated/); - - const values = await page.locator('#rtl-probe').evaluate((fixture) => { - const direction = fixture.getAttribute('dir'); - const style = (selector: string) => getComputedStyle(fixture.querySelector(selector)!); - const fab = fixture.querySelector('#fab-probe')!.getBoundingClientRect(); - const tabs = fixture.querySelector('#tabs-probe')!.getBoundingClientRect(); - const note = style('#list-probe > ion-note'); - const radio = style('.radio-group-top'); - const toolbarButtons = style('#toolbar-probe ion-buttons'); - - return { - fabStart: direction === 'ltr' ? fab.left : innerWidth - fab.right, - tabsStart: direction === 'ltr' ? tabs.left : innerWidth - tabs.right, - noteStart: note.marginInlineStart, - noteEnd: note.marginInlineEnd, - radioStart: radio.paddingInlineStart, - radioEnd: radio.paddingInlineEnd, - toolbarGap: toolbarButtons.marginInlineEnd, - }; - }); - - expect(values.fabStart).toBeCloseTo(16, 1); - expect(values.tabsStart).toBeCloseTo(direction === 'ltr' ? 36 : 24, 1); - expect(values.noteStart).toBe(direction === 'ltr' ? '40px' : '28px'); - expect(values.noteEnd).toBe(direction === 'ltr' ? '28px' : '40px'); - expect(values.radioStart).toBe(direction === 'ltr' ? '40px' : '28px'); - expect(values.radioEnd).toBe(direction === 'ltr' ? '28px' : '40px'); - expect(values.toolbarGap).toBe('6px'); - }); -} diff --git a/demo/e2e/screenshot.spec.ts b/demo/e2e/screenshot.spec.ts index 4183a3ba..5bec2e23 100644 --- a/demo/e2e/screenshot.spec.ts +++ b/demo/e2e/screenshot.spec.ts @@ -46,13 +46,13 @@ const routes = [ { path: '/main/index/reorder', name: 'reorder' }, { path: '/main/index/tabs', name: 'tabs' }, { path: '/main/index/toolbar', name: 'toolbar' }, -].sort(() => Math.random() - 0.5); +]; const prepareScreenShot = async (page: Page, routeName: string) => { - await page.waitForTimeout(1000); await page.waitForSelector('ion-content[role="main"]', { timeout: 10000 }); + await page.evaluate(() => document.fonts.ready); if (!routeName.includes(':')) { - const scrollHeight = await page.locator('ion-content[role="main"]').evaluate(async (el: any) => { + const scrollHeight = await page.locator('ion-content[role="main"]').evaluate(async (el: HTMLIonContentElement) => { const scrollEl = await el.getScrollElement(); return scrollEl.scrollHeight; }); @@ -60,11 +60,69 @@ const prepareScreenShot = async (page: Page, routeName: string) => { } }; +const prepareVerticalBarsLayout = async (page: Page, direction: 'ltr' | 'rtl', width = 700) => { + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); + await page.setViewportSize({ width, height: 900 }); + await page.goto('/main/index', { waitUntil: 'networkidle' }); + await page.waitForSelector('ion-content[role="main"]'); + await page.evaluate(() => document.fonts.ready); + await page.evaluate((direction) => { + const app = document.querySelector('ion-app')!; + app.dir = direction; + app.style.setProperty('--ios-theme-vertical-bars-safe-area-left', '76px'); + app.style.setProperty('--ios-theme-vertical-bars-safe-area-right', '84px'); + app.style.setProperty('--ion-safe-area-left', '76px'); + app.style.setProperty('--ion-safe-area-right', '84px'); + app.classList.add('ios-theme-vertical-bars'); + + const content = document.querySelector('ion-content[role="main"]')!; + const logicalLeft = direction === 'ltr' ? 'start' : 'end'; + for (const physicalSide of ['left', 'right'] as const) { + const logicalSide = physicalSide === 'left' ? logicalLeft : logicalLeft === 'start' ? 'end' : 'start'; + const fab = document.createElement('ion-fab'); + fab.mode = 'ios'; + fab.dir = direction; + fab.horizontal = logicalSide; + fab.vertical = 'center'; + fab.slot = 'fixed'; + fab.style.setProperty('--ios-theme-menu-width', '0px'); + fab.style.setProperty('--ios26-menu-width', '0px'); + fab.innerHTML = `${physicalSide === 'left' ? 'L' : 'R'}`; + content.append(fab); + } + }, direction); +}; + +const prepareVerticalBarsBackButton = async (page: Page, direction: 'ltr' | 'rtl') => { + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/button', { waitUntil: 'networkidle' }); + await page.evaluate(() => document.fonts.ready); + await page.locator('ion-app').evaluate((app, dir) => { + app.dir = dir; + app.style.setProperty('--ios-theme-vertical-bars-safe-area-right', '84px'); + app.classList.add('ios-theme-vertical-bars'); + }, direction); + await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toBeVisible(); +}; + +const prepareVerticalBarsToolbar = async (page: Page) => { + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell', { waitUntil: 'networkidle' }); + await page.evaluate(() => document.fonts.ready); + await page.locator('ion-app').evaluate((app) => { + app.style.setProperty('--ios-theme-vertical-bars-safe-area-right', '84px'); + app.classList.add('ios-theme-vertical-bars'); + }); + await expect(page.locator('ion-app > ion-buttons.ios-theme-vertical-bars-toolbar-projection')).not.toHaveCount(0); +}; + test.describe('Screenshot Tests - All Routes', () => { for (const route of routes) { test(`should match screenshot for ${route.name}`, async ({ page }) => { // Set E2E testing flag to disable animations - await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); await page.goto(route.path, { waitUntil: 'networkidle' }); await prepareScreenShot(page, route.name); await expect(page).toHaveScreenshot(`${route.name}.png`, { @@ -81,7 +139,7 @@ test.describe('Screenshot Tests - Dark Mode', () => { for (const route of routes) { test(`should match dark mode screenshot for ${route.name}`, async ({ page }) => { // Set E2E testing flag to disable animations - await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); await page.goto(route.path, { waitUntil: 'networkidle' }); await page.evaluate(async () => { document.documentElement.classList.add('ion-palette-dark'); @@ -95,3 +153,50 @@ test.describe('Screenshot Tests - Dark Mode', () => { }); } }); + +test.describe('Screenshot Tests - VerticalBars Layout', () => { + for (const direction of ['ltr', 'rtl'] as const) { + test(`should keep the app foreground clear of verticalBars system UI in ${direction.toUpperCase()}`, async ({ page }) => { + await prepareVerticalBarsLayout(page, direction); + await expect(page).toHaveScreenshot(`vertical-bars-layout-${direction}.png`, { animations: 'disabled' }); + }); + + test(`should keep verticalBars tabs in the system rail beside a visible split pane in ${direction.toUpperCase()}`, async ({ page }) => { + await prepareVerticalBarsLayout(page, direction, 1024); + await expect(page.locator('ion-split-pane')).toHaveClass(/split-pane-visible/); + await expect(page).toHaveScreenshot(`vertical-bars-split-pane-${direction}.png`, { animations: 'disabled' }); + }); + } +}); + +test('Settings split-menu widths on iPhone Duo', async ({ page }) => { + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); + await page.setViewportSize({ width: 951, height: 669 }); + await page.goto('/main/index', { waitUntil: 'networkidle' }); + const splitPane = page.locator('ion-split-pane'); + await splitPane.evaluate((element) => { + element.setAttribute('when', '(min-width: 900px)'); + }); + await expect(splitPane).toHaveClass(/split-pane-visible/); + await page.evaluate(() => document.fonts.ready); + await expect(page).toHaveScreenshot('duo-split-menu-flat.png', { animations: 'disabled' }); + + await splitPane.evaluate((element) => { + element.classList.add('ios-theme-split-pane-half-open'); + }); + await expect(page).toHaveScreenshot('duo-split-menu-half-open.png', { animations: 'disabled' }); +}); + +test.describe('Screenshot Tests - VerticalBars Back Button', () => { + for (const direction of ['ltr', 'rtl'] as const) { + test(`should project the active back button into the physical system rail in ${direction.toUpperCase()}`, async ({ page }) => { + await prepareVerticalBarsBackButton(page, direction); + await expect(page).toHaveScreenshot(`vertical-bars-back-button-${direction}.png`, { animations: 'disabled' }); + }); + } + + test('should project icon actions while keeping text actions in the toolbar', async ({ page }) => { + await prepareVerticalBarsToolbar(page); + await expect(page).toHaveScreenshot('vertical-bars-toolbar-actions.png', { animations: 'disabled' }); + }); +}); diff --git a/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-flat.png b/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-flat.png new file mode 100644 index 00000000..4d2e1872 Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-flat.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-half-open.png b/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-half-open.png new file mode 100644 index 00000000..487d4e69 Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-half-open.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png new file mode 100644 index 00000000..8252345f Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png new file mode 100644 index 00000000..b10f62ef Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png new file mode 100644 index 00000000..74e72e4b Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png new file mode 100644 index 00000000..add7d4db Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png new file mode 100644 index 00000000..e587a601 Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png new file mode 100644 index 00000000..36319c5a Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png new file mode 100644 index 00000000..03cddb11 Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png differ diff --git a/demo/e2e/submit-color.spec.ts b/demo/e2e/submit-color.spec.ts deleted file mode 100644 index a2956c56..00000000 --- a/demo/e2e/submit-color.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { expect, test } from '@playwright/test'; - -for (const dark of [false, true]) { - test(`glass button pressed shading: dark=${dark}`, async ({ page }) => { - await page.goto('/main/index/button'); - await expect(page.locator('ion-tab-bar')).toBeVisible(); - await page.evaluate((dark) => { - document.documentElement.classList.toggle('ion-palette-dark', dark); - const fixture = document.createElement('div'); - fixture.id = 'glass-button-probe'; - fixture.innerHTML = ` - Grouped - Standalone`; - document.body.append(fixture); - }, dark); - const buttons = page.locator('#glass-button-probe ion-button'); - await expect(buttons.locator('[part="native"]')).toHaveCount(2); - const read = () => - page.locator('#glass-button-probe').evaluate((fixture) => - [ - fixture.querySelector('ion-buttons')!, - fixture.querySelector(':scope > ion-button')!.shadowRoot!.querySelector('[part="native"]')!, - ].map((el) => { - const style = getComputedStyle(el); - return { background: style.backgroundColor, shadow: style.boxShadow }; - }), - ); - const rest = await read(); - await buttons.evaluateAll((elements) => elements.forEach((el) => el.classList.add('ion-activated'))); - const pressed = await read(); - if (dark) { - for (const surface of pressed) { - expect(surface.background).toBe('rgba(255, 255, 255, 0.5)'); - expect(surface.shadow).toContain('rgba(0, 0, 0, 0.32)'); - expect(surface.shadow).toContain('inset'); - } - } else { - for (const surface of pressed) expect(surface.background).toBe('rgba(255, 255, 255, 0.96)'); - } - await buttons.evaluateAll((elements) => elements.forEach((el) => el.classList.remove('ion-activated'))); - expect(await read()).toEqual(rest); - }); -} - -for (const color of [undefined, 'primary', 'danger', 'brand']) { - test(`submit preserves ${color ?? 'default'} color and contrast when pressed`, async ({ page }) => { - await page.goto('/main/index/button'); - await page.addStyleTag({ content: '.ion-color-brand { --ion-color-base: #ffee00; --ion-color-contrast: #112233; }' }); - await page.evaluate((color) => { - const button = document.createElement('ion-button'); - button.id = 'submit-probe'; - button.mode = 'ios'; - button.setAttribute('type', 'submit'); - button.color = color; - button.textContent = 'Submit'; - button.style.cssText = 'position:fixed;top:100px;left:20px;z-index:99999'; - document.body.append(button); - }, color); - const button = page.locator('#submit-probe'); - await expect(button).toHaveClass(/hydrated/); - const expected = await button.evaluate((element, color) => { - const style = getComputedStyle(element); - const background = style.getPropertyValue(color ? '--ion-color-base' : '--ion-color-primary').trim(); - const contrast = style.getPropertyValue(color ? '--ion-color-contrast' : '--ion-color-primary-contrast').trim(); - const probe = document.createElement('span'); - probe.style.color = contrast; - document.body.append(probe); - const foreground = getComputedStyle(probe).color; - probe.remove(); - return { background, foreground }; - }, color); - const native = button.locator('button'); - await expect(native).toHaveCSS('color', expected.foreground); - await button.evaluate((element) => element.classList.add('ion-activated')); - await expect - .poll(() => button.evaluate((element) => getComputedStyle(element).getPropertyValue('--background-activated').trim())) - .toBe(expected.background); - await expect(native).toHaveCSS('color', expected.foreground); - }); -} diff --git a/demo/e2e/tab-bar-position.spec.ts b/demo/e2e/tab-bar-position.spec.ts deleted file mode 100644 index 06e32e7f..00000000 --- a/demo/e2e/tab-bar-position.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, test } from '@playwright/test'; - -for (const direction of ['ltr', 'rtl']) { - for (const slot of ['top', 'bottom']) { - for (const count of [2, 3, 4, 5] as const) { - test(`${direction} ${slot} ${count} tabs support all positions without shifting the press origin`, async ({ page }) => { - await page.setViewportSize({ width: 800, height: 900 }); - await page.goto('/main/index'); - await page.evaluate( - ({ direction, slot, count }) => { - const fixture = document.createElement('div'); - fixture.style.cssText = 'position:fixed;inset:0;z-index:99999;--ion-safe-area-left:20px;--ion-safe-area-right:8px'; - fixture.dir = direction; - const bar = document.createElement('ion-tab-bar'); - bar.id = 'position-probe'; - bar.mode = 'ios'; - bar.slot = slot; - for (let i = 0; i < count; i++) { - const button = document.createElement('ion-tab-button'); - button.mode = 'ios'; - button.textContent = `Tab ${i + 1}`; - bar.append(button); - } - fixture.append(bar); - document.body.append(fixture); - }, - { direction, slot, count }, - ); - const bar = page.locator('#position-probe'); - await expect(bar).toHaveClass(/hydrated/); - for (const position of ['start', 'center', 'end']) { - await bar.evaluate((element, position) => { - element.classList.remove('tab-bar-position-start', 'tab-bar-position-center', 'tab-bar-position-end'); - element.classList.add(`tab-bar-position-${position}`); - }, position); - const normal = (await bar.boundingBox())!; - const nativeWidth = { 2: 188, 3: 274, 4: 336, 5: 414 }[count]!; - // A half-pixel border rounds differently at desktop and device DPR. - expect(Math.abs(normal.width - nativeWidth)).toBeLessThanOrEqual(1); - if (position === 'center') { - expect(normal.x + normal.width / 2).toBeCloseTo(406, 1); - } else if ((position === 'start') === (direction === 'ltr')) { - expect(normal.x).toBeCloseTo(36, 1); - } else { - expect(normal.x + normal.width).toBeCloseTo(776, 1); - } - await bar - .locator('ion-tab-button') - .first() - .evaluate((button) => button.classList.add('ion-activated')); - await expect - .poll(() => bar.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).a)) - .toBeCloseTo(1.038, 3); - const pressed = (await bar.boundingBox())!; - expect(pressed.x + pressed.width / 2).toBeCloseTo(normal.x + normal.width / 2, 1); - await bar - .locator('ion-tab-button') - .first() - .evaluate((button) => button.classList.remove('ion-activated')); - await expect.poll(() => bar.evaluate((element) => element.getBoundingClientRect().width)).toBeCloseTo(normal.width, 1); - } - }); - } - } -} diff --git a/demo/e2e/toggle.spec.ts b/demo/e2e/toggle.spec.ts index 07815463..5a677417 100644 --- a/demo/e2e/toggle.spec.ts +++ b/demo/e2e/toggle.spec.ts @@ -102,30 +102,6 @@ test('reduced motion removes the release transition', async ({ page }) => { expect((await toggle.locator('[part="handle"]').boundingBox())!.height).toBe(24); }); -test('native resting and held dimensions preserve the handle center', async ({ page }) => { - const toggle = page.locator('.section-example ion-toggle').first(); - const track = toggle.locator('[part="track"]'); - const handle = toggle.locator('[part="handle"]'); - await track.scrollIntoViewIfNeeded(); - const rest = (await handle.boundingBox())!; - const bounds = (await track.boundingBox())!; - expect(bounds.width).toBe(63); - expect(bounds.height).toBe(28); - expect(rest.width).toBe(37); - expect(rest.height).toBe(24); - await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); - await page.mouse.down(); - // Measure the held state, not the same width on the initial spring's way up. - await page.waitForTimeout(400); - await expect.poll(async () => Math.round((await handle.boundingBox())!.width)).toBe(58); - const held = (await handle.boundingBox())!; - expect(held.height).toBeCloseTo(38.333, 0); - expect(held.x + held.width / 2).toBeCloseTo(rest.x + rest.width / 2, 1); - await page.mouse.up(); - await expect.poll(async () => Math.round((await handle.boundingBox())!.width)).toBe(37); - await expect.poll(async () => Math.round((await handle.boundingBox())!.x - rest.x)).toBe(22); -}); - 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')); @@ -190,24 +166,6 @@ for (const duration of [50, 80, 100, 150, 600]) { }); } -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); -}); - test('native handle still moves when lens CSS is unavailable', async ({ page }) => { const removed = await page.evaluate(() => { let count = 0; diff --git a/demo/e2e/vertical-bars-back-button.spec.ts b/demo/e2e/vertical-bars-back-button.spec.ts new file mode 100644 index 00000000..f4c3cd38 --- /dev/null +++ b/demo/e2e/vertical-bars-back-button.spec.ts @@ -0,0 +1,213 @@ +import { expect, test } from '@playwright/test'; +import type { TestAppElement } from './native-shell-mock'; + +test('verticalBars mode replaces the toolbar back button with an interactive Web projection', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + + const source = page.locator('app-button ion-header ion-back-button').first(); + const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); + await expect(projection).toHaveCount(1); + + await source.evaluate((element) => { + const original = element.getBoundingClientRect.bind(element); + const app = document.querySelector('ion-app') as TestAppElement; + app.verticalBarsBackButtonReads = 0; + element.getBoundingClientRect = () => { + app.verticalBarsBackButtonReads = (app.verticalBarsBackButtonReads ?? 0) + 1; + return original(); + }; + element.toggleAttribute('data-projection-sync'); + }); + await page.waitForTimeout(100); + const settledReads = await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).verticalBarsBackButtonReads); + await page.waitForTimeout(150); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).verticalBarsBackButtonReads)).toBe(settledReads); + + await projection.click(); + await expect(page).toHaveURL(/\/main\/index$/); + await expect(projection).toHaveCount(0); +}); + +test('disabling verticalBars mode restores the toolbar back button', async ({ page }) => { + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const source = page.locator('app-button ion-header ion-back-button').first(); + await expect(source).toBeHidden(); + + await app.evaluate((element) => element.classList.remove('ios-theme-vertical-bars')); + await expect(source).toBeVisible(); + await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toHaveCount(0); +}); + +test('Native UI Shell suspension synchronously restores and resumes verticalBars ownership', async ({ page }) => { + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const source = page.locator('app-button ion-header ion-back-button').first(); + const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(projection).toBeVisible(); + + await page.evaluate(async () => { + const app = document.querySelector('ion-app') as TestAppElement; + app.verticalBarsLease = await app.nativeUIShell!.suspend(); + }); + await expect(source).toBeVisible(); + await expect(projection).toHaveCount(0); + + await page.evaluate(async () => (document.querySelector('ion-app') as TestAppElement).verticalBarsLease!.resume()); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); +}); + +test('a stale suspension lease cannot re-hide Web controls after shell teardown', async ({ page }) => { + await page.goto('/main/index/button'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const source = page.locator('app-button ion-header ion-back-button').first(); + await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toBeVisible(); + + await page.evaluate(async () => { + const shell = (document.querySelector('ion-app') as TestAppElement).nativeUIShell!; + const lease = await shell.suspend(); + await shell.destroy(); + await lease.resume(); + }); + await expect(source).toBeVisible(); + await expect(page.locator('html')).not.toHaveClass(/ios-theme-native-ui-shell-prehide/); +}); + +test('Web toolbar projection switches at WillLeave and restores on cancellation', async ({ page }) => { + await page.goto('/main/index/button'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(projection).toBeVisible(); + const routedPage = page.locator('app-button.ion-page'); + + await routedPage.evaluate((element) => { + element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true })); + }); + await expect(projection).toHaveCount(0); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true }))); + await page.waitForTimeout(150); + await expect(projection).toHaveCount(0); + + await routedPage.evaluate((element) => { + element.classList.add('ion-page-invisible'); + element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true })); + }); + await expect(projection).toBeVisible(); + await routedPage.evaluate((element) => { + element.classList.remove('ion-page-invisible'); + element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true })); + }); + + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await expect(projection).toHaveCount(0); + await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeVerticalBarsTransitionCanceled', { bubbles: true }))); + await expect(projection).toBeVisible(); +}); + +test('turning verticalBars on during a transition honors its success or cancellation', async ({ page }) => { + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.classList.remove('ios-theme-vertical-bars')); + const routedPage = page.locator('app-button.ion-page'); + const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + await expect(projection).toHaveCount(0); + await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeVerticalBarsTransitionCanceled', { bubbles: true }))); + await expect(projection).toBeVisible(); + + await app.evaluate((element) => element.classList.remove('ios-theme-vertical-bars')); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true }))); + await expect(projection).toHaveCount(0); +}); + +test('verticalBars back projection respects source opt-out regardless of Ionic mode', async ({ page }) => { + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + const source = page.locator('app-button ion-header ion-back-button').first(); + const toolbar = source.locator('xpath=ancestor::ion-toolbar'); + const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + + await source.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); + await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + await expect(projection).toHaveCount(0); + await expect(source).toBeVisible(); + + await source.evaluate((element) => { + element.classList.remove('ios-theme-shell-disabled'); + (element as HTMLIonBackButtonElement).mode = 'md'; + }); + await toolbar.evaluate((element) => element.classList.remove('ios')); + await expect(source).toBeVisible(); + await page + .locator('app-button.ion-page') + .evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true }))); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); + expect(await projection.evaluate((element: HTMLIonBackButtonElement) => element.mode)).toBe('md'); + await expect(projection).toHaveCSS('position', 'fixed'); +}); + +test('verticalBars toolbar projects icon actions and preserves text-only actions', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + + const sourceGroup = page.locator('app-native-ui-shell ion-header ion-buttons[slot="end"]').first(); + const textAction = sourceGroup.getByText('Cancel', { exact: true }); + const iconSource = sourceGroup.locator('ion-button[aria-label="Save"]'); + const iconProjection = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Save"]'); + + await expect(sourceGroup).toBeVisible(); + await expect(textAction).toBeVisible(); + await expect(iconSource).toBeHidden(); + await expect(iconProjection).toBeVisible(); + await iconProjection.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); + + await textAction.evaluate((element) => element.remove()); + await expect(sourceGroup).toBeHidden(); +}); + +test('theme-disabled ion-buttons project their icon actions as independent controls', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell'); + const app = page.locator('ion-app'); + const sourceGroup = page.locator('[data-glass-group]'); + await sourceGroup.evaluate((element) => element.classList.add('ionic-theme-disabled')); + await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + await expect( + page.locator( + 'ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection:has(ion-icon:is([name="logo-github"], [name="refresh-circle"]))', + ), + ).toHaveCount(2); + await expect(sourceGroup).toBeHidden(); +}); + +test('horizontal-only keeps a group and an individual button in their Web toolbar', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell'); + const group = page.locator('[data-glass-group]'); + const save = page.locator('app-native-ui-shell ion-button[type="submit"]'); + await group.evaluate((element) => element.classList.add('ios-theme-horizontal-only')); + await save.evaluate((element) => element.classList.add('ios-theme-horizontal-only')); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + + await expect(group).toBeVisible(); + await expect(save).toBeVisible(); + await expect(page.locator('ion-app > ion-buttons.ios-theme-vertical-bars-toolbar-projection')).toHaveCount(0); + await expect(page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Save"]')).toHaveCount(0); + await save.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); +}); diff --git a/demo/e2e/vertical-bars-layout.spec.ts b/demo/e2e/vertical-bars-layout.spec.ts new file mode 100644 index 00000000..3d1abd65 --- /dev/null +++ b/demo/e2e/vertical-bars-layout.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; + +for (const direction of ['ltr', 'rtl'] as const) { + test(`menus respect verticalBars safe-area insets in ${direction}`, async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/main/index', { waitUntil: 'networkidle' }); + const menu = page.locator('ion-menu'); + + const result = await menu.evaluate(async (element: HTMLIonMenuElement, direction) => { + const app = document.querySelector('ion-app')!; + app.dir = direction; + app.classList.add('ios-theme-vertical-bars'); + element.side = direction === 'ltr' ? 'end' : 'start'; + await new Promise(requestAnimationFrame); + const container = element.shadowRoot!.querySelector('[part~="container"]')!; + await element.open(false); + const defaultHostBounds = element.getBoundingClientRect(); + const defaultRightOffset = innerWidth - container.getBoundingClientRect().right; + await element.close(false); + app.style.setProperty('--ios-theme-vertical-bars-safe-area-left', '76px'); + app.style.setProperty('--ios-theme-vertical-bars-safe-area-right', '84px'); + app.style.setProperty('--ion-safe-area-left', '76px'); + app.style.setProperty('--ion-safe-area-right', '84px'); + const offsets = []; + for (const side of ['start', 'end'] as const) { + element.side = side; + await new Promise(requestAnimationFrame); + await element.open(false); + const bounds = element.getBoundingClientRect(); + const containerBounds = container.getBoundingClientRect(); + const physicalSide = side === 'start' ? (direction === 'ltr' ? 'left' : 'right') : direction === 'ltr' ? 'right' : 'left'; + offsets.push({ + side, + physicalSide, + hostWidthPreserved: bounds.width > 0 && bounds.width === defaultHostBounds.width, + offset: physicalSide === 'left' ? containerBounds.left : innerWidth - containerBounds.right, + }); + await element.close(false); + } + const contentStyle = getComputedStyle(element.querySelector('ion-content')!); + + const modal = document.createElement('ion-modal'); + modal.mode = 'ios'; + modal.style.setProperty('--ion-safe-area-right', '12px'); + const modalContent = document.createElement('ion-content'); + modalContent.mode = 'ios'; + modal.append(modalContent); + app.append(modal); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + if (!modalContent.classList.contains('ios')) throw new Error('Expected an iOS ion-content fixture'); + return { + defaultHostWidthPositive: defaultHostBounds.width > 0, + defaultRightOffset, + offsets, + safeAreaLeft: contentStyle.getPropertyValue('--ion-safe-area-left').trim(), + safeAreaRight: contentStyle.getPropertyValue('--ion-safe-area-right').trim(), + modalSafeAreaRight: getComputedStyle(modalContent).getPropertyValue('--ion-safe-area-right').trim(), + }; + }, direction); + + expect({ hostWidthPositive: result.defaultHostWidthPositive, containerRight: result.defaultRightOffset }).toEqual({ + hostWidthPositive: true, + containerRight: 80, + }); + expect(result.offsets).toEqual( + direction === 'ltr' + ? [ + { side: 'start', physicalSide: 'left', hostWidthPreserved: true, offset: 76 }, + { side: 'end', physicalSide: 'right', hostWidthPreserved: true, offset: 84 }, + ] + : [ + { side: 'start', physicalSide: 'right', hostWidthPreserved: true, offset: 84 }, + { side: 'end', physicalSide: 'left', hostWidthPreserved: true, offset: 76 }, + ], + ); + expect(result.safeAreaLeft).toBe('0px'); + expect(result.safeAreaRight).toBe('0px'); + expect(result.modalSafeAreaRight).toBe('12px'); + }); +} diff --git a/demo/e2e/vertical-bars-standalone.spec.ts b/demo/e2e/vertical-bars-standalone.spec.ts new file mode 100644 index 00000000..b09c0241 --- /dev/null +++ b/demo/e2e/vertical-bars-standalone.spec.ts @@ -0,0 +1,54 @@ +import { resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { compile } from 'sass'; + +const verticalBars = compile(resolve(__dirname, '../../src/styles/vertical-bars.scss')).css; + +test('Vertical Control Area works in md mode with Ionic CSS and no iOS 27 theme', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell?verticalBarsOnly=1&ionicMode=md'); + await expect(page.locator('ion-app')).toHaveClass(/\bmd\b/); + await page.evaluate(() => { + for (const sheet of Array.from(document.styleSheets)) { + for (let index = sheet.cssRules.length - 1; index >= 0; index--) { + const rule = sheet.cssRules[index]; + if (rule instanceof CSSSupportsRule && rule.cssText.includes('--ios27-color-scheme')) sheet.deleteRule(index); + } + } + }); + await page.addStyleTag({ content: verticalBars }); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--ios27-color-scheme').trim())).toBe(''); + const backSource = page.locator('app-native-ui-shell ion-back-button'); + await backSource.evaluate((element: HTMLIonBackButtonElement) => { + element.text = 'Return'; + element.closest('app-native-ui-shell')?.querySelector('ion-content')?.prepend(element); + }); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + + const tabBar = page.locator('#tab-bar-bottom'); + await expect.poll(async () => (await tabBar.boundingBox())?.x).toBeGreaterThan(620); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.style.setProperty('--ios-theme-vertical-bars-native-inset', '64px')); + await expect + .poll(() => + app.evaluate((element) => getComputedStyle(element).getPropertyValue('--ios-theme-vertical-bars-safe-area-right-resolved').trim()), + ) + .toBe('64px'); + await app.evaluate((element) => element.style.removeProperty('--ios-theme-vertical-bars-native-inset')); + const toolbar = page.locator('app-native-ui-shell ion-toolbar').first(); + await expect + .poll(() => toolbar.evaluate((element) => getComputedStyle(element).getPropertyValue('--ion-safe-area-right').trim())) + .toBe('0px'); + const source = page.locator('app-native-ui-shell ion-button[type="submit"]'); + const projection = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Save"]'); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); + await projection.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); + + const back = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(back).toBeVisible(); + await expect(backSource).toBeHidden(); + await back.click(); + await expect(page).toHaveURL(/\/main\/index$/); +}); diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index bd6650ea..d8cbfdb1 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -108,6 +108,8 @@ final class NativeUIShellTests: XCTestCase { entry.tap() let save = nativeButton(app, label: "Save") XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertFalse(nativeButton(app, label: "Cancel").exists, "Text-only toolbar actions must remain in Web") + XCTAssertTrue(app.webViews.buttons["Cancel"].firstMatch.exists, app.debugDescription) XCUIDevice.shared.press(.home) XCTAssertTrue(app.wait(for: .runningBackground, timeout: 5)) app.activate() @@ -249,6 +251,72 @@ final class NativeUIShellTests: XCTestCase { capture("native-tab-settings") } + func testNativeVerticalBars() throws { + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + let toggle = app.webViews.switches["iPhone Duo Mode"] + XCTAssertTrue(toggle.waitForExistence(timeout: 15), app.debugDescription) + if toggle.value as? String == "0" { toggle.tap() } + + let index = app.buttons["Index"] + let library = app.buttons["Library"] + XCTAssertTrue(index.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(library.waitForExistence(timeout: 10), app.debugDescription) + assertOnVerticalBars(index, in: app) + library.tap() + XCTAssertTrue(app.webViews.staticTexts["Library"].firstMatch.waitForExistence(timeout: 5), "The native tab did not project its activation to Web\n" + app.debugDescription) + XCTAssertTrue(library.isSelected, app.debugDescription) + index.tap() + XCTAssertTrue(toggle.waitForExistence(timeout: 5), app.debugDescription) + capture("native-vertical-bars-index") + openPage(app, name: "native-ui-shell") + let save = nativeButton(app, label: "Save") + XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) + save.tap() + XCTAssertTrue(savedOnce(app).waitForExistence(timeout: 5), app.debugDescription) + let github = app.buttons["GitHub"] + XCTAssertTrue(github.waitForExistence(timeout: 5), app.debugDescription) + github.tap() + XCTAssertTrue(app.webViews.staticTexts["Actions: 1 / 0"].waitForExistence(timeout: 5), app.debugDescription) + let refresh = app.buttons["Refresh"] + XCTAssertTrue(refresh.waitForExistence(timeout: 5), app.debugDescription) + refresh.tap() + XCTAssertTrue(app.webViews.staticTexts["Actions: 1 / 1"].waitForExistence(timeout: 5), app.debugDescription) + capture("native-vertical-bars-toolbar") + + let back = app.buttons["BackButton"] + XCTAssertTrue(back.waitForExistence(timeout: 5), app.debugDescription) + assertOnVerticalBars(back, in: app) + back.tap() + XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(nativeButton(app, label: "GitHub").waitForExistence(timeout: 10), "Index toolbar did not return after native back\n" + app.debugDescription) + openPage(app, name: "native-ui-shell") + XCTAssertTrue(back.waitForExistence(timeout: 5), app.debugDescription) + let pageMenu = nativeButton(app, label: "menu") + XCTAssertTrue(pageMenu.waitForExistence(timeout: 5), app.debugDescription) + pageMenu.tap() + let menuLink = app.webViews.links["Docs"] + XCTAssertTrue(menuLink.waitForExistence(timeout: 5), app.debugDescription) + capture("native-vertical-bars-menu-open") + for control in [pageMenu, back, save, library] { + XCTAssertTrue(control.waitForExistence(timeout: 5), "Every verticalBars rail control must remain native while the menu is open\n" + app.debugDescription) + assertOnVerticalBars(control, in: app) + XCTAssertTrue(control.isEnabled, "VerticalBars rail controls must remain enabled while Ionic disables the covered page\n" + app.debugDescription) + } + save.tap() + app.coordinate(withNormalizedOffset: CGVector(dx: 0.81, dy: 0.55)).tap() + XCTAssertTrue(menuLink.waitForNonExistence(timeout: 5), "The projected Ionic menu button did not close its menu\n" + app.debugDescription) + XCTAssertTrue(savedOnce(app).waitForExistence(timeout: 5), "The native control stopped projecting actions while the menu was open\n" + app.debugDescription) + capture("native-vertical-bars-after-menu") + } + + private func assertOnVerticalBars(_ element: XCUIElement, in app: XCUIApplication, + file: StaticString = #filePath, line: UInt = #line) { + XCTAssertGreaterThan(element.frame.midX, app.frame.width * 0.8, + "Expected native control on the physical right rail, got \(element.frame) in \(app.frame)", + file: file, line: line) + } + func testNativeShellPageAuditAndRepeatedNavigation() throws { executionTimeAllowance = 900 let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") diff --git a/demo/package-lock.json b/demo/package-lock.json index 82ac6ad5..d386d48d 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -56,13 +56,13 @@ }, "..": { "name": "@rdlabo/ionic-theme-ios27", - "version": "1.0.1", + "version": "1.0.4", "bundleDependencies": [ "@rdlabo/ionic-theme-utils" ], "license": "MIT", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#e82be4389d9209750229f5fee990046b5b1217da" + "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#44b1535387c326ed69fc239d38dbdb9776b629c2" }, "devDependencies": { "@capacitor/core": "^8.5.2", diff --git a/demo/src/app/app.config.ts b/demo/src/app/app.config.ts index de474a42..fc27fca0 100644 --- a/demo/src/app/app.config.ts +++ b/demo/src/app/app.config.ts @@ -3,7 +3,7 @@ import { provideRouter, withComponentInputBinding } from '@angular/router'; import * as allIcons from 'ionicons/icons'; import { routes } from './app.routes'; -import { IONIC_MAJOR, provideIonicAngular } from '@demo/ionic'; +import { IONIC_MAJOR, provideIonicAngular, type AnimationBuilder } from '@demo/ionic'; import { addIcons } from 'ionicons'; addIcons(allIcons); @@ -13,13 +13,13 @@ if (typeof document !== 'undefined') { } export interface IonicAnimationOptions { - navAnimation?: (...args: any[]) => any; - popoverEnter?: (...args: any[]) => any; - popoverLeave?: (...args: any[]) => any; + navAnimation?: AnimationBuilder; + popoverEnter?: AnimationBuilder; + popoverLeave?: AnimationBuilder; } // Disable animations during E2E tests for consistent screenshots -const isE2ETesting = typeof window !== 'undefined' && (window as any).IONIC_E2E_TESTING === true; +const isE2ETesting = typeof document !== 'undefined' && (document as Document & { IONIC_E2E_TESTING?: boolean }).IONIC_E2E_TESTING === true; export const createAppConfig = (animations: IonicAnimationOptions = {}): ApplicationConfig => ({ providers: [ @@ -27,7 +27,7 @@ export const createAppConfig = (animations: IonicAnimationOptions = {}): Applica provideRouter(routes, withComponentInputBinding()), provideIonicAngular({ useSetInputAPI: true, - mode: 'ios', + mode: typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('ionicMode') === 'md' ? 'md' : 'ios', backButtonText: '', animated: !isE2ETesting, ...animations, diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index cd46b6ec..c16e6d57 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 submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\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

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

These classes do not reposition a separate ion-fab; leave room for it when choosing the bar's position.

\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-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\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

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\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 27 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

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

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

\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 submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\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

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

These classes do not reposition a separate ion-fab; leave room for it when choosing the bar's position.

\n

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import {\n  IonicNativeUIShell,\n  enableVerticalControlArea,\n} from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// Start Web projection on Chrome too; it remains idle until the class is present.\nconst rail = await enableVerticalControlArea();\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  await IonicNativeUIShell.startDeviceLayoutMonitoring();\n  const listener = await IonicNativeUIShell.addListener(\'deviceLayoutChange\', ({ placement }) => rail.setPlacement(placement));\n  rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement);\n  // When updates are no longer needed:\n  // await listener.remove();\n  // await IonicNativeUIShell.stopDeviceLayoutMonitoring();\n}
\n\n

The platform.is('ios') guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

The device-layout listener reports what iOS chose; the application decides whether to call setPlacement. Passing null restores the ordinary layout. The placement includes the physical edge and its UIKit safe-area inset. The same event also includes hinge status and WebView corner radius. Call stopDeviceLayoutMonitoring() after removing the listener to stop device-layout events. Projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full enableNativeUIShell(), use setVerticalControlAreaPlacement(placement) instead of starting another runtime.

\n

Start either enableVerticalControlArea() or the full enableNativeUIShell() once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime.

\n

For Chrome development, no native plugin is needed. Add .ios-theme-vertical-bars to ion-app to simulate the right rail, or add .ios-theme-vertical-bars-left as well to simulate the left rail:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

setPlacement requires a mounted ion-app. Call it after the app root exists; passing null restores the ordinary layout.

\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\n

The class reserves 80px on the physical right in Chrome to simulate iPhone Duo. When setPlacement receives a native placement, it uses the measured UIKit inset instead of the simulated width, even when that inset is less than 80px. The left modifier moves the reservation to the physical left. Override --ios-theme-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different layout.

\n

This keeps routers and component backgrounds full-viewport. ion-content moves its scroll foreground, ion-toolbar moves its container foreground, and ion-fab adjusts only when it is placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.

\n

ion-menu, ion-modal, and ion-popover are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's side="start" and side="end" values remain logical.

\n

For a side-by-side menu on iPhone Duo, opt the ion-split-pane into the separately measured Settings layout. The sidebar is 320pt when fully unfolded and reaches the display midpoint when half-opened (50vw). The application supplies the posture; both states have the same viewport width, so a width media query cannot distinguish them:

\n
<ion-split-pane\n  [class.ios-theme-split-pane-half-open]="halfOpened"\n  contentId="main-content"\n  when="(min-width: 900px)"\n>\n  <ion-menu contentId="main-content">...</ion-menu>\n  <div id="main-content">...</div>\n</ion-split-pane>
\n\n

Set the ordinary split-pane width to 320pt in the application's stylesheet, and let the half-open class change only the width value:

\n
ion-split-pane {\n  --ios-theme-menu-width: var(--ios-theme-split-pane-width);\n  --side-width: var(--ios-theme-menu-width);\n  --side-max-width: var(--ios-theme-menu-width);\n  transition: --ios-theme-split-pane-width 300ms ease;\n}
\n\n

The registered --ios-theme-split-pane-width defaults to 320px; .ios-theme-split-pane-half-open sets it to 50vw. Set halfOpened from deviceLayoutChange.hingeStatus (and read the initial value with getDeviceLayout). The exported HingeStatus enum has Unavailable, Closed, PartiallyOpen, and FullyOpen; Unavailable means no hinge is available. Ionic's when decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout works without the iOS 27 theme and does not enable Vertical Bars or move an overlay menu.

\n

These values are web-layout simulation inputs. They are independent from Ionic's normal iPhone safe-area variables and do not change ordinary iPhone layouts unless the opt-in class is present.

\n

When the app contains ion-tabs, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different position. Without Native UI Shell, the stable Web rail is icon-only, matching a four-tab SwiftUI TabView on iPhone Duo. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar receives pointer input in the simulated system region. Use ion-menu when navigation should become a sidebar; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. The Ionic controls remain the sources of labels, icons, selected/disabled state, form submission, routing, and click handlers while SwiftUI owns adaptive placement and interaction. Fixed-toolbar actions need an icon or SVG, no direct text node, and standard fill="default" or fill="clear" to be eligible for the side rail. Text-only actions stay in the original Web toolbar. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the Web toolbar. Placement is chosen when a routed page enters; changing an existing button's content does not move it between the toolbar and rail until the page leaves and re-enters. Menus, modals, and popovers retain their own toolbar layout.

\n

On Web, Android, or when native projection is unavailable, the Web tab bar and fixed-toolbar clones remain the fallback. Those projections also work when no ion-tabs exists; text-only actions stay in the original Web toolbar. Disabling the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-toolbar-top when the simulated system controls use a different vertical layout.

\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-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\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

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\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 27 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

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

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

\n'; diff --git a/demo/src/app/index/index-page.component.html b/demo/src/app/index/index-page.component.html index f508efea..1c12496c 100644 --- a/demo/src/app/index/index-page.component.html +++ b/demo/src/app/index/index-page.component.html @@ -6,9 +6,11 @@ Index + - @@ -21,6 +23,9 @@ Settings + + iPhone Duo Mode + Dark Mode diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index e1a2138e..c20f829d 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -19,6 +19,8 @@ import { ToggleCustomEvent, } from '@demo/ionic'; import { ActivatedRoute, Router } from '@angular/router'; +import { IonicNativeUIShell, setVerticalControlAreaPlacement } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { Capacitor } from '@capacitor/core'; interface IComponent { name: string; @@ -84,6 +86,10 @@ export class IndexPageComponent { readonly #route = inject(ActivatedRoute); readonly #document = inject(DOCUMENT); + get verticalBarsModeEnabled() { + return !!this.#document.querySelector('ion-app.ios-theme-vertical-bars'); + } + async navigateNativeUiShell() { await this.#router.navigate(['native-ui-shell'], { relativeTo: this.#route }); } @@ -95,4 +101,11 @@ export class IndexPageComponent { changeColorMode(event: ToggleCustomEvent) { this.#document.documentElement.classList.toggle('ion-palette-dark', event.detail.checked); } + + async changeVerticalBarsMode(event: ToggleCustomEvent) { + if (!event.detail.checked) return setVerticalControlAreaPlacement(null); + const placement = + Capacitor.getPlatform() === 'ios' ? (await IonicNativeUIShell.getDeviceLayout()).placement : ({ edge: null, inset: 0 } as const); + setVerticalControlAreaPlacement(placement.edge ? placement : 'right'); + } } diff --git a/demo/src/app/native-ui-shell/native-ui-shell.page.html b/demo/src/app/native-ui-shell/native-ui-shell.page.html index 866f778e..f405bda9 100644 --- a/demo/src/app/native-ui-shell/native-ui-shell.page.html +++ b/demo/src/app/native-ui-shell/native-ui-shell.page.html @@ -3,6 +3,7 @@ Native UI Shell + Cancel Save @@ -17,11 +18,11 @@ - + Actions: {{ github() }} / {{ refreshes() }} - - + + diff --git a/demo/src/app/tabs/tabs.page.html b/demo/src/app/tabs/tabs.page.html index d1a2c969..014ee7b7 100644 --- a/demo/src/app/tabs/tabs.page.html +++ b/demo/src/app/tabs/tabs.page.html @@ -1,21 +1,21 @@ - + - + Index - + Docs - + Library - + Settings diff --git a/demo/src/app/tabs/tabs.page.scss b/demo/src/app/tabs/tabs.page.scss index bfffad40..ff8049a6 100644 --- a/demo/src/app/tabs/tabs.page.scss +++ b/demo/src/app/tabs/tabs.page.scss @@ -1,3 +1,14 @@ ion-item { --background: transparent; } + +// The demo enables its Duo split pane at 900px; ordinary Ionic split panes keep their own width. +ion-split-pane[when='(min-width: 900px)'] { + --ios-theme-menu-width: var(--ios-theme-split-pane-width); + --side-width: var(--ios-theme-menu-width); + --side-max-width: var(--ios-theme-menu-width); + + @media (prefers-reduced-motion: no-preference) { + transition: --ios-theme-split-pane-width 300ms ease; + } +} diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index 1543d673..89ed0826 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, inject, OnInit } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, inject, OnDestroy, OnInit, viewChild } from '@angular/core'; import { IonContent, IonIcon, @@ -14,21 +14,40 @@ import { ViewDidEnter, ViewDidLeave, } from '@demo/ionic'; -import { NavigationEnd, Router } from '@angular/router'; +import { NavigationEnd, Router, RouterLink } from '@angular/router'; import { filter } from 'rxjs'; // import { registerTabBarEffect } from '@rdlabo/ionic-theme-ios27'; import { registeredEffect, registerTabBarEffect } from '../../../../src'; +import { HingeStatus, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { Capacitor } from '@capacitor/core'; @Component({ selector: 'app-tabs', templateUrl: 'tabs.page.html', styleUrls: ['tabs.page.scss'], - imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, IonSplitPane, IonMenu, IonContent, IonList, IonItem, IonItemGroup], + imports: [ + IonTabs, + IonTabBar, + IonTabButton, + IonIcon, + IonLabel, + IonSplitPane, + IonMenu, + IonContent, + IonList, + IonItem, + IonItemGroup, + RouterLink, + ], }) -export class TabsPage implements OnInit, ViewDidEnter, ViewDidLeave { +export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, ViewDidLeave { readonly #router = inject(Router); readonly #el = inject(ElementRef); + readonly splitPane = viewChild.required>('splitPane', { read: ElementRef }); + #hingeListener?: { remove(): Promise }; + #hingeMonitoring = false; + #destroyed = false; readonly registeredGestures: registeredEffect[] = []; ngOnInit() { this.#router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe((params) => { @@ -44,6 +63,44 @@ export class TabsPage implements OnInit, ViewDidEnter, ViewDidLeave { }); } + ngAfterViewInit() { + void this.observeHinge(); + } + + setHingeStatus(status: HingeStatus) { + const splitPane = this.splitPane().nativeElement; + // The width rules key off the `when` attribute, so go through setAttribute. + splitPane.setAttribute('when', status === HingeStatus.Unavailable ? '(min-width: 992px)' : '(min-width: 900px)'); + splitPane.classList.toggle('ios-theme-split-pane-half-open', status === HingeStatus.PartiallyOpen); + } + + async observeHinge() { + if (Capacitor.getPlatform() !== 'ios') return; + await IonicNativeUIShell.startDeviceLayoutMonitoring(); + this.#hingeMonitoring = true; + if (this.#destroyed) return this.#releaseHinge(); + this.#hingeListener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ hingeStatus }) => { + if (!this.#destroyed) this.setHingeStatus(hingeStatus); + }); + const { hingeStatus } = await IonicNativeUIShell.getDeviceLayout(); + if (this.#destroyed) return this.#releaseHinge(); + this.setHingeStatus(hingeStatus); + } + + #releaseHinge() { + void this.#hingeListener?.remove(); + this.#hingeListener = undefined; + if (this.#hingeMonitoring) { + this.#hingeMonitoring = false; + void IonicNativeUIShell.stopDeviceLayoutMonitoring(); + } + } + + ngOnDestroy() { + this.#destroyed = true; + this.#releaseHinge(); + } + ionViewDidEnter() { const registerGesture = registerTabBarEffect(document.querySelector('ion-tab-bar')!); if (registerGesture) { diff --git a/demo/src/global.scss b/demo/src/global.scss index 8dac0e64..fba628bb 100644 --- a/demo/src/global.scss +++ b/demo/src/global.scss @@ -37,6 +37,7 @@ @use '@ionic/angular/css/palettes/dark.class.css'; @use 'sass:meta'; +@use '../../src/styles/vertical-bars'; // Adaptive iOS themes: capability checks, not OS version. See README.md. // `overflow-anchor: auto` selects the newer theme; `text-wrap: pretty` without it keeps iOS 26. diff --git a/demo/src/main.ts b/demo/src/main.ts index 3082d306..e76f1057 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -2,6 +2,8 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { createAppConfig, type IonicAnimationOptions } from './app/app.config'; import { AppComponent } from './app/app.component'; import { enableNativeUIShell } from '../../src/native'; +import { IonicNativeUIShell, enableVerticalControlArea, setVerticalControlAreaPlacement } from '../../src/vertical-bars'; +import { Capacitor } from '@capacitor/core'; import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios27'; /** @@ -19,6 +21,22 @@ function loadIOSAnimations(): IonicAnimationOptions { }; } -// Demo forces mode: 'ios' (including Playwright), so do not gate on isPlatform('ios'). -bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())).catch((err) => console.error(err)); -void enableNativeUIShell().then((handle) => Object.assign(window, { nativeUIShell: handle })); +// Keep the Web fallback available in the demo; applications can choose when to enable it. +void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) + .then(async () => { + if (Capacitor.getPlatform() !== 'ios') return; + await IonicNativeUIShell.startDeviceLayoutMonitoring(); + await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => { + const app = document.querySelector('ion-app.ios-theme-vertical-bars'); + if (app) + setVerticalControlAreaPlacement( + placement.edge ? placement : app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right', + ); + }); + }) + .catch((err) => console.error(err)); +const startShell = new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; +void startShell().then((handle) => { + const app = document.querySelector('ion-app'); + if (app) Object.assign(app, { nativeUIShell: handle }); +}); diff --git a/demo/src/native-ui-shell-lifecycle.spec.ts b/demo/src/native-ui-shell-lifecycle.spec.ts index 5dc606d2..de4b8303 100644 --- a/demo/src/native-ui-shell-lifecycle.spec.ts +++ b/demo/src/native-ui-shell-lifecycle.spec.ts @@ -1,29 +1,33 @@ -import { expect, test, vi } from 'vitest'; -import type { NativeUIShellHandle } from '../../src/native'; -import { bindMetricsLifecycle } from '../../src/native/lifecycle'; +import { expect, test } from 'vitest'; +import { enableNativeUIShell, setVerticalControlAreaPlacement } from '../../src/native'; -const runtime = (destroy = vi.fn(async () => {})): NativeUIShellHandle => ({ - getStatus: () => ({ state: 'native', projected: 1, updates: 1 }), - suspend: async () => ({ resume: async () => {} }), - destroy, -}); +test('placement requires ion-app and clears it when disabled', () => { + document.body.replaceChildren(); + expect(() => setVerticalControlAreaPlacement('right')).toThrow('requires ion-app'); + document.body.innerHTML = ''; + const app = document.querySelector('ion-app')!; -test('listener registration failure destroys the initialized runtime', async () => { - const destroy = vi.fn(async () => {}); - const failure = new Error('listener registration failed'); + setVerticalControlAreaPlacement('left'); + expect(app.classList.contains('ios-theme-vertical-bars-left')).toBe(true); + expect(app.style.getPropertyValue('--ios-theme-vertical-bars-native-inset')).toBe(''); - await expect(bindMetricsLifecycle(runtime(destroy), async () => Promise.reject(failure), vi.fn())).rejects.toBe(failure); - expect(destroy).toHaveBeenCalledOnce(); -}); + setVerticalControlAreaPlacement({ edge: 'right', inset: 64 }); + expect(app.classList.contains('ios-theme-vertical-bars-left')).toBe(false); + expect(app.style.getPropertyValue('--ios-theme-vertical-bars-native-inset')).toBe('64px'); -test('listener removal failure still destroys and deactivates once', async () => { - const destroy = vi.fn(async () => {}); - const deactivate = vi.fn(); - const failure = new Error('listener removal failed'); - const handle = await bindMetricsLifecycle(runtime(destroy), async () => ({ remove: async () => Promise.reject(failure) }), deactivate); + setVerticalControlAreaPlacement(null); + expect(app.classList.contains('ios-theme-vertical-bars')).toBe(false); + expect(app.style.getPropertyValue('--ios-theme-vertical-bars-native-inset')).toBe(''); + + setVerticalControlAreaPlacement('right'); + expect(app.classList.contains('ios-theme-vertical-bars')).toBe(true); + setVerticalControlAreaPlacement(null); + document.body.replaceChildren(); +}); - await expect(handle.destroy()).rejects.toBe(failure); - await expect(handle.destroy()).rejects.toBe(failure); - expect(destroy).toHaveBeenCalledOnce(); - expect(deactivate).toHaveBeenCalledOnce(); +test('a second startup cannot silently replace the active configuration', async () => { + const first = await enableNativeUIShell({ controls: { tabs: true }, verticalBarsOnly: true }); + expect(await enableNativeUIShell({ controls: { tabs: true }, verticalBarsOnly: true })).toBe(first); + await expect(enableNativeUIShell({ controls: { toolbar: true } })).rejects.toThrow('different controls'); + await first.destroy(); }); diff --git a/demo/src/native-ui-shell-transition.spec.ts b/demo/src/native-ui-shell-transition.spec.ts index 6dc9b608..698052ab 100644 --- a/demo/src/native-ui-shell-transition.spec.ts +++ b/demo/src/native-ui-shell-transition.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest'; import { connectNativeUIShellTransition, + VERTICAL_BARS_TRANSITION_CANCELED, getNativeSearchBindings, registerNativeSearch, setNativeUIShellIntegration, @@ -16,14 +17,18 @@ const fixture = () => { const pending = new Promise((resolve) => { ready = resolve; }); + let finish!: Parameters[0]; setNativeUIShellIntegration(doc, { suspend: async () => { await pending; - return () => calls.push('resume'); + return (canceled = false) => calls.push(`resume:${canceled}`); }, }); const animation = { - onFinish: () => animation, + onFinish: (callback: typeof finish) => { + finish = callback; + return animation; + }, play: async () => { calls.push('play'); }, @@ -45,7 +50,7 @@ const fixture = () => { }, } as unknown as Animation; connectNativeUIShellTransition(animation, page); - return { animation, calls, ready }; + return { animation, calls, ready, finish: (step: 0 | 1) => finish(step, animation) }; }; test('normal transition waits for native suspension', async () => { @@ -54,11 +59,11 @@ test('normal transition waits for native suspension', async () => { expect(calls).toEqual([]); ready(); await played; - expect(calls).toEqual(['play', 'resume']); + expect(calls).toEqual(['play', 'resume:false']); }); test('interactive cancellation queued during suspension keeps the last progress', async () => { - const { animation, calls, ready } = fixture(); + const { animation, calls, ready, finish } = fixture(); animation.progressStart(true); animation.progressStep(0.2); animation.progressStep(0.4); @@ -66,8 +71,10 @@ test('interactive cancellation queued during suspension keeps the last progress' expect(calls).toEqual([]); ready(); await expect.poll(() => calls).toEqual(['start', 'step:0.4', 'end:0']); + finish(0); + expect(calls).toEqual(['start', 'step:0.4', 'end:0', 'resume:true']); animation.destroy(); - expect(calls).toEqual(['start', 'step:0.4', 'end:0', 'resume', 'destroy']); + expect(calls).toEqual(['start', 'step:0.4', 'end:0', 'resume:true', 'destroy']); }); test('destroy during bridge preparation cannot start an abandoned transition', async () => { @@ -76,7 +83,7 @@ test('destroy during bridge preparation cannot start an abandoned transition', a animation.destroy(); ready(); await played; - expect(calls).toEqual(['destroy', 'resume']); + expect(calls).toEqual(['destroy', 'resume:true']); }); test('without native enablement the animation remains untouched', () => { @@ -86,6 +93,30 @@ test('without native enablement the animation remains untouched', () => { expect(animation.play).toBe(play); }); +test('a canceled Web transition releases the still-active leaving page', () => { + const doc = document.implementation.createHTMLDocument(); + const app = doc.createElement('ion-app'); + doc.body.append(app); + const entering = doc.createElement('main'); + const leaving = doc.createElement('main'); + app.append(entering, leaving); + let finish!: Parameters[0]; + const animation = { onFinish: (callback: typeof finish) => (finish = callback) } as unknown as Animation; + let cancellations = 0; + let canceledEntering: HTMLElement | undefined; + leaving.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, (event) => { + cancellations++; + canceledEntering = (event as CustomEvent<{ entering: HTMLElement }>).detail.entering; + }); + + connectNativeUIShellTransition(animation, entering, leaving); + finish(1, animation); + expect(cancellations).toBe(0); + finish(0, animation); + expect(cancellations).toBe(1); + expect(canceledEntering).toBe(entering); +}); + test('server rendering has no DOM side effects', async () => { await expect(configureNativeTransition()).resolves.toEqual({ radius: 0 }); const handle = await enableNativeUIShell(); diff --git a/demo/src/vertical-bars-web.spec.ts b/demo/src/vertical-bars-web.spec.ts new file mode 100644 index 00000000..dbc35a41 --- /dev/null +++ b/demo/src/vertical-bars-web.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'vitest'; +import { createVerticalBarsWebProjection } from '../../src/native/vertical-bars-web'; +import { setVerticalBarsPlacement } from '../../src/native/shared/dom'; + +const mountEligibleBackButton = () => { + document.body.innerHTML = ` + +
+ +
+
`; + const button = document.querySelector('ion-back-button') as HTMLElement; + button.style.display = 'block'; + button.style.visibility = 'visible'; + button.getBoundingClientRect = () => ({ width: 44, height: 44 }) as DOMRect; + // Eligibility requires prehide's per-page placement capture; mark it directly. + setVerticalBarsPlacement(button, true); +}; + +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + +test('toolbar opt-out leaves an otherwise eligible verticalBars back button under application ownership', async () => { + mountEligibleBackButton(); + const enabled = createVerticalBarsWebProjection(document, {}); + await nextFrame(); + expect(enabled.getStatus().projected).toBe(1); + expect(document.querySelector('.ios-theme-vertical-bars-back-button-projection')).not.toBeNull(); + await enabled.destroy(); + + mountEligibleBackButton(); + const handle = createVerticalBarsWebProjection(document, { controls: { tabs: true } }); + await nextFrame(); + + expect(handle.getStatus()).toEqual({ state: 'web', projected: 0, updates: 0 }); + expect(document.querySelector('ion-back-button')?.hasAttribute('data-native-ui-shell')).toBe(false); + expect(document.querySelector('.ios-theme-vertical-bars-back-button-projection')).toBeNull(); + + await handle.destroy(); +}); diff --git a/demo/tsconfig.json b/demo/tsconfig.json index be67a836..f917e6ca 100644 --- a/demo/tsconfig.json +++ b/demo/tsconfig.json @@ -22,6 +22,7 @@ "target": "es2022", "module": "es2020", "useDefineForClassFields": false, + "lib": ["es2023", "dom", "dom.iterable", "webworker.importscripts", "scripthost"], "skipLibCheck": true }, "angularCompilerOptions": { diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md new file mode 100644 index 00000000..0d4a38f1 --- /dev/null +++ b/docs/iphone-duo.md @@ -0,0 +1,238 @@ +--- +title: iPhone Duo support +--- + +# iPhone Duo support + +iPhone Duo folds along a hinge and reserves a physical system rail on one side of the display. On iOS 27.1 and later, the system reports both facts to the app: the rail's edge with its safe-area inset, and the hinge posture while the device opens and closes. + +This package provides three independent pieces for that hardware. Each works **without the iOS 27 theme stylesheets** and **without the full Native UI Shell**: + +- `dist/css/vertical-bars.css` — opt-in classes that reserve the rail's safe area, plus a registered custom property for a posture-driven split-pane width. +- `enableVerticalControlArea()` — moves eligible tabs and toolbar controls into the reserved area. On Capacitor iOS they are rendered by a native SwiftUI `TabView` and toolbar; everywhere else the same controls appear as Web clones. +- Device-layout reporting — the bundled Capacitor plugin reports rail placement, hinge status and the WebView corner radius through `getDeviceLayout()` and the `deviceLayoutChange` event. + +## Choose what to adopt + +| Goal | Stylesheet | Runtime | +| ------------------------------------------------ | ------------------- | ------------------------------------------------------------------ | +| Hinge posture only (split pane, layout switches) | `vertical-bars.css` | none — subscribe to the plugin directly | +| Vertical rail for tabs and toolbar actions | `vertical-bars.css` | `enableVerticalControlArea()` | +| Native shell plus the rail | `vertical-bars.css` | `enableNativeUIShell()` — already includes rail and posture support | + +```scss +@use '@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css'; +``` + +The stylesheet never changes ordinary Ionic UI by itself; every rule requires an opt-in class. Load it unconditionally — these values are simulation and layout inputs, independent from Ionic's normal safe-area variables. + +## Read the device layout + +`npx cap sync ios` registers the plugin automatically; no `configure` call is needed for device layout. An app that only wants the hinge posture — for example to drive a split pane — uses this API alone, with no projection runtime: + +```ts +import { Capacitor } from '@capacitor/core'; +import { HingeStatus, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; + +// The plugin has no Web implementation; guard the subscription. +if (Capacitor.getPlatform() === 'ios') { + await IonicNativeUIShell.startDeviceLayoutMonitoring(); + const listener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ hingeStatus }) => { + // apply the posture + }); + const { hingeStatus } = await IonicNativeUIShell.getDeviceLayout(); // initial value + + // When the consumer goes away: + // await listener.remove(); + // await IonicNativeUIShell.stopDeviceLayoutMonitoring(); +} +``` + +`DeviceLayout` carries: + +| Field | Meaning | +| ----------------------- | ---------------------------------------------------------------------------------------------------------- | +| `placement` | `{ edge: 'left' \| 'right' \| null, inset }` — the physical rail edge and its UIKit safe-area inset in points; `edge` is `null` on devices without a rail | +| `hingeStatus` | `HingeStatus.Unavailable` (no hinge), `Closed`, `PartiallyOpen`, or `FullyOpen` | +| `webViewMetrics.radius` | the WebView's effective top-left corner radius in points | + +Monitoring is reference-counted: each consumer pairs `startDeviceLayoutMonitoring()` with `stopDeviceLayoutMonitoring()`, and events stop when the last consumer releases it. `getDeviceLayout()` also works without monitoring for a one-shot read. While `enableVerticalControlArea()` or `enableNativeUIShell()` has native projection active it already holds a monitoring reference, so those users only add a listener and read the initial value — no extra start/stop pair. + +The plugin reports device facts and never applies them to the DOM. The application decides what each value means for its layout — this boundary keeps the native values easy to mock in tests and keeps the theme's responsibility limited to the stylesheets and runtime below. + +## Reserve the vertical rail + +Add `.ios-theme-vertical-bars` to `ion-app` to reserve the rail region on the physical right, or add `.ios-theme-vertical-bars-left` as well to use the physical left: + +```html +... +``` + +For Chrome development, no native plugin is needed — the class alone reserves `80px` to simulate iPhone Duo. When `setPlacement` receives a native placement, the measured UIKit inset replaces the simulated width, even when that inset is less than `80px`. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. + +This keeps routers and component backgrounds full-viewport. `ion-content` moves its scroll foreground, `ion-toolbar` moves its container foreground, and `ion-fab` adjusts only when placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again. + +`ion-menu`, `ion-modal`, and `ion-popover` are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's `side="start"` and `side="end"` values remain logical. + +The mode is component-mode independent: an app can keep Ionic `mode: 'md'` on iOS and still enable Vertical Bars. No component needs `mode="ios"`. + +## Project controls into the rail + +Start the standalone runtime once at application startup: + +```ts +import { Capacitor } from '@capacitor/core'; +import { enableVerticalControlArea, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; + +// Start on Chrome too; the Web projection stays idle until the class is present. +const rail = await enableVerticalControlArea(); + +if (Capacitor.getPlatform() === 'ios') { + // The runtime already monitors device layout; only subscribe. + await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => rail.setPlacement(placement)); + rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement); +} +``` + +`setPlacement` on the handle and the exported `setVerticalControlAreaPlacement` are the same function; either applies the application's chosen placement to the CSS layout and both projections. Passing `null` restores the ordinary layout. It requires a mounted `ion-app` — call it after the app root exists. The device-layout listener reports what iOS chose; the application decides whether to apply it. An app that wants to keep its own fixed edge can ignore `placement.edge` and pass `'left'` or `'right'`. + +Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement(placement)` from its listener. + +On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; other actions still require one. Fixed-toolbar actions need an icon or SVG, no direct text node, and standard `fill="default"` or `fill="clear"` — text-only actions stay in the original Web toolbar. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal toolbar. Placement is chosen when a routed page enters; changing an existing button's content does not move it between the toolbar and rail until the page leaves and re-enters. Menus, modals, and popovers retain their own toolbar layout. + +When the app contains `ion-tabs`, its tab bar moves into the reserved region and aligns above the bottom safe area; the Ionic `slot` value does not select a different position. Without native projection, the stable Web rail is icon-only, matching a four-tab SwiftUI `TabView`. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar receives pointer input in the simulated system region. Use `ion-menu` when navigation should become a sidebar; this mode does not convert tabs into a menu. Web clones also work when no `ion-tabs` exists. Disabling the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. + +## Adapt the split pane + +For a side-by-side menu on iPhone Duo, opt the `ion-split-pane` into the separately measured Settings layout. The sidebar is 320pt when fully unfolded and reaches the display midpoint when half-opened (50vw). The application supplies the posture; both states have the same viewport width, so a width media query cannot distinguish them: + +```html + + ... +
...
+
+``` + +Set the ordinary split-pane width to 320pt in the application's stylesheet, and let the half-open class change only the width value: + +```css +ion-split-pane { + --ios-theme-menu-width: var(--ios-theme-split-pane-width); + --side-width: var(--ios-theme-menu-width); + --side-max-width: var(--ios-theme-menu-width); + transition: --ios-theme-split-pane-width 300ms ease; +} +``` + +The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `deviceLayoutChange` reports `HingeStatus.PartiallyOpen` (and read the initial value with `getDeviceLayout`). Ionic's `when` decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed — `HingeStatus.Unavailable` means the device has no hinge, so restore the ordinary breakpoint for it. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout does not enable Vertical Bars or move an overlay menu. + +## Vertical Control Area API + +The generated reference below documents the handle returned by `enableVerticalControlArea()`. + + + +* [`setPlacement(...)`](#setplacement) +* [`getStatus()`](#getstatus) +* [`suspend()`](#suspend) +* [`destroy()`](#destroy) +* [Interfaces](#interfaces) +* [Type Aliases](#type-aliases) + + + + + + +### setPlacement(...) + +```typescript +setPlacement(placement: VerticalBarEdge | VerticalBarPlacement) => void +``` + +Applies the application's chosen placement to both Web and native controls. + +| Param | Type | +| --------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **`placement`** | VerticalBarEdge \| VerticalBarPlacement | + +-------------------- + + +### getStatus() + +```typescript +getStatus() => NativeUIShellStatus +``` + +Returns the current Web/native projection state. + +**Returns:** NativeUIShellStatus + +-------------------- + + +### suspend() + +```typescript +suspend() => Promise +``` + +Restores projected controls to the Web until the returned lease is resumed. + +**Returns:** Promise<NativeUIShellSuspension> + +-------------------- + + +### destroy() + +```typescript +destroy() => Promise +``` + +Stops synchronization, restores Web controls and releases native resources. + +-------------------- + + +### Interfaces + + +#### VerticalBarPlacement + +| Prop | Type | Description | +| ----------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | +| **`edge`** | VerticalBarEdge | | +| **`inset`** | number | UIKit safe-area inset on the physical vertical-bar edge, in points. | + + +#### NativeUIShellStatus + +| Prop | Type | +| --------------- | ------------------------------------------- | +| **`state`** | 'native' \| 'stopped' \| 'web' | +| **`projected`** | number | +| **`updates`** | number | +| **`reason`** | string | + + +#### NativeUIShellSuspension + +| Method | Signature | Description | +| ---------- | ---------------------------- | ---------------------------------------------------------------------------------------------- | +| **resume** | () => Promise<void> | Releases this suspension. Native projection resumes after all active suspensions are released. | + + +### Type Aliases + + +#### VerticalBarEdge + +'left' | 'right' | null + + diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index afb3f7ac..e7725d5f 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -6,7 +6,7 @@ title: Native UI Shell (Experimental) Native UI Shell is experimental. Its API and supported controls may change. -Native UI Shell gives an Ionic app native navigation and action controls around its Web content. The optional Capacitor iOS plugin renders supported fixed Ionic controls with UIKit and the system's Liquid Glass material. Page content, scrolling, application state and routing remain in Ionic's WebView. +Native UI Shell gives an Ionic app native navigation and action controls around its Web content. The optional Capacitor iOS plugin renders supported fixed Ionic controls with UIKit or SwiftUI and the system's Liquid Glass material. Page content, scrolling, application state and routing remain in Ionic's WebView. ## Background @@ -50,7 +50,7 @@ Native appearance follows the applied class, system or always-dark theme CSS. Sy | `ion-segment` | Fixed toolbar, non-scrollable, text **or** one icon per item | `UISegmentedControl` | | `ion-fab` / `ion-fab-button` / `ion-fab-list` | Glass FAB in an `ion-content` fixed slot; one main button and optional directional lists | Persistent glass `UIButton` per button; one FAB synchronization group | -Only iOS-mode components with the theme variables installed are eligible. `ionic-theme-disabled`, `ios-theme-disabled`, and the legacy `ios26-disabled` on an element or ancestor always exclude it. A disabled theme on one tab/segment item keeps its whole group on the Web. +For the ordinary Native UI Shell, only iOS-mode components with the theme variables installed are eligible. Explicitly enabled Vertical Bars is mode-independent as described below. `ionic-theme-disabled`, `ios-theme-disabled`, and the legacy `ios26-disabled` on an element or ancestor always exclude it. A disabled theme on one tab/segment item keeps its whole group on the Web. Use `ios-theme-shell-disabled` to disable only the iOS Native UI Shell while keeping the Web theme. It excludes the element and all its descendants. Adding or removing the class at runtime automatically restores Web rendering or re-evaluates native eligibility. @@ -62,7 +62,7 @@ Use `ios-theme-shell-disabled` to disable only the iOS Native UI Shell while kee If a child inside a shared native surface opts out, the entire surface stays on the Web: this includes button groups, tab bars, segments and FAB lists. Opting out of the search FAB or any part of the search footer disables native search integration; the tab bar can still render natively if it remains eligible. -Placement is required even when the appearance is glass. Buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. +Placement is required even when the appearance is glass. In the ordinary Native UI Shell, buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. When `.ios-theme-vertical-bars` is enabled, a standard `ion-back-button` can instead be projected to the Vertical Control Area from outside a fixed toolbar, including routed content or a persistent app shell. The application chooses where to enable this mode and which Ionic component mode to use; Vertical Bars projection does not require `ios` mode classes. Overlays, collapsed headers, opted-out controls and departed pages are excluded. Native tabs accept equal-width items with Ionic's default `layout="icon-top"`. The native bar uses a local compact horizontal and regular vertical size class to preserve the Web's stacked icon/label arrangement on iPad and in landscape. This does not change the app's size class. Label size and weight follow the Web snapshot. Other explicit Ionic layouts (`icon-start`, `icon-end`, `icon-bottom`, `icon-hide`, `label-hide`) and unequal item widths keep the entire tab bar on the Web. Start, center and end placement follow the original `ion-tab-bar`, including RTL. Directional `ion-icon` artwork preserves its rendered RTL flip. @@ -167,16 +167,26 @@ Suspensions are nestable and `resume()` is idempotent. Native projection resumes The native material and control appearance follow the running iOS version; an iOS 26 device does not acquire iOS 27's appearance merely by installing this theme. +## Support iPhone Duo + +The standalone Vertical Control Area entry point (`@rdlabo/ionic-theme-ios27/vertical-bars`) and `dist/css/vertical-bars.css` work without loading the iOS 27 theme. Call `enableVerticalControlArea()` for this use case; it projects only controls placed in the vertical area. Apps already calling `enableNativeUIShell()` should keep that single runtime rather than starting both. See [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) for the complete setup, including hinge posture and the split-pane layout for apps that do not use this shell at all. + +On supported iOS versions, adding `.ios-theme-vertical-bars` changes only controls that the system relocates into the physical side rail. Native UI Shell presents eligible tabs, back navigation, menu buttons, and toolbar actions through a SwiftUI `TabView` and toolbar only when iOS reports a physical right-side safe area large enough for that rail. SwiftUI owns their adaptive placement and Liquid Glass appearance; Ionic remains the source of labels, icons, selected/disabled state, routing, form submission, and click handlers. + +The SwiftUI surface is clipped and hit-tested to the system rail. Web content remains visible and interactive outside that physical region. The runtime optimistically updates tab selection before forwarding the action to the original `ion-tab-button`, using the same event and stale-revision protection as the other native controls. Menus, modals, and popovers remain independent surfaces and are not moved into the main-page rail. + +This mode accepts standard `fill="default"` and `fill="clear"` fixed-toolbar buttons because SwiftUI determines their compact representation. Solid, outline, custom-color, scrolling, and explicitly opted-out controls remain on the Web. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal Web toolbar. On Web, Android, older iOS, or when native projection is unavailable during setup, the Web projection remains the fallback. + ## Native UI Shell API The generated reference below documents the handle returned by `enableNativeUIShell()`. The underlying Capacitor bridge and its control-snapshot protocol are implementation details. -- [`getStatus()`](#getstatus) -- [`suspend()`](#suspend) -- [`destroy()`](#destroy) -- [Interfaces](#interfaces) +* [`getStatus()`](#getstatus) +* [`suspend()`](#suspend) +* [`destroy()`](#destroy) +* [Interfaces](#interfaces) @@ -193,7 +203,8 @@ Returns the current Web/native projection state. **Returns:** NativeUIShellStatus ---- +-------------------- + ### suspend() @@ -205,7 +216,8 @@ Restores projected controls to the Web until the returned lease is resumed. **Returns:** Promise<NativeUIShellSuspension> ---- +-------------------- + ### destroy() @@ -215,10 +227,12 @@ destroy() => Promise Stops synchronization, restores Web controls and releases native resources. ---- +-------------------- + ### Interfaces + #### NativeUIShellStatus | Prop | Type | @@ -228,6 +242,7 @@ Stops synchronization, restores Web controls and releases native resources. | **`updates`** | number | | **`reason`** | string | + #### NativeUIShellSuspension | Method | Signature | Description | diff --git a/docs/special-markup.md b/docs/special-markup.md index ded0e2f5..42ade0da 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -47,6 +47,10 @@ Add one of `tab-bar-position-start`, `tab-bar-position-center`, or `tab-bar-posi These classes do not reposition a separate `ion-fab`; leave room for it when choosing the bar's position. +## Support iPhone Duo + +iPhone Duo support — the vertical system rail, hinge posture, and the posture-driven split-pane layout — is independent of the iOS 27 theme and the full Native UI Shell. See [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) for the complete setup, including device-layout monitoring without a projection runtime. + ## 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/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift index d2b71b3a..0a79ea39 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift @@ -45,8 +45,22 @@ private final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate { func textFieldShouldClear(_ textField: UITextField) -> Bool { clear?(); return false } } +// Lets the plugin registry retain iOS 26-gated search controllers without +// availability-gated stored properties, mirroring ShellVerticalBarsControlling. +protocol ShellSearchControlling: AnyObject { + var surface: ShellSearchHost { get } + var ownsKeyboard: Bool { get } + var ownsKeyboardChrome: Bool { get } + var transitionCoordinator: UIViewControllerTransitionCoordinator? { get } + var activate: ((String) -> Void)? { get set } + var changed: ((String, ShellSearchPhase, String, Bool, Int) -> Int)? { get set } + func attach(to parent: UIViewController, in container: UIView) + func detach() + func apply(_ snapshot: ShellControl, webFrame: CGRect, barFrame: CGRect, triggerFrame: CGRect, rendering: ShellRendering) -> Bool +} + @available(iOS 26.0, *) -final class ShellSearchController: UITabBarController, UITabBarControllerDelegate, UISearchBarDelegate { +final class ShellSearchController: UITabBarController, UITabBarControllerDelegate, UISearchBarDelegate, ShellSearchControlling { // Wire stays active+focused; local session drives chrome (idle / presented / focused). private enum Session: Equatable { case idle, presented, focused } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift new file mode 100644 index 00000000..79522e51 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -0,0 +1,358 @@ +import Combine +import SwiftUI +import UIKit + +protocol ShellVerticalBarsControlling: AnyObject { + var view: UIView { get } + func attach(to owner: UIViewController, in parent: UIView) + func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) + func detach() +} + +@available(iOS 26.0, *) +final class ShellVerticalBarsModel: ObservableObject { + struct Item: Identifiable { + let id: String + let label: String + let accessibilityLabel: String + let image: UIImage? + let badge: ShellBadge? + let disabled: Bool + var selected: Bool + } + + struct Group: Identifiable { + let id: String + let items: [Item] + let slot: ShellControl.ToolbarSlot? + } + + @Published var back: Item? + @Published var groups: [Group] = [] + @Published var tabs: [Item] = [] + @Published var selection = "" + var activate: (String) -> Void = { _ in } + private var domSelection = "" + private var pendingSelection: ShellTabBar.PendingSelection? + private var pendingExpiryWork: DispatchWorkItem? + + func apply(_ controls: [ShellControl], rendering: ShellRendering, + now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) { + func item(_ source: ShellItem) -> Item { + Item(id: source.id, label: source.content.label, + accessibilityLabel: source.content.accessibilityLabel, + image: rendering.image(source.content), badge: source.content.badge, disabled: source.content.disabled, + selected: source.content.selected) + } + withAnimation(.smooth(duration: 0.3)) { + back = controls.first(where: { $0.kind == .backButton })?.items.first.map(item) + groups = controls.compactMap { control in + guard [.button, .buttons, .menuButton].contains(control.kind) else { return nil } + return Group(id: control.id, items: control.items.map(item), slot: control.toolbarSlot) + } + } + tabs = controls.first(where: { $0.kind == .tabBar })?.items.map(item) ?? [] + domSelection = tabs.first(where: \.selected)?.id ?? "" + reconcileSelection(now: now) + } + + func select(_ id: String, now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent(), ttl: CFTimeInterval = 1) { + guard let item = tabs.first(where: { $0.id == id }), !item.disabled else { return } + pendingSelection = .init(id: id, until: now + ttl) + selection = id + pendingExpiryWork?.cancel() + let work = DispatchWorkItem { [weak self] in self?.reconcileSelection() } + pendingExpiryWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + ttl + 0.02, execute: work) + activate(id) + } + + private func reconcileSelection(now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) { + guard let pending = pendingSelection else { selection = domSelection; return } + let item = tabs.first(where: { $0.id == pending.id }) + if item == nil || item!.disabled || now >= pending.until || domSelection == pending.id { + pendingSelection = nil + pendingExpiryWork?.cancel() + pendingExpiryWork = nil + selection = domSelection + } else { + selection = pending.id + } + } +} + +@available(iOS 26.0, *) +private struct ShellVerticalBarsLabel: View { + let item: ShellVerticalBarsModel.Item + + var body: some View { + if let image = item.image { + Label { + Text(item.label.isEmpty ? item.accessibilityLabel : item.label) + } icon: { + Image(uiImage: image) + } + } else { + Text(item.label) + } + } +} + +@available(iOS 26.0, *) +private func verticalBarsButton(_ item: ShellVerticalBarsModel.Item, model: ShellVerticalBarsModel) -> some View { + Button { model.activate(item.id) } label: { ShellVerticalBarsLabel(item: item) } + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier(item.id) +} + +@available(iOS 26.0, *) +private func verticalBarsBackButton(_ item: ShellVerticalBarsModel.Item, model: ShellVerticalBarsModel) -> some View { + Button { model.activate(item.id) } label: { Image(systemName: "chevron.backward") } + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier("BackButton") +} + +@available(iOS 26.0, *) +private struct ShellVerticalBarsBadge: ViewModifier { + let badge: ShellBadge? + + @ViewBuilder func body(content: Content) -> some View { + if let badge { content.badge(badge.value) } + else { content } + } +} + +@available(iOS 26.0, *) +private struct ShellVerticalBarsView: View { + @ObservedObject var model: ShellVerticalBarsModel + + var body: some View { + Group { + if model.tabs.isEmpty { + ShellVerticalBarsPage(model: model) + } else { + TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) { + ForEach(model.tabs) { item in + ShellVerticalBarsPage(model: model) + .tag(item.id) + .tabItem { ShellVerticalBarsLabel(item: item) } + .modifier(ShellVerticalBarsBadge(badge: item.badge)) + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier(item.id) + } + } + } + } + .modifier(ShellVerticalBarsCompression()) + .background(Color.clear) + } +} + +@available(iOS 26.0, *) +private struct ShellVerticalBarsPage: View { + @ObservedObject var model: ShellVerticalBarsModel + + var body: some View { + NavigationStack { + Color.clear.modifier(ShellVerticalBarsToolbarAdapter(model: model)) + } + } +} + +@available(iOS 26.0, *) +private struct ShellVerticalBarsCompression: ViewModifier { + @ViewBuilder func body(content: Content) -> some View { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *) { + content.toolbarVerticalCompressionBehavior(.prefersToolbarItems) + } else { + content + } + #else + content + #endif + } +} + +@available(iOS 26.0, *) +private struct ShellVerticalBarsToolbarAdapter: ViewModifier { + @ObservedObject var model: ShellVerticalBarsModel + + @ViewBuilder func body(content: Content) -> some View { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *) { + content.modifier(ShellVerticalBarsToolbar(model: model)) + } else { + content.modifier(ShellVerticalBarsLegacyToolbar(model: model)) + } + #else + content.modifier(ShellVerticalBarsLegacyToolbar(model: model)) + #endif + } +} + +#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) +@available(iOS 27.1, *) +private struct ShellVerticalBarsToolbar: ViewModifier { + @ObservedObject var model: ShellVerticalBarsModel + + func body(content: Content) -> some View { + content.toolbar { + if let back = model.back { + ToolbarItem(placement: .navigation) { + verticalBarsBackButton(back, model: model) + } + .axisBehavior(.verticalPreferred) + } + ForEach(model.groups.filter { $0.slot == .start }) { group in + ToolbarItemGroup(placement: .topBarLeading) { + ForEach(group.items) { item in + verticalBarsButton(item, model: model) + } + } + .axisBehavior(.verticalPreferred) + } + ForEach(model.groups.filter { $0.slot != .start }) { group in + ToolbarItemGroup(placement: .topBarTrailing) { + ForEach(group.items) { item in + verticalBarsButton(item, model: model) + } + } + .axisBehavior(.verticalPreferred) + } + } + } +} +#endif + +@available(iOS 26.0, *) +private struct ShellVerticalBarsLegacyToolbar: ViewModifier { + @ObservedObject var model: ShellVerticalBarsModel + + func body(content: Content) -> some View { + content.toolbar { + if let back = model.back { + ToolbarItem(placement: .navigation) { + verticalBarsBackButton(back, model: model) + } + } + ForEach(model.groups) { group in + ToolbarItemGroup(placement: .primaryAction) { + ForEach(group.items) { item in + verticalBarsButton(item, model: model) + } + } + } + } + } +} + +@available(iOS 26.0, *) +final class ShellVerticalBarsController: ShellVerticalBarsControlling { + private final class TransparentHostingController: UIHostingController { + var railEdge = "right" + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + makeFullSizeSurfacesTransparent(in: view) + } + + private func makeFullSizeSurfacesTransparent(in surface: UIView) { + let frame = surface.convert(surface.bounds, to: view) + guard !(surface is UIVisualEffectView) else { return } + let coversHost = frame.insetBy(dx: -1, dy: -1).contains(view.bounds) + // SwiftUI may add an opaque backing behind the rail controls, sized to + // the rail or to an expanded tab bar. Keep that backing clear without + // touching the glass controls or materials. + let coversRail = frame.minY <= 1 && frame.maxY >= view.bounds.maxY - 1 && + (railEdge == "left" ? frame.minX <= 1 : frame.maxX >= view.bounds.maxX - 1) + if coversHost || coversRail { + surface.backgroundColor = .clear + surface.isOpaque = false + } + surface.subviews.forEach(makeFullSizeSurfacesTransparent) + } + } + + private final class RailContainer: UIView { + var railEdge = "right" + + private var railWidth: CGFloat { + let inset = railEdge == "left" ? safeAreaInsets.left : safeAreaInsets.right + return inset > 0 ? inset : 80 + } + + // The rail is not masked: expanded rail content (a widened tab bar) is + // allowed to draw over the WebView. Inside the base rail touches behave + // as before; beyond it touches are captured only where they land inside + // an actual bar surface so empty overlap still belongs to the WebView. + // The SwiftUI hosting scaffold reports a full-size hosting view for + // every point, so the hit result cannot tell bar content from empty + // space — the bar frames decide instead. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + let inRail = railEdge == "left" ? point.x <= railWidth : point.x >= bounds.maxX - railWidth + if inRail { return hit } + guard hit != nil, containsBarSurface(at: point) else { return nil } + return hit + } + + private func containsBarSurface(at point: CGPoint) -> Bool { + containsBarSurface(in: self, at: point) + } + + private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool { + guard !view.isHidden, view.alpha > 0.05 else { return false } + let name = NSStringFromClass(type(of: view)) + if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar")), + convert(view.bounds, from: view).contains(point) { return true } + return view.subviews.contains { containsBarSurface(in: $0, at: point) } + } + } + + private let model = ShellVerticalBarsModel() + private lazy var controller = TransparentHostingController(rootView: ShellVerticalBarsView(model: model)) + private let container = RailContainer() + private weak var owner: UIViewController? + + var view: UIView { container } + + init(activate: @escaping (String) -> Void) { + model.activate = activate + container.backgroundColor = .clear + controller.view.backgroundColor = .clear + controller.view.isOpaque = false + } + + func attach(to owner: UIViewController, in parent: UIView) { + guard self.owner !== owner || container.superview !== parent else { return } + detach() + self.owner = owner + owner.addChild(controller) + container.frame = parent.bounds + container.autoresizingMask = [.flexibleWidth, .flexibleHeight] + controller.view.frame = container.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + container.addSubview(controller.view) + parent.addSubview(container) + controller.didMove(toParent: owner) + } + + func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) { + container.railEdge = edge + controller.railEdge = edge + model.apply(controls, rendering: rendering) + controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light + } + + func detach() { + guard controller.parent != nil else { return } + controller.willMove(toParent: nil) + container.removeFromSuperview() + controller.view.removeFromSuperview() + controller.removeFromParent() + owner = nil + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 11cfd077..9165b570 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -8,13 +8,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele public let jsName = "IonicNativeUIShell" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getDeviceLayout", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "startDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "stopDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) ] private var host: ShellHost? + private var verticalBars: ShellVerticalBarsControlling? private var controls: [String: UIView] = [:] - private var searchControllers: [String: UIViewController] = [:] + private var searchControllers: [String: ShellSearchControlling] = [:] private var fingerprints: [String: ShellControl] = [:] private let rendering = ShellRendering() private var revision = 0 @@ -24,6 +27,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:] private var restoreTopEdge: (() -> Void)? private var observers: [NSObjectProtocol] = [] + private var lastVerticalBarEdge: String? + private var lastVerticalBarInset: CGFloat = 0 + private var verticalBarPlacementObserved = false + private weak var observedVerticalBarView: UIView? + private var unregisterVerticalBarObservation: (() -> Void)? + private weak var observedHingeView: UIView? + private var hingeInteraction: UIInteraction? + private var hingeStatus: String? + private var deviceLayoutMonitoring = 0 + private var lastDeviceLayout: String? public override func load() { for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification, @@ -40,14 +53,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self.keyboardVisible = !overlap.isNull && overlap.width > 0 && overlap.height > 0 } } - if !keyboard { self.host?.isHidden = true } + if !keyboard { + self.host?.isHidden = true + self.verticalBars?.view.isHidden = true + } var searchOwnsKeyboard = false - if #available(iOS 26.0, *) { - self.searchControllers.values.forEach { - guard let controller = $0 as? ShellSearchController else { return } - if controller.ownsKeyboardChrome { searchOwnsKeyboard = true } - if !keyboard { controller.surface.isHidden = true } - } + self.searchControllers.values.forEach { controller in + if controller.ownsKeyboardChrome { searchOwnsKeyboard = true } + if !keyboard { controller.surface.isHidden = true } } if keyboard { if !searchOwnsKeyboard { @@ -55,6 +68,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } } else if name == UIDevice.orientationDidChangeNotification { self.notifyWebViewMetricsChange() + self.notifyVerticalBarPlacementChange() } }) } @@ -64,6 +78,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false } self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}") if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() } + if name == UIApplication.didBecomeActiveNotification { self?.notifyVerticalBarPlacementChange() } + if name == UIApplication.didBecomeActiveNotification { self?.refreshHingeStatus() } }) } } @@ -72,6 +88,17 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele observers.forEach(NotificationCenter.default.removeObserver) } + private func stopDeviceLayoutObservation() { + unregisterVerticalBarObservation?() + unregisterVerticalBarObservation = nil + observedVerticalBarView = nil + if let interaction = hingeInteraction { + observedHingeView?.removeInteraction(interaction) + } + hingeInteraction = nil + observedHingeView = nil + } + private func webViewMetrics() -> JSObject? { guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil } webView.layoutIfNeeded() @@ -79,35 +106,158 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } private func notifyWebViewMetricsChange() { - guard let metrics = webViewMetrics() else { return } - notifyListeners("webViewMetricsChange", data: metrics) + notifyDeviceLayoutChange() + } + + private func deviceLayout() -> JSObject { + var layout: JSObject = ["placement": verticalBarPlacement(), + "webViewMetrics": webViewMetrics() ?? ["radius": 0]] + layout["hingeStatus"] = hingeStatus ?? "unavailable" + return layout + } + + private func notifyDeviceLayoutChange() { + guard deviceLayoutMonitoring > 0 else { return } + let layout = deviceLayout() + let fingerprint = "\(verticalBarEdge() ?? "none"):\(verticalBarInset(for: verticalBarEdge())):\(hingeStatus ?? "none"):\(webViewMetrics()?["radius"] ?? 0)" + guard fingerprint != lastDeviceLayout else { return } + lastDeviceLayout = fingerprint + notifyListeners("deviceLayoutChange", data: layout) } - @objc func getWebViewMetrics(_ call: CAPPluginCall) { + @objc func getDeviceLayout(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in - guard #available(iOS 26.0, *) else { - call.resolve(["radius": 0]) - return + self?.observeVerticalBarPlacement() + self?.observeHingeStatus() + self?.refreshHingeStatus() + DispatchQueue.main.async { + call.resolve(self?.deviceLayout() ?? ["placement": ["edge": NSNull(), "inset": 0], "hingeStatus": "unavailable", "webViewMetrics": ["radius": 0]]) + if self?.deviceLayoutMonitoring == 0 { self?.stopDeviceLayoutObservation() } } - guard let metrics = self?.webViewMetrics() else { - call.reject("WebView unavailable") - return + } + } + + @objc func startDeviceLayoutMonitoring(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + self?.deviceLayoutMonitoring += 1 + self?.lastDeviceLayout = nil + self?.observeVerticalBarPlacement() + self?.observeHingeStatus() + self?.refreshHingeStatus() + call.resolve() + } + } + + @objc func stopDeviceLayoutMonitoring(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + if let self, self.deviceLayoutMonitoring > 0 { + self.deviceLayoutMonitoring -= 1 + if self.deviceLayoutMonitoring == 0 { + self.lastDeviceLayout = nil + self.stopDeviceLayoutObservation() + } + } + call.resolve() + } + } + + private func verticalBarEdge() -> String? { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *), let webView = bridge?.webView { + let rtl = webView.effectiveUserInterfaceLayoutDirection == .rightToLeft + switch webView.traitCollection.verticalBarEdge { + case .leading: return rtl ? "right" : "left" + case .trailing: return rtl ? "left" : "right" + default: return nil + } + } + #endif + return nil + } + + private func verticalBarInset(for edge: String?) -> CGFloat { + guard let edge, let webView = bridge?.webView else { return 0 } + webView.layoutIfNeeded() + return edge == "left" ? webView.safeAreaInsets.left : webView.safeAreaInsets.right + } + + private func verticalBarPlacement() -> JSObject { + if let edge = verticalBarEdge() { return ["edge": edge, "inset": Double(verticalBarInset(for: edge))] } + return ["edge": NSNull(), "inset": 0] + } + + private func notifyVerticalBarPlacementChange() { + let edge = verticalBarEdge() + let inset = verticalBarInset(for: edge) + guard !verticalBarPlacementObserved || edge != lastVerticalBarEdge || abs(inset - lastVerticalBarInset) > 0.5 else { return } + verticalBarPlacementObserved = true + lastVerticalBarEdge = edge + lastVerticalBarInset = inset + notifyDeviceLayoutChange() + } + + private func observeVerticalBarPlacement() { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *), let webView = bridge?.webView, observedVerticalBarView !== webView { + unregisterVerticalBarObservation?() + observedVerticalBarView = webView + let traits: [UITrait] = [UITraitLayoutDirection.self] + UITraitCollection.systemTraitsAffectingVerticalBarEdge + let registration = webView.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in + self?.notifyVerticalBarPlacementChange() + } + unregisterVerticalBarObservation = { [weak webView] in webView?.unregisterForTraitChanges(registration) } + } + #endif + notifyVerticalBarPlacementChange() + } + + private func observeHingeStatus() { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *), let webView = bridge?.webView, observedHingeView !== webView { + if let interaction = hingeInteraction { + observedHingeView?.removeInteraction(interaction) + } + observedHingeView = webView + let interaction = UIHingeInteraction { [weak self] _, update in + let status: String? + switch update.hinge?.status { + case .closed: status = "closed" + case .partiallyOpen: status = "partially-open" + case .fullyOpen: status = "fully-open" + default: status = nil + } + guard status != self?.hingeStatus else { return } + self?.hingeStatus = status + self?.notifyDeviceLayoutChange() } - call.resolve(metrics) + hingeInteraction = interaction + webView.addInteraction(interaction) + } + #endif + } + + private func refreshHingeStatus() { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *), let interaction = hingeInteraction as? UIHingeInteraction { + interaction.isEnabled = false + interaction.isEnabled = true } + #endif } @objc func configure(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in + self?.observeVerticalBarPlacement() // A new JS context starts revision numbering again (live reload / navigation). self?.restoreTopEdge?() self?.restoreTopEdge = nil self?.removeControls() self?.revision = 0 if #available(iOS 26.0, *) { + self?.bridge?.webView?.layoutIfNeeded() // Ionic already paints the header edge; a second native effect can // add a dark scrim when the OS and Web themes differ. - if let effect = self?.bridge?.webView?.scrollView.topEdgeEffect { + if call.getBool("verticalBarsOnly") != true, let effect = self?.bridge?.webView?.scrollView.topEdgeEffect { let hidden = effect.isHidden effect.isHidden = true self?.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden } @@ -133,9 +283,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } private func removeControl(_ id: String, duration: TimeInterval = 0) { - if #available(iOS 26.0, *) { - (searchControllers.removeValue(forKey: id) as? ShellSearchController)?.detach() - } + searchControllers.removeValue(forKey: id)?.detach() if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) } fingerprints.removeValue(forKey: id) pendingTabSelections.removeValue(forKey: id) @@ -146,6 +294,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele Array(controls.keys).forEach { removeControl($0, duration: duration) } host?.removeFromSuperview() host = nil + verticalBars?.detach() + verticalBars = nil rendering.clear() pendingTabSelections.removeAll() pendingTabExpiryWorks.values.forEach { $0.cancel() } @@ -194,19 +344,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } let duration = ShellCrossfade.duration(snapshot.transitionDuration) let existing = Set(self.controls.keys) - let snapshots = snapshot.controls + let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars } + let snapshots = snapshot.controls.filter { $0.placement != .verticalBars } let width = snapshot.viewportWidth self.revision = next - if snapshots.isEmpty { + if snapshots.isEmpty && verticalBars.isEmpty { self.removeControls(duration: duration) call.resolve(["revision": next]); return } - let host = self.host ?? ShellHost() - self.host = host - host.frame = parent.bounds - host.autoresizingMask = [.flexibleWidth, .flexibleHeight] - host.backgroundColor = .clear - host.isAccessibilityElement = false let scale = webView.bounds.width / width let retained = Set(snapshots.map(\.id)) for id in Array(self.controls.keys) where !retained.contains(id) { @@ -214,8 +359,33 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } var rejectedControls: [String] = [] var fabs: [(ShellFab, ShellControl)] = [] - var searches: [(ShellSearchController, ShellControl, CGRect, CGRect, UIView?, Bool)] = [] + var searches: [(ShellSearchControlling, ShellControl, CGRect, CGRect, UIView?, Bool)] = [] var rejectedSearches: [String] = [] + if verticalBars.isEmpty || self.keyboardVisible { + self.verticalBars?.detach() + self.verticalBars = nil + if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) } + } else if let owner = self.bridge?.viewController { + let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) }) + self.verticalBars = rail + rail.attach(to: owner, in: owner.view) + rail.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right") + rail.view.isHidden = false + } else { + rejectedControls.append(contentsOf: verticalBars.map(\.id)) + } + if snapshots.isEmpty { + self.host?.removeFromSuperview() + self.host = nil + call.resolve(["revision": next, "rejectedControls": rejectedControls]) + return + } + let host = self.host ?? ShellHost() + self.host = host + host.frame = parent.bounds + host.autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.backgroundColor = .clear + host.isAccessibilityElement = false UIView.performWithoutAnimation { if host.superview !== parent { parent.addSubview(host) } for node in snapshots { @@ -233,7 +403,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele rejectedControls.append(id) } // Only native search owns its keyboard; other controls return to Web. - if self.keyboardVisible && (self.searchControllers[id] as? ShellSearchController)?.ownsKeyboard != true { + if self.keyboardVisible && self.searchControllers[id]?.ownsKeyboard != true { reject(); continue } let local = node.frame.rect @@ -241,9 +411,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele width: local.width * scale, height: local.height * scale), to: parent) if let search = node.search { guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue } - let controller: ShellSearchController + let controller: ShellSearchControlling let previousCover = self.controls[id] - let replacing = self.searchControllers[id] as? ShellSearchController + let replacing = self.searchControllers[id] if let existing = replacing { controller = existing } else { // Keep the ordinary UITabBar cover until the search controller applies. diff --git a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift index 75e74adf..76921bfa 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift @@ -11,10 +11,12 @@ struct ShellSnapshot: Decodable { let revision: Int let transitionDuration: Double? let viewportWidth: Double + let verticalBarEdge: String? let controls: [ShellControl] var isValid: Bool { revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 && + (verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") && Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid) } } @@ -31,8 +33,12 @@ struct ShellFrame: Decodable, Equatable { } struct ShellControl: Decodable, Equatable { + enum Placement: String, Decodable { case verticalBars = "vertical-bars" } + enum ToolbarSlot: String, Decodable { case start, end } let id: String let kind: ShellComponent + let placement: Placement? + let toolbarSlot: ToolbarSlot? let frame: ShellFrame let items: [ShellItem] let dark: Bool @@ -40,12 +46,14 @@ struct ShellControl: Decodable, Equatable { let tabBarAnchor: ShellTabBar.Anchor? let search: ShellSearch? - private enum CodingKeys: String, CodingKey { case id, kind, items, dark, rtl, tabBarAnchor, search } + private enum CodingKeys: String, CodingKey { case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) kind = try values.decode(ShellComponent.self, forKey: .kind) + placement = try values.decodeIfPresent(Placement.self, forKey: .placement) + toolbarSlot = try values.decodeIfPresent(ToolbarSlot.self, forKey: .toolbarSlot) frame = try ShellFrame(from: decoder) items = try values.decode([ShellItem].self, forKey: .items) dark = try values.decode(Bool.self, forKey: .dark) @@ -57,6 +65,7 @@ struct ShellControl: Decodable, Equatable { var isValid: Bool { ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 && (!ShellButton.kinds.contains(kind) || items.count == 1) && + (toolbarSlot == nil || (placement == .verticalBars && [.button, .buttons, .menuButton].contains(kind))) && Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) && (tabBarAnchor.map { kind == .tabBar && $0.isValid } ?? true) && (search.map { kind == .tabBar && $0.isValid } ?? true) diff --git a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift index c804b0e8..1514ae31 100644 --- a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +++ b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift @@ -37,6 +37,35 @@ final class ShellSnapshotTests: XCTestCase { } } + @MainActor func testVerticalBarsTabOptimismWaitsForWebAndRollsBackWhenStale() throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") } + func tabs(_ selected: String, includeRight: Bool = true) throws -> [ShellControl] { + var items = [item(["id": "left", "selected": selected == "left"])] + if includeRight { + items.append(item(["id": "right", "selected": selected == "right", + "badge": ["value": "3", "color": "rgb(255, 0, 0)", "textColor": "rgb(255, 255, 255)"]])) + } + return try decode([control(["kind": "ion-tab-bar", "items": items])]).controls + } + let model = ShellVerticalBarsModel() + let rendering = ShellRendering() + model.apply(try tabs("left"), rendering: rendering, now: 100) + XCTAssertEqual(model.tabs.last?.badge?.value, "3") + model.select("right", now: 100, ttl: 10) + model.apply(try tabs("left"), rendering: rendering, now: 101) + XCTAssertEqual(model.selection, "right", "a stale Web echo must not undo the optimistic selection") + model.apply(try tabs("right"), rendering: rendering, now: 102) + XCTAssertEqual(model.selection, "right", "the matching Web echo confirms the selection") + + model.apply(try tabs("left"), rendering: rendering, now: 200) + model.select("right", now: 200, ttl: 10) + model.apply(try tabs("left"), rendering: rendering, now: 211) + XCTAssertEqual(model.selection, "left", "an expired selection rolls back to Web state") + model.select("right", now: 220, ttl: 10) + model.apply(try tabs("left", includeRight: false), rendering: rendering, now: 221) + XCTAssertEqual(model.selection, "left", "a removed target rolls back immediately") + } + @MainActor func testSegmentSelectionEchoPreservesNativeViewsAndActionsUseUpdatedItems() throws { guard #available(iOS 26.0, *) else { return } func node(_ items: [JSObject]) throws -> ShellControl { diff --git a/package-lock.json b/package-lock.json index 6eb32d17..3d353f09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ ], "license": "MIT", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#30acb040153797407a5fe27f0db6c69a3410d855" + "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#44b1535387c326ed69fc239d38dbdb9776b629c2" }, "devDependencies": { "@capacitor/core": "^8.5.2", @@ -2259,7 +2259,7 @@ }, "node_modules/@rdlabo/ionic-theme-utils": { "version": "0.1.0", - "resolved": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#30acb040153797407a5fe27f0db6c69a3410d855", + "resolved": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#44b1535387c326ed69fc239d38dbdb9776b629c2", "integrity": "sha512-W4MFC4reLS4cK23hqWrWEf88d1Gx3mRfkkd2eihzjaGm+jndS+SdBmGmcefuaUXzL0J6lDtBPMWEkn78IOI+LA==", "inBundle": true, "license": "MIT", diff --git a/package.json b/package.json index 4523ee81..a9800007 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "./native": { "types": "./dist/native/index.d.ts", "import": "./dist/native/index.js" + }, + "./vertical-bars": { + "types": "./dist/vertical-bars.d.ts", + "import": "./dist/vertical-bars.js" } }, "files": [ @@ -31,7 +35,7 @@ "prebuild:css": "rdlabo-copy-structured-list src/styles/utils/structured-list.scss", "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map", "build:ts": "tsc", - "docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md", + "docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md && docgen --project tsconfig.docgen.json --api VerticalControlAreaHandle --output-readme docs/iphone-duo.md", "build:demo": "npm run build && cd demo && npm install && npm run build -- --configuration=production", "lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"", "fmt": "prettier --write \"./**/*.{scss,ts}\" && prettier --parser angular --write \"./**/*.html\"", @@ -56,7 +60,7 @@ }, "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios27", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#30acb040153797407a5fe27f0db6c69a3410d855" + "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#44b1535387c326ed69fc239d38dbdb9776b629c2" }, "bundleDependencies": [ "@rdlabo/ionic-theme-utils" diff --git a/src/index.ts b/src/index.ts index 4a90cebe..f61ca477 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,23 +14,30 @@ export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffe const win = targetElement.ownerDocument.defaultView; if (!targetElement.classList.contains('ios') || !win) return undefined; const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)'); + const verticalBarsRoot = targetElement.closest('ion-app'); let effect: registeredEffect | undefined; const update = () => { effect?.destroy(); - effect = reducedMotion.matches - ? undefined - : registerEffect(targetElement, 'ion-tab-button', 'tab-selected', { - small: 'scale(1.1, 1)', - medium: 'scale(1.2)', - large: 'scale(1.15, 1.4)', - xlarge: 'scale(1.3)', - }); + const isVertical = + !!verticalBarsRoot?.classList.contains('ios-theme-vertical-bars') && !targetElement.closest('ion-menu, ion-modal, ion-popover'); + effect = + reducedMotion.matches || isVertical + ? undefined + : registerEffect(targetElement, 'ion-tab-button', 'tab-selected', { + small: 'scale(1.1, 1)', + medium: 'scale(1.2)', + large: 'scale(1.15, 1.4)', + xlarge: 'scale(1.3)', + }); }; update(); reducedMotion.addEventListener('change', update); + const placementObserver = new win.MutationObserver(update); + if (verticalBarsRoot) placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'] }); return { destroy: () => { reducedMotion.removeEventListener('change', update); + placementObserver?.disconnect(); effect?.destroy(); effect = undefined; }, diff --git a/src/native-integration/index.ts b/src/native-integration/index.ts index 84144b2f..03404410 100644 --- a/src/native-integration/index.ts +++ b/src/native-integration/index.ts @@ -2,7 +2,7 @@ import type { Animation } from '@ionic/core'; export interface NativeUIShellIntegration { - suspend(scopes: HTMLElement[]): Promise<() => void>; + suspend(scopes: HTMLElement[]): Promise<(canceled?: boolean) => void>; search?(binding: NativeSearchBinding, active: boolean, focus?: boolean): Promise; } @@ -32,6 +32,7 @@ export const requestNativeSearch = async (binding: NativeSearchBinding, active: (await runtimes.get(binding.tabBar.ownerDocument)?.search?.(binding, active, focus)) ?? false; const runtimes = new WeakMap(); +export const VERTICAL_BARS_TRANSITION_CANCELED = 'iosThemeVerticalBarsTransitionCanceled'; export const setNativeUIShellIntegration = (doc: Document, runtime?: NativeUIShellIntegration) => { if (runtime) runtimes.set(doc, runtime); @@ -40,11 +41,15 @@ export const setNativeUIShellIntegration = (doc: Document, runtime?: NativeUIShe export const isNativeUIShell = (element: HTMLElement) => element.hasAttribute('data-native-ui-shell'); -export const suspendNativeUIShell = async (scopes: HTMLElement[]): Promise<() => void> => +export const suspendNativeUIShell = async (scopes: HTMLElement[]): Promise<(canceled?: boolean) => void> => (await runtimes.get(scopes[0]?.ownerDocument)?.suspend(scopes)) ?? (() => {}); /** Ionic write hooks cannot await the bridge. Gate playback, including interactive playback. */ export const connectNativeUIShellTransition = (animation: Animation, entering: HTMLElement, leaving?: HTMLElement) => { + if (leaving) + animation.onFinish((step) => { + if (step === 0) leaving.dispatchEvent(new CustomEvent(VERTICAL_BARS_TRANSITION_CANCELED, { bubbles: true, detail: { entering } })); + }); if (!runtimes.has(entering.ownerDocument)) return; const scopes = leaving ? [entering, leaving] : [entering]; const play = animation.play.bind(animation); @@ -52,7 +57,7 @@ export const connectNativeUIShellTransition = (animation: Animation, entering: H const progressStep = animation.progressStep.bind(animation); const progressEnd = animation.progressEnd.bind(animation); const destroy = animation.destroy.bind(animation); - let release: (() => void) | undefined; + let release: ((canceled?: boolean) => void) | undefined; let preparation: Promise | undefined; let disposed = false; let interactiveReady = false; @@ -60,15 +65,15 @@ export const connectNativeUIShellTransition = (animation: Animation, entering: H let end: Parameters | undefined; const prepare = () => (preparation ??= suspendNativeUIShell(scopes).then((resume) => { - if (disposed) resume(); + if (disposed) resume(true); else release = resume; })); - const finish = () => { - release?.(); + const finish = (canceled = false) => { + release?.(canceled); release = undefined; preparation = undefined; }; - animation.onFinish(finish); + animation.onFinish((step) => finish(step === 0)); animation.play = async (options) => { await prepare(); if (disposed) return; @@ -103,7 +108,7 @@ export const connectNativeUIShellTransition = (animation: Animation, entering: H }; animation.destroy = (...args) => { disposed = true; - finish(); + finish(true); return destroy(...args); }; }; diff --git a/src/native/components/index.ts b/src/native/components/index.ts index 89711c5a..670d865e 100644 --- a/src/native/components/index.ts +++ b/src/native/components/index.ts @@ -5,7 +5,7 @@ import * as menuButton from './ion-menu-button'; import * as tabBar from './ion-tab-bar'; import * as segment from './ion-segment'; import * as fab from './ion-fab'; -import { visible } from '../shared/dom'; +import { isDisabledButtonGroupChild, isVerticalBarsSource, visible } from '../shared/dom'; import type { Candidate, Identify } from '../shared/candidate'; // Static composition only. Each component declares its own tag, discovery and reader. @@ -27,10 +27,19 @@ export const motionSelector = [ ...components.filter((component) => 'tracksMotion' in component && component.tracksMotion).map((component) => component.tag), ].join(', '); +export const isVerticalBarsCandidate = isVerticalBarsSource; + export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => { - if (!element.classList.contains('ios') || !visible(element) || element.closest('ion-modal, ion-popover')) return; + const verticalBars = isVerticalBarsCandidate(element); + if ((!element.classList.contains('ios') && !verticalBars) || !visible(element, verticalBars) || element.closest('ion-modal, ion-popover')) + return; const style = getComputedStyle(element); - if (!style.getPropertyValue('--ios-theme-glass-background-rgb').trim() && !style.getPropertyValue('--ios26-glass-background-rgb').trim()) + if ( + !isDisabledButtonGroupChild(element) && + !style.getPropertyValue('--ios-theme-glass-background-rgb').trim() && + !style.getPropertyValue('--ios26-glass-background-rgb').trim() && + !verticalBars + ) return; if (element.contains(element.ownerDocument.activeElement)) return; return components.find((component) => component.tag === element.localName)?.read(element, id); diff --git a/src/native/components/ion-back-button.ts b/src/native/components/ion-back-button.ts index 496c0133..111f7ee4 100644 --- a/src/native/components/ion-back-button.ts +++ b/src/native/components/ion-back-button.ts @@ -1,12 +1,18 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { inFixedToolbar } from '../shared/dom'; +import { inFixedToolbar, isVerticalBarsSource } from '../shared/dom'; export const tag = 'ion-back-button'; export const read = (element: HTMLElement, id: Identify): Candidate | undefined => { const button = element as HTMLIonBackButtonElement; - if (!inFixedToolbar(element) || button.icon !== undefined || button.color !== undefined || !button.shadowRoot) return; + if ( + !button.shadowRoot || + button.icon !== undefined || + button.color !== undefined || + (!isVerticalBarsSource(element) && !inFixedToolbar(element)) + ) + return; const candidate = createCandidate(element, tag, id); const label = button.shadowRoot.querySelector('[part="text"]')?.textContent?.trim() ?? ''; return appendItem(candidate, element, id, button.shadowRoot, label) ? candidate : undefined; diff --git a/src/native/components/ion-button.ts b/src/native/components/ion-button.ts index 369bbc62..a1f5dc9b 100644 --- a/src/native/components/ion-button.ts +++ b/src/native/components/ion-button.ts @@ -1,12 +1,22 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { inFixedToolbar } from '../shared/dom'; +import { inFixedToolbar, isVerticalBarsToolbarAction, isVerticalBarsToolbarGroup } from '../shared/dom'; export const tag = 'ion-button'; export const read = (element: HTMLElement, id: Identify): Candidate | undefined => { const button = element as HTMLIonButtonElement; - if (!inFixedToolbar(element) || button.fill !== 'default' || button.classList.contains('ion-color')) return; + const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars'); + if (verticalBars && element.parentElement && isVerticalBarsToolbarGroup(element.parentElement)) return; + const fill = button.fill ?? 'default'; + if ( + !inFixedToolbar(element) || + (verticalBars && !isVerticalBarsToolbarAction(element)) || + (!verticalBars && fill !== 'default') || + (verticalBars && !['default', 'clear'].includes(fill)) || + button.classList.contains('ion-color') + ) + return; const candidate = createCandidate(element, tag, id); return appendItem(candidate, element, id) ? candidate : undefined; }; diff --git a/src/native/components/ion-buttons.ts b/src/native/components/ion-buttons.ts index 0dd49d66..8b19cbdf 100644 --- a/src/native/components/ion-buttons.ts +++ b/src/native/components/ion-buttons.ts @@ -1,6 +1,6 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { inFixedToolbar } from '../shared/dom'; +import { verticalBarsToolbarActions, inFixedToolbar, isVerticalBarsToolbarGroup } from '../shared/dom'; import * as menuButton from './ion-menu-button'; export const tag = 'ion-buttons'; @@ -8,18 +8,23 @@ export const tracksMotion = true; export const read = (element: HTMLElement, id: Identify): Candidate | undefined => { if (!inFixedToolbar(element)) return; - const children = Array.from(element.children) as HTMLElement[]; + let children = Array.from(element.children) as HTMLElement[]; if (children.length === 1) return menuButton.read(element, id); + const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars'); + if (verticalBars && !isVerticalBarsToolbarGroup(element)) return; + if (verticalBars) children = verticalBarsToolbarActions(element); if ( !children.length || children.some( (child) => - !child.matches(`${menuButton.tag}.ios`) && - (!child.matches('ion-button.ios.button-clear') || (child as HTMLIonButtonElement).fill !== 'clear'), + !child.matches(`${menuButton.tag}${verticalBars ? '' : '.ios'}`) && + (!child.matches(`ion-button${verticalBars ? '' : '.ios.button-clear'}`) || + !(verticalBars ? ['default', 'clear'] : ['clear']).includes((child as HTMLIonButtonElement).fill ?? 'default')), ) ) return; const candidate = createCandidate(element, tag, id); + if (verticalBars && children.length !== element.children.length) candidate.sources = children; for (const child of children) { const supported = child.matches(menuButton.tag) ? menuButton.append(candidate, child as HTMLIonMenuButtonElement, id) diff --git a/src/native/components/ion-menu-button.ts b/src/native/components/ion-menu-button.ts index c225aef3..94e13908 100644 --- a/src/native/components/ion-menu-button.ts +++ b/src/native/components/ion-menu-button.ts @@ -16,7 +16,7 @@ export const append = (candidate: Candidate, button: HTMLIonMenuButtonElement, i export const read = (group: HTMLElement, id: Identify): Candidate | undefined => { if (!inFixedToolbar(group) || !group.matches('ion-buttons') || group.children.length !== 1) return; const button = group.firstElementChild; - if (!button?.matches(`${tag}.ios`)) return; + if (!button?.matches(`${tag}${group.closest('ion-app.ios-theme-vertical-bars') ? '' : '.ios'}`)) return; const candidate = createCandidate(group, tag, id); return append(candidate, button as HTMLIonMenuButtonElement, id) ? candidate : undefined; }; diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 1cd38532..6c2dee68 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -15,6 +15,8 @@ export interface NativeUIShellOptions { enabled?: boolean; /** Controls eligible for native projection. Omit to enable every control; when present, only `true` controls are enabled. */ controls?: NativeUIShellControls; + /** Internal: limit native projection to the Vertical Control Area. */ + verticalBarsOnly?: boolean; } export interface NativeUIShellControls { @@ -37,6 +39,26 @@ export interface NativeUIShellHandle { destroy(): Promise; } +export type VerticalBarEdge = 'left' | 'right' | null; + +export interface VerticalBarPlacement { + edge: VerticalBarEdge; + /** UIKit safe-area inset on the physical vertical-bar edge, in points. */ + inset: number; +} + +export enum HingeStatus { + Unavailable = 'unavailable', + Closed = 'closed', + PartiallyOpen = 'partially-open', + FullyOpen = 'fully-open', +} + +export interface VerticalControlAreaHandle extends NativeUIShellHandle { + /** Applies the application's chosen placement to both Web and native controls. */ + setPlacement(placement: VerticalBarEdge | VerticalBarPlacement): void; +} + export interface NativeUIShellSuspension { /** Releases this suspension. Native projection resumes after all active suspensions are released. */ resume(): Promise; @@ -83,6 +105,10 @@ export interface ShellItem extends Frame { export interface ShellControl extends Frame { id: string; kind: NativeUIShellComponent; + /** Lets the native host own adaptive placement instead of mirroring the DOM frame. */ + placement?: 'vertical-bars'; + /** Logical Ionic toolbar slot, preserved when projecting to the vertical rail. */ + toolbarSlot?: 'start' | 'end'; items: ShellItem[]; dark: boolean; rtl: boolean; @@ -116,6 +142,7 @@ export interface ShellSnapshot { revision: number; transitionDuration?: number; viewportWidth: number; + verticalBarEdge?: Exclude; controls: ShellControl[]; } @@ -130,12 +157,20 @@ export interface WebViewMetrics { radius: number; } +export interface DeviceLayout { + placement: VerticalBarPlacement; + hingeStatus: HingeStatus; + webViewMetrics: WebViewMetrics; +} + export interface NativeUIShellPlugin { - configure(): Promise<{ supported: boolean }>; - getWebViewMetrics(): Promise; + configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; + getDeviceLayout(): Promise; + startDeviceLayoutMonitoring(): Promise; + stopDeviceLayoutMonitoring(): Promise; update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise; addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise; - addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise; + addListener(name: 'deviceLayoutChange', listener: (event: DeviceLayout) => void): Promise; } diff --git a/src/native/index.ts b/src/native/index.ts index 00682612..d0a714d4 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,8 +1,17 @@ import { Capacitor, registerPlugin } from '@capacitor/core'; import { setConfig } from '../transition/ios.transition'; -import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellPlugin, WebViewMetrics } from './definitions'; -import { bindMetricsLifecycle } from './lifecycle'; +import type { + NativeUIShellHandle, + NativeUIShellOptions, + NativeUIShellPlugin, + VerticalBarEdge, + VerticalBarPlacement, + VerticalControlAreaHandle, + WebViewMetrics, +} from './definitions'; import { createRuntime } from './runtime'; +import { createVerticalBarsWebProjection } from './vertical-bars-web'; +import { prehideVerticalBarsToolbarSources } from './prehide'; export type { NativeUIShellComponent, NativeUIShellControls, @@ -10,29 +19,118 @@ export type { NativeUIShellOptions, NativeUIShellStatus, NativeUIShellSuspension, + DeviceLayout, + VerticalBarEdge, + VerticalBarPlacement, + VerticalControlAreaHandle, WebViewMetrics, } from './definitions'; +export { HingeStatus } from './definitions'; const plugin = registerPlugin('IonicNativeUIShell'); +export const IonicNativeUIShell = plugin; let active: Promise | undefined; +let activeConfiguration: string | undefined; const web = (reason: string): NativeUIShellHandle => ({ getStatus: () => ({ state: 'web', projected: 0, updates: 0, reason }), suspend: async () => ({ resume: async () => {} }), destroy: async () => {}, }); +const combine = (native: NativeUIShellHandle, fallback: NativeUIShellHandle): NativeUIShellHandle => ({ + getStatus() { + const nativeStatus = native.getStatus(); + const fallbackStatus = fallback.getStatus(); + return { + ...nativeStatus, + state: + nativeStatus.state === 'stopped' && fallbackStatus.state === 'stopped' ? 'stopped' : nativeStatus.projected > 0 ? 'native' : 'web', + projected: nativeStatus.projected + fallbackStatus.projected, + updates: nativeStatus.updates + fallbackStatus.updates, + }; + }, + async suspend() { + const [nativeLease, fallbackLease] = await Promise.all([native.suspend(), fallback.suspend()]); + return { resume: async () => void (await Promise.all([nativeLease.resume(), fallbackLease.resume()])) }; + }, + async destroy() { + await Promise.all([native.destroy(), fallback.destroy()]); + }, +}); + +/** Wraps a runtime handle with the shared enable-lifecycle: reason, suspend hooks and idempotent destroy. */ +const manage = ( + handle: NativeUIShellHandle, + lifecycle: { + reason?: string; + suspend?: () => (() => void) | undefined; + destroy?: () => void | Promise; + } = {}, +): NativeUIShellHandle => { + let destroyed = false; + return { + getStatus: () => (lifecycle.reason ? { ...handle.getStatus(), reason: lifecycle.reason } : handle.getStatus()), + async suspend() { + const lease = await handle.suspend(); + const resume = lifecycle.suspend?.(); + return { + async resume() { + await lease.resume(); + resume?.(); + }, + }; + }, + async destroy() { + if (destroyed) return; + destroyed = true; + try { + await handle.destroy(); + } finally { + await lifecycle.destroy?.(); + active = undefined; + activeConfiguration = undefined; + } + }, + }; +}; + /** Reads the current native WebView geometry and applies it to page transitions. */ export const configureNativeTransition = async (): Promise => { - const metrics = typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? await plugin.getWebViewMetrics() : { radius: 0 }; + const metrics = + typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? (await plugin.getDeviceLayout()).webViewMetrics : { radius: 0 }; setConfig({ radius: metrics.radius }); return metrics; }; +/** Applies one placement to the CSS layout and both Web/native projections. */ +export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | VerticalBarPlacement): void => { + if (typeof document === 'undefined') return; + const app = document.querySelector('ion-app'); + if (!app) throw new Error('Vertical Control Area requires ion-app'); + const { edge, inset } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 }; + app.classList.toggle('ios-theme-vertical-bars', edge !== null); + app.classList.toggle('ios-theme-vertical-bars-left', edge === 'left'); + if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`); + else app.style.removeProperty('--ios-theme-vertical-bars-native-inset'); +}; + +/** Call once at application startup. Ionic markup remains the source of truth. */ +export const enableVerticalControlArea = async (): Promise => { + const handle = await enableNativeUIShell({ controls: { tabs: true, toolbar: true }, verticalBarsOnly: true }); + return { + getStatus: () => handle.getStatus(), + suspend: () => handle.suspend(), + destroy: () => handle.destroy(), + setPlacement: setVerticalControlAreaPlacement, + }; +}; + /** Call once at application startup. Ionic markup remains the source of truth. */ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise => { if (options.enabled === false) { const current = active; active = undefined; + activeConfiguration = undefined; return current ? current.then(async (handle) => { await handle.destroy(); @@ -40,23 +138,66 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise }) : Promise.resolve(web('Disabled')); } - if (typeof document === 'undefined' || Capacitor.getPlatform() !== 'ios') return Promise.resolve(web('Requires Capacitor iOS')); + if (typeof document === 'undefined') return Promise.resolve(web('Requires a document')); + const controls = options.controls; + const configuration = JSON.stringify([ + options.verticalBarsOnly === true, + ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true), + ]); + if (active && activeConfiguration !== configuration) + return Promise.reject( + new Error('Native UI Shell is already running with different controls; destroy it before changing configuration.'), + ); + activeConfiguration = configuration; + const prehide = + !active && (options.controls === undefined || options.controls.toolbar === true) + ? prehideVerticalBarsToolbarSources(document) + : undefined; + const fallback = (reason: string) => + manage(createVerticalBarsWebProjection(document, options), { + reason, + suspend: () => prehide?.suspend(), + destroy: () => prehide?.stop(), + }); return (active ??= (async () => { + if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS'); + let runtime: NativeUIShellHandle | undefined; + let placementListener: Awaited> | undefined; + let monitoring = false; try { - await configureNativeTransition().catch(() => undefined); - if (!(await plugin.configure()).supported) { - active = undefined; - return web('Requires iOS 26 or later'); - } - const runtime = await createRuntime(document, plugin, options); - return await bindMetricsLifecycle( - runtime, - () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })), - () => (active = undefined), + if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined); + const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }); + if (!capabilities.supported) return fallback('Requires iOS 26 or later'); + let nativeEdge: VerticalBarEdge = null; + const nativeVerticalBars = () => { + const root = document.querySelector('ion-app.ios-theme-vertical-bars'); + return nativeEdge !== null && !!root && nativeEdge === (root.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right'); + }; + await plugin.startDeviceLayoutMonitoring(); + monitoring = true; + placementListener = await plugin.addListener('deviceLayoutChange', ({ placement, webViewMetrics }) => { + nativeEdge = placement.edge; + if (!options.verticalBarsOnly) setConfig({ radius: webViewMetrics.radius }); + document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh')); + }); + nativeEdge = (await plugin.getDeviceLayout()).placement.edge; + runtime = combine( + await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true), + createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars()), ); + return manage(runtime, { + suspend: () => prehide?.suspend(), + destroy: async () => { + await placementListener?.remove().catch(() => {}); + if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {}); + prehide?.stop(); + }, + }); } catch (error) { - active = undefined; - return web(error instanceof Error ? error.message : String(error)); + await runtime?.destroy(); + await placementListener?.remove().catch(() => {}); + if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {}); + return fallback(error instanceof Error ? error.message : String(error)); } })()); }; diff --git a/src/native/lifecycle.ts b/src/native/lifecycle.ts deleted file mode 100644 index a8e18d72..00000000 --- a/src/native/lifecycle.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { PluginListenerHandle } from '@capacitor/core'; -import type { NativeUIShellHandle } from './definitions'; - -export const bindMetricsLifecycle = async ( - runtime: NativeUIShellHandle, - listen: () => Promise, - deactivate: () => void, -): Promise => { - let listener: PluginListenerHandle; - try { - listener = await listen(); - } catch (error) { - await runtime.destroy(); - throw error; - } - - let destroying: Promise | undefined; - return { - getStatus: runtime.getStatus, - suspend: runtime.suspend, - destroy() { - return (destroying ??= (async () => { - let removalError: unknown; - try { - await listener.remove(); - } catch (error) { - removalError = error; - } - try { - await runtime.destroy(); - } finally { - deactivate(); - } - if (removalError) throw removalError; - })()); - }, - }; -}; diff --git a/src/native/prehide.ts b/src/native/prehide.ts new file mode 100644 index 00000000..a01c8782 --- /dev/null +++ b/src/native/prehide.ts @@ -0,0 +1,245 @@ +import { LIFECYCLE_DID_LEAVE, LIFECYCLE_WILL_ENTER } from '@ionic/core'; +import { + clearVerticalBarsPlacement, + verticalBarsBackWebClass, + verticalBarsOwned, + inFixedToolbar, + isVerticalBarsToolbarActionShape, + isPermanentlyExcluded, + isVerticalBarsBackPosition, + isShellDisabled, + prehiddenClass, + prehideRootClass, + rejectedClass, + setVerticalBarsPlacement, +} from './shared/dom'; + +const overlays = 'ion-menu, ion-modal, ion-popover'; +const sourceSelector = 'ion-back-button, ion-menu-button, ion-buttons, ion-button'; +const backSupported = (element: HTMLElement): boolean => { + const back = element as HTMLIonBackButtonElement; + return back.icon === undefined && back.color === undefined && !!back.shadowRoot; +}; +const eligibleBack = (element: HTMLElement): boolean => + isVerticalBarsBackPosition(element) && + !element.closest('ion-buttons.ios-theme-horizontal-only') && + !isPermanentlyExcluded(element) && + !isShellDisabled(element) && + !element.closest(overlays); +const eligible = (element: HTMLElement): boolean => + inFixedToolbar(element) && + !element.closest('ion-buttons.ios-theme-horizontal-only, ion-button.ios-theme-horizontal-only') && + !isPermanentlyExcluded(element) && + !isShellDisabled(element) && + !element.closest(overlays); + +/** Capture toolbar placement once per routed-page epoch, before Ionic paints its transition. */ +export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () => () => void; stop: () => void } => { + doc.documentElement.classList.add(prehideRootClass); + const scopes = new Map>(); + const owner = new WeakMap(); + const pendingBacks = new Map }>(); + const listeners = new AbortController(); + const root = () => doc.querySelector('ion-app.ios-theme-vertical-bars'); + let suspended = 0; + let stopped = false; + const routedPage = (element: HTMLElement) => element.closest('.ion-page:not(ion-app, body)'); + const release = (scope: HTMLElement) => { + for (const [element, pending] of pendingBacks) { + if (pending.scope !== scope) continue; + clearTimeout(pending.timer); + pendingBacks.delete(element); + } + const owned = scopes.get(scope); + if (!owned) return; + owned.forEach((element) => { + if (owner.get(element) !== scope) return; + owner.delete(element); + element.classList.remove(prehiddenClass); + element.classList.remove(verticalBarsBackWebClass); + clearVerticalBarsPlacement(element); + }); + scopes.delete(scope); + }; + const capture = (scope: HTMLElement) => { + if (!root()?.contains(scope) || scope.closest(overlays)) return; + const owned = scopes.get(scope) ?? new Set(); + const place = (element: HTMLElement, rail: boolean) => { + if (owned.has(element)) return; + const previousScope = owner.get(element); + if (previousScope) scopes.get(previousScope)?.delete(element); + owner.set(element, scope); + setVerticalBarsPlacement(element, rail); + if (element.matches('ion-back-button')) element.classList.toggle(verticalBarsBackWebClass, !rail); + owned.add(element); + }; + const sources = scope.matches('ion-back-button') ? [scope] : Array.from(scope.querySelectorAll(sourceSelector)); + sources.forEach((element) => { + if (element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope)) return; + if (element.matches('ion-buttons')) { + const children = Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement); + const actions = children.filter((child) => eligible(child) && isVerticalBarsToolbarActionShape(child)); + const group = + eligible(element) && + actions.length === children.length && + (actions.length > 1 || (actions.length === 1 && actions[0].matches('ion-menu-button'))); + const freshGroup = !owned.has(element); + place(element, group); + children.forEach((child) => { + if (!child.matches('ion-back-button')) place(child, freshGroup && actions.includes(child)); + }); + } else if (element.matches('ion-back-button') || !element.parentElement?.matches('ion-buttons')) { + if (owned.has(element)) return; + if ( + element.matches('ion-back-button') && + eligibleBack(element) && + (element as HTMLIonBackButtonElement).icon === undefined && + (element as HTMLIonBackButtonElement).color === undefined && + !element.shadowRoot && + !element.classList.contains('hydrated') + ) { + const previousPending = pendingBacks.get(element); + if (previousPending && previousPending.scope !== scope) { + clearTimeout(previousPending.timer); + pendingBacks.delete(element); + } + if (!pendingBacks.has(element)) { + const timer = setTimeout(() => { + if (pendingBacks.get(element)?.scope !== scope) return; + pendingBacks.delete(element); + // A custom element that never hydrates must remain usable on Web. + place(element, false); + reconcile(); + }, 1500); + pendingBacks.set(element, { scope, timer }); + } + return; + } + const pending = pendingBacks.get(element); + if (pending) { + clearTimeout(pending.timer); + pendingBacks.delete(element); + } + place( + element, + element.matches('ion-back-button') + ? eligibleBack(element) && backSupported(element) + : eligible(element) && isVerticalBarsToolbarActionShape(element), + ); + } + }); + scopes.set(scope, owned); + }; + const reconcile = () => { + const verticalBars = root(); + if (!verticalBars) { + Array.from(scopes.keys()).forEach(release); + return; + } + for (const scope of scopes.keys()) if (!scope.isConnected) release(scope); + for (const [element, pending] of pendingBacks) if (element.shadowRoot || element.classList.contains('hydrated')) capture(pending.scope); + // Ionic inserts the destination as invisible before WillEnter. Hide its + // sources in that same mutation microtask; capture new DOM identities on active pages too. + verticalBars.querySelectorAll('.ion-page:not(ion-app, body, .ion-page-hidden)').forEach(capture); + // Root toolbars can mount after startup; each new DOM identity is captured once. + verticalBars.querySelectorAll(sourceSelector).forEach((element) => { + if (routedPage(element) || element.closest(overlays)) return; + const scope = element.closest('ion-toolbar') ?? (element.matches('ion-back-button') ? element : null); + if (scope) capture(scope); + }); + for (const owned of scopes.values()) { + for (const element of owned) { + const groupOwnsChildren = + element.parentElement?.matches('ion-buttons') && + verticalBarsOwned(element.parentElement) && + Array.from(element.parentElement.children).every((child) => child instanceof HTMLElement && verticalBarsOwned(child)); + const hide = + !suspended && + verticalBarsOwned(element) && + !groupOwnsChildren && + element.isConnected && + !element.closest(`.${rejectedClass}`) && + !isPermanentlyExcluded(element) && + !isShellDisabled(element) && + (!element.matches('ion-back-button') || backSupported(element) || !element.classList.contains('hydrated')) && + (!element.matches('ion-buttons') || + Array.from(element.children).every((child) => child instanceof HTMLElement && verticalBarsOwned(child))); + element.classList.toggle(prehiddenClass, hide); + if (element.matches('ion-back-button')) element.classList.toggle(verticalBarsBackWebClass, !hide); + } + } + }; + const onWillEnter = (event: Event) => { + const page = event.target; + if (page instanceof HTMLElement && page.matches('.ion-page')) { + release(page); // Cached pages begin a new placement epoch. + capture(page); + reconcile(); + } + }; + const onDidLeave = (event: Event) => { + const page = event.target; + if (page instanceof HTMLElement) release(page); + }; + doc.addEventListener(LIFECYCLE_WILL_ENTER, onWillEnter, { capture: true, signal: listeners.signal }); + doc.addEventListener(LIFECYCLE_DID_LEAVE, onDidLeave, { capture: true, signal: listeners.signal }); + reconcile(); + const mutationRelevant = (record: MutationRecord) => { + if (record.type === 'childList') + return [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)].some( + (node) => + node instanceof Element && + (node.matches(`${sourceSelector}, .ion-page, ion-toolbar`) || !!node.querySelector(`${sourceSelector}, .ion-page, ion-toolbar`)), + ); + if (record.attributeName !== 'class') return true; + const current = (record.target as Element).className; + const previous = record.oldValue ?? ''; + const withoutOwned = (value: string) => + value + .split(/\s+/) + .filter((name) => name && ![prehiddenClass, prehideRootClass, verticalBarsBackWebClass].includes(name)) + .join(' '); + return typeof current !== 'string' || withoutOwned(previous) !== withoutOwned(current); + }; + let scheduled = false; + const observer = new (doc.defaultView?.MutationObserver ?? MutationObserver)((records) => { + if (scheduled || !records.some(mutationRelevant)) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + reconcile(); + }); + }); + observer.observe(doc.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeOldValue: true, + attributeFilter: ['class', 'hidden', 'inert', 'icon', 'color'], + }); + return { + suspend: () => { + if (stopped) return () => {}; + suspended++; + doc.documentElement.classList.remove(prehideRootClass); + reconcile(); + let resumed = false; + return () => { + if (resumed || stopped) return; + resumed = true; + if (--suspended === 0) { + doc.documentElement.classList.add(prehideRootClass); + reconcile(); + } + }; + }, + stop: () => { + if (stopped) return; + stopped = true; + observer.disconnect(); + listeners.abort(); + doc.documentElement.classList.remove(prehideRootClass); + Array.from(scopes.keys()).forEach(release); + }, + }; +}; diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 893f1ca3..70a7a632 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -1,6 +1,6 @@ import type { PluginListenerHandle } from '@capacitor/core'; import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from '@ionic/core'; -import { getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration'; +import { VERTICAL_BARS_TRANSITION_CANCELED, getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration'; import { createSearchSupport } from './components/searchable-tabs'; import type { ShellActivation, @@ -10,8 +10,17 @@ import type { NativeUIShellPlugin, NativeUIShellStatus, } from './definitions'; -import { readCandidate, selector, shadowSelector, motionSelector } from './components'; -import { marker, unprojected } from './shared/dom'; +import { readCandidate, selector, shadowSelector, motionSelector, isVerticalBarsCandidate } from './components'; +import { + activateProjectedElement, + createVerticalBarsPageState, + isVerticalBarsSource, + preferredVerticalBarsBack, + marker, + prehideOnlyMutation, + rejectedClass, + unprojected, +} from './shared/dom'; import { createIconRenderer } from './shared/icons'; import type { Candidate } from './shared/candidate'; import { CSS_MOTION_EVENTS } from './shared/events'; @@ -19,6 +28,8 @@ import { createCrossfade, fadeMarker } from './shared/crossfade'; const overlays = 'ion-modal, ion-popover, ion-alert, ion-action-sheet, ion-loading, ion-picker, ion-toast, ion-menu'; const overlayNames = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast']; +const verticalBarsMarker = 'data-native-ui-shell-vertical-bars'; +const verticalBarsMemberMarker = 'data-native-ui-shell-vertical-bars-member'; // A failed bridge must not leave the source inaccessible indefinitely. const bounded = (promise: Promise): Promise => @@ -31,6 +42,8 @@ export const createRuntime = async ( doc: Document, plugin: NativeUIShellPlugin, options: NativeUIShellOptions = {}, + nativeVerticalBars: () => boolean = () => true, + verticalBarsOnly = false, ): Promise => { const win = doc.defaultView!; const icons = createIconRenderer(); @@ -38,8 +51,11 @@ export const createRuntime = async ( const ids = new WeakMap(); let rejected = new WeakMap(); const sources = new Map(); + const verticalBarsOwners = new Set(); + const verticalBarsMembers = new Set(); const suspended = new Set(); const pages = new Set(); + const verticalBarsPages = createVerticalBarsPageState(); const presented = new Set(); const manualSuspensions = new Set(); const moving = new Map>(); @@ -95,13 +111,22 @@ export const createRuntime = async ( const style = doc.createElement('style'); const hidden = `[${marker}]:not([${fadeMarker}])`; style.textContent = `${hidden}, ${hidden} *, ${hidden}::before, ${hidden}::after, ${hidden}::part(native) { visibility: hidden !important; } - [${marker}], [${marker}] * { pointer-events: none !important; }`; + [${marker}], [${marker}] * { pointer-events: none !important; } + /* Ionic disables the covered page while a menu is open. VerticalBars rail + controls remain outside that page; zero specificity preserves any + pointer-events rule supplied by the application itself. */ + :where(.menu-content-open) :where([${verticalBarsMarker}]) { + pointer-events: auto; + } + :where(.menu-content-open) :where([${verticalBarsMarker}]) > :where(:not([${verticalBarsMemberMarker}])) { + pointer-events: none; + }`; const restore = (element: HTMLElement) => { lastSnapshot = ''; search.release(element); element.removeAttribute(marker); - if (!stopped) crossfade.play(element, false, handoffInstant); + if (!stopped) crossfade.play(element, false, handoffInstant || isVerticalBarsSource(element)); if (element.getAttribute('aria-hidden') === 'true') { const previous = sources.get(element); if (previous == null) element.removeAttribute('aria-hidden'); @@ -110,7 +135,13 @@ export const createRuntime = async ( sources.delete(element); element.dispatchEvent(new CustomEvent('nativeUIShellChange')); }; - const restoreAll = () => Array.from(sources.keys()).forEach(restore); + const restoreAll = () => { + Array.from(sources.keys()).forEach(restore); + verticalBarsOwners.forEach((element) => element.removeAttribute(verticalBarsMarker)); + verticalBarsOwners.clear(); + verticalBarsMembers.forEach((element) => element.removeAttribute(verticalBarsMemberMarker)); + verticalBarsMembers.clear(); + }; const finishWaiters = () => { const current = waiters; waiters = []; @@ -123,8 +154,39 @@ export const createRuntime = async ( }; const search = createSearchSupport(doc, id, schedule); const candidateSources = (candidate: Candidate) => candidate.sources ?? [candidate.element]; + const setRejected = (element: HTMLElement, value: boolean) => { + element.classList.toggle(rejectedClass, value); + }; + const measuringPointerPages = new WeakSet(); const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { - const candidate = readCandidate(element, id); + if (element.matches('ion-back-button') && element.closest('ion-app.ios-theme-vertical-bars') && !isVerticalBarsCandidate(element)) + return; + if (verticalBarsOnly && !isVerticalBarsCandidate(element)) return; + const pointerPage = isVerticalBarsCandidate(element) ? element.closest('.ion-page') : undefined; + let candidate: Candidate | undefined; + if (pointerPage && getComputedStyle(pointerPage).pointerEvents === 'none') { + const previous = pointerPage.style.getPropertyValue('pointer-events'); + const priority = pointerPage.style.getPropertyPriority('pointer-events'); + const hadStyle = pointerPage.hasAttribute('style'); + measuringPointerPages.add(pointerPage); + pointerPage.style.setProperty('pointer-events', 'auto', 'important'); + try { + candidate = readCandidate(element, id); + } finally { + if (previous) pointerPage.style.setProperty('pointer-events', previous, priority); + else pointerPage.style.removeProperty('pointer-events'); + if (!hadStyle && !pointerPage.style.length) pointerPage.removeAttribute('style'); + win.setTimeout(() => measuringPointerPages.delete(pointerPage), 0); + } + } else candidate = readCandidate(element, id); + if (candidate && isVerticalBarsCandidate(element)) { + if (!nativeVerticalBars()) return undefined; + candidate.control.placement = 'vertical-bars'; + if (['ion-button', 'ion-buttons', 'ion-menu-button'].includes(candidate.control.kind)) { + const slot = (element.matches('ion-buttons') ? element : (element.closest('ion-buttons') ?? element)).getAttribute('slot'); + if (slot === 'start' || slot === 'end') candidate.control.toolbarSlot = slot; + } + } return candidate && controlEnabled(candidate) ? candidate : undefined; }; const flush = async () => { @@ -137,17 +199,22 @@ export const createRuntime = async ( } }; const blocked = (element: HTMLElement) => - Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) || - Array.from(pages).some((page) => page.contains(element)) || - Array.from(moving.keys()).some((surface) => surface.contains(element)); + verticalBarsPages.isDeparted(element) || + (!isVerticalBarsSource(element) && + (Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) || + Array.from(pages).some((scope) => scope.contains(element)) || + Array.from(moving.keys()).some((surface) => surface.contains(element)))); const painted = () => new Promise((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve()))); - const overlayOpen = () => { + const overlayOpen = (includeMenu = true) => { for (const element of presented) if (!element.isConnected) presented.delete(element); + const presentedOverlayOpen = Array.from(presented).some((element) => includeMenu || !element.matches('ion-menu')); return ( manualSuspensions.size > 0 || - presented.size > 0 || + presentedOverlayOpen || Array.from(doc.querySelectorAll(overlays)).some( - (element) => (element as Element & { presented?: boolean }).presented || element.classList.contains('show-menu'), + (element) => + (includeMenu || !element.matches('ion-menu')) && + ((element as Element & { presented?: boolean }).presented || element.classList.contains('show-menu')), ) ); }; @@ -158,19 +225,33 @@ export const createRuntime = async ( search.keepSearchTabsVisible(); for (const page of pages) if (!page.isConnected) pages.delete(page); for (const surface of moving.keys()) if (!surface.isConnected) moving.delete(surface); - if (doc.hidden || overlayOpen()) return []; + if (doc.hidden || overlayOpen(false)) return []; if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive()) return []; - return unprojected(sources.keys(), () => + const menuOpen = overlayOpen(); + const candidates = unprojected(sources.keys(), () => search .decorate( Array.from(doc.querySelectorAll(selector)) - .filter((element) => !blocked(element)) + .filter( + (element) => !verticalBarsPages.isDeparted(element) && (!blocked(element) || (menuOpen && isVerticalBarsSource(element))), + ) .map(readEnabledCandidate) .filter((candidate): candidate is Candidate => !!candidate), blocked, ) .filter((candidate) => !rejected.has(candidate.element) || rejected.get(candidate.element) !== signature(candidate)), ); + const back = preferredVerticalBarsBack( + candidates + .filter((candidate) => candidate.control.kind === 'ion-back-button' && isVerticalBarsCandidate(candidate.element)) + .map((candidate) => candidate.element), + doc, + ); + const selected = candidates.filter( + (candidate) => + candidate.control.kind !== 'ion-back-button' || !isVerticalBarsCandidate(candidate.element) || candidate.element === back, + ); + return menuOpen ? selected.filter((candidate) => isVerticalBarsCandidate(candidate.element)) : selected; }; const observe = () => { const wanted = new Set(); @@ -239,12 +320,18 @@ export const createRuntime = async ( removed.forEach(restore); // The outgoing tab is no longer visible, so waiting two frames only leaves its // native snapshot over the destination. Stack transitions still need the paint. - if (!handoffInstant) { + if (!handoffInstant && removed.some((element) => !isVerticalBarsSource(element))) { await painted(); if (stopped || dirty) return; } } - const data = { viewportWidth: win.innerWidth, controls: candidates.map((candidate) => candidate.control) }; + const root = doc.querySelector('ion-app.ios-theme-vertical-bars'); + const verticalBarEdge: 'left' | 'right' | undefined = root + ? root.classList.contains('ios-theme-vertical-bars-left') + ? 'left' + : 'right' + : undefined; + const data = { viewportWidth: win.innerWidth, verticalBarEdge, controls: candidates.map((candidate) => candidate.control) }; const serialized = JSON.stringify(data); if (serialized === lastSnapshot && !forceRefresh) return; const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) }; @@ -268,6 +355,7 @@ export const createRuntime = async ( for (const candidate of candidates) { if (result.rejectedControls?.includes(candidate.control.id)) { rejected.set(candidate.element, signatures.get(candidate.element)!); + setRejected(candidate.element, true); dirty = true; } } @@ -276,6 +364,15 @@ export const createRuntime = async ( const current = dirty ? new Map(currentCandidates.map((candidate) => [candidate.element, signature(candidate)])) : signatures; const currentSources = new Set(currentCandidates.flatMap(candidateSources)); const accepted = candidates.filter((candidate) => current.get(candidate.element) === signatures.get(candidate.element)); + accepted.forEach((candidate) => setRejected(candidate.element, false)); + const acceptedVerticalBarsOwners = new Set( + accepted.filter((candidate) => candidate.control.placement === 'vertical-bars').map((candidate) => candidate.element), + ); + const acceptedVerticalBarsMembers = new Set( + accepted + .filter((candidate) => candidate.control.placement === 'vertical-bars') + .flatMap((candidate) => Array.from(candidate.actions.values())), + ); const invalidated = candidates.length !== accepted.length; acceptedRevision = result.revision; lastSnapshot = invalidated ? '' : serialized; @@ -285,10 +382,28 @@ export const createRuntime = async ( // Keep an existing cover while its content catches up. Only an ineligible // source needs to return to Web; new sources still require an exact ack. for (const element of sources.keys()) if (!currentSources.has(element)) restore(element); + for (const element of verticalBarsOwners) { + if (acceptedVerticalBarsOwners.has(element)) continue; + element.removeAttribute(verticalBarsMarker); + verticalBarsOwners.delete(element); + } + for (const element of acceptedVerticalBarsOwners) { + element.setAttribute(verticalBarsMarker, ''); + verticalBarsOwners.add(element); + } + for (const element of verticalBarsMembers) { + if (acceptedVerticalBarsMembers.has(element)) continue; + element.removeAttribute(verticalBarsMemberMarker); + verticalBarsMembers.delete(element); + } + for (const element of acceptedVerticalBarsMembers) { + element.setAttribute(verticalBarsMemberMarker, ''); + verticalBarsMembers.add(element); + } for (const element of accepted.flatMap(candidateSources)) { if (!sources.has(element)) { sources.set(element, element.getAttribute('aria-hidden')); - crossfade.play(element, true, handoffInstant); + crossfade.play(element, true, handoffInstant || isVerticalBarsSource(element)); element.setAttribute(marker, ''); element.setAttribute('aria-hidden', 'true'); element.dispatchEvent(new CustomEvent('nativeUIShellChange')); @@ -320,13 +435,21 @@ export const createRuntime = async ( else finishWaiters(); } }; - const observation: MutationObserverInit = { subtree: true, childList: true, characterData: true, attributes: true }; + const observation: MutationObserverInit = { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeOldValue: true, + }; const observer = new MutationObserver((records) => { if ( records.some( (record) => record.attributeName !== marker && record.attributeName !== fadeMarker && + !prehideOnlyMutation(record) && + !(record.attributeName === 'style' && measuringPointerPages.has(record.target as HTMLElement)) && !( record.attributeName === 'aria-hidden' && sources.has(record.target as HTMLElement) && @@ -341,6 +464,7 @@ export const createRuntime = async ( target.addEventListener(name, callback, { capture: true, signal: listeners.signal }); const pageWill: EventListener = (event) => { const page = event.target as HTMLElement; + verticalBarsPages.lifecycle(event); getNativeSearchBindings(doc) .filter((binding) => page.contains(binding.footer)) .forEach((binding) => search.retire(binding)); @@ -351,7 +475,9 @@ export const createRuntime = async ( schedule(); }; const pageDid: EventListener = (event) => { - pages.delete(event.target as HTMLElement); + const page = event.target as HTMLElement; + verticalBarsPages.lifecycle(event); + pages.delete(page); schedule(); if (tabSwitchHandoff && pages.size === 0) endTabSwitchHandoff(); }; @@ -411,6 +537,14 @@ export const createRuntime = async ( for (const name of Object.values(CSS_MOTION_EVENTS)) on(doc, name, motion); for (const name of [LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE]) on(doc, name, pageWill); for (const name of [LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE]) on(doc, name, pageDid); + on(doc, VERTICAL_BARS_TRANSITION_CANCELED, (event) => { + const leaving = event.target as HTMLElement; + const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering; + verticalBarsPages.cancel(entering, leaving); + pages.delete(leaving); + if (entering) pages.delete(entering); + schedule(); + }); on(doc, 'ionTabsWillChange', () => { // Vanilla ion-tabs dispatches DOM events; @ionic/angular uses EventEmitters instead. armTabSwitchHandoff(); @@ -433,12 +567,16 @@ export const createRuntime = async ( schedule(); }); } + const eventMenu = (event: Event) => + event.composedPath().find((target): target is HTMLElement => target instanceof HTMLElement && target.matches('ion-menu')); on(doc, 'ionWillOpen', (event) => { - presented.add(event.target as HTMLElement); + const menu = eventMenu(event); + if (menu) presented.add(menu); schedule(); }); on(doc, 'ionDidClose', (event) => { - presented.delete(event.target as HTMLElement); + const menu = eventMenu(event); + if (menu) presented.delete(menu); schedule(); }); for (const name of [ @@ -516,6 +654,7 @@ export const createRuntime = async ( /* Always restore the Web, even after bridge loss. */ } style.remove(); + doc.querySelectorAll(`.${rejectedClass}`).forEach((element) => setRejected(element, false)); icons.clear(); pages.clear(); presented.clear(); @@ -534,7 +673,7 @@ export const createRuntime = async ( event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence || - overlayOpen() + overlayOpen(false) ) return; // Native may send input before update() resolves on the JS bridge. @@ -555,7 +694,7 @@ export const createRuntime = async ( candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id); if (!searchAction && (!item || item.disabled || item.visible === false)) return; // The original Ionic host owns form submission, routerLink and selection events. - element.click(); + activateProjectedElement(element); lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection. schedule(); }; @@ -591,9 +730,14 @@ export const createRuntime = async ( await flush(); // Source DOM has been restored before Ionic starts moving it. await new Promise((resolve) => win.requestAnimationFrame(() => resolve())); - return () => { + return (canceled = false) => { suspended.delete(scopes); - scopes.forEach((scope) => pages.delete(scope)); // interactive cancellation has no didLeave. + if (canceled || !scopes.some((scope) => scope.closest('ion-app.ios-theme-vertical-bars'))) { + scopes.forEach((scope) => pages.delete(scope)); // Preserve ordinary iPhone handoff; cancellation has no DidLeave. + if (canceled) { + verticalBarsPages.cancel(scopes[0], scopes[1]); // The entering page is abandoned; the leaving page stays active. + } + } schedule(); }; }, diff --git a/src/native/shared/candidate.ts b/src/native/shared/candidate.ts index 22b00dd7..e9739cc9 100644 --- a/src/native/shared/candidate.ts +++ b/src/native/shared/candidate.ts @@ -1,5 +1,5 @@ import type { ShellControl, ShellItem, NativeUIShellComponent } from '../definitions'; -import { frame, isDark, text, visible } from './dom'; +import { frame, isDark, isVerticalBarsSource, text, visible } from './dom'; import { iconSource } from './icons'; export interface Candidate { @@ -39,7 +39,11 @@ export const appendItem = ( content: ParentNode = child, label = text(child), ): ShellItem | undefined => { - if (!visible(child) || child.querySelector('input, button, a, img, canvas, video, ion-spinner, ion-avatar')) return; + if ( + !visible(child, isVerticalBarsSource(candidate.element)) || + child.querySelector('input, button, a, img, canvas, video, ion-spinner, ion-avatar') + ) + return; const icons = Array.from(content.querySelectorAll('ion-icon')); const directSVGs = Array.from(content.querySelectorAll('svg')); if (icons.length + directSVGs.length > 1) return undefined; @@ -54,7 +58,11 @@ export const appendItem = ( id: id(child), ...frame(child.getBoundingClientRect(), candidate.element.getBoundingClientRect()), label, - accessibilityLabel: child.getAttribute('aria-label') ?? native?.getAttribute('aria-label') ?? label, + accessibilityLabel: + child.getAttribute('aria-label') ?? + native?.getAttribute('aria-label') ?? + (icons[0]?.getAttribute('aria-hidden') === 'true' ? null : icons[0]?.getAttribute('aria-label')) ?? + label, disabled: !!(child as ItemElement).disabled || !!(candidate.element as ItemElement).disabled || diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 30096285..ecd02de1 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -3,15 +3,174 @@ import { fadeMarker } from './crossfade'; import type { Frame } from '../definitions'; export const marker = 'data-native-ui-shell'; -export const isDark = (style: CSSStyleDeclaration): boolean => style.getPropertyValue('--ios27-color-scheme').trim() === 'dark'; -export const excluded = - '.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled, .ion-page-hidden, .ion-page-invisible, .ion-cloned-element, [hidden], [inert]'; +export const prehiddenClass = 'ios-theme-native-ui-shell-prehidden'; +export const prehideRootClass = 'ios-theme-native-ui-shell-prehide'; +export const rejectedClass = 'ios-theme-native-ui-shell-rejected'; +export const verticalBarsBackWebClass = 'ios-theme-vertical-bars-back-web-owned'; +const prehideClasses = new Set([prehiddenClass, prehideRootClass]); +export const prehideOnlyMutation = (record: MutationRecord): boolean => { + if (record.attributeName !== 'class' || record.oldValue === null) return false; + const withoutPrehide = (value: string) => + value + .split(/\s+/) + .filter((name) => name && !prehideClasses.has(name)) + .join(' '); + return withoutPrehide(record.oldValue) === withoutPrehide((record.target as Element).getAttribute('class') ?? ''); +}; + +export const withoutPrehide = (element: HTMLElement, read: () => T): T => { + const changed: HTMLElement[] = []; + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + if (current.classList.contains(prehiddenClass)) { + current.classList.remove(prehiddenClass); + changed.push(current); + } + } + const root = element.ownerDocument.documentElement; + if (element.matches('ion-back-button') && root.classList.contains(prehideRootClass)) { + root.classList.remove(prehideRootClass); + changed.push(root); + } + try { + return read(); + } finally { + changed.forEach((current) => current.classList.add(current === root ? prehideRootClass : prehiddenClass)); + } +}; +export const isDark = (style: CSSStyleDeclaration): boolean => { + const themeScheme = style.getPropertyValue('--ios27-color-scheme').trim(); + if (themeScheme) return themeScheme === 'dark'; + const background = style.getPropertyValue('--ion-background-color-rgb').match(/\d+/g)?.slice(0, 3).map(Number); + return !!background && background.length === 3 && background[0] * 0.2126 + background[1] * 0.7152 + background[2] * 0.0722 < 128; +}; +const permanentlyExcluded = '.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled, .ion-cloned-element, [hidden], [inert]'; +export const excluded = `${permanentlyExcluded}, .ion-page-hidden, .ion-page-invisible`; +const enteringPages = new WeakSet(); +export const setVerticalBarsEnteringPage = (page: HTMLElement, entering: boolean): void => { + if (entering) enteringPages.add(page); + else enteringPages.delete(page); +}; +export const verticalBarsEnteringPage = (element: HTMLElement): HTMLElement | undefined => { + const page = element.closest('.ion-page-invisible'); + return page && enteringPages.has(page) && page.closest('ion-app.ios-theme-vertical-bars') ? page : undefined; +}; +export const createVerticalBarsPageState = () => { + const departed = new WeakSet(); + return { + isDeparted(element: HTMLElement): boolean { + const page = element.closest('.ion-page'); + return !!page && departed.has(page) && !!element.closest('ion-app.ios-theme-vertical-bars'); + }, + lifecycle(event: Event): void { + const page = event.target; + if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return; + const entering = event.type === 'ionViewWillEnter'; + if (entering || event.type === 'ionViewDidEnter') departed.delete(page); + else if (event.type === 'ionViewWillLeave' || event.type === 'ionViewDidLeave') departed.add(page); + setVerticalBarsEnteringPage(page, entering); + }, + cancel(entering?: HTMLElement, leaving?: HTMLElement): void { + if (entering) { + departed.add(entering); + setVerticalBarsEnteringPage(entering, false); + } + if (leaving) departed.delete(leaving); + }, + }; +}; +const disabledButtonGroup = 'ion-buttons:is(.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled)'; const shellDisabledSelector = '.ios-theme-shell-disabled'; +export const isDisabledButtonGroupChild = (element: HTMLElement): boolean => + element.matches('ion-button') && element.parentElement?.matches(disabledButtonGroup) === true; + +const verticalBarsTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons', 'ion-menu-button', 'ion-tab-bar']); + +export const isVerticalBarsSource = (element: HTMLElement): boolean => + verticalBarsTags.has(element.localName) && + (element.matches('ion-tab-bar') || verticalBarsOwned(element)) && + !element.closest('ion-menu, ion-modal, ion-popover') && + !!element.closest('ion-app.ios-theme-vertical-bars'); + +export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => { + if (element.closest('ion-header[collapse], ion-footer[collapse]')) return false; + const page = element.closest('.ion-page'); + if (!page) return true; + const candidates = Array.from(page.querySelectorAll('ion-back-button')).filter( + (back) => + back.closest('.ion-page') === page && + !back.matches('.ion-cloned-element') && + !back.closest('ion-header[collapse], ion-footer[collapse], ion-menu, ion-modal, ion-popover'), + ); + const rank = (back: HTMLElement) => + inFixedToolbar(back) ? 0 : back.closest('ion-header ion-toolbar, ion-footer ion-toolbar') ? 1 : back.closest('ion-toolbar') ? 3 : 2; + const best = candidates.reduce( + (winner, back) => (!winner || rank(back) < rank(winner) ? back : winner), + undefined, + ); + return best === element; +}; + +export const preferredVerticalBarsBack = (elements: T[], doc: Document): T | undefined => { + const pages = Array.from(doc.querySelectorAll('.ion-page')); + const pageOrder = (element: Element) => pages.indexOf(element.closest('.ion-page')!); + return elements.sort((a, b) => pageOrder(b) - pageOrder(a) || Number(!!b.closest('ion-header')) - Number(!!a.closest('ion-header')))[0]; +}; + +const excludedBy = (element: HTMLElement, selector: string): boolean => { + const owner = element.closest(selector); + return !!owner && !(element.parentElement === owner && isDisabledButtonGroupChild(element)); +}; + +export const isPermanentlyExcluded = (element: HTMLElement): boolean => excludedBy(element, permanentlyExcluded); +export const isExcluded = (element: HTMLElement, enteringPage?: HTMLElement): boolean => + excludedBy(element, `${permanentlyExcluded}, .ion-page-hidden`) || (!!element.closest('.ion-page-invisible') && !enteringPage); + // A shared native surface must not cover an opted-out descendant either. export const isShellDisabled = (element: Element): boolean => !!element.closest(shellDisabledSelector) || !!element.querySelector(shellDisabledSelector); +export const isVerticalBarsToolbarActionShape = (element: HTMLElement): boolean => + element.matches('ion-menu-button') || + (element.matches('ion-button') && + !!element.querySelector('ion-icon, svg') && + !Array.from(element.childNodes).some((node) => node.nodeType === Node.TEXT_NODE && !!node.textContent?.trim()) && + !element.matches('.ion-color, [color]') && + ['default', 'clear'].includes((element as HTMLIonButtonElement).fill ?? element.getAttribute('fill') ?? 'default')); + +// Placement belongs to the DOM identity for one routed-page epoch. Changes to +// content/disabled state affect rendering, never its chosen surface. +const verticalBarsPlacement = new WeakMap(); +export const setVerticalBarsPlacement = (element: HTMLElement, rail: boolean): void => { + verticalBarsPlacement.set(element, rail); +}; +export const clearVerticalBarsPlacement = (element: HTMLElement): void => { + verticalBarsPlacement.delete(element); +}; +export const verticalBarsOwned = (element: HTMLElement): boolean => verticalBarsPlacement.get(element) === true; + +export const isVerticalBarsToolbarAction = (element: HTMLElement): boolean => + verticalBarsOwned(element) && !isExcluded(element, verticalBarsEnteringPage(element)) && !isShellDisabled(element); + +export const verticalBarsToolbarActions = (element: HTMLElement): HTMLElement[] => + Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement && isVerticalBarsToolbarAction(child)); + +export const isVerticalBarsToolbarGroup = (element: HTMLElement): boolean => { + return ( + element.matches('ion-buttons') && + verticalBarsOwned(element) && + !element.matches('.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled') && + !isShellDisabled(element) + ); +}; + +export const activateProjectedElement = (element: HTMLElement): void => { + const target = element.matches('ion-button, ion-back-button, ion-menu-button') + ? element.shadowRoot?.querySelector('[part~="native"]') + : undefined; + (target ?? element).click(); +}; + export const unprojected = (elements: Iterable, read: () => T): T => { const hidden = Array.from(elements).filter((element) => element.hasAttribute(marker)); hidden.forEach((element) => element.removeAttribute(marker)); @@ -22,14 +181,22 @@ export const unprojected = (elements: Iterable, read: () => T): } }; -export const visible = (element: HTMLElement): boolean => { - if (!element.isConnected || element.closest(excluded) || isShellDisabled(element)) return false; +const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boolean => { + const enteringPage = isVerticalBarsSource(element) ? verticalBarsEnteringPage(element) : undefined; + if (!element.isConnected || isExcluded(element, enteringPage) || isShellDisabled(element)) return false; for (let current: HTMLElement | null = element; current; current = current.parentElement) { const style = getComputedStyle(current); - if (style.display === 'none' || style.visibility !== 'visible' || (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker))) + if ( + style.display === 'none' || + style.visibility !== 'visible' || + (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker) && current !== enteringPage) + ) return false; - // Moving/collapsing/custom transformed surfaces stay in Web coordinates. + // Ordinary controls on moving/collapsing/custom transformed surfaces stay in Web coordinates. + // VerticalBars rail controls are placed independently of their Web coordinates and must remain + // owned while Ionic transforms the content behind an open menu. if ( + !allowOutsideViewport && style.transform !== 'none' && !new DOMMatrixReadOnly(style.transform).isIdentity && current !== element && @@ -39,10 +206,15 @@ export const visible = (element: HTMLElement): boolean => { } const rect = element.getBoundingClientRect(); return ( - rect.width > 0 && rect.height > 0 && rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1 + rect.width > 0 && + rect.height > 0 && + (allowOutsideViewport || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1)) ); }; +export const visible = (element: HTMLElement, allowOutsideViewport = false): boolean => + withoutPrehide(element, () => readVisible(element, allowOutsideViewport)); + export const frame = (rect: DOMRect, origin?: DOMRect): Frame => ({ x: rect.x - (origin?.x ?? 0), y: rect.y - (origin?.y ?? 0), @@ -61,10 +233,11 @@ export const text = (element: Element): string => { export const inFixedToolbar = (element: Element): boolean => { const edge = element.closest('ion-toolbar')?.parentElement; + const verticalBars = !!element.closest('ion-app.ios-theme-vertical-bars'); return ( !!edge?.matches('ion-header, ion-footer') && !element.closest('ion-content') && !edge.hasAttribute('collapse') && - !edge.matches('.header-collapse-main, .header-collapse-condense') + (verticalBars || !edge.matches('.header-collapse-main, .header-collapse-condense')) ); }; diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts new file mode 100644 index 00000000..ec7205b8 --- /dev/null +++ b/src/native/vertical-bars-web.ts @@ -0,0 +1,380 @@ +import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions'; +import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration'; +import { + activateProjectedElement, + createVerticalBarsPageState, + verticalBarsEnteringPage, + verticalBarsToolbarActions, + inFixedToolbar, + isExcluded, + isVerticalBarsToolbarGroup, + isShellDisabled, + isVerticalBarsBackPosition, + preferredVerticalBarsBack, + verticalBarsOwned, + marker, + prehideOnlyMutation, + prehiddenClass, + unprojected, + withoutPrehide, +} from './shared/dom'; + +const backProjectionClass = 'ios-theme-vertical-bars-back-button-projection'; +const toolbarProjectionClass = 'ios-theme-vertical-bars-toolbar-projection'; +const readyClass = 'ios-theme-vertical-bars-toolbar-ready'; +const toolbarControlSize = 46; +const toolbarControlGap = 10; + +interface ToolbarProjection { + source: HTMLIonButtonsElement; + projection: HTMLElement; + actions: { source: HTMLElement; projection: HTMLElement; previousAriaHidden: string | null }[]; +} + +interface ToolbarSource { + group: HTMLIonButtonsElement; + actions: HTMLElement[]; +} + +export const createVerticalBarsWebProjection = ( + doc: Document, + options: NativeUIShellOptions, + enabled: () => boolean = () => true, +): NativeUIShellHandle => { + const win = doc.defaultView!; + if (options.controls !== undefined && options.controls.toolbar !== true) + return { + getStatus: () => ({ state: 'web', projected: 0, updates: 0 }), + suspend: async () => ({ resume: async () => {} }), + destroy: async () => {}, + }; + let root: HTMLElement | undefined; + let backSource: HTMLIonBackButtonElement | undefined; + let backProjection: HTMLIonBackButtonElement | undefined; + let previousBackAriaHidden: string | null = null; + let toolbarProjections: ToolbarProjection[] = []; + let suspended = 0; + let stopped = false; + let frame = 0; + let updates = 0; + let observingVerticalBars = false; + const verticalBarsPages = createVerticalBarsPageState(); + let sourceObserver: MutationObserver | undefined; + let waiters: (() => void)[] = []; + const listeners = new AbortController(); + const verticalBarsRoot = () => doc.querySelector('ion-app.ios-theme-vertical-bars'); + const projectedSources = () => + [backSource, ...toolbarProjections.flatMap(({ actions }) => actions.map(({ source }) => source))].filter( + (source): source is HTMLElement => !!source, + ); + const isRendered = (element: HTMLElement) => + withoutPrehide(element, () => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return element.isConnected && style.display !== 'none' && style.visibility === 'visible' && rect.width > 0 && rect.height > 0; + }); + const inEligibleToolbar = (element: HTMLElement) => + !!verticalBarsRoot()?.contains(element) && + inFixedToolbar(element) && + !isExcluded(element, verticalBarsEnteringPage(element)) && + !isShellDisabled(element) && + !verticalBarsPages.isDeparted(element) && + !element.closest('ion-menu, ion-modal, ion-popover, .ion-page-hidden'); + const isEligibleBack = (element: HTMLIonBackButtonElement) => + !!verticalBarsRoot()?.contains(element) && + verticalBarsOwned(element) && + isVerticalBarsBackPosition(element) && + !element.closest('ion-buttons.ios-theme-horizontal-only') && + !isExcluded(element, verticalBarsEnteringPage(element)) && + !isShellDisabled(element) && + !verticalBarsPages.isDeparted(element) && + !element.closest('ion-menu, ion-modal, ion-popover') && + unprojected(projectedSources(), () => isRendered(element)); + const pageOrder = (element: Element) => Array.from(doc.querySelectorAll('.ion-page')).indexOf(element.closest('.ion-page')!); + const findBack = () => { + const candidates = Array.from(doc.querySelectorAll(`ion-back-button:not(.${backProjectionClass})`)).filter( + isEligibleBack, + ); + return preferredVerticalBarsBack(candidates, doc); + }; + const findToolbarGroups = () => { + const candidates = Array.from(doc.querySelectorAll(`ion-buttons:not(.${toolbarProjectionClass})`)).flatMap( + (group): ToolbarSource[] => { + const actions = verticalBarsToolbarActions(group).filter((action) => unprojected(projectedSources(), () => isRendered(action))); + if (!actions.length) return []; + if (!isVerticalBarsToolbarGroup(group)) return actions.filter(inEligibleToolbar).map((action) => ({ group, actions: [action] })); + return inEligibleToolbar(group) && unprojected(projectedSources(), () => isRendered(group)) ? [{ group, actions }] : []; + }, + ); + const highestPage = Math.max(...candidates.map(({ group }) => pageOrder(group))); + return candidates.filter(({ group }) => pageOrder(group) === highestPage); + }; + const isCurrentToolbarAction = (source: HTMLElement) => findToolbarGroups().some(({ actions }) => actions.includes(source)); + const restore = () => { + backProjection?.remove(); + backProjection = undefined; + if (backSource) { + backSource.removeAttribute(marker); + if (previousBackAriaHidden === null) backSource.removeAttribute('aria-hidden'); + else backSource.setAttribute('aria-hidden', previousBackAriaHidden); + backSource.dispatchEvent(new CustomEvent('nativeUIShellChange')); + } + backSource = undefined; + previousBackAriaHidden = null; + for (const { projection, actions } of toolbarProjections) { + projection.remove(); + for (const { source, previousAriaHidden } of actions) { + source.removeAttribute(marker); + if (previousAriaHidden === null) source.removeAttribute('aria-hidden'); + else source.setAttribute('aria-hidden', previousAriaHidden); + source.dispatchEvent(new CustomEvent('nativeUIShellChange')); + } + } + toolbarProjections = []; + sourceObserver?.disconnect(); + sourceObserver = undefined; + root?.classList.remove(readyClass); + root = undefined; + }; + const copyAttributes = (target: HTMLElement, original: HTMLElement) => { + for (const name of ['id', 'slot', marker, 'aria-hidden']) target.removeAttribute(name); + for (const attribute of Array.from(target.attributes)) + if (!['class', marker].includes(attribute.name) && !original.hasAttribute(attribute.name)) target.removeAttribute(attribute.name); + for (const attribute of Array.from(original.attributes)) + if (!['id', 'slot', marker, 'aria-hidden'].includes(attribute.name)) target.setAttribute(attribute.name, attribute.value); + target.classList.remove(prehiddenClass); + }; + const syncBack = (target: HTMLIonBackButtonElement, original: HTMLIonBackButtonElement) => { + copyAttributes(target, original); + target.classList.add(backProjectionClass, 'ion-cloned-element'); + target.disabled = original.disabled; + target.defaultHref = original.defaultHref; + target.icon = original.icon; + target.color = original.color; + target.mode = original.mode; + target.text = ''; + }; + const syncAction = (target: HTMLElement, original: HTMLElement) => { + copyAttributes(target, original); + target.classList.add('ios-theme-vertical-bars-toolbar-action', 'ion-cloned-element'); + const label = original.getAttribute('aria-label') ?? original.textContent?.trim(); + if (label) target.setAttribute('aria-label', label); + if ('disabled' in original) (target as HTMLIonButtonElement).disabled = (original as HTMLIonButtonElement).disabled; + if (original.matches('ion-button')) { + const icon = original.querySelector('ion-icon, svg'); + const clonedIcon = icon?.cloneNode(true) as HTMLElement | undefined; + clonedIcon?.setAttribute('slot', 'icon-only'); + target.replaceChildren(...(clonedIcon ? [clonedIcon] : [])); + } + }; + const sameSources = (back: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => + back === backSource && + groups.length === toolbarProjections.length && + groups.every(({ group, actions }, index) => { + const current = toolbarProjections[index]; + return ( + group === current.source && + actions.length === current.actions.length && + actions.every((action, actionIndex) => action === current.actions[actionIndex].source) + ); + }); + const syncExisting = () => { + if (backSource && backProjection) syncBack(backProjection, backSource); + let topOffset = backSource ? toolbarControlSize + toolbarControlGap : 0; + for (const { projection, source, actions } of toolbarProjections) { + if (actions.length === 1 && !projection.matches('ion-buttons')) syncAction(projection, actions[0].source); + else copyAttributes(projection, source); + projection.classList.add(toolbarProjectionClass, 'ion-cloned-element'); + projection.style.setProperty('--ios-theme-vertical-bars-toolbar-offset', `${topOffset}px`); + if (projection.matches('ion-buttons')) actions.forEach(({ source: action, projection: clone }) => syncAction(clone, action)); + topOffset += actions.length * toolbarControlSize + toolbarControlGap; + } + }; + const project = (nextBack: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => { + root = verticalBarsRoot()!; + let topOffset = 0; + if (nextBack) { + backSource = nextBack; + previousBackAriaHidden = nextBack.getAttribute('aria-hidden'); + backProjection = nextBack.cloneNode(false) as HTMLIonBackButtonElement; + syncBack(backProjection, nextBack); + backProjection.addEventListener( + 'click', + (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + if (backSource && backSource === findBack()) activateProjectedElement(backSource); + }, + { capture: true }, + ); + root.append(backProjection); + nextBack.setAttribute(marker, ''); + nextBack.setAttribute('aria-hidden', 'true'); + nextBack.dispatchEvent(new CustomEvent('nativeUIShellChange')); + topOffset = toolbarControlSize + toolbarControlGap; + } + for (const { group, actions: sources } of groups) { + const projection = (sources.length === 1 ? sources[0] : group).cloneNode(false) as HTMLElement; + if (sources.length === 1) syncAction(projection, sources[0]); + else copyAttributes(projection, group); + projection.classList.add(toolbarProjectionClass, 'ion-cloned-element'); + projection.style.setProperty('--ios-theme-vertical-bars-toolbar-offset', `${topOffset}px`); + const actions = sources.map((source) => { + const clone = sources.length === 1 ? projection : (source.cloneNode(false) as HTMLElement); + if (sources.length > 1) syncAction(clone, source); + clone.addEventListener( + 'click', + (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + if (isCurrentToolbarAction(source)) activateProjectedElement(source); + }, + { capture: true }, + ); + if (sources.length > 1) projection.append(clone); + const previousAriaHidden = source.getAttribute('aria-hidden'); + source.setAttribute(marker, ''); + source.setAttribute('aria-hidden', 'true'); + source.dispatchEvent(new CustomEvent('nativeUIShellChange')); + return { source, projection: clone, previousAriaHidden }; + }); + root.append(projection); + toolbarProjections.push({ source: group, projection, actions }); + topOffset += actions.length * toolbarControlSize + toolbarControlGap; + } + if (nextBack || toolbarProjections.length) root.classList.add(readyClass); + sourceObserver = new MutationObserver((records) => { + if ( + records.some( + (record) => + record.type !== 'attributes' || (![marker, 'aria-hidden'].includes(record.attributeName ?? '') && !prehideOnlyMutation(record)), + ) + ) + schedule(); + }); + if (nextBack?.shadowRoot) + sourceObserver.observe(nextBack.shadowRoot, { subtree: true, childList: true, attributes: true, attributeOldValue: true }); + for (const { group } of groups) + sourceObserver.observe(group, { subtree: true, childList: true, attributes: true, attributeOldValue: true }); + }; + const performUpdate = () => { + frame = 0; + const currentRoot = verticalBarsRoot(); + if (observingVerticalBars !== !!currentRoot) { + observingVerticalBars = !!currentRoot; + observer.disconnect(); + observer.observe(doc.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeOldValue: true, + attributeFilter: observingVerticalBars ? undefined : ['class'], + }); + } + if (stopped || suspended || !currentRoot || !enabled()) return restore(); + const nextBack = findBack(); + const groups = findToolbarGroups(); + if (!nextBack && !groups.length) return restore(); + if (sameSources(nextBack, groups)) return syncExisting(); + restore(); + project(nextBack, groups); + updates++; + }; + const update = () => { + try { + performUpdate(); + } catch { + restore(); + } finally { + const pending = waiters; + waiters = []; + pending.forEach((resolve) => resolve()); + } + }; + const schedule = (): Promise => { + if (stopped) return Promise.resolve(); + const done = new Promise((resolve) => waiters.push(resolve)); + if (!frame) frame = win.requestAnimationFrame(update); + return done; + }; + const observer = new MutationObserver((records) => { + const insideProjection = (target: Node) => { + const element = target instanceof Element ? target : target.parentNode instanceof Element ? target.parentNode : undefined; + const rootNode = target.getRootNode(); + const shadowHost = rootNode instanceof ShadowRoot ? rootNode.host : undefined; + return !![element, shadowHost].some((candidate) => candidate?.closest(`.${backProjectionClass}, .${toolbarProjectionClass}`)); + }; + const verticalBarsChanged = records.some( + (record) => + (record.type === 'attributes' && (record.target as Element).matches('ion-app')) || + (record.type === 'childList' && + Array.from(record.addedNodes).some( + (node) => + node instanceof Element && + (node.matches('ion-app.ios-theme-vertical-bars') || !!node.querySelector('ion-app.ios-theme-vertical-bars')), + )), + ); + if ( + (observingVerticalBars && + records.some((record) => record.attributeName !== marker && !prehideOnlyMutation(record) && !insideProjection(record.target))) || + (!observingVerticalBars && verticalBarsChanged && records.some((record) => !prehideOnlyMutation(record))) + ) + schedule(); + }); + observer.observe(doc.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeOldValue: true, + attributeFilter: ['class'], + }); + const pageLifecycle = (event: Event) => { + const page = event.target; + if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return; + if (event.type === VERTICAL_BARS_TRANSITION_CANCELED) { + const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering; + verticalBarsPages.cancel(entering, page); + } else verticalBarsPages.lifecycle(event); + if (verticalBarsRoot()) schedule(); + }; + for (const name of ['ionViewWillEnter', 'ionViewWillLeave', 'ionViewDidEnter', 'ionViewDidLeave']) + doc.addEventListener(name, pageLifecycle, { capture: true, signal: listeners.signal }); + doc.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, pageLifecycle, { capture: true, signal: listeners.signal }); + for (const name of ['ionModalWillPresent', 'ionModalDidDismiss']) + doc.addEventListener(name, schedule, { capture: true, signal: listeners.signal }); + win.addEventListener('nativeUIShellRefresh', schedule, { signal: listeners.signal }); + if (options.controls === undefined || options.controls.toolbar === true) schedule(); + + return { + getStatus: (): NativeUIShellStatus => ({ + state: 'web', + projected: Number(!!backSource) + toolbarProjections.reduce((count, group) => count + group.actions.length, 0), + updates, + }), + async suspend() { + suspended++; + restore(); + let resumed = false; + return { + async resume() { + if (resumed) return; + resumed = true; + suspended = Math.max(0, suspended - 1); + if (!suspended) await schedule(); + }, + }; + }, + async destroy() { + if (stopped) return; + stopped = true; + win.cancelAnimationFrame(frame); + frame = 0; + waiters.forEach((resolve) => resolve()); + waiters = []; + observer.disconnect(); + sourceObserver?.disconnect(); + listeners.abort(); + restore(); + }, + }; +}; diff --git a/src/styles/components/ion-list.scss b/src/styles/components/ion-list.scss index bde9f050..5a593f64 100644 --- a/src/styles/components/ion-list.scss +++ b/src/styles/components/ion-list.scss @@ -37,10 +37,6 @@ ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) { padding-right: calc(var(--ion-safe-area-right, 0px) + 18px); } - &:dir(rtl)::part(native) { - padding-left: calc(var(--ion-safe-area-left, 0px) + 18px); - } - --min-height: 52px; font-size: 1rem; line-height: 1.29rem; diff --git a/src/styles/components/ion-menu.scss b/src/styles/components/ion-menu.scss index 3d437eb9..187c9982 100644 --- a/src/styles/components/ion-menu.scss +++ b/src/styles/components/ion-menu.scss @@ -4,7 +4,7 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled) { ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible:has(ion-menu.menu-pane-visible) { .split-pane-main { - ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { + ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(.ios-theme-vertical-bars *)) { width: calc( 100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 60px - 12px - var(--ios-theme-menu-width, var(--ios26-menu-width)) @@ -43,7 +43,7 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible: ion-footer ion-toolbar { --padding-start: var(--ios-theme-menu-width, var(--ios26-menu-width)); } - ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { + ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(.ios-theme-vertical-bars *)) { &[slot='bottom'] { inset-inline-start: calc(16px + var(--ion-safe-area-left, 0px) + var(--ios-theme-menu-width, var(--ios26-menu-width))); &:dir(rtl) { diff --git a/src/styles/ionic-theme-ios27.scss b/src/styles/ionic-theme-ios27.scss index 48dc0af4..65b9351c 100644 --- a/src/styles/ionic-theme-ios27.scss +++ b/src/styles/ionic-theme-ios27.scss @@ -21,3 +21,4 @@ @use 'components/ion-toast'; @use 'components/ion-toggle'; @use 'components/ion-toolbar'; +@use 'vertical-bars'; diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss new file mode 100644 index 00000000..963a083f --- /dev/null +++ b/src/styles/vertical-bars.scss @@ -0,0 +1,342 @@ +@use 'utils/api'; +@use 'utils/glass'; + +// iPhone Duo simulation: the system reserves 80pt at the physical right edge. +// This entry point does not load the iOS 27 theme or change ordinary Ionic UI. +// A split menu is independent of the vertical control area. Its width is +// selected by the application from the device posture, not the viewport width. +@property --ios-theme-split-pane-width { + syntax: ''; + inherits: false; + initial-value: 320px; +} + +ion-split-pane.ios-theme-split-pane-half-open { + --ios-theme-split-pane-width: 50vw; +} + +ion-app.ios-theme-vertical-bars { + --ios-theme-vertical-bars-safe-area-left-resolved: var(--ios-theme-vertical-bars-safe-area-left, 0px); + --ios-theme-vertical-bars-safe-area-right-resolved: var( + --ios-theme-vertical-bars-safe-area-right, + var(--ios-theme-vertical-bars-native-inset, max(80px, env(safe-area-inset-right, 0px))) + ); +} + +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left { + --ios-theme-vertical-bars-safe-area-left-resolved: var( + --ios-theme-vertical-bars-safe-area-left, + var(--ios-theme-vertical-bars-native-inset, max(80px, env(safe-area-inset-left, 0px))) + ); + --ios-theme-vertical-bars-safe-area-right-resolved: var(--ios-theme-vertical-bars-safe-area-right, 0px); +} + +// Supply material defaults only to Web projections, never to ordinary controls. +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready + > :is(ion-menu-button, ion-button).ios-theme-vertical-bars-toolbar-projection { + --ios26-glass-background-rgb: var(--ion-background-color-rgb, 255, 255, 255); + --ios26-glass-border-color-rgb: var(--ion-background-color-rgb, 255, 255, 255); + --ios26-glass-box-shadow-color-rgb: var(--ion-text-color-rgb, 0, 0, 0); +} + +// The transition shade belongs to the Web page, never to the control area. +ion-app.ios-theme-vertical-bars .ios-transition-shade { + clip-path: inset(0 var(--ios-theme-vertical-bars-safe-area-right-resolved) 0 var(--ios-theme-vertical-bars-safe-area-left-resolved)); +} + +// Keep page backgrounds and the router full width; move only their foregrounds. +ion-app.ios-theme-vertical-bars + ion-content:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + --ion-safe-area-left: 0px; + --ion-safe-area-right: 0px; + + &::part(scroll) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + } + + &:dir(rtl)::part(scroll) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + } +} + +ion-app.ios-theme-vertical-bars + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + --ion-safe-area-left: 0px; + --ion-safe-area-right: 0px; + + // Collapse headers already sit inside the padded ion-content scroll area. + &:not(:where(ion-header.header-collapse-condense *, ion-footer.footer-collapse-fade *)) { + &::part(container) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + } + + &:dir(rtl)::part(container) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + } + } +} + +ion-app.ios-theme-vertical-bars + ion-fab:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + --ion-safe-area-left: 0px; + --ion-safe-area-right: 0px; + --ios-theme-vertical-bars-fab-edge-gap: 16px; + --ios-theme-vertical-bars-fab-start-offset: var(--ios-theme-menu-width, var(--ios26-menu-width, 0px)); + + &.fab-horizontal-start { + inset-inline-start: calc( + var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-fab-start-offset) + + var(--ios-theme-vertical-bars-safe-area-left-resolved) + ); + } + &.fab-horizontal-end { + inset-inline-end: calc(var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + } + &:dir(rtl).fab-horizontal-start { + inset-inline-start: calc( + var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-fab-start-offset) + + var(--ios-theme-vertical-bars-safe-area-right-resolved) + ); + } + &:dir(rtl).fab-horizontal-end { + inset-inline-end: calc(var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + } + &:has(.fab-button-small) { + --ios-theme-vertical-bars-fab-edge-gap: 10px; + } +} + +// Menus retain their own safe-area handling and full-width animation host. +ion-app.ios-theme-vertical-bars { + ion-menu:is(.ios, .md).menu-side-end:not(:dir(rtl)):not(.ios-theme-disabled, .ios26-disabled), + ion-menu:is(.ios, .md).menu-side-start:dir(rtl):not(.ios-theme-disabled, .ios26-disabled) { + --ion-safe-area-right: 0px; + &::part(container) { + margin-right: var(--ios-theme-vertical-bars-safe-area-right-resolved); + } + } + ion-menu:is(.ios, .md).menu-side-start:not(:dir(rtl)):not(.ios-theme-disabled, .ios26-disabled), + ion-menu:is(.ios, .md).menu-side-end:dir(rtl):not(.ios-theme-disabled, .ios26-disabled) { + --ion-safe-area-left: 0px; + &::part(container) { + margin-left: var(--ios-theme-vertical-bars-safe-area-left-resolved); + } + } +} + +ion-app.ios-theme-vertical-bars + ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) + > ion-tab-bar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled) { + --ios-theme-side-tab-bar-width: 50px; + --ios-theme-side-tab-bar-gap: 8px; + --ios-theme-side-safe-area: var(--ios-theme-vertical-bars-safe-area-right-resolved); + contain: layout style; + position: absolute; + z-index: 2; + display: flex; + flex-direction: column; + width: 46px; + min-width: 46px; + max-width: 46px; + height: fit-content; + min-height: 0; + padding: 2px; + border-radius: 25px; + left: auto; + right: max(4px, calc((var(--ios-theme-side-safe-area) - var(--ios-theme-side-tab-bar-width)) / 2)); + margin: 0; + top: auto; + bottom: calc(var(--ion-safe-area-bottom, 0px) + var(--ios-theme-side-tab-bar-gap)); + + > ion-tab-button:not(.ion-cloned-element) { + display: block; + flex: none; + width: 46px; + min-width: 46px; + height: 52px; + min-height: 52px; + border-radius: 23px; + &::part(native) { + justify-content: center; + } + ion-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + ion-icon { + margin: 0; + font-size: 28px; + } + } + > ion-tab-button:not(.ion-cloned-element) ~ ion-tab-button:not(.ion-cloned-element) { + margin-block-start: 0; + margin-inline-start: 0; + } + &:has(> ion-tab-button:is(.ion-activated, :active)) > ion-tab-button:not(.ion-cloned-element) { + &::part(native) { + flex-direction: column; + padding-block: 4px; + } + ion-label { + position: static; + width: auto; + height: auto; + max-width: 44px; + padding: 0; + margin: 1px 0 0; + overflow: hidden; + clip: auto; + font-size: 0.5rem; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; + } + ion-icon { + margin: 0; + font-size: 22px; + } + } +} + +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left + ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) + > ion-tab-bar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled) { + --ios-theme-side-safe-area: var(--ios-theme-vertical-bars-safe-area-left-resolved); + right: auto; + left: max(4px, calc((var(--ios-theme-side-safe-area) - var(--ios-theme-side-tab-bar-width)) / 2)); +} + +.ios-theme-native-ui-shell-prehidden { + position: absolute !important; + visibility: hidden !important; +} + +// Hide an entering back source until JS decides whether it belongs in the rail. +html.ios-theme-native-ui-shell-prehide + ion-app.ios-theme-vertical-bars + ion-back-button:not( + [icon], + [color], + .ios-theme-vertical-bars-back-web-owned, + .ionic-theme-disabled, + .ios-theme-disabled, + .ios26-disabled, + .ios-theme-vertical-bars-back-button-projection + ):not( + :where( + ion-menu *, + ion-modal *, + ion-popover *, + ion-header[collapse] *, + ion-footer[collapse] *, + ion-buttons.ios-theme-horizontal-only *, + .ios-theme-shell-disabled *, + .ionic-theme-disabled *, + .ios-theme-disabled *, + .ios26-disabled * + ) + ) { + position: absolute !important; + visibility: hidden !important; +} + +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-back-button:is(.ios, .md)[data-native-ui-shell]:not( + .ios-theme-disabled, + .ios26-disabled, + .ios-theme-vertical-bars-back-button-projection + ) { + display: none; + } + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-buttons:is(.ios, .md) + > :is(ion-button, ion-menu-button):is(.ios, .md)[data-native-ui-shell]:not( + .ios-theme-disabled, + .ios26-disabled, + .ios-theme-vertical-bars-toolbar-action + ) { + display: none; + } + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-buttons:is(.ios, .md):has(> :is(ion-back-button, ion-button, ion-menu-button)[data-native-ui-shell]):not( + :has(> :is(ion-back-button, ion-button, ion-menu-button):not([data-native-ui-shell])) + ) { + display: none; + } + > ion-back-button.ios-theme-vertical-bars-back-button-projection { + position: fixed; + z-index: 1001; + top: var(--ios-theme-vertical-bars-toolbar-top, 220px); + right: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-right-resolved) - 46px) / 2)); + display: block; + width: 46px; + height: 46px; + margin: 0; + } + > :is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { + position: fixed; + z-index: 1001; + top: calc(var(--ios-theme-vertical-bars-toolbar-top, 220px) + var(--ios-theme-vertical-bars-toolbar-offset, 0px)); + right: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-right-resolved) - 46px) / 2)); + width: 46px; + min-width: 46px; + height: 46px; + min-height: 46px; + margin: 0; + :is(ion-icon, svg) { + width: 22px; + height: 22px; + margin: 0; + font-size: 22px; + } + } + > ion-menu-button:is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { + @include api.glass-control-background; + border-radius: 50%; + } + > ion-button:is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { + --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72); + --border-radius: 50%; + @include glass.light-shadow('--box-shadow'); + &::part(native) { + @include api.glass-control-background($include-background: false, $shadow: var(--box-shadow)); + } + } + > ion-buttons:is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { + display: flex; + flex-direction: column; + height: auto; + min-height: 0; + > :is(ion-button, ion-menu-button):is(.ios, .md).ios-theme-vertical-bars-toolbar-action { + display: block; + width: 44px; + min-width: 44px; + height: 46px; + min-height: 46px; + margin: 0 1px; + &::part(native) { + margin-inline: auto; + } + } + } +} + +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left { + > ion-back-button.ios-theme-vertical-bars-back-button-projection, + > :is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { + right: auto; + left: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-left-resolved) - 46px) / 2)); + } +} diff --git a/src/transition/index.ts b/src/transition/index.ts index 607ebf40..a092cd30 100644 --- a/src/transition/index.ts +++ b/src/transition/index.ts @@ -170,7 +170,7 @@ const waitForReady = async (opts: TransitionOptions, defaultDeep: boolean) => { await notifyViewReady(opts.viewIsReady, opts.enteringEl); }; -const notifyViewReady = async (viewIsReady: undefined | ((enteringEl: HTMLElement) => Promise), enteringEl: HTMLElement) => { +const notifyViewReady = async (viewIsReady: undefined | ((enteringEl: HTMLElement) => Promise), enteringEl: HTMLElement) => { if (viewIsReady) { await viewIsReady(enteringEl); } @@ -180,7 +180,7 @@ const playTransition = (trans: Animation, opts: TransitionOptions): Promise((resolve) => { - trans.onFinish((currentStep: any) => resolve(currentStep === 1)); + trans.onFinish((currentStep) => resolve(currentStep === 1)); }); // cool, let's do this, start the transition @@ -236,8 +236,13 @@ export const waitForMount = (): Promise => { return new Promise((resolve) => raf(() => raf(() => resolve()))); }; -export const deepReady = async (el: any | undefined): Promise => { - const element = el as any; +type LazyLoadedElement = Element & { + componentOnReady?: () => Promise; + __registerHost?: unknown; +}; + +export const deepReady = async (el: Element | undefined): Promise => { + const element = el as LazyLoadedElement | undefined; if (element) { if (element.componentOnReady != null) { // eslint-disable-next-line custom-rules/no-component-on-ready-method @@ -259,7 +264,7 @@ export const deepReady = async (el: any | undefined): Promise => { return; } - await Promise.all(Array.from(element.children).map(deepReady)); + await Promise.all(Array.from(element.children).map((child) => deepReady(child))); } }; @@ -358,7 +363,7 @@ const getIosIonHeader = (opts: TransitionOptions): HTMLElement | null => { export interface TransitionOptions extends NavOptions { progressCallback?: (ani: Animation | undefined) => void; - baseEl: any; + baseEl: HTMLElement; enteringEl: HTMLElement; leavingEl: HTMLElement | undefined; } diff --git a/src/transition/ios.transition.ts b/src/transition/ios.transition.ts index 44e9f7e9..652c86de 100644 --- a/src/transition/ios.transition.ts +++ b/src/transition/ios.transition.ts @@ -14,6 +14,7 @@ const transitionConfig = { offLeftPercent: 30, getIonPageElement, connectNativeUIShellTransition, + shouldAnimateFixedBackButton: (navEl: HTMLElement) => !navEl.closest('ion-app.ios-theme-vertical-bars'), radius: 0, }; diff --git a/src/utils.ts b/src/utils.ts index 126f620c..b9e8a71d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,8 @@ import { AnimationPosition } from './sheets-of-glass/interfaces'; import { IonicConfig } from '@ionic/core'; -declare const __zone_symbol__requestAnimationFrame: any; -declare const requestAnimationFrame: any; +declare const __zone_symbol__requestAnimationFrame: ((callback: FrameRequestCallback) => number) | undefined; +declare const requestAnimationFrame: ((callback: FrameRequestCallback) => number) | undefined; export const getElementRoot = (el: HTMLElement, fallback: HTMLElement = el) => { return el.shadowRoot || fallback; @@ -58,15 +58,15 @@ export const changeSelectedElement = ( }; export class Config { - private m = new Map(); + private m = new Map(); reset(configObj: IonicConfig) { - this.m = new Map(Object.entries(configObj) as any); + this.m = new Map(Object.entries(configObj) as [keyof IonicConfig, unknown][]); } - get(key: keyof IonicConfig, fallback?: any): any { + get(key: keyof IonicConfig, fallback?: T): T { const value = this.m.get(key); - return value !== undefined ? value : fallback; + return value !== undefined ? (value as T) : (fallback as T); } getBoolean(key: keyof IonicConfig, fallback = false): boolean { @@ -81,11 +81,11 @@ export class Config { } getNumber(key: keyof IonicConfig, fallback?: number): number { - const val = parseFloat(this.m.get(key)); + const val = parseFloat(String(this.m.get(key))); return isNaN(val) ? (fallback !== undefined ? fallback : NaN) : val; } - set(key: keyof IonicConfig, value: any) { + set(key: keyof IonicConfig, value: unknown) { this.m.set(key, value); } } diff --git a/src/vertical-bars.ts b/src/vertical-bars.ts new file mode 100644 index 00000000..7aad866c --- /dev/null +++ b/src/vertical-bars.ts @@ -0,0 +1,3 @@ +export { enableVerticalControlArea, setVerticalControlAreaPlacement, IonicNativeUIShell } from './native'; +export { HingeStatus } from './native'; +export type { DeviceLayout, NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native';