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
235 changes: 235 additions & 0 deletions demo/e2e/native-ui-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface ShellMock extends ShellMockCore {
lateSearch?: () => void;
tabRetirements: number;
retirementDetails: { path: string; tabs: string }[];
preparedPopovers: number;
}

const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'trailing' | null | 'unreported' = 'trailing') => {
Expand Down Expand Up @@ -46,6 +47,13 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra
notifyListeners(eventName: string, data: unknown) {
for (const listener of this.listeners[eventName] ?? []) listener(data as never);
},
preparedPopovers: 0,
async prepareOverlay() {
this.preparedPopovers++;
throw new Error('Native popover unavailable');
},
async closeOverlay() {},
async stopOverlays() {},
async configure(options: { verticalBarsOnly?: boolean }) {
this.configuredWith = options;
return { supported: true, verticalBars: nativeEdge !== null };
Expand Down Expand Up @@ -3376,3 +3384,230 @@ test('tab visibility ignores query parameters and fragments', async ({ page }) =
await page.goto('/main/index?verticalBarsOnly&buttonDefaultFill=solid#comparison');
await expect(page.locator('ion-tab-bar')).not.toHaveClass(/tab-bar-hidden/);
});

for (const standalone of [false, true])
test(`Vertical Bars popover retains native controls and background geometry (${standalone ? 'standalone' : 'full shell'})`, async ({
page,
}) => {
await page.setViewportSize({ width: 700, height: 900 });
await mockNative(page);
await page.goto(`/main/index/popover${standalone ? '?verticalBarsOnly' : ''}`);
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-shell-vertical-bars-member', '', { timeout: 15000 });
await page.evaluate(() =>
Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), {
async prepareOverlay() {},
async presentOverlay() {},
async dismissOverlay() {},
}),
);
await page
.locator('app-popover ion-content')
.first()
.evaluate(async (content: HTMLIonContentElement) => content.scrollToPoint(0, 100));
const geometry = () =>
page
.locator('app-popover ion-content')
.first()
.evaluate(async (content: HTMLIonContentElement) => {
const rect = content.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scroll: (await content.getScrollElement()).scrollTop };
});
const before = await geometry();
for (let attempt = 1; attempt <= 3; attempt++) {
const updateCount = await page.evaluate(() => Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').updates.length);
const opened = page.waitForEvent('popup');
await activate(page, 'Open popover');
const relay = await opened;
await expect(relay.getByText('Toolbar popover', { exact: true })).toBeVisible();
expect(await relay.evaluate(() => document.compatMode)).toBe('CSS1Compat');
expect((await relay.getByRole('button', { name: 'Increment', exact: true }).boundingBox())!.height).toBeLessThan(80);
await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 });
expect(await geometry()).toEqual(before);
expect(
await page.evaluate(
(start) =>
Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell')
.updates.slice(start)
.every((snapshot) =>
snapshot.controls.some(
(control) =>
control.placement === 'vertical-bars' && control.items.some((item) => item.accessibilityLabel === 'Open popover'),
),
),
updateCount,
),
).toBe(true);
await relay.getByRole('button', { name: 'Increment', exact: true }).click();
await expect(relay.getByText(`Count: ${attempt}`, { exact: true })).toBeVisible();
await relay.getByRole('button', { name: 'Close popover', exact: true }).click();
await expect.poll(() => relay.isClosed()).toBe(true);
await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 });
expect(await geometry()).toEqual(before);
}
});

