diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index a85b2617..ae42a4b9 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -9,6 +9,7 @@ import type { ShellMockCore, TestAppElement } from './native-shell-mock'; const importer = new NodePackageImporter(resolve(__dirname, '../../')); interface ShellMock extends ShellMockCore { + releaseOverlay?: () => void; delay: number; hang: boolean; rejectInactiveSearch: boolean; @@ -20,9 +21,19 @@ interface ShellMock extends ShellMockCore { retirementDetails: { path: string; tabs: string }[]; } -const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'trailing' | null | 'unreported' = 'trailing') => { - const script = ([fail, nativeEdge]: readonly [boolean, 'leading' | 'trailing' | null | 'unreported']) => { +const mockNative = async ( + page: Page, + fail = false, + nativeEdge: 'leading' | 'trailing' | null | 'unreported' = 'trailing', + deferOverlayPreparation = false, +) => { + const script = ([fail, nativeEdge, deferOverlayPreparation]: readonly [ + boolean, + 'leading' | 'trailing' | null | 'unreported', + boolean, + ]) => { const mock = { + releaseOverlay: undefined as (() => void) | undefined, updates: [] as ShellSnapshot[], sequence: 0, delay: 0, @@ -46,6 +57,11 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra notifyListeners(eventName: string, data: unknown) { for (const listener of this.listeners[eventName] ?? []) listener(data as never); }, + async prepareOverlay() { + throw new Error('Native overlay unavailable'); + }, + async closeOverlay() {}, + async stopOverlays() {}, async configure(options: { verticalBarsOnly?: boolean }) { this.configuredWith = options; return { supported: true, verticalBars: nativeEdge !== null }; @@ -58,8 +74,8 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra if (this.hang) await new Promise(() => {}); return this.rejections(options); }, - async clear(options: { revision: number }) { - this.updates.push({ revision: options.revision, viewportWidth: 0, controls: [] }); + async clear(options: { revision: number; overlayId?: string }) { + this.updates.push({ ...options, viewportWidth: 0, controls: [] }); return this.rejections(options); }, async rejections(options: { revision?: number; controls?: ShellControl[] }) { @@ -82,6 +98,14 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra }, }; + if (deferOverlayPreparation) + Object.assign(mock, { + prepareOverlay: () => + new Promise((_, reject) => { + mock.releaseOverlay = () => reject(new Error('Native overlay unavailable')); + }), + }); + const foldable = { async getBarPlacement() { if (nativeEdge === 'unreported') return new Promise(() => {}); @@ -110,12 +134,12 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra }, }); }; - await page.addInitScript(script, [fail, nativeEdge] as const); + await page.addInitScript(script, [fail, nativeEdge, deferOverlayPreparation] as const); }; -const activate = (page: Page, label: string, duplicate = false) => +const activate = (page: Page, label: string, duplicate = false, overlayId?: string) => page.evaluate( - ({ label, duplicate }) => { + ({ label, duplicate, overlayId }) => { 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)), @@ -123,11 +147,16 @@ const activate = (page: Page, label: string, duplicate = false) => const item = snapshot.controls .flatMap((control) => control.items) .find((item) => item.label === label || item.accessibilityLabel === label)!; - const event = { id: item.id, revision: snapshot.revision, sequence: ++state.sequence }; + const event = { + id: item.id, + revision: snapshot.revision, + sequence: overlayId ? state.sequence + 1000 : ++state.sequence, + overlayId, + }; state.notifyListeners('activate', event); if (duplicate) state.notifyListeners('activate', event); }, - { label, duplicate }, + { label, duplicate, overlayId }, ); test('FAB keeps a complete native batch across staggered lists and measures each button', async ({ page }) => { @@ -159,7 +188,7 @@ test('FAB keeps a complete native batch across staggered lists and measures each await expect(fab).toHaveAttribute('data-native-ui-shell', ''); }); -test('FAB activation stays with Ionic and rejects hidden, disabled and duplicate actions', async ({ page }) => { +test('FAB activation stays with Ionic and rejects other surfaces, hidden, disabled and duplicate actions', async ({ page }) => { await mockNative(page); await page.goto('/main/index/floating-action-button'); const fab = page.locator('ion-fab[horizontal=center]'); @@ -169,6 +198,10 @@ test('FAB activation stays with Ionic and rejects hidden, disabled and duplicate app.fabClicks = 0; element.querySelector('ion-fab-list ion-fab-button')!.addEventListener('click', () => (app.fabClicks = (app.fabClicks ?? 0) + 1)); }); + // A relayed overlay can reuse IDs/revisions without activating the covered page + // or advancing its sequence guard. + await activate(page, 'Center FAB actions', false, 'ios-theme-overlay-1'); + expect(await fab.evaluate((element: HTMLIonFabElement) => element.activated)).toBeFalsy(); await activate(page, 'Up action'); expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).fabClicks)).toBe(0); await activate(page, 'Center FAB actions', true); @@ -626,7 +659,7 @@ test('native verticalBars toolbar returns with Index after a pushed page', async 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; + 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'), @@ -3043,6 +3076,32 @@ test('verticalBars return to native projection when the requested edge matches a await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); +test('native modal preparation retains the covered projection and releases it on fallback', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page, false, 'trailing', true); + await page.goto('/main/index/modal'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const back = page.locator('app-modal ion-back-button'); + await expect(back).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + Capacitor.registerPlugin('IonicNativeUIShell').updates.length = 0; + }); + await page.getByText('present:card', { exact: true }).click(); + await expect.poll(() => page.evaluate(() => !!Capacitor.registerPlugin('IonicNativeUIShell').releaseOverlay)).toBe(true); + await expect(back).toHaveAttribute('data-native-ui-shell', ''); + expect( + await page.evaluate(() => + Capacitor.registerPlugin('IonicNativeUIShell').updates.some((snapshot) => snapshot.controls.length === 0), + ), + ).toBe(false); + await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').releaseOverlay!()); + await expect(page.locator('ion-modal ion-toolbar ion-button').first()).toHaveAttribute('data-native-ui-shell', ''); + for (const group of await page.locator('ion-modal ion-toolbar ion-buttons').all()) await expect(group).toHaveCSS('display', 'none'); + await activate(page, 'Close'); + await expect(page.locator('ion-modal')).toHaveCount(0); + await expect(back).toHaveAttribute('data-native-ui-shell', ''); +}); + for (const type of ['normal', 'card', 'sheet']) { test(`verticalBars native controls follow the foreground ${type} modal`, async ({ page }) => { await page.setViewportSize({ width: 700, height: 900 }); @@ -3353,3 +3412,62 @@ test('tab visibility ignores query parameters and fragments', async ({ page }) = await page.goto('/main/index?verticalBarsOnly&buttonDefaultFill=solid#comparison'); await expect(page.locator('ion-tab-bar')).not.toHaveClass(/tab-bar-hidden/); }); + +for (const kind of ['popover', 'alert'] as const) { + test(`native ${kind} relay preserves live handlers after blank navigation and closes its window`, async ({ page }) => { + await mockNative(page); + await page.goto(`/main/index/${kind}`); + await page.evaluate(() => { + Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), { + async prepareOverlay() {}, + async snapshotOverlay() {}, + async presentOverlay() {}, + async revealOverlay() {}, + async dismissOverlay() {}, + }); + }); + for (let attempt = 0; attempt < 2; attempt++) { + const opened = page.waitForEvent('popup'); + await page.getByRole('button', { name: kind === 'popover' ? 'Compact controller popover' : 'Input alert', exact: true }).click(); + const relay = await opened; + if (kind === 'popover') { + await relay.getByRole('button', { name: 'Increment', exact: true }).click(); + await expect(relay.getByText('Count: 1', { exact: true })).toBeVisible(); + await relay.evaluate(() => (document.querySelector('ion-popover') as HTMLIonPopoverElement).dismiss()); + } else { + await relay.getByPlaceholder('Your name').fill('Relay'); + await relay.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved input: Relay', { exact: true })).toBeVisible(); + } + await expect.poll(() => relay.isClosed()).toBe(true); + await expect(page.locator(`ion-${kind}:not(.overlay-hidden)`)).toHaveCount(0); + } + }); +} + +test('opening an Ionic menu restores a relayed modal to the source WebView', async ({ page }) => { + // Collapse the demo's split pane so its menu presents as an overlay. + await page.setViewportSize({ width: 400, height: 800 }); + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.evaluate(() => { + Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), { + async prepareOverlay() {}, + async snapshotOverlay() {}, + async presentOverlay() {}, + async revealOverlay() {}, + async dismissOverlay() {}, + }); + }); + const opened = page.waitForEvent('popup'); + await page.getByRole('button', { name: 'Open modal', exact: true }).click(); + const relay = await opened; + await expect(relay.getByRole('button', { name: 'Close modal', exact: true })).toBeVisible(); + await page.evaluate(() => document.querySelector('ion-menu')!.open()); + await expect.poll(() => relay.isClosed()).toBe(true); + const modal = page.locator('ion-modal:not(.overlay-hidden)'); + await expect(modal).toHaveCount(1); + await page.evaluate(() => document.querySelector('ion-menu')!.close()); + await modal.getByRole('button', { name: 'Close modal', exact: true }).click(); + await expect(modal).toHaveCount(0); +}); diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index f9c14590..ba8150ef 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -208,13 +208,15 @@ final class NativeUIShellTests: XCTestCase { app.webViews.buttons["Parent hidden: true"].tap() XCTAssertTrue(native.waitForExistence(timeout: 5)) + // A disabled ion-buttons group releases the group projection but its + // direct children are still projected individually. app.webViews.buttons["Theme disabled: false"].tap() - XCTAssertTrue(native.waitForNonExistence(timeout: 5)) + XCTAssertTrue(native.waitForExistence(timeout: 5)) app.webViews.buttons["Theme disabled: true"].tap() XCTAssertTrue(native.waitForExistence(timeout: 5)) app.webViews.buttons["fill: clear"].tap() - XCTAssertTrue(native.waitForNonExistence(timeout: 5)) + XCTAssertTrue(native.waitForExistence(timeout: 5)) app.webViews.buttons["fill: default"].tap() XCTAssertTrue(native.waitForExistence(timeout: 5)) @@ -231,8 +233,19 @@ final class NativeUIShellTests: XCTestCase { app.webViews.buttons["Select One programmatically"].tap() app.webViews.buttons["Open modal"].tap() - XCTAssertTrue(segment.waitForNonExistence(timeout: 5), app.debugDescription) - app.webViews.buttons["Close modal"].tap() + let modalClose = app.webViews.buttons["Close modal"] + XCTAssertTrue(modalClose.waitForExistence(timeout: 5), app.debugDescription) + if modalClose.isHittable { + // Without the native relay, a presented overlay leaves covered controls unprojected. + XCTAssertTrue(segment.waitForNonExistence(timeout: 5), app.debugDescription) + modalClose.tap() + } else { + // A covering overlay retires page projections like the Web path; the + // relayed content lives in a hosted WebView XCTest cannot hit-test. + expectation(for: NSPredicate(format: "hittable == false"), evaluatedWith: segment) + waitForExpectations(timeout: 5) + modalClose.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } XCTAssertTrue(segment.waitForExistence(timeout: 5), app.debugDescription) app.swipeUp() @@ -504,22 +517,393 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(main.identifier.hasPrefix("shell-")) } + func testToolbarAnchoredPopover() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "popover") + // The toolbar's ellipsis button is a native projection that anchors the popover. + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + for i in 0..<5 { + usleep(150_000) + capture("anchored-popover-\(i)") + } + let text = app.webViews.staticTexts["Toolbar popover"] + XCTAssertTrue(text.waitForExistence(timeout: 5), app.debugDescription) + capture("anchored-popover-final") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).tap() + XCTAssertTrue(text.waitForNonExistence(timeout: 5), app.debugDescription) + } + + func testModalHidesPageProjections() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15)) + openPage(app, name: "native-ui-shell") + let save = nativeButton(app, label: "Save") + XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) + app.webViews.buttons["Open modal"].tap() + // Page projections must retire while the relayed modal covers the page. + XCTAssertTrue(save.waitForNonExistence(timeout: 5), app.debugDescription) + capture("modal-covering-projections-hidden") + let close = app.webViews.buttons["Close modal"] + XCTAssertTrue(close.waitForExistence(timeout: 5), app.debugDescription) + close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + // Projections return after the overlay is gone. + XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) + capture("modal-dismissed-projections-back") + } + + func testModalKindsVisuals() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15)) + openPage(app, name: "modal") + for kind in ["card", "sheet", "normal"] { + let trigger = app.webViews.buttons["present:" + kind] + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + Thread.sleep(forTimeInterval: 1.5) + capture("modal-kind-\(kind)") + let shells = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'shell-'")) + XCTAssertEqual(shells.count, 0, "page projections must retire while a covering \(kind) modal is open") + let close = app.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Done"])).firstMatch + let webClose = app.webViews.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Done"])).firstMatch + if close.exists { close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } + else if webClose.exists { webClose.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } + else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.2)).tap() } + Thread.sleep(forTimeInterval: 1.0) + capture("modal-kind-\(kind)-closed") + } + } + + func testPopoverTransitionsVisuals() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15)) + openPage(app, name: "popover") + // Rect-anchored (non-projected) popover: should open with UIKit's own popover animation. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.46)).tap() + for i in 0..<3 { + usleep(120_000) + capture("compact-popover-\(i)") + } + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.8) + capture("compact-popover-dismissed") + // Anchored morph from the projected toolbar ellipsis. + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + for i in 0..<3 { + usleep(120_000) + capture("morph-popover-\(i)") + } + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + for i in 0..<3 { + usleep(120_000) + capture("morph-popover-closing-\(i)") + } + capture("morph-popover-dismissed") + } + + func testPopoverTeardownLeavesNoStaleProjection() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + for round in 0..<2 { + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + XCTAssertTrue(app.webViews.staticTexts["Toolbar popover"].waitForExistence(timeout: 10), app.debugDescription) + // Dismiss the morph and navigate away immediately; tearing down the + // page must release the retained page projections. If the release + // is ever skipped, later pages freeze on the stale snapshot and the + // popover page's ellipsis keeps drawing over them. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + let back = app.buttons["back"].firstMatch + if !back.waitForExistence(timeout: 5) { continue } + back.tap() + waitForWebTransition() + openPage(app, name: "modal") + capture("stalecheck-modal-page-\(round)") + let stale = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'shell-' AND label == 'Open popover'")) + XCTAssertEqual(stale.count, 0, "popover page projections must not survive navigation") + // Present and dismiss a modal; covering overlays must still retire + // and restore the current page projections, not the stale ones. + let present = app.webViews.buttons["present:normal"] + XCTAssertTrue(present.waitForExistence(timeout: 10), app.debugDescription) + present.tap() + Thread.sleep(forTimeInterval: 1.5) + capture("stalecheck-modal-open-\(round)") + let close = app.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Done"])).firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 10), app.debugDescription) + close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + Thread.sleep(forTimeInterval: 1.0) + capture("stalecheck-modal-closed-\(round)") + XCTAssertEqual(stale.count, 0, "stale popover projections must not reappear after a modal") + XCTAssertTrue(app.buttons["back"].firstMatch.waitForExistence(timeout: 10), app.debugDescription) + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + } + } + + func testFirstLaunchPopoverChromeShadow() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "popover") + // Tap the controller-section close button as early as possible after + // launch. The first popover presentation used to keep an unresolved + // chrome — flat surface, no shadow — until a second presentation. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.90, dy: 0.611)).tap() + let popoverText = app.webViews.staticTexts["Hello World!"].firstMatch + XCTAssertTrue(popoverText.waitForExistence(timeout: 10), app.debugDescription) + capture("firstlaunch-popover-chrome") + XCTAssertTrue(popoverChromeHasShadow(XCUIScreen.main.screenshot().image, + below: popoverText.frame, + pointsWidth: app.frame.width), + "first-launch popover chrome rendered without a shadow") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.8) + } + + /// The popover chrome must cast a shadow onto the page. Without it, the + /// tone right under the surface edge stays at the flat page background. + private func popoverChromeHasShadow(_ image: UIImage, below rect: CGRect, pointsWidth: CGFloat) -> Bool { + guard let cg = image.cgImage, + let data = cg.dataProvider?.data, + let bytes = CFDataGetBytePtr(data) else { return false } + let width = cg.width, height = cg.height + let bytesPerRow = cg.bytesPerRow, bytesPerPixel = bytesPerRow / width + func tone(_ x: Int, _ y: Int) -> Int { Int(bytes[y * bytesPerRow + x * bytesPerPixel]) } + let pxPerPoint = CGFloat(width) / pointsWidth + // The surface edge sits ~17pt below the text baseline; its shadow darkens + // the band just beneath it before the page background returns. + let x = Int(rect.midX * pxPerPoint) + let start = Int((rect.maxY + 14) * pxPerPoint) + let limit = min(Int((rect.maxY + 48) * pxPerPoint), height - 1) + var darkest = 255 + for y in start...limit { + for dx in -2...2 { darkest = min(darkest, tone(x + dx, y)) } + } + return darkest < 236 + } + + func testAnchoredPopoverHoldOpen() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + sleep(3) + } + + func testAnchoredPopoverVerticalBars() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + // Turn on the rail (Duo mode) so toolbar projections move into the vertical bar. + let toggle = app.switches["iPhone Duo Mode"] + XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) + toggle.tap() + sleep(1) + openPage(app, name: "popover") + let trigger = app.buttons["Open popover"].firstMatch + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + for i in 0..<4 { + usleep(300_000) + capture("rail-popover-\(i)") + } + let text = app.webViews.staticTexts["Toolbar popover"] + XCTAssertTrue(text.waitForExistence(timeout: 5), app.debugDescription) + capture("rail-popover-final") + // Dismiss via outside tap: the surface must collapse back into the button. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap() + sleep(1) + capture("rail-popover-dismissed") + XCTAssertFalse(app.webViews.staticTexts["Toolbar popover"].exists, app.debugDescription) + XCTAssertTrue(app.buttons["Open popover"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + } + + func testOverlaySnapshotVisuals() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "native-ui-shell") + let openModal = app.webViews.buttons["Open modal"] + XCTAssertTrue(openModal.waitForExistence(timeout: 10), app.debugDescription) + openModal.tap() + for i in 0..<6 { + usleep(120_000) + capture("modal-handoff-\(i)") + } + let modalClose = app.webViews.buttons["Close modal"] + XCTAssertTrue(modalClose.waitForExistence(timeout: 10), app.debugDescription) + capture("modal-relayed") + modalClose.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 10), app.debugDescription) + capture("modal-restored") + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + openPage(app, name: "popover") + let trigger = app.webViews.descendants(matching: .any)["Compact controller popover"].firstMatch + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + for i in 0..<6 { + usleep(120_000) + capture("popover-handoff-\(i)") + } + let inc = app.webViews.buttons["Increment"] + XCTAssertTrue(inc.waitForExistence(timeout: 10), app.debugDescription) + capture("popover-relayed") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.08)).tap() + XCTAssertTrue(inc.waitForNonExistence(timeout: 10), app.debugDescription) + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + openPage(app, name: "alert") + let inputAlert = app.webViews.buttons["Input alert"] + XCTAssertTrue(inputAlert.waitForExistence(timeout: 10), app.debugDescription) + inputAlert.tap() + for i in 0..<6 { + usleep(120_000) + capture("alert-handoff-\(i)") + } + capture("alert-relayed") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.08)).tap() + Thread.sleep(forTimeInterval: 0.7) + } + + func testRepeatedPopoverStress() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + let popText = app.webViews.staticTexts["Toolbar popover"] + // Alternate outside-dismiss timing to hit the morph mid-present and at rest. + let delays: [TimeInterval] = [0.15, 0.6, 0.15, 0.6, 0.15, 0.6, 0.15, 0.6] + for (i, delay) in delays.enumerated() { + trigger.tap() + Thread.sleep(forTimeInterval: delay) + capture("stress-open-\(i)") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.25) + capture("stress-close-\(i)") + } + Thread.sleep(forTimeInterval: 1.0) + capture("stress-settled") + // The morph trigger and the rest of the projections must still work. + XCTAssertTrue(trigger.waitForExistence(timeout: 10), "native trigger lost after repeated popover use\n" + app.debugDescription) + trigger.tap() + XCTAssertTrue(popText.waitForExistence(timeout: 10), "popover failed to relay after repeats\n" + app.debugDescription) + capture("stress-reopen") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.8) + // Navigate away and back; projections must keep tracking the page. + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + openPage(app, name: "popover") + XCTAssertTrue(nativeButton(app, label: "Open popover").waitForExistence(timeout: 10), + "projections did not survive the stress round trip\n" + app.debugDescription) + capture("stress-roundtrip") + } + + func testMixedOverlayStress() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + let shellButtons = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'shell-'")) + for i in 0..<3 { + // Anchored morph popover. + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), "morph trigger lost at round \(i)\n" + app.debugDescription) + trigger.tap() + XCTAssertTrue(app.webViews.staticTexts["Toolbar popover"].waitForExistence(timeout: 10), + "morph did not present at round \(i)\n" + app.debugDescription) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.5) + capture("mixed-popover-\(i)") + // Unanchored controller popover via the x button. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.90, dy: 0.611)).tap() + let hello = app.webViews.staticTexts["Hello World!"] + XCTAssertTrue(hello.waitForExistence(timeout: 10), "uikit popover did not present at round \(i)\n" + app.debugDescription) + capture("mixed-uikit-\(i)") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.5) + // Back to index, then modal page and a modal. + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + openPage(app, name: "modal") + let present = app.webViews.buttons["present:normal"] + XCTAssertTrue(present.waitForExistence(timeout: 10), app.debugDescription) + present.tap() + Thread.sleep(forTimeInterval: 1.2) + capture("mixed-modal-\(i)") + XCTAssertEqual(shellButtons.count, 0, "page projections must retire under the modal at round \(i)") + let close = app.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Done"])).firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 10), app.debugDescription) + close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + Thread.sleep(forTimeInterval: 0.8) + capture("mixed-closed-\(i)") + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + } + // Final sanity: projections alive, morph still works. + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), "native projections died after mixed stress\n" + app.debugDescription) + trigger.tap() + XCTAssertTrue(app.webViews.staticTexts["Toolbar popover"].waitForExistence(timeout: 10), + "morph failed after mixed stress\n" + app.debugDescription) + capture("mixed-final") + } + private func openPage(_ app: XCUIApplication, name: String) { let label = name == "native-ui-shell" ? "native-ui-shell (Preview)" : name - let entry = app.webViews.buttons[label].exists ? app.webViews.buttons[label] : app.webViews.links[label] - // WebKit's isHittable does not account for a sibling native tab bar. - func unobscured() -> Bool { - entry.isHittable && entry.frame.midY > app.frame.minY + 130 && entry.frame.midY < app.frame.maxY - 120 - } - for _ in 0..<20 { - if unobscured() { break } - let below = entry.frame.midY >= app.frame.maxY - 120 + let web = app.webViews + func find() -> XCUIElement? { + for query in [web.buttons[label], web.links[label], web.otherElements[label], web.staticTexts[label]] { + if query.exists { return query } + } + let any = web.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch + return any.exists ? any : nil + } + var entry: XCUIElement? + // WebKit's isHittable does not account for a sibling native tab bar, and + // WebKit exposes list items lazily — poll across element types while scrolling. + for _ in 0..<30 { + entry = find() + if let e = entry, e.frame.midY > app.frame.minY + 130, e.frame.midY < app.frame.maxY - 120 { break } + let below = (entry?.frame.midY ?? 0) >= app.frame.maxY - 120 || entry == nil let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: below ? 0.65 : 0.35)) let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: below ? 0.35 : 0.65)) start.press(forDuration: 0.05, thenDragTo: end) + Thread.sleep(forTimeInterval: 0.3) } - XCTAssertTrue(unobscured(), "Cannot open " + name + "\n" + app.debugDescription) - entry.tap() + guard let e = entry, e.frame.midY > app.frame.minY + 130, e.frame.midY < app.frame.maxY - 120 else { + XCTFail("Cannot open " + name + "\n" + app.debugDescription); return + } + e.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() waitForWebTransition() } diff --git a/demo/src/app/index/pages/alert/alert.page.html b/demo/src/app/index/pages/alert/alert.page.html index 4a9c9d9c..69025f17 100644 --- a/demo/src/app/index/pages/alert/alert.page.html +++ b/demo/src/app/index/pages/alert/alert.page.html @@ -25,6 +25,10 @@

alert

+ Input alert + Saved input: {{ inputResult() }} present:preferred present:all present:button-only diff --git a/demo/src/app/index/pages/alert/alert.page.ts b/demo/src/app/index/pages/alert/alert.page.ts index 4aabbed4..29afc686 100644 --- a/demo/src/app/index/pages/alert/alert.page.ts +++ b/demo/src/app/index/pages/alert/alert.page.ts @@ -1,4 +1,4 @@ -import { Component, effect, inject, OnInit } from '@angular/core'; +import { Component, effect, inject, OnInit, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { @@ -55,6 +55,20 @@ export class AlertPage implements OnInit { }); } + readonly inputResult = signal(''); + + async presentInputs() { + const alert = await this.overlayCtrl.create({ + header: 'Input relay', + inputs: [{ name: 'name', placeholder: 'Your name', type: 'text' }], + buttons: [ + { text: 'Cancel', role: 'cancel' }, + { text: 'Save', handler: (values) => this.inputResult.set(values.name) }, + ], + }); + await alert.present(); + } + ngOnInit() {} async present(type: (typeof alertTypes)[number]) { diff --git a/demo/src/app/index/pages/popover/popover-content.component.ts b/demo/src/app/index/pages/popover/popover-content.component.ts new file mode 100644 index 00000000..8d0dfc8d --- /dev/null +++ b/demo/src/app/index/pages/popover/popover-content.component.ts @@ -0,0 +1,34 @@ +import { Component, input, signal } from '@angular/core'; +import { IonButton, IonContent, IonItem, IonItemGroup, IonLabel, IonList } from '@demo/ionic'; + +@Component({ + selector: 'app-popover-content', + standalone: true, + imports: [IonButton, IonContent, IonItem, IonItemGroup, IonLabel, IonList], + template: ` + + + + Count: {{ count() }}Increment + @if (scrollable()) { + @for (row of rows; track row) { + Row {{ row }} + } + } + + + + `, + styles: [':host { display: block; height: 100%; }'], +}) +export class PopoverContentComponent { + readonly scrollable = input(false); + readonly count = signal(0); + readonly rows = Array.from({ length: 20 }, (_, index) => index + 1); + readonly increment = (value: number) => value + 1; +} diff --git a/demo/src/app/index/pages/popover/popover.page.html b/demo/src/app/index/pages/popover/popover.page.html index 5b04510b..dc60a45c 100644 --- a/demo/src/app/index/pages/popover/popover.page.html +++ b/demo/src/app/index/pages/popover/popover.page.html @@ -4,8 +4,16 @@ popover + + + + + + +
Toolbar popover
+
@@ -14,15 +22,20 @@

popover

- - - This page is a component demo for @rdlabo/ionic-theme-ios27.
+ + Controller + + Compact controller popover + Scrollable controller popover + + + diff --git a/demo/src/app/index/pages/popover/popover.page.ts b/demo/src/app/index/pages/popover/popover.page.ts index 29f06c48..ee2933c5 100644 --- a/demo/src/app/index/pages/popover/popover.page.ts +++ b/demo/src/app/index/pages/popover/popover.page.ts @@ -1,4 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; + +import { PopoverContentComponent } from './popover-content.component'; import { FormsModule } from '@angular/forms'; import { @@ -18,6 +20,7 @@ import { IonText, IonTitle, IonToolbar, + PopoverController, } from '@demo/ionic'; @Component({ @@ -46,7 +49,17 @@ import { ], }) export class PopoverPage implements OnInit { - constructor() {} + readonly popoverController = inject(PopoverController); + + async present(event: Event, scrollable = false) { + const popover = await this.popoverController.create({ + component: PopoverContentComponent, + componentProps: { scrollable }, + event, + cssClass: scrollable ? 'demo-scrollable-popover' : 'demo-compact-popover', + }); + await popover.present(); + } ngOnInit() {} } diff --git a/demo/src/global.scss b/demo/src/global.scss index fba628bb..1e63bc91 100644 --- a/demo/src/global.scss +++ b/demo/src/global.scss @@ -228,3 +228,10 @@ app-settings-page { } } } + +ion-popover.demo-compact-popover { + --height: 64px; +} +ion-popover.demo-scrollable-popover { + --height: 240px; +} diff --git a/demo/src/main.ts b/demo/src/main.ts index 23dc4c40..60eb3b8f 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -45,7 +45,14 @@ const startShell = stockIonic || new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; const buttonDefaultFill = stockIonic || new URLSearchParams(window.location.search).get('buttonDefaultFill') === 'solid' ? 'solid' : null; const buttonProjection = new URLSearchParams(window.location.search).get('buttonProjection') === 'source' ? 'source' : 'system'; -void startShell({ buttonProjection, buttonDefaultFill }).then((handle) => { +void startShell({ + buttonProjection, + buttonDefaultFill, + controls: + startShell === enableNativeUIShell + ? { tabs: true, toolbar: true, segment: true, fab: true, modal: true, popover: true, alert: true } + : undefined, +}).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 8d5960e6..9648e910 100644 --- a/demo/src/native-ui-shell-lifecycle.spec.ts +++ b/demo/src/native-ui-shell-lifecycle.spec.ts @@ -36,6 +36,10 @@ test('placement requires ion-app and clears it when disabled', () => { 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); + expect(await enableNativeUIShell({ controls: { tabs: true, modal: false }, verticalBarsOnly: true })).toBe(first); + await expect(enableNativeUIShell({ controls: { tabs: true, modal: true }, verticalBarsOnly: true })).rejects.toThrow( + 'different controls', + ); await expect(enableNativeUIShell({ controls: { toolbar: true } })).rejects.toThrow('different controls'); await first.destroy(); }); diff --git a/demo/src/prehide.spec.ts b/demo/src/prehide.spec.ts index dc5893b0..d23cf5b3 100644 --- a/demo/src/prehide.spec.ts +++ b/demo/src/prehide.spec.ts @@ -13,6 +13,29 @@ const markup = ` `; +test('releases controls adopted into another document and recaptures them on return', async () => { + document.body.innerHTML = markup; + const app = document.querySelector('ion-app')!; + const page = document.querySelector('.ion-page')!; + const group = document.querySelector('ion-buttons')!; + const destination = document.implementation.createHTMLDocument(); + const prehide = prehideVerticalBarsToolbarSources(document); + try { + expect(group.classList.contains(prehiddenClass)).toBe(true); + destination.body.append(destination.adoptNode(page)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(page.isConnected).toBe(true); + expect(group.classList.contains(prehiddenClass)).toBe(false); + + app.append(document.adoptNode(page)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(group.classList.contains(prehiddenClass)).toBe(true); + } finally { + prehide.stop(); + document.body.innerHTML = ''; + } +}); + test('a departed page is not recaptured before Ionic hides it', async () => { document.documentElement.className = ''; document.body.innerHTML = markup; diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 25e1c0ca..c985d068 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -154,7 +154,39 @@ const shell = await enableNativeUIShell({ const disabledShell = await enableNativeUIShell({ enabled: false }); ``` -Omitting `controls` enables every supported control for backward compatibility. When `controls` is present, only entries set to `true` are native-eligible. Available entries are `tabs`, `toolbar`, `segment`, and `fab`. +Omitting `controls` enables `tabs`, `toolbar`, `segment`, and `fab` for backward compatibility. Preview overlay projection defaults to `false`. When `controls` is present, only entries set to `true` are native-eligible. Available entries are `tabs`, `toolbar`, `segment`, `fab`, `modal`, `popover`, and `alert`. + +### Modal content relay (preview) + +Opt in with `controls.modal: true`. Keep calling Ionic's `ModalController` or using inline `ion-modal`; no separate controller is needed in application code: + +```ts +await enableNativeUIShell({ + controls: { tabs: true, toolbar: true, segment: true, fab: true, modal: true }, +}); +``` + +Ionic keeps the original overlay host, controller registry, lifecycle and dismissal. Its content moves into a native-hosted WebView, preserving the existing component instance and application state. The relay manages styles, focus and cleanup. Omitting `modal`, or setting it to `false`, keeps ordinary Web rendering. + +The native overlay controller appears above the existing Native UI Shell. The covered page keeps its native projections throughout presentation and dismissal. Ordinary toolbars and tabs inside relayed content remain Web-rendered. On full-width modals using native Vertical Bars, eligible toolbar buttons continue to project into the modal’s native rail, using the same placement, appearance and disabled rules as the page. + +Ionic's own enter animation plays during the handoff. At `didPresent` the rendered overlay is frozen into the native host, its live content moves into the hosted WebView, and the native surface replaces the same pixels without replaying an opening animation. Dismissal uses UIKit's closing animations. Standard modals retain Ionic's geometry and use a full-screen native controller; Card modals (`presentingElement`) and Sheet modals use UIKit's sheet presentation, including the backing-page effect. Sheet `breakpoints` map to fractions of UIKit's available sheet height; `setCurrentBreakpoint()` and native dragging synchronize with Ionic. A zero breakpoint permits drag-to-dismiss, subject to the original `canDismiss` check. `animated: false` skips both animations. Custom Web enter/leave animations do not replace UIKit's transitions. + +This preview currently relays one overlay at a time. `--height: auto` modals keep Web rendering. Any nested overlay, including another Modal, returns the relay to the source WebView for the rest of that presentation. Opening an Ionic Menu does the same, so a menu is never hidden beneath the native overlay. Suspending or destroying the shell also restores the content and its Web animation settings. Ionic's `didPresent` reports initialization of the source modal; native presentation follows that initialization. + +The original modal host is not copied into the destination. DOM queries beneath it cannot find relayed content, and CSS selectors that depend on that host (such as `ion-modal.my-modal ion-button` or `.my-modal .field`) do not match. Accessible label references must point into the relayed content; references to elements left in the source document cannot resolve in the destination. + +Standard HTML `form="id"` associations cannot cross documents. If relayed native HTML controls depend on a form outside the Modal, keep that Modal on the Web with `data-shell="disabled"`. This does not exclude Ionic's `[form]="formRef"` pattern or a form and its controls that move together inside the Modal. + +### Popover and Alert content relay (preview) + +Opt in separately with `controls.popover: true` and `controls.alert: true`; both default to `false`. Continue using Ionic controllers or inline components. `data-shell="disabled"` keeps an individual overlay on the Web. + +Ordinary Popover presentations relay Ionic's content into a UIKit popover anchored to the original trigger. Native UI Shell buttons retain their existing size and position; their `UIButton` is the `sourceView`, with its bounds as `sourceRect`. They use the standard arrow popover rather than a button-to-popover morph. Vertical Bars uses SwiftUI's standard toolbar popover and lets the system control its transition and arrow. The native presentation owns its arrow, corners and outline; its initial size and background come from Ionic. + +Alert keeps Ionic's rendered input fields, buttons and handlers in a native-hosted WebView above Native UI Shell. It is not translated into `UIAlertController`. + +The same single-overlay restriction applies: opening a nested overlay restores the existing relay to the Web. Native presentation follows Ionic's `didPresent`. Queries under the source overlay cannot find content while it is relayed. For a custom modal or overlay that Native UI Shell cannot detect, acquire a suspension before presenting it. The resolved suspension means projected controls have returned to Web rendering. Always release it after dismissal: diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 0bac7c88..9e89c6b4 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -10,8 +10,17 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "prepareOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "snapshotOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "presentOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "revealOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setOverlayBreakpoint", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "dismissOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "closeOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "stopOverlays", returnType: CAPPluginReturnPromise) ] + private var overlays: ShellOverlayController? private var host: ShellHost? private var verticalBars: ShellVerticalBarsControlling? private var controls: [String: UIView] = [:] @@ -74,6 +83,149 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele observers.forEach(NotificationCenter.default.removeObserver) } + @objc func prepareOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let self, let id = call.getString("id"), + let webView = self.bridge?.webView, let owner = self.bridge?.viewController else { + call.reject("Overlay source unavailable"); return + } + if self.overlays == nil { + self.overlays = ShellOverlayController(source: webView, owner: owner) { [weak self] id, action, breakpoint in + var data: JSObject = ["id": id, "action": action] + if let breakpoint { data["breakpoint"] = breakpoint } + self?.notifyListeners("overlay", data: data) + } + } + guard self.overlays?.prepare(id, options: call.getObject("presentation")) == true else { + call.reject("An overlay window is already pending"); return + } + call.resolve() + } + } + + @objc func snapshotOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + func rect(_ key: String) -> CGRect? { + guard let value = call.getObject(key) else { return nil } + return CGRect(x: value["x"] as? Double ?? 0, y: value["y"] as? Double ?? 0, + width: value["width"] as? Double ?? 0, height: value["height"] as? Double ?? 0) + } + guard let overlays = self?.overlays, let id = call.getString("id"), + let source = rect("source"), let destination = rect("destination") else { + call.resolve(); return + } + overlays.snapshot(id, options: call.getObject("presentation"), source: source, destination: destination) { _ in + call.resolve() + } + } + } + + @objc func presentOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { + call.reject("Overlay not prepared"); return + } + overlays.present(id, options: call.getObject("presentation"), anchored: { [weak self] anchor, host, completion in + guard #available(iOS 26.0, *), let self else { return false } + func findAnchor(in view: UIView) -> UIView? { + if view.accessibilityIdentifier == anchor { return view } + for child in view.subviews { if let anchor = findAnchor(in: child) { return anchor } } + return nil + } + // The overlay must sit above every projected surface, so it grows + // out of the projected view itself inside a window-level layer. + // Rail items live in the hosting view; controls live in the host. + var roots = Array(self.controls.values) + let railView = self.verticalBars?.view + if let railView { roots.append(railView) } + var anchorView: UIView? + for root in roots { + guard let found = findAnchor(in: root) else { continue } + // Grow from the whole glass capsule, not the matched inner + // item — otherwise the capsule's rim sticks out of the + // surface's rounded corner. Rail items already resolve to + // their own capsule view. + anchorView = root is UIVisualEffectView && root !== railView ? root : found + break + } + guard let anchorView, let window = anchorView.window else { return false } + // Grow toward the screen middle: down from a top control, up from a + // bottom one, and left out of a trailing rail item. + // The page supplies the capsule frame: a grouped button's visible + // pill is its ion-buttons wrapper, wider than the projected button. + let nativeFrame = anchorView.convert(anchorView.bounds, to: window) + let frame = (host.options?["anchor"] as? [String: Double]).map({ + CGRect(x: $0["x"] ?? 0, y: $0["y"] ?? 0, width: $0["width"] ?? 44, height: $0["height"] ?? 44) + }) ?? nativeFrame + let height = window.bounds.height + let growth: ShellAnchoredMorph.Growth = + self.verticalBars.map({ anchorView.isDescendant(of: $0.view) }) == true ? .left + : frame.midY < height * 0.5 ? .down : .up + let morph = ShellAnchoredMorph(anchorView: anchorView, frame: frame, host: host, growth: growth) { [weak host] in + if host?.options?["backdropDismiss"] as? Bool != false { host?.event("dismiss", nil) } + } + host.dismissAnchored = { [weak morph, weak host] in + guard let morph, let host else { + let done = host?.anchoredDidDismiss + host?.anchoredDidDismiss = nil + done?() + return + } + morph.dismiss(animated: host.options?["animated"] as? Bool ?? true) { + host.anchoredVisible = false + let done = host.anchoredDidDismiss + host.anchoredDidDismiss = nil + done?() + } + } + window.addSubview(morph) + host.anchoredVisible = true + morph.present(animated: host.options?["animated"] as? Bool ?? true, completion: completion) + return true + }) { presented in + if presented { call.resolve() } else { call.reject("Overlay presenter unavailable") } + } + } + } + + @objc func revealOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } + overlays.reveal(id) { call.resolve() } + } + } + + @objc func dismissOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } + overlays.dismiss(id, animated: call.getBool("animated") ?? false, gesture: call.getBool("gesture") ?? false) { call.resolve() } + } + } + + @objc func closeOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } + overlays.close(id) { call.resolve() } + } + } + + @objc func setOverlayBreakpoint(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let id = call.getString("id"), let value = call.getDouble("breakpoint") else { + call.reject("Overlay breakpoint unavailable"); return + } + self?.overlays?.setBreakpoint(id, value: value) + call.resolve() + } + } + + @objc func stopOverlays(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let self, let overlays = self.overlays else { call.resolve(); return } + overlays.stop { self.overlays = nil; call.resolve() } + } + } + private func webViewMetrics() -> JSObject? { guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil } webView.layoutIfNeeded() @@ -120,6 +272,10 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele DispatchQueue.main.async { [weak self] in guard let self else { call.resolve(); return } let next = call.getInt("revision") ?? 0 + if let overlayId = call.getString("overlayId") { + self.overlays?.projectionHost(overlayId)?.clearBars(revision: next) + call.resolve(); return + } if next >= self.revision { self.revision = next self.restoreTopEdge?() @@ -181,6 +337,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele DispatchQueue.main.async { [weak self] in guard let self else { call.reject("Plugin released"); return } guard #available(iOS 26.0, *) else { call.reject("Requires iOS 26"); return } + if let overlayId = call.getString("overlayId") { + guard let host = self.overlays?.projectionHost(overlayId), + let snapshot = try? call.decode(ShellSnapshot.self), snapshot.isValid else { + call.reject("Invalid overlay projection"); return + } + let rejected = host.updateBars(snapshot, rendering: self.rendering) { [weak self] id, revision, sequence in + self?.notifyListeners("activate", data: ["overlayId": overlayId, "id": id, "revision": revision, "sequence": sequence]) + } + call.resolve(["revision": host.projectionRevision, "rejectedControls": rejected]); return + } let next = call.getInt("revision") ?? 0 guard next > self.revision else { call.resolve(["revision": self.revision]); return } guard let webView = self.bridge?.webView, let parent = webView.superview else { diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift new file mode 100644 index 00000000..c5696f9d --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift @@ -0,0 +1,177 @@ +import UIKit + +/// iOS-style anchored morph: the projected control's capsule expands into the +/// popover surface instead of a separate bubble beside it. The surface lives in +/// a window-level layer so it always draws above every shell projection. +final class ShellAnchoredMorph: UIView { + /// Grow direction mirrors the platform: a top toolbar item grows downward + /// from its capsule, a bottom one upward, a trailing rail item leftward. + enum Growth { case down, up, left } + private enum PinX { case leading, center, trailing } + private enum PinY { case top, center, bottom } + + /// Keeps the final-size content pinned to the clip corner that does not move + /// while the capsule grows, so the reveal slides over a still image instead + /// of stretching it. Plain autoresizing cannot express this because the + /// hosted view starts larger than the clip. + private final class ClipView: UIView { + var pinX: PinX = .trailing + var pinY: PinY = .top + var hostedSize = CGSize.zero + weak var hosted: UIView? + override func layoutSubviews() { + super.layoutSubviews() + guard let hosted else { return } + let x: CGFloat = switch pinX { + case .leading: 0 + case .center: (bounds.width - hostedSize.width) / 2 + case .trailing: bounds.width - hostedSize.width + } + let y: CGFloat = switch pinY { + case .top: 0 + case .center: (bounds.height - hostedSize.height) / 2 + case .bottom: bounds.height - hostedSize.height + } + hosted.frame = CGRect(origin: CGPoint(x: x, y: y), size: hostedSize) + } + } + + private let surface = UIView() + private let clip = ClipView() + private let anchorView: UIView + private let anchorFrame: CGRect + private let targetFrame: CGRect + private let onDismiss: () -> Void + private var dismissStarted = false + private var restoredAnchor = false + + private static let edgeMargin: CGFloat = 8 + /// Matches the theme's popover radius (34px) — small surfaces become capsules. + private static let surfaceRadius: CGFloat = 34 + + init(anchorView: UIView, frame: CGRect, host: ShellOverlayHost, growth: Growth, onDismiss: @escaping () -> Void) { + self.anchorView = anchorView + self.onDismiss = onDismiss + let window = anchorView.window + anchorFrame = frame + let bounds = window?.bounds ?? .zero + let size = host.preferredContentSize + var target = CGRect(origin: .zero, size: size) + let leadingAligned = anchorFrame.midX < bounds.midX + switch growth { + case .down: + target.origin.y = anchorFrame.minY + target.origin.x = leadingAligned ? anchorFrame.minX : anchorFrame.maxX - size.width + case .up: + target.origin.y = anchorFrame.maxY - size.height + target.origin.x = leadingAligned ? anchorFrame.minX : anchorFrame.maxX - size.width + case .left: + target.origin.x = anchorFrame.maxX - size.width + target.origin.y = anchorFrame.midY - size.height * 0.25 + } + target.origin.x = min(max(target.origin.x, Self.edgeMargin), max(Self.edgeMargin, bounds.width - Self.edgeMargin - size.width)) + target.origin.y = min(max(target.origin.y, Self.edgeMargin), max(Self.edgeMargin, bounds.height - Self.edgeMargin - size.height)) + targetFrame = target + + super.init(frame: bounds) + autoresizingMask = [.flexibleWidth, .flexibleHeight] + backgroundColor = .clear + + surface.frame = anchorFrame + surface.backgroundColor = .clear + surface.layer.shadowColor = UIColor.black.cgColor + surface.layer.shadowOpacity = 0.18 + surface.layer.shadowRadius = 24 + surface.layer.shadowOffset = CGSize(width: 0, height: 8) + + clip.frame = surface.bounds + clip.autoresizingMask = [.flexibleWidth, .flexibleHeight] + clip.clipsToBounds = true + clip.layer.cornerCurve = .continuous + clip.layer.cornerRadius = anchorFrame.height / 2 + surface.addSubview(clip) + + let background: UIView + if #available(iOS 26.0, *) { + background = UIVisualEffectView(effect: UIGlassEffect(style: .regular)) + } else { + background = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) + } + background.frame = clip.bounds + background.autoresizingMask = [.flexibleWidth, .flexibleHeight] + clip.addSubview(background) + + // The hosted document keeps its final size; the growing clip reveals it + // from the corner the morph grows away from. + clip.hostedSize = size + clip.pinY = growth == .up ? .bottom : growth == .left ? .center : .top + clip.pinX = growth == .left ? .trailing : leadingAligned ? .leading : .trailing + clip.hosted = host.view + clip.addSubview(host.view) + + addSubview(surface) + + // The capsule becomes the surface; keep its pixels from bleeding through. + UIView.animate(withDuration: 0.1) { anchorView.alpha = 0 } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Expand the capsule into the popover surface. + func present(animated: Bool, completion: @escaping () -> Void) { + let animate = { + self.surface.frame = self.targetFrame + self.clip.layer.cornerRadius = Self.surfaceRadius + } + guard animated else { + animate() + completion() + return + } + let animator = UIViewPropertyAnimator(duration: 0.38, dampingRatio: 0.82, animations: animate) + animator.addCompletion { _ in completion() } + animator.startAnimation() + } + + /// Collapse the surface back into the capsule before the anchor returns. + func dismiss(animated: Bool, completion: @escaping () -> Void) { + guard !dismissStarted else { completion(); return } + dismissStarted = true + let finish = { + self.restoreAnchor() + self.removeFromSuperview() + completion() + } + // The surface stays opaque while it shrinks so it reads as the capsule + // itself collapsing; the real anchor swaps back in once they overlap. + let animate = { + self.surface.frame = self.anchorFrame + self.clip.layer.cornerRadius = self.anchorFrame.height / 2 + } + guard animated else { + animate() + finish() + return + } + let animator = UIViewPropertyAnimator(duration: 0.28, dampingRatio: 0.9, animations: animate) + animator.addCompletion { _ in finish() } + animator.startAnimation() + } + + private func restoreAnchor() { + guard !restoredAnchor else { return } + restoredAnchor = true + anchorView.alpha = 1 + } + + /// Outside touches are eaten like a UIKit popover dismissal; the surface + /// itself receives its own touches so the hosted web content stays live. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + if hit === self { + if !dismissStarted { onDismiss() } + return dismissStarted ? nil : self + } + return hit + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayBars.swift new file mode 100644 index 00000000..4019fb6a --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayBars.swift @@ -0,0 +1,38 @@ +import UIKit + +extension ShellOverlayHost { + /// The overlay owns its rail and revisions independently of the covered Capacitor page. + @available(iOS 26.0, *) + func updateBars(_ snapshot: ShellSnapshot, rendering: ShellRendering, + activate: @escaping (String, Int, Int) -> Void) -> [String] { + guard snapshot.revision > projectionRevision else { return [] } + projectionRevision = snapshot.revision + let controls = snapshot.controls.filter { + $0.placement == .verticalBars && [.button, .buttons, .backButton, .menuButton].contains($0.kind) + } + let retained = Set(controls.map(\.id)) + let rejected = snapshot.controls.filter { !retained.contains($0.id) }.map(\.id) + guard !controls.isEmpty else { + clearBars(revision: snapshot.revision) + return rejected + } + let rail = verticalBars ?? ShellVerticalBarsController( + activate: { [weak self] id in + guard let self else { return } + self.projectionSequence += 1 + activate(id, self.projectionRevision, self.projectionSequence) + }, changed: { _, _, _, _, _ in 0 }) + verticalBars = rail + rail.attach(to: self, in: view) + rail.view.frame = view.bounds + rail.apply(controls, rendering: rendering, edge: snapshot.verticalBarEdge ?? "right") + return rejected + } + + func clearBars(revision: Int) { + guard revision >= projectionRevision else { return } + projectionRevision = revision + verticalBars?.detach() + verticalBars = nil + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift new file mode 100644 index 00000000..c3ef7644 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift @@ -0,0 +1,162 @@ +import UIKit +import WebKit + +/// Intercepts only a prepared blank window. Other requests keep Capacitor's behavior. +final class ShellOverlayController: NSObject, WKUIDelegate { + private weak var source: WKWebView? + private weak var owner: UIViewController? + private let original: WKUIDelegate? + private let automaticallyOpensWindows: Bool + private var prepared: (id: String, options: [String: Any]?)? + private let event: (String, String, Double?) -> Void + private var id: String? + private var host: ShellOverlayHost? + + init(source: WKWebView, owner: UIViewController, event: @escaping (String, String, Double?) -> Void) { + self.source = source + self.owner = owner + self.event = event + original = source.uiDelegate + automaticallyOpensWindows = source.configuration.preferences.javaScriptCanOpenWindowsAutomatically + super.init() + source.uiDelegate = self + } + + override func responds(to selector: Selector!) -> Bool { + super.responds(to: selector) || original?.responds(to: selector) == true + } + + override func forwardingTarget(for selector: Selector!) -> Any? { + original?.responds(to: selector) == true ? original : super.forwardingTarget(for: selector) + } + + func prepare(_ id: String, options: [String: Any]?) -> Bool { + guard prepared == nil, host == nil else { return false } + prepared = (id, options) + source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + return true + } + + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + // WebKit percent-encodes '#' inside about: URLs; compare the decoded form. + guard webView === source, navigationAction.sourceFrame.isMainFrame, + let prepared, navigationAction.targetFrame == nil, + navigationAction.request.url?.absoluteString.removingPercentEncoding == "about:blank#\(prepared.id)" else { + return original?.webView?(webView, createWebViewWith: configuration, + for: navigationAction, windowFeatures: windowFeatures) + } + self.prepared = nil + source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows + id = prepared.id + let host = ShellOverlayHost(configuration: configuration, options: prepared.options) { [weak self] action, breakpoint in + self?.event(prepared.id, action, breakpoint) + } + host.webView.uiDelegate = self + host.webView.frame = source?.bounds ?? .zero + self.host = host + return host.webView + } + + /// Capture the rendered overlay so the host can cover itself with identical + /// pixels while the relayed document finishes its first paint. + func snapshot(_ id: String, options: [String: Any]?, source sourceRect: CGRect, destination destRect: CGRect, + completion: @escaping (Bool) -> Void) { + guard self.id == id, let host, let source else { completion(false); return } + host.apply(options) + let configuration = WKSnapshotConfiguration() + configuration.rect = sourceRect + source.takeSnapshot(with: configuration) { image, error in + // A missing image still presents; the hosted WebView paints on its own. + if let image, error == nil { + let view = UIImageView(image: image) + view.frame = destRect + host.placeholder = view + host.view.addSubview(view) + } + completion(true) + } + } + + /// Lift the frozen cover once the hosted document has painted. The hosted + /// pixels match the image, so removal is instant rather than a crossfade. + func reveal(_ id: String, completion: @escaping () -> Void) { + guard self.id == id, let host else { completion(); return } + host.placeholder = nil + completion() + } + + func present(_ id: String, options: [String: Any]?, anchored: (String, ShellOverlayHost, @escaping () -> Void) -> Bool, + completion: @escaping (Bool) -> Void) { + guard self.id == id, let host, let owner, + owner.presentedViewController == nil else { completion(false); return } + host.apply(options) + if host.options?["kind"] as? String == "popover", + let anchor = host.options?["anchorId"] as? String, + anchored(anchor, host, { completion(true) }) { return } + // Without a projected view to grow from, a popover falls back to the + // trigger rect the page measured before handing off. + if host.options?["kind"] as? String == "popover" { host.modalPresentationStyle = .popover } + if let popover = host.popoverPresentationController, + let anchor = host.options?["anchor"] as? [String: Double] { + popover.sourceView = source + popover.sourceRect = CGRect(x: anchor["x"] ?? 0, y: anchor["y"] ?? 0, + width: anchor["width"] ?? 1, height: anchor["height"] ?? 1) + popover.backgroundColor = ShellRendering().color(host.options?["backgroundColor"] as? String) + } + owner.present(host, animated: host.options?["animated"] as? Bool ?? false) { completion(true) } + } + + // Keep the WebView alive until JavaScript has restored the adopted nodes and listeners. + func dismiss(_ id: String, animated: Bool, gesture: Bool, completion: @escaping () -> Void) { + guard self.id == id, let host else { completion(); return } + if let dismiss = host.dismissAnchored { + if host.anchoredVisible { host.anchoredDidDismiss = completion; dismiss() } + else { completion() } + return + } + guard host.presentingViewController != nil else { completion(); return } + if gesture { host.showDismissalSnapshot() } + host.dismiss(animated: animated, completion: completion) + } + + func close(_ id: String, completion: @escaping () -> Void) { + if prepared?.id == id { + prepared = nil + source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows + } + guard self.id == id, let host else { completion(); return } + let finish = { [self] in + host.verticalBars?.detach() + host.webView.uiDelegate = nil + host.placeholder = nil + self.host = nil + self.id = nil + completion() + } + if let dismiss = host.dismissAnchored, host.anchoredVisible { host.anchoredDidDismiss = finish; dismiss() } + else if host.presentingViewController == nil { finish() } + else { host.dismiss(animated: false, completion: finish) } + } + + func projectionHost(_ id: String) -> ShellOverlayHost? { + self.id == id ? host : nil + } + + func setBreakpoint(_ id: String, value: Double) { + if self.id == id { host?.setBreakpoint(value) } + } + + func stop(completion: @escaping () -> Void) { + let finish = { [self] in + prepared = nil + if let source, source.uiDelegate === self { + source.uiDelegate = original + source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows + } + completion() + } + if let id { close(id, completion: finish) } + else { finish() } + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift new file mode 100644 index 00000000..2bd56efe --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift @@ -0,0 +1,126 @@ +import UIKit +import WebKit + +/// Ionic owns dismissal permission; UIKit owns modal presentation. +final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDelegate, UIPopoverPresentationControllerDelegate { + let webView: WKWebView + private(set) var options: [String: Any]? + let event: (String, Double?) -> Void + var verticalBars: ShellVerticalBarsControlling? + var projectionRevision = 0 + var projectionSequence = 0 + /// Frozen cover that keeps the presented surface identical while the relayed + /// document finishes its first paint inside the hosted WebView. + var placeholder: UIView? { + didSet { oldValue?.removeFromSuperview() } + } + private var dismissalSnapshot: UIView? + + var dismissAnchored: (() -> Void)? + var anchoredVisible = false + var anchoredDidDismiss: (() -> Void)? + + init(configuration: WKWebViewConfiguration, options: [String: Any]?, event: @escaping (String, Double?) -> Void) { + webView = WKWebView(frame: .zero, configuration: configuration) + self.options = options + self.event = event + super.init(nibName: nil, bundle: nil) + apply(options) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Presentation details can arrive after the host exists; apply them before use. + func apply(_ options: [String: Any]?) { + guard let options else { return } + self.options = options + let kind = options["kind"] as? String + modalPresentationStyle = kind == "popover" && options["anchorId"] == nil ? .popover : (kind == "card" || kind == "sheet" ? .pageSheet : .overFullScreen) + if kind == "popover" { + overrideUserInterfaceStyle = options["dark"] as? Bool == true ? .dark : .light + preferredContentSize = CGSize(width: options["width"] as? Double ?? 280, height: options["height"] as? Double ?? 200) + } + if kind == "alert" { modalTransitionStyle = .crossDissolve } + isModalInPresentation = kind != "popover" + if modalPresentationStyle == .popover { + popoverPresentationController?.delegate = self + popoverPresentationController?.popoverBackgroundViewClass = ShellPopoverBackgroundView.self + } + configureSheet() + } + + override func loadView() { + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + view = UIView(frame: webView.frame) + view.backgroundColor = .clear + // Covered page controls stay projected during the relay; keep them out of + // VoiceOver while the overlay owns interaction. + view.accessibilityViewIsModal = true + view.addSubview(webView) + if options?["kind"] as? String == "popover" { + webView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } else { + webView.frame = view.bounds + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + applyPopoverChrome() + } + + /// The custom `ShellPopoverBackgroundView` supplies the silhouette; clip the + /// hosted document to the same radius so its square corners stay inside it. + private func applyPopoverChrome() { + guard modalPresentationStyle == .popover else { return } + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = min(34, view.bounds.height / 2) + view.clipsToBounds = true + } + + func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { .none } + + func popoverPresentationControllerShouldDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) -> Bool { + options?["backdropDismiss"] as? Bool != false + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + if options?["kind"] as? String == "popover" { event("dismiss", nil) } + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + if options?["kind"] as? String == "popover" { + if options?["backdropDismiss"] as? Bool != false { event("dismiss", nil) } + return + } + let breakpoints = options?["breakpoints"] as? [Double] ?? [] + if options?["kind"] as? String == "card" || breakpoints.contains(0) { + // Ionic skips its leave animation for the gesture role and may immediately destroy its content. + dismissalSnapshot = webView.snapshotView(afterScreenUpdates: false) + event("dismiss", nil) + } + } + + func showDismissalSnapshot() { + guard let snapshot = dismissalSnapshot else { return } + snapshot.frame = webView.bounds + snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.addSubview(snapshot) + dismissalSnapshot = nil + } + + func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { + if let value = sheetPresentationController.selectedDetentIdentifier?.rawValue, let breakpoint = Double(value) { + event("breakpoint", breakpoint) + } + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift new file mode 100644 index 00000000..6e70e19d --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift @@ -0,0 +1,36 @@ +import UIKit + +extension ShellOverlayHost { + func configureSheet() { + guard #available(iOS 16.0, *), let options, + let kind = options["kind"] as? String, kind == "card" || kind == "sheet", + let sheet = sheetPresentationController else { return } + sheet.delegate = self + if kind == "card" { + // Leave Ionic's card gap open so the shrunken page peeks above the sheet. + let gap = CGFloat(options["topInset"] as? Double ?? 40) + sheet.detents = [.custom { context in max(context.maximumDetentValue - gap, 1) }] + return + } + let breakpoints = (options["breakpoints"] as? [Double] ?? []).filter { $0 > 0 }.sorted() + sheet.detents = breakpoints.map { value in + .custom(identifier: .init(String(value))) { context in context.maximumDetentValue * CGFloat(value) } + } + if let initial = options["initialBreakpoint"] as? Double { + sheet.selectedDetentIdentifier = .init(String(initial)) + } + if let threshold = options["backdropBreakpoint"] as? Double, + let undimmed = breakpoints.last(where: { $0 <= threshold }) { + sheet.largestUndimmedDetentIdentifier = .init(String(undimmed)) + } + sheet.prefersScrollingExpandsWhenScrolledToEdge = options["expandToScroll"] as? Bool ?? true + sheet.prefersGrabberVisible = options["handle"] as? Bool ?? true + } + + func setBreakpoint(_ value: Double) { + guard #available(iOS 16.0, *), let sheet = sheetPresentationController else { return } + let identifier = UISheetPresentationController.Detent.Identifier(String(value)) + guard sheet.detents.contains(where: { $0.identifier == identifier }), sheet.selectedDetentIdentifier != identifier else { return } + sheet.animateChanges { sheet.selectedDetentIdentifier = identifier } + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellPopoverBackgroundView.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellPopoverBackgroundView.swift new file mode 100644 index 00000000..f3a6a126 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellPopoverBackgroundView.swift @@ -0,0 +1,100 @@ +import UIKit + +/// Custom `UIPopoverBackgroundView` so fallback popovers share the anchored +/// morph's chrome: a liquid-glass surface with a 34pt continuous corner and a +/// soft drop shadow. Registered through `popoverBackgroundViewClass`, which +/// keeps everything inside the public API surface. +final class ShellPopoverBackgroundView: UIPopoverBackgroundView { + /// `UIGlassEffect` owns its backing layer and discards an installed mask, so + /// the silhouette mask lives on this plain clip view instead. + private let clip = UIView() + private let surface: UIVisualEffectView + private let shapeMask = CAShapeLayer() + private var edge: UIPopoverArrowDirection = .unknown + private var centerOffset: CGFloat = 0 + + /// UIKit forwards `popoverPresentationController.backgroundColor` here; the + /// glass supplies the surface, so the backing view itself stays clear. + override var backgroundColor: UIColor? { + get { .clear } + set { super.backgroundColor = .clear } + } + + override var arrowDirection: UIPopoverArrowDirection { + get { edge } + set { edge = newValue; setNeedsLayout() } + } + + override var arrowOffset: CGFloat { + get { centerOffset } + set { centerOffset = newValue; setNeedsLayout() } + } + + override class func arrowBase() -> CGFloat { 26 } + override class func arrowHeight() -> CGFloat { 13 } + override class func contentViewInsets() -> UIEdgeInsets { .zero } + + override init(frame: CGRect) { + if #available(iOS 26.0, *) { + surface = UIVisualEffectView(effect: UIGlassEffect(style: .regular)) + } else { + surface = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) + } + super.init(frame: frame) + super.backgroundColor = .clear + clip.layer.mask = shapeMask + clip.addSubview(surface) + addSubview(clip) + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.18 + layer.shadowRadius = 24 + layer.shadowOffset = CGSize(width: 0, height: 8) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + clip.frame = bounds + surface.frame = clip.bounds + shapeMask.frame = clip.bounds + let arrowDepth = Self.arrowHeight() + var body = bounds + switch edge { + case .up: body.origin.y += arrowDepth; body.size.height -= arrowDepth + case .down: body.size.height -= arrowDepth + case .left: body.origin.x += arrowDepth; body.size.width -= arrowDepth + case .right: body.size.width -= arrowDepth + default: break + } + let radius = min(34, min(body.width, body.height) / 2) + let half = Self.arrowBase() / 2 + let path = UIBezierPath(roundedRect: body, cornerRadius: radius) + switch edge { + case .up: + let cx = min(max(bounds.midX + centerOffset, body.minX + radius), body.maxX - radius) + path.move(to: CGPoint(x: cx - half, y: body.minY)) + path.addLine(to: CGPoint(x: cx, y: body.minY - arrowDepth)) + path.addLine(to: CGPoint(x: cx + half, y: body.minY)) + case .down: + let cx = min(max(bounds.midX + centerOffset, body.minX + radius), body.maxX - radius) + path.move(to: CGPoint(x: cx + half, y: body.maxY)) + path.addLine(to: CGPoint(x: cx, y: body.maxY + arrowDepth)) + path.addLine(to: CGPoint(x: cx - half, y: body.maxY)) + case .left: + let cy = min(max(bounds.midY + centerOffset, body.minY + radius), body.maxY - radius) + path.move(to: CGPoint(x: body.minX, y: cy - half)) + path.addLine(to: CGPoint(x: body.minX - arrowDepth, y: cy)) + path.addLine(to: CGPoint(x: body.minX, y: cy + half)) + case .right: + let cy = min(max(bounds.midY + centerOffset, body.minY + radius), body.maxY - radius) + path.move(to: CGPoint(x: body.maxX, y: cy + half)) + path.addLine(to: CGPoint(x: body.maxX + arrowDepth, y: cy)) + path.addLine(to: CGPoint(x: body.maxX, y: cy - half)) + default: break + } + path.close() + shapeMask.path = path.cgPath + layer.shadowPath = path.cgPath + } +} diff --git a/src/native/definitions.ts b/src/native/definitions.ts index f36e5601..4843b3fe 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -47,6 +47,12 @@ export interface NativeUIShellControls { segment?: boolean; /** Projects floating action buttons. */ fab?: boolean; + /** Preview: relays Modal content into a native-hosted WebView. Defaults to false. */ + modal?: boolean; + /** Preview: relays Popover content into a native popover. Defaults to false. */ + popover?: boolean; + /** Preview: relays Alert content above Native UI Shell. Defaults to false. */ + alert?: boolean; } export interface NativeUIShellHandle { @@ -159,6 +165,8 @@ export interface ShellSearchEvent extends ShellActivation { } export interface ShellSnapshot { + /** Internal projection destination; omitted for the Capacitor WebView. */ + overlayId?: string; revision: number; transitionDuration?: number; viewportWidth: number; @@ -170,6 +178,7 @@ export interface ShellSnapshot { } export interface ShellActivation { + overlayId?: string; revision: number; id: string; sequence: number; @@ -183,9 +192,50 @@ export interface WebViewMetrics { export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; getWebViewMetrics(): Promise; + prepareOverlay(options: { id: string; presentation?: ShellOverlayPresentation }): Promise; + snapshotOverlay(options: { id: string; presentation?: ShellOverlayPresentation; source: Frame; destination: Frame }): Promise; + presentOverlay(options: { id: string; presentation?: ShellOverlayPresentation }): Promise; + revealOverlay(options: { id: string }): Promise; + setOverlayBreakpoint(options: { id: string; breakpoint: number }): Promise; + dismissOverlay(options: { id: string; animated: boolean; gesture: boolean }): Promise; + closeOverlay(options: { id: string }): Promise; + stopOverlays(): Promise; update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; - clear(options: { revision: number }): Promise; + clear(options: { revision: number; overlayId?: string }): 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: 'overlay', listener: (event: ShellOverlayEvent) => void): Promise; +} + +export interface ShellModalPresentation { + kind: 'normal' | 'card' | 'sheet'; + animated: boolean; + /** Gap the card sheet leaves at the top so the shrunken page stays visible. */ + topInset?: number; + breakpoints?: number[]; + initialBreakpoint?: number; + backdropBreakpoint?: number; + expandToScroll?: boolean; + handle?: boolean; +} + +export interface ShellPopoverPresentation { + kind: 'popover'; + animated: boolean; + dark: boolean; + anchorId?: string; + anchor: Frame; + width: number; + height: number; + backgroundColor: string; + backdropDismiss: boolean; +} + +export type ShellOverlayPresentation = ShellModalPresentation | ShellPopoverPresentation | { kind: 'alert'; animated: boolean }; + +export interface ShellOverlayEvent { + id: string; + action: 'dismiss' | 'breakpoint'; + breakpoint?: number; } diff --git a/src/native/index.ts b/src/native/index.ts index e9059deb..d62f9c58 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -11,6 +11,7 @@ import type { WebViewMetrics, } from './definitions'; import { createRuntime } from './runtime'; +import { createOverlayController } from './overlays/controller'; import { createVerticalBarsWebProjection } from './vertical-bars-web'; import { prehideVerticalBarsToolbarSources } from './prehide'; import { observeVerticalBarsModals } from './shared/modal'; @@ -64,7 +65,7 @@ const manage = ( handle: NativeUIShellHandle, lifecycle: { reason?: string; - suspend?: () => (() => void) | undefined; + suspend?: () => (() => void) | undefined | Promise<(() => void) | undefined>; /** Runs after teardown; clears the shared slot only while this activation owns it. */ release?: () => void; destroy?: () => void | Promise; @@ -75,7 +76,7 @@ const manage = ( getStatus: () => (lifecycle.reason ? { ...handle.getStatus(), reason: lifecycle.reason } : handle.getStatus()), async suspend() { const lease = await handle.suspend(); - const resume = lifecycle.suspend?.(); + const resume = await lifecycle.suspend?.(); return { async resume() { await lease.resume(); @@ -89,8 +90,11 @@ const manage = ( try { await handle.destroy(); } finally { - await lifecycle.destroy?.(); - lifecycle.release?.(); + try { + await lifecycle.destroy?.(); + } finally { + lifecycle.release?.(); + } } }, }; @@ -203,6 +207,9 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise // Local source overrides can use this even when the startup projection is system. options.buttonDefaultFill ?? null, ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true), + controls?.modal === true, + controls?.popover === true, + controls?.alert === true, ]); if (active && activeConfiguration !== configuration) return Promise.reject( @@ -235,6 +242,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise (async () => { if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS'); let runtime: NativeUIShellHandle | undefined; + let overlays: Awaited> | undefined; let metricsListener: Awaited> | undefined; let stopVerticalBarsLayout: (() => void) | undefined; try { @@ -261,10 +269,20 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise native, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'), ); + if (controls?.modal === true || controls?.popover === true || controls?.alert === true) + overlays = await createOverlayController(document, plugin, options, native.retain, nativeVerticalBars); return manage(runtime, { - suspend: () => prehide?.suspend(), + suspend: async () => { + const restorePrehide = prehide?.suspend(); + const resumeOverlays = await overlays?.suspend(); + return () => { + restorePrehide?.(); + resumeOverlays?.(); + }; + }, release, destroy: async () => { + await overlays?.destroy(); await metricsListener?.remove().catch(() => {}); stopVerticalBarsLayout?.(); prehide?.stop(); @@ -272,6 +290,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise }, }); } catch (error) { + await overlays?.destroy(); await runtime?.destroy(); await metricsListener?.remove().catch(() => {}); stopVerticalBarsLayout?.(); diff --git a/src/native/overlays/alert.ts b/src/native/overlays/alert.ts new file mode 100644 index 00000000..e8d225b1 --- /dev/null +++ b/src/native/overlays/alert.ts @@ -0,0 +1,23 @@ +import { moveContent } from './content'; + +/** Keep Ionic's host registered; its existing wrapper retains input and button handlers. */ +export const relayAlert = (overlay: HTMLIonAlertElement, destination: HTMLElement) => { + const doc = destination.ownerDocument; + const root = doc.createElement('ion-alert'); + for (const attribute of Array.from(overlay.attributes)) root.setAttribute(attribute.name, attribute.value); + Object.assign(root.style, { visibility: 'visible', opacity: '1', pointerEvents: 'auto' }); + destination.append(root); + const restore = moveContent(Array.from(overlay.children) as HTMLElement[], root); + const backdrop = () => { + if (overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + }; + root.addEventListener('ionBackdropTap', backdrop); + return { + root, + stop() { + root.removeEventListener('ionBackdropTap', backdrop); + restore(); + root.remove(); + }, + }; +}; diff --git a/src/native/overlays/content.ts b/src/native/overlays/content.ts new file mode 100644 index 00000000..bd91e51b --- /dev/null +++ b/src/native/overlays/content.ts @@ -0,0 +1,51 @@ +import { sheetText } from './styles'; + +/** Constructed stylesheets cannot cross documents with their nodes. */ +export const moveContent = (content: HTMLElement[], destination: HTMLElement): (() => void) => { + const home = content.map((element) => ({ element, parent: element.parentNode!, next: element.nextSibling })); + const roots: { root: ShadowRoot; css: string; sheets: CSSStyleSheet[] }[] = []; + const collect = (node: Element) => { + if (node.shadowRoot) { + roots.push({ + root: node.shadowRoot, + css: sheetText(node.shadowRoot.adoptedStyleSheets), + sheets: [...node.shadowRoot.adoptedStyleSheets], + }); + for (const child of Array.from(node.shadowRoot.children)) collect(child); + } + for (const child of Array.from(node.children)) collect(child); + }; + content.forEach(collect); + for (const element of content) destination.append(destination.ownerDocument.adoptNode(element)); + const styles = roots + .filter(({ css }) => css) + .map(({ root, css }) => { + const style = destination.ownerDocument.createElement('style'); + style.textContent = css; + root.append(style); + return style; + }); + return () => { + styles.forEach((style) => style.remove()); + for (const { element, parent, next } of home) { + const connected = element.isConnected; + parent.ownerDocument!.adoptNode(element); + // Ionic may already have destroyed the Angular component on dismiss. + if (connected) parent.insertBefore(element, next?.parentNode === parent ? next : null); + } + for (const { root, css, sheets } of roots) { + // WebKit re-associates adopted sheets with the new document across + // adoptNode, so they may no longer match this root; fall back to the + // inline copy instead of aborting the remaining restore. + try { + root.adoptedStyleSheets = sheets; + } catch { + if (css) { + const style = root.ownerDocument.createElement('style'); + style.textContent = css; + root.append(style); + } + } + } + }; +}; diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts new file mode 100644 index 00000000..92ee82bf --- /dev/null +++ b/src/native/overlays/controller.ts @@ -0,0 +1,368 @@ +import type { NativeUIShellOptions, NativeUIShellPlugin, ShellModalPresentation } from '../definitions'; +import { bounded } from '../runtime'; +import { isExcluded, isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; +import { relayVerticalBars } from './vertical-bars'; +import { activeElement, trapFocus } from './focus'; +import { relayModal } from './modal'; +import { relayPopover, popoverPresentation, popoverAnchorCapsule } from './popover'; +import { relayAlert } from './alert'; +import { nativeDialogAnimation } from './dialog-animation'; +import { overlaySnapshot } from './snapshot'; +import { relayStyles } from './styles'; +import { modalPresentation, syncModalBreakpoint } from './modal-presentation'; +import { nativeModalAnimation } from './modal-animation'; + +type Presentation = ShellModalPresentation | { kind: 'popover'; animated: boolean } | { kind: 'alert'; animated: boolean }; +type Overlay = HTMLIonModalElement | HTMLIonPopoverElement | HTMLIonAlertElement; +const lifecycle = (overlay: Overlay, phase: string) => + `ion${overlay.localName.slice(4, 5).toUpperCase()}${overlay.localName.slice(5)}${phase}`; + +interface Connection { + id: string; + overlay: Overlay; + dismissed: boolean; + win?: Window; + focus: Element | null; + stopStyles?: () => void; + releaseProjection?: () => void; + stopVerticalBars?: () => Promise; + stopContent?: () => void; + stopFocus?: () => void; + stopEvents?: () => void; + stopChildEvents?: () => void; + stopExclusions?: () => void; + stopAnchor?: () => void; + closing?: Promise; + nativeClosing?: Promise; + stopAnimation?: () => void; + hideAnimation?: () => void; + stopLifecycle?: () => void; +} + +/** Ionic owns lifecycle and dismissal; only one overlay is relayed at a time. */ +export const createOverlayController = async ( + doc: Document, + plugin: NativeUIShellPlugin, + options: NativeUIShellOptions, + retainProjection: () => () => void, + nativeVerticalBars: () => boolean, +) => { + let current: Connection | undefined; + let opening = Promise.resolve(); + let sequence = 0; + let stopped = false; + const suspensions = new Set(); + const events = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast'].map((name) => `ion${name}WillPresent`); + const cleanup = async (connection: Connection) => { + // Every teardown step is isolated and bounded: a throwing or hanging step + // must not strand the retained page projections or the child window. + const stage = async (label: string, run?: () => void | Promise) => { + try { + await bounded(Promise.resolve(run?.())); + } catch (error) { + console.error(`Native overlay teardown failed at ${label}; continuing cleanup anyway.`, error); + } + }; + try { + await stage('events', connection.stopEvents); + await stage('lifecycle', connection.stopLifecycle); + await stage('childEvents', connection.stopChildEvents); + await stage('exclusions', connection.stopExclusions); + await stage('focus', connection.stopFocus); + // A timed-out dismissal must not strand the relayed content in the child window. + await connection.nativeClosing?.catch((error) => { + console.error('Native overlay dismissal did not complete; restoring the Web content anyway.', error); + }); + await stage('anchor', connection.stopAnchor); + await stage('verticalBars', connection.stopVerticalBars); + await stage('content', connection.stopContent); + await stage('animation', connection.stopAnimation); + await stage('styles', connection.stopStyles); + await stage('close', () => bounded(plugin.closeOverlay({ id: connection.id }))); + } finally { + // The page projections must be released even if teardown throws above. + connection.releaseProjection?.(); + connection.releaseProjection = undefined; + try { + connection.win?.close(); + } catch (error) { + console.error('Native overlay window was already gone.', error); + } + if (current === connection) current = undefined; + const focus = connection.focus as HTMLElement | null; + if (focus?.isConnected && focus.tabIndex >= 0) focus.focus({ preventScroll: true }); + } + }; + const release = (connection: Connection): Promise => (connection.closing ??= cleanup(connection)); + const restoreWeb = async () => { + if (current) await release(current); + }; + const excluded = (overlay: HTMLElement) => isPermanentlyExcluded(overlay) || isExcluded(overlay) || isShellDisabled(overlay); + const connect = async (connection: Connection, ready: Promise, presentation: Presentation) => { + const { overlay } = connection; + const gone = () => + current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay); + // A popover presents exactly once, natively: anchored ones morph out of the + // projected control and the rest use UIKit's own popover animation. Playing + // the Web enter underneath would flicker or jump position at the swap. + if (presentation.kind === 'popover') connection.hideAnimation?.(); + try { + // Ionic's own enter animation covers the wait; spin up the child window + // underneath it so the handoff is ready when didPresent lands. + const staging = (async () => { + // Full presentation details (post-layout geometry included) are applied + // natively at snapshot/present time; prepare only needs the modal shape. + await bounded( + plugin.prepareOverlay({ + id: connection.id, + presentation: presentation.kind === 'popover' ? undefined : presentation, + }), + ); + if (gone()) return undefined; + const win = doc.defaultView!.open(`about:blank#${connection.id}`, '_blank'); + if (!win) throw new Error('Native overlay window unavailable'); + connection.win = win; + // The requested blank page replaces the initial document created by window.open. + // WebKit percent-encodes the '#' marker in the popup URL; compare decoded. + // Adopt nodes only after that navigation; otherwise WebKit discards the relay. + if (decodeURIComponent(win.document.URL) !== `about:blank#${connection.id}` || win.document.readyState !== 'complete') + await bounded(new Promise((resolve) => win.addEventListener('load', () => resolve(), { once: true }))); + if (gone()) return undefined; + connection.stopChildEvents = observe(win.document); + const verticalBars = nativeVerticalBars() && overlay.classList.contains('ios-theme-vertical-bars-modal'); + const environment = relayStyles(doc, win.document, overlay, verticalBars); + connection.stopStyles = environment.stop; + return { destination: environment.destination, verticalBars, win }; + })(); + // The staging join is awaited below; settle early rejections quietly. + staging.catch(() => {}); + // Ionic initializes its gestures after didPresent. Let that turn finish before adopting their content. + // A stalled presentation must not park the opening queue forever. + await bounded(ready); + // Placement is captured after Ionic has laid out the overlay. + await bounded(new Promise((resolve) => doc.defaultView!.requestAnimationFrame(() => resolve()))); + const staged = await staging; + if (gone() || !staged) { + await release(connection); + return; + } + const focused = activeElement(doc) as HTMLElement | null; + const nativePresentation = presentation.kind === 'popover' ? popoverPresentation(overlay as HTMLIonPopoverElement) : presentation; + // Freeze the rendered overlay so the native surface can swap identical + // pixels in instantly instead of replaying an opening animation. Popovers + // are hidden from the start and animate natively, so there is nothing to + // freeze. + const snapshot = presentation.kind === 'popover' ? undefined : overlaySnapshot(overlay); + if (snapshot) { + try { + await bounded(plugin.snapshotOverlay({ id: connection.id, presentation: nativePresentation, ...snapshot })); + } catch (error) { + console.warn('Native overlay snapshot unavailable; presenting without it.', error); + } + } + if (gone()) { + await release(connection); + return; + } + const content = + presentation.kind === 'popover' + ? relayPopover(overlay as HTMLIonPopoverElement, staged.destination) + : presentation.kind === 'alert' + ? relayAlert(overlay as HTMLIonAlertElement, staged.destination) + : relayModal(overlay as HTMLIonModalElement, staged.destination, presentation.kind); + connection.stopContent = content.stop; + // The morph covers the projected control; also hide its Web capsule — + // the ion-buttons backdrop-filter pill is a padding ring wider than the + // projected button and would peek out of the surface's rounded corner. + if (presentation.kind === 'popover' && (nativePresentation as { anchorId?: string }).anchorId) { + const capsule = popoverAnchorCapsule(overlay as HTMLIonPopoverElement); + if (capsule) { + const visibility = capsule.style.visibility; + capsule.style.visibility = 'hidden'; + connection.stopAnchor = () => { + capsule.style.visibility = visibility; + }; + } + } + connection.hideAnimation?.(); + // Release the source document's captured placement before the relay takes ownership. + doc.defaultView!.dispatchEvent(new Event('nativeUIShellRefresh')); + if (staged.verticalBars) + connection.stopVerticalBars = await bounded(relayVerticalBars(staged.win.document, plugin, options, connection.id)); + if (gone()) { + await release(connection); + return; + } + // Ionic does not dismiss a presented overlay when its page is hidden or + // destroyed; without this a popover could retain the page projections + // forever and freeze every later control update. + const exclusions = new MutationObserver(() => { + if (!overlay.isConnected || excluded(overlay) || isShellDisabled(content.root)) void release(connection).catch(console.error); + }); + const attributes = { attributes: true, attributeFilter: ['class', 'data-shell', 'hidden', 'inert'] }; + for (let node: HTMLElement | null = overlay; node; node = node.parentElement) { + exclusions.observe(node, attributes); + exclusions.observe(node, { childList: true }); + } + exclusions.observe(content.root, { ...attributes, subtree: true }); + connection.stopExclusions = () => exclusions.disconnect(); + connection.stopFocus = trapFocus( + content.root, + () => + !connection.dismissed && + (!('focusTrap' in overlay) || overlay.focusTrap !== false) && + !overlay.classList.contains('ion-disable-focus-trap'), + ); + const close = () => { + void release(connection).catch(console.error); + }; + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && overlay.localName !== 'ion-modal' && overlay.backdropDismiss) + void overlay.dismiss(undefined, 'backdrop'); + }; + staged.win.document.addEventListener('keydown', escape); + overlay.addEventListener(lifecycle(overlay, 'DidDismiss'), close, { once: true }); + const stopBreakpoint = + presentation.kind === 'sheet' ? syncModalBreakpoint(overlay as HTMLIonModalElement, plugin, connection.id) : undefined; + connection.stopEvents = () => { + staged.win.document.removeEventListener('keydown', escape); + overlay.removeEventListener(lifecycle(overlay, 'DidDismiss'), close); + stopBreakpoint?.(); + }; + // The Web enter animation already played; swap to the hosted window instantly. + // Popovers never played a Web enter; they animate on the native side + // instead (anchored morph or UIKit's own popover presentation). + await bounded( + plugin.presentOverlay({ + id: connection.id, + presentation: { ...nativePresentation, animated: presentation.kind === 'popover' ? presentation.animated : false }, + }), + ); + // Let the hosted document paint one frame before uncovering the frozen image. + await bounded(new Promise((resolve) => staged.win.document.defaultView!.requestAnimationFrame(() => resolve()))).catch( + () => {}, + ); + await bounded(plugin.revealOverlay({ id: connection.id })).catch(() => {}); + (focused?.isConnected && focused.ownerDocument === staged.win.document ? focused : content.root).focus({ preventScroll: true }); + } catch (error) { + await release(connection); + console.error('Native overlay projection failed; keeping Ionic content in the source WebView.', error); + } + }; + const present = (event: Event) => { + const overlay = event.target as Overlay; + const autoHeight = + overlay.localName === 'ion-modal' && doc.defaultView!.getComputedStyle(overlay).getPropertyValue('--height').trim() === 'auto'; + if (current) { + const connection = current; + current = undefined; + opening = opening.then(() => release(connection)).catch(console.error); + if (!connection.dismissed) return; + // A didDismiss handler may immediately open the next modal while UIKit is still closing this one. + connection.stopAnimation?.(); + connection.stopAnimation = undefined; + } + const enabled = + (overlay.localName === 'ion-modal' && options.controls?.modal === true) || + (overlay.localName === 'ion-popover' && options.controls?.popover === true) || + (overlay.localName === 'ion-alert' && options.controls?.alert === true); + if (stopped || suspensions.size || !enabled || autoHeight || excluded(overlay)) { + opening = opening.then(restoreWeb).catch(console.error); + return; + } + const connection: Connection = { id: `ios-theme-overlay-${++sequence}`, overlay, dismissed: false, focus: activeElement(doc) }; + const presentation: Presentation = + overlay.localName === 'ion-modal' + ? modalPresentation(overlay as HTMLIonModalElement) + : overlay.localName === 'ion-alert' + ? { kind: 'alert', animated: overlay.animated } + : { kind: 'popover', animated: overlay.animated }; + current = connection; + // A covering overlay lets the page projections retire at willPresent as + // usual; only a popover keeps them — its page stays visible and the morph + // needs the projected anchor alive. + if (presentation.kind === 'popover') connection.releaseProjection = retainProjection(); + const closed = () => { + connection.dismissed = true; + }; + overlay.addEventListener(lifecycle(overlay, 'DidDismiss'), closed, { once: true }); + let resolveReady!: () => void; + const readyPromise = new Promise((resolve) => { + resolveReady = resolve; + }); + const ready = () => { + resolveReady(); + }; + const closeNative = (gesture = false) => + (connection.nativeClosing ??= bounded(plugin.dismissOverlay({ id: connection.id, animated: presentation.animated, gesture }))); + if (presentation.kind === 'popover' || presentation.kind === 'alert') { + const animation = nativeDialogAnimation(overlay as HTMLIonPopoverElement | HTMLIonAlertElement, closeNative); + connection.stopAnimation = animation.stop; + connection.hideAnimation = animation.hide; + } else { + const animation = nativeModalAnimation(overlay as HTMLIonModalElement, connection.id, presentation.kind, closeNative); + connection.stopAnimation = () => animation.stop(connection.dismissed); + connection.hideAnimation = animation.hide; + } + overlay.addEventListener(lifecycle(overlay, 'DidPresent'), ready, { once: true }); + connection.stopLifecycle = () => overlay.removeEventListener(lifecycle(overlay, 'DidDismiss'), closed); + opening = opening + .then(() => connect(connection, readyPromise, presentation)) + .catch(console.error) + .finally(() => { + overlay.removeEventListener(lifecycle(overlay, 'DidPresent'), ready); + }); + }; + const menuOpen = (event: Event) => { + if (!event.composedPath().some((node): node is HTMLElement => node instanceof HTMLElement && node.matches('ion-menu'))) return; + // Menus render in the source document below the native overlay; release the relay. + const connection = current; + current = undefined; + if (connection) opening = opening.then(() => release(connection)).catch(console.error); + }; + const observe = (target: Document) => { + for (const name of events) target.addEventListener(name, present); + target.addEventListener('ionWillOpen', menuOpen); + return () => { + for (const name of events) target.removeEventListener(name, present); + target.removeEventListener('ionWillOpen', menuOpen); + }; + }; + const listener = await bounded( + plugin.addListener('overlay', (event) => { + if (event.id !== current?.id) return; + if (event.action === 'dismiss') { + const popover = current.overlay.localName === 'ion-popover'; + if (!popover || current.overlay.backdropDismiss) + void current.overlay.dismiss(undefined, popover ? 'backdrop' : 'gesture').catch(console.error); + } else if (event.breakpoint !== undefined && current.overlay.localName === 'ion-modal') + void (current.overlay as HTMLIonModalElement).setCurrentBreakpoint(event.breakpoint).catch(console.error); + }), + ); + const stopEvents = observe(doc); + return { + async suspend() { + const lease = Symbol(); + suspensions.add(lease); + await opening; + await restoreWeb(); + return () => { + suspensions.delete(lease); + }; + }, + async destroy() { + stopped = true; + stopEvents(); + try { + await opening; + await restoreWeb(); + } finally { + try { + await bounded(plugin.stopOverlays()); + } finally { + await listener.remove(); + } + } + }, + }; +}; diff --git a/src/native/overlays/dialog-animation.ts b/src/native/overlays/dialog-animation.ts new file mode 100644 index 00000000..ad646330 --- /dev/null +++ b/src/native/overlays/dialog-animation.ts @@ -0,0 +1,27 @@ +import { dismissAnimation } from './dismiss-animation'; + +export type Dialog = HTMLIonPopoverElement | HTMLIonAlertElement; + +/** Hide the emptied source surface once the relay hosts the same live content. */ +export const nativeDialogAnimation = (overlay: Dialog, close: () => Promise) => { + const leaveAnimation = overlay.leaveAnimation; + const opacity = overlay.style.opacity; + const event = overlay.localName === 'ion-popover' ? 'ionPopoverWillDismiss' : 'ionAlertWillDismiss'; + const dismiss = () => { + const closing = close(); + overlay.leaveAnimation = () => dismissAnimation(closing); + }; + overlay.addEventListener(event, dismiss); + return { + // Called only after the relay adopts the content: the Web enter animation + // stays visible until the native surface replaces the same pixels. + hide() { + overlay.style.opacity = '0'; + }, + stop() { + overlay.removeEventListener(event, dismiss); + overlay.leaveAnimation = leaveAnimation; + overlay.style.opacity = opacity; + }, + }; +}; diff --git a/src/native/overlays/dismiss-animation.ts b/src/native/overlays/dismiss-animation.ts new file mode 100644 index 00000000..1c72c7a9 --- /dev/null +++ b/src/native/overlays/dismiss-animation.ts @@ -0,0 +1,12 @@ +import { createAnimation } from '@ionic/core'; + +/** Let Ionic finish dismissal after the native presentation has closed. */ +export const dismissAnimation = (closing: Promise) => { + const animation = createAnimation(); + const play = animation.play; + animation.play = async (options) => { + await closing; + await play(options); + }; + return animation; +}; diff --git a/src/native/overlays/focus.ts b/src/native/overlays/focus.ts new file mode 100644 index 00000000..eca21b35 --- /dev/null +++ b/src/native/overlays/focus.ts @@ -0,0 +1,64 @@ +export const activeElement = (doc: Document): Element | null => { + let element = doc.activeElement; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return element; +}; + +const focusableElements = (root: HTMLElement): HTMLElement[] => { + const elements: HTMLElement[] = []; + const win = root.ownerDocument.defaultView!; + const collect = (scope: HTMLElement | ShadowRoot) => { + for (const node of Array.from(scope.querySelectorAll('*'))) { + if (node.inert || node.closest('[inert]') || (node as HTMLButtonElement).disabled) continue; + const before = elements.length; + if (node.shadowRoot) collect(node.shadowRoot); + const css = win.getComputedStyle(node); + if ( + elements.length === before && + node.tabIndex >= 0 && + !node.hidden && + css.display !== 'none' && + css.visibility !== 'hidden' && + node.getClientRects().length + ) + elements.push(node); + } + }; + collect(root); + return elements + .map((element, index) => ({ element, index })) + .sort((a, b) => (a.element.tabIndex || Infinity) - (b.element.tabIndex || Infinity) || a.index - b.index) + .map(({ element }) => element); +}; + +export const trapFocus = (root: HTMLElement, enabled: () => boolean): (() => void) => { + const doc = root.ownerDocument; + const previous = root.getAttribute('tabindex'); + root.tabIndex = -1; + let last: Element | null; + const onFocus = (event: FocusEvent) => { + if (!enabled()) return; + if (event.composedPath().includes(root)) { + last = activeElement(doc); + return; + } + const elements = focusableElements(root); + (elements.find((element) => element === last) ?? elements[0] ?? root).focus({ preventScroll: true }); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey || !enabled()) return; + const elements = focusableElements(root); + const index = elements.findIndex((element) => element === activeElement(doc)); + const next = event.shiftKey ? (index <= 0 ? elements.length - 1 : index - 1) : (index + 1) % elements.length; + event.preventDefault(); + (elements[next] ?? root).focus(); + }; + doc.addEventListener('focusin', onFocus, true); + doc.addEventListener('keydown', onKey, true); + return () => { + doc.removeEventListener('focusin', onFocus, true); + doc.removeEventListener('keydown', onKey, true); + if (previous === null) root.removeAttribute('tabindex'); + else root.setAttribute('tabindex', previous); + }; +}; diff --git a/src/native/overlays/modal-animation.ts b/src/native/overlays/modal-animation.ts new file mode 100644 index 00000000..6e131f3a --- /dev/null +++ b/src/native/overlays/modal-animation.ts @@ -0,0 +1,37 @@ +import type { ShellModalPresentation } from '../definitions'; +import { dismissAnimation } from './dismiss-animation'; +import { preserveModalBackground } from './modal-background'; + +/** Keep Ionic's gesture setup, but hide the emptied surface once the relay adopts it. */ +export const nativeModalAnimation = ( + overlay: HTMLIonModalElement, + id: string, + kind: ShellModalPresentation['kind'], + close: (gesture: boolean) => Promise, +) => { + const leaveAnimation = overlay.leaveAnimation; + // Ionic renders the iOS shadow beside the wrapper. Both belong to the + // relayed surface; only the backdrop stays in the source for normal modals. + const surfaces = ( + kind !== 'normal' ? [overlay] : Array.from(overlay.shadowRoot!.querySelectorAll('.modal-wrapper, .modal-shadow')) + ).map((element) => ({ element, visibility: element.style.visibility })); + const restoreBackground = preserveModalBackground(overlay, id); + const dismiss = (event: Event) => { + const closing = close((event as CustomEvent).detail.role === 'gesture'); + overlay.leaveAnimation = () => dismissAnimation(closing); + }; + overlay.addEventListener('ionModalWillDismiss', dismiss); + return { + // Called only after the relay adopts the content: the Web enter animation + // stays visible until the native surface replaces the same pixels. + hide() { + for (const { element } of surfaces) element.style.visibility = 'hidden'; + }, + stop(dismissed: boolean) { + overlay.removeEventListener('ionModalWillDismiss', dismiss); + overlay.leaveAnimation = leaveAnimation; + for (const { element, visibility } of surfaces) element.style.visibility = visibility; + restoreBackground?.(dismissed); + }, + }; +}; diff --git a/src/native/overlays/modal-background.ts b/src/native/overlays/modal-background.ts new file mode 100644 index 00000000..84f4c874 --- /dev/null +++ b/src/native/overlays/modal-background.ts @@ -0,0 +1,34 @@ +/** Suppress Ionic's backing-page effect while UIKit owns the Card presentation. */ +export const preserveModalBackground = (overlay: HTMLIonModalElement, id: string) => { + if (!overlay.presentingElement) return; + const doc = overlay.ownerDocument; + const style = doc.createElement('style'); + style.setAttribute('data-overlay-relay-local', ''); + const saved = [ + { element: overlay.presentingElement, properties: ['transform', 'transform-origin', 'overflow', 'filter', 'border-radius'] }, + { element: doc.body, properties: ['background-color'] }, + ].map(({ element, properties }, index) => { + const attribute = element.getAttribute('data-overlay-background'); + const token = `${id}-${index}`; + const css = doc.defaultView!.getComputedStyle(element); + const values = properties.map((name) => ({ + name, + value: element.style.getPropertyValue(name), + priority: element.style.getPropertyPriority(name), + })); + // Ionic still settles its Web presentation for a possible return to the WebView. + // Its backing-page transform must not also scale UIKit's backing view. + style.textContent += `[data-overlay-background="${token}"] {${properties.map((name) => `${name}:${css.getPropertyValue(name)}!important`).join(';')}}`; + element.setAttribute('data-overlay-background', token); + return { element, attribute, values }; + }); + doc.head.append(style); + return (dismissed: boolean) => { + style.remove(); + for (const { element, attribute, values } of saved) { + if (attribute === null) element.removeAttribute('data-overlay-background'); + else element.setAttribute('data-overlay-background', attribute); + if (dismissed) for (const { name, value, priority } of values) element.style.setProperty(name, value, priority); + } + }; +}; diff --git a/src/native/overlays/modal-presentation.ts b/src/native/overlays/modal-presentation.ts new file mode 100644 index 00000000..d8309422 --- /dev/null +++ b/src/native/overlays/modal-presentation.ts @@ -0,0 +1,36 @@ +import type { NativeUIShellPlugin, ShellModalPresentation } from '../definitions'; + +export const modalPresentation = (overlay: HTMLIonModalElement): ShellModalPresentation => { + if (overlay.breakpoints && overlay.initialBreakpoint !== undefined) { + return { + kind: 'sheet', + animated: overlay.animated, + breakpoints: overlay.breakpoints, + initialBreakpoint: overlay.initialBreakpoint, + backdropBreakpoint: overlay.backdropBreakpoint, + expandToScroll: overlay.expandToScroll, + handle: overlay.handle, + }; + } + const kind = overlay.presentingElement !== undefined ? ('card' as const) : ('normal' as const); + return { kind, animated: overlay.animated, topInset: kind === 'card' ? cardInset(overlay.ownerDocument) : undefined }; +}; + +// Mirrors Ionic's card height: `100% - max(30px, var(--ion-safe-area-top)) - 10px`. +const cardInset = (doc: Document): number => { + const probe = doc.createElement('div'); + probe.style.cssText = 'position:fixed;top:env(safe-area-inset-top);left:0;visibility:hidden;pointer-events:none'; + doc.body.append(probe); + const safe = probe.getBoundingClientRect().top; + probe.remove(); + return Math.max(30, safe) + 10; +}; + +/** Sheet detent changes stay with the modal adapter, not the document relay. */ +export const syncModalBreakpoint = (overlay: HTMLIonModalElement, plugin: NativeUIShellPlugin, id: string): (() => void) => { + const breakpoint = (event: Event) => { + void plugin.setOverlayBreakpoint({ id, breakpoint: (event as CustomEvent).detail.breakpoint }).catch(console.error); + }; + overlay.addEventListener('ionBreakpointDidChange', breakpoint); + return () => overlay.removeEventListener('ionBreakpointDidChange', breakpoint); +}; diff --git a/src/native/overlays/modal.ts b/src/native/overlays/modal.ts new file mode 100644 index 00000000..df5aa3aa --- /dev/null +++ b/src/native/overlays/modal.ts @@ -0,0 +1,106 @@ +import type { ShellModalPresentation } from '../definitions'; +import { moveContent } from './content'; + +/** Keep Ionic's host and animation wrapper in the source document. */ +export const relayModal = ( + overlay: HTMLIonModalElement, + destination: HTMLElement, + kind: ShellModalPresentation['kind'], +): { root: HTMLElement; stop: () => void } => { + const fillsViewport = kind !== 'normal'; + const doc = overlay.ownerDocument; + const win = doc.defaultView!; + const target = destination.ownerDocument; + const wrapper = overlay.shadowRoot?.querySelector('.modal-wrapper'); + const shadow = overlay.shadowRoot?.querySelector('.modal-shadow'); + const slot = wrapper?.querySelector('slot'); + if (!wrapper || !slot) throw new Error('Modal content is not mounted'); + const content = slot.assignedElements().filter((element): element is HTMLElement => element.nodeType === 1); + const shell = target.createElement('div'); + const shadowSurface = !fillsViewport && shadow ? target.createElement('div') : null; + if (shadowSurface) { + shadowSurface.style.position = 'absolute'; + shadowSurface.style.pointerEvents = 'none'; + shadowSurface.setAttribute('aria-hidden', 'true'); + destination.append(shadowSurface); + } + shell.tabIndex = -1; + shell.style.position = 'absolute'; + shell.style.overflow = 'hidden'; + destination.append(shell); + const sync = () => { + for (const name of ['role', 'aria-modal', 'aria-label', 'aria-labelledby', 'aria-describedby']) { + const value = wrapper.getAttribute(name); + if (value === null) shell.removeAttribute(name); + else shell.setAttribute(name, value); + } + const rect = wrapper.getBoundingClientRect(); + const css = win.getComputedStyle(wrapper); + for (const name of Array.from(css)) + if (name.startsWith('--') && !(fillsViewport && name.startsWith('--ion-safe-area-'))) + shell.style.setProperty(name, css.getPropertyValue(name)); + Object.assign(shell.style, { + left: fillsViewport ? '0' : `${rect.x}px`, + top: fillsViewport ? '0' : `${rect.y}px`, + width: fillsViewport ? '100%' : `${rect.width}px`, + height: fillsViewport ? '100%' : `${rect.height}px`, + borderRadius: fillsViewport ? '0' : css.borderRadius, + boxShadow: fillsViewport ? 'none' : css.boxShadow, + // UIKit owns the sheet outline. Keep Web backdrop filters inside its + // content viewport so the native outer shadow cannot bleed into headers. + clipPath: fillsViewport ? 'inset(0)' : css.clipPath, + background: css.backgroundColor, + }); + if (shadowSurface) { + // Keep the shadow outside the clipped surface, as Ionic does. Otherwise + // translucent headers sample the shadow and acquire a dark inner edge. + Object.assign(shadowSurface.style, { + left: shell.style.left, + top: shell.style.top, + width: shell.style.width, + height: shell.style.height, + borderRadius: shell.style.borderRadius, + boxShadow: win.getComputedStyle(shadow!).boxShadow, + }); + } + }; + sync(); + // Ionic limits the page to the visible Web sheet fraction. UIKit has already + // sized this viewport to that fraction, so applying it again leaves a gap. + const sheetPage = kind === 'sheet' && !overlay.expandToScroll ? overlay.querySelector('.ion-page') : null; + const maxHeight = sheetPage?.style.getPropertyValue('max-height') ?? ''; + const maxHeightPriority = sheetPage?.style.getPropertyPriority('max-height') ?? ''; + sheetPage?.style.setProperty('max-height', '100%', 'important'); + const restore = moveContent(content, shell); + const backdrop = (event: MouseEvent) => { + if (!event.composedPath().includes(shell) && overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + }; + target.addEventListener('click', backdrop); + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + }; + target.addEventListener('keydown', escape); + const resize = new ResizeObserver(sync); + resize.observe(wrapper); + const theme = new MutationObserver(sync); + for (let node: HTMLElement | null = overlay; node; node = node.parentElement) theme.observe(node, { attributes: true }); + theme.observe(doc.head, { subtree: true, childList: true, characterData: true, attributes: true }); + const palette = win.matchMedia('(prefers-color-scheme: dark)'); + palette.addEventListener('change', sync); + target.defaultView!.addEventListener('resize', sync); + return { + root: shell, + stop() { + target.removeEventListener('click', backdrop); + target.removeEventListener('keydown', escape); + resize.disconnect(); + theme.disconnect(); + palette.removeEventListener('change', sync); + target.defaultView!.removeEventListener('resize', sync); + sheetPage?.style.setProperty('max-height', maxHeight, maxHeightPriority); + restore(); + shell.remove(); + shadowSurface?.remove(); + }, + }; +}; diff --git a/src/native/overlays/popover.ts b/src/native/overlays/popover.ts new file mode 100644 index 00000000..a141da4e --- /dev/null +++ b/src/native/overlays/popover.ts @@ -0,0 +1,81 @@ +import type { ShellPopoverPresentation } from '../definitions'; +import { projectionIds } from '../shared/projection-id'; +import { isDark, marker } from '../shared/dom'; +import { moveContent } from './content'; + +const popoverTrigger = (overlay: HTMLIonPopoverElement): Element | undefined => { + const event = overlay.event as MouseEvent | undefined; + const target = (overlay.trigger ? overlay.ownerDocument.getElementById(overlay.trigger) : event?.target) as Element | undefined; + return target?.closest?.('ion-button,ion-fab-button,ion-item') ?? target; +}; + +// The visible pill around a grouped button is the ion-buttons glass capsule, +// which is one padding ring wider than the button the projection hides. +export const popoverAnchorCapsule = (overlay: HTMLIonPopoverElement): HTMLElement | undefined => { + const trigger = popoverTrigger(overlay); + return (trigger?.closest('ion-buttons') ?? trigger) as HTMLElement | undefined; +}; + +export const popoverPresentation = (overlay: HTMLIonPopoverElement): ShellPopoverPresentation => { + const trigger = popoverTrigger(overlay); + const content = overlay.shadowRoot!.querySelector('.popover-content')!; + const rect = content.getBoundingClientRect(); + const anchored = !!trigger?.closest(`[${marker}]`); + return { + kind: 'popover', + animated: overlay.animated, + dark: isDark(overlay.ownerDocument.defaultView!.getComputedStyle(overlay)), + anchorId: anchored && trigger ? projectionIds.get(trigger) : undefined, + anchor: (() => { + // Morph from the capsule the user actually sees — a grouped button's pill + // belongs to its ion-buttons wrapper, not the button's own bounds. + const box = (anchored ? popoverAnchorCapsule(overlay) : trigger)?.getBoundingClientRect() ?? rect; + return { x: box.x, y: box.y, width: box.width, height: box.height }; + })(), + width: rect.width, + height: rect.height, + backgroundColor: overlay.ownerDocument.defaultView!.getComputedStyle(content).backgroundColor, + backdropDismiss: overlay.backdropDismiss, + }; +}; + +export const relayPopover = (overlay: HTMLIonPopoverElement, destination: HTMLElement) => { + const doc = destination.ownerDocument; + const source = overlay.shadowRoot!.querySelector('.popover-content')!; + const root = doc.createElement('ion-popover'); + for (const attribute of Array.from(overlay.attributes)) root.setAttribute(attribute.name, attribute.value); + Object.assign(root.style, { + position: 'absolute', + inset: '0', + display: 'block', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }); + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-modal', 'true'); + const shadow = root.attachShadow({ mode: 'open' }); + const shell = doc.createElement('div'); + const css = overlay.ownerDocument.defaultView!.getComputedStyle(source); + Object.assign(shell.style, { height: '100%', overflow: css.overflow, background: css.backgroundColor }); + shell.append(doc.createElement('slot')); + shadow.append(shell); + destination.append(root); + // Components such as ion-select-popover dismiss their closest ion-popover. + root.dismiss = (...args) => overlay.dismiss(...args); + const restore = moveContent(Array.from(source.querySelector('slot')!.assignedElements()) as HTMLElement[], root); + const select = (event: MouseEvent) => { + if (!event.composedPath().includes(shell)) { + if (overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + } else if (overlay.dismissOnSelect) void overlay.dismiss(); + }; + root.addEventListener('click', select); + return { + root, + stop() { + root.removeEventListener('click', select); + restore(); + root.remove(); + }, + }; +}; diff --git a/src/native/overlays/snapshot.ts b/src/native/overlays/snapshot.ts new file mode 100644 index 00000000..72e7ea86 --- /dev/null +++ b/src/native/overlays/snapshot.ts @@ -0,0 +1,39 @@ +import type { Frame } from '../definitions'; + +/** + * Where the rendered overlay sits inside the source WebView and where its image + * lands in the host view, so the native handoff can swap identical pixels. + */ +export const overlaySnapshot = ( + overlay: HTMLIonModalElement | HTMLIonPopoverElement | HTMLIonAlertElement, +): { source: Frame; destination: Frame } | undefined => { + const rectOf = (selector: string): Frame | undefined => { + const rect = overlay.shadowRoot?.querySelector(selector)?.getBoundingClientRect(); + return rect && rect.width > 0 ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined; + }; + if (overlay.localName === 'ion-popover') { + // The popover host is sized to the content, so the image fills it. + const content = rectOf('.popover-content'); + return content && { source: content, destination: { x: 0, y: 0, width: content.width, height: content.height } }; + } + if (overlay.localName === 'ion-alert') { + const wrapper = rectOf('.alert-wrapper'); + return wrapper && { source: wrapper, destination: wrapper }; + } + const wrapper = rectOf('.modal-wrapper'); + if (!wrapper) return undefined; + // The iOS shadow sits beside the wrapper; keep both inside the frozen image. + const shadow = rectOf('.modal-shadow'); + const source = shadow + ? { + x: Math.min(wrapper.x, shadow.x), + y: Math.min(wrapper.y, shadow.y), + width: Math.max(wrapper.x + wrapper.width, shadow.x + shadow.width) - Math.min(wrapper.x, shadow.x), + height: Math.max(wrapper.y + wrapper.height, shadow.y + shadow.height) - Math.min(wrapper.y, shadow.y), + } + : wrapper; + // Sheet and card presentations size the host to the overlay surface itself. + const fillsViewport = + (overlay as HTMLIonModalElement).presentingElement !== undefined || (overlay as HTMLIonModalElement).breakpoints !== undefined; + return { source, destination: fillsViewport ? { x: 0, y: 0, width: source.width, height: source.height } : source }; +}; diff --git a/src/native/overlays/styles.ts b/src/native/overlays/styles.ts new file mode 100644 index 00000000..42946bef --- /dev/null +++ b/src/native/overlays/styles.ts @@ -0,0 +1,70 @@ +export const sheetText = (sheets: readonly CSSStyleSheet[]): string => + sheets.flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText)).join('\n'); + +export const relayStyles = ( + source: Document, + destination: Document, + overlay: HTMLElement, + verticalBars = false, +): { destination: HTMLElement; stop: () => void } => { + const base = destination.createElement('base'); + base.href = source.baseURI; + destination.head.append(base); + const copies = new Map(); + const sheets = destination.createElement('style'); + destination.head.append(sheets); + const sync = () => { + sheets.textContent = sheetText(source.adoptedStyleSheets); + const sources = Array.from(source.querySelectorAll('meta[name=viewport],link[rel=stylesheet],style:not([data-overlay-relay-local])')); + for (const [node, copy] of copies) + if (!sources.includes(node)) { + copy.remove(); + copies.delete(node); + } + for (const node of sources) { + let copy = copies.get(node); + if (!copy) { + copy = node.cloneNode(true) as Element; + copies.set(node, copy); + } else { + for (const attribute of Array.from(copy.attributes)) copy.removeAttribute(attribute.name); + for (const attribute of Array.from(node.attributes)) copy.setAttribute(attribute.name, attribute.value); + copy.textContent = node.textContent; + } + destination.head.append(copy); + } + destination.head.append(surface); + }; + const surface = destination.createElement('style'); + surface.textContent = 'html,body{background:transparent!important}'; + const observers: MutationObserver[] = []; + const mirror = (node: Element, target: HTMLElement, flatten = false) => { + const update = () => { + for (const attribute of Array.from(target.attributes)) if (!node.hasAttribute(attribute.name)) target.removeAttribute(attribute.name); + for (const attribute of Array.from(node.attributes)) target.setAttribute(attribute.name, attribute.value); + // Centered dialogs and ordinary iPhones keep their Web toolbar layout. + if (!verticalBars && target.localName === 'ion-app') target.classList.remove('ios-theme-vertical-bars'); + if (flatten) target.style.setProperty('display', 'contents', 'important'); + }; + update(); + const observer = new MutationObserver(update); + observer.observe(node, { attributes: true }); + observers.push(observer); + }; + mirror(source.documentElement, destination.documentElement); + mirror(source.body, destination.body); + const ancestors: Element[] = []; + for (let node = overlay.parentElement; node && node !== source.body; node = node.parentElement) ancestors.unshift(node); + let parent = destination.body; + for (const node of ancestors) { + const shell = destination.createElement(node.localName); + mirror(node, shell, true); + parent.append(shell); + parent = shell; + } + sync(); + const observer = new MutationObserver(sync); + observer.observe(source.head, { subtree: true, childList: true, characterData: true, attributes: true }); + observers.push(observer); + return { destination: parent, stop: () => observers.forEach((item) => item.disconnect()) }; +}; diff --git a/src/native/overlays/vertical-bars.ts b/src/native/overlays/vertical-bars.ts new file mode 100644 index 00000000..7e3a9c92 --- /dev/null +++ b/src/native/overlays/vertical-bars.ts @@ -0,0 +1,14 @@ +import type { NativeUIShellOptions, NativeUIShellPlugin } from '../definitions'; +import { prehideVerticalBarsToolbarSources } from '../prehide'; +import { createRuntime } from '../runtime'; + +/** Reuse toolbar eligibility and activation against the relayed document. */ +export const relayVerticalBars = async (doc: Document, plugin: NativeUIShellPlugin, options: NativeUIShellOptions, overlayId: string) => { + const toolbar = options.controls === undefined || options.controls.toolbar === true; + const prehide = toolbar ? prehideVerticalBarsToolbarSources(doc) : undefined; + const runtime = await createRuntime(doc, plugin, { ...options, controls: { toolbar } }, () => true, true, overlayId); + return async () => { + await runtime.destroy(); + prehide?.stop(); + }; +}; diff --git a/src/native/prehide.ts b/src/native/prehide.ts index 5118bdbd..3440709f 100644 --- a/src/native/prehide.ts +++ b/src/native/prehide.ts @@ -138,7 +138,8 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () Array.from(scopes.keys()).forEach(release); return; } - for (const scope of scopes.keys()) if (!scope.isConnected || !modalUsesVerticalBars(scope)) release(scope); + for (const scope of scopes.keys()) + if (scope.ownerDocument !== doc || !scope.isConnected || !modalUsesVerticalBars(scope)) 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. diff --git a/src/native/runtime.ts b/src/native/runtime.ts index b68c43bb..d6f40e37 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -1,3 +1,4 @@ +import { projectionIds } from './shared/projection-id'; import { inVerticalBarsSurface, topModal, modalUsesVerticalBars, modalVerticalBarFrame } from './shared/modal'; import type { PluginListenerHandle } from '@capacitor/core'; import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from '@ionic/core'; @@ -33,7 +34,7 @@ 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 => +export const bounded = (promise: Promise): Promise => new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Native UI Shell bridge timed out')), 5000); promise.then(resolve, reject).finally(() => clearTimeout(timer)); @@ -45,7 +46,8 @@ export const createRuntime = async ( options: NativeUIShellOptions = {}, nativeVerticalBars: () => boolean = () => true, verticalBarsOnly = false, -): Promise => { + overlayId?: string, +): Promise () => void }> => { const win = doc.defaultView!; const icons = createIconRenderer(); const crossfade = createCrossfade(win); @@ -75,6 +77,7 @@ export const createRuntime = async ( let dirty = false; let pending = false; let stopped = false; + let retentions = 0; /** True while a tab-switch handoff should skip crossfade. */ let tabSwitchHandoff = false; /** Keep instant updates until this time — DidLeave often precedes the retire sync. */ @@ -106,6 +109,7 @@ export const createRuntime = async ( if (!value) { value = `shell-${++nextId}`; ids.set(element, value); + projectionIds.set(element, value); } return value; }; @@ -300,8 +304,11 @@ export const createRuntime = async ( dirty = false; pending = true; handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalPresentHandoff || win.performance.now() < handoffUntil; - modalPresentHandoff = false; try { + // A retained sync performs no update; keep the modal handoff armed so the + // deferred retirement still runs instantly after the overlay releases. + if (retentions && !manualSuspensions.size) return; + modalPresentHandoff = false; const size = `${win.innerWidth}:${win.innerHeight}`; // WebKit can resize before Ionic's fixed DOM positions catch up. // Measure on the next frame instead of retiring a valid native search. @@ -323,7 +330,7 @@ export const createRuntime = async ( ); } } - if (stopped || dirty) return; + if (stopped || dirty || (retentions && !manualSuspensions.size)) return; const retained = new Set(candidates.flatMap(candidateSources)); const removed = Array.from(sources.keys()).filter((element) => !retained.has(element)); if (removed.length) { @@ -352,13 +359,13 @@ export const createRuntime = async ( }; const serialized = JSON.stringify(data); if (serialized === lastSnapshot && !forceRefresh) return; - const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) }; + const snapshot: ShellSnapshot = { ...data, overlayId, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) }; // A native visibility notification during this update must survive its ack. forceRefresh = false; updates++; updatingRevision = snapshot.revision; const result = await bounded(plugin.update(snapshot)); - if (stopped) return; + if (stopped || (retentions && !manualSuspensions.size)) return; if (result.revision !== snapshot.revision) throw new Error('Native UI Shell revision mismatch'); if (`${win.innerWidth}:${win.innerHeight}` !== size) { lastSnapshot = ''; @@ -633,7 +640,15 @@ export const createRuntime = async ( on(win.visualViewport, 'resize', refreshLayout); on(win.visualViewport, 'scroll'); } - const handle: NativeUIShellHandle = { + const handle: NativeUIShellHandle & { retain: () => () => void } = { + // A native overlay covers this surface; keep its controls and Web ownership intact. + retain() { + retentions++; + return () => { + retentions--; + schedule(); + }; + }, getStatus: (): NativeUIShellStatus => ({ state: stopped ? 'stopped' : sources.size ? 'native' : 'web', projected: sources.size, @@ -671,9 +686,10 @@ export const createRuntime = async ( search.destroy(); crossfade.destroy(); restoreAll(); - if (!doc.hidden) await painted(); + // A relayed surface closes with its native controller, not a Web crossfade. + if (!doc.hidden && !overlayId) await painted(); try { - await bounded(plugin.clear({ revision: ++revision })); + await bounded(plugin.clear({ revision: ++revision, overlayId })); } catch { /* Always restore the Web, even after bridge loss. */ } @@ -694,6 +710,7 @@ export const createRuntime = async ( if ( stopped || doc.hidden || + event.overlayId !== overlayId || event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence || @@ -726,7 +743,15 @@ export const createRuntime = async ( listener = await bounded(plugin.addListener('activate', activate)); searchListener = await bounded( plugin.addListener('search', (event) => { - if (stopped || doc.hidden || event.revision < acceptedRevision || event.revision > revision || overlayOpen()) return; + if ( + event.overlayId !== overlayId || + stopped || + doc.hidden || + event.revision < acceptedRevision || + event.revision > revision || + overlayOpen() + ) + return; search.event(event); }), ); diff --git a/src/native/shared/projection-id.ts b/src/native/shared/projection-id.ts new file mode 100644 index 00000000..9da66b67 --- /dev/null +++ b/src/native/shared/projection-id.ts @@ -0,0 +1,2 @@ +/** Internal identity used to anchor overlays to their native control. */ +export const projectionIds = new WeakMap(); diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index 19dd85dd..c7561c90 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -317,8 +317,8 @@ html.ios-theme-native-ui-shell-prehide visibility: hidden !important; } -ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready, -ion-app.ios-theme-vertical-bars ion-modal.ios-theme-vertical-bars-modal.ios-theme-vertical-bars-toolbar-ready { +// Source layout is shared by native and Web projection; only accepted controls collapse. +ion-app.ios-theme-vertical-bars { ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not( :where(ion-menu *, ion-modal:not(.ios-theme-vertical-bars-modal) *, ion-popover *) ) @@ -348,6 +348,10 @@ ion-app.ios-theme-vertical-bars ion-modal.ios-theme-vertical-bars-modal.ios-them ) { display: none; } +} + +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready, +ion-app.ios-theme-vertical-bars ion-modal.ios-theme-vertical-bars-modal.ios-theme-vertical-bars-toolbar-ready { > ion-back-button.ios-theme-vertical-bars-back-button-projection { position: fixed; z-index: 1001;