* replace NavigationStack with ElementNavigationStack to allow the content to be rendered without a NavigationStack in a11y tests * fix a11y tests * update xcodeproject * swiftformat fix * use iOS 26.1 for CI * use a wrapper to solve the issue for a11y tests * ElementNavigationStack only uses the trick in DEBUG mode, and added a swiftlint rule to prevent the usage of NavigationStack
218 lines
7.2 KiB
Swift
218 lines
7.2 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Compound
|
|
import SwiftUI
|
|
|
|
struct GlobalSearchScreen: View {
|
|
@ObservedObject var context: GlobalSearchScreenViewModel.Context
|
|
|
|
@State private var selectedRoom: GlobalSearchRoom?
|
|
@FocusState private var searchFieldFocus
|
|
|
|
var body: some View {
|
|
List {
|
|
header
|
|
|
|
Section {
|
|
ForEach(context.viewState.rooms) { room in
|
|
GlobalSearchScreenListRow(room: room, context: context)
|
|
.listRowBackground(backgroundColor(for: room))
|
|
.listRowInsets(.init())
|
|
.contentShape(.rect)
|
|
.onTapGesture {
|
|
context.send(viewAction: .select(roomID: room.id))
|
|
}
|
|
.onAppear {
|
|
if room == context.viewState.rooms.first {
|
|
context.send(viewAction: .reachedTop)
|
|
} else if room == context.viewState.rooms.last {
|
|
context.send(viewAction: .reachedBottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.frame(maxWidth: 700, maxHeight: 800)
|
|
.background(.compound.bgCanvasDefault)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.padding()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color.black.opacity(0.5).ignoresSafeArea())
|
|
.background { keyboardShortcuts }
|
|
.onAppear {
|
|
selectedRoom = context.viewState.rooms.first
|
|
searchFieldFocus = true
|
|
}
|
|
.onChange(of: context.viewState.rooms) {
|
|
selectedRoom = context.viewState.rooms.first
|
|
}
|
|
.onTapGesture {
|
|
context.send(viewAction: .dismiss)
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
GlobalSearchTextFieldRepresentable(placeholder: L10n.actionSearch, text: $context.searchQuery) { keyCode in
|
|
switch keyCode {
|
|
case .keyboardUpArrow:
|
|
moveToNextEntry(backwards: true)
|
|
return true
|
|
case .keyboardDownArrow:
|
|
moveToNextEntry()
|
|
return true
|
|
case .keyboardReturnOrEnter, .keyboardReturn:
|
|
if let selectedRoom {
|
|
context.send(viewAction: .select(roomID: selectedRoom.id))
|
|
}
|
|
return true
|
|
case .keyboardEscape:
|
|
context.send(viewAction: .dismiss)
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
} endEditingHandler: {
|
|
if let selectedRoom {
|
|
context.send(viewAction: .select(roomID: selectedRoom.id))
|
|
} else { // Bring the focus back to the text field
|
|
searchFieldFocus = true
|
|
}
|
|
}
|
|
.focused($searchFieldFocus)
|
|
.autocorrectionDisabled(true)
|
|
.autocapitalization(.none)
|
|
.textInputAutocapitalization(.never)
|
|
}
|
|
|
|
private var keyboardShortcuts: some View {
|
|
Group {
|
|
Button("") {
|
|
context.send(viewAction: .dismiss)
|
|
}
|
|
// Need this to enable escape on the textField and forward the presses
|
|
.keyboardShortcut(.escape, modifiers: [])
|
|
}
|
|
}
|
|
|
|
private func backgroundColor(for room: GlobalSearchRoom) -> Color {
|
|
if selectedRoom == room {
|
|
.compound.bgSubtlePrimary
|
|
} else {
|
|
.compound.bgCanvasDefault
|
|
}
|
|
}
|
|
|
|
private func moveToNextEntry(backwards: Bool = false) {
|
|
guard let selectedRoom else {
|
|
selectedRoom = context.viewState.rooms.first
|
|
return
|
|
}
|
|
|
|
guard let currentIndex = context.viewState.rooms.firstIndex(of: selectedRoom) else {
|
|
return
|
|
}
|
|
|
|
let nextIndex = (backwards ? currentIndex - 1 : currentIndex + 1)
|
|
|
|
guard context.viewState.rooms.indices.contains(nextIndex) else {
|
|
return
|
|
}
|
|
|
|
self.selectedRoom = context.viewState.rooms[nextIndex]
|
|
}
|
|
}
|
|
|
|
private struct GlobalSearchTextFieldRepresentable: UIViewRepresentable {
|
|
let placeholder: String
|
|
@Binding var text: String
|
|
let keyPressHandler: (UIKeyboardHIDUsage) -> Bool
|
|
let endEditingHandler: () -> Void
|
|
|
|
func makeUIView(context: Context) -> UITextField {
|
|
let textField = GlobalSearchTextField(keyPressHandler: keyPressHandler)
|
|
textField.delegate = context.coordinator
|
|
textField.autocorrectionType = .no
|
|
textField.placeholder = placeholder
|
|
return textField
|
|
}
|
|
|
|
func updateUIView(_ uiView: UITextField, context: Context) {
|
|
uiView.text = text
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(text: $text, endEditingHandler: endEditingHandler)
|
|
}
|
|
|
|
class Coordinator: NSObject, UITextFieldDelegate {
|
|
var text: Binding<String>
|
|
let endEditingHandler: () -> Void
|
|
|
|
init(text: Binding<String>, endEditingHandler: @escaping () -> Void) {
|
|
self.text = text
|
|
self.endEditingHandler = endEditingHandler
|
|
}
|
|
|
|
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
|
// pressesBegan sometimes doesn't receive return events. Handle it here instead
|
|
if string.rangeOfCharacter(from: .newlines) != nil {
|
|
endEditingHandler()
|
|
return false
|
|
}
|
|
|
|
let currentText = textField.text ?? ""
|
|
DispatchQueue.main.async {
|
|
self.text.wrappedValue = (currentText as NSString).replacingCharacters(in: range, with: string)
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
private class GlobalSearchTextField: UITextField {
|
|
let keyPressHandler: (UIKeyboardHIDUsage) -> Bool
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError()
|
|
}
|
|
|
|
init(keyPressHandler: @escaping (UIKeyboardHIDUsage) -> Bool) {
|
|
self.keyPressHandler = keyPressHandler
|
|
super.init(frame: .zero)
|
|
}
|
|
|
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
|
guard let key = presses.first?.key else {
|
|
super.pressesBegan(presses, with: event)
|
|
return
|
|
}
|
|
|
|
if keyPressHandler(key.keyCode) {
|
|
return
|
|
}
|
|
|
|
super.pressesBegan(presses, with: event)
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct GlobalSearchScreen_Previews: PreviewProvider, TestablePreview {
|
|
static let viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
|
mediaProvider: MediaProviderMock(configuration: .init()))
|
|
|
|
static var previews: some View {
|
|
ElementNavigationStack {
|
|
GlobalSearchScreen(context: viewModel.context)
|
|
}
|
|
}
|
|
}
|