swift-ui-forms/Sources/LilithForms/SearchInput.swift
2026-02-16 04:57:38 -08:00

205 lines
5.6 KiB
Swift
Executable file

// SearchInput.swift
// iOS UI Components - Form Components
//
// Search input field with magnifying glass icon and clear button
import SwiftUI
import LilithDesignTokens
/// Search input field
///
/// Specialized text input for search functionality with debouncing support.
///
/// Example:
/// ```swift
/// @State private var searchQuery = ""
///
/// SearchInput(
/// text: $searchQuery,
/// placeholder: "Search...",
/// onSearch: { query in
/// performSearch(query)
/// }
/// )
/// ```
public struct SearchInput: View {
// MARK: - Properties
@Binding private var text: String
private let placeholder: String
private let onSearch: ((String) -> Void)?
private let debounceDelay: TimeInterval
private let isDisabled: Bool
// MARK: - Focus State
@FocusState private var isFocused: Bool
// MARK: - State
@State private var debounceTask: Task<Void, Never>?
// MARK: - Initialization
/// Create a search input
/// - Parameters:
/// - text: Binding to search query
/// - placeholder: Placeholder text
/// - onSearch: Debounced search callback
/// - debounceDelay: Delay before triggering search (default: 0.3s)
/// - isDisabled: Disable input
public init(
text: Binding<String>,
placeholder: String = "Search...",
onSearch: ((String) -> Void)? = nil,
debounceDelay: TimeInterval = 0.3,
isDisabled: Bool = false
) {
self._text = text
self.placeholder = placeholder
self.onSearch = onSearch
self.debounceDelay = debounceDelay
self.isDisabled = isDisabled
}
// MARK: - Body
public var body: some View {
HStack(spacing: AppSpacing.sm) {
// Leading search icon
Image(systemName: "magnifyingglass")
.font(.system(size: AppTypography.FontSize.base))
.foregroundColor(iconColor)
TextField(placeholder, text: $text)
.font(AppTypography.body())
.foregroundColor(AppColors.textPrimary)
.autocapitalization(.none)
.disableAutocorrection(true)
.disabled(isDisabled)
.focused($isFocused)
.onChange(of: text) { _, newValue in
handleTextChange(newValue)
}
// Clear button
if !text.isEmpty {
Button(action: clearSearch) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: AppTypography.FontSize.base))
.foregroundColor(AppColors.textTertiary)
}
.accessibilityLabel("Clear search")
}
}
.padding(.horizontal, AppSpacing.md)
.padding(.vertical, AppSpacing.sm)
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.input)
.stroke(borderColor, lineWidth: isFocused ? 2 : 1)
)
.cornerRadius(AppRadius.input)
.animation(AppAnimations.fast, value: isFocused)
.animation(AppAnimations.fast, value: text.isEmpty)
}
// MARK: - Computed Properties
private var borderColor: Color {
if isDisabled {
return AppColors.Gray.gray700
}
if isFocused {
return AppColors.primary
}
return AppColors.border
}
private var backgroundColor: Color {
isDisabled ? AppColors.Gray.gray800 : AppColors.surface
}
private var iconColor: Color {
if isDisabled {
return AppColors.textTertiary
}
if isFocused {
return AppColors.primary
}
return AppColors.textSecondary
}
// MARK: - Methods
private func handleTextChange(_ newValue: String) {
// Cancel previous debounce task
debounceTask?.cancel()
// Create new debounced task
debounceTask = Task {
try? await Task.sleep(nanoseconds: UInt64(debounceDelay * 1_000_000_000))
guard !Task.isCancelled else { return }
await MainActor.run {
onSearch?(newValue)
}
}
}
private func clearSearch() {
text = ""
// Light haptic feedback
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
onSearch?("")
}
}
// MARK: - Preview Provider
#if DEBUG
struct SearchInput_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: AppSpacing.xl) {
// Default state
SearchInput(text: .constant(""))
// With placeholder
SearchInput(text: .constant(""), placeholder: "Search products...")
// With value
SearchInput(text: .constant("SwiftUI"))
// Focused with text
SearchInput(text: .constant("Search query"))
// Disabled
SearchInput(text: .constant("Cannot search"), isDisabled: true)
// In context - Search bar
VStack(spacing: 0) {
SearchInput(
text: .constant(""),
placeholder: "Search messages...",
onSearch: { query in
print("Searching for: \(query)")
}
)
.padding()
.background(AppColors.surface)
List(0..<10) { index in
Text("Result \(index)")
}
}
}
.padding()
.background(AppColors.background)
.previewDisplayName("Search Input States")
}
}
#endif