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
231 changes: 228 additions & 3 deletions demo/e2e/native-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import type { Page } from '@playwright/test';
import { compile, NodePackageImporter } from 'sass';
import { resolve } from 'node:path';
import * as overlayTypes from '../src/app/overlay-types';
import type { NativeUIControl, NativeUIItem, NativeUISnapshot } from '../../packages/capacitor-native-ui/src/native/definitions';
import type {
NativeUIControl,
NativeUIItem,
NativeUISnapshot,
NativeUIPlugin,
} from '../../packages/capacitor-native-ui/src/native/definitions';
import type { NativeUIMockCore, TestAppElement } from './native-ui-mock';

const importer = new NodePackageImporter(resolve(__dirname, '../../'));

interface NativeUIMock extends NativeUIMockCore {
update: NativeUIPlugin['update'];
toolbarProgress: { id: number; progress: number }[];
delay: number;
hang: boolean;
Expand Down Expand Up @@ -1676,7 +1682,8 @@ for (const nativeEdge of [null, 'trailing'] as const) {
await expect(group).toHaveAttribute('data-native-ui-projected', '');
await refresh.evaluate((element) => element.remove());
await expect(group).not.toHaveAttribute('data-native-ui-projected');
await expect(github.locator('button')).toHaveCSS('visibility', 'visible');
// A one-item group becomes an independent native button, not a Web handoff.
await expect(github).toHaveAttribute('data-native-ui-projected', '');
});
}

Expand Down Expand Up @@ -2999,6 +3006,224 @@ test('reduced motion hands off without a crossfade', async ({ page }) => {
await expect(segment).toHaveCSS('opacity', '1');
});