test('ordinary popovers stay in the source WebView', async ({ page }) => {
await mockNative(page);
await page.goto('/main/index/popover');
await expect(page.locator('#native-popover-trigger')).toHaveAttribute('data-native-ui-shell', '');

await activate(page, 'Open popover');
await expect(page.getByText('Toolbar popover', { exact: true })).toBeVisible();
expect(page.context().pages()).toHaveLength(1);
expect(await page.evaluate(() => Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').preparedPopovers)).toBe(0);
await page.getByRole('button', { name: 'Close popover', exact: true }).click();
});

test('Vertical Bars popover preparation failure restores usable Web content and projection updates', 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-shell-vertical-bars-member', '', { timeout: 15000 });
await activate(page, 'Open popover');
await expect(page.getByText('Toolbar popover', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Increment', exact: true }).click();
await expect(page.getByText('Count: 1', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Close popover', exact: true }).click();
await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 });
expect(page.context().pages()).toHaveLength(1);
});

for (const disabled of [false, true])
test(`Vertical Bars popover handles anchor clicks during dismissal (${disabled ? 'disabled before replay' : 'reopens once'})`, 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-shell-vertical-bars-member', '', { timeout: 15000 });
await page.evaluate(() => {
const plugin = Capacitor.registerPlugin<ShellMock & { finishDismiss?: () => void }>('IonicNativeUIShell');
Object.assign(plugin, {
async prepareOverlay() {},
async presentOverlay() {},
dismissOverlay: () =>
new Promise<void>((resolve) => {
plugin.finishDismiss = resolve;
}),
});
});
const opened = page.waitForEvent('popup');
await activate(page, 'Open popover');
const relay = await opened;
await expect(relay.getByText('Toolbar popover', { exact: true })).toBeVisible();
await relay.getByRole('button', { name: 'Close popover', exact: true }).click();
await expect
.poll(() =>
page.evaluate(() => !!Capacitor.registerPlugin<ShellMock & { finishDismiss?: () => void }>('IonicNativeUIShell').finishDismiss),
)
.toBe(true);
// UIKit can accept the next tap while Ionic still waits for didDismiss.
// Repeated clicks must coalesce, and a now-disabled source must not replay.
for (let i = 0; i < 3; i++) await activate(page, 'Open popover');
if (disabled)
await trigger.evaluate((button: HTMLIonButtonElement) => {
button.disabled = true;
});
await page.evaluate(() => Capacitor.registerPlugin<ShellMock & { finishDismiss?: () => void }>('IonicNativeUIShell').finishDismiss!());
await expect.poll(() => relay.isClosed()).toBe(true);
if (disabled) {
await expect(trigger).toHaveJSProperty('disabled', true);
await expect.poll(() => page.context().pages().length).toBe(1);
} else {
await expect.poll(() => page.context().pages().length).toBe(2);
const reopened = page
.context()
.pages()
.find((candidate) => candidate !== page)!;
await expect(reopened.getByText('Toolbar popover', { exact: true })).toBeVisible();
await reopened.getByRole('button', { name: 'Increment', exact: true }).click();
await expect(reopened.getByText('Count: 1', { exact: true })).toBeVisible();
}
});

for (const failure of ['reject', 'timeout'] as const)
test(`Vertical Bars popover preserves didDismiss after native dismissal ${failure}`, 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-shell-vertical-bars-member', '', { timeout: 15000 });
await page.evaluate((failure) => {
Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), {
async prepareOverlay() {},
async presentOverlay() {},
dismissOverlay: () => (failure === 'reject' ? Promise.reject(new Error('Native dismiss failed')) : new Promise(() => {})),
});
}, failure);
const opened = page.waitForEvent('popup');
await activate(page, 'Open popover');
const relay = await opened;
await expect(relay.getByText('Toolbar popover', { exact: true })).toBeVisible();
const result = await page.locator('ion-popover[trigger="native-popover-trigger"]').evaluate(async (overlay: HTMLIonPopoverElement) => {
const didDismiss = overlay.onDidDismiss();
await overlay.dismiss({ saved: true }, 'confirm');
return Promise.race([didDismiss, new Promise((resolve) => setTimeout(() => resolve('missing didDismiss'), 1000))]);
});
expect(result).toEqual({ data: { saved: true }, role: 'confirm' });
await expect.poll(() => relay.isClosed()).toBe(true);
await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '');
const reopened = page.waitForEvent('popup');
await activate(page, 'Open popover');
await expect((await reopened).getByText('Toolbar popover', { exact: true })).toBeVisible();
});

