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

391 lines
13 KiB
Swift
Executable file

// Picker.swift
// iOS UI Components - Form Components
//
// Dropdown picker with customizable options
import SwiftUI
import LilithDesignTokens
/// Picker control
///
/// Dropdown picker for selecting from a list of options.
///
/// Example:
/// ```swift
/// @State private var selectedCountry = "US"
///
/// Picker(
/// "Country",
/// selection: $selectedCountry,
/// options: ["US": "United States", "CA": "Canada", "UK": "United Kingdom"]
/// )
/// ```
public struct Picker<Value: Hashable>: View {
// MARK: - Properties
private let label: String
@Binding private var selection: Value
private let options: [(value: Value, label: String)]
private let placeholder: String?
private let isDisabled: Bool
private let onChange: ((Value) -> Void)?
// MARK: - Initialization
/// Create a picker from dictionary
/// - Parameters:
/// - label: Input label
/// - selection: Binding to selected value
/// - options: Dictionary of value-label pairs
/// - placeholder: Placeholder when no selection
/// - isDisabled: Disable interaction
/// - onChange: Callback when selection changes
public init(
_ label: String,
selection: Binding<Value>,
options: [Value: String],
placeholder: String? = nil,
isDisabled: Bool = false,
onChange: ((Value) -> Void)? = nil
) {
self.label = label
self._selection = selection
self.options = options.map { ($0.key, $0.value) }.sorted { $0.label < $1.label }
self.placeholder = placeholder
self.isDisabled = isDisabled
self.onChange = onChange
}
/// Create a picker from array (uses value as label)
/// - Parameters:
/// - label: Input label
/// - selection: Binding to selected value
/// - options: Array of values
/// - placeholder: Placeholder when no selection
/// - isDisabled: Disable interaction
/// - onChange: Callback when selection changes
public init(
_ label: String,
selection: Binding<Value>,
options: [Value],
placeholder: String? = nil,
isDisabled: Bool = false,
onChange: ((Value) -> Void)? = nil
) where Value: CustomStringConvertible {
self.label = label
self._selection = selection
self.options = options.map { ($0, $0.description) }
self.placeholder = placeholder
self.isDisabled = isDisabled
self.onChange = onChange
}
// MARK: - Body
public var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.xs) {
// Label
Text(label)
.font(AppTypography.label(weight: .medium))
.foregroundColor(labelColor)
// Picker menu
Menu {
ForEach(options, id: \.value) { option in
Button {
handleSelection(option.value)
} label: {
HStack {
Text(option.label)
if selection == option.value {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack {
Text(selectedLabel)
.font(AppTypography.body())
.foregroundColor(textColor)
Spacer()
Image(systemName: "chevron.down")
.font(.system(size: AppTypography.FontSize.sm))
.foregroundColor(AppColors.textSecondary)
}
.padding(.horizontal, AppSpacing.md)
.padding(.vertical, AppSpacing.sm)
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.input)
.stroke(borderColor, lineWidth: 1)
)
.cornerRadius(AppRadius.input)
}
.disabled(isDisabled)
}
}
// MARK: - Computed Properties
private var selectedLabel: String {
if let option = options.first(where: { $0.value == selection }) {
return option.label
}
return placeholder ?? "Select..."
}
private var labelColor: Color {
isDisabled ? AppColors.textTertiary : AppColors.textSecondary
}
private var textColor: Color {
if isDisabled {
return AppColors.textTertiary
}
if placeholder != nil && selectedLabel == placeholder {
return AppColors.textTertiary
}
return AppColors.textPrimary
}
private var borderColor: Color {
isDisabled ? AppColors.Gray.gray700 : AppColors.border
}
private var backgroundColor: Color {
isDisabled ? AppColors.Gray.gray800 : AppColors.surface
}
// MARK: - Methods
private func handleSelection(_ value: Value) {
guard !isDisabled else { return }
selection = value
// Light haptic feedback
#if canImport(UIKit)
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
#endif
onChange?(value)
}
}
// MARK: - Preview Provider
#if DEBUG
struct Picker_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppSpacing.xl) {
// Basic picker
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Basic Pickers")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Picker(
"Country",
selection: .constant("US"),
options: ["US": "United States", "CA": "Canada", "UK": "United Kingdom"]
)
Picker(
"Language",
selection: .constant("en"),
options: ["en": "English", "es": "Spanish", "fr": "French", "de": "German"]
)
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
Divider()
// With placeholder
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("With Placeholder")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Picker(
"Select Country",
selection: .constant(""),
options: ["": "", "US": "United States", "CA": "Canada"],
placeholder: "Choose a country..."
)
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
Divider()
// Disabled state
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Disabled State")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Picker(
"Locked Selection",
selection: .constant("US"),
options: ["US": "United States", "CA": "Canada"],
isDisabled: true
)
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
Divider()
// In context - Profile form
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Profile Information")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Picker(
"Gender",
selection: .constant("female"),
options: [
"male": "Male",
"female": "Female",
"non-binary": "Non-binary",
"other": "Other",
"prefer-not": "Prefer not to say"
]
)
Picker(
"Timezone",
selection: .constant("PST"),
options: [
"EST": "Eastern Time (ET)",
"CST": "Central Time (CT)",
"MST": "Mountain Time (MT)",
"PST": "Pacific Time (PT)"
]
)
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
Divider()
// In context - Notification settings
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Notification Preferences")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Picker(
"Email Frequency",
selection: .constant("weekly"),
options: [
"realtime": "Real-time",
"daily": "Daily digest",
"weekly": "Weekly summary",
"never": "Never"
]
)
Picker(
"Notification Sound",
selection: .constant("default"),
options: [
"default": "Default",
"chime": "Chime",
"bell": "Bell",
"silent": "Silent"
]
)
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
Divider()
// In context - Filter options
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Search Filters")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Picker(
"Sort By",
selection: .constant("popular"),
options: [
"popular": "Most Popular",
"recent": "Most Recent",
"price-low": "Price: Low to High",
"price-high": "Price: High to Low"
]
)
Picker(
"Category",
selection: .constant("all"),
options: [
"all": "All Categories",
"electronics": "Electronics",
"clothing": "Clothing",
"home": "Home & Garden",
"sports": "Sports & Outdoors"
]
)
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
Divider()
// In context - Payment method
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Payment Method")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Picker(
"Card Type",
selection: .constant("visa"),
options: [
"visa": "Visa",
"mastercard": "Mastercard",
"amex": "American Express",
"discover": "Discover"
]
)
Picker(
"Billing Country",
selection: .constant("US"),
options: [
"US": "United States",
"CA": "Canada",
"UK": "United Kingdom",
"AU": "Australia"
]
)
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
}
.padding()
}
.background(AppColors.background)
.previewDisplayName("Picker States")
}
}
#endif