Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions demo/e2e/native-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NativeUIMock>('NativeUI');
Object.assign(plugin, { async prepareOverlay() {}, async presentOverlay() {}, async dismissOverlay() {} });
const button = document.querySelector<HTMLIonButtonElement>('#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,
Expand Down Expand Up @@ -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<NativeUIMock>('NativeUI').updates.length);
await page.waitForTimeout(300);
expect(await page.evaluate(() => Capacitor.registerPlugin<NativeUIMock>('NativeUI').updates.length)).toBe(updates);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
8 changes: 2 additions & 6 deletions packages/capacitor-native-ui/src/native/overlays/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
Loading