diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index e525a9b57..d2d9398a6 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -3983,6 +3983,47 @@ for (const refusal of ['disabled', 'md']) await expect(trigger).toHaveAttribute('data-native-ui-vertical-bars-member', ''); }); +for (const phase of ['click', 'presented']) { + test(`Vertical Bars popover keeps its anchor when disabled at ${phase}`, async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page); + await page.goto('/main/index/popover'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const trigger = page.locator('#native-popover-trigger'); + await expect(trigger).toHaveAttribute('data-native-ui-vertical-bars-member', ''); + await page.evaluate((phase) => { + const plugin = Capacitor.registerPlugin('NativeUI'); + Object.assign(plugin, { async prepareOverlay() {}, async presentOverlay() {}, async dismissOverlay() {} }); + const button = document.querySelector('#native-popover-trigger')!; + if (phase === 'click') + button.addEventListener( + 'click', + () => { + button.disabled = true; + }, + { capture: true, once: true }, + ); + }, phase); + const opened = page.waitForEvent('popup'); + await activate(page, 'Open popover'); + const relay = await opened; + await expect(relay.getByText('Toolbar popover', { exact: true })).toBeVisible(); + if (phase === 'presented') + await trigger.evaluate((button: HTMLIonButtonElement) => { + button.disabled = true; + }); + await expect(trigger).toHaveJSProperty('disabled', true); + await expect(trigger).toHaveAttribute('data-native-ui-projected', ''); + await relay.getByRole('button', { name: 'Increment', exact: true }).click(); + await expect(relay.getByText('Count: 1', { exact: true })).toBeVisible(); + await relay.getByRole('button', { name: 'Close popover', exact: true }).click(); + await expect.poll(() => relay.isClosed()).toBe(true); + await activate(page, 'Open popover'); + await expect.poll(() => page.context().pages().length).toBe(1); + await expect(trigger).toHaveJSProperty('disabled', true); + }); +} + for (const disabled of [false, true]) test(`Vertical Bars popover handles anchor clicks during dismissal (${disabled ? 'disabled before replay' : 'reopens once'})`, async ({ page, @@ -4919,6 +4960,10 @@ test('horizontal tabs retain measured native width on Web handoff without feedin await page.locator('ion-app').evaluate((app) => app.classList.remove('ios-theme-vertical-bars')); await expect.poll(requestWidth).toBeCloseTo(original, 0); await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0); + // Toolbar artwork can finish after the tab width is acknowledged. Wait for + // the complete handoff before checking that synchronization has settled. + await expect(page.locator('index-page ion-buttons[slot=start]')).toHaveAttribute('data-native-ui-projected', ''); + await expect(page.locator('index-page ion-buttons[slot=end]')).toHaveAttribute('data-native-ui-projected', ''); const updates = await page.evaluate(() => Capacitor.registerPlugin('NativeUI').updates.length); await page.waitForTimeout(300); expect(await page.evaluate(() => Capacitor.registerPlugin('NativeUI').updates.length)).toBe(updates); diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift index 0b01de1cd..b3c7972f1 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift @@ -611,7 +611,7 @@ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling { func presentPopover(anchor: String, host: NativeUIOverlayHost, completion: @escaping (Bool) -> Void) -> Bool { guard container.window != nil, model.popoverAnchor == nil, - model.groups.flatMap(\.items).contains(where: { $0.id == anchor && !$0.disabled }) else { return false } + model.groups.flatMap(\.items).contains(where: { $0.id == anchor }) else { return false } model.popoverHost = host model.popoverPresented = completion host.anchoredVisible = false diff --git a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index eaa5c73aa..777ee915b 100644 --- a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -410,27 +410,30 @@ final class NativeUISnapshotTests: XCTestCase { @MainActor func testDetachingRailSettlesPopoverPresentationAndDismissal() throws { guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native Vertical Bars") } - let owner = UIViewController() - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 900)) - window.rootViewController = owner - window.makeKeyAndVisible() - defer { window.isHidden = true } - let rail = NativeUIVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 }) - rail.attach(to: owner, in: owner.view) - rail.apply(try decode([control(["placement": "vertical-bars"])]).controls, - rendering: NativeUIControlRendering(), edge: "right") - let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in }) - var presentations: [Bool] = [] - XCTAssertTrue(rail.presentPopover(anchor: "action", host: host) { presentations.append($0) }) - host.anchoredVisible = true - var dismissals = 0 - host.dismissPopover(animated: false) { dismissals += 1 } - rail.detach() - XCTAssertEqual(presentations, [false]) - XCTAssertEqual(dismissals, 1) - XCTAssertFalse(host.anchoredVisible) - rail.detach() - XCTAssertEqual(dismissals, 1) + // Disabling a valid trigger after a tap must not invalidate its popover anchor. + for disabled in [false, true] { + let owner = UIViewController() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 900)) + window.rootViewController = owner + window.makeKeyAndVisible() + defer { window.isHidden = true } + let rail = NativeUIVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 }) + rail.attach(to: owner, in: owner.view) + rail.apply(try decode([control(["placement": "vertical-bars", "items": [item(["disabled": disabled])]])]).controls, + rendering: NativeUIControlRendering(), edge: "right") + let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in }) + var presentations: [Bool] = [] + XCTAssertTrue(rail.presentPopover(anchor: "action", host: host) { presentations.append($0) }) + host.anchoredVisible = true + var dismissals = 0 + host.dismissPopover(animated: false) { dismissals += 1 } + rail.detach() + XCTAssertEqual(presentations, [false]) + XCTAssertEqual(dismissals, 1) + XCTAssertFalse(host.anchoredVisible) + rail.detach() + XCTAssertEqual(dismissals, 1) + } } @MainActor func testStoppingPreparedPopoverRestoresWebViewAndAllowsNewSession() { diff --git a/packages/capacitor-native-ui/src/native/overlays/popover.ts b/packages/capacitor-native-ui/src/native/overlays/popover.ts index bbe905b5c..35ffdb758 100644 --- a/packages/capacitor-native-ui/src/native/overlays/popover.ts +++ b/packages/capacitor-native-ui/src/native/overlays/popover.ts @@ -10,14 +10,10 @@ export const popoverTrigger = (overlay: HTMLIonPopoverElement): Element | undefi return target?.closest?.('ion-button,ion-fab-button,ion-item') ?? target; }; -/** Only accepted native rail buttons can retain the page projection. */ +/** Anchoring is independent of click eligibility: handlers may disable the trigger after a valid tap. */ export const verticalBarsPopoverAnchor = (overlay: HTMLIonPopoverElement): string | undefined => { const trigger = popoverTrigger(overlay); - if ( - !trigger?.matches('ion-button[data-native-ui-vertical-bars-member]') || - !trigger.closest('[data-native-ui-projected]') || - (trigger as HTMLIonButtonElement).disabled - ) + if (!trigger?.matches('ion-button[data-native-ui-vertical-bars-member]') || !trigger.closest('[data-native-ui-projected]')) return undefined; return projectionIds.get(trigger); };