swift-ui-forms/Sources/LilithForms/TextArea.swift
2026-02-16 05:03:57 -08:00

259 lines
8 KiB
Swift
Executable file

// TextArea.swift
// iOS UI Components - Form Components
//
// Multi-line text input with character counter
import SwiftUI
import LilithDesignTokens
/// Multi-line text input area
///
/// Text area with optional character counter and validation.
///
/// Example:
/// ```swift
/// @State private var bio = ""
///
/// TextArea(
/// "Bio",
/// text: $bio,
/// placeholder: "Tell us about yourself...",
/// maxCharacters: 500,
/// minHeight: 120
/// )
/// ```
@available(macOS 14.0, iOS 17.0, *)
public struct TextArea: View {
// MARK: - Properties
private let label: String
@Binding private var text: String
private let placeholder: String
private let errorMessage: String?
private let helperText: String?
private let maxCharacters: Int?
private let minHeight: CGFloat
private let isDisabled: Bool
// MARK: - Focus State
@FocusState private var isFocused: Bool
// MARK: - Initialization
/// Create a text area
/// - Parameters:
/// - label: Input label
/// - text: Binding to text value
/// - placeholder: Placeholder text
/// - errorMessage: Error message to display
/// - helperText: Helper text below input
/// - maxCharacters: Maximum character limit
/// - minHeight: Minimum height of text area
/// - isDisabled: Disable input
public init(
_ label: String,
text: Binding<String>,
placeholder: String = "",
errorMessage: String? = nil,
helperText: String? = nil,
maxCharacters: Int? = nil,
minHeight: CGFloat = 100,
isDisabled: Bool = false
) {
self.label = label
self._text = text
self.placeholder = placeholder
self.errorMessage = errorMessage
self.helperText = helperText
self.maxCharacters = maxCharacters
self.minHeight = minHeight
self.isDisabled = isDisabled
}
// MARK: - Body
public var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.xs) {
// Label
Text(label)
.font(AppTypography.label(weight: .medium))
.foregroundColor(labelColor)
// Text area container
ZStack(alignment: .topLeading) {
// Placeholder (shown when empty)
if text.isEmpty {
Text(placeholder)
.font(AppTypography.body())
.foregroundColor(AppColors.textTertiary)
.padding(.horizontal, AppSpacing.md)
.padding(.vertical, AppSpacing.sm)
}
TextEditor(text: $text)
.font(AppTypography.body())
.foregroundColor(AppColors.textPrimary)
.disabled(isDisabled)
.focused($isFocused)
.scrollContentBackground(.hidden)
.padding(.horizontal, AppSpacing.sm)
.padding(.vertical, AppSpacing.xs)
.onChange(of: text) { _, newValue in
if let maxCharacters = maxCharacters {
if newValue.count > maxCharacters {
text = String(newValue.prefix(maxCharacters))
}
}
}
}
.frame(minHeight: minHeight)
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.input)
.stroke(borderColor, lineWidth: isFocused ? 2 : 1)
)
.cornerRadius(AppRadius.input)
// Bottom row (helper text / error / character count)
HStack {
// Helper or error text
if let errorMessage = errorMessage {
Text(errorMessage)
.font(AppTypography.caption())
.foregroundColor(AppColors.Semantic.error)
} else if let helperText = helperText {
Text(helperText)
.font(AppTypography.caption())
.foregroundColor(AppColors.textSecondary)
}
Spacer()
// Character counter
if let maxCharacters = maxCharacters {
Text("\(text.count)/\(maxCharacters)")
.font(AppTypography.caption())
.foregroundColor(characterCountColor)
}
}
}
.animation(AppAnimations.fast, value: isFocused)
.animation(AppAnimations.fast, value: errorMessage)
}
// MARK: - Computed Properties
private var labelColor: Color {
if isDisabled {
return AppColors.textTertiary
}
if errorMessage != nil {
return AppColors.Semantic.error
}
if isFocused {
return AppColors.primary
}
return AppColors.textSecondary
}
private var borderColor: Color {
if isDisabled {
return AppColors.Gray.gray700
}
if errorMessage != nil {
return AppColors.Semantic.error
}
if isFocused {
return AppColors.primary
}
return AppColors.border
}
private var backgroundColor: Color {
isDisabled ? AppColors.Gray.gray800 : AppColors.surface
}
private var characterCountColor: Color {
guard let maxCharacters = maxCharacters else {
return AppColors.textSecondary
}
let ratio = Double(text.count) / Double(maxCharacters)
if ratio >= 1.0 {
return AppColors.Semantic.error
} else if ratio >= 0.9 {
return AppColors.Semantic.warning
} else {
return AppColors.textSecondary
}
}
}
// MARK: - Preview Provider
#if DEBUG
struct TextArea_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
VStack(spacing: AppSpacing.xl) {
// Default state
TextArea("Description", text: .constant(""), placeholder: "Enter a description...")
// With value
TextArea(
"Bio",
text: .constant("I'm a software engineer passionate about building great products."),
placeholder: "Tell us about yourself..."
)
// With character counter
TextArea(
"Tweet",
text: .constant("This is a short tweet"),
placeholder: "What's happening?",
maxCharacters: 280,
minHeight: 80
)
// Near character limit
TextArea(
"Comment",
text: .constant(String(repeating: "A", count: 95)),
placeholder: "Add a comment...",
maxCharacters: 100,
minHeight: 100
)
// With helper text
TextArea(
"Notes",
text: .constant(""),
placeholder: "Add your notes here...",
helperText: "Private notes visible only to you",
minHeight: 120
)
// With error
TextArea(
"Message",
text: .constant("Bad word"),
placeholder: "Type your message...",
errorMessage: "Message contains inappropriate content"
)
// Disabled
TextArea(
"Locked Content",
text: .constant("This content cannot be edited"),
isDisabled: true
)
}
.padding()
}
.background(AppColors.background)
.previewDisplayName("Text Area States")
}
}
#endif