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

280 lines
9.4 KiB
Swift
Executable file

// RadioButton.swift
// iOS UI Components - Form Components
//
// Radio button with single selection group management
import SwiftUI
import LilithDesignTokens
/// Radio button control
///
/// Radio button for single selection within a group.
///
/// Example:
/// ```swift
/// @State private var selectedPlan: String? = "basic"
///
/// RadioButtonGroup(selection: $selectedPlan) {
/// RadioButton("Basic", value: "basic", selection: $selectedPlan)
/// RadioButton("Pro", value: "pro", selection: $selectedPlan)
/// RadioButton("Enterprise", value: "enterprise", selection: $selectedPlan)
/// }
/// ```
public struct RadioButton<Value: Equatable>: View {
// MARK: - Properties
private let label: String
private let value: Value
@Binding private var selection: Value?
private let isDisabled: Bool
private let onChange: ((Value) -> Void)?
// MARK: - State
@State private var isPressed = false
// MARK: - Initialization
/// Create a radio button
/// - Parameters:
/// - label: Label text
/// - value: Value this radio button represents
/// - selection: Binding to selected value
/// - isDisabled: Disable interaction
/// - onChange: Callback when selection changes
public init(
_ label: String,
value: Value,
selection: Binding<Value?>,
isDisabled: Bool = false,
onChange: ((Value) -> Void)? = nil
) {
self.label = label
self.value = value
self._selection = selection
self.isDisabled = isDisabled
self.onChange = onChange
}
// MARK: - Body
public var body: some View {
Button(action: handleTap) {
HStack(spacing: AppSpacing.sm) {
// Radio circle
ZStack {
Circle()
.fill(circleBackgroundColor)
.frame(width: 24, height: 24)
Circle()
.stroke(borderColor, lineWidth: 2)
.frame(width: 24, height: 24)
// Inner dot when selected
if isSelected {
Circle()
.fill(dotColor)
.frame(width: 12, height: 12)
}
}
.scaleEffect(isPressed ? 0.9 : 1.0)
.animation(AppAnimations.buttonPress, value: isPressed)
// Label
Text(label)
.font(AppTypography.body())
.foregroundColor(labelColor)
.multilineTextAlignment(.leading)
}
}
.disabled(isDisabled)
.buttonStyle(PlainButtonStyle())
.accessibilityElement(children: .combine)
.accessibilityLabel(label)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
.accessibilityHint("Double tap to select")
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { pressing in
if !isDisabled {
isPressed = pressing
}
}, perform: {})
}
// MARK: - Computed Properties
private var isSelected: Bool {
selection == value
}
private var circleBackgroundColor: Color {
if isDisabled {
return AppColors.Gray.gray800
}
return AppColors.surface
}
private var borderColor: Color {
if isDisabled {
return AppColors.Gray.gray600
}
if isSelected {
return AppColors.primary
}
return AppColors.border
}
private var dotColor: Color {
AppColors.primary
}
private var labelColor: Color {
isDisabled ? AppColors.textTertiary : AppColors.textPrimary
}
// MARK: - Methods
private func handleTap() {
guard !isDisabled else { return }
selection = value
// Light haptic feedback
#if canImport(UIKit)
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
#endif
onChange?(value)
}
}
/// Radio button group container
///
/// Helper view for grouping radio buttons with consistent spacing.
public struct RadioButtonGroup<Content: View>: View {
private let content: Content
private let spacing: CGFloat
public init(
spacing: CGFloat = AppSpacing.md,
@ViewBuilder content: () -> Content
) {
self.spacing = spacing
self.content = content()
}
public var body: some View {
VStack(alignment: .leading, spacing: spacing) {
content
}
}
}
// MARK: - Preview Provider
#if DEBUG
struct RadioButton_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppSpacing.xl) {
// Basic states
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Basic States")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
RadioButton("Unselected", value: 1, selection: .constant(2))
RadioButton("Selected", value: 1, selection: .constant(1))
RadioButton("Disabled Unselected", value: 1, selection: .constant(2), isDisabled: true)
RadioButton("Disabled Selected", value: 1, selection: .constant(1), isDisabled: true)
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
Divider()
// In context - Plan selection
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Choose Your Plan")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Text("Select the plan that works best for you")
.font(AppTypography.body())
.foregroundColor(AppColors.textSecondary)
RadioButtonGroup {
RadioButton("Basic - Free", value: "basic", selection: .constant("basic"))
RadioButton("Pro - $29/month", value: "pro", selection: .constant("basic"))
RadioButton("Enterprise - Contact us", value: "enterprise", selection: .constant("basic"))
}
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
Divider()
// In context - Notification preferences
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Email Frequency")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
RadioButtonGroup(spacing: AppSpacing.sm) {
RadioButton("Daily digest", value: "daily", selection: .constant("weekly"))
RadioButton("Weekly summary", value: "weekly", selection: .constant("weekly"))
RadioButton("Monthly recap", value: "monthly", selection: .constant("weekly"))
RadioButton("Never", value: "never", selection: .constant("weekly"))
}
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
Divider()
// In context - Detailed option cards
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Subscription Type")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
ForEach(["monthly", "annual"], id: \.self) { plan in
HStack(spacing: AppSpacing.md) {
RadioButton("", value: plan, selection: .constant("monthly"))
VStack(alignment: .leading, spacing: AppSpacing.xs) {
Text(plan == "monthly" ? "Monthly" : "Annual")
.font(AppTypography.body(weight: .semibold))
.foregroundColor(AppColors.textPrimary)
Text(plan == "monthly" ? "$29/month" : "$299/year (Save 15%)")
.font(AppTypography.caption())
.foregroundColor(plan == "annual" ? AppColors.Semantic.success : AppColors.textSecondary)
}
Spacer()
}
.padding()
.background(plan == "monthly" ? AppColors.primary.opacity(0.1) : AppColors.surface)
.cornerRadius(AppRadius.card)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.card)
.stroke(plan == "monthly" ? AppColors.primary : AppColors.border, lineWidth: plan == "monthly" ? 2 : 1)
)
}
}
.padding()
.background(AppColors.surface)
.cornerRadius(AppRadius.card)
}
.padding()
}
.background(AppColors.background)
.previewDisplayName("Radio Button States")
}
}
#endif