for (const nestedInRelay of [false, true])
test(`Vertical Bars popover restores nested overlays (${nestedInRelay ? 'inline content' : 'source controller'})`, 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'));
await expect(page.locator('#native-popover-trigger')).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '');
await page.evaluate(() =>
Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), {
async prepareOverlay() {},
async presentOverlay() {},
async dismissOverlay() {},
}),
);
await page.evaluate(() => {
const open = window.open.bind(window);
window.open = (...args) => {
const relay = open(...args);
(window as Window & { reviewRelay?: Window | null }).reviewRelay = relay;
return relay;
};
});
const opened = page.waitForEvent('popup');
await activate(page, 'Open popover');
const relay = await opened;
await expect(relay.getByText('Toolbar popover', { exact: true })).toBeVisible();
await page.evaluate(async (nestedInRelay) => {
const nested = document.createElement('ion-popover');
nested.id = 'nested-review-popover';
const body = document.createElement('div');
body.textContent = 'Nested content';
nested.component = body;
if (nestedInRelay) {
// The Angular content remains live in the child document.
const windows = window as Window & { reviewRelay?: Window };
windows.reviewRelay!.document.querySelector('ion-popover > div')!.append(nested);
} else document.querySelector('ion-app')!.append(nested);
await nested.present();
}, nestedInRelay);
await expect.poll(() => relay.isClosed()).toBe(true);
await expect(page.getByText('Nested content', { exact: true })).toBeVisible();
await page
.locator('#nested-review-popover')
.evaluate(async (overlay: HTMLIonPopoverElement) => overlay.dismiss(undefined, undefined, false));
await expect(page.getByText('Toolbar popover', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Close popover', exact: true }).click();
await expect(page.locator('#native-popover-trigger')).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '');
});
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/popover-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/popover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions demo/ios/NativeUIShellTests/NativeUIShellTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,50 @@ final class NativeUIShellTests: XCTestCase {
XCTAssertTrue(main.identifier.hasPrefix("shell-"))
}

func testVerticalBarsPopoverKeepsBackgroundAndLiveActions() throws {
let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27")
app.launch()
let toggle = app.webViews.switches["iPhone Duo Mode"]
XCTAssertTrue(toggle.waitForExistence(timeout: 15), app.debugDescription)
if toggle.value as? String == "0" { toggle.tap() }
openPage(app, name: "popover")
let trigger = nativeButton(app, label: "Open popover")
XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription)
assertOnVerticalBars(trigger, in: app)
let source = app.webViews.firstMatch
let sourceFrame = source.frame
let triggerFrame = trigger.frame
let background = app.webViews.staticTexts["Controller"].firstMatch
XCTAssertTrue(background.exists, app.debugDescription)
let backgroundFrame = background.frame
capture("vertical-popover-before")
for attempt in 1...20 {
trigger.tap()
let title = app.webViews.staticTexts["Toolbar popover"].firstMatch
XCTAssertTrue(title.waitForExistence(timeout: 5), app.debugDescription)
// SwiftUI's modal accessibility scope hides covered controls from XCTest.
// Their native identities and geometry are checked again after dismissal.
capture("vertical-popover-open-\(attempt)")
let increment = app.webViews.buttons["Increment"].firstMatch
XCTAssertLessThan(increment.frame.height, 80, "Popover buttons must keep their intrinsic height")
increment.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(app.webViews.staticTexts["Count: \(attempt)"].firstMatch.waitForExistence(timeout: 5), app.debugDescription)
if attempt % 2 == 0 {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.8)).tap()
} else {
app.webViews.buttons["Close popover"].firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
XCTAssertTrue(title.waitForNonExistence(timeout: 5), app.debugDescription)
XCTAssertTrue(trigger.waitForExistence(timeout: 5), app.debugDescription)
XCTAssertEqual(trigger.frame.minX, triggerFrame.minX, accuracy: 1)
XCTAssertEqual(trigger.frame.minY, triggerFrame.minY, accuracy: 1)
XCTAssertEqual(source.frame.minX, sourceFrame.minX, accuracy: 1)
XCTAssertEqual(source.frame.minY, sourceFrame.minY, accuracy: 1)
XCTAssertEqual(background.frame.minY, backgroundFrame.minY, accuracy: 1)
capture("vertical-popover-dismissed-\(attempt)")
}
}

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]
Expand Down
34 changes: 34 additions & 0 deletions demo/src/app/index/pages/popover/popover-content.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Component, input, signal } from '@angular/core';
import { IonButton, IonContent, IonItem, IonItemGroup, IonLabel, IonList } from '@demo/ionic';