test('Duo hides the entering search FAB before native acknowledgement and restores rejected search', async ({ page }) => {
await mockNative(page);
await page.route('https://picsum.photos/**', (route) => route.abort());
await page.goto('/main/index');
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-projected', '');
const fab = page.locator('app-album-page ion-fab');
for (let visit = 0; visit < 2; visit++) {
await page.evaluate(() => {
Capacitor.registerPlugin<NativeUIMock>('NativeUI').delay = 150;
const evidence: number[] = [];
Object.assign(window, { enteringFabFrames: evidence });
const sample = () => {
const fab = document.querySelector('app-album-page ion-fab-button');
if (fab && !fab.closest('.ion-page-hidden, .ion-page-invisible') && getComputedStyle(fab).visibility === 'visible')
evidence.push(performance.now());
if (!document.querySelector('app-album-page ion-fab[data-native-ui-projected]')) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
await activate(page, 'Library');
await expect(fab).toHaveAttribute('data-native-ui-projected', '');
expect(await page.evaluate(() => (window as Window & { enteringFabFrames: number[] }).enteringFabFrames)).toEqual([]);
await activate(page, 'Index');
await expect(page).toHaveURL(/\/main\/index/);
await expect(fab).not.toHaveAttribute('data-native-ui-projected');
}
await page.evaluate(() => {
Capacitor.registerPlugin<NativeUIMock>('NativeUI').rejectAllSearch = true;
});
await activate(page, 'Library');
await expect(fab).not.toHaveClass(/native-ui-prehidden/);
await expect(fab).not.toHaveAttribute('data-native-ui-projected');
});

for (const edge of [null, 'trailing'] as const) {
test(`tab search keeps Web trigger hidden while native search remains available (${edge ?? 'horizontal'})`, async ({ page }) => {
await page.setViewportSize({ width: 402, height: 874 });
await mockNative(page, false, edge);
await page.route('https://picsum.photos/**', (route) => route.abort());
await page.goto('/main/album');
if (edge) await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
const fab = page.locator('app-album-page ion-fab');
await expect(fab).toHaveAttribute('data-native-ui-projected', '');
await fab.evaluate((element) => {
const evidence: string[] = [];
Object.assign(window, { searchOwnershipGaps: evidence });
element.addEventListener('nativeUIChange', () => {
const state = Capacitor.registerPlugin<NativeUIMock>('NativeUI');
if (
!element.hasAttribute('data-native-ui-projected') &&
!element.closest('.ion-page-hidden, .ion-page-invisible') &&
state.updates.at(-1)?.controls.some((control) => control.search?.available)
) {
evidence.push(location.pathname);
}
});
Capacitor.registerPlugin<NativeUIMock>('NativeUI').delay = 150;
});
await page.evaluate(() => {
const decode = HTMLImageElement.prototype.decode;
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
HTMLImageElement.prototype.decode = async function () {
await decode.call(this);
if (this.src.startsWith('blob:')) await gate;
};
Object.assign(window, {
releaseSearchDestinationIcons: () => {
HTMLImageElement.prototype.decode = decode;
release();
},
});
});
await activate(page, 'Index');
await expect(page).toHaveURL(/\/main\/index/);
await expect
.poll(() =>
page.evaluate(() =>
Capacitor.registerPlugin<NativeUIMock>('NativeUI')
.updates.at(-1)
?.controls.some((control) => control.search?.available),
),
)
.toBe(false);
// A router may finish hiding the departing page after its lifecycle event.
// The normal read must not resurrect a Search already retired natively.
await page.locator('app-album-page').evaluate((element) => element.classList.remove('ion-page-hidden'));
await page.evaluate(() => (window as Window & { releaseSearchDestinationIcons(): void }).releaseSearchDestinationIcons());
await expect(fab).not.toHaveAttribute('data-native-ui-projected');
expect(
await page.evaluate(() =>
Capacitor.registerPlugin<NativeUIMock>('NativeUI')
.updates.at(-1)
?.controls.some((control) => control.search?.available),
),
).toBe(false);
await page.locator('app-album-page').evaluate((element) => element.classList.add('ion-page-hidden'));
expect(await page.evaluate(() => (window as Window & { searchOwnershipGaps: string[] }).searchOwnershipGaps)).toEqual([]);
});

test(`tab switches retire old controls before destination icons finish (${edge ?? 'horizontal'})`, async ({ page }) => {
await page.setViewportSize({ width: 402, height: 874 });
await mockNative(page, false, edge);
await page.route('https://picsum.photos/**', (route) => route.abort());
await page.goto('/main/index/native-ui');
if (edge) await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
const segment = page.locator('app-native-ui ion-segment');
await expect(segment).toHaveAttribute('data-native-ui-projected', '');
const outgoing = await page.evaluate(() => {
const controls = Capacitor.registerPlugin<NativeUIMock>('NativeUI').updates.at(-1)!.controls;
return controls.filter((control) => control.kind !== 'tab-bar').map((control) => control.id);
});
await page.evaluate((ids) => {
const state = Capacitor.registerPlugin<NativeUIMock>('NativeUI');
const update = state.update.bind(state);
state.update = async (snapshot) => {
if (!snapshot.controls.some((control) => ids.includes(control.id))) {
const evidence = window as Window & { tabRetiredAt?: string };
evidence.tabRetiredAt ??= location.pathname;
}
return update(snapshot);
};
const decode = HTMLImageElement.prototype.decode;
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
HTMLImageElement.prototype.decode = async function () {
await decode.call(this);
if (this.src.startsWith('blob:')) await gate;
};
Object.assign(window, {
releaseTabIcons: () => {
HTMLImageElement.prototype.decode = decode;
release();
},
});
}, outgoing);
await activate(page, 'Library');
await expect(page).toHaveURL(/\/main\/album/);
expect(await page.evaluate(() => (window as Window & { tabRetiredAt?: string }).tabRetiredAt)).toBe('/main/index/native-ui');
await expect
.poll(() =>
page.evaluate((ids) => {
const snapshot = Capacitor.registerPlugin<NativeUIMock>('NativeUI').updates.at(-1)!;
return snapshot.controls.some((control) => ids.includes(control.id));
}, outgoing),
)
.toBe(false);
await expect
.poll(() =>
page.evaluate(() => {
const snapshot = Capacitor.registerPlugin<NativeUIMock>('NativeUI').updates.at(-1)!;
return snapshot.controls.some((control) => control.kind === 'tab-bar');
}),
)
.toBe(true);
await page.evaluate(() => (window as Window & { releaseTabIcons(): void }).releaseTabIcons());
await activate(page, 'Index');
await expect(segment).toHaveAttribute('data-native-ui-projected', '');
await page.evaluate(() => {
Capacitor.registerPlugin<NativeUIMock>('NativeUI').delay = 150;
});
await activate(page, 'Library');
await activate(page, 'Docs');
await expect(page).toHaveURL(/\/main\/docs/);
await activate(page, 'Index');
await expect(segment).toHaveAttribute('data-native-ui-projected', '');
// A rejected click must return the unchanged page to native ownership.
await page.locator('ion-tab-button[tab="album"]').evaluate((button) => {
button.addEventListener('click', (event) => event.stopImmediatePropagation(), { capture: true, once: true });
});
await activate(page, 'Library');
await expect(segment).not.toHaveAttribute('data-native-ui-projected');
await expect(segment).toHaveAttribute('data-native-ui-projected', '');
await expect(page).toHaveURL(/\/main\/index\/native-ui/);
});
}

for (const edge of [null, 'trailing'] as const) {
test(`rejected tab selection preserves the next stack transition (${edge ?? 'horizontal'})`, async ({ page }) => {
await page.setViewportSize({ width: 402, height: 874 });
await mockNative(page, false, edge);
await page.goto('/main/index');
if (edge) await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
const menu = page.locator('index-page > ion-header ion-buttons[slot="end"]');
await expect(menu).toHaveAttribute('data-native-ui-projected', '');
await page.locator('ion-tab-button[tab="album"]').evaluate((button) => {
button.addEventListener('click', (event) => event.stopImmediatePropagation(), { capture: true, once: true });
});
await activate(page, 'Library');
await expect(menu).not.toHaveAttribute('data-native-ui-projected');
await expect(menu).toHaveAttribute('data-native-ui-projected', '');
await expect(page).toHaveURL(/\/main\/index/);
await page.getByText('native-ui', { exact: true }).click();
if (edge) {
const depth = () =>
page.evaluate(() => Capacitor.registerPlugin<NativeUIMock>('NativeUI').updates.at(-1)?.verticalBarNavigation?.length);
await expect.poll(depth).toBe(2);
await page.goBack();
await expect(page).toHaveURL(/\/main\/index/);
await expect.poll(depth).toBe(1);
return;
}
await expect
.poll(() =>
page.evaluate(() => {
const state = Capacitor.registerPlugin<NativeUIMock>('NativeUI');
return (
state.updates.some((snapshot) =>
snapshot.toolbarTransition?.destination.some((control) => control.items.some((item) => item.accessibilityLabel === 'Save')),
) && state.toolbarProgress.length > 0
);
}),
)
.toBe(true);
});
}

test('tab switches hand off without a crossfade', async ({ page }) => {
await mockNative(page);
await page.goto('/main/index/native-ui');
Expand Down Expand Up @@ -3182,7 +3407,7 @@ test('rejected search retries when tab content changes without resizing', async
await page
.locator('ion-tab-button ion-label')
.first()
.evaluate((el) => (el.textContent = 'Index'));
.evaluate((el) => (el.textContent = 'Retry'));
await expect(footer).toHaveAttribute('data-native-ui-projected', '');
expect(await page.locator('ion-tab-bar').boundingBox()).toEqual(before);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getNativeSearchBindings, requestNativeSearch } from '../../native-integration';
import type { NativeSearchBinding } from '../../native-integration';
import { excluded, inFixedToolbar, isNativeUIDisabled, marker, text, visible, withoutPrehide } from '../shared/dom';
import { excluded, inFixedToolbar, isNativeUIDisabled, marker, prehiddenClass, text, visible, withoutPrehide } from '../shared/dom';
import { iconSource } from '../shared/icons';
import type { Candidate } from '../shared/candidate';
import type { NativeUIItem, NativeUISearch, NativeUISearchEvent } from '../definitions';
Expand All @@ -24,6 +24,8 @@ interface SearchState {

export const createSearchSupport = (doc: Document, id: (element: Element) => string, schedule: () => void) => {
const states = new Map<NativeSearchBinding, SearchState>();
const enteringTriggers = new Map<HTMLElement, NativeSearchBinding>();
const preparedBindings = new WeakSet<NativeSearchBinding>();
let keyboardVisible = false;
let heldKeyboardResize: string | null = null;
let keyboardResizeGeneration = 0;
Expand Down Expand Up @@ -177,6 +179,32 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
}
schedule();
},
prepare(scope: ParentNode = doc) {
for (const binding of getNativeSearchBindings(doc)) {
if (
(scope === doc && preparedBindings.has(binding)) ||
!scope.contains(binding.trigger) ||
!binding.trigger.closest('ion-app.ios-theme-vertical-bars') ||
binding.trigger.closest('.ion-page-hidden') ||
isNativeUIDisabled(binding.trigger)
)
continue;
preparedBindings.add(binding);
const trigger = binding.trigger.closest<HTMLElement>('ion-fab') ?? binding.trigger;
if (trigger.hasAttribute(marker) || enteringTriggers.has(trigger)) continue;
// Registration / WillEnter runs before the first page paint. Keep the
// original FAB hidden until native eligibility and acknowledgement settle.
trigger.classList.add(prehiddenClass);
enteringTriggers.set(trigger, binding);
}
},
settle(transitioning: (element: HTMLElement) => boolean) {
for (const [trigger, binding] of enteringTriggers) {
if (trigger.isConnected && !trigger.closest('.ion-page-hidden') && transitioning(binding.footer)) continue;
trigger.classList.remove(prehiddenClass);
enteringTriggers.delete(trigger);
}
},
keepSearchTabsVisible() {
if (!keyboardVisible) return;
for (const [binding, state] of states) {
Expand All @@ -198,7 +226,6 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
}
}
const inactive: [Candidate, NativeUISearch][] = [];
const pageHidden = (element: HTMLElement) => !!element.closest('.ion-page-hidden, .ion-page-invisible');
const retainInactive = (candidate: Candidate, state?: SearchState, keepAvailable = false) => {
// Keep NativeUISearchController across page transitions instead of demoting to UITabBar.
// While the searchable page is only blocked mid-transition (not yet hidden), keep
Expand All @@ -219,7 +246,12 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
const pageUnavailable = blocked(binding.footer) || !!binding.footer.closest(excluded);
const existing = states.get(binding);
if (pageUnavailable) {
retainInactive(candidate, existing, !!existing?.last?.available && !pageHidden(binding.footer));
const keepAvailable = !!existing?.last?.available && !binding.footer.closest(excluded);
retainInactive(candidate, existing, keepAvailable);
// The retained native search still owns its Web sources until the page
// hides. Releasing only those sources briefly draws both search buttons.
if (keepAvailable)
candidate.sources = [binding.tabBar, binding.trigger.closest<HTMLElement>('ion-fab') ?? binding.trigger, binding.footer];
// A first registration on a cached/hidden page has no measurable trigger.
// Keep ordinary tabs until the page becomes available; only reuse a previous valid search.
continue;
Expand Down Expand Up @@ -476,8 +508,9 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
}
schedule();
},
retire(binding: NativeSearchBinding) {
retire(binding: NativeSearchBinding, unavailable = false) {
const state = states.get(binding);
if (unavailable && state?.last) state.last = { ...state.last, available: false, active: false, focused: false };
// Keep ownership until keyboardWillHide can release this class.
if (state?.keyboardHidden && keyboardVisible) binding.tabBar.classList.add('tab-bar-hidden');
if (state?.focused) {
Expand All @@ -488,6 +521,8 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
releaseKeyboardResize();
},
destroy() {
for (const trigger of enteringTriggers.keys()) trigger.classList.remove(prehiddenClass);
enteringTriggers.clear();
states.forEach((state) => {
this.retire(state.binding);
state.restore();
Expand Down
Loading
Loading