280 lines
9.4 KiB
Swift
Executable file
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
|