diff --git a/openclient-llm/Shared/Features/Chat/Views/SearchConversationsView.swift b/openclient-llm/Shared/Features/Chat/Views/SearchConversationsView.swift index 3c47c9d..54d16a3 100644 --- a/openclient-llm/Shared/Features/Chat/Views/SearchConversationsView.swift +++ b/openclient-llm/Shared/Features/Chat/Views/SearchConversationsView.swift @@ -16,9 +16,12 @@ struct SearchConversationsView: View { @State private var viewModel = ConversationListViewModel() @State private var localSearchText = "" @State private var selectedConversation: Conversation? + @State private var isSearchPresented = false + @FocusState private var isSearchFocused: Bool private let externalSearchText: Binding? private let showsSearchField: Bool + private let isSearchActive: Bool private let onConversationSelected: ((Conversation) -> Void)? // MARK: - Init @@ -26,10 +29,12 @@ struct SearchConversationsView: View { init( searchText: Binding? = nil, showsSearchField: Bool = true, + isSearchActive: Bool = true, onConversationSelected: ((Conversation) -> Void)? = nil ) { externalSearchText = searchText self.showsSearchField = showsSearchField + self.isSearchActive = isSearchActive self.onConversationSelected = onConversationSelected } @@ -72,12 +77,32 @@ private extension SearchConversationsView { var searchPresentation: some View { if showsSearchField { #if os(iOS) - searchNavigation - .searchable( - text: searchBinding, - placement: .navigationBarDrawer(displayMode: .always), - prompt: String(localized: "Search conversations...") - ) + if externalSearchText != nil { + searchNavigation + .searchFocused($isSearchFocused) + .searchable( + text: searchBinding, + isPresented: $isSearchPresented, + placement: .navigationBarDrawer(displayMode: .always), + prompt: String(localized: "Search") + "..." + ) + .onChange(of: isSearchActive, initial: true) { _, active in + if active { + isSearchPresented = true + } + isSearchFocused = active + } + .onDisappear { + isSearchFocused = false + } + } else { + searchNavigation + .searchable( + text: searchBinding, + placement: .navigationBarDrawer(displayMode: .always), + prompt: String(localized: "Search conversations...") + ) + } #else searchNavigation .searchable(text: searchBinding, prompt: String(localized: "Search conversations...")) @@ -147,6 +172,7 @@ private extension SearchConversationsView { ForEach(conversations) { conversation in Button { if let onConversationSelected { + isSearchFocused = false onConversationSelected(conversation) } else { selectedConversation = conversation diff --git a/openclient-llm/Shared/Features/Home/Views/HomeView+iPadSearch.swift b/openclient-llm/Shared/Features/Home/Views/HomeView+iPadSearch.swift index fbf35e5..fb84254 100644 --- a/openclient-llm/Shared/Features/Home/Views/HomeView+iPadSearch.swift +++ b/openclient-llm/Shared/Features/Home/Views/HomeView+iPadSearch.swift @@ -21,6 +21,9 @@ extension HomeView { Binding( get: { selectedTab }, set: { tab in + // Switching to results can write the Chats selection back before its view appears. + guard !isSidebarSearchFocusRequested || tab != .chats else { return } + isSidebarSearchFocusRequested = false isSidebarSearchFocused = false isSidebarSearchActive = false selectedTab = tab @@ -33,6 +36,7 @@ extension HomeView { if UIDevice.current.userInterfaceIdiom == .pad { SearchConversationsView( searchText: $iPadSearchText, + isSearchActive: selectedTab == .search && !isSidebarSearchVisible, onConversationSelected: openSearchResult ) } else { @@ -41,46 +45,36 @@ extension HomeView { } var iPadSidebarSearch: some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - TextField(String(localized: "Search conversations..."), text: $iPadSearchText) - .textFieldStyle(.plain) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.search) - .focused($isSidebarSearchFocused) - .accessibilityIdentifier("iPadSidebarSearch") - .onSubmit { isSidebarSearchFocused = false } - if !iPadSearchText.isEmpty { - Button { - iPadSearchText = "" - activateSidebarSearch() - isSidebarSearchFocused = true - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel(String(localized: "Clear Search")) - } - } - .padding(.leading, 12) - .padding(.trailing, iPadSearchText.isEmpty ? 12 : 0) - .frame(minHeight: 44) - .background(.quaternary, in: .capsule) + SidebarSearchField( + text: $iPadSearchText, + isFocused: $isSidebarSearchFocused, + focusRequested: $isSidebarSearchFocusRequested, + onActivated: activateSidebarSearch + ) + .padding(.horizontal, -16) .padding(.vertical, 8) - .onChange(of: isSidebarSearchFocused) { _, focused in - if focused { activateSidebarSearch() } + } + + func activateSidebarSearch() { + guard !isSidebarSearchActive else { + isSidebarSearchFocused = true + return } - .onAppear { - isSidebarSearchVisible = true + isSidebarSearchFocusRequested = true + selectedTab = .chats + isSidebarSearchActive = true + isSidebarSearchFocused = true + } + + func updateSidebarPlacement(_ placement: TabBarPlacement) { + guard UIDevice.current.userInterfaceIdiom == .pad else { return } + let showsSidebar = placement == .sidebar + guard showsSidebar != isSidebarSearchVisible else { return } + isSidebarSearchVisible = showsSidebar + if showsSidebar { if selectedTab == .search { activateSidebarSearch() } - } - .onDisappear { - isSidebarSearchVisible = false + } else { + isSidebarSearchFocusRequested = false isSidebarSearchFocused = false if isSidebarSearchActive { isSidebarSearchActive = false @@ -89,12 +83,8 @@ extension HomeView { } } - func activateSidebarSearch() { - selectedTab = .chats - isSidebarSearchActive = true - } - func openSearchResult(_ conversation: Conversation) { + isSidebarSearchFocusRequested = false isSidebarSearchFocused = false isSidebarSearchActive = false isPrivateChatActive = false diff --git a/openclient-llm/Shared/Features/Home/Views/HomeView.swift b/openclient-llm/Shared/Features/Home/Views/HomeView.swift index 8656d06..df4f6ab 100644 --- a/openclient-llm/Shared/Features/Home/Views/HomeView.swift +++ b/openclient-llm/Shared/Features/Home/Views/HomeView.swift @@ -30,7 +30,8 @@ struct HomeView: View { @State var iPadSearchText = "" @State var isSidebarSearchVisible = false @State var isSidebarSearchActive = false - @FocusState var isSidebarSearchFocused: Bool + @State var isSidebarSearchFocusRequested = false + @State var isSidebarSearchFocused = false #endif // MARK: - Init @@ -133,6 +134,7 @@ private extension HomeView { TabView(selection: tabSelection) { Tab(value: AppTab.chats) { chatsTab + .modifier(TabBarPlacementObserver(onChange: updateSidebarPlacement)) } label: { Label { Text(String(localized: "Chats")) @@ -143,6 +145,7 @@ private extension HomeView { } Tab(value: AppTab.models) { ModelsView() + .modifier(TabBarPlacementObserver(onChange: updateSidebarPlacement)) } label: { Label { Text(String(localized: "Models")) @@ -153,6 +156,7 @@ private extension HomeView { } Tab(value: AppTab.settings) { SettingsView(requestedPresentation: $requestedSettingsPresentation) + .modifier(TabBarPlacementObserver(onChange: updateSidebarPlacement)) } label: { Label { Text(String(localized: "Settings")) @@ -163,6 +167,7 @@ private extension HomeView { } Tab(value: AppTab.search, role: .search) { searchTab + .modifier(TabBarPlacementObserver(onChange: updateSidebarPlacement)) } label: { Label(String(localized: "Search"), systemImage: "magnifyingglass") } @@ -176,18 +181,21 @@ private extension HomeView { } .onChange(of: selectedTab) { _, tab in if tab != .chats { + isSidebarSearchFocusRequested = false isSidebarSearchActive = false isSidebarSearchFocused = false } } .onChange(of: selectedConversation) { _, conversation in if conversation != nil { + isSidebarSearchFocusRequested = false isSidebarSearchActive = false isSidebarSearchFocused = false } } .onChange(of: isPrivateChatActive) { _, active in if active { + isSidebarSearchFocusRequested = false isSidebarSearchActive = false isSidebarSearchFocused = false } @@ -202,6 +210,14 @@ private extension HomeView { showsSearchField: false, onConversationSelected: openSearchResult ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .simultaneousGesture( + TapGesture().onEnded { + isSidebarSearchFocusRequested = false + isSidebarSearchFocused = false + } + ) } else { iPhoneChatsLayout } @@ -244,7 +260,6 @@ private extension HomeView { #if os(iOS) if isSidebarSearchVisible { activateSidebarSearch() - isSidebarSearchFocused = true } else { selectedTab = .search } diff --git a/openclient-llm/Shared/Features/Home/Views/SidebarSearchField.swift b/openclient-llm/Shared/Features/Home/Views/SidebarSearchField.swift new file mode 100644 index 0000000..742fb06 --- /dev/null +++ b/openclient-llm/Shared/Features/Home/Views/SidebarSearchField.swift @@ -0,0 +1,88 @@ +// +// SidebarSearchField.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 15/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import SwiftUI + +#if os(iOS) +struct SidebarSearchField: View { + // MARK: - Properties + + @Binding var text: String + @Binding var isFocused: Bool + @Binding var focusRequested: Bool + let onActivated: () -> Void + + // Keep focus in the sidebar's view hierarchy, rather than the TabView's parent. + @FocusState private var isFieldFocused: Bool + + // MARK: - View + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + TextField(String(localized: "Search") + "...", text: $text) + .textFieldStyle(.plain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.search) + .focused($isFieldFocused) + .accessibilityIdentifier("iPadSidebarSearch") + .onSubmit { isFieldFocused = false } + if !text.isEmpty { + Button { + text = "" + onActivated() + isFieldFocused = true + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Clear Search")) + } + } + .padding(.leading, 12) + .padding(.trailing, text.isEmpty ? 12 : 0) + .frame(maxWidth: .infinity, minHeight: 44) + .background(.quaternary, in: .capsule) + .onChange(of: isFieldFocused) { _, focused in + isFocused = focused + if focused { onActivated() } + } + .onChange(of: isFocused, initial: true) { _, focused in + isFieldFocused = focused + } + .task(id: focusRequested) { + guard focusRequested else { return } + // Reapply focus after the detail change, outside the tap's navigation update. + await Task.yield() + guard !Task.isCancelled, focusRequested else { return } + isFieldFocused = true + isFocused = true + focusRequested = false + } + } +} + +#Preview { + @Previewable @State var text = "" + @Previewable @State var isFocused = false + @Previewable @State var focusRequested = false + + SidebarSearchField( + text: $text, + isFocused: $isFocused, + focusRequested: $focusRequested, + onActivated: {} + ) + .padding() +} +#endif diff --git a/openclient-llm/Shared/Features/Home/Views/TabBarPlacementObserver.swift b/openclient-llm/Shared/Features/Home/Views/TabBarPlacementObserver.swift new file mode 100644 index 0000000..eb00628 --- /dev/null +++ b/openclient-llm/Shared/Features/Home/Views/TabBarPlacementObserver.swift @@ -0,0 +1,25 @@ +// +// TabBarPlacementObserver.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 15/09/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import SwiftUI + +#if os(iOS) +struct TabBarPlacementObserver: ViewModifier { + @Environment(\.tabBarPlacement) private var placement + + let onChange: (TabBarPlacement) -> Void + + func body(content: Content) -> some View { + content + .onChange(of: placement, initial: true) { _, placement in + guard let placement else { return } + onChange(placement) + } + } +} +#endif