diff --git a/demo/e2e/mini-player.spec.ts b/demo/e2e/mini-player.spec.ts new file mode 100644 index 000000000..225693f59 --- /dev/null +++ b/demo/e2e/mini-player.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/main/index/native-ui-extension'); + await page.getByRole('switch', { name: 'Native extension', exact: true }).click(); + await expect(page.getByRole('switch', { name: 'Native extension', exact: true })).toBeChecked(); +}); + +test('playback stays Web-owned, is isolated from opening, and survives tab changes', async ({ page }) => { + const player = page.locator('[data-mini-player]'); + await expect(player).toBeVisible(); + await player.getByRole('button', { name: 'Pause', exact: true }).click(); + await expect(player).toHaveAttribute('data-playing', 'false'); + await expect(page.getByRole('button', { name: 'Close Now Playing' })).not.toBeVisible(); + await page.locator('ion-tab-button[tab="album"]').click(); + await expect(player).not.toBeVisible(); + await page.locator('ion-tab-button[tab="index"]').click(); + await page.getByRole('link', { name: 'Native UI Extension', exact: true }).click(); + await expect(player).toHaveAttribute('data-playing', 'false'); + await player.getByRole('button', { name: 'Open Now Playing' }).click(); + const modal = page.locator('ion-modal'); + await expect(modal.getByText('Paused · 1:24 / 3:45')).toBeVisible(); + await modal.getByRole('button', { name: 'Play', exact: true }).click(); + await modal.getByRole('button', { name: 'Close Now Playing' }).click(); + await expect(player).toHaveAttribute('data-playing', 'true'); +}); + +test('upward swipe opens once and keyboard activation still works after dismissal', async ({ page }) => { + const open = page.locator('[data-player-open]'); + const box = await open.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.move(box!.x + 40, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move(box!.x + 40, box!.y - 60, { steps: 8 }); + await page.mouse.up(); + const close = page.getByRole('button', { name: 'Close Now Playing' }); + await expect(close).toBeVisible(); + await close.click(); + await expect(close).not.toBeVisible(); + await open.focus(); + await page.keyboard.press('Enter'); + await expect(close).toBeVisible(); +}); + +test('disable keeps the Web UI and re-enable preserves playback state', async ({ page }) => { + const player = page.locator('[data-mini-player]'); + await player.getByRole('button', { name: 'Pause', exact: true }).click(); + await page.getByRole('switch', { name: 'Native extension', exact: true }).click(); + await expect(page.getByRole('switch', { name: 'Native extension', exact: true })).not.toBeChecked(); + await expect(player).toBeVisible(); + await expect(player).toHaveAttribute('data-shell', 'disabled'); + await expect(page.getByRole('status')).toHaveText('Extension: Disabled'); + await page.getByRole('switch', { name: 'Native extension', exact: true }).click(); + await expect(page.getByRole('switch', { name: 'Native extension', exact: true })).toBeChecked(); + await expect(player).toBeVisible(); + await expect(player).toHaveAttribute('data-playing', 'false'); +}); + +for (const edge of ['left', 'right'] as const) { + test(`Web player controls stay inside the ${edge} rail safe area`, async ({ page }) => { + await page.locator('ion-app').evaluate((app, edge) => { + app.classList.add('ios-theme-vertical-bars'); + app.classList.toggle('ios-theme-vertical-bars-left', edge === 'left'); + app.style.setProperty('--ios-theme-vertical-bars-native-inset', '80px'); + }, edge); + const player = page.locator('[data-mini-player]'); + const box = await player.boundingBox(); + expect(box).not.toBeNull(); + const width = page.viewportSize()!.width; + expect(box!.x).toBeGreaterThanOrEqual(edge === 'left' ? 92 : 12); + expect(box!.x + box!.width).toBeLessThanOrEqual(width - (edge === 'right' ? 92 : 12)); + expect(box!.height).toBe(48); + await player.getByRole('button', { name: 'Pause', exact: true }).click(); + await expect(player).toHaveAttribute('data-playing', 'false'); + }); +} diff --git a/demo/e2e/native-ui-extensions.spec.ts b/demo/e2e/native-ui-extensions.spec.ts new file mode 100644 index 000000000..35a8ae659 --- /dev/null +++ b/demo/e2e/native-ui-extensions.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from '@playwright/test'; +import { buildSync } from 'esbuild'; + +const bundle = buildSync({ + stdin: { + contents: "export { createRuntime } from '../../src/native/runtime'; export { defineNativeUI } from '../../src/native/extensions';", + resolveDir: __dirname, + }, + bundle: true, + format: 'iife', + globalName: 'extensions', + write: false, +}).outputFiles[0].text; + +test.beforeEach(async ({ page }) => { + await page.setContent( + '
Counter
', + ); + await page.addScriptTag({ content: bundle }); + await page.evaluate(async () => { + const w = window as any; + const source = document.querySelector('[data-counter]')!; + source.querySelector('button')!.addEventListener('click', () => { + source.dataset.count = String(Number(source.dataset.count) + 1); + }); + w.snapshots = []; + w.replies = []; + w.reject = false; + w.badReader = false; + w.externalTarget = false; + w.events = {}; + w.extension = w.extensions.defineNativeUI({ + name: 'app.counter', + selector: '[data-counter]', + read(element: HTMLElement) { + if (w.badReader) throw new Error('Invalid app state'); + return { + props: w.scalarProps ? 42 : { count: Number(element.dataset.count) }, + actions: { increment: w.externalTarget ? document.body : element.querySelector('button') }, + }; + }, + }); + w.plugin = { + async addListener(name: string, listener: Function) { + w.events[name] = listener; + return { remove: async () => delete w.events[name] }; + }, + async update(snapshot: any) { + w.snapshots.push(snapshot); + return { revision: snapshot.revision, rejectedControls: w.reject ? snapshot.controls.map((c: any) => c.id) : [] }; + }, + async clear() {}, + async completeExtensionAction(reply: any) { + w.replies.push(reply); + }, + }; + w.handle = await w.extensions.createRuntime(document, w.plugin, { controls: {}, extensions: [w.extension] }); + w.tap = () => { + const snapshot = w.snapshots.at(-1); + const control = snapshot.controls.find((c: any) => c.kind === 'extension'); + w.events.activate({ + revision: snapshot.revision, + id: control.extension.actions.increment, + sequence: w.replies.length + 1, + requestId: `request-${w.replies.length}`, + }); + }; + }); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); +}); + +test('projects an app-local reader, dispatches to Web once, and synchronizes new props', async ({ page }) => { + await page.evaluate(() => (window as any).tap()); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-count', '1'); + await expect.poll(() => page.evaluate(() => (window as any).replies)).toEqual([{ requestId: 'request-0', accepted: true }]); + await expect.poll(() => page.evaluate(() => JSON.parse((window as any).snapshots.at(-1).controls[0].extension.props).count)).toBe(1); +}); + +test('revalidates disabled actions before dispatch', async ({ page }) => { + await page.evaluate(() => { + document.querySelector('button')!.disabled = true; + (window as any).tap(); + }); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-count', '0'); + await expect.poll(() => page.evaluate(() => (window as any).replies[0]?.accepted)).toBe(false); +}); + +for (const shadow of [false, true]) { + test(`clipped element projections return to Web and recover on ${shadow ? 'shadow' : 'light'} DOM scroll`, async ({ page }) => { + await page.evaluate((shadow) => { + const source = document.querySelector('[data-counter]')!; + const host = document.createElement('div'); + source.before(host); + const scroller = document.createElement('div'); + scroller.id = 'scroller'; + scroller.style.cssText = 'position:absolute;top:100px;left:10px;width:300px;height:80px;overflow:auto'; + source.style.marginTop = '100px'; + const tail = document.createElement('div'); + tail.style.height = '200px'; + if (shadow) { + host.id = 'scroll-host'; + host.attachShadow({ mode: 'open' }).append(scroller); + scroller.append(document.createElement('slot'), tail); + host.append(source); + } else { + host.append(scroller); + scroller.append(source, tail); + } + }, shadow); + const source = page.locator('[data-counter]'); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + // Fully outside, partially clipped, then fully inside the same scroll container. + await page.locator('#scroller').evaluate((element) => { + element.scrollTop = 70; + }); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + await page.locator('#scroller').evaluate((element) => { + element.scrollTop = 100; + }); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => (window as any).tap()); + await expect(source).toHaveAttribute('data-count', '1'); + await page.locator('#scroller').evaluate((element) => { + element.scrollTop = 0; + }); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + }); +} + +for (const failure of ['native-rejection', 'reader-error', 'outside-action', 'invalid-props', 'scalar-props']) { + test(`returns to Web on ${failure} without stopping the shell`, async ({ page }) => { + await page.evaluate((failure) => { + const w = window as any; + w.reject = failure === 'native-rejection'; + w.badReader = failure === 'reader-error'; + w.externalTarget = failure === 'outside-action'; + w.scalarProps = failure === 'scalar-props'; + document.querySelector('[data-counter]')!.dataset.count = failure === 'invalid-props' ? 'NaN' : '2'; + }, failure); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('data-native-ui-shell', ''); + expect(await page.evaluate(() => (window as any).handle.getStatus().state)).not.toBe('stopped'); + }); +} + +test('overlay, manual suspension and teardown restore Web ownership', async ({ page }) => { + await page.evaluate(() => { + const overlay = document.createElement('ion-alert'); + document.body.append(overlay); + overlay.dispatchEvent(new Event('ionAlertWillPresent', { bubbles: true })); + }); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + const overlay = document.querySelector('ion-alert')!; + overlay.dispatchEvent(new Event('ionAlertDidDismiss', { bubbles: true })); + overlay.remove(); + }); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(async () => { + (window as any).lease = await (window as any).handle.suspend(); + }); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => (window as any).lease.resume()); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => (window as any).handle.destroy()); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('aria-hidden', 'true'); + expect(await page.evaluate(() => Object.keys((window as any).events))).toEqual([]); +}); + +test('an accessory waits for its native tab owner and recovers after competing sources disappear', async ({ page }) => { + await page.evaluate(async () => { + const w = window as any; + await w.handle.destroy(); + const app = document.querySelector('ion-app')!; + app.classList.add('ios-theme-vertical-bars'); + const source = document.querySelector('[data-counter]')!; + const tabs = document.createElement('ion-tabs'); + app.append(tabs); + tabs.append(source); + const bar = document.createElement('ion-tab-bar'); + bar.style.cssText = 'display:flex;width:200px;height:50px'; + tabs.append(bar); + w.handle = await w.extensions.createRuntime(document, w.plugin, { + controls: { tabs: true }, + extensions: [{ ...w.extension, placement: 'tab-accessory' }], + }); + }); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + document.querySelector('ion-tab-bar')!.innerHTML = + 'Home'; + }); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + const source = document.querySelector('[data-counter]')!; + const clone = source.cloneNode(true) as HTMLElement; + clone.removeAttribute('data-native-ui-shell'); + clone.removeAttribute('aria-hidden'); + clone.id = 'competing'; + source.after(clone); + }); + await expect(page.locator('[data-counter]').first()).not.toHaveAttribute('data-native-ui-shell', ''); + await expect(page.locator('#competing')).not.toHaveAttribute('data-native-ui-shell', ''); + await page.locator('#competing').evaluate((element) => element.remove()); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); +}); diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png index 0886e70d6..fbd05e94b 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png differ diff --git a/demo/ios/App/App.xcodeproj/project.pbxproj b/demo/ios/App/App.xcodeproj/project.pbxproj index 8008c87fe..7003fe377 100644 --- a/demo/ios/App/App.xcodeproj/project.pbxproj +++ b/demo/ios/App/App.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + AA2010010000000000000001 /* NativeUICounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2010010000000000000002 /* NativeUICounter.swift */; }; + AA2010020000000000000001 /* NativeMiniPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2010020000000000000002 /* NativeMiniPlayer.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; @@ -19,6 +21,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + AA2010010000000000000002 /* NativeUICounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeUICounter.swift; sourceTree = ""; }; + AA2010020000000000000002 /* NativeMiniPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMiniPlayer.swift; sourceTree = ""; }; 0F2D0778A9AC04BB9CF8EA1D /* debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = ""; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; @@ -66,6 +70,8 @@ children = ( 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + AA2010010000000000000002 /* NativeUICounter.swift */, + AA2010020000000000000002 /* NativeMiniPlayer.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -159,6 +165,8 @@ buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + AA2010010000000000000001 /* NativeUICounter.swift in Sources */, + AA2010020000000000000001 /* NativeMiniPlayer.swift in Sources */, 5A8EC910E2334844A592648D /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/demo/ios/App/App/AppDelegate.swift b/demo/ios/App/App/AppDelegate.swift index 64e216c69..4e0e40aa5 100644 --- a/demo/ios/App/App/AppDelegate.swift +++ b/demo/ios/App/App/AppDelegate.swift @@ -7,7 +7,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + do { + try registerNativeUICounter() + try registerNativeMiniPlayer() + } + catch { NSLog("Native UI registration failed: %@", String(describing: error)) } return true } diff --git a/demo/ios/App/App/NativeMiniPlayer.swift b/demo/ios/App/App/NativeMiniPlayer.swift new file mode 100644 index 000000000..28a8317b9 --- /dev/null +++ b/demo/ios/App/App/NativeMiniPlayer.swift @@ -0,0 +1,102 @@ +import SwiftUI +import IonicNativeUIShellPlugin + +private struct MiniPlayerProps: Decodable { + let title: String + let artist: String + let playing: Bool +} + +@available(iOS 26.0, *) +private struct MiniPlayerView: View { + let props: MiniPlayerProps + let actions: NativeUIActions + @State private var busy = false + @State private var showError = false + + var body: some View { + HStack(spacing: 4) { + Button { activate("open") } label: { + HStack(spacing: 10) { + AlbumArtwork() + .frame(width: 32, height: 32) + .clipShape(.rect(cornerRadius: 6)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(props.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(props.artist) + .font(.caption) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(Rectangle()) + } + .layoutPriority(1) + .accessibilityLabel("Open Now Playing") + .accessibilityIdentifier("mini-player-open") + .simultaneousGesture(DragGesture(minimumDistance: 16).onEnded { value in + if value.translation.height < -32 && abs(value.translation.height) > abs(value.translation.width) { + activate("open") + } + }) + Button { activate("toggle") } label: { + Image(systemName: props.playing ? "pause.fill" : "play.fill") + .font(.system(size: 18, weight: .semibold)) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel(props.playing ? "Pause" : "Play") + .accessibilityIdentifier("mini-player-playback") + } + .buttonStyle(.plain) + .disabled(busy) + .padding(.leading, 10) + .padding(.trailing, 2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .glassEffect(.regular, in: .capsule) + .alert("Action unavailable", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { + Text("Please try again.") + } + } + + private func activate(_ name: String) { + guard !busy else { return } + busy = true + Task { @MainActor in + defer { busy = false } + do { try await actions.activate(name) } + catch { showError = true } + } + } +} + +private struct AlbumArtwork: View { + var body: some View { + GeometryReader { geometry in + ZStack { + LinearGradient(colors: [Color(red: 0.13, green: 0.16, blue: 0.35), Color(red: 0.7, green: 0.31, blue: 0.43)], + startPoint: .topLeading, endPoint: .bottomTrailing) + Circle().fill(Color(red: 1, green: 0.76, blue: 0.55)) + .frame(width: geometry.size.width * 0.44) + .offset(x: geometry.size.width * 0.12, y: -geometry.size.height * 0.08) + Rectangle().fill(Color(red: 0.1, green: 0.17, blue: 0.27)) + .frame(height: geometry.size.height * 0.38) + .frame(maxHeight: .infinity, alignment: .bottom) + } + } + } +} + +@MainActor func registerNativeMiniPlayer() throws { + guard #available(iOS 26.0, *) else { return } + try NativeUIShell.register("demo.mini-player") { (props: MiniPlayerProps, actions: NativeUIActions) in + MiniPlayerView(props: props, actions: actions) + } +} diff --git a/demo/ios/App/App/NativeUICounter.swift b/demo/ios/App/App/NativeUICounter.swift new file mode 100644 index 000000000..a2294058a --- /dev/null +++ b/demo/ios/App/App/NativeUICounter.swift @@ -0,0 +1,43 @@ +import SwiftUI +import IonicNativeUIShellPlugin + +private struct CounterProps: Decodable { + let count: Int +} + +private struct CounterView: View { + let props: CounterProps + let actions: NativeUIActions + let accessory: Bool + @State private var busy = false + @State private var error: String? + + var body: some View { + HStack { + Text("\(accessory ? "Accessory count" : "Count"): \(props.count)") + Button(accessory ? "Increment native accessory" : "Increment native counter") { + busy = true + Task { @MainActor in + defer { busy = false } + do { + try await actions.activate("increment") + error = nil + } catch { + self.error = "Action unavailable" + } + } + } + .disabled(busy) + if let error { Text(error).font(.caption) } + } + .padding(.horizontal) + } +} + +@MainActor func registerNativeUICounter() throws { + for name in ["demo.counter", "demo.accessory-counter"] { + try NativeUIShell.register(name) { (props: CounterProps, actions: NativeUIActions) in + CounterView(props: props, actions: actions, accessory: name == "demo.accessory-counter") + } + } +} diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index 6e34b4b35..c7e030031 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -604,6 +604,92 @@ final class NativeUIShellTests: XCTestCase { } } + func testAppLocalExtensionsFollowWebStateAndOverlayOwnership() throws { + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + for vertical in [false, true] { + app.launch() + let toggle = app.webViews.switches["iPhone Duo Mode"] + XCTAssertTrue(toggle.waitForExistence(timeout: 15), app.debugDescription) + if (toggle.value as? String == "1") != vertical { toggle.tap() } + openPage(app, name: "native-ui-shell") + let counter = app.buttons["Increment native counter"].firstMatch + XCTAssertTrue(counter.waitForExistence(timeout: 10), app.debugDescription) + for count in 1...3 { + counter.tap() + XCTAssertTrue(app.staticTexts["Count: \(count)"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + } + if vertical { + let accessory = app.buttons["Increment native accessory"].firstMatch + XCTAssertTrue(accessory.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(accessory.isHittable, app.debugDescription) + accessory.tap() + XCTAssertTrue(app.staticTexts["Accessory count: 1"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + } else { + XCTAssertTrue(app.webViews.buttons["Increment accessory"].firstMatch.exists, app.debugDescription) + } + app.webViews.buttons["Open modal"].firstMatch.tap() + XCTAssertTrue(app.webViews.buttons["Close modal"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(counter.waitForNonExistence(timeout: 5), app.debugDescription) + app.webViews.buttons["Close modal"].firstMatch.tap() + XCTAssertTrue(counter.waitForExistence(timeout: 10), app.debugDescription) + counter.tap() + XCTAssertTrue(app.staticTexts["Count: 4"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + capture(vertical ? "app-extension-accessory" : "app-extension-element") + app.terminate() + } + } + + func testAppLocalMiniPlayerPlaybackAndPresentation() throws { + try verifyMiniPlayer(vertical: false) + } + + func testAppLocalMiniPlayerWithVerticalBars() throws { + try verifyMiniPlayer(vertical: true) + } + + private func verifyMiniPlayer(vertical: Bool) throws { + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + let toggle = app.webViews.switches["iPhone Duo Mode"] + XCTAssertTrue(toggle.waitForExistence(timeout: 15), app.debugDescription) + if (toggle.value as? String == "1") != vertical { toggle.tap() } + openPage(app, name: "Native UI Extension") + XCTAssertTrue(app.webViews.buttons["Pause"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + let webFrame = app.webViews.buttons["Pause"].firstMatch.frame + capture(vertical ? "web-mini-player-vertical" : "web-mini-player-ordinary") + app.webViews.switches["Native extension"].tap() + let pause = app.buttons.matching(NSPredicate(format: "identifier == %@ AND label == %@", "mini-player-playback", "Pause")).firstMatch + XCTAssertTrue(pause.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertEqual(pause.frame.midX, webFrame.midX, accuracy: 4) + XCTAssertEqual(pause.frame.midY, webFrame.midY, accuracy: 4) + pause.tap() + let play = app.buttons.matching(NSPredicate(format: "identifier == %@ AND label == %@", "mini-player-playback", "Play")).firstMatch + XCTAssertTrue(play.waitForExistence(timeout: 5), app.debugDescription) + let open = app.buttons["mini-player-open"] + XCTAssertTrue(open.exists, app.debugDescription) + capture("swiftui-mini-player-paused") + open.tap() + let close = app.webViews.buttons["Close Now Playing"].firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(open.waitForNonExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(app.webViews.staticTexts["Paused · 1:24 / 3:45"].firstMatch.exists, app.debugDescription) + app.webViews.buttons["Play"].firstMatch.tap() + close.tap() + XCTAssertTrue(pause.waitForExistence(timeout: 10), app.debugDescription) + let start = open.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -90))) + XCTAssertTrue(close.waitForExistence(timeout: 5), app.debugDescription) + close.tap() + XCTAssertTrue(pause.waitForExistence(timeout: 10), app.debugDescription) + app.webViews.switches["Native extension"].tap() + XCTAssertTrue(pause.waitForNonExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(app.webViews.buttons["Pause"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + app.webViews.switches["Native extension"].tap() + XCTAssertTrue(pause.waitForExistence(timeout: 10), app.debugDescription) + capture("swiftui-mini-player-restored") + app.terminate() + } + 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] diff --git a/demo/src/app/index/index-page.component.html b/demo/src/app/index/index-page.component.html index 6552938b3..40454cdb6 100644 --- a/demo/src/app/index/index-page.component.html +++ b/demo/src/app/index/index-page.component.html @@ -36,6 +36,9 @@ native-ui-shell (Preview) + + Native UI Extension + button-projection diff --git a/demo/src/app/index/index.routes.ts b/demo/src/app/index/index.routes.ts index 5f0b056c2..d74b1622d 100644 --- a/demo/src/app/index/index.routes.ts +++ b/demo/src/app/index/index.routes.ts @@ -2,6 +2,10 @@ import { Routes } from '@angular/router'; import { IndexPageComponent } from './index-page.component'; export const routes: Routes = [ + { + path: 'native-ui-extension', + loadComponent: () => import('../mini-player/native-ui-extension.page').then((m) => m.NativeUIExtensionPage), + }, { path: 'button-projection', loadComponent: () => import('../button-projection/button-projection.page').then((m) => m.ButtonProjectionPage), diff --git a/demo/src/app/mini-player/mini-player-demo.ts b/demo/src/app/mini-player/mini-player-demo.ts new file mode 100644 index 000000000..ac93d9d5c --- /dev/null +++ b/demo/src/app/mini-player/mini-player-demo.ts @@ -0,0 +1,6 @@ +import { Injectable, signal } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class MiniPlayerDemo { + readonly enabled = signal(false); +} diff --git a/demo/src/app/mini-player/mini-player.component.html b/demo/src/app/mini-player/mini-player.component.html new file mode 100644 index 000000000..dea7ad67b --- /dev/null +++ b/demo/src/app/mini-player/mini-player.component.html @@ -0,0 +1,33 @@ +
+ + + + + Now Playing + + Close + + + + +
+ Album artwork +

{{ title }}

+

{{ artist }}

+

{{ playing() ? 'Playing' : 'Paused' }} · 1:24 / 3:45

+ + {{ playing() ? 'Pause' : 'Play' }} + +
+
+
+
diff --git a/demo/src/app/mini-player/mini-player.component.scss b/demo/src/app/mini-player/mini-player.component.scss new file mode 100644 index 000000000..55200f817 --- /dev/null +++ b/demo/src/app/mini-player/mini-player.component.scss @@ -0,0 +1,38 @@ +:host { + display: block; + padding: 6px 12px; + padding-left: calc( + var(--player-edge-gap, 12px) + max(env(safe-area-inset-left, 0px), var(--ios-theme-vertical-bars-safe-area-left-resolved, 0px)) + ); + padding-right: calc( + var(--player-edge-gap, 12px) + max(env(safe-area-inset-right, 0px), var(--ios-theme-vertical-bars-safe-area-right-resolved, 0px)) + ); + margin-bottom: calc(72px + var(--ion-safe-area-bottom, 0px)); +} +:host-context(ion-app.ios-theme-vertical-bars) { + --player-edge-gap: 48px; + margin-bottom: var(--ion-safe-area-bottom, 0px); +} +:host([hidden]) { + display: none; +} +.cover { + width: min(100%, 320px); + border-radius: 20px; +} + +.now-playing { + max-width: 360px; + margin: 24px auto; + text-align: center; + + h2 { + margin-bottom: 8px; + } + p { + color: var(--ion-color-medium); + } + ion-button { + margin-top: 24px; + } +} diff --git a/demo/src/app/mini-player/mini-player.component.ts b/demo/src/app/mini-player/mini-player.component.ts new file mode 100644 index 000000000..1f862a954 --- /dev/null +++ b/demo/src/app/mini-player/mini-player.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, AfterViewInit, Component, DestroyRef, ElementRef, inject, signal, viewChild } from '@angular/core'; +import { MiniPlayerDemo } from './mini-player-demo'; +import { mountNowPlaying } from '../../native-ui/mini-player'; +import { IonButton, IonButtons, IonContent, IonHeader, IonModal, IonTitle, IonToolbar } from '@demo/ionic'; + +@Component({ + selector: 'app-mini-player', + imports: [IonButton, IonButtons, IonContent, IonHeader, IonModal, IonTitle, IonToolbar], + templateUrl: './mini-player.component.html', + styleUrl: './mini-player.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MiniPlayerComponent implements AfterViewInit { + readonly demo = inject(MiniPlayerDemo); + readonly source = viewChild.required>('source'); + readonly #destroyRef = inject(DestroyRef); + + ngAfterViewInit() { + this.#destroyRef.onDestroy(mountNowPlaying(this.source().nativeElement)); + } + + readonly playing = signal(true); + readonly opened = signal(false); + readonly title = 'Midnight Drive'; + readonly artist = 'The Coast'; +} diff --git a/demo/src/app/mini-player/native-ui-extension.page.html b/demo/src/app/mini-player/native-ui-extension.page.html new file mode 100644 index 000000000..c99b16672 --- /dev/null +++ b/demo/src/app/mini-player/native-ui-extension.page.html @@ -0,0 +1,64 @@ + + + + Native UI Extension + + + +
+
+

App-local SwiftUI

+

One player, two renderers

+

Now Playing demonstrates how your Web UI can become a native SwiftUI view, using the same state and actions.

+
+ +
+

1. Try Now Playing

+
+ Native extension +

Extension: {{ demo.enabled() ? 'Enabled' : 'Disabled' }}

+

Off: Web. On: SwiftUI on supported iOS; Web elsewhere. Ordinary tabs and Duo Mode both work.

+
+
    +
  1. Use the player at the bottom to pause or resume.
  2. +
  3. Switch the toggle. Playback state stays the same.
  4. +
  5. Tap the track or swipe up to open the Web modal. Close it using the button at the top left.
  6. +
+

UI demo only. No audio is played.

+
+ +
+

2. Shared state and actions

+
+
Web owns the state
+
data-title, data-artist and data-playing supply both renderers.
+
SwiftUI sends actions back
+
Play and open activate the original Web actions. The app updates its state and attributes.
+
Layout stays with the app
+
This example uses element projection: SwiftUI follows the Web element's position and size.
+
+
+ +
+

3. Build your own

+

Keep the Web renderer, DOM reader and SwiftUI view in your project. No separate npm package or Capacitor plugin is needed.

+
    +
  1. Read your element with defineNativeUI().
  2. +
  3. Register the matching view with NativeUIShell.register().
  4. +
  5. Pass the definition to enableNativeUIShell().
  6. +
+

+ tab-accessory is an optional placement chosen by your app. This example uses the default + element placement. +

+ +
+
+
diff --git a/demo/src/app/mini-player/native-ui-extension.page.scss b/demo/src/app/mini-player/native-ui-extension.page.scss new file mode 100644 index 000000000..b754f3b10 --- /dev/null +++ b/demo/src/app/mini-player/native-ui-extension.page.scss @@ -0,0 +1,66 @@ +.extension-demo { + max-width: 800px; + margin: 0 auto; + padding: 24px 16px 32px; + line-height: 1.6; + + h1 { + margin: 4px 0 12px; + font-size: 28px; + line-height: 1.2; + } + h2 { + margin: 0 0 16px; + font-size: 20px; + } + section { + margin-top: 32px; + } + .eyebrow { + margin: 0; + color: var(--ion-color-medium); + font-size: 13px; + } + .demo-control { + padding: 16px; + border-radius: 16px; + background: var(--ion-color-light); + } + ion-toggle { + width: 100%; + } + [role='status'] { + margin: 12px 0 4px; + font-weight: 600; + } + .hint { + margin: 8px 0 0; + font-size: 14px; + color: var(--ion-color-medium); + } + ol { + padding-inline-start: 24px; + } + li + li { + margin-top: 8px; + } + dt { + margin-top: 16px; + font-weight: 600; + } + dd { + margin: 4px 0 0; + } + code { + font-size: 0.9em; + overflow-wrap: anywhere; + } + nav { + display: grid; + gap: 8px; + } + a { + display: block; + padding: 10px 0; + } +} diff --git a/demo/src/app/mini-player/native-ui-extension.page.ts b/demo/src/app/mini-player/native-ui-extension.page.ts new file mode 100644 index 000000000..4132d261c --- /dev/null +++ b/demo/src/app/mini-player/native-ui-extension.page.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { IonBackButton, IonToggle, IonButtons, IonContent, IonHeader, IonTitle, IonToolbar } from '@demo/ionic'; +import { MiniPlayerDemo } from './mini-player-demo'; + +@Component({ + selector: 'app-native-ui-extension-page', + imports: [IonBackButton, IonToggle, IonButtons, IonContent, IonHeader, IonTitle, IonToolbar], + templateUrl: './native-ui-extension.page.html', + styleUrl: './native-ui-extension.page.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NativeUIExtensionPage { + readonly demo = inject(MiniPlayerDemo); +} diff --git a/demo/src/app/native-ui-shell/native-ui-shell.page.html b/demo/src/app/native-ui-shell/native-ui-shell.page.html index 8ba854c9d..9c8687849 100644 --- a/demo/src/app/native-ui-shell/native-ui-shell.page.html +++ b/demo/src/app/native-ui-shell/native-ui-shell.page.html @@ -32,6 +32,16 @@
+ +

App-local SwiftUI extensions (Web fallback on unsupported hosts):

+
+ Count: {{ counter() }} + +
+
+ Accessory count: {{ accessoryCounter() }} + +

Save count: {{ saves() }} @@ -42,7 +52,6 @@ Selection: {{ selected() }} / Change count: {{ changes() }}

-

Scroll the background to compare transparency and refraction in the top-right button.

diff --git a/demo/src/app/native-ui-shell/native-ui-shell.page.ts b/demo/src/app/native-ui-shell/native-ui-shell.page.ts index 42b4d8113..401e8e46c 100644 --- a/demo/src/app/native-ui-shell/native-ui-shell.page.ts +++ b/demo/src/app/native-ui-shell/native-ui-shell.page.ts @@ -37,6 +37,8 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class NativeUIShellPage { + readonly counter = signal(0); + readonly accessoryCounter = signal(0); readonly saves = signal(0); readonly github = signal(0); readonly refreshes = signal(0); diff --git a/demo/src/app/tabs/tabs.page.html b/demo/src/app/tabs/tabs.page.html index 72b06e0d4..d6bbfb86f 100644 --- a/demo/src/app/tabs/tabs.page.html +++ b/demo/src/app/tabs/tabs.page.html @@ -26,6 +26,7 @@
+ diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index c6db3f9a7..475e23ca4 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit, viewChild } from '@angular/core'; +import { AfterViewInit, Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit, signal, viewChild } from '@angular/core'; import { IonContent, IonIcon, @@ -21,6 +21,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { registeredEffect, registerTabBarEffect } from '../../../../src'; import { applyFoldStateClasses } from '../../../../src/vertical-bars'; import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; +import { MiniPlayerComponent } from '../mini-player/mini-player.component'; import { Capacitor } from '@capacitor/core'; @Component({ @@ -28,6 +29,7 @@ import { Capacitor } from '@capacitor/core'; templateUrl: 'tabs.page.html', styleUrls: ['tabs.page.scss'], imports: [ + MiniPlayerComponent, IonTabs, IonTabBar, IonTabButton, @@ -49,6 +51,7 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, #hingeListener?: { remove(): Promise }; readonly #destroyRef = inject(DestroyRef); readonly registeredGestures: registeredEffect[] = []; + readonly showMiniPlayer = signal(false); ngOnInit() { this.#router.events .pipe( @@ -61,6 +64,7 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, return; } const path = params.urlAfterRedirects.split(/[?#]/, 1)[0]; + this.showMiniPlayer.set(path === '/main/index/native-ui-extension'); if (['/main/settings', '/main/index/toolbar', '/main/index/button-projection'].includes(path)) { tabBar.classList.add('tab-bar-hidden'); } else { diff --git a/demo/src/assets/mini-player-artwork.svg b/demo/src/assets/mini-player-artwork.svg new file mode 100644 index 000000000..61d549c12 --- /dev/null +++ b/demo/src/assets/mini-player-artwork.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/demo/src/main.ts b/demo/src/main.ts index 4680659d7..72c5b032d 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -1,3 +1,5 @@ +import { miniPlayer } from './native-ui/mini-player'; +import { counter, accessoryCounter } from './native-ui/counter'; import { bootstrapApplication } from '@angular/platform-browser'; import { createAppConfig, type IonicAnimationOptions } from './app/app.config'; import { AppComponent } from './app/app.component'; @@ -46,6 +48,7 @@ const startShell = 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({ + extensions: [counter, accessoryCounter, miniPlayer], buttonProjection, buttonDefaultFill, controls: { tabs: true, toolbar: true, segment: true, fab: true, popover: true }, diff --git a/demo/src/native-ui-extensions.spec.ts b/demo/src/native-ui-extensions.spec.ts new file mode 100644 index 000000000..fa9956ae4 --- /dev/null +++ b/demo/src/native-ui-extensions.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest'; +import { defineNativeUI, enableNativeUIShell } from '../../src/native'; + +const counter = () => defineNativeUI({ name: 'app.counter', selector: '[data-counter]', read: () => ({ props: { count: 0 } }) }); + +test('local extensions are stable configuration and require teardown before replacement', async () => { + const extension = counter(); + const first = await enableNativeUIShell({ extensions: [extension] }); + try { + expect(await enableNativeUIShell({ extensions: [extension] })).toBe(first); + await expect(enableNativeUIShell({ extensions: [counter()] })).rejects.toThrow('different controls'); + await expect(enableNativeUIShell()).rejects.toThrow('different controls'); + } finally { + await first.destroy(); + } + const replacement = await enableNativeUIShell({ extensions: [counter()] }); + await replacement.destroy(); +}); + +test('duplicate names and invalid selectors reject before acquiring shell resources', async () => { + await expect(enableNativeUIShell({ extensions: [counter(), counter()] })).rejects.toThrow('unique'); + await expect(enableNativeUIShell({ extensions: [{ ...counter(), selector: '[' }] })).rejects.toThrow(); + const handle = await enableNativeUIShell({ extensions: [counter()] }); + await handle.destroy(); +}); diff --git a/demo/src/native-ui/counter.ts b/demo/src/native-ui/counter.ts new file mode 100644 index 000000000..ea9dd9cb1 --- /dev/null +++ b/demo/src/native-ui/counter.ts @@ -0,0 +1,14 @@ +import { defineNativeUI, type NativeUIExtension } from '../../../src/native'; + +const read: NativeUIExtension['read'] = (element) => ({ + props: { count: Number(element.getAttribute('data-count') ?? 0) }, + actions: { increment: element.querySelector('button') }, +}); + +export const counter = defineNativeUI({ name: 'demo.counter', selector: '[data-native-counter]', read }); +export const accessoryCounter = defineNativeUI({ + name: 'demo.accessory-counter', + selector: '[data-native-accessory-counter]', + placement: 'tab-accessory', + read, +}); diff --git a/demo/src/native-ui/mini-player.spec.ts b/demo/src/native-ui/mini-player.spec.ts new file mode 100644 index 000000000..382ee1c3c --- /dev/null +++ b/demo/src/native-ui/mini-player.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from 'vitest'; +import { miniPlayer, mountNowPlaying } from './mini-player'; + +test('plain data attributes drive both renderers and Web actions without native', async () => { + const source = document.createElement('section'); + source.setAttribute('data-mini-player', ''); + source.setAttribute('data-title', 'Track'); + source.setAttribute('data-artist', 'Artist'); + source.setAttribute('data-playing', 'true'); + document.body.append(source); + const destroy = mountNowPlaying(source); + try { + expect(source.querySelector('strong')?.textContent).toBe('Track'); + expect(source.querySelector('strong b')).toBeNull(); + expect(miniPlayer.read(source)?.props).toEqual({ title: 'Track', artist: 'Artist', playing: true }); + let opens = 0; + let toggles = 0; + source.addEventListener('openNowPlaying', () => { + opens++; + }); + source.addEventListener('togglePlayback', () => { + toggles++; + source.setAttribute('data-playing', 'false'); + }); + const actions = miniPlayer.read(source)!.actions!; + actions['toggle']!.click(); + await vi.waitFor(() => expect(actions['toggle']!.getAttribute('aria-label')).toBe('Play')); + expect(toggles).toBe(1); + expect(opens).toBe(0); + expect(miniPlayer.read(source)?.props['playing']).toBe(false); + actions['open']!.click(); + expect(opens).toBe(1); + destroy(); + actions['open']!.click(); + expect(opens).toBe(1); + expect(source.childNodes).toHaveLength(0); + const remount = mountNowPlaying(source); + expect(source.querySelector('[data-player-toggle]')?.getAttribute('aria-label')).toBe('Play'); + remount(); + } finally { + destroy(); + source.remove(); + } +}); diff --git a/demo/src/native-ui/mini-player.ts b/demo/src/native-ui/mini-player.ts new file mode 100644 index 000000000..35d8bd766 --- /dev/null +++ b/demo/src/native-ui/mini-player.ts @@ -0,0 +1,113 @@ +import { defineNativeUI } from '../../../src/native'; + +const props = (element: HTMLElement) => ({ + title: element.getAttribute('data-title') ?? '', + artist: element.getAttribute('data-artist') ?? '', + playing: element.getAttribute('data-playing') === 'true', +}); + +/** The Web element is the source and fallback for the matching app-local SwiftUI View. */ +export const miniPlayer = defineNativeUI({ + name: 'demo.mini-player', + selector: '[data-mini-player]', + read(element) { + return { + props: props(element), + actions: { + open: element.querySelector('[data-player-open]'), + toggle: element.querySelector('[data-player-toggle]'), + }, + }; + }, +}); + +/** Render an empty ordinary element from data-* state. Return the matching teardown. */ +export function mountNowPlaying(element: HTMLElement): () => void { + if (element.hasChildNodes()) throw new Error('Now Playing needs an empty render target'); + const listeners = new AbortController(); + const { signal } = listeners; + let start: { id: number; x: number; y: number } | undefined; + let swiped = false; + // Only static markup is HTML. Application values are assigned with textContent. + element.innerHTML = ` + + + `; + const children = Array.from(element.childNodes); + const open = element.querySelector('[data-player-open]')!; + const toggle = element.querySelector('[data-player-toggle]')!; + const emitOpen = () => element.dispatchEvent(new CustomEvent('openNowPlaying', { bubbles: true })); + open.addEventListener( + 'click', + (event) => { + if (!swiped || event.detail === 0) emitOpen(); + swiped = false; + }, + { signal }, + ); + toggle.addEventListener( + 'click', + () => { + element.dispatchEvent(new CustomEvent('togglePlayback', { bubbles: true })); + }, + { signal }, + ); + open.addEventListener( + 'pointerdown', + (event) => { + if (!event.isPrimary || event.button !== 0) return; + swiped = false; + start = { id: event.pointerId, x: event.clientX, y: event.clientY }; + open.setPointerCapture(event.pointerId); + }, + { signal }, + ); + open.addEventListener( + 'pointerup', + (event) => { + if (start?.id !== event.pointerId) return; + const dy = event.clientY - start.y; + swiped = dy < -32 && Math.abs(dy) > Math.abs(event.clientX - start.x); + start = undefined; + if (swiped) emitOpen(); + }, + { signal }, + ); + open.addEventListener( + 'pointercancel', + () => { + start = undefined; + }, + { signal }, + ); + const render = () => { + const state = props(element); + element.querySelector('strong')!.textContent = state.title; + element.querySelector('small')!.textContent = state.artist; + toggle.setAttribute('aria-label', state.playing ? 'Pause' : 'Play'); + element.querySelector('path')!.setAttribute('d', state.playing ? 'M6 4h4v16H6zm8 0h4v16h-4z' : 'M7 3v18l15-9z'); + }; + const observer = new MutationObserver(render); + observer.observe(element, { attributes: true, attributeFilter: ['data-title', 'data-artist', 'data-playing'] }); + render(); + return () => { + observer.disconnect(); + listeners.abort(); + children.forEach((child) => child.parentNode === element && child.remove()); + }; +} diff --git a/docs/native-mini-player.md b/docs/native-mini-player.md new file mode 100644 index 000000000..f95871188 --- /dev/null +++ b/docs/native-mini-player.md @@ -0,0 +1,43 @@ +--- +title: Native UI Extension: Now Playing +--- + +# Native UI Extension: Now Playing + +The demo's Index → Native UI Extension page (below native-ui-shell) reimplements the mini-player proposed in PR #248 using the [app-local extension API](./native-ui-extensions.md). Its implementation lives in the application; there is no mini-player component or playback API in the theme package. + +- [Web component](../demo/src/app/mini-player/mini-player.component.ts): owns playback state and the Now Playing modal. Play/pause is separate from opening the player. Tap or swipe up on the track opens the same modal. +- [Web renderer + DOM reader](../demo/src/native-ui/mini-player.ts): renders the Web UI from attributes, sends title, artist and playback state to SwiftUI, and maps `open` / `toggle` to the original Web buttons. +- [SwiftUI View](../demo/ios/App/App/NativeMiniPlayer.swift): uses `Button`, SF Symbols, `DragGesture`, and a capsule glass background. It awaits `NativeUIActions.activate()` rather than duplicating playback state or presenting its own modal. + +Register the View once in the app delegate and pass `miniPlayer` in `enableNativeUIShell({ extensions: [miniPlayer] })`. Copy these app-local files to adapt the sample to your own playback state and artwork. The original sunset artwork is drawn with SwiftUI shapes, with a matching local SVG for the Web. The compact player shows only artwork, track metadata and playback; elapsed time stays in Now Playing. + +The sample uses the default **element projection**: SwiftUI follows the Web element’s position and size on iOS 26+, with ordinary tabs or Vertical Bars. Browsers and unsupported environments retain the Web player. Use the **Native extension** toggle to switch native projection on or off. The player starts in Web mode; disabling the extension restores its Web UI. It is shown only on this demo page; leaving the page hides it without resetting playback state. + +The Web fallback reserves its own bottom-slot space and keeps play state across tab changes. Opening Now Playing retires the native player through the shell's existing overlay policy; dismissing it restores the current Web state. + +This is a UI example: elapsed time is fixed and no audio engine is started. Applications own playback and reflect current values into the Web DOM. This implementation replaces #248's dedicated UIKit accessory/controller and theme-level mini-player API; it does not include that PR's unrelated segment/browser-support changes or add a system accessory host for ordinary UIKit tabs. + +## Use the Web + SwiftUI set in your application + +The local TypeScript file includes both a renderer for an ordinary DOM element and its native reader. Copy it together with `NativeMiniPlayer.swift`; in your app, replace the demo’s relative `defineNativeUI` import with `@rdlabo/ionic-theme-ios27/native`. The Web renderer works without Angular, Capacitor or an enabled Native UI Shell; SwiftUI replaces it only after native accepts the projection. + +```ts +import { miniPlayer, mountNowPlaying } from './native-ui/mini-player'; +import { enableNativeUIShell } from '@rdlabo/ionic-theme-ios27/native'; + +const player = document.querySelector('[data-mini-player]')!; +const destroyWeb = mountNowPlaying(player); // Run on every platform, including the Web. +player.addEventListener('togglePlayback', () => { + player.setAttribute('data-playing', String(player.getAttribute('data-playing') !== 'true')); +}); +player.addEventListener('openNowPlaying', () => openNowPlaying()); +await enableNativeUIShell({ extensions: [miniPlayer] }); +// Call destroyWeb() when the application's source element is destroyed. +``` + +```html +
+``` + +The sample does not specify `placement`; the default is `'element'`. Applications can opt into `placement: 'tab-accessory'` when they want a system tab accessory; see the [placement options and host requirements](./native-ui-extensions.md#content-and-placement-are-independent). Its surrounding Web layout belongs to the application. The same attributes feed the Web view and SwiftUI; both dispatch the same events. The application updates attributes after its playback state changes. Mount into an empty element. The Web renderer owns only its generated children and releases its observer and handlers on teardown. Frameworks own the host's attributes, not those children. Text is rendered as text, never interpreted as HTML. diff --git a/docs/native-ui-extensions.md b/docs/native-ui-extensions.md new file mode 100644 index 000000000..aa595d314 --- /dev/null +++ b/docs/native-ui-extensions.md @@ -0,0 +1,108 @@ +--- +title: App-local Native UI extensions +--- + +# App-local Native UI extensions + +An extension projects your existing Web UI into a SwiftUI View. Keep both definitions in your application: no npm package, separate library, or Capacitor plugin is required. The Web element remains the source of truth and the fallback UI. + +## 1. Define a DOM reader + +```ts +// src/native-ui/counter.ts +import { defineNativeUI } from '@rdlabo/ionic-theme-ios27/native'; + +export const counter = defineNativeUI({ + name: 'app.counter', + selector: '[data-counter]', + read(element) { + return { + props: { count: Number(element.getAttribute('data-count') ?? 0) }, + actions: { increment: element.querySelector('button') }, + }; + }, +}); +``` + +For example, render `
` with your framework. Its click handler updates the Web state and `data-count` attribute. The shell observes DOM changes and sends the new props to SwiftUI. State held only in JavaScript variables must first be reflected in the DOM. + +The reader is synchronous and read-only. Return `null` when the source should stay on the Web. Props are a JSON object (finite numbers, strings, booleans, null, arrays and objects), limited to 64K characters. Action names map to the source itself or its descendants; missing, hidden and disabled targets cannot be activated. Up to 30 actions are supported per source. + +## 2. Register a SwiftUI View in the app target + +Add a Swift file to the application's Xcode target. Import `IonicNativeUIShellPlugin`, the module supplied by the theme's existing Capacitor installation. + +```swift +import SwiftUI +import IonicNativeUIShellPlugin + +struct CounterProps: Decodable { + let count: Int +} + +struct CounterView: View { + let props: CounterProps + let actions: NativeUIActions + @State private var error: String? + + var body: some View { + HStack { + Text("Count: \(props.count)") + Button("Increment") { + Task { @MainActor in + do { + try await actions.activate("increment") + error = nil + } catch { + self.error = "Action unavailable" + } + } + } + if let error { Text(error) } + } + } +} + +@MainActor func registerAppNativeUI() throws { + try NativeUIShell.register("app.counter") { + (props: CounterProps, actions: NativeUIActions) in + CounterView(props: props, actions: actions) + } +} +``` + +Call `registerAppNativeUI()` once from `application(_:didFinishLaunchingWithOptions:)`, before JavaScript enables the shell. Handle a registration error there; duplicate or empty names are rejected. Swift source is compiled into the app, so adding or changing a View requires an iOS rebuild. Registration is synchronous setup, not a native bridge command. + +## 3. Enable with local definitions + +```ts +import { enableNativeUIShell } from '@rdlabo/ionic-theme-ios27/native'; +import { counter } from './native-ui/counter'; + +const shell = await enableNativeUIShell({ extensions: [counter] }); +``` + +Keep extension objects stable across repeated enable calls. Changing extensions while running requires `await shell.destroy()` first. Extension names must be unique within an activation. Do not register overlapping selectors for the same source. An extension owns its source subtree, so built-in controls inside it are not projected separately. + +## Content and placement are independent + +| `placement` | Behavior | +| --- | --- | +| Omitted or `'element'` | Hosts the View in the source element's Web frame on iOS 26+. | +| `'tab-accessory'` | Attaches the View to the system SwiftUI tab accessory on iOS 26.1+. | + +For `tab-accessory`, place the source inside its owning `ion-tabs`. The shell resolves that tabs element's `ion-tab-bar`; applications do not pass native controller references. Currently only the **Vertical Bars SwiftUI tab host** supports this placement. Ordinary UIKit tab bars, missing native tabs, iOS 26.0, or multiple competing accessories keep the source on the Web. The shell does not approximate a system accessory with a floating overlay. Keep an equivalent Web layout (for example, a fixed footer); the source retains its layout space while hidden. + +`enableVerticalControlArea({ extensions: [...] })` also accepts extensions. In this standalone mode only accessories belonging to native Vertical Bars are eligible; ordinary element projections require `enableNativeUIShell`. + +## Lifecycle and action completion + +The shell owns discovery, geometry, state synchronization, native controller containment, overlay retirement, suspension and teardown. Extensions do not implement attach/detach hooks. Web sources are hidden only after native accepts the projection. An unregistered renderer, incompatible Swift props, unsupported placement or failed reader leaves that instance on the Web. Existing shell overlay policy applies; extensions return to the Web while Web overlays cover the page. + +Element placement requires the source to fit fully inside the viewport and its ancestors' overflow clipping bounds, including Ionic's shadow scroll containers. Partially clipped sources stay on the Web and become eligible again when scrolled fully into view. Tab accessories use the system host's placement instead. + +`try await actions.activate(name)` completes after the original Web element receives its click. It does **not** wait for application work launched by the click handler (network requests, navigation or modal presentation). Disabled, removed or stale actions reject; teardown, Swift task cancellation and bridge timeouts settle pending calls. The bridge acknowledgement is a Capacitor Promise method. + +The [demo reader](../demo/src/native-ui/counter.ts) and [demo SwiftUI View](../demo/ios/App/App/NativeUICounter.swift) show both placements using app-local files. Open the Native UI Shell demo page to compare native projection with its Web fallback. + +For a complete application feature, see the [SwiftUI mini-player example](./native-mini-player.md): artwork, track metadata, playback controls and swipe-to-open, all defined in the demo application. diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 34e0df3ce..b25bdf7ba 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -2,6 +2,8 @@ title: Native UI Shell (Preview) --- +For application-specific SwiftUI content, see [App-local Native UI extensions](native-ui-extensions.md). + # Native UI Shell (Preview) Native UI Shell is available as a **preview** in `1.2.0`. Its API and supported controls may change before stable status. Stable status is planned after the official release of Xcode 27.1. diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift index bb7caeac5..c9e12fee0 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift @@ -13,6 +13,7 @@ enum ShellComponents { case .tabBar: return ShellTabBar.make(node, rendering: rendering, delegate: tabDelegate) case .segment: return ShellSegment.make(node, scale: scale, rendering: rendering, activate: activate) case .fab: return ShellFab() + case .extension: return nil // App-local SwiftUI content is hosted by the shell. } } } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index 78efc3e9a..b4c747fec 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -7,6 +7,7 @@ protocol ShellVerticalBarsControlling: AnyObject { func measuredFrames(relativeTo webView: UIView) -> [String: CGRect] @MainActor func attach(to owner: UIViewController, in parent: UIView) @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String, animated: Bool) + @MainActor func setAccessory(_ content: AnyView?) var ownsKeyboardChrome: Bool { get } func presentPopover(anchor: String, host: ShellOverlayHost, completion: @escaping (Bool) -> Void) -> Bool @MainActor func detach(duration: TimeInterval) @@ -34,6 +35,7 @@ final class ShellVerticalBarsModel: ObservableObject { let slot: ShellControl.ToolbarSlot? } + @Published var accessory: AnyView? @Published var popoverAnchor: String? var popoverHost: ShellOverlayHost? var popoverPresented: ((Bool) -> Void)? @@ -235,6 +237,7 @@ private struct ShellVerticalBarsView: View { .accessibilityIdentifier(item.id) } } + .modifier(ShellExtensionAccessory(content: model.accessory)) } } .modifier(ShellVerticalBarsSearch(model: model)) @@ -438,6 +441,8 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { private weak var owner: UIViewController? private let changed: (String, ShellSearchPhase, String, Bool, Int) -> Int + func setAccessory(_ content: AnyView?) { model.accessory = content } + var view: UIView { container } func measuredFrames(relativeTo webView: UIView) -> [String: CGRect] { var frames: [String: CGRect] = [:] diff --git a/ios/Sources/IonicNativeUIShellPlugin/Extensions/NativeUIShell.swift b/ios/Sources/IonicNativeUIShellPlugin/Extensions/NativeUIShell.swift new file mode 100644 index 000000000..12e3e54e5 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Extensions/NativeUIShell.swift @@ -0,0 +1,128 @@ +import SwiftUI +import UIKit + +/// App-local SwiftUI renderers. Register once before enabling the shell from JavaScript. +@MainActor public enum NativeUIShell { + private static var renderers: [String: (String, NativeUIActions) throws -> AnyView] = [:] + + /// The name must match defineNativeUI in the application's TypeScript source. + public static func register( + _ name: String, @ViewBuilder content: @escaping (Props, NativeUIActions) -> Content + ) throws { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, renderers[name] == nil else { + throw NativeUIError.duplicateRegistration + } + renderers[name] = { json, actions in + let props = try JSONDecoder().decode(Props.self, from: Data(json.utf8)) + return AnyView(content(props, actions)) + } + } + + static var names: [String] { renderers.keys.sorted() } + + static func content(_ data: ShellExtension, actions: NativeUIActions) throws -> AnyView { + guard let renderer = renderers[data.name] else { throw NativeUIError.unavailable } + return try renderer(data.props, actions) + } +} + +public enum NativeUIError: Error { + case duplicateRegistration, unavailable, rejected, timedOut +} + +/// Completion means the original Web element received its click, not that the +/// application's asynchronous work triggered by that click has finished. +@MainActor public struct NativeUIActions { + let dispatch: (String) async throws -> Void + + public func activate(_ name: String) async throws { + try Task.checkCancellation() + try await dispatch(name) + } +} + +struct ShellExtension: Decodable, Equatable { + let name: String + let props: String + let actions: [String: String] + let tabBarId: String? + + var isValid: Bool { + !name.isEmpty && props.utf8.count <= 262144 && actions.count <= 30 && + actions.allSatisfy { !$0.key.isEmpty && !$0.value.isEmpty } && + (tabBarId.map { !$0.isEmpty } ?? true) && + (try? JSONSerialization.jsonObject(with: Data(props.utf8))) is [String: Any] + } +} + +/// Owns pending native -> Web calls independently of the View's lifetime. +@MainActor final class ShellExtensionActions { + private struct Pending { + let owner: String + let continuation: CheckedContinuation + let deadline: DispatchWorkItem + } + private var pending: [String: Pending] = [:] + + func perform(owner: String, timeout: TimeInterval = 4, send: (String) -> Void) async throws { + let request = UUID().uuidString + try await withTaskCancellationHandler { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { continuation in + let deadline = DispatchWorkItem { [weak self] in self?.finish(request, error: NativeUIError.timedOut) } + pending[request] = Pending(owner: owner, continuation: continuation, deadline: deadline) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: deadline) + send(request) + } + } onCancel: { + Task { @MainActor [weak self] in self?.finish(request, error: CancellationError()) } + } + } + + func finish(_ request: String, error: Error? = nil) { + guard let call = pending.removeValue(forKey: request) else { return } + call.deadline.cancel() + if let error { call.continuation.resume(throwing: error) } + else { call.continuation.resume() } + } + + func cancel(owner: String? = nil) { + for (request, call) in pending where owner == nil || call.owner == owner { + finish(request, error: NativeUIError.unavailable) + } + } +} + +/// Normal UIViewController containment keeps SwiftUI appearance and teardown intact. +@MainActor final class ShellExtensionController { + let controller: UIHostingController + var view: UIView { controller.view } + + init(content: AnyView, owner: UIViewController) { + controller = UIHostingController(rootView: content) + controller.view.backgroundColor = .clear + if #available(iOS 16.4, *) { controller.safeAreaRegions = [] } + owner.addChild(controller) + // Capacitor can use WKWebView as its controller's root view. A sibling + // shell surface is outside that controller's containment hierarchy. + owner.view.addSubview(controller.view) + controller.didMove(toParent: owner) + } + + func update(_ content: AnyView) { controller.rootView = content } + func detach(duration: TimeInterval) { + controller.willMove(toParent: nil) + ShellCrossfade.retire(controller.view, duration: duration) + controller.removeFromParent() + } +} + +@available(iOS 26.0, *) +struct ShellExtensionAccessory: ViewModifier { + let content: AnyView? + func body(content base: Content) -> some View { + if #available(iOS 26.1, *) { + base.tabViewBottomAccessory(isEnabled: content != nil) { content } + } else { base } + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index dbf6c015d..cd2fd8555 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -1,5 +1,6 @@ import Capacitor import UIKit +import SwiftUI import WebKit @objc(IonicNativeUIShellPlugin) @@ -7,6 +8,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele public let identifier = "IonicNativeUIShellPlugin" public let jsName = "IonicNativeUIShell" public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "completeExtensionAction", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getVerticalBarsGeometry", returnType: CAPPluginReturnPromise), @@ -23,6 +25,10 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private var verticalBars: ShellVerticalBarsControlling? private var controls: [String: UIView] = [:] private var searchControllers: [String: ShellSearchControlling] = [:] + private var extensionControllers: [String: ShellExtensionController] = [:] + private var extensionSessions: [String: UUID] = [:] + private var extensionNodes: [String: ShellControl] = [:] + private let extensionActions = ShellExtensionActions() private var fingerprints: [String: ShellControl] = [:] private let rendering = ShellRendering() private var configurationGeneration = 0 @@ -53,6 +59,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if !keyboard { self.host?.isHidden = true self.verticalBars?.view.isHidden = true + self.extensionControllers.values.forEach { $0.view.isHidden = true } } var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true self.searchControllers.values.forEach { controller in @@ -188,7 +195,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele effect.isHidden = true self.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden } } - call.resolve(["supported": true]) + call.resolve(["supported": true, "extensions": NativeUIShell.names]) } else { call.resolve(["supported": false]) } } @@ -219,6 +226,36 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } } + @objc func completeExtensionAction(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let request = call.getString("requestId"), let accepted = call.getBool("accepted") else { + call.reject("Invalid extension acknowledgement"); return + } + self?.extensionActions.finish(request, error: accepted ? nil : NativeUIError.rejected) + call.resolve() + } + } + + @MainActor private func extensionContent(_ node: ShellControl) throws -> AnyView { + guard let data = node.extensionContent else { throw NativeUIError.unavailable } + let session = extensionSessions[node.id] ?? UUID() + extensionSessions[node.id] = session + let actions = NativeUIActions { [weak self] name in + guard let self, self.extensionNodes[node.id] == node, self.extensionSessions[node.id] == session, let target = data.actions[name] else { + throw NativeUIError.unavailable + } + try await self.extensionActions.perform(owner: node.id) { request in + self.sequence += 1 + self.notifyListeners("activate", data: ["id": target, "revision": self.revision, + "sequence": self.sequence, "requestId": request]) + } + } + return AnyView(try NativeUIShell.content(data, actions: actions) + .environment(\.layoutDirection, node.rtl ? .rightToLeft : .leftToRight) + .environment(\.colorScheme, node.dark ? .dark : .light) + .id(node.id)) + } + @objc func clear(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in guard let self else { call.resolve(); return } @@ -234,14 +271,23 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } private func removeControl(_ id: String, duration: TimeInterval = 0) { + extensionActions.cancel(owner: id) + extensionNodes.removeValue(forKey: id) + extensionSessions.removeValue(forKey: id) + let extensionController = extensionControllers.removeValue(forKey: id) searchControllers.removeValue(forKey: id)?.detach() - if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) } + let control = controls.removeValue(forKey: id) + if let extensionController { extensionController.detach(duration: duration) } + else if let control { ShellCrossfade.retire(control, duration: duration) } fingerprints.removeValue(forKey: id) pendingTabSelections.removeValue(forKey: id) pendingTabExpiryWorks.removeValue(forKey: id)?.cancel() } private func removeControls(duration: TimeInterval = 0) { + extensionActions.cancel() + extensionNodes.removeAll() + extensionSessions.removeAll() Array(controls.keys).forEach { removeControl($0, duration: duration) } host?.removeFromSuperview() host = nil @@ -297,11 +343,18 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele let duration = ShellCrossfade.duration(snapshot.transitionDuration) let existing = Set(self.controls.keys) let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars } - let snapshots = snapshot.controls.filter { $0.placement != .verticalBars } + let accessories = snapshot.controls.filter { $0.extensionContent?.tabBarId != nil } + let snapshots = snapshot.controls.filter { $0.placement != .verticalBars && $0.extensionContent?.tabBarId == nil } + let extensionIDs = Set(snapshot.controls.filter { $0.kind == .extension }.map(\.id)) + for id in Array(self.extensionNodes.keys) where !extensionIDs.contains(id) { + self.extensionActions.cancel(owner: id) + self.extensionNodes.removeValue(forKey: id) + self.extensionSessions.removeValue(forKey: id) + } let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false } let width = snapshot.viewportWidth self.revision = next - if snapshots.isEmpty && verticalBars.isEmpty { + if snapshots.isEmpty && verticalBars.isEmpty && accessories.isEmpty { self.removeControls(duration: duration) call.resolve(["revision": next]); return } @@ -339,6 +392,22 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } else { rejectedControls.append(contentsOf: verticalBars.map(\.id)) } + // A system tab host has one accessory slot. Ambiguous owners stay on the Web. + var accessoryContent: AnyView? + for node in accessories { + guard #available(iOS 26.1, *), accessories.count == 1, !self.keyboardVisible, self.verticalBars != nil, + verticalBars.filter({ $0.kind == .tabBar }).map(\.id) == [node.extensionContent!.tabBarId!], + let content = try? self.extensionContent(node) else { + rejectedControls.append(node.id) + self.extensionNodes.removeValue(forKey: node.id) + self.extensionSessions.removeValue(forKey: node.id) + self.extensionActions.cancel(owner: node.id) + continue + } + self.extensionNodes[node.id] = node + accessoryContent = content + } + self.verticalBars?.setAccessory(accessoryContent) if snapshots.isEmpty { self.host?.removeFromSuperview() self.host = nil @@ -355,6 +424,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if host.superview !== parent { parent.addSubview(host) } for node in snapshots { let id = node.id + if node.kind != .extension, self.extensionControllers[id] != nil { self.removeControl(id) } let previous = self.controls[id] let previousBounds = previous?.bounds let previousCenter = previous?.center @@ -374,6 +444,28 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele let local = node.frame.rect let bounds = webView.convert(CGRect(x: local.minX * scale, y: local.minY * scale, width: local.width * scale, height: local.height * scale), to: parent) + if node.kind == .extension { + guard let owner = self.bridge?.viewController, let content = try? self.extensionContent(node) else { + self.extensionNodes.removeValue(forKey: id) + self.extensionSessions.removeValue(forKey: id) + self.extensionActions.cancel(owner: id) + reject(); continue + } + if let controller = self.extensionControllers[id] { + controller.update(content) + } else { + if let previous = self.controls.removeValue(forKey: id) { previous.removeFromSuperview() } + self.searchControllers.removeValue(forKey: id)?.detach() + let controller = ShellExtensionController(content: content, owner: owner) + self.extensionControllers[id] = controller + self.controls[id] = controller.view + } + self.extensionNodes[id] = node + self.controls[id]?.frame = parent.convert(bounds, to: owner.view) + self.controls[id]?.isHidden = false + self.controls[id]?.overrideUserInterfaceStyle = node.dark ? .dark : .light + continue + } if let search = node.search { guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue } let controller: ShellSearchControlling diff --git a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift index 58b09ec61..86ffad89d 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift @@ -2,6 +2,7 @@ import Foundation import CoreGraphics enum ShellComponent: String, Decodable { + case `extension` case button = "ion-button", buttons = "ion-buttons" case backButton = "ion-back-button", menuButton = "ion-menu-button" case tabBar = "ion-tab-bar", segment = "ion-segment", fab = "ion-fab" @@ -47,8 +48,12 @@ struct ShellControl: Decodable, Equatable { let rtl: Bool let tabBarAnchor: ShellTabBar.Anchor? let search: ShellSearch? + let extensionContent: ShellExtension? - private enum CodingKeys: String, CodingKey { case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search } + private enum CodingKeys: String, CodingKey { + case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search + case extensionContent = "extension" + } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -62,10 +67,15 @@ struct ShellControl: Decodable, Equatable { rtl = try values.decode(Bool.self, forKey: .rtl) tabBarAnchor = try values.decodeIfPresent(ShellTabBar.Anchor.self, forKey: .tabBarAnchor) search = try values.decodeIfPresent(ShellSearch.self, forKey: .search) + extensionContent = try values.decodeIfPresent(ShellExtension.self, forKey: .extensionContent) } var isValid: Bool { - ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 && + if kind == .extension { + return !id.isEmpty && frame.isValid && items.isEmpty && placement == nil && toolbarSlot == nil && + search == nil && tabBarAnchor == nil && extensionContent?.isValid == true + } + return extensionContent == nil && ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 && (!ShellButton.kinds.contains(kind) || items.count == 1) && (toolbarSlot == nil || (placement == .verticalBars && [.button, .buttons, .menuButton].contains(kind))) && Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) && diff --git a/ios/Tests/IonicNativeUIShellPluginTests/NativeUIExtensionTests.swift b/ios/Tests/IonicNativeUIShellPluginTests/NativeUIExtensionTests.swift new file mode 100644 index 000000000..41956e232 --- /dev/null +++ b/ios/Tests/IonicNativeUIShellPluginTests/NativeUIExtensionTests.swift @@ -0,0 +1,99 @@ +import XCTest +import SwiftUI +import WebKit +@testable import IonicNativeUIShellPlugin + +final class NativeUIExtensionTests: XCTestCase { + struct Props: Decodable { let title: String } + + @MainActor func testLocalRegistrationDecodesPropsAndRejectsDuplicateAndMissingRenderers() throws { + let name = UUID().uuidString + var title = "" + try NativeUIShell.register(name) { (props: Props, _: NativeUIActions) in + title = props.title + return Text(props.title) + } + let actions = NativeUIActions { _ in } + _ = try NativeUIShell.content(ShellExtension(name: name, props: "{\"title\":\"Local View\"}", actions: [:], tabBarId: nil), actions: actions) + XCTAssertEqual(title, "Local View") + XCTAssertThrowsError(try NativeUIShell.register(name) { (props: Props, _: NativeUIActions) in Text(props.title) }) + XCTAssertThrowsError(try NativeUIShell.content(ShellExtension(name: name, props: "{}", actions: [:], tabBarId: nil), actions: actions)) + XCTAssertThrowsError(try NativeUIShell.content(ShellExtension(name: "missing", props: "{}", actions: [:], tabBarId: nil), actions: actions)) + } + + @MainActor func testExtensionSnapshotAcceptsEmptyActionsButRejectsInvalidProps() throws { + func decode(_ props: String) throws -> ShellControl { + let json: [String: Any] = ["id": "counter", "kind": "extension", "x": 0, "y": 0, "width": 100, "height": 44, + "dark": false, "rtl": false, "items": [], "extension": ["name": "app.counter", "props": props, "actions": [:]]] + return try JSONDecoder().decode(ShellControl.self, from: JSONSerialization.data(withJSONObject: json)) + } + XCTAssertTrue(try decode("{\"title\":\"Ready\"}").isValid) + XCTAssertFalse(try decode("[]").isValid) + XCTAssertFalse(try decode("broken").isValid) + } + + @MainActor func testActionWaitsForWebAcknowledgementAndIgnoresDuplicateReplies() async throws { + let broker = ShellExtensionActions() + var request: String? + var completed = false + let sent = expectation(description: "Web action sent") + let task = Task { @MainActor in + try await broker.perform(owner: "source") { request = $0; sent.fulfill() } + completed = true + } + defer { task.cancel() } + await fulfillment(of: [sent], timeout: 2) + let requestID = try XCTUnwrap(request) + XCTAssertFalse(completed) + broker.finish(requestID) + broker.finish(requestID, error: NativeUIError.rejected) + try await task.value + XCTAssertTrue(completed) + } + + @MainActor func testRetirementRejectsPendingCallsOnlyForThatSource() async throws { + let broker = ShellExtensionActions() + var requests: [String: String] = [:] + let sent = expectation(description: "Both actions sent") + sent.expectedFulfillmentCount = 2 + let first = Task { @MainActor in try await broker.perform(owner: "first") { requests["first"] = $0; sent.fulfill() } } + let second = Task { @MainActor in try await broker.perform(owner: "second") { requests["second"] = $0; sent.fulfill() } } + defer { first.cancel(); second.cancel() } + await fulfillment(of: [sent], timeout: 2) + broker.cancel(owner: "first") + do { try await first.value; XCTFail("Retired source must reject") } catch NativeUIError.unavailable {} catch { XCTFail("\(error)") } + broker.finish(try XCTUnwrap(requests["second"])) + try await second.value + } + + @MainActor func testBridgeLossAndTaskCancellationAlwaysSettle() async { + let broker = ShellExtensionActions() + do { + try await broker.perform(owner: "lost", timeout: 0.01) { _ in } + XCTFail("Lost bridge must time out") + } catch NativeUIError.timedOut {} catch { XCTFail("\(error)") } + let sent = expectation(description: "Cancelable action sent") + let task = Task { @MainActor in try await broker.perform(owner: "canceled") { _ in sent.fulfill() } } + await fulfillment(of: [sent], timeout: 2) + task.cancel() + do { try await task.value; XCTFail("Canceled task must reject") } catch is CancellationError {} catch { XCTFail("\(error)") } + broker.cancel() + } + + @MainActor func testHostedViewUsesControllerContainmentAndDetaches() { + let owner = UIViewController() + owner.view = WKWebView() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + window.rootViewController = owner + window.makeKeyAndVisible() + defer { window.isHidden = true } + let host = ShellExtensionController(content: AnyView(Text("Local content")), owner: owner) + XCTAssertTrue(host.view.superview === owner.view) + XCTAssertTrue(host.controller.parent === owner) + host.update(AnyView(Text("Updated"))) + host.detach(duration: 0) + XCTAssertNil(host.controller.parent) + XCTAssertNil(host.view.superview) + XCTAssertTrue(owner.children.isEmpty) + } +} diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 7e58d3a63..40646e2d2 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -1,3 +1,4 @@ +import type { NativeUIExtension } from './extensions'; import type { PluginListenerHandle } from '@capacitor/core'; import type { NativeUIShellComponent } from './components'; @@ -11,6 +12,8 @@ export interface NativeUIShellStatus { } export interface VerticalControlAreaOptions { + /** App-local DOM readers with matching SwiftUI registrations. */ + extensions?: readonly NativeUIExtension[]; /** Opt-in popovers anchored to native Vertical Bars buttons. */ controls?: Pick; /** Appearance of native vertical ion-button and ion-menu-button actions. @@ -128,7 +131,8 @@ export interface ShellItem extends Frame { export interface ShellControl extends Frame { id: string; - kind: NativeUIShellComponent; + kind: NativeUIShellComponent | 'extension'; + extension?: { name: string; props: string; actions: Record; tabBarId?: string }; /** Lets the native host own adaptive placement instead of mirroring the DOM frame. */ placement?: 'vertical-bars'; /** Logical Ionic toolbar slot, preserved when projecting to the vertical rail. */ @@ -177,6 +181,8 @@ export interface ShellActivation { revision: number; id: string; sequence: number; + /** Native extension action awaiting a Web dispatch acknowledgement. */ + requestId?: string; } export interface WebViewMetrics { @@ -185,7 +191,8 @@ export interface WebViewMetrics { } export interface NativeUIShellPlugin { - configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; + configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean; extensions?: string[] }>; + completeExtensionAction(options: { requestId: string; accepted: boolean }): Promise; getWebViewMetrics(): Promise; getVerticalBarsGeometry(options: { viewportWidth: number; ids: string[] }): Promise<{ revision?: number; items: Record }>; prepareOverlay(options: { id: string }): Promise; diff --git a/src/native/extensions.ts b/src/native/extensions.ts new file mode 100644 index 000000000..8adc01eeb --- /dev/null +++ b/src/native/extensions.ts @@ -0,0 +1,134 @@ +import type { Candidate, Identify } from './shared/candidate'; +import { frame, isDark, visible } from './shared/dom'; + +export type NativeUIValue = string | number | boolean | null | NativeUIValue[] | { [key: string]: NativeUIValue }; + +/** An app-local DOM projection. Swift registers its View under the same name. */ +export interface NativeUIExtension { + readonly name: string; + readonly selector: string; + /** Defaults to the source element's frame. Unsupported hosts keep the Web source visible. */ + readonly placement?: 'element' | 'tab-accessory'; + /** Return null to keep this instance on the Web. Read only DOM-backed state. */ + readonly read: (element: HTMLElement) => { + props: { [key: string]: NativeUIValue }; + /** Click targets must belong to the source element. Null targets are unavailable. */ + actions?: Record; + } | null; +} + +/** Defines a projection locally; it does not start native work or register a Capacitor plugin. */ +export const defineNativeUI = (extension: NativeUIExtension): NativeUIExtension => Object.freeze({ ...extension }); + +export const validateExtensions = (extensions: readonly NativeUIExtension[], doc: Document): void => { + const names = new Set(); + for (const extension of extensions) { + if (!extension.name.trim() || names.has(extension.name)) throw new Error('Native UI extension names must be nonempty and unique'); + names.add(extension.name); + if (!extension.selector.trim()) throw new Error('Native UI extensions require a selector'); + doc.querySelector(extension.selector); + if (extension.placement && !['element', 'tab-accessory'].includes(extension.placement)) throw new Error('Invalid native UI placement'); + } +}; + +const json = (value: unknown): string => { + if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) + throw new Error('Native UI props must be a JSON object'); + const result = JSON.stringify(value, (_key, item: unknown) => { + if ( + item === undefined || + typeof item === 'function' || + typeof item === 'symbol' || + (typeof item === 'number' && !Number.isFinite(item)) || + (item !== null && typeof item === 'object' && !Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype) + ) + throw new Error('Native UI props must contain only JSON values'); + return item; + }); + if (!result || result.length > 65536) throw new Error('Native UI props exceed 64K characters'); + return result; +}; + +/** Follow slot assignment too: Ionic scroll containers live inside shadow roots. */ +export function* extensionAncestors(element: HTMLElement): Generator { + let current: HTMLElement | null = element; + while (current) { + const root = current.getRootNode(); + current = current.assignedSlot ?? current.parentElement ?? (root instanceof ShadowRoot ? (root.host as HTMLElement) : null); + if (current) yield current; + } +} + +const fitsClip = (element: HTMLElement): boolean => { + const rect = element.getBoundingClientRect(); + for (const ancestor of extensionAncestors(element)) { + const style = getComputedStyle(ancestor); + if (style.transform !== 'none' && !new DOMMatrixReadOnly(style.transform).isIdentity) return false; + const bounds = ancestor.getBoundingClientRect(); + const left = bounds.left + ancestor.clientLeft; + const top = bounds.top + ancestor.clientTop; + if (style.overflowX !== 'visible' && (rect.left < left - 1 || rect.right > left + ancestor.clientWidth + 1)) return false; + if (style.overflowY !== 'visible' && (rect.top < top - 1 || rect.bottom > top + ancestor.clientHeight + 1)) return false; + } + return true; +}; + +export const readExtension = (element: HTMLElement, extensions: readonly NativeUIExtension[], id: Identify): Candidate | undefined => { + const matches = extensions.filter((extension) => element.matches(extension.selector)); + if ( + matches.length !== 1 || + !visible(element) || + element.closest('ion-modal, ion-popover, ion-menu') || + element.contains(element.ownerDocument.activeElement) + ) + return; + const extension = matches[0]; + if (extension.placement !== 'tab-accessory' && !fitsClip(element)) return; + try { + const state = extension.read(element); + if (!state) return; + const props = json(state.props); + const actions = new Map(); + const targets: Record = {}; + for (const [name, target] of Object.entries(state.actions ?? {})) { + if (!name || (target && !element.contains(target))) return; + if ( + !target || + !visible(target) || + (target as HTMLElement & { disabled?: boolean }).disabled || + target.matches(':disabled, [disabled]') || + target.closest('[aria-disabled="true"]') || + getComputedStyle(target).pointerEvents === 'none' + ) + continue; + targets[name] = id(target); + actions.set(id(target), target); + } + if (Object.keys(targets).length > 30) return; + const tabBar = + extension.placement === 'tab-accessory' + ? Array.from(element.closest('ion-tabs')?.querySelectorAll('ion-tab-bar') ?? []).find( + (bar) => bar.closest('ion-tabs') === element.closest('ion-tabs'), + ) + : undefined; + if (extension.placement === 'tab-accessory' && !tabBar) return; + const style = getComputedStyle(element); + return { + element, + actions, + icons: [], + control: { + id: id(element), + kind: 'extension', + ...frame(element.getBoundingClientRect()), + items: [], + dark: isDark(style), + rtl: style.direction === 'rtl', + extension: { name: extension.name, props, actions: targets, tabBarId: tabBar ? id(tabBar) : undefined }, + }, + }; + } catch { + // A broken app reader must not retire otherwise valid built-in controls. + return; + } +}; diff --git a/src/native/index.ts b/src/native/index.ts index 6c419ed80..7b0c8c073 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,3 +1,4 @@ +import { validateExtensions, type NativeUIExtension } from './extensions'; import { Capacitor, registerPlugin } from '@capacitor/core'; import { setConfig } from '../transition/ios.transition'; import type { @@ -16,6 +17,8 @@ import { createOverlayController } from './overlays/controller'; import { createVerticalBarsWebProjection } from './vertical-bars-web'; import { prehideVerticalBarsToolbarSources } from './prehide'; import { observeVerticalBarsModals } from './shared/modal'; +export { defineNativeUI } from './extensions'; +export type { NativeUIExtension, NativeUIValue } from './extensions'; export type { NativeUIShellComponent, NativeUIShellControls, @@ -34,6 +37,7 @@ const plugin = registerPlugin('IonicNativeUIShell'); export const IonicNativeUIShell = plugin; let active: Promise | undefined; let activeConfiguration: string | undefined; +let activeExtensions: readonly NativeUIExtension[] = []; let stopping: Promise | undefined; // Every stop entry point releases the activation immediately but reserves the @@ -42,6 +46,7 @@ const stopActivation = (owner: Promise, destruction: Promis if (active !== owner) return; active = undefined; activeConfiguration = undefined; + activeExtensions = []; stopping = destruction; const release = () => { if (stopping === destruction) stopping = undefined; @@ -189,6 +194,7 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver */ export const enableVerticalControlArea = async (options: VerticalControlAreaOptions = {}): Promise => { const handle = await enableNativeUIShell({ + extensions: options.extensions, buttonProjection: options.buttonProjection, buttonDefaultFill: options.buttonDefaultFill, controls: { tabs: true, toolbar: true, popover: options.controls?.popover === true }, @@ -214,6 +220,12 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise return (stopping ?? Promise.resolve()).then(() => web('Disabled')); } if (typeof document === 'undefined') return Promise.resolve(web('Requires a document')); + const extensions = [...(options.extensions ?? [])]; + try { + validateExtensions(extensions, document); + } catch (error) { + return Promise.reject(error); + } const controls = options.controls; const configuration = JSON.stringify([ options.verticalBarsOnly === true, @@ -223,12 +235,18 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true), controls?.popover === true, ]); - if (active && activeConfiguration !== configuration) + if ( + active && + (activeConfiguration !== configuration || + extensions.length !== activeExtensions.length || + extensions.some((extension, index) => extension !== activeExtensions[index])) + ) return Promise.reject( - new Error('Native UI Shell is already running with different controls; destroy it before changing configuration.'), + new Error('Native UI Shell is already running with different controls or extensions; destroy it before changing configuration.'), ); if (active) return active; activeConfiguration = configuration; + activeExtensions = extensions; const previousStop = stopping; let start: Promise; const stop = (destruction: Promise) => stopActivation(start, destruction); @@ -276,7 +294,13 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise ); await configureNativeTransition().catch(() => undefined); } - const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true); + const native = await createRuntime( + document, + plugin, + { ...options, extensions: extensions.filter((extension) => capabilities.extensions?.includes(extension.name)) }, + nativeVerticalBars, + options.verticalBarsOnly === true, + ); runtime = combine( native, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().projected === 0), diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 9f867f6e3..6ae7ce93e 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -1,3 +1,4 @@ +import { extensionAncestors, readExtension } from './extensions'; import { bounded, boundedListener } from './shared/bridge'; import { popoverTrigger } from './overlays/popover'; import { @@ -53,6 +54,8 @@ export const createRuntime = async ( verticalBarsOnly = false, ): Promise boolean) => () => void }> => { const win = doc.defaultView!; + const extensions = options.extensions ?? []; + const discovery = [selector, ...extensions.map((extension) => extension.selector)].join(', '); const icons = createIconRenderer(); const crossfade = createCrossfade(win); const ids = new WeakMap(); @@ -108,6 +111,7 @@ export const createRuntime = async ( return 'toolbar'; }; const controlEnabled = (candidate: Candidate) => { + if (candidate.control.kind === 'extension') return true; const controls = options.controls; return controls === undefined || controls[control(candidate.control.kind)] === true; }; @@ -173,6 +177,11 @@ export const createRuntime = async ( const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { if (element.matches('ion-back-button') && element.closest('ion-app.ios-theme-vertical-bars') && !isVerticalBarsCandidate(element)) return; + if (extensions.some((extension) => element.matches(extension.selector))) { + const candidate = readExtension(element, extensions, id); + if (verticalBarsOnly && (!candidate?.control.extension?.tabBarId || !nativeVerticalBars())) return; + return candidate; + } if (verticalBarsOnly && !isVerticalBarsCandidate(element)) return; const pointerPage = isVerticalBarsCandidate(element) ? element.closest('.ion-page') : undefined; let candidate: Candidate | undefined; @@ -256,7 +265,7 @@ export const createRuntime = async ( const candidates = unprojected(measurableSources, () => search .decorate( - Array.from(doc.querySelectorAll(selector)) + Array.from(doc.querySelectorAll(discovery)) .filter( (element) => inVerticalBarsSurface(element) && @@ -269,13 +278,26 @@ export const createRuntime = async ( ) .filter((candidate) => !rejected.has(candidate.element) || rejected.get(candidate.element) !== signature(candidate)), ); + // An extension owns its entire source subtree. Never project a nested built-in twice. + const extensionSources = candidates.filter((candidate) => candidate.control.kind === 'extension'); + const exclusive = candidates.filter((candidate) => { + if (extensionSources.some((owner) => owner !== candidate && owner.element.contains(candidate.element))) return false; + const tabBarId = candidate.control.extension?.tabBarId; + if (!tabBarId) return true; + // Wait for the owning native tab host; do not cache an early rejection while its icons load. + return ( + candidates.some( + (owner) => owner.control.id === tabBarId && owner.control.kind === 'ion-tab-bar' && owner.control.placement === 'vertical-bars', + ) && extensionSources.filter((source) => source.control.extension?.tabBarId === tabBarId).length === 1 + ); + }); const back = preferredVerticalBarsBack( candidates .filter((candidate) => candidate.control.kind === 'ion-back-button' && isVerticalBarsCandidate(candidate.element)) .map((candidate) => candidate.element), doc, ); - const selected = candidates.filter( + const selected = exclusive.filter( (candidate) => candidate.control.kind !== 'ion-back-button' || !isVerticalBarsCandidate(candidate.element) || candidate.element === back, ); @@ -283,9 +305,16 @@ export const createRuntime = async ( }; const observe = () => { const wanted = new Set(); - for (const element of Array.from(doc.querySelectorAll(selector))) { + for (const element of Array.from(doc.querySelectorAll(discovery))) { wanted.add(element); if (element.parentElement) wanted.add(element.parentElement); + if (extensions.some((extension) => element.matches(extension.selector))) { + for (const ancestor of extensionAncestors(element)) { + wanted.add(ancestor); + const root = ancestor.getRootNode(); + if (root instanceof ShadowRoot) wanted.add(root); + } + } const inspectShadow = (node: Element) => { if (node.shadowRoot) { wanted.add(node.shadowRoot); @@ -299,12 +328,15 @@ export const createRuntime = async ( if (wanted.size === observed.size && Array.from(wanted).every((element) => observed.has(element))) return; observer.disconnect(); resize.disconnect(); + for (const element of observed) if (element instanceof ShadowRoot) element.removeEventListener('scroll', schedule, true); observed.clear(); observer.observe(doc.documentElement, observation); for (const element of wanted) { observed.add(element); - if (element instanceof ShadowRoot) observer.observe(element, observation); - else resize.observe(element); + if (element instanceof ShadowRoot) { + observer.observe(element, observation); + on(element, 'scroll'); + } else resize.observe(element); } }; const fail = async (error: unknown) => { @@ -611,7 +643,7 @@ export const createRuntime = async ( }; const motion: EventListener = (event) => { const target = event.target as HTMLElement; - if (!target.matches?.(motionSelector)) return; + if (!target.matches?.(`${motionSelector}, ${discovery}`)) return; const key = (event as TransitionEvent).propertyName ?? (event as AnimationEvent).animationName; if (event.type === CSS_MOTION_EVENTS.transitionRun || event.type === CSS_MOTION_EVENTS.animationStart) { const keys = moving.get(target) ?? new Set(); @@ -790,7 +822,14 @@ export const createRuntime = async ( }, }; const activate = (event: ShellActivation) => { - if (stopped || doc.hidden || event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence) return; + const accepted = dispatchActivation(event); + if (event.requestId && accepted !== undefined) { + void bounded(plugin.completeExtensionAction({ requestId: event.requestId, accepted })).catch(() => {}); + } + }; + const dispatchActivation = (event: ShellActivation): boolean | undefined => { + if (stopped || doc.hidden || event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence) + return false; const element = actions.get(event.id); const owner = element && Array.from(sources.keys()).find((source) => source === element || source.contains(element)); const ignored = owner && isVerticalBarsSource(owner) ? (retentions.size ? 'ion-toast, ion-picker' : railPreservingOverlays) : undefined; @@ -801,7 +840,7 @@ export const createRuntime = async ( retention.activation = event; } } - return; + return false; } // Native may send input before update() resolves on the JS bridge. // Revalidate it after ownership is accepted, using the same action path. @@ -810,18 +849,20 @@ export const createRuntime = async ( return; } lastSequence = event.sequence; - if (!element) return; - if (!owner || !inVerticalBarsSurface(owner)) return; + if (!element) return false; + if (!owner || !inVerticalBarsSurface(owner)) return false; const direct = !blocked(owner) && unprojected(sources.keys(), () => readEnabledCandidate(owner)); const candidate = direct || read().find((candidate) => candidate.actions.has(event.id)); const item = candidate?.control.items.find((item) => item.id === event.id); const searchAction = candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id); - if (!searchAction && (!item || item.disabled || item.visible === false)) return; + const extensionAction = candidate?.control.kind === 'extension' && candidate.actions.get(event.id) === element; + if (!extensionAction && !searchAction && (!item || item.disabled || item.visible === false)) return false; // The original Ionic host owns form submission, routerLink and selection events. activateProjectedElement(element); lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection. schedule(); + return true; }; try { listener = await boundedListener(plugin.addListener('activate', activate));