@Component({
selector: 'app-popover-content',
standalone: true,
imports: [IonButton, IonContent, IonItem, IonItemGroup, IonLabel, IonList],
template: `
<ion-content>
<ion-list>
<ion-item-group>
<ion-item
><ion-label>Count: {{ count() }}</ion-label
><ion-button (click)="count.update(increment)">Increment</ion-button></ion-item
>
@if (scrollable()) {
@for (row of rows; track row) {
<ion-item
><ion-label>Row {{ row }}</ion-label></ion-item
>
}
}
</ion-item-group>
</ion-list>
</ion-content>
`,
styles: [':host { display: block; height: 100%; }'],
})
export class PopoverContentComponent {
readonly scrollable = input(false);
readonly count = signal(0);
readonly rows = Array.from({ length: 20 }, (_, index) => index + 1);
readonly increment = (value: number) => value + 1;
}
26 changes: 23 additions & 3 deletions demo/src/app/index/pages/popover/popover.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,23 @@
<ion-back-button defaultHref="/main/index"></ion-back-button>
</ion-buttons>
<ion-title>popover</ion-title>
<ion-buttons slot="end">
<ion-button id="native-popover-trigger" aria-label="Open popover">
<ion-icon name="ellipsis-horizontal" slot="icon-only"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-popover #toolbarPopover trigger="native-popover-trigger">
<ng-template
><div class="ion-padding">
<p>Toolbar popover</p>
<p>Count: {{ toolbarCount() }}</p>
<ion-button (click)="incrementToolbar()">Increment</ion-button>
<ion-button (click)="toolbarPopover.dismiss()">Close popover</ion-button>
</div></ng-template
>
</ion-popover>

<ion-content [fullscreen]="true" color="light">
<ion-list [inset]="true">
Expand All @@ -14,15 +29,20 @@
<ion-label>
<ion-icon name="logo-ionic" style="background: linear-gradient(#489bfd, #2b77f4)"></ion-icon>
<h2>popover</h2>
<ion-header collapse="condense">
<ion-toolbar></ion-toolbar>
</ion-header>
<ion-text><code>This page is a component demo for &#64;rdlabo/ionic-theme-ios27.</code></ion-text>
</ion-label>
</ion-item>
</ion-item-group>
</ion-list>

<ion-list [inset]="true">
<ion-list-header><ion-label>Controller</ion-label></ion-list-header>
<ion-item-group>
<ion-item [button]="true" (click)="present($event)"><ion-label>Compact controller popover</ion-label></ion-item>
<ion-item [button]="true" (click)="present($event, true)"><ion-label>Scrollable controller popover</ion-label></ion-item>
</ion-item-group>
</ion-list>

<ion-toolbar>
<ion-buttons id="click-trigger-right-buttons" slot="end">
<ion-button><ion-icon name="close-outline" slot="icon-only"></ion-icon></ion-button>
Expand Down
Loading
Loading