From 2d8817659f1137f2c5518979de5744c1fb95e2ea Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 28 Sep 2026 13:09:03 +0900 Subject: [PATCH 1/4] fix: refresh native controls after foreground and layout changes --- demo/e2e/native-ui-shell.spec.ts | 23 +++++++++++++++++++++++ src/native/runtime.ts | 19 ++++++++++++------- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index dc9344fd..ba4ccad8 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -1242,6 +1242,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('IonicNativeUIShell').updates.at(-1)!; + if (event === 'foreground') document.dispatchEvent(new Event('visibilitychange')); + else if (event === 'window-resize') window.dispatchEvent(new Event('resize')); + else window.visualViewport!.dispatchEvent(new Event('resize')); + return snapshot; + }, event); + await expect + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.revision)) + .toBeGreaterThan(previous.revision); + const restored = await page.evaluate(() => Capacitor.registerPlugin('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'); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index f76bc75b..82126485 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -608,24 +608,29 @@ export const createRuntime = async ( 'focusout', ]) on(doc, name); - on(doc, 'visibilitychange', () => { - if (doc.hidden) getNativeSearchBindings(doc).forEach((binding) => search.retire(binding)); + const refreshLayout = () => { + rejected = new WeakMap(); + forceRefresh = true; schedule(); + }; + on(doc, 'visibilitychange', () => { + if (doc.hidden) { + getNativeSearchBindings(doc).forEach((binding) => search.retire(binding)); + schedule(); + } else refreshLayout(); }); - on(win, 'resize'); + on(win, 'resize', refreshLayout); on(win, 'keyboardWillShow', () => search.keyboard(true)); on(win, 'keyboardWillHide', () => search.keyboard(false)); on(win, 'scroll'); on(win.matchMedia('(prefers-color-scheme: dark)'), 'change'); on(win, 'nativeUIShellRefresh', (event) => { - rejected = new WeakMap(); if ((event as Event & { retireSearch?: boolean }).retireSearch) getNativeSearchBindings(doc).forEach((binding) => search.retire(binding)); - forceRefresh = true; - schedule(); + refreshLayout(); }); if (win.visualViewport) { - on(win.visualViewport, 'resize'); + on(win.visualViewport, 'resize', refreshLayout); on(win.visualViewport, 'scroll'); } const handle: NativeUIShellHandle = { From 35a6f4e997a3edaa8ed664b292ac2ada8a8e145d Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 28 Sep 2026 14:44:22 +0900 Subject: [PATCH 2/4] fix: use system search presentation in vertical bars --- demo/e2e/native-ui-shell.spec.ts | 138 ++++++++++++++---- docs/native-ui-shell.md | 8 +- .../Components/ShellSearchController.swift | 2 +- .../Components/ShellVerticalBars.swift | 29 +++- .../Components/ShellVerticalSearch.swift | 118 +++++++++++++++ .../IonicNativeUIShellPlugin.swift | 25 +++- .../ShellSnapshotTests.swift | 37 +++++ src/native/components/searchable-tabs.ts | 10 +- src/tab-bar-searchable/index.ts | 49 +++++-- 9 files changed, 358 insertions(+), 58 deletions(-) create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index ba4ccad8..2942f56e 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -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('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('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('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('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('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('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('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 }) => { diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 18bb3e8c..2ba41fb2 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -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`. diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift index 0a79ea39..df818394 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift @@ -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 { diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index 7acce49f..bc613ce6 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -6,6 +6,7 @@ protocol ShellVerticalBarsControlling: AnyObject { var view: UIView { get } func attach(to owner: UIViewController, in parent: UIView) @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) + var ownsKeyboardChrome: Bool { get } func detach() } @@ -30,6 +31,7 @@ final class ShellVerticalBarsModel: ObservableObject { let slot: ShellControl.ToolbarSlot? } + @Published var search: ShellVerticalSearchModel? @Published var back: Item? @Published var groups: [Group] = [] @Published var tabs: [Item] = [] @@ -163,6 +165,7 @@ private struct ShellVerticalBarsView: View { } } } + .modifier(ShellVerticalBarsSearch(model: model)) .modifier(ShellVerticalBarsCompression()) .background(Color.clear) } @@ -179,6 +182,16 @@ private struct ShellVerticalBarsPage: View { } } +@available(iOS 26.0, *) +private struct ShellVerticalBarsSearch: ViewModifier { + @ObservedObject var model: ShellVerticalBarsModel + @ViewBuilder func body(content: Content) -> some View { + if let search = model.search { + content.modifier(ShellVerticalSearchModifier(model: search)) + } else { content } + } +} + @available(iOS 26.0, *) private struct ShellVerticalBarsCompression: ViewModifier { @ViewBuilder func body(content: Content) -> some View { @@ -271,9 +284,11 @@ private struct ShellVerticalBarsLegacyToolbar: ViewModifier { final class ShellVerticalBarsController: ShellVerticalBarsControlling { private final class TransparentHostingController: UIHostingController { var railEdge = "right" + var layoutSearch: (() -> Void)? override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() makeFullSizeSurfacesTransparent(in: view) + layoutSearch?() } private func makeFullSizeSurfacesTransparent(in surface: UIView) { @@ -323,7 +338,7 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool { guard !view.isHidden, view.alpha > 0.05 else { return false } let name = NSStringFromClass(type(of: view)) - if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar")), + if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar") || view is UISearchBar || view is UINavigationBar), convert(view.bounds, from: view).contains(point) { return true } return view.subviews.contains { containsBarSurface(in: $0, at: point) } } @@ -333,14 +348,20 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { private lazy var controller = TransparentHostingController(rootView: ShellVerticalBarsView(model: model)) private let container = RailContainer() private weak var owner: UIViewController? + private let changed: (String, ShellSearchPhase, String, Bool, Int) -> Int var view: UIView { container } - init(activate: @escaping (String) -> Void) { + var ownsKeyboardChrome: Bool { model.search?.ownsKeyboardChrome == true } + + init(activate: @escaping (String) -> Void, + changed: @escaping (String, ShellSearchPhase, String, Bool, Int) -> Int) { + self.changed = changed model.activate = activate container.backgroundColor = .clear controller.view.backgroundColor = .clear controller.view.isOpaque = false + controller.layoutSearch = { [weak self] in self?.model.search?.configureField() } } func attach(to owner: UIViewController, in parent: UIView) { @@ -361,6 +382,10 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { container.railEdge = edge controller.railEdge = edge let tabsAppear = model.tabs.isEmpty && controls.contains { $0.kind == .tabBar && !$0.items.isEmpty } + if let search = controls.first(where: { $0.search != nil })?.search { + if model.search?.configuration.id == search.id { model.search?.apply(search) } + else { model.search = ShellVerticalSearchModel(search, in: container, activate: model.activate, changed: changed) } + } else { model.search = nil } model.apply(controls, rendering: rendering) if tabsAppear { ShellCrossfade.enter(container, duration: ShellCrossfade.duration(180)) } controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift new file mode 100644 index 00000000..09fb7038 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift @@ -0,0 +1,118 @@ +import Combine +import SwiftUI +import UIKit + +@available(iOS 26.0, *) +final class ShellVerticalSearchModel: ObservableObject { + @Published private(set) var configuration: ShellSearch + @Published var text: String + @Published var presented: Bool + @Published var focused: Bool + private weak var railView: UIView? + private let activate: (String) -> Void + private let changed: (String, ShellSearchPhase, String, Bool, Int) -> Int + private let inputDelegate = ShellSearchInputDelegate() + private var editingSequence = 0 + private var valueVersion: Int + + init(_ configuration: ShellSearch, in railView: UIView, + activate: @escaping (String) -> Void, + changed: @escaping (String, ShellSearchPhase, String, Bool, Int) -> Int) { + self.configuration = configuration + self.railView = railView + self.activate = activate + self.changed = changed + text = configuration.value + presented = configuration.available && configuration.active + focused = configuration.focused + valueVersion = configuration.valueVersion + inputDelegate.clear = { [weak self] in self?.emit(.clear) } + } + + var ownsKeyboardChrome: Bool { presented && (focused || textField?.isFirstResponder == true) } + + private var textField: UISearchTextField? { + func find(_ view: UIView) -> UISearchTextField? { + guard !view.isHidden, view.alpha > 0.01 else { return nil } + if let field = view as? UISearchTextField { return field } + for child in view.subviews { + if let field = find(child) { return field } + } + return nil + } + return railView.flatMap(find) + } + + func apply(_ next: ShellSearch) { + let previous = configuration + configuration = next + if next.valueVersion != valueVersion || next.editSequence >= editingSequence { + valueVersion = next.valueVersion + text = next.value + } + presented = next.available && next.active + // The first Web acknowledgement still carries the old focus state. + if !next.active { focused = false } + else if previous.focused != next.focused { focused = next.focused } + } + + func input(_ value: String) { + text = value + emit(.input) + } + + func present(_ value: Bool) { + presented = value + if value != configuration.active { + activate(value ? configuration.trigger.id : configuration.closeId) + } + } + + func focus(_ value: Bool) { + focused = value + emit(value ? .focus : .blur) + } + + func commit() { emit(.commit) } + + func configureField() { + guard let field = textField else { return } + field.accessibilityIdentifier = configuration.id + field.accessibilityLabel = configuration.field.accessibilityLabel + field.isEnabled = !configuration.disabled + field.autocapitalizationType = .none + field.autocorrectionType = .no + field.spellCheckingType = .no + field.clearButtonMode = .always + if field.delegate !== inputDelegate { + inputDelegate.original = field.delegate + field.delegate = inputDelegate + } + } + + private func emit(_ phase: ShellSearchPhase) { + editingSequence = changed(configuration.id, phase, text, + textField?.markedTextRange != nil, valueVersion) + } +} + +@available(iOS 26.0, *) +struct ShellVerticalSearchModifier: ViewModifier { + @ObservedObject var model: ShellVerticalSearchModel + @FocusState private var focused: Bool + + @ViewBuilder func body(content: Content) -> some View { + if model.configuration.available { + content + .searchable(text: Binding(get: { model.text }, set: model.input), + isPresented: Binding(get: { model.presented }, set: model.present), + placement: .toolbar, prompt: Text(model.configuration.placeholder)) + .searchToolbarBehavior(.minimize) + .searchPresentationToolbarBehavior(.avoidHidingContent) + .searchFocused($focused) + .onSubmit(of: .search, model.commit) + .onChange(of: focused) { _, value in model.focus(value) } + .onChange(of: model.focused) { _, value in focused = value } + } else { content } + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index b345745d..0bac7c88 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -46,7 +46,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self.host?.isHidden = true self.verticalBars?.view.isHidden = true } - var searchOwnsKeyboard = false + var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true self.searchControllers.values.forEach { controller in if controller.ownsKeyboardChrome { searchOwnsKeyboard = true } if !keyboard { controller.surface.isHidden = true } @@ -195,6 +195,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele let existing = Set(self.controls.keys) let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars } let snapshots = snapshot.controls.filter { $0.placement != .verticalBars } + let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false } let width = snapshot.viewportWidth self.revision = next if snapshots.isEmpty && verticalBars.isEmpty { @@ -210,12 +211,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele var fabs: [(ShellFab, ShellControl)] = [] var searches: [(ShellSearchControlling, ShellControl, CGRect, CGRect, UIView?, Bool)] = [] var rejectedSearches: [String] = [] - if verticalBars.isEmpty || self.keyboardVisible { + if verticalBars.isEmpty || (self.keyboardVisible && !verticalSearchActive) { self.verticalBars?.detach() self.verticalBars = nil if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) } } else if let owner = self.bridge?.viewController { - let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) }) + let rail = self.verticalBars ?? ShellVerticalBarsController( + activate: { [weak self] id in self?.activate(id) }, + changed: { [weak self] id, phase, value, composing, valueVersion in + self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0 + }) self.verticalBars = rail rail.attach(to: owner, in: owner.view) if let frame = snapshot.verticalBarFrame { @@ -274,11 +279,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele controller = ShellSearchController() controller.activate = { [weak self] id in self?.activate(id) } controller.changed = { [weak self] id, phase, value, composing, valueVersion in - guard let self else { return 0 } - self.sequence += 1 - self.notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value, - "composing": composing, "valueVersion": valueVersion, "sequence": self.sequence, "revision": self.revision]) - return self.sequence + self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0 } controller.attach(to: owner, in: owner.view) self.searchControllers[id] = controller @@ -379,6 +380,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele activate(itemId) } + private func searchChanged(_ id: String, phase: ShellSearchPhase, value: String, + composing: Bool, valueVersion: Int) -> Int { + sequence += 1 + notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value, + "composing": composing, "valueVersion": valueVersion, "sequence": sequence, "revision": revision]) + return sequence + } + private func activate(_ id: String) { sequence += 1 notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence]) diff --git a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift index 6494dd42..7a533d9e 100644 --- a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +++ b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift @@ -607,4 +607,41 @@ final class ShellSnapshotTests: XCTestCase { XCTAssertEqual(phases, ["input"]) } + func testVerticalSearchBridgesInputFocusAndDismissalWithoutStaleWebEchoes() throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native glass search") } + func node(active: Bool, value: String = "", version: Int = 0) throws -> ShellControl { + let search: JSObject = ["id": "search", "field": item(), + "trigger": item(["id": "trigger"]), "closeId": "close", "active": active, + "available": true, "focused": false, "value": value, "placeholder": "Search", + "disabled": false, "editSequence": 0, "valueVersion": version] + return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "placement": "vertical-bars", + "search": search, "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first) + } + var actions: [String] = [] + var values: [String] = [] + let rail = UIView() + let model = ShellVerticalSearchModel(try XCTUnwrap(node(active: false).search), in: rail, + activate: { actions.append($0) }, + changed: { _, _, value, _, _ in values.append(value); return values.count }) + model.present(true) + XCTAssertEqual(actions, ["trigger"]) + model.apply(try XCTUnwrap(node(active: true).search)) + XCTAssertTrue(model.presented) + model.input("native query") + XCTAssertEqual(values, ["native query"]) + model.apply(try XCTUnwrap(node(active: true).search)) + XCTAssertEqual(model.text, "native query") + // The first presentation acknowledgement must not undo native focus. + model.focus(true) + model.apply(try XCTUnwrap(node(active: true).search)) + XCTAssertTrue(model.focused) + model.apply(try XCTUnwrap(node(active: true, value: "application", version: 1).search)) + XCTAssertEqual(model.text, "application") + model.present(false) + XCTAssertEqual(actions, ["trigger", "close"]) + model.apply(try XCTUnwrap(node(active: false).search)) + XCTAssertFalse(model.presented) + XCTAssertFalse(model.focused) + } + } diff --git a/src/native/components/searchable-tabs.ts b/src/native/components/searchable-tabs.ts index b595c48e..5c5db14b 100644 --- a/src/native/components/searchable-tabs.ts +++ b/src/native/components/searchable-tabs.ts @@ -202,15 +202,7 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str }; for (const binding of bindings) { const candidate = candidates.find((c) => c.element === binding.tabBar); - // The vertical rail has no search surface; hiding the FAB/footer there - // would leave searchable tabs without any search control. - if ( - !candidate || - candidate.control.search || - candidate.control.placement === 'vertical-bars' || - binding.tabBar.getAttribute('slot') !== 'bottom' - ) - continue; + if (!candidate || candidate.control.search || binding.tabBar.getAttribute('slot') !== 'bottom') continue; const fab = binding.trigger.parentElement; // Search also supports the existing page-level fixed FAB, outside scrolling content. if ( diff --git a/src/tab-bar-searchable/index.ts b/src/tab-bar-searchable/index.ts index 088ec2e2..d7f91743 100644 --- a/src/tab-bar-searchable/index.ts +++ b/src/tab-bar-searchable/index.ts @@ -64,6 +64,8 @@ export const attachTabBarSearchable = ( // Initialize ionFooter.style.pointerEvents = 'none'; ionFooter.style.opacity = '0'; + // Search dismissal belongs beside the Web search field, never in the vertical rail. + ionFooter.querySelector('ion-buttons[slot=start]')?.classList.add('ios-theme-horizontal-only'); const nativeSearch = registerNativeSearch(ionTabBar, ionFabButton, ionFooter); // Saved Params @@ -72,6 +74,8 @@ export const attachTabBarSearchable = ( // Leave is valid both while native Enter awaits its ack and after it completes. let nativeEntry = false; let nativeRequest = 0; + let verticalSearch = false; + let tabVisibilityObserver: MutationObserver | undefined; return async (event: Event, type: TabBarSearchableType) => { const selector = type === TabBarSearchableType.Enter ? 'ion-fab-button' : 'ion-buttons[slot=start] ion-button'; @@ -93,19 +97,38 @@ export const attachTabBarSearchable = ( return; } if (type === TabBarSearchableType.Enter) { - resumeNative ??= await suspendNativeUIShell([ionTabBar, ionFooter, ionFabButton.closest('ion-fab') ?? ionFabButton]); + verticalSearch = !!ionTabBar.closest('ion-app.ios-theme-vertical-bars'); + if (verticalSearch) { + // Ionic hides horizontal tabs when the keyboard opens; keep this rail available. + const keepTabsVisible = () => { + if (ionTabBar.classList.contains('tab-bar-hidden') && ionTabBar.closest('ion-app.ios-theme-vertical-bars')) { + ionTabBar.classList.remove('tab-bar-hidden'); + } + }; + tabVisibilityObserver?.disconnect(); + tabVisibilityObserver = new MutationObserver(keepTabsVisible); + tabVisibilityObserver.observe(ionTabBar, { attributes: true, attributeFilter: ['class'] }); + keepTabsVisible(); + } + resumeNative ??= await suspendNativeUIShell([ + ...(verticalSearch ? [] : [ionTabBar]), + ionFooter, + ionFabButton.closest('ion-fab') ?? ionFabButton, + ]); try { - searchableEventCache = await enterEvent(event, ionTabBar, ionFabButton, ionFooter); + searchableEventCache = await enterEvent(event, ionTabBar, ionFabButton, ionFooter, verticalSearch); } catch (error) { + tabVisibilityObserver?.disconnect(); resumeNative(); resumeNative = undefined; throw error; } } else if (searchableEventCache !== undefined) { try { - await leaveEvent(event, searchableEventCache, ionTabBar, ionFabButton, ionFooter); + await leaveEvent(event, searchableEventCache, ionTabBar, ionFabButton, ionFooter, verticalSearch); searchableEventCache = undefined; } finally { + tabVisibilityObserver?.disconnect(); resumeNative?.(); resumeNative = undefined; } @@ -120,6 +143,7 @@ const enterEvent = async ( ionTabBar: HTMLElement, ionFabButton: HTMLElement, ionFooter: HTMLElement, + verticalSearch: boolean, ): Promise => { if (!(event.target as HTMLElement)?.closest('ion-fab-button')) { throw throwErrorByFailedClickElement('ion-fab-button'); @@ -131,10 +155,12 @@ const enterEvent = async ( ? getComputedStyle(references.selectedTabButton).getPropertyValue('--color-selected').trim() : ''; - const effectAnimation = createEffectAnimation(references, sizes); + if (verticalSearch) references.closeButtonIcon.setAttribute('name', 'close'); + const tabAnimations = verticalSearch + ? [] + : [createTabBarAnimation(ionTabBar, references, sizes), createEffectAnimation(references, sizes)]; const searchContainerAnimation = createSearchContainerAnimation(references, sizes); const closeButtonsAnimation = createCloseButtonsAnimation(references); - const tabBarAnimation = createTabBarAnimation(ionTabBar, references, sizes); const fabButtonAnimation = createFabButtonAnimation(ionFabButton); await createAnimation() @@ -144,7 +170,7 @@ const enterEvent = async ( .addElement(ionFooter) .afterAddWrite(() => (ionFooter.style.pointerEvents = 'auto')) .fromTo('opacity', '0.8', '1') - .addAnimation([tabBarAnimation, fabButtonAnimation, searchContainerAnimation, effectAnimation, closeButtonsAnimation]) + .addAnimation([...tabAnimations, fabButtonAnimation, searchContainerAnimation, closeButtonsAnimation]) .play(); return { @@ -159,6 +185,7 @@ const leaveEvent = async ( ionTabBar: HTMLElement, ionFabButton: HTMLElement, ionFooter: HTMLElement, + verticalSearch: boolean, ): Promise => { if (!(event.target as HTMLElement)?.closest('ion-buttons[slot=start] ion-button')) { throw throwErrorByFailedClickElement('ion-buttons[slot=start] ion-button'); @@ -166,10 +193,14 @@ const leaveEvent = async ( const references = getElementReferences(ionTabBar, ionFooter); - const effectAnimation = createReverseEffectAnimation(references, searchableEventCache.elementSizes, searchableEventCache.colorSelected); + const tabAnimations = verticalSearch + ? [] + : [ + createReverseTabBarAnimation(ionTabBar, references, searchableEventCache.elementSizes), + createReverseEffectAnimation(references, searchableEventCache.elementSizes, searchableEventCache.colorSelected), + ]; const searchContainerAnimation = createReverseSearchContainerAnimation(references, searchableEventCache.elementSizes); const closeButtonsAnimation = createReverseCloseButtonsAnimation(references); - const tabBarAnimation = createReverseTabBarAnimation(ionTabBar, references, searchableEventCache.elementSizes); const fabButtonAnimation = createReverseFabButtonAnimation(ionFabButton, searchableEventCache.elementSizes); await createAnimation() @@ -179,6 +210,6 @@ const leaveEvent = async ( .addElement(ionFooter) .afterAddWrite(() => (ionFooter.style.pointerEvents = 'none')) .fromTo('opacity', '1', '0') - .addAnimation([tabBarAnimation, fabButtonAnimation, searchContainerAnimation, effectAnimation, closeButtonsAnimation]) + .addAnimation([...tabAnimations, fabButtonAnimation, searchContainerAnimation, closeButtonsAnimation]) .play(); }; From a9e07b3943e0c035776a3b124a5135d3bac7d59b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 28 Sep 2026 15:16:00 +0900 Subject: [PATCH 3/4] fix: restore search tabs after keyboard rotation --- src/native/components/searchable-tabs.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/native/components/searchable-tabs.ts b/src/native/components/searchable-tabs.ts index 5c5db14b..5c863dff 100644 --- a/src/native/components/searchable-tabs.ts +++ b/src/native/components/searchable-tabs.ts @@ -167,7 +167,12 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str keyboard(visible: boolean) { keyboardVisible = visible; if (!visible) { - states.forEach((state) => (state.keyboardHidden = false)); + for (const [binding, state] of states) { + // Ionic can wait for the pre-keyboard height indefinitely after rotation. + // Release the visibility override we own when native search's keyboard closes. + if (state.keyboardHidden) binding.tabBar.classList.remove('tab-bar-hidden'); + state.keyboardHidden = false; + } releaseKeyboardResize(); } schedule(); @@ -433,8 +438,8 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str }, retire(binding: NativeSearchBinding) { const state = states.get(binding); + // Keep ownership until keyboardWillHide can release this class. if (state?.keyboardHidden && keyboardVisible) binding.tabBar.classList.add('tab-bar-hidden'); - if (state) state.keyboardHidden = false; if (state?.focused) { state.focused = false; state.input.dispatchEvent(new FocusEvent('blur')); From 0026744732d0faa594b191dd89f56ab37e8ea2d4 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 28 Sep 2026 15:26:42 +0900 Subject: [PATCH 4/4] test: align geometry rejection check with resize refresh --- demo/e2e/native-ui-shell-edge.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/e2e/native-ui-shell-edge.spec.ts b/demo/e2e/native-ui-shell-edge.spec.ts index 37e98323..9b1db65b 100644 --- a/demo/e2e/native-ui-shell-edge.spec.ts +++ b/demo/e2e/native-ui-shell-edge.spec.ts @@ -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('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 };