swift-buttons/Sources/LilithButtons/PrimaryButton.swift
2026-02-16 09:29:42 -08:00

158 lines
4.5 KiB
Swift
Executable file

// PrimaryButton.swift
// iOS UI Components - Button Components
//
// Primary action button with loading state and accessibility support
import SwiftUI
import LilithDesignTokens
/// Primary call-to-action button
///
/// Use for the most important action on a screen.
/// Supports loading state, disabled state, and full accessibility.
///
/// Example:
/// ```swift
/// PrimaryButton("Sign In", isLoading: isLoading) {
/// await viewModel.signIn()
/// }
/// ```
public struct PrimaryButton: View {
// MARK: - Properties
private let title: String
private let icon: Image?
private let isLoading: Bool
private let isDisabled: Bool
private let fullWidth: Bool
private let action: () -> Void
// MARK: - State
@State private var isPressed = false
// MARK: - Initialization
/// Create a primary button
/// - Parameters:
/// - title: Button label text
/// - icon: Optional leading icon
/// - isLoading: Show loading indicator
/// - isDisabled: Disable button interaction
/// - fullWidth: Expand to fill available width
/// - action: Action to perform on tap
public init(
_ title: String,
icon: Image? = nil,
isLoading: Bool = false,
isDisabled: Bool = false,
fullWidth: Bool = false,
action: @escaping () -> Void
) {
self.title = title
self.icon = icon
self.isLoading = isLoading
self.isDisabled = isDisabled
self.fullWidth = fullWidth
self.action = action
}
// MARK: - Body
public var body: some View {
Button(action: handleTap) {
HStack(spacing: AppSpacing.sm) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
} else if let icon = icon {
icon
.font(.system(size: AppTypography.FontSize.base))
}
Text(title)
.font(AppTypography.body(weight: .semibold))
}
.foregroundColor(.white)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: AppSpacing.touchTarget)
.padding(.horizontal, AppSpacing.lg)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.button))
.shadow(AppShadows.card)
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(AppAnimations.buttonPress, value: isPressed)
}
.disabled(isDisabled || isLoading)
.buttonStyle(PlainButtonStyle())
.accessibilityLabel(title)
.accessibilityHint(isLoading ? "Loading" : "Double tap to activate")
.accessibilityAddTraits(isLoading ? [.updatesFrequently] : [])
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { pressing in
isPressed = pressing
}, perform: {})
}
// MARK: - Computed Properties
private var backgroundColor: Color {
if isDisabled || isLoading {
return AppColors.Gray.gray600
}
return AppColors.primary
}
// MARK: - Methods
private func handleTap() {
guard !isLoading && !isDisabled else { return }
// Haptic feedback
#if canImport(UIKit)
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
#endif
action()
}
}
// MARK: - Preview Provider
#if DEBUG
struct PrimaryButton_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: AppSpacing.xl) {
// Default state
PrimaryButton("Sign In") {
print("Primary button tapped")
}
// With icon
PrimaryButton("Upload", icon: Image(systemName: "arrow.up.circle")) {
print("Upload tapped")
}
// Loading state
PrimaryButton("Processing", isLoading: true) {
print("Should not fire")
}
// Disabled state
PrimaryButton("Unavailable", isDisabled: true) {
print("Should not fire")
}
// Full width
PrimaryButton("Continue", fullWidth: true) {
print("Full width button tapped")
}
}
.padding()
.previewLayout(.sizeThatFits)
.background(AppColors.background)
.previewDisplayName("Primary Button States")
}
}
#endif