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
4 changes: 2 additions & 2 deletions demo/e2e/native-ui-shell-edge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,11 +440,11 @@ test('native geometry rejection paints Web before retirement and retries after l
expect(retirement.frame - retirement.visibleFrame).toBeGreaterThanOrEqual(2);
const counts = await page.evaluate(async () => {
const state = Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell');
// Let the restoration's mutation notification settle, then re-read identical data.
// Re-read identical data without a resize, which explicitly retries native geometry.
for (let frame = 0; frame < 4; frame++) await new Promise(requestAnimationFrame);
const before = state.updates.length;
for (let frame = 0; frame < 12; frame++) {
window.dispatchEvent(new Event('resize'));
window.dispatchEvent(new Event('scroll'));
await new Promise(requestAnimationFrame);
}
return { before, after: state.updates.length };
Expand Down
161 changes: 135 additions & 26 deletions demo/e2e/native-ui-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,35 +421,121 @@ test('standalone Vertical Control Area never snapshots ordinary Native UI Shell
.toBe(true);
});

test('standalone vertical bars keep searchable tabs usable on the Web', async ({ page }) => {
await mockNative(page);
await page.route('https://picsum.photos/**', (route) => route.abort());
await page.goto('/main/album?verticalBarsOnly=1');
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
await expect
.poll(() =>
for (const standalone of [false, true]) {
test(`vertical native search preserves rail tabs (${standalone ? 'standalone' : 'full shell'})`, async ({ page }) => {
await mockNative(page);
await page.route('https://picsum.photos/**', (route) => route.abort());
await page.goto(`/main/album${standalone ? '?verticalBarsOnly=1' : ''}`);
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
const footer = page.locator('app-album-page ion-footer');
const fab = page.locator('app-album-page ion-fab');
const tabs = page.locator('ion-tab-bar');
const config = () =>
page.evaluate(() =>
Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell')
.updates.at(-1)
?.controls.some((control: ShellControl) => control.placement === 'vertical-bars'),
),
)
.toBe(true);
// The rail has no search surface, so no control may carry a search payload.
await expect
.poll(() =>
page.evaluate(() =>
Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell')
.updates.at(-1)!
.controls.every((control: ShellControl) => !control.search),
),
)
.toBe(true);
// The Web search trigger and footer stay visible and interactive.
const fab = page.locator('app-album-page ion-fab');
await expect(fab).toBeVisible();
await expect(fab).not.toHaveAttribute('data-native-ui-shell');
await expect(page.locator('app-album-page ion-footer')).toBeVisible();
?.controls.find((control: ShellControl) => control.placement === 'vertical-bars' && control.search),
);
await expect.poll(config).toBeTruthy();
await expect(footer).toHaveAttribute('data-native-ui-shell', '');
await expect(fab).toHaveAttribute('data-native-ui-shell', '');
const tabState = (control: ShellControl) => control.items.map(({ id, selected }) => ({ id, selected }));
const items = tabState((await config())!);
await page.evaluate(() => {
const state = Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell');
const snapshot = state.updates.at(-1)!;
const search = snapshot.controls.find((control: ShellControl) => control.search)!.search!;
state.notifyListeners('activate', { id: search.trigger.id, revision: snapshot.revision, sequence: ++state.sequence });
});
await expect.poll(async () => (await config())?.search?.active).toBe(true);
expect(tabState((await config())!)).toEqual(items);
await expect(tabs).toHaveAttribute('data-native-ui-shell', '');
// Native enter must bypass the Web search morph.
await expect(footer).toHaveCSS('opacity', '0');
await expect(tabs).toHaveCSS('opacity', '1');
await page.evaluate(() => {
const state = Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell');
const snapshot = state.updates.at(-1)!;
const search = snapshot.controls.find((control: ShellControl) => control.search)!.search!;
state.notifyListeners('search', {
id: search.id,
phase: 'input',
value: 'native query',
composing: false,
valueVersion: search.valueVersion,
revision: snapshot.revision,
sequence: ++state.sequence,
});
});
await expect.poll(() => footer.locator('ion-searchbar').evaluate((bar: HTMLIonSearchbarElement) => bar.value)).toBe('native query');
await page.evaluate(() => {
const state = Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell');
const snapshot = state.updates.at(-1)!;
const search = snapshot.controls.find((control: ShellControl) => control.search)!.search!;
state.notifyListeners('activate', { id: search.closeId, revision: snapshot.revision, sequence: ++state.sequence });
});
await expect.poll(async () => (await config())?.search?.active).toBe(false);
await expect(tabs).toHaveAttribute('data-native-ui-shell', '');
expect(tabState((await config())!)).toEqual(items);
// Late icon hydration must not add the Web close button to the native rail.
await footer.locator('ion-buttons[slot=start] ion-icon').evaluate((icon) => icon.setAttribute('name', 'close'));
for (let cycle = 0; cycle < 3; cycle++) {
await page.evaluate(() => {
const state = Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell');
const snapshot = state.updates.at(-1)!;
const search = snapshot.controls.find((control: ShellControl) => control.search)!.search!;
state.notifyListeners('activate', { id: search.trigger.id, revision: snapshot.revision, sequence: ++state.sequence });
});
await expect.poll(async () => (await config())?.search?.active).toBe(true);
await footer.locator('ion-searchbar').evaluate((bar: HTMLIonSearchbarElement) => bar.setFocus());
await expect.poll(async () => (await config())?.search?.focused).toBe(true);
await page.evaluate(() => {
const state = Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell');
const snapshot = state.updates.at(-1)!;
const search = snapshot.controls.find((control: ShellControl) => control.search)!.search!;
state.notifyListeners('activate', { id: search.closeId, revision: snapshot.revision, sequence: ++state.sequence });
});
await expect.poll(async () => (await config())?.search?.active).toBe(false);
expect(tabState((await config())!)).toEqual(items);
}
await activate(page, 'Index');
await activate(page, 'Library');
await expect.poll(async () => (await config())?.search?.available).toBe(true);
});
}

test('vertical Web tabs remain visible while searching without a native rail', async ({ page }) => {
await mockNative(page, false, null);
await page.route('https://picsum.photos/**', (route) => route.abort());
await page.goto('/main/album?verticalBarsOnly=1');
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
const tabs = page.locator('ion-tab-bar');
await expect(tabs).not.toHaveAttribute('data-native-ui-shell');
await page.locator('app-album-page ion-fab-button').click();
const footer = page.locator('app-album-page ion-footer');
await expect(footer).toHaveCSS('opacity', '1');
await tabs.evaluate((element) => element.classList.add('tab-bar-hidden'));
await expect(tabs).toBeVisible();
await expect(tabs).toHaveCSS('opacity', '1');
await expect(tabs.locator('ion-tab-button.tab-selected')).toBeVisible();
await expect(tabs.locator('ion-tab-button').first()).toHaveCSS('opacity', '1');
await footer.locator('ion-buttons[slot=start] ion-button').click();
await expect(footer).toHaveCSS('opacity', '0');
await expect(tabs).toHaveCSS('opacity', '1');
let bounds: { x: number; width: number } | undefined;
for (let cycle = 0; cycle < 3; cycle++) {
await page.locator('app-album-page ion-fab-button').click();
await expect(footer).toHaveCSS('opacity', '1');
const frame = await footer.locator('ion-searchbar').evaluate((bar) => {
const { x, width } = bar.getBoundingClientRect();
return { x, width };
});
if (bounds) expect(frame).toEqual(bounds);
bounds = frame;
await footer.locator('ion-buttons[slot=start] ion-button').click();
await expect(footer).toHaveCSS('opacity', '0');
await expect(tabs).toHaveCSS('opacity', '1');
}
});

test('verticalBars back navigation and toolbar slots request native rail placement', async ({ page }) => {
Expand Down Expand Up @@ -1242,6 +1328,29 @@ test('delayed response cannot reclaim a hidden source', async ({ page }) => {
.toBe(false);
});

for (const event of ['foreground', 'window-resize', 'visual-viewport-resize'] as const) {
test(`${event} restores native tabs even when the control snapshot is unchanged`, async ({ page }) => {
await mockNative(page);
await page.goto('/main/index');
await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-shell', '');
// Wait for the selected-tab icon animation to finish before comparing snapshots.
await page.waitForTimeout(750);
const previous = await page.evaluate((event) => {
const snapshot = Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').updates.at(-1)!;
if (event === 'foreground') document.dispatchEvent(new Event('visibilitychange'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 前景復帰テストは非表示期間を再現しない

表示中の document に visibilitychange を送るため、非表示時の検索終了と復帰後の表示回復は通らない。実機のバックグラウンド復帰も確認したい。

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

else if (event === 'window-resize') window.dispatchEvent(new Event('resize'));
else window.visualViewport!.dispatchEvent(new Event('resize'));
Comment on lines +1341 to +1342

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 リサイズテストは寸法を変えない

resize イベントのみを送るため、寸法変更後の再測定やネイティブ配置は検証できない。回転・折りたたみ時の実寸法で確認したい。

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

return snapshot;
}, event);
await expect
.poll(() => page.evaluate(() => Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').updates.at(-1)!.revision))
.toBeGreaterThan(previous.revision);
const restored = await page.evaluate(() => Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').updates.at(-1)!);
expect(restored.controls).toEqual(previous.controls);
await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-shell', '');
});
}

test('native refresh during a pending acknowledgement resends unchanged controls', async ({ page }) => {
await mockNative(page);
await page.goto('/main/index/native-ui-shell');
Expand Down
8 changes: 5 additions & 3 deletions docs/native-ui-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ The native host accepts input only within native controls. Tab interaction and a

## Searchable tabs

Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations automatically use native search when their bottom tab bar and glass search controls are supported. No new component option, route, native setup or page listener is required. Ordinary tab bars continue using `UITabBar`; searchable groups use a persistent `UITabBarController`, `UITab` / `UISearchTab`, and `UISearchController`. The original Capacitor WebView continues to render results and handle navigation.
Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations automatically use native search when their bottom tab bar and glass search controls are supported. No new component option, route, native setup or page listener is required. Ordinary tab bars continue using `UITabBar`; horizontal searchable groups use a persistent `UITabBarController`, `UITab` / `UISearchTab`, and `UISearchController`. The original Capacitor WebView continues to render results and handle navigation.

With vertical bars, the native rail keeps every tab and its selection. SwiftUI’s `searchable` and `searchToolbarBehavior(.minimize)` provide the system search button, field, dismissal control and transitions within the same native navigation surface. Apple determines their placement for the current device layout and focuses the native field when system search opens. Input, focus, clear and submit events use the same bridge as horizontal search. When native search is unavailable, the Web search presentation also preserves vertical tabs.

Search registration does not bypass placement restrictions. Its searchbar and close button must be in fixed footer toolbars. Its trigger must belong to an `ion-fab[slot="fixed"]` directly inside `ion-content`, or directly on the existing non-scrolling `.ion-page` layout. A wrapper inside scrolling content is not a fixed slot.

While a registration is alive, Native UI Shell keeps the searchable controller even when the page is transitioning or temporarily unavailable (`available: false`), instead of demoting back to a separate ordinary control identity. Resting chrome (including Album before search opens) uses the same `UITabBar` + `ShellTabBar.fit` path as ordinary tabs, with the search trigger pinned to the FAB when available; `UISearchTab` is shown only while the search session is active. Flipping availability or entering/leaving search crossfades between those layers. Register before the destination page finishes entering (for example in `ionViewWillEnter`) so the first visit does not paint ordinary tabs and then swap.
For horizontal tabs, while a registration is alive, Native UI Shell keeps the searchable controller even when the page is transitioning or temporarily unavailable (`available: false`), instead of demoting back to a separate ordinary control identity. Resting chrome (including Album before search opens) uses the same `UITabBar` + `ShellTabBar.fit` path as ordinary tabs, with the search trigger pinned to the FAB when available; `UISearchTab` is shown only while the search session is active. Flipping availability or entering/leaving search crossfades between those layers. Register before the destination page finishes entering (for example in `ionViewWillEnter`) so the first visit does not paint ordinary tabs and then swap.

Opening search preserves the selected Ionic tab and does not automatically show the keyboard (`automaticallyActivatesSearch` stays off). Tap the field or call `ion-searchbar.setFocus()` for the keyboard. While search is active, Native UI Shell freezes Web layout projection and holds Capacitor Keyboard resize at `none`; UIKit owns tab/search chrome (no Ionic `fit` mid-session). Leave re-fits ordinary tabs to `ion-tab-bar` and search to the FAB. Ordinary native tabs keep an optimistic selection until the Web `selected` state catches up. Input events and application `value` updates continue across the bridge until search closes. The trigger's resolved SVG and the search icon are projected from Ionic, including `ion-icon name`.
With horizontal tabs, opening search preserves the selected Ionic tab and does not automatically show the keyboard (`automaticallyActivatesSearch` stays off). Tap the field or call `ion-searchbar.setFocus()` for the keyboard. While search is active, Native UI Shell freezes Web layout projection and holds Capacitor Keyboard resize at `none`; UIKit owns tab/search chrome (no Ionic `fit` mid-session). Leave re-fits ordinary tabs to `ion-tab-bar` and search to the FAB. Ordinary native tabs keep an optimistic selection until the Web `selected` state catches up. Input events and application `value` updates continue across the bridge until search closes. The trigger's resolved SVG and the search icon are projected from Ionic, including `ion-icon name`.

Native edits pass through Ionic's input handlers, preserving `ionInput` debounce, `ionChange`, `ionFocus`, `ionBlur`, and `ionClear`. Programmatic `value` changes do not emit `ionInput`; synchronous application corrections and stale native input are distinguished. Native editing owns marked text and the caret. Returning through the footer's close action retains the value and does not emit `ionCancel` or `ionClear`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ final class ShellSearchHost: UIView {
}

// Preserve UISearchBar's delegate while returning its clear action to Ionic.
private final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate {
final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate {
weak var original: UITextFieldDelegate?
var clear: (() -> Void)?
override func responds(to selector: Selector!) -> Bool {
Expand Down
Loading
Loading