├── logo.png ├── docs ├── img │ ├── dash.png │ ├── carat.png │ └── spinner.gif ├── docsets │ ├── SwiftThemeKit.tgz │ └── SwiftThemeKit.docset │ │ └── Contents │ │ ├── Resources │ │ ├── docSet.dsidx │ │ └── Documents │ │ │ ├── img │ │ │ ├── carat.png │ │ │ ├── dash.png │ │ │ └── spinner.gif │ │ │ ├── badge.svg │ │ │ ├── js │ │ │ ├── jazzy.js │ │ │ └── jazzy.search.js │ │ │ └── css │ │ │ └── highlight.css │ │ └── Info.plist ├── badge.svg ├── js │ ├── jazzy.js │ └── jazzy.search.js └── css │ └── highlight.css ├── DemoApp └── SwiftThemeKitDemo │ ├── SwiftThemeKitDemo │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── bottomContainer.imageset │ │ │ ├── bottomContainer.png │ │ │ └── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── ContentView.swift │ ├── SwiftThemeKitDemo.entitlements │ ├── SwiftThemeKitDemoApp.swift │ ├── Views │ │ ├── RadioGroupView.swift │ │ ├── TypographyView.swift │ │ ├── StrokesView.swift │ │ ├── ShadowsView.swift │ │ ├── ColorsView.swift │ │ ├── ShapesView.swift │ │ ├── RadiiView.swift │ │ ├── SpacingsView.swift │ │ ├── TextFieldsView.swift │ │ ├── CheckboxesView.swift │ │ └── ButtonsView.swift │ ├── Extensions │ │ └── Color+Hex.swift │ ├── View+ColorScheme.swift │ ├── ThemeManager.swift │ ├── GetStartedView.swift │ └── HomeScreenView.swift │ ├── SwiftThemeKitDemo.xcodeproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved │ └── SwiftThemeKitDemoTests │ └── SwiftThemeKitDemoTests.swift ├── .gitignore ├── Package.resolved ├── Sources └── SwiftThemeKit │ ├── Extensions │ ├── Shape+ThemeModifiers.swift │ ├── ThemeShapes+Copy.swift │ ├── ThemeShadow+Copy.swift │ ├── ThemeButton+Copy.swift │ ├── ThemeCheckboxSize+Copy.swift │ ├── ThemeRadioButtonSize+Copy.swift │ ├── ThemeShadows+Copy.swift │ ├── ThemeRadii+Copy.swift │ ├── ThemeSpacing+Copy.swift │ ├── View+TextEditor.swift │ ├── View+Utils.swift │ ├── Theme+Copy.swift │ ├── ThemeTypography+Copy.swift │ ├── View+TextField.swift │ ├── ThemeColors+Copy.swift │ └── View+Button.swift │ ├── Modifiers │ ├── TextEditor │ │ ├── TextEditorConfiguration.swift │ │ └── ThemedTextEditorModifier.swift │ ├── ForegroundModifier.swift │ ├── CornerRadiusModifier.swift │ ├── ClipShapeModifier.swift │ ├── ShadowModifier.swift │ ├── PaddingModifier.swift │ ├── TextField │ │ ├── TextFieldConfiguration.swift │ │ └── ThemedTextFieldModifier.swift │ ├── ClipRadiusModifier.swift │ ├── StrokeModifier.swift │ ├── BackgroundColorModifier.swift │ ├── Typography │ │ └── TypographyModifier.swift │ ├── BackgroundShapeModifier.swift │ ├── Button │ │ ├── ThemedButtonModifier.swift │ │ └── ButtonConfiguration.swift │ └── ThemedNavigationTitleModifier.swift │ ├── Tokens │ ├── ThemePositions.swift │ ├── ThemeButtonDefaults.swift │ ├── ThemeShadow.swift │ ├── ThemeTextFieldDefaults.swift │ ├── ThemePlatform.swift │ ├── TextField │ │ ├── TextFieldVariant.swift │ │ ├── TextFieldSize.swift │ │ └── TextFieldShape.swift │ ├── ThemeFontToken.swift │ ├── RadioButton │ │ ├── RadioButtonConfiguration.swift │ │ ├── ThemeRadioButtonSize.swift │ │ └── ThemePlatform+RadioButtonConfiguration.swift │ ├── ThemeStroke.swift │ ├── ThemeShapes.swift │ ├── Checkbox │ │ ├── CheckboxConfiguration.swift │ │ ├── CheckboxShape.swift │ │ ├── ThemeCheckboxSize.swift │ │ └── ThemePlatform+CheckboxConfiguration.swift │ ├── ThemeSpacing.swift │ ├── ThemeRadii.swift │ ├── ThemeShadows.swift │ └── Button │ │ ├── ButtonSize.swift │ │ └── ButtonShape.swift │ ├── Components │ ├── ThemedShape.swift │ ├── Card.swift │ ├── RadioButton.swift │ └── RadioGroup.swift │ ├── ThemeProvider.swift │ ├── Styles │ ├── PlainTextButtonStyle.swift │ ├── ThemeButtonStyle.swift │ └── ThemeTextFieldStyle.swift │ ├── EnvironmentKeys.swift │ └── Theme.swift ├── Package.swift ├── Tests └── SwiftThemeKitTests │ ├── ThemeStrokeTests.swift │ ├── ThemeSpacingTests.swift │ ├── ThemeRadiiTests.swift │ ├── ThemeProviderTests.swift │ ├── ThemeShapesTests.swift │ ├── ThemeShadowsTests.swift │ ├── ThemeTypographyTests.swift │ └── ThemeColorsTests.swift ├── LICENCE └── CONTRIBUTING.md /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/logo.png -------------------------------------------------------------------------------- /docs/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/docs/img/dash.png -------------------------------------------------------------------------------- /docs/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/docs/img/carat.png -------------------------------------------------------------------------------- /docs/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/docs/img/spinner.gif -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/docs/docsets/SwiftThemeKit.tgz -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.docset/Contents/Resources/docSet.dsidx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/docs/docsets/SwiftThemeKit.docset/Contents/Resources/docSet.dsidx -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/img/carat.png -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/img/dash.png -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/img/spinner.gif -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Assets.xcassets/bottomContainer.imageset/bottomContainer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charlyk/swift-theme-kit/HEAD/DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Assets.xcassets/bottomContainer.imageset/bottomContainer.png -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct ContentView: View { 5 | var body: some View { 6 | GetStartedView() 7 | } 8 | } 9 | 10 | #Preview { 11 | ContentView() 12 | } 13 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "viewinspector", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/nalexn/ViewInspector", 7 | "state" : { 8 | "revision" : "788e7879d38a839c4e348ab0762dcc0364e646a2", 9 | "version" : "0.10.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/SwiftThemeKitDemo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Assets.xcassets/bottomContainer.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "bottomContainer.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/Shape+ThemeModifiers.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension Shape { 4 | /// Applies a themed stroke (border) to the current view. 5 | /// 6 | /// - Parameters: 7 | /// - width: A `StrokeToken` representing the stroke width from the theme. 8 | /// - radius: A `RadiusToken` representing the corner radius. Default is `.md`. 9 | /// - color: A `ColorToken` for stroke color. Default is `.primary`. 10 | @ViewBuilder 11 | func fill( 12 | _ color: ColorToken 13 | ) -> some View { 14 | ThemedShape(shape: self, color: color) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/SwiftThemeKitDemoApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | @main 5 | struct SwiftThemeKitDemoApp: App { 6 | @StateObject private var themeManager = ThemeManager() 7 | 8 | var body: some Scene { 9 | WindowGroup { 10 | ThemeProvider(light: themeManager.colorThemeLight, dark: themeManager.colorThemeDark) { 11 | NavigationStack { 12 | ContentView() 13 | } 14 | } 15 | .environmentObject(themeManager) 16 | .preferredColorScheme( 17 | themeManager.scheme == .system ? .light : 18 | themeManager.scheme == .dark ? .dark : .light 19 | ) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeShapes+Copy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension ThemeShapes { 4 | /// Returns a new `ThemeShapes` instance by copying the current one 5 | /// and applying the specified overrides. 6 | /// 7 | /// Only parameters you pass will be changed — others will retain existing values. 8 | func copy( 9 | xs: CGFloat? = nil, 10 | sm: CGFloat? = nil, 11 | md: CGFloat? = nil, 12 | lg: CGFloat? = nil, 13 | xl: CGFloat? = nil 14 | ) -> ThemeShapes { 15 | ThemeShapes( 16 | xs: xs ?? self.xs, 17 | sm: sm ?? self.sm, 18 | md: md ?? self.md, 19 | lg: lg ?? self.lg, 20 | xl: xl ?? self.xl 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/RadioGroupView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct RadioGroupView: View { 5 | @Environment(\.appTheme) private var theme 6 | @Binding var shapes: Shapes 7 | 8 | var body: some View { 9 | Card { 10 | VStack(alignment: .leading, spacing: theme.spacing.md) { 11 | Text("Radio buttons") 12 | .font(.headlineLarge) 13 | 14 | RadioGroup( 15 | selection: $shapes, 16 | options: Shapes.allCases 17 | ) { item in 18 | item.rawValue 19 | } 20 | } 21 | } 22 | } 23 | } 24 | 25 | #Preview { 26 | RadioGroupView(shapes: .constant(.rectangle)) 27 | .padding(.md) 28 | } 29 | -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.docset/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | com.jazzy.swiftthemekit 7 | CFBundleName 8 | SwiftThemeKit 9 | DocSetPlatformFamily 10 | swiftthemekit 11 | isDashDocset 12 | 13 | dashIndexFilePath 14 | index.html 15 | isJavaScriptEnabled 16 | 17 | DashDocSetFamily 18 | dashtoc 19 | 20 | 21 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/TypographyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypographyView.swift 3 | // SwiftThemeKitDemo 4 | // 5 | // Created by Albu Eduard on 19.05.2025. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftThemeKit 10 | 11 | struct TypographyView: View { 12 | @Environment(\.appTheme) private var theme 13 | 14 | var body: some View { 15 | Card { 16 | VStack(alignment: .leading, spacing: theme.spacing.md) { 17 | Text("Typography") 18 | .font(.headlineLarge) 19 | 20 | ForEach(TextStyleToken.allCases, id: \.rawValue) { 21 | Text($0.rawValue) 22 | .font($0) 23 | } 24 | } 25 | .fillMaxWidth() 26 | } 27 | } 28 | } 29 | 30 | #Preview { 31 | TypographyView() 32 | .padding(.md) 33 | } 34 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/StrokesView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct StrokesView: View { 5 | @Environment(\.appTheme) private var theme 6 | 7 | var body: some View { 8 | Card { 9 | VStack(alignment: .leading, spacing: theme.spacing.md) { 10 | Text("Stroke") 11 | .font(.headlineLarge) 12 | 13 | ForEach(StrokeToken.allCases, id: \.rawValue) { token in 14 | HStack(spacing: 0) { 15 | Text(token.rawValue) 16 | .font(.bodyMedium) 17 | .foregroundColor(.onSurfaceVariant) 18 | .padding(.md) 19 | } 20 | .fillMaxWidth() 21 | .stroke(token) 22 | } 23 | } 24 | .fillMaxWidth() 25 | } 26 | } 27 | } 28 | 29 | #Preview { 30 | StrokesView() 31 | .padding(.md) 32 | } 33 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/ShadowsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct ShadowsView: View { 5 | @Environment(\.appTheme) private var theme 6 | 7 | var body: some View { 8 | Card { 9 | VStack(alignment: .leading, spacing: theme.spacing.lg) { 10 | Text("Shadows") 11 | .font(.headlineLarge) 12 | 13 | ForEach(ShadowToken.allCases, id: \.rawValue) { token in 14 | HStack { 15 | Text(token.rawValue) 16 | .font(.bodyMedium) 17 | .foregroundColor(.onSurfaceVariant) 18 | } 19 | .padding(.md) 20 | .fillMaxWidth() 21 | .backgroundShape(.md, color: .secondaryContainer) 22 | .shadow(token) 23 | } 24 | } 25 | .fillMaxWidth() 26 | } 27 | } 28 | } 29 | 30 | #Preview { 31 | ShadowsView() 32 | .padding(.md) 33 | } 34 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeShadow+Copy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension ThemeShadow { 4 | /// Returns a new `ThemeShadow` instance by copying the current one 5 | /// and applying the specified overrides. 6 | /// 7 | /// - Parameters: 8 | /// - color: Optional override for the shadow color. 9 | /// - radius: Optional override for the blur radius. 10 | /// - x: Optional override for the horizontal offset. 11 | /// - y: Optional override for the vertical offset. 12 | /// - Returns: A new `ThemeShadow` instance with the applied overrides. 13 | func copy( 14 | color: Color? = nil, 15 | radius: CGFloat? = nil, 16 | x: CGFloat? = nil, 17 | y: CGFloat? = nil 18 | ) -> ThemeShadow { 19 | ThemeShadow( 20 | color: color ?? self.color, 21 | radius: radius ?? self.radius, 22 | x: x ?? self.x, 23 | y: y ?? self.y 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/TextEditor/TextEditorConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Configuration object that holds text editor styling parameters 4 | struct TextEditorConfiguration { 5 | var variant: TextFieldVariant? 6 | var size: TextFieldSize? 7 | var shape: TextFieldShape? 8 | var font: ThemeFontToken? 9 | var isError: Bool 10 | var minHeight: CGFloat? 11 | var maxHeight: CGFloat? 12 | 13 | init( 14 | variant: TextFieldVariant? = nil, 15 | size: TextFieldSize? = nil, 16 | shape: TextFieldShape? = nil, 17 | font: ThemeFontToken? = nil, 18 | isError: Bool = false, 19 | minHeight: CGFloat? = nil, 20 | maxHeight: CGFloat? = nil 21 | ) { 22 | self.variant = variant 23 | self.size = size 24 | self.shape = shape 25 | self.font = font 26 | self.isError = isError 27 | self.minHeight = minHeight 28 | self.maxHeight = maxHeight 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xFF", 9 | "green" : "0x57", 10 | "red" : "0x72" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xFF", 27 | "green" : "0x57", 28 | "red" : "0x72" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Extensions/Color+Hex.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Color { 4 | init(hex: String) { 5 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 6 | var int: UInt64 = 0 7 | Scanner(string: hex).scanHexInt64(&int) 8 | let a, r, g, b: UInt64 9 | switch hex.count { 10 | case 3: // RGB (12-bit) 11 | (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) 12 | case 6: // RGB (24-bit) 13 | (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) 14 | case 8: // ARGB (32-bit) 15 | (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) 16 | default: 17 | (a, r, g, b) = (1, 1, 1, 0) 18 | } 19 | 20 | self.init( 21 | .sRGB, 22 | red: Double(r) / 255, 23 | green: Double(g) / 255, 24 | blue: Double(b) / 255, 25 | opacity: Double(a) / 255 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/ColorsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct ColorsView: View { 5 | @Environment(\.appTheme) private var theme 6 | 7 | var body: some View { 8 | Card { 9 | VStack(alignment: .leading, spacing: theme.spacing.md) { 10 | Text("Colors") 11 | .font(.headlineLarge) 12 | 13 | ForEach(ColorToken.allCases, id: \.rawValue) { token in 14 | HStack { 15 | Text(token.rawValue) 16 | .font(.bodyMedium) 17 | 18 | Spacer() 19 | 20 | RoundedRectangle(cornerRadius: theme.radii.md) 21 | .fill(token) 22 | .size(40) 23 | } 24 | .fillMaxWidth() 25 | .frame(height: 40) 26 | } 27 | } 28 | .fillMaxWidth() 29 | } 30 | } 31 | } 32 | 33 | #Preview { 34 | ScrollView { 35 | ColorsView() 36 | .padding(.md) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/ShapesView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct ShapesView: View { 5 | @Environment(\.appTheme) private var theme 6 | 7 | var body: some View { 8 | Card { 9 | VStack(alignment: .leading, spacing: theme.spacing.md) { 10 | Text("Shapes") 11 | .font(.headlineLarge) 12 | 13 | ForEach(ShapeToken.allCases, id: \.rawValue) { token in 14 | ZStack { 15 | Text(token.rawValue) 16 | .font(.bodyMedium) 17 | .foregroundColor(.onPrimary) 18 | .padding(.horizontal, .md) 19 | } 20 | .fillMaxWidth() 21 | .frame(height: 40) 22 | .backgroundShape(token, color: .primary) 23 | } 24 | } 25 | .fillMaxWidth() 26 | } 27 | } 28 | } 29 | 30 | #Preview { 31 | ShapesView() 32 | .padding(.md) 33 | } 34 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/RadiiView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct RadiiView: View { 5 | @Environment(\.appTheme) private var theme 6 | var body: some View { 7 | Card { 8 | VStack(alignment: .leading, spacing: theme.spacing.md) { 9 | Text("Radii") 10 | .font(.headlineLarge) 11 | 12 | ForEach(RadiusToken.allCases, id: \.rawValue) { token in 13 | HStack { 14 | Text(token.rawValue) 15 | .font(.bodyMedium) 16 | .foregroundColor(.onSurfaceVariant) 17 | 18 | Spacer() 19 | 20 | Rectangle() 21 | .fill(.primary) 22 | .size(80) 23 | .clipRadius(token) 24 | } 25 | .fillMaxWidth() 26 | } 27 | } 28 | .fillMaxWidth() 29 | } 30 | } 31 | } 32 | 33 | #Preview { 34 | RadiiView() 35 | .padding(.md) 36 | } 37 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/ForegroundModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that applies a foreground color to a view using a color token from the current theme. 4 | /// 5 | /// This is useful for setting text, icon, or other foreground element colors in a consistent and theme-driven way. 6 | /// 7 | /// ### Example: 8 | /// ```swift 9 | /// Text("Warning") 10 | /// .modifier(ForegroundModifier(token: .error)) 11 | /// ``` 12 | /// 13 | /// - Parameters: 14 | /// - token: A `ColorToken` representing the desired foreground color from the theme (e.g., `.primary`, `.onSurface`, `.error`). 15 | struct ForegroundModifier: ViewModifier { 16 | /// Accesses the current theme from the environment. 17 | @Environment(\.appTheme) private var theme 18 | 19 | /// The color token to apply to the foreground. 20 | let token: ColorToken 21 | 22 | func body(content: Content) -> some View { 23 | content.foregroundColor(theme.colors[token]) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeButton+Copy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension ThemeButtonDefaults { 4 | /// Returns a new `ThemeSpacing` instance by copying the current one 5 | /// and applying the specified overrides. 6 | /// 7 | /// Only the parameters you pass will be changed; others retain their original values. 8 | /// 9 | /// - Parameters: 10 | /// - shape: Optional override for buttons default shape. 11 | /// - size: Optional override for buttons default size. 12 | /// - variant: Optional override for buttons default variant. 13 | /// - Returns: A new `ThemeSpacing` instance with the applied overrides. 14 | func copy( 15 | shape: ButtonShape? = nil, 16 | size: ButtonSize? = nil, 17 | variant: ButtonVariant? = nil 18 | ) -> ThemeButtonDefaults { 19 | ThemeButtonDefaults( 20 | shape: shape ?? self.shape, 21 | size: size ?? self.size, 22 | variant: variant ?? self.variant 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/CornerRadiusModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that applies a corner radius to a view using a radius token defined in the current theme. 4 | /// 5 | /// This modifier reads a `RadiusToken` and retrieves the corresponding corner radius from the theme's radii scale. 6 | /// 7 | /// ### Example: 8 | /// ```swift 9 | /// Text("Hello, World!") 10 | /// .modifier(CornerRadiusModifier(token: .md)) 11 | /// ``` 12 | /// 13 | /// - Parameters: 14 | /// - token: A `RadiusToken` representing the desired corner radius (e.g., `.xs`, `.md`, `.xl`) defined in the theme. 15 | struct CornerRadiusModifier: ViewModifier { 16 | /// Accesses the current theme from the environment. 17 | @Environment(\.appTheme) private var theme 18 | 19 | /// The token used to determine the corner radius from the theme. 20 | let token: RadiusToken 21 | 22 | func body(content: Content) -> some View { 23 | content.cornerRadius(theme.radii[token]) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/SpacingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct SpacingsView: View { 5 | @Environment(\.appTheme) private var theme 6 | var body: some View { 7 | Card { 8 | VStack(alignment: .leading, spacing: theme.spacing.md) { 9 | Text("Spacings") 10 | .font(.headlineLarge) 11 | 12 | ForEach(SpacingToken.allCases, id: \.rawValue) { token in 13 | HStack(spacing: 0) { 14 | Text(token.rawValue) 15 | .font(.bodyMedium) 16 | .foregroundColor(.onSurfaceVariant) 17 | 18 | Text(token.rawValue) 19 | .font(.bodyMedium) 20 | .foregroundColor(.onSurfaceVariant) 21 | .padding(.leading, token) 22 | } 23 | .fillMaxWidth() 24 | } 25 | } 26 | .fillMaxWidth() 27 | } 28 | } 29 | } 30 | 31 | #Preview { 32 | SpacingsView() 33 | .padding(.md) 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/ClipShapeModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that clips the view using a shape defined in the current theme. 4 | /// 5 | /// This modifier uses a `ShapeToken` to retrieve a shape (e.g., rounded rectangles of various sizes) 6 | /// from the `ThemeShapes` and applies it as a clipping mask to the content. 7 | /// 8 | /// ### Example: 9 | /// ```swift 10 | /// Image("avatar") 11 | /// .modifier(ClipShapeModifier(token: .md)) 12 | /// ``` 13 | /// 14 | /// - Parameters: 15 | /// - token: A `ShapeToken` representing the shape size (e.g., `.xs`, `.md`, `.xl`) defined in the current theme. 16 | struct ClipShapeModifier: ViewModifier { 17 | /// Accesses the current theme from the environment. 18 | @Environment(\.appTheme) private var theme 19 | 20 | /// The shape token used to retrieve the shape from the theme. 21 | let token: ShapeToken 22 | 23 | func body(content: Content) -> some View { 24 | content.clipShape(theme.shapes[token]) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "SwiftThemeKit", 6 | platforms: [ 7 | .iOS(.v15), 8 | .macOS(.v12), 9 | .tvOS(.v15), 10 | .watchOS(.v8) 11 | ], 12 | products: [ 13 | .library( 14 | name: "SwiftThemeKit", 15 | targets: ["SwiftThemeKit"] 16 | ), 17 | ], 18 | dependencies: [ 19 | // Add ViewInspector here 20 | .package(url: "https://github.com/nalexn/ViewInspector", from: "0.10.1") 21 | ], 22 | targets: [ 23 | .target( 24 | name: "SwiftThemeKit", 25 | dependencies: [], 26 | path: "Sources/SwiftThemeKit" 27 | ), 28 | .testTarget( 29 | name: "SwiftThemeKitTests", 30 | dependencies: [ 31 | "SwiftThemeKit", 32 | .product(name: "ViewInspector", package: "ViewInspector") 33 | ], 34 | path: "Tests/SwiftThemeKitTests" 35 | ), 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /Tests/SwiftThemeKitTests/ThemeStrokeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftThemeKit 3 | 4 | final class ThemeStrokeTests: XCTestCase { 5 | 6 | func testDefaultLightStrokeValues() { 7 | let stroke = ThemeStroke.defaultLignt 8 | XCTAssertEqual(stroke.none, 0) 9 | XCTAssertEqual(stroke.xs, 1) 10 | XCTAssertEqual(stroke.sm, 2) 11 | XCTAssertEqual(stroke.md, 4) 12 | XCTAssertEqual(stroke.lg, 8) 13 | } 14 | 15 | func testDefaultDarkStrokeValues() { 16 | let stroke = ThemeStroke.defaultDark 17 | XCTAssertEqual(stroke.none, 0) 18 | XCTAssertEqual(stroke.xs, 1) 19 | XCTAssertEqual(stroke.sm, 2) 20 | XCTAssertEqual(stroke.md, 4) 21 | XCTAssertEqual(stroke.lg, 8) 22 | } 23 | 24 | func testStrokeTokenSubscriptReturnsCorrectValues() { 25 | let custom = ThemeStroke(none: 10, xs: 11, sm: 12, md: 13, lg: 14) 26 | 27 | XCTAssertEqual(custom[.none], 10) 28 | XCTAssertEqual(custom[.xs], 11) 29 | XCTAssertEqual(custom[.sm], 12) 30 | XCTAssertEqual(custom[.md], 13) 31 | XCTAssertEqual(custom[.lg], 14) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/ShadowModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that applies a themed shadow using a `ShadowToken` from the current design system. 4 | /// 5 | /// This helps maintain consistent elevation and shadow styling across the app by relying on pre-defined shadow tokens. 6 | /// 7 | /// ### Example: 8 | /// ```swift 9 | /// Card { 10 | /// Text("Elevated") 11 | /// } 12 | /// .modifier(ShadowModifier(token: .md)) 13 | /// ``` 14 | /// 15 | /// - Parameters: 16 | /// - token: A `ShadowToken` that maps to a shadow configuration (color, radius, offset) from the theme. 17 | struct ShadowModifier: ViewModifier { 18 | /// Accesses the current theme’s shadow definitions. 19 | @Environment(\.appTheme) private var theme 20 | 21 | /// The shadow token to apply (e.g., `.sm`, `.md`, `.lg`). 22 | let token: ShadowToken 23 | 24 | func body(content: Content) -> some View { 25 | let shadow = theme.shadows[token] 26 | 27 | content.shadow( 28 | color: shadow.color, 29 | radius: shadow.radius, 30 | x: shadow.x, 31 | y: shadow.y 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeCheckboxSize+Copy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension ThemeCheckboxSize { 4 | /// Creates a copy of this `ThemeCheckboxSize`, optionally replacing one or more size configurations. 5 | /// 6 | /// - Parameters: 7 | /// - small: An optional new configuration to replace the existing `.small` size. Defaults to `nil`. 8 | /// - medium: An optional new configuration to replace the existing `.medium` size. Defaults to `nil`. 9 | /// - large: An optional new configuration to replace the existing `.large` size. Defaults to `nil`. 10 | /// 11 | /// - Returns: A new `ThemeCheckboxSize` instance with the specified replacements applied, 12 | /// or identical to this instance if no replacements are provided. 13 | func copy( 14 | small: CheckboxConfiguration? = nil, 15 | medium: CheckboxConfiguration? = nil, 16 | large: CheckboxConfiguration? = nil 17 | ) -> ThemeCheckboxSize { 18 | ThemeCheckboxSize( 19 | small: small ?? self.small, 20 | medium: medium ?? self.medium, 21 | large: large ?? self.large 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Eduard Albu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeRadioButtonSize+Copy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension ThemeRadioButtonSize { 4 | /// Creates a copy of this `ThemeRadioButtonSize`, optionally replacing one or more size configurations. 5 | /// 6 | /// - Parameters: 7 | /// - small: An optional new configuration to replace the existing `.small` size. Defaults to `nil`. 8 | /// - medium: An optional new configuration to replace the existing `.medium` size. Defaults to `nil`. 9 | /// - large: An optional new configuration to replace the existing `.large` size. Defaults to `nil`. 10 | /// 11 | /// - Returns: A new `ThemeRadioButtonSize` instance with the specified replacements applied, 12 | /// or identical to this instance if no replacements are provided. 13 | func copy( 14 | small: RadioButtonConfiguration? = nil, 15 | medium: RadioButtonConfiguration? = nil, 16 | large: RadioButtonConfiguration? = nil 17 | ) -> ThemeRadioButtonSize { 18 | ThemeRadioButtonSize( 19 | small: small ?? self.small, 20 | medium: medium ?? self.medium, 21 | large: large ?? self.large 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/TextEditor/ThemedTextEditorModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that injects a fully resolved `TextFieldConfiguration` into the environment. 4 | /// 5 | /// It merges the currently active configuration from the environment with the default values 6 | /// from the theme. This allows individual style modifiers (e.g., shape, size) to override only 7 | /// parts of the configuration without resetting the others. 8 | /// 9 | /// Use this as the base modifier when building a styled text field. 10 | struct ThemedTextEditorModifier: ViewModifier { 11 | @Environment(\.appTheme) private var theme 12 | @Environment(\.textEditorConfiguration) private var config 13 | 14 | func body(content: Content) -> some View { 15 | let configuration = TextEditorConfiguration( 16 | variant: config.variant ?? theme.textFields.variant, 17 | size: config.size ?? theme.textFields.size, 18 | shape: config.shape ?? theme.textFields.shape, 19 | font: config.font, 20 | isError: config.isError 21 | ) 22 | content 23 | .environment(\.textEditorConfiguration, configuration) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemePositions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents horizontal alignment or placement direction. 4 | /// 5 | /// Used to position UI elements such as labels relative to controls (e.g., leading or trailing side of a checkbox or radio button). 6 | /// 7 | /// - Example: 8 | /// ```swift 9 | /// Checkbox(isChecked: $checked, label: "Accept Terms", labelPosition: .leading) 10 | /// ``` 11 | public enum HorizontalPosition: CaseIterable { 12 | /// Leading edge (left in left-to-right layouts). 13 | case leading 14 | 15 | /// Trailing edge (right in left-to-right layouts). 16 | case trailing 17 | } 18 | 19 | /// Represents vertical alignment or placement direction. 20 | /// 21 | /// Useful for positioning content relative to other components, such as placing a message above or below a form field. 22 | /// 23 | /// - Example: 24 | /// ```swift 25 | /// Tooltip(position: .top) 26 | /// ``` 27 | public enum VerticalPosition: CaseIterable { 28 | /// Top edge — places the content above the reference view. 29 | case top 30 | 31 | /// Bottom edge — places the content below the reference view. 32 | case bottom 33 | } 34 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeShadows+Copy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension ThemeShadows { 4 | /// Returns a new `ThemeShadows` instance by copying the current one 5 | /// and applying the specified overrides. 6 | /// 7 | /// Only the provided parameters will be replaced; all others will retain their existing values. 8 | /// 9 | /// - Parameters: 10 | /// - sm: Optional override for the small shadow. 11 | /// - md: Optional override for the medium shadow. 12 | /// - lg: Optional override for the large shadow. 13 | /// - focus: Optional override for the focus shadow. 14 | /// - none: Optional override for the no-shadow style. 15 | /// - Returns: A new `ThemeShadows` instance with applied overrides. 16 | func copy( 17 | sm: ThemeShadow? = nil, 18 | md: ThemeShadow? = nil, 19 | lg: ThemeShadow? = nil, 20 | focus: ThemeShadow? = nil, 21 | none: ThemeShadow? = nil 22 | ) -> ThemeShadows { 23 | ThemeShadows( 24 | sm: sm ?? self.sm, 25 | md: md ?? self.md, 26 | lg: lg ?? self.lg, 27 | focus: focus ?? self.focus, 28 | none: none ?? self.none 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/PaddingModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that applies theme-based padding to specified edges of a view using a spacing token. 4 | /// 5 | /// This modifier helps ensure consistent spacing throughout the UI by relying on the design system’s spacing scale. 6 | /// 7 | /// ### Example: 8 | /// ```swift 9 | /// Text("Hello") 10 | /// .modifier(PaddingModifier(edges: .horizontal, token: .md)) 11 | /// ``` 12 | /// 13 | /// - Parameters: 14 | /// - edges: The set of edges (`.top`, `.horizontal`, `.all`, etc.) to apply the padding to. 15 | /// - token: A `SpacingToken` representing the spacing size from the current theme (e.g., `.sm`, `.md`, `.lg`). 16 | struct PaddingModifier: ViewModifier { 17 | /// Accesses the current theme's spacing scale from the environment. 18 | @Environment(\.appTheme) private var theme 19 | 20 | /// The edges where padding should be applied. 21 | let edges: Edge.Set 22 | 23 | /// The token representing the spacing value from the theme. 24 | let token: SpacingToken 25 | 26 | func body(content: Content) -> some View { 27 | content.padding(edges, theme.spacing[token]) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/TextFieldsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct TextFieldsView: View { 5 | @Environment(\.appTheme) private var theme 6 | @State private var fieldsValue: String = "" 7 | let shapes: Shapes 8 | 9 | var body: some View { 10 | Card { 11 | VStack(alignment: .leading, spacing: theme.spacing.md) { 12 | Text("Text fields") 13 | .font(.headlineLarge) 14 | 15 | TextField("Outlined", text: $fieldsValue) 16 | .textFieldVariant(.outlined) 17 | .textFieldShape(shapes.textFieldShape) 18 | 19 | TextField("Underlined", text: $fieldsValue) 20 | .textFieldVariant(.underlined) 21 | .textFieldShape(shapes.textFieldShape) 22 | 23 | TextField("Filled", text: $fieldsValue) 24 | .textFieldVariant(.filled) 25 | .textFieldShape(shapes.textFieldShape) 26 | 27 | TextField("Plain", text: $fieldsValue) 28 | .textFieldVariant(.plain) 29 | .textFieldShape(shapes.textFieldShape) 30 | } 31 | } 32 | } 33 | } 34 | 35 | #Preview { 36 | TextFieldsView(shapes: .rounded) 37 | .padding(.md) 38 | } 39 | -------------------------------------------------------------------------------- /docs/badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | documentation 17 | 18 | 19 | documentation 20 | 21 | 22 | 71% 23 | 24 | 25 | 71% 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeRadii+Copy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension ThemeRadii { 4 | /// Returns a new `ThemeRadii` instance by copying the current one and applying the specified overrides. 5 | /// 6 | /// Only parameters you pass will be changed — others will retain their existing values. 7 | /// 8 | /// - Parameters: 9 | /// - xs: Override for the extra small radius token. 10 | /// - sm: Override for the small radius token. 11 | /// - md: Override for the medium radius token. 12 | /// - lg: Override for the large radius token. 13 | /// - xl: Override for the extra large radius token. 14 | /// - pill: Override for the pill radius token. 15 | /// - Returns: A new `ThemeRadii` instance with the applied overrides. 16 | func copy( 17 | xs: CGFloat? = nil, 18 | sm: CGFloat? = nil, 19 | md: CGFloat? = nil, 20 | lg: CGFloat? = nil, 21 | xl: CGFloat? = nil, 22 | pill: CGFloat? = nil 23 | ) -> ThemeRadii { 24 | ThemeRadii( 25 | xs: xs ?? self.xs, 26 | sm: sm ?? self.sm, 27 | md: md ?? self.md, 28 | lg: lg ?? self.lg, 29 | xl: xl ?? self.xl, 30 | pill: pill ?? self.pill 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/SwiftThemeKitTests/ThemeSpacingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftThemeKit 3 | 4 | final class ThemeSpacingTests: XCTestCase { 5 | 6 | func testDefaultLightSpacingValues() { 7 | let spacing = ThemeSpacing.defaultLight 8 | XCTAssertEqual(spacing.xs, 4) 9 | XCTAssertEqual(spacing.sm, 8) 10 | XCTAssertEqual(spacing.md, 16) 11 | XCTAssertEqual(spacing.lg, 32) 12 | XCTAssertEqual(spacing.xl, 48) 13 | XCTAssertEqual(spacing.xxl, 80) 14 | } 15 | 16 | func testDefaultDarkSpacingValues() { 17 | let spacing = ThemeSpacing.defaultDark 18 | XCTAssertEqual(spacing.xs, 4) 19 | XCTAssertEqual(spacing.sm, 8) 20 | XCTAssertEqual(spacing.md, 16) 21 | XCTAssertEqual(spacing.lg, 32) 22 | XCTAssertEqual(spacing.xl, 48) 23 | XCTAssertEqual(spacing.xxl, 80) 24 | } 25 | 26 | func testSpacingTokenSubscriptReturnsCorrectValues() { 27 | let custom = ThemeSpacing(xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 6) 28 | 29 | XCTAssertEqual(custom[.xs], 1) 30 | XCTAssertEqual(custom[.sm], 2) 31 | XCTAssertEqual(custom[.md], 3) 32 | XCTAssertEqual(custom[.lg], 4) 33 | XCTAssertEqual(custom[.xl], 5) 34 | XCTAssertEqual(custom[.xxl], 6) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemoTests/SwiftThemeKitDemoTests.swift: -------------------------------------------------------------------------------- 1 | import SnapshotTesting 2 | import Testing 3 | import SwiftUI 4 | @testable import SwiftThemeKitDemo 5 | 6 | @MainActor 7 | struct SwiftThemeKitDemoTests { 8 | @Test func testExampleViewSnapshot() { 9 | let views: [(String, AnyView)] = [ 10 | ("ButtonsView", AnyView(ButtonsView(shapes: .rounded))), 11 | ("CheckboxesView", AnyView(CheckboxesView())), 12 | ("ColorsView", AnyView(ColorsView())), 13 | ("RadiiView", AnyView(RadiiView())), 14 | ("RadioGroupView", AnyView(RadioGroupView(shapes: .constant(.rounded)))), 15 | ("ShadowsView", AnyView(ShadowsView())), 16 | ("ShapesView", AnyView(ShapesView())), 17 | ("SpacingsView", AnyView(SpacingsView())), 18 | ("StrokesView", AnyView(StrokesView())), 19 | ("TextFieldsView", AnyView(TextFieldsView(shapes: .rounded))), 20 | ("TypographyView", AnyView(TypographyView())) 21 | ] 22 | 23 | for (name, view) in views { 24 | let hostingController = UIHostingController(rootView: view) 25 | hostingController.view.backgroundColor = .clear 26 | assertSnapshots(of: hostingController, as: [name: .image], record: false) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeSpacing+Copy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension ThemeSpacing { 4 | /// Returns a new `ThemeSpacing` instance by copying the current one 5 | /// and applying the specified overrides. 6 | /// 7 | /// Only the parameters you pass will be changed; others retain their original values. 8 | /// 9 | /// - Parameters: 10 | /// - xs: Optional override for extra-small spacing. 11 | /// - sm: Optional override for small spacing. 12 | /// - md: Optional override for medium spacing. 13 | /// - lg: Optional override for large spacing. 14 | /// - xl: Optional override for extra-large spacing. 15 | /// - xxl: Optional override for double-extra-large spacing. 16 | /// - Returns: A new `ThemeSpacing` instance with the applied overrides. 17 | func copy( 18 | xs: CGFloat? = nil, 19 | sm: CGFloat? = nil, 20 | md: CGFloat? = nil, 21 | lg: CGFloat? = nil, 22 | xl: CGFloat? = nil, 23 | xxl: CGFloat? = nil 24 | ) -> ThemeSpacing { 25 | ThemeSpacing( 26 | xs: xs ?? self.xs, 27 | sm: sm ?? self.sm, 28 | md: md ?? self.md, 29 | lg: lg ?? self.lg, 30 | xl: xl ?? self.xl, 31 | xxl: xxl ?? self.xxl 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | documentation 17 | 18 | 19 | documentation 20 | 21 | 22 | 59% 23 | 24 | 25 | 59% 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/View+ColorScheme.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | /// Adds a toolbar button to toggle the app's color scheme between light and dark modes. 5 | /// 6 | /// - Parameter colorScheme: A binding to the current `AppColorScheme` value. 7 | /// - Returns: A view with a toolbar button placed appropriately depending on the platform. 8 | func colorSchemeButton(colorScheme: Binding) -> some View { 9 | #if os(iOS) 10 | let placement: ToolbarItemPlacement = .topBarTrailing 11 | #elseif os(macOS) 12 | // `.automatic` is typically appropriate for macOS toolbars. 13 | let placement: ToolbarItemPlacement = .automatic 14 | #else 15 | let placement: ToolbarItemPlacement = .automatic 16 | #endif 17 | 18 | return self.toolbar { 19 | ToolbarItem(placement: placement) { 20 | Button { 21 | // Toggle between light and dark modes. 22 | colorScheme.wrappedValue = (colorScheme.wrappedValue == .light) ? .dark : .light 23 | } label: { 24 | // Show icon depending on current color scheme. 25 | Image(systemName: colorScheme.wrappedValue == .light ? "moon" : "sun.max") 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Components/ThemedShape.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI view that renders a shape using a color defined in the current theme. 4 | /// 5 | /// `ThemedShape` allows developers to apply consistent, theme-based coloring to any SwiftUI `Shape`, 6 | /// leveraging the `ColorToken` system from `Theme.colors`. 7 | /// 8 | /// The actual color is resolved from the current `Theme` injected in the environment via `ThemeProvider`. 9 | /// 10 | /// Example usage: 11 | /// ```swift 12 | /// ThemedShape(shape: Circle(), color: .accent) 13 | /// .frame(width: 50, height: 50) 14 | /// ``` 15 | /// 16 | /// - Note: Ensure the view hierarchy is wrapped in a `ThemeProvider` so the `theme` environment is properly available. 17 | public struct ThemedShape: View { 18 | 19 | /// Accesses the current `Theme` from the environment. 20 | @Environment(\.appTheme) private var theme 21 | 22 | /// The SwiftUI shape to render, such as `Circle`, `RoundedRectangle`, etc. 23 | public let shape: S 24 | 25 | /// The color token to apply to the shape’s fill, resolved from the theme. 26 | public let color: ColorToken 27 | 28 | /// The content and behavior of the `ThemedShape` view. 29 | public var body: some View { 30 | shape.fill(theme.colors[color]) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemeButtonDefaults.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ThemeButtonDefaults: Equatable { 4 | /// The default shape of the buttons if no other modifiers applied 5 | public let shape: ButtonShape 6 | 7 | /// The default size of the buttons if no other modifiers applied 8 | public let size: ButtonSize 9 | 10 | /// The default variant of the buttons if no other modifiers applied 11 | public let variant: ButtonVariant 12 | 13 | /// The default configuration for light mode 14 | public static let defaultLight = ThemeButtonDefaults( 15 | shape: .rounded, 16 | size: .medium, 17 | variant: .filled 18 | ) 19 | 20 | /// The default configuration for dark mode 21 | public static let defaultDark = ThemeButtonDefaults( 22 | shape: .rounded, 23 | size: .medium, 24 | variant: .filled 25 | ) 26 | 27 | /// Initialize a new button configuration 28 | /// 29 | /// - Parameters: 30 | /// - shape: The `ButtonShape` to be used by default 31 | /// - size: The `ButtonSize` to be used by default 32 | /// - variant: The `ButtonVariant` to be used by default 33 | public init(shape: ButtonShape, size: ButtonSize, variant: ButtonVariant) { 34 | self.shape = shape 35 | self.size = size 36 | self.variant = variant 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemeShadow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Defines a shadow style for UI elements, including color, blur radius, and offset values. 4 | /// Used to indicate elevation, depth, or focus across your app. 5 | public struct ThemeShadow: Equatable { 6 | 7 | /// The color of the shadow. 8 | /// Typically black with opacity in light mode or white with opacity in dark mode. 9 | public let color: Color 10 | 11 | /// The blur radius of the shadow. 12 | /// Larger values create softer, more diffused shadows. 13 | public let radius: CGFloat 14 | 15 | /// The horizontal offset of the shadow. 16 | /// Positive values move the shadow to the right; negative to the left. 17 | public let x: CGFloat 18 | 19 | /// The vertical offset of the shadow. 20 | /// Positive values move the shadow down; negative values move it up. 21 | public let y: CGFloat 22 | 23 | /// Initializes a new shadow definition. 24 | /// - Parameters: 25 | /// - color: The shadow color. 26 | /// - radius: The blur radius of the shadow. 27 | /// - x: Horizontal offset in points. 28 | /// - y: Vertical offset in points. 29 | public init(color: Color, radius: CGFloat, x: CGFloat, y: CGFloat) { 30 | self.color = color 31 | self.radius = radius 32 | self.x = x 33 | self.y = y 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemeTextFieldDefaults.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ThemeTextFieldDefaults: Equatable { 4 | /// The default shape of the buttons if no other modifiers applied 5 | public let shape: TextFieldShape 6 | 7 | /// The default size of the buttons if no other modifiers applied 8 | public let size: TextFieldSize 9 | 10 | /// The default variant of the buttons if no other modifiers applied 11 | public let variant: TextFieldVariant 12 | 13 | /// The default configuration for light mode 14 | public static let defaultLight = ThemeTextFieldDefaults( 15 | shape: .rounded, 16 | size: .medium, 17 | variant: .filled 18 | ) 19 | 20 | /// The default configuration for dark mode 21 | public static let defaultDark = ThemeTextFieldDefaults( 22 | shape: .rounded, 23 | size: .medium, 24 | variant: .filled 25 | ) 26 | 27 | /// Initialize a new button configuration 28 | /// 29 | /// - Parameters: 30 | /// - shape: The `TextFieldShape` to be used by default 31 | /// - size: The `TextFieldSize` to be used by default 32 | /// - variant: The `TextFieldVariant` to be used by default 33 | public init(shape: TextFieldShape, size: TextFieldSize, variant: TextFieldVariant) { 34 | self.shape = shape 35 | self.size = size 36 | self.variant = variant 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/SwiftThemeKitTests/ThemeRadiiTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftThemeKit 3 | 4 | final class ThemeRadiiTests: XCTestCase { 5 | 6 | func testDefaultLightValues() { 7 | let radii = ThemeRadii.defaultLight 8 | 9 | XCTAssertEqual(radii.none, 0) 10 | XCTAssertEqual(radii.xs, 4) 11 | XCTAssertEqual(radii.sm, 8) 12 | XCTAssertEqual(radii.md, 12) 13 | XCTAssertEqual(radii.lg, 16) 14 | XCTAssertEqual(radii.xl, 28) 15 | XCTAssertEqual(radii.pill, 9999) 16 | } 17 | 18 | func testDefaultDarkEqualsLight() { 19 | let light = ThemeRadii.defaultLight 20 | let dark = ThemeRadii.defaultDark 21 | 22 | XCTAssertEqual(light.xs, dark.xs) 23 | XCTAssertEqual(light.sm, dark.sm) 24 | XCTAssertEqual(light.md, dark.md) 25 | XCTAssertEqual(light.lg, dark.lg) 26 | XCTAssertEqual(light.xl, dark.xl) 27 | XCTAssertEqual(light.pill, dark.pill) 28 | } 29 | 30 | func testSubscriptReturnsCorrectValues() { 31 | let radii = ThemeRadii(xs: 1, sm: 2, md: 3, lg: 4, xl: 5, pill: 6) 32 | 33 | XCTAssertEqual(radii[.none], 0) 34 | XCTAssertEqual(radii[.xs], 1) 35 | XCTAssertEqual(radii[.sm], 2) 36 | XCTAssertEqual(radii[.md], 3) 37 | XCTAssertEqual(radii[.lg], 4) 38 | XCTAssertEqual(radii[.xl], 5) 39 | XCTAssertEqual(radii[.pill], 6) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemePlatform.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents the platform on which the app is currently running. 4 | /// 5 | /// `ThemePlatform` allows SwiftThemeKit to apply platform-specific logic for 6 | /// sizing, spacing, typography, and layout. For example, checkboxes or buttons 7 | /// may have different base sizes and font styles depending on whether the app 8 | /// runs on iOS, macOS, watchOS, or tvOS. 9 | public enum ThemePlatform { 10 | 11 | /// The iOS platform (iPhone and iPad). 12 | case iOS 13 | 14 | /// The macOS platform (macOS apps and Catalyst). 15 | case macOS 16 | 17 | /// The watchOS platform (Apple Watch). 18 | case watchOS 19 | 20 | /// The tvOS platform (Apple TV). 21 | case tvOS 22 | 23 | /// Returns the platform the app is currently running on, determined at compile time. 24 | /// 25 | /// This is used internally by the theme to customize component sizes and spacing 26 | /// to match native platform conventions. 27 | /// 28 | /// - Returns: A `ThemePlatform` representing the current OS. 29 | public static var current: ThemePlatform { 30 | #if os(macOS) 31 | return .macOS 32 | #elseif os(iOS) 33 | return .iOS 34 | #elseif os(watchOS) 35 | return .watchOS 36 | #elseif os(tvOS) 37 | return .tvOS 38 | #else 39 | return .iOS // fallback 40 | #endif 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/TextField/TextFieldConfiguration.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Represents a configuration for a themed text field, allowing overrides for variant, size, shape, font, and error state. 4 | /// 5 | /// This struct is typically used in conjunction with environment values to manage consistent styling for custom text field components. 6 | struct TextFieldConfiguration { 7 | 8 | /// The visual style of the text field (e.g., filled, outlined, underlined). 9 | /// If `nil`, the default variant from the theme will be used. 10 | var variant: TextFieldVariant? = nil 11 | 12 | /// The size of the text field, which determines padding and font size. 13 | /// If `nil`, the default size from the theme will be used. 14 | var size: TextFieldSize? = nil 15 | 16 | /// The shape of the text field, controlling corner radius and clipping behavior. 17 | /// If `nil`, the default shape from the theme will be used. 18 | var shape: TextFieldShape? = nil 19 | 20 | /// The font style applied to the text content inside the text field. 21 | /// If `nil`, the font is determined based on the `size` or theme default. 22 | var font: ThemeFontToken? = nil 23 | 24 | /// A Boolean flag indicating whether the text field is in an error state. 25 | /// When `true`, the error color from the theme will be applied to borders and content. 26 | var isError: Bool = false 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/TextField/TextFieldVariant.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Defines the visual style or variant of a text field. 4 | /// 5 | /// Use `TextFieldVariant` to control how a text field appears in your UI. 6 | /// Each variant corresponds to a specific Material-style or native iOS presentation. 7 | /// 8 | /// This enum is typically consumed by `ThemeTextFieldStyle` or applied via 9 | /// modifiers like `.textFieldVariant()` to customize text field appearance. 10 | /// 11 | /// - Usage: 12 | /// ```swift 13 | /// TextField("Email", text: $email) 14 | /// .textFieldVariant(.outlined) 15 | /// ``` 16 | public enum TextFieldVariant { 17 | 18 | /// An outlined text field with a border around its shape. 19 | /// 20 | /// - Example: `┌────────────┐` 21 | /// `| Email |` 22 | /// `└────────────┘` 23 | case outlined 24 | 25 | /// A text field with an underline rather than a full border. 26 | /// 27 | /// - Example: `Email` 28 | /// `────────────` 29 | case underlined 30 | 31 | /// A filled text field with a background color and no border. 32 | /// 33 | /// - Example: `[ Email ]` with background fill. 34 | case filled 35 | 36 | /// A plain text field without additional styling. 37 | /// 38 | /// Useful when embedding in custom layouts or when minimal styling is desired. 39 | case plain 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/ClipRadiusModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that applies a rounded clipping shape to a view using a semantic radius token from the theme. 4 | /// 5 | /// This modifier reads the corner radius value from the current theme's `radii` using a `RadiusToken`, 6 | /// and clips the content with a `RoundedRectangle` using that radius. You can also specify the rounding style. 7 | /// 8 | /// ### Example: 9 | /// ```swift 10 | /// Image("profile") 11 | /// .modifier(ClipRadiusModifier(token: .lg)) 12 | /// ``` 13 | /// 14 | /// - Parameters: 15 | /// - token: A `RadiusToken` that corresponds to a semantic radius size (e.g., `.sm`, `.md`, `.lg`) defined in the theme. 16 | /// - style: The corner style to use for the rounded rectangle. Defaults to `.circular`. 17 | struct ClipRadiusModifier: ViewModifier { 18 | /// Accesses the current theme from the environment. 19 | @Environment(\.appTheme) private var theme 20 | 21 | /// The semantic token used to retrieve the corner radius from the theme. 22 | let token: RadiusToken 23 | 24 | /// The corner style used when rendering the rounded rectangle shape. Defaults to `.circular`. 25 | var style: RoundedCornerStyle = .circular 26 | 27 | func body(content: Content) -> some View { 28 | content.clipShape( 29 | RoundedRectangle(cornerRadius: theme.radii[token], style: style) 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemeFontToken.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A font descriptor that combines a text style token with an optional font weight override. 4 | /// 5 | /// `ThemeFontToken` is used throughout SwiftThemeKit to describe typography in a flexible, 6 | /// composable way. It allows developers to define a `TextStyleToken` from the theme and 7 | /// optionally override its weight (e.g., make `.bodyMedium` semibold). 8 | /// 9 | /// This type is used in components like buttons, text fields, and custom views to centralize 10 | /// font styling and resolve it consistently from the current theme. 11 | public struct ThemeFontToken { 12 | 13 | /// The base typography style from the theme, such as `.bodyMedium`, `.headlineSmall`, etc. 14 | public let style: TextStyleToken 15 | 16 | /// An optional override for the font weight (e.g., `.regular`, `.semibold`, `.bold`). 17 | /// 18 | /// If `nil`, the default weight from the theme’s `TextStyleToken` will be used. 19 | public let weight: Font.Weight? 20 | 21 | /// Initializes a new `ThemeFontToken` with a text style and optional weight override. 22 | /// 23 | /// - Parameters: 24 | /// - style: The text style token from the theme. 25 | /// - weight: An optional font weight override. 26 | public init(_ style: TextStyleToken, weight: Font.Weight? = nil) { 27 | self.style = style 28 | self.weight = weight 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/ThemeManager.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | enum AppColorScheme: String { 5 | case system, light, dark 6 | } 7 | 8 | final class ThemeManager: ObservableObject { 9 | @AppStorage("colorScheme") private var storedScheme: String = AppColorScheme.light.rawValue { 10 | didSet { objectWillChange.send() } 11 | } 12 | @AppStorage("colorTheme") private var storedTheme: String = "theme1" { 13 | didSet { objectWillChange.send() } 14 | } 15 | 16 | @Published var colorMode: String = AppColorScheme.light.rawValue { 17 | didSet { storedScheme = colorMode } 18 | } 19 | 20 | @Published var selectedTheme: String = "theme1" { 21 | didSet { storedTheme = selectedTheme } 22 | } 23 | 24 | var scheme: AppColorScheme { 25 | get { AppColorScheme(rawValue: storedScheme) ?? .system } 26 | set { storedScheme = newValue.rawValue } 27 | } 28 | 29 | var colorThemeLight: Theme { 30 | if storedTheme == "theme1" { 31 | return lightTheme1 32 | } else if storedTheme == "theme2" { 33 | return lightTheme2 34 | } else { 35 | return lightTheme3 36 | } 37 | } 38 | 39 | var colorThemeDark: Theme { 40 | if storedTheme == "theme1" { 41 | return darkTheme1 42 | } else if storedTheme == "theme2" { 43 | return darkTheme2 44 | } else { 45 | return darkTheme3 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/View+TextEditor.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension View { 4 | /// Applies a text editor variant style 5 | func textEditorVariant(_ variant: TextFieldVariant) -> some View { 6 | modifier(TextEditorVariantModifier(token: variant)) 7 | } 8 | 9 | /// Applies a text editor size 10 | func textEditorSize(_ size: TextFieldSize) -> some View { 11 | modifier(TextEditorSizeModifier(token: size)) 12 | } 13 | 14 | /// Applies a text editor shape 15 | func textEditorShape(_ shape: TextFieldShape) -> some View { 16 | modifier(TextEditorShapeModifier(token: shape)) 17 | } 18 | 19 | /// Applies a text editor font 20 | func textEditorFont(_ font: ThemeFontToken) -> some View { 21 | modifier(TextEditorFontModifier(token: font)) 22 | } 23 | 24 | /// Sets the text editor error state 25 | func textEditorError(_ isError: Bool = true) -> some View { 26 | modifier(TextEditorErrorModifier(isError: isError)) 27 | } 28 | 29 | /// Sets the text editor height constraints 30 | func textEditorHeight(min: CGFloat? = nil, max: CGFloat? = nil) -> some View { 31 | modifier(TextEditorHeightModifier(minHeight: min, maxHeight: max)) 32 | } 33 | 34 | /// Convenience method to apply the theme text editor style using environment configuration 35 | func applyThemeTextEditorStyle() -> some View { 36 | modifier(ThemedTextEditorModifier()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/SwiftThemeKitTests/ThemeProviderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import ViewInspector 4 | @testable import SwiftThemeKit 5 | 6 | final class ThemeProviderTests: XCTestCase { 7 | 8 | func testAppliesLightThemeWhenColorSchemeIsLight() throws { 9 | let sut = ThemeProvider(light: .defaultLight, dark: .defaultDark) { 10 | Text("Hello Light") 11 | } 12 | .environment(\.colorScheme, .light) 13 | 14 | let view = try sut.inspect().view(ThemeProvider.self, 0) 15 | let envTheme = try view.actualView().lightTheme 16 | XCTAssertEqual(envTheme.colors.primary, Theme.defaultLight.colors.primary) 17 | } 18 | 19 | func testAppliesDarkThemeWhenColorSchemeIsDark() throws { 20 | let sut = ThemeProvider(light: .defaultLight, dark: .defaultDark) { 21 | Text("Hello Dark") 22 | } 23 | .environment(\.colorScheme, .dark) 24 | 25 | let view = try sut.inspect().view(ThemeProvider.self, 0) 26 | let envTheme = try view.actualView().darkTheme 27 | XCTAssertEqual(envTheme.colors.primary, Theme.defaultDark.colors.primary) 28 | } 29 | 30 | func testWrapsContentCorrectly() throws { 31 | let sut = ThemeProvider { 32 | Text("Themed Text") 33 | } 34 | 35 | let view = try sut.inspect().view(ThemeProvider.self, 0) 36 | let text = try view.actualView().content() 37 | XCTAssertEqual(text, Text("Themed Text")) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/StrokeModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that applies a themed stroke (border) using radius, color, and width tokens from the design system. 4 | /// 5 | /// This enables consistent styling of outlines or borders across components like buttons, cards, inputs, etc. 6 | /// 7 | /// ### Example: 8 | /// ```swift 9 | /// Text("Bordered") 10 | /// .modifier(StrokeModifier(width: .sm)) 11 | /// ``` 12 | /// 13 | /// - Parameters: 14 | /// - width: A `StrokeToken` defining the line thickness (e.g., `.xs`, `.sm`, `.md`). 15 | /// - radius: A `RadiusToken` defining the corner radius of the border. Defaults to `.md`. 16 | /// - color: A `ColorToken` defining the stroke color. Defaults to `.primary`. 17 | struct StrokeModifier: ViewModifier { 18 | /// Accesses the current theme to retrieve radii, colors, and stroke widths. 19 | @Environment(\.appTheme) private var theme 20 | 21 | /// Stroke width from the theme (e.g., `.xs`, `.sm`). 22 | let width: StrokeToken 23 | 24 | /// Corner radius for the stroke's shape. Defaults to `.md`. 25 | var radius: RadiusToken = .md 26 | 27 | /// Color used for the stroke. Defaults to `.primary`. 28 | var color: ColorToken = .primary 29 | 30 | func body(content: Content) -> some View { 31 | content.overlay( 32 | RoundedRectangle(cornerRadius: theme.radii[radius]) 33 | .stroke(theme.colors[color], lineWidth: theme.stroke[width]) 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "be9509b2c2e1213af6cb13f0e4cafc108b9c29e437ee8e1d401adfe27e318197", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-custom-dump", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 8 | "state" : { 9 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 10 | "version" : "1.3.3" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-snapshot-testing", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", 17 | "state" : { 18 | "revision" : "37230a37e83f1b7023be08e1b1a2603fcb1567fb", 19 | "version" : "1.18.4" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-syntax", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/swiftlang/swift-syntax", 26 | "state" : { 27 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", 28 | "version" : "601.0.1" 29 | } 30 | }, 31 | { 32 | "identity" : "xctest-dynamic-overlay", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 35 | "state" : { 36 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 37 | "version" : "1.5.2" 38 | } 39 | } 40 | ], 41 | "version" : 3 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/BackgroundColorModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that applies a themed background color from the `ColorToken` system. 4 | /// 5 | /// This modifier reads the current `Theme` from the environment and applies the color 6 | /// associated with the provided `ColorToken`. Optionally, it can extend the background color 7 | /// to ignore specific safe area edges. 8 | /// 9 | /// ### Usage: 10 | /// ```swift 11 | /// Text("Hello") 12 | /// .modifier(BackgroundModifier(token: .surface, edgesIgnoringSafeArea: .all)) 13 | /// ``` 14 | /// 15 | /// - Parameters: 16 | /// - token: A `ColorToken` representing the semantic background color (e.g. `.surface`, `.background`, etc.). 17 | /// - edgesIgnoringSafeArea: Optional. A set of edges (e.g. `.all`, `.top`) to extend the background beyond the safe area. 18 | struct BackgroundColorModifier: ViewModifier { 19 | /// The current theme pulled from the environment. 20 | @Environment(\.appTheme) private var theme 21 | 22 | /// The semantic color token used to resolve the background color. 23 | let token: ColorToken 24 | 25 | /// Optional set of edges to ignore safe area insets for the background color. 26 | let edgesIgnoringSafeArea: Edge.Set? 27 | 28 | func body(content: Content) -> some View { 29 | if let edgesIgnoringSafeArea { 30 | content.background( 31 | theme.colors[token] 32 | .edgesIgnoringSafeArea(edgesIgnoringSafeArea) 33 | ) 34 | } else { 35 | content.background(theme.colors[token]) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/CheckboxesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CheckboxesView.swift 3 | // SwiftThemeKitDemo 4 | // 5 | // Created by Albu Eduard on 19.05.2025. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftThemeKit 10 | 11 | struct CheckboxesView: View { 12 | @Environment(\.appTheme) private var theme 13 | @State private var isChecked: Bool = false 14 | 15 | var body: some View { 16 | Card { 17 | VStack(alignment: .leading, spacing: theme.spacing.md) { 18 | Text("Checkboxes") 19 | .font(.headlineLarge) 20 | 21 | Checkbox( 22 | isChecked: $isChecked, 23 | label: "Selected checkbox" 24 | ) 25 | .fillMaxWidth(alignment: .leading) 26 | 27 | Checkbox( 28 | isChecked: $isChecked, 29 | label: "Unselected checkbox" 30 | ) 31 | .fillMaxWidth(alignment: .leading) 32 | 33 | Checkbox( 34 | isChecked: $isChecked, 35 | label: "Leading label checkbox", 36 | labelPosition: .leading 37 | ) 38 | .fillMaxWidth(alignment: .leading) 39 | 40 | Checkbox( 41 | isChecked: $isChecked, 42 | labelPosition: .leading 43 | ) { 44 | HStack { 45 | Image(systemName: "info.circle") 46 | 47 | Text("Custom label checkbox") 48 | .font(.titleLarge) 49 | } 50 | } 51 | .fillMaxWidth(alignment: .leading) 52 | } 53 | .fillMaxWidth() 54 | } 55 | } 56 | } 57 | 58 | #Preview { 59 | CheckboxesView() 60 | .padding(.md) 61 | } 62 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # 🙌 Contributing to SwiftThemeKit 3 | 4 | Thank you for considering contributing to **SwiftThemeKit**! Your help is highly appreciated. Whether you’re fixing a bug, improving documentation, or suggesting a new feature, this guide will help you get started. 5 | 6 | --- 7 | 8 | ## 🚀 Getting Started 9 | 10 | 1. Fork the repository 11 | 2. Clone your fork: 12 | ```bash 13 | git clone https://github.com/your-username/SwiftThemeKit.git 14 | ``` 15 | 3. Create a new branch for your work: 16 | ```bash 17 | git checkout -b feature/my-new-feature 18 | ``` 19 | 20 | --- 21 | 22 | ## ✅ What Can You Contribute? 23 | 24 | - Bug fixes 25 | - Improvements or new UI components 26 | - Documentation updates 27 | - Unit tests for uncovered areas 28 | - Feature proposals (please open an issue first) 29 | 30 | --- 31 | 32 | ## 🧪 Running Tests 33 | 34 | Make sure your changes don’t break the build: 35 | 36 | ```bash 37 | swift test 38 | ``` 39 | 40 | --- 41 | 42 | ## 🧼 Code Style 43 | 44 | - Follow Swift best practices and the existing code style 45 | - Use descriptive names and keep code readable 46 | - Use documentation comments (`///`) for public types and methods 47 | 48 | --- 49 | 50 | ## 📄 Pull Request Checklist 51 | 52 | - The PR is submitted to the main branch 53 | - The code compiles and tests pass 54 | - You’ve documented any public API changes 55 | - If it’s a bug fix, it includes a unit test 56 | 57 | --- 58 | 59 | ## 💬 Need Help? 60 | 61 | Feel free to open an issue with any questions or proposals. 62 | 63 | --- 64 | 65 | Thanks again for helping make **SwiftThemeKit** better! ✨ 66 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/RadioButton/RadioButtonConfiguration.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct RadioButtonConfiguration: Equatable { 4 | 5 | /// The width and height of the radio button box (circle) in points. 6 | /// 7 | /// This controls the size of the tappable/selectable region. 8 | public let size: CGFloat 9 | 10 | /// The horizontal spacing between the radio button circle box and its label. 11 | /// 12 | /// This is typically defined by a `SpacingToken` like `.sm`, `.md`, etc., 13 | /// to keep spacing consistent across the system. 14 | public let labelSpacing: SpacingToken 15 | 16 | /// The typography token used for the radio button label. 17 | /// 18 | /// This defines the font size, weight, and line height for the associated label text. 19 | public let font: TextStyleToken 20 | 21 | /// The stroke thickness applied to the radio button border. 22 | /// 23 | /// This is defined using a `StrokeToken` and varies depending on size and platform. 24 | public let stroke: StrokeToken 25 | 26 | /// Creates a new `CheckboxConfiguration` with the specified size, spacing, font, and stroke. 27 | /// 28 | /// - Parameters: 29 | /// - size: The square size of the radio button in points. 30 | /// - labelSpacing: The spacing between radio button and label. 31 | /// - font: The typography token used for the radio button label. 32 | /// - stroke: The stroke thickness applied to the radio button border. 33 | public init( 34 | size: CGFloat, 35 | labelSpacing: SpacingToken, 36 | font: TextStyleToken, 37 | stroke: StrokeToken 38 | ) { 39 | self.size = size 40 | self.labelSpacing = labelSpacing 41 | self.font = font 42 | self.stroke = stroke 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/Typography/TypographyModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that applies a themed font style to any view based on a `ThemeFontToken`. 4 | /// 5 | /// This modifier looks up the font style from the current theme and optionally applies a 6 | /// specific font weight if one is provided in the token. It supports backward compatibility 7 | /// for iOS, macOS, and tvOS versions prior to when `.fontWeight(_:)` was available as a chained modifier. 8 | /// 9 | /// Use this modifier to apply consistent typography styles defined in your theme. 10 | /// 11 | /// Example usage: 12 | /// ```swift 13 | /// Text("Hello") 14 | /// .modifier(TypographyModifier(token: ThemeFontToken(.titleMedium, weight: .bold))) 15 | /// ``` 16 | struct TypographyModifier: ViewModifier { 17 | /// The current theme from the environment. 18 | @Environment(\.appTheme) private var theme 19 | 20 | /// The font token containing the desired style and optional weight. 21 | let token: ThemeFontToken 22 | 23 | func body(content: Content) -> some View { 24 | let baseFont = theme.typography[token.style] 25 | if let weight = token.weight { 26 | #if canImport(UIKit) 27 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) { 28 | content 29 | .font(baseFont) 30 | .fontWeight(weight) 31 | } else { 32 | content.font(baseFont.weight(weight)) 33 | } 34 | #elseif canImport(AppKit) 35 | if #available(macOS 13.0, *) { 36 | content 37 | .font(baseFont) 38 | .fontWeight(weight) 39 | } else { 40 | content.font(baseFont.weight(weight)) 41 | } 42 | #else 43 | content 44 | #endif 45 | } else { 46 | content.font(baseFont) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/View+Utils.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | /// Conditionally applies a view transformation when a condition is `true`. 5 | /// 6 | /// This is useful for chaining conditional modifiers in a clean and readable way. 7 | /// 8 | /// - Parameters: 9 | /// - condition: A Boolean value that determines whether the transform is applied. 10 | /// - transform: A closure that takes the original view and returns a modified view. 11 | /// 12 | /// - Returns: Either the original view or the transformed view if the condition is `true`. 13 | /// 14 | /// ### Example: 15 | /// ```swift 16 | /// Text("Hello") 17 | /// .if(isHighlighted) { $0.foregroundColor(.red) } 18 | /// ``` 19 | @ViewBuilder 20 | func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { 21 | if condition { 22 | transform(self) 23 | } else { 24 | self 25 | } 26 | } 27 | 28 | /// Measures the size of the view and writes it to a bound `CGSize`. 29 | /// 30 | /// This can be used to capture layout dimensions for alignment, animation, 31 | /// or dynamic layout decisions. 32 | /// 33 | /// - Parameter size: A binding to a `CGSize` value that will be updated with the view’s size. 34 | /// 35 | /// ### Example: 36 | /// ```swift 37 | /// @State private var size: CGSize = .zero 38 | /// 39 | /// Text("Resizable") 40 | /// .measure($size) 41 | /// .onChange(of: size) { print("New size: \($0)") } 42 | /// ``` 43 | @ViewBuilder 44 | public func measure(_ size: Binding) -> some View { 45 | self.background( 46 | GeometryReader { proxy in 47 | Color.clear.onAppear { 48 | size.wrappedValue = proxy.size 49 | } 50 | } 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/BackgroundShapeModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that applies a themed background shape from the `ShapeToken` system. 4 | /// 5 | /// This modifier reads the current `Theme` from the environment and applies the color 6 | /// associated with the provided `ColorToken`. Optionally, it can extend the background color 7 | /// to ignore specific safe area edges. 8 | /// 9 | /// ### Usage: 10 | /// ```swift 11 | /// Text("Hello") 12 | /// .modifier(BackgroundModifier(token: .surface, edgesIgnoringSafeArea: .all)) 13 | /// ``` 14 | /// 15 | /// - Parameters: 16 | /// - token: A `ColorToken` representing the semantic background color (e.g. `.surface`, `.background`, etc.). 17 | /// - edgesIgnoringSafeArea: Optional. A set of edges (e.g. `.all`, `.top`) to extend the background beyond the safe area. 18 | struct BackgroundShapeModifier: ViewModifier { 19 | /// The current theme pulled from the environment. 20 | @Environment(\.appTheme) private var theme 21 | 22 | /// The semantic shape token used to resolve the background color. 23 | let token: ShapeToken 24 | 25 | /// The semantic color token used to resolve the background color. 26 | let colorToken: ColorToken? 27 | 28 | /// Optional set of edges to ignore safe area insets for the background color. 29 | let edgesIgnoringSafeArea: Edge.Set? 30 | 31 | func body(content: Content) -> some View { 32 | if let edgesIgnoringSafeArea { 33 | content.background( 34 | theme.shapes[token] 35 | .if(colorToken != nil) { $0.fill(theme.colors[colorToken!]) } 36 | .edgesIgnoringSafeArea(edgesIgnoringSafeArea) 37 | ) 38 | } else { 39 | content.background( 40 | theme.shapes[token] 41 | .if(colorToken != nil) { $0.fill(theme.colors[colorToken!]) } 42 | ) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/SwiftThemeKitTests/ThemeShapesTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | @testable import SwiftThemeKit 4 | 5 | final class ThemeShapesTests: XCTestCase { 6 | 7 | func testDefaultLightShapesUseCorrectRadii() { 8 | let shapes = ThemeShapes.defaultLight 9 | XCTAssertEqual(shapes.xs.cornerSize.width, ThemeRadii.defaultLight.xs) 10 | XCTAssertEqual(shapes.sm.cornerSize.width, ThemeRadii.defaultLight.sm) 11 | XCTAssertEqual(shapes.md.cornerSize.width, ThemeRadii.defaultLight.md) 12 | XCTAssertEqual(shapes.lg.cornerSize.width, ThemeRadii.defaultLight.lg) 13 | XCTAssertEqual(shapes.xl.cornerSize.width, ThemeRadii.defaultLight.xl) 14 | } 15 | 16 | func testDefaultDarkShapesUseCorrectRadii() { 17 | let shapes = ThemeShapes.defaultDark 18 | XCTAssertEqual(shapes.xs.cornerSize.width, ThemeRadii.defaultLight.xs) 19 | XCTAssertEqual(shapes.sm.cornerSize.width, ThemeRadii.defaultLight.sm) 20 | XCTAssertEqual(shapes.md.cornerSize.width, ThemeRadii.defaultLight.md) 21 | XCTAssertEqual(shapes.lg.cornerSize.width, ThemeRadii.defaultLight.lg) 22 | XCTAssertEqual(shapes.xl.cornerSize.width, ThemeRadii.defaultLight.xl) 23 | } 24 | 25 | func testShapeTokenSubscriptReturnsCorrectShape() { 26 | let customShapes = ThemeShapes( 27 | xs: RoundedRectangle(cornerRadius: 1), 28 | sm: RoundedRectangle(cornerRadius: 2), 29 | md: RoundedRectangle(cornerRadius: 3), 30 | lg: RoundedRectangle(cornerRadius: 4), 31 | xl: RoundedRectangle(cornerRadius: 5) 32 | ) 33 | 34 | XCTAssertEqual(customShapes[.xs].cornerSize.width, 1) 35 | XCTAssertEqual(customShapes[.sm].cornerSize.width, 2) 36 | XCTAssertEqual(customShapes[.md].cornerSize.width, 3) 37 | XCTAssertEqual(customShapes[.lg].cornerSize.width, 4) 38 | XCTAssertEqual(customShapes[.xl].cornerSize.width, 5) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemeStroke.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Defines the stroke widths used throughout the design system. 4 | public struct ThemeStroke: Equatable { 5 | 6 | /// No stroke. Used when no border is needed. 7 | public let none: CGFloat 8 | 9 | /// Very thin stroke (1pt). Suitable for subtle outlines or hairlines. 10 | public let xs: CGFloat 11 | 12 | /// Thin stroke (2pt). Used for input fields or minimal borders. 13 | public let sm: CGFloat 14 | 15 | /// Medium stroke (4pt). Used for prominent component outlines or highlights. 16 | public let md: CGFloat 17 | 18 | /// Thick stroke (8pt). Used sparingly for high emphasis elements. 19 | public let lg: CGFloat 20 | 21 | /// The default set of stroke widths for light mode. 22 | public static let defaultLight = ThemeStroke( 23 | none: 0, 24 | xs: 1, 25 | sm: 2, 26 | md: 4, 27 | lg: 8 28 | ) 29 | 30 | /// The default set of stroke widths for dark mode. 31 | public static let defaultDark = ThemeStroke( 32 | none: 0, 33 | xs: 1, 34 | sm: 2, 35 | md: 4, 36 | lg: 8 37 | ) 38 | 39 | public init( 40 | none: CGFloat, 41 | xs: CGFloat, 42 | sm: CGFloat, 43 | md: CGFloat, 44 | lg: CGFloat 45 | ) { 46 | self.none = none 47 | self.xs = xs 48 | self.sm = sm 49 | self.md = md 50 | self.lg = lg 51 | } 52 | 53 | subscript(token: StrokeToken) -> CGFloat { 54 | switch token { 55 | case .none: return none 56 | case .xs: return xs 57 | case .sm: return sm 58 | case .md: return md 59 | case .lg: return lg 60 | } 61 | } 62 | } 63 | 64 | /// Stroke tokens for standardized access to stroke widths. 65 | public enum StrokeToken: String, CaseIterable { 66 | case none 67 | case xs // 1pt 68 | case sm // 2pt 69 | case md // 4pt 70 | case lg // 8pt 71 | } 72 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | }, 30 | { 31 | "idiom" : "mac", 32 | "scale" : "1x", 33 | "size" : "16x16" 34 | }, 35 | { 36 | "idiom" : "mac", 37 | "scale" : "2x", 38 | "size" : "16x16" 39 | }, 40 | { 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "32x32" 44 | }, 45 | { 46 | "idiom" : "mac", 47 | "scale" : "2x", 48 | "size" : "32x32" 49 | }, 50 | { 51 | "idiom" : "mac", 52 | "scale" : "1x", 53 | "size" : "128x128" 54 | }, 55 | { 56 | "idiom" : "mac", 57 | "scale" : "2x", 58 | "size" : "128x128" 59 | }, 60 | { 61 | "idiom" : "mac", 62 | "scale" : "1x", 63 | "size" : "256x256" 64 | }, 65 | { 66 | "idiom" : "mac", 67 | "scale" : "2x", 68 | "size" : "256x256" 69 | }, 70 | { 71 | "idiom" : "mac", 72 | "scale" : "1x", 73 | "size" : "512x512" 74 | }, 75 | { 76 | "idiom" : "mac", 77 | "scale" : "2x", 78 | "size" : "512x512" 79 | } 80 | ], 81 | "info" : { 82 | "author" : "xcode", 83 | "version" : 1 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/ThemeProvider.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI view that injects a visual theme (`Theme`) into the environment based on the system color scheme. 4 | /// 5 | /// Use `ThemeProvider` to wrap your app or specific view hierarchies and enable access to the current theme 6 | /// via `@Environment(\.appTheme)`. It automatically switches between light and dark theme variants. 7 | @MainActor 8 | public struct ThemeProvider: View { 9 | 10 | /// Reads the system color scheme (light or dark). 11 | @Environment(\.colorScheme) var colorScheme 12 | 13 | /// The theme used when the system is in light mode. 14 | public let lightTheme: Theme 15 | 16 | /// The theme used when the system is in dark mode. 17 | public let darkTheme: Theme 18 | 19 | /// The content view to wrap in the themed environment. 20 | public let content: () -> Content 21 | 22 | /// Initializes a `ThemeProvider` with optional light and dark themes. 23 | /// 24 | /// - Parameters: 25 | /// - light: The theme to apply in light mode. Defaults to `.defaultLight`. 26 | /// - dark: The theme to apply in dark mode. Defaults to `.defaultDark`. 27 | /// - content: The root view that will inherit the themed environment. 28 | public init( 29 | light: Theme = .defaultLight, 30 | dark: Theme = .defaultDark, 31 | @ViewBuilder content: @escaping () -> Content 32 | ) { 33 | self.lightTheme = light 34 | self.darkTheme = dark 35 | self.content = content 36 | } 37 | 38 | /// The view body that injects the current theme into the environment 39 | /// based on the active color scheme. 40 | public var body: some View { 41 | let currentTheme = (colorScheme == .dark) ? darkTheme : lightTheme 42 | let currentTint = currentTheme.colors.primary 43 | 44 | return content() 45 | .environment(\.appTheme, currentTheme) 46 | .tint(currentTint) 47 | .animation(.easeInOut, value: colorScheme) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemeShapes.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ThemeShapes: Equatable { 4 | /// A shape style with 4 same-sized corners smaller than ThemeShapes.sm. 5 | public let xs: CGFloat 6 | 7 | /// A shape style with 4 same-sized corners bigger than ThemeShapes.xs. 8 | public let sm: CGFloat 9 | 10 | /// A shape style with 4 same-sized corners bigger than ThemeShapes.sm. 11 | public let md: CGFloat 12 | 13 | /// A shape style with 4 same-sized corners bigger than ThemeShapes.md. 14 | public let lg: CGFloat 15 | 16 | /// A shape style with 4 same-sized corners bigger than ThemeShapes.lg. 17 | public let xl: CGFloat 18 | 19 | public static let defaultLight: ThemeShapes = ThemeShapes( 20 | xs: ThemeRadii.defaultLight.xs, 21 | sm: ThemeRadii.defaultLight.sm, 22 | md: ThemeRadii.defaultLight.md, 23 | lg: ThemeRadii.defaultLight.lg, 24 | xl: ThemeRadii.defaultLight.xl 25 | ) 26 | 27 | public static let defaultDark: ThemeShapes = ThemeShapes( 28 | xs: ThemeRadii.defaultLight.xs, 29 | sm: ThemeRadii.defaultLight.sm, 30 | md: ThemeRadii.defaultLight.md, 31 | lg: ThemeRadii.defaultLight.lg, 32 | xl: ThemeRadii.defaultLight.xl 33 | ) 34 | 35 | public init(xs: CGFloat, sm: CGFloat, md: CGFloat, lg: CGFloat, xl: CGFloat) { 36 | self.xs = xs 37 | self.sm = sm 38 | self.md = md 39 | self.lg = lg 40 | self.xl = xl 41 | } 42 | 43 | public subscript(_ token: ShapeToken) -> RoundedRectangle { 44 | switch token { 45 | case .xs: return RoundedRectangle(cornerRadius: xs) 46 | case .sm: return RoundedRectangle(cornerRadius: sm) 47 | case .md: return RoundedRectangle(cornerRadius: md) 48 | case .lg: return RoundedRectangle(cornerRadius: lg) 49 | case .xl: return RoundedRectangle(cornerRadius: xl) 50 | } 51 | } 52 | } 53 | 54 | public enum ShapeToken: String, CaseIterable { 55 | case xs 56 | case sm 57 | case md 58 | case lg 59 | case xl 60 | } 61 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/Checkbox/CheckboxConfiguration.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A struct that defines the visual and layout configuration for a checkbox of a specific size. 4 | /// 5 | /// `CheckboxConfiguration` is used internally by `ThemeCheckboxSize` to encapsulate 6 | /// the size, font, spacing, and border stroke width of a checkbox. These values are 7 | /// platform-aware and resolved through `ThemePlatform`. 8 | public struct CheckboxConfiguration: Equatable { 9 | 10 | /// The width and height of the checkbox box (square) in points. 11 | /// 12 | /// This controls the size of the tappable/selectable region. 13 | public let size: CGFloat 14 | 15 | /// The horizontal spacing between the checkbox box and its label. 16 | /// 17 | /// This is typically defined by a `SpacingToken` like `.sm`, `.md`, etc., 18 | /// to keep spacing consistent across the system. 19 | public let labelSpacing: SpacingToken 20 | 21 | /// The typography token used for the checkbox label. 22 | /// 23 | /// This defines the font size, weight, and line height for the associated label text. 24 | public let font: TextStyleToken 25 | 26 | /// The stroke thickness applied to the checkbox border. 27 | /// 28 | /// This is defined using a `StrokeToken` and varies depending on size and platform. 29 | public let stroke: StrokeToken 30 | 31 | /// Creates a new `CheckboxConfiguration` with the specified size, spacing, font, and stroke. 32 | /// 33 | /// - Parameters: 34 | /// - size: The square size of the checkbox in points. 35 | /// - labelSpacing: The spacing between checkbox and label. 36 | /// - font: The typography style for the label. 37 | /// - stroke: The border thickness of the checkbox. 38 | public init( 39 | size: CGFloat, 40 | labelSpacing: SpacingToken, 41 | font: TextStyleToken, 42 | stroke: StrokeToken 43 | ) { 44 | self.size = size 45 | self.labelSpacing = labelSpacing 46 | self.font = font 47 | self.stroke = stroke 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/Views/ButtonsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct ButtonsView: View { 5 | @Environment(\.appTheme) private var theme 6 | let shape: ButtonShape 7 | 8 | var body: some View { 9 | Card(elevation: .none) { 10 | VStack(alignment: .leading, spacing: theme.spacing.md) { 11 | Text("Buttons") 12 | .font(.headlineLarge) 13 | 14 | Button { 15 | 16 | } label: { 17 | Text("Filled button") 18 | } 19 | .buttonShape(shape) 20 | .buttonSize(.fullWidth) 21 | .buttonVariant(.filled) 22 | 23 | Button { 24 | 25 | } label: { 26 | Text("Tonal button") 27 | } 28 | .buttonShape(shape) 29 | .buttonSize(.fullWidth) 30 | .buttonVariant(.tonal) 31 | 32 | Button { 33 | 34 | } label: { 35 | Text("Outlined button") 36 | } 37 | .buttonShape(shape) 38 | .buttonSize(.fullWidth) 39 | .buttonVariant(.outline) 40 | 41 | Button { 42 | 43 | } label: { 44 | Text("Elevated button") 45 | } 46 | .buttonShape(shape) 47 | .buttonSize(.fullWidth) 48 | .buttonVariant(.elevated) 49 | 50 | Button { 51 | 52 | } label: { 53 | Text("Text button") 54 | } 55 | .buttonShape(shape) 56 | .buttonSize(.fullWidth) 57 | .buttonVariant(.text) 58 | 59 | Button { 60 | 61 | } label: { 62 | Text("Custom background") 63 | } 64 | .buttonShape(shape) 65 | .buttonSize(.fullWidth) 66 | .buttonVariant(.filled) 67 | .buttonBackgroundColor(.green) 68 | 69 | Button { 70 | 71 | } label: { 72 | Text("Plain text button") 73 | } 74 | .plainTextButton() 75 | } 76 | .fillMaxWidth() 77 | } 78 | } 79 | } 80 | 81 | #Preview { 82 | ButtonsView(shape: .rounded) 83 | .padding(.md) 84 | } 85 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Styles/PlainTextButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A minimal button style that applies only a themed font and foreground color, without background or padding. 4 | /// 5 | /// `PlainTextButtonStyle` is useful for links, plain text interactions, or text-only buttons 6 | /// where you want to preserve the layout and keep the button visually minimal. 7 | /// 8 | /// It supports: 9 | /// - Themed typography using `ThemeFontToken` 10 | /// - Color changes on press (darkens the color) 11 | /// - Destructive role styling (uses the theme’s `error` color) 12 | /// 13 | /// ### Example: 14 | /// ```swift 15 | /// Button("Delete") { ... } 16 | /// .buttonStyle(PlainTextButtonStyle(token: ThemeFontToken(.labelMedium, weight: .medium))) 17 | /// ``` 18 | /// 19 | /// You can also use the `.plainTextButton()` modifier from your SDK for convenience. 20 | /// 21 | /// - Parameters: 22 | /// - token: A `ThemeFontToken` containing the text style and optional weight to apply. 23 | struct PlainTextButtonStyle: ButtonStyle { 24 | /// Access to the current app theme. 25 | @Environment(\.appTheme) private var theme 26 | 27 | /// Access to the button's enabled/disabled state. 28 | @Environment(\.isEnabled) private var isEnabled 29 | 30 | /// The typography token (style + optional weight) to apply. 31 | let token: ThemeFontToken 32 | 33 | init(token: ThemeFontToken) { 34 | self.token = token 35 | } 36 | 37 | func makeBody(configuration: Configuration) -> some View { 38 | let isPressed = configuration.isPressed 39 | let isDestructive = configuration.role == .destructive 40 | 41 | // Choose the correct foreground color 42 | let fgColor: Color = isDestructive ? theme.colors.error : theme.colors.primary 43 | let effectiveFgColor = isPressed ? fgColor.darken(by: 0.1) : fgColor 44 | 45 | return configuration.label 46 | .font(token.style, weight: token.weight) 47 | .foregroundColor(effectiveFgColor) 48 | .padding(0) 49 | .environment(\.typographyStyle, token) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/GetStartedView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | struct GetStartedView: View { 5 | @EnvironmentObject var themeManager: ThemeManager 6 | 7 | @Environment(\.appTheme) private var theme 8 | 9 | var body: some View { 10 | VStack { 11 | topContainer 12 | .weight(1) 13 | 14 | bottomContainer 15 | .weight(1) 16 | } 17 | .backgroundColor(.surface, edgesIgnoringSafeArea: .all) 18 | } 19 | 20 | private var topContainer: some View { 21 | VStack(spacing: theme.spacing.md) { 22 | Text("Develop faster with SwiftThemeKit") 23 | .font(.displaySmall) 24 | .multilineTextAlignment(.center) 25 | 26 | Text("Join over 100 million people who use SwifthThemeKit to develop better UI.") 27 | .font(.bodyLarge) 28 | .multilineTextAlignment(.center) 29 | .padding(.bottom, theme.spacing.md) 30 | 31 | Picker("Theme", selection: $themeManager.colorMode) { 32 | ForEach(["light", "dark"], id: \.self) { text in 33 | Text(text) 34 | } 35 | } 36 | .pickerStyle(.segmented) 37 | 38 | Picker("Theme", selection: $themeManager.selectedTheme) { 39 | ForEach(["theme1", "theme2", "theme3"], id: \.self) { text in 40 | Text(text) 41 | } 42 | } 43 | .pickerStyle(.segmented) 44 | 45 | NavigationLink { 46 | HomeScreenView() 47 | } label: { 48 | Text("View components") 49 | } 50 | .buttonSize(.fullWidth) 51 | 52 | NavigationLink { 53 | 54 | } label: { 55 | Text("View examples") 56 | } 57 | .buttonVariant(.tonal) 58 | .buttonSize(.fullWidth) 59 | .disabled(true) 60 | } 61 | .padding(.horizontal, .md) 62 | .backgroundColor(.surface, edgesIgnoringSafeArea: .all) 63 | } 64 | 65 | private var bottomContainer: some View { 66 | Image("bottomContainer") 67 | .resizable() 68 | .edgesIgnoringSafeArea(.all) 69 | } 70 | } 71 | 72 | #Preview { 73 | GetStartedView() 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/Theme+Copy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension Theme { 4 | /// Returns a new `Theme` instance by copying the current one 5 | /// and applying the specified overrides. 6 | /// - Parameters: 7 | /// - colors: The color token configuration. 8 | /// - typography: The typography scale. 9 | /// - spacing: The spacing scale. 10 | /// - radii: The corner radius scale. 11 | /// - shadows: The shadow styles for various elevations. 12 | /// - stroke: The stroke styles. 13 | /// - shapes: The shapes styles used for various components. 14 | /// - buttons: The buttons default values used when no modifier applied 15 | /// - textFields: The text field default values used when no modifier applied 16 | /// - checkboxSize: The size styles for the checkboxes 17 | /// - radioButtonSize: The size styles for the radio button 18 | /// - platform: The platform on which the app is running. 19 | func copy( 20 | colors: ThemeColors? = nil, 21 | typography: ThemeTypography? = nil, 22 | spacing: ThemeSpacing? = nil, 23 | radii: ThemeRadii? = nil, 24 | shadows: ThemeShadows? = nil, 25 | stroke: ThemeStroke? = nil, 26 | shapes: ThemeShapes? = nil, 27 | buttons: ThemeButtonDefaults? = nil, 28 | textFields: ThemeTextFieldDefaults? = nil, 29 | checkboxSize: ThemeCheckboxSize? = nil, 30 | radioButtonSize: ThemeRadioButtonSize? = nil, 31 | platform: ThemePlatform? = nil 32 | ) -> Theme { 33 | Theme( 34 | colors: colors ?? self.colors, 35 | typography: typography ?? self.typography, 36 | spacing: spacing ?? self.spacing, 37 | radii: radii ?? self.radii, 38 | shadows: shadows ?? self.shadows, 39 | stroke: stroke ?? self.stroke, 40 | shapes: shapes ?? self.shapes, 41 | buttons: buttons ?? self.buttons, 42 | textFields: textFields ?? self.textFields, 43 | checkboxSize: checkboxSize ?? self.checkboxSize, 44 | radioButtonSize: radioButtonSize ?? self.radioButtonSize, 45 | platform: platform ?? self.platform 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/TextField/ThemedTextFieldModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that injects a fully resolved `TextFieldConfiguration` into the environment. 4 | /// 5 | /// It merges the currently active configuration from the environment with the default values 6 | /// from the theme. This allows individual style modifiers (e.g., shape, size) to override only 7 | /// parts of the configuration without resetting the others. 8 | /// 9 | /// Use this as the base modifier when building a styled text field. 10 | struct ThemedTextFiedlModifier: ViewModifier { 11 | @Environment(\.appTheme) private var theme 12 | @Environment(\.textFieldConfiguration) private var config 13 | 14 | func body(content: Content) -> some View { 15 | let configuration = TextFieldConfiguration( 16 | variant: config.variant ?? theme.textFields.variant, 17 | size: config.size ?? theme.textFields.size, 18 | shape: config.shape ?? theme.textFields.shape, 19 | font: config.font, 20 | isError: config.isError 21 | ) 22 | content 23 | .environment(\.textFieldConfiguration, configuration) 24 | } 25 | } 26 | 27 | /// A view modifier that applies the final `ThemeTextFieldStyle` to a text field 28 | /// using the current configuration from the environment. 29 | /// 30 | /// This should be applied after all other configuration modifiers to ensure 31 | /// the final style reflects all resolved variant, size, shape, font, and error values. 32 | /// 33 | /// Typically used internally by `.applyThemeTextFieldStyle()` to finalize styling. 34 | struct ApplyFinalTextFieldStyleModifier: ViewModifier { 35 | @Environment(\.appTheme) private var theme 36 | @Environment(\.textFieldConfiguration) private var config 37 | 38 | func body(content: Content) -> some View { 39 | content.textFieldStyle( 40 | ThemeTextFieldStyle( 41 | variant: config.variant ?? theme.textFields.variant, 42 | size: config.size ?? theme.textFields.size, 43 | shape: config.shape ?? theme.textFields.shape, 44 | font: config.font, 45 | isError: config.isError 46 | ) 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/js/jazzy.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | window.jazzy = {'docset': false} 6 | if (typeof window.dash != 'undefined') { 7 | document.documentElement.className += ' dash' 8 | window.jazzy.docset = true 9 | } 10 | if (navigator.userAgent.match(/xcode/i)) { 11 | document.documentElement.className += ' xcode' 12 | window.jazzy.docset = true 13 | } 14 | 15 | function toggleItem($link, $content) { 16 | var animationDuration = 300; 17 | $link.toggleClass('token-open'); 18 | $content.slideToggle(animationDuration); 19 | } 20 | 21 | function itemLinkToContent($link) { 22 | return $link.parent().parent().next(); 23 | } 24 | 25 | // On doc load + hash-change, open any targeted item 26 | function openCurrentItemIfClosed() { 27 | if (window.jazzy.docset) { 28 | return; 29 | } 30 | var $link = $(`a[name="${location.hash.substring(1)}"]`).nextAll('.token'); 31 | $content = itemLinkToContent($link); 32 | if ($content.is(':hidden')) { 33 | toggleItem($link, $content); 34 | } 35 | } 36 | 37 | $(openCurrentItemIfClosed); 38 | $(window).on('hashchange', openCurrentItemIfClosed); 39 | 40 | // On item link ('token') click, toggle its discussion 41 | $('.token').on('click', function(event) { 42 | if (window.jazzy.docset) { 43 | return; 44 | } 45 | var $link = $(this); 46 | toggleItem($link, itemLinkToContent($link)); 47 | 48 | // Keeps the document from jumping to the hash. 49 | var href = $link.attr('href'); 50 | if (history.pushState) { 51 | history.pushState({}, '', href); 52 | } else { 53 | location.hash = href; 54 | } 55 | event.preventDefault(); 56 | }); 57 | 58 | // Clicks on links to the current, closed, item need to open the item 59 | $("a:not('.token')").on('click', function() { 60 | if (location == this.href) { 61 | openCurrentItemIfClosed(); 62 | } 63 | }); 64 | 65 | // KaTeX rendering 66 | if ("katex" in window) { 67 | $($('.math').each( (_, element) => { 68 | katex.render(element.textContent, element, { 69 | displayMode: $(element).hasClass('m-block'), 70 | throwOnError: false, 71 | trust: true 72 | }); 73 | })) 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemeSpacing.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Defines the standard spacing scale for layout and padding/margin values throughout the app. 4 | public struct ThemeSpacing: Equatable { 5 | /// Extra-small spacing. Used for tight gaps between small elements like icons or labels. 6 | public let xs: CGFloat // 4pt 7 | 8 | /// Small spacing. Typically used for padding inside buttons, cards, or input fields. 9 | public let sm: CGFloat // 8pt 10 | 11 | /// Medium spacing. Ideal for spacing between grouped elements or sections. 12 | public let md: CGFloat // 16pt 13 | 14 | /// Large spacing. Used for separating components or laying out screen-level spacing. 15 | public let lg: CGFloat // 32pt 16 | 17 | /// Extra-large spacing. Often used for outer padding or generous layout gaps. 18 | public let xl: CGFloat // 48pt 19 | 20 | /// Double-extra-large spacing. Reserved for very large vertical spacing or whitespace-heavy layouts. 21 | public let xxl: CGFloat // 80pt 22 | 23 | /// Default spacing scale based on common design system sizes. 24 | public static let defaultLight = ThemeSpacing( 25 | xs: 4, 26 | sm: 8, 27 | md: 16, 28 | lg: 32, 29 | xl: 48, 30 | xxl: 80 31 | ) 32 | 33 | /// Default spacing scale based on common design system sizes. 34 | public static let defaultDark = ThemeSpacing( 35 | xs: 4, 36 | sm: 8, 37 | md: 16, 38 | lg: 32, 39 | xl: 48, 40 | xxl: 80 41 | ) 42 | 43 | public init(xs: CGFloat, sm: CGFloat, md: CGFloat, lg: CGFloat, xl: CGFloat, xxl: CGFloat) { 44 | self.xs = xs 45 | self.sm = sm 46 | self.md = md 47 | self.lg = lg 48 | self.xl = xl 49 | self.xxl = xxl 50 | } 51 | 52 | subscript(_ token: SpacingToken) -> CGFloat { 53 | switch token { 54 | case .xs: return xs 55 | case .sm: return sm 56 | case .md: return md 57 | case .lg: return lg 58 | case .xl: return xl 59 | case .xxl: return xxl 60 | case .none: return .zero 61 | } 62 | } 63 | } 64 | 65 | public enum SpacingToken: String, CaseIterable { 66 | case xs 67 | case sm 68 | case md 69 | case lg 70 | case xl 71 | case xxl 72 | case none 73 | } 74 | -------------------------------------------------------------------------------- /DemoApp/SwiftThemeKitDemo/SwiftThemeKitDemo/HomeScreenView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftThemeKit 3 | 4 | enum Shapes: String, Hashable, CaseIterable { 5 | case rectangle, rounded, capsule 6 | 7 | var buttonShape: ButtonShape { 8 | switch self { 9 | case .rectangle: 10 | return .square 11 | case .rounded: 12 | return .rounded 13 | case .capsule: 14 | return .capsule 15 | } 16 | } 17 | 18 | var textFieldShape: TextFieldShape { 19 | switch self { 20 | case .rectangle: 21 | return .square 22 | case .rounded: 23 | return .rounded 24 | case .capsule: 25 | return .capsule 26 | } 27 | } 28 | } 29 | 30 | struct HomeScreenView: View { 31 | @EnvironmentObject var themeManager: ThemeManager 32 | @Environment(\.appTheme) private var theme 33 | @State private var shapes: Shapes = .rounded 34 | @State private var buttonsShape: ButtonShape = .rounded 35 | @State private var fieldsValue: String = "" 36 | @State private var isChecked: Bool = true 37 | @State private var isOn: Bool = true 38 | @State private var slider: CGFloat = 0.5 39 | 40 | var body: some View { 41 | ScrollView { 42 | VStack(alignment: .leading, spacing: theme.spacing.md) { 43 | Text("Welcome to SwiftThemeKit!") 44 | .font(.displaySmall) 45 | 46 | Toggle("System componets color", isOn: $isOn) 47 | 48 | Slider(value: $slider) 49 | 50 | TypographyView() 51 | 52 | ShapesView() 53 | 54 | ColorsView() 55 | 56 | ShadowsView() 57 | 58 | RadiiView() 59 | 60 | SpacingsView() 61 | 62 | StrokesView() 63 | 64 | ButtonsView(shape: buttonsShape) 65 | 66 | TextFieldsView(shapes: shapes) 67 | 68 | RadioGroupView(shapes: $shapes) 69 | 70 | CheckboxesView() 71 | } 72 | .padding(.md) 73 | .fillMaxSize() 74 | } 75 | .fillMaxSize() 76 | .backgroundColor(.surface, edgesIgnoringSafeArea: .all) 77 | .colorSchemeButton(colorScheme: $themeManager.scheme) 78 | .onAppear { 79 | buttonsShape = theme.buttons.shape 80 | } 81 | } 82 | } 83 | 84 | #Preview { 85 | HomeScreenView() 86 | .environmentObject(ThemeManager()) 87 | } 88 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/Button/ThemedButtonModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A modifier that injects a resolved `ButtonConfiguration` into the environment, 4 | /// combining any user-defined button styling (via `.buttonVariant`, `.buttonSize`, etc.) 5 | /// with fallback values from the current theme. 6 | /// 7 | /// This allows downstream modifiers or styles to access a complete and consistent button configuration. 8 | /// 9 | /// - Environment: 10 | /// - `appTheme`: Provides default button configuration from the active theme. 11 | /// - `buttonConfiguration`: Allows partial overrides (e.g., shape only) to be merged. 12 | struct ThemedButtonModifier: ViewModifier { 13 | @Environment(\.appTheme) private var theme 14 | @Environment(\.buttonConfiguration) private var config 15 | 16 | func body(content: Content) -> some View { 17 | let configuration = ButtonConfiguration( 18 | variant: config.variant ?? theme.buttons.variant, 19 | shape: config.shape ?? theme.buttons.shape, 20 | size: config.size ?? theme.buttons.size, 21 | font: config.font 22 | ) 23 | 24 | content 25 | .environment(\.buttonConfiguration, configuration) 26 | } 27 | } 28 | 29 | /// A modifier that applies the final `ThemeButtonStyle` to the view using the resolved 30 | /// `ButtonConfiguration` from the environment or the theme defaults. 31 | /// 32 | /// This should be applied after all other button configuration modifiers to ensure 33 | /// that the composed button style reflects all customizations. 34 | /// 35 | /// - Environment: 36 | /// - `appTheme`: Provides fallback values for variant, size, shape, and font. 37 | /// - `buttonConfiguration`: The final resolved configuration for the button. 38 | struct ApplyFinalButtonStyleModifier: ViewModifier { 39 | @Environment(\.appTheme) private var theme 40 | @Environment(\.buttonConfiguration) private var config 41 | 42 | func body(content: Content) -> some View { 43 | content.buttonStyle( 44 | ThemeButtonStyle( 45 | variant: config.variant ?? theme.buttons.variant, 46 | size: config.size ?? theme.buttons.size, 47 | shape: config.shape ?? theme.buttons.shape, 48 | font: config.font 49 | ) 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/js/jazzy.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | window.jazzy = {'docset': false} 6 | if (typeof window.dash != 'undefined') { 7 | document.documentElement.className += ' dash' 8 | window.jazzy.docset = true 9 | } 10 | if (navigator.userAgent.match(/xcode/i)) { 11 | document.documentElement.className += ' xcode' 12 | window.jazzy.docset = true 13 | } 14 | 15 | function toggleItem($link, $content) { 16 | var animationDuration = 300; 17 | $link.toggleClass('token-open'); 18 | $content.slideToggle(animationDuration); 19 | } 20 | 21 | function itemLinkToContent($link) { 22 | return $link.parent().parent().next(); 23 | } 24 | 25 | // On doc load + hash-change, open any targeted item 26 | function openCurrentItemIfClosed() { 27 | if (window.jazzy.docset) { 28 | return; 29 | } 30 | var $link = $(`a[name="${location.hash.substring(1)}"]`).nextAll('.token'); 31 | $content = itemLinkToContent($link); 32 | if ($content.is(':hidden')) { 33 | toggleItem($link, $content); 34 | } 35 | } 36 | 37 | $(openCurrentItemIfClosed); 38 | $(window).on('hashchange', openCurrentItemIfClosed); 39 | 40 | // On item link ('token') click, toggle its discussion 41 | $('.token').on('click', function(event) { 42 | if (window.jazzy.docset) { 43 | return; 44 | } 45 | var $link = $(this); 46 | toggleItem($link, itemLinkToContent($link)); 47 | 48 | // Keeps the document from jumping to the hash. 49 | var href = $link.attr('href'); 50 | if (history.pushState) { 51 | history.pushState({}, '', href); 52 | } else { 53 | location.hash = href; 54 | } 55 | event.preventDefault(); 56 | }); 57 | 58 | // Clicks on links to the current, closed, item need to open the item 59 | $("a:not('.token')").on('click', function() { 60 | if (location == this.href) { 61 | openCurrentItemIfClosed(); 62 | } 63 | }); 64 | 65 | // KaTeX rendering 66 | if ("katex" in window) { 67 | $($('.math').each( (_, element) => { 68 | katex.render(element.textContent, element, { 69 | displayMode: $(element).hasClass('m-block'), 70 | throwOnError: false, 71 | trust: true 72 | }); 73 | })) 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/ThemedNavigationTitleModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that sets a themed navigation title using your design system's typography. 4 | /// 5 | /// This modifier allows applying a custom `TextStyleToken` and optional `Font.Weight` 6 | /// to the navigation title in a consistent way across screens. 7 | /// 8 | /// - Works with `.navigationBarTitleDisplayMode(.inline)` 9 | /// - Compatible with iOS, macOS, and tvOS 10 | /// 11 | /// ### Example: 12 | /// ```swift 13 | /// ContentView() 14 | /// .modifier(ThemedNavigationTitleModifier(title: "Profile", token: .headlineLarge)) 15 | /// ``` 16 | /// 17 | /// - Parameters: 18 | /// - title: The navigation bar title string. 19 | /// - token: The text style token from the theme's typography (e.g. `.headlineLarge`, `.titleMedium`). 20 | /// - weight: Optional override for font weight (e.g. `.bold`, `.semibold`). 21 | public struct ThemedNavigationTitleModifier: ViewModifier { 22 | /// Injects the current app theme to access typography styles. 23 | @Environment(\.appTheme) private var theme 24 | 25 | /// The string title to display in the navigation bar. 26 | let title: String 27 | 28 | /// The typography token defining the base font. 29 | let token: TextStyleToken 30 | 31 | /// An optional font weight override (e.g. `.semibold`, `.bold`). 32 | let weight: Font.Weight? 33 | 34 | public func body(content: Content) -> some View { 35 | let baseFont = theme.typography[token] 36 | let font = weight.map { baseFont.weight($0) } ?? baseFont 37 | 38 | #if os(tvOS) 39 | return content 40 | .toolbar { 41 | ToolbarItem(placement: .principal) { 42 | Text(title) 43 | .font(font) 44 | } 45 | } 46 | #elseif os(macOS) 47 | return content 48 | .toolbar { 49 | ToolbarItem(placement: .principal) { 50 | Text(title) 51 | .font(font) 52 | } 53 | } 54 | #elseif os(iOS) 55 | return content 56 | .navigationBarTitleDisplayMode(.inline) 57 | .toolbar { 58 | ToolbarItem(placement: .principal) { 59 | Text(title) 60 | .font(font) 61 | } 62 | } 63 | #else 64 | return content 65 | #endif 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/TextField/TextFieldSize.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Represents the size of a text field, affecting both padding and font. 4 | /// 5 | /// Use this enum to configure consistent spacing and typography 6 | /// for text fields across your application. 7 | /// 8 | /// - Usage: 9 | /// ```swift 10 | /// TextField("Name", text: $name) 11 | /// .textFieldSize(.medium) 12 | /// ``` 13 | /// 14 | /// Sizes are mapped to tokens defined in your `Theme` for consistent scaling. 15 | public enum TextFieldSize { 16 | 17 | /// A compact text field with smaller padding and font. 18 | case small 19 | 20 | /// The default size for standard input fields. 21 | case medium 22 | 23 | /// A large input field typically used in prominent UI sections. 24 | case large 25 | 26 | /// Returns the padding to apply based on the theme's spacing tokens. 27 | /// 28 | /// - Parameter theme: The theme to use for spacing values. 29 | /// - Returns: EdgeInsets appropriate for the field size. 30 | public func padding(theme: Theme) -> EdgeInsets { 31 | switch self { 32 | case .small: 33 | return EdgeInsets( 34 | top: theme.spacing.sm, 35 | leading: theme.spacing.sm, 36 | bottom: theme.spacing.sm, 37 | trailing: theme.spacing.sm 38 | ) 39 | case .medium: 40 | return EdgeInsets( 41 | top: theme.spacing.md, 42 | leading: theme.spacing.md, 43 | bottom: theme.spacing.md, 44 | trailing: theme.spacing.md 45 | ) 46 | case .large: 47 | return EdgeInsets( 48 | top: theme.spacing.lg, 49 | leading: theme.spacing.lg, 50 | bottom: theme.spacing.lg, 51 | trailing: theme.spacing.lg 52 | ) 53 | } 54 | } 55 | 56 | /// Returns the corresponding font from the theme based on field size. 57 | /// 58 | /// - Parameter theme: The theme to use for typography values. 59 | /// - Returns: A `Font` appropriate for the field size. 60 | public func font(theme: Theme) -> Font { 61 | switch self { 62 | case .small: 63 | return theme.typography.bodySmall 64 | case .medium: 65 | return theme.typography.bodyMedium 66 | case .large: 67 | return theme.typography.bodyLarge 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/Checkbox/CheckboxShape.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Represents the visual shape of a button used in themed UI components. 4 | /// 5 | /// This enum defines several commonly used button shapes, including: 6 | /// - Square: No rounding. 7 | /// - Rounded: Medium rounding using the theme's default radius. 8 | /// - Capsule: Fully rounded pill shape. 9 | /// - Custom: Developer-defined corner radius. 10 | public enum CheckboxShape: Equatable { 11 | /// A square button with no corner radius. 12 | case square 13 | 14 | /// A rounded button using the theme's medium radius (`ThemeRadii.md`). 15 | case rounded 16 | 17 | /// A circle checkbox 18 | case circle 19 | 20 | /// A custom shape with a specific corner radius. 21 | /// - Parameter cornerRadius: The radius in points to use. 22 | case custom(cornerRadius: CGFloat) 23 | 24 | /// Resolves the numeric corner radius based on the selected shape and current theme. 25 | /// - Parameter theme: The theme from which to pull radius values. 26 | /// - Returns: A `CGFloat` representing the radius to apply. 27 | public func radius(for theme: Theme) -> CGFloat { 28 | switch self { 29 | case .square: 30 | return .zero 31 | case .rounded: 32 | return theme.radii.xs 33 | case .circle: 34 | return 9999 35 | case .custom(let cornerRadius): 36 | return cornerRadius 37 | } 38 | } 39 | 40 | /// Returns the corresponding `Shape` for this button shape, wrapped in an `AnyShape`. 41 | /// - Parameter theme: The theme to resolve radius values for `rounded` and `custom` types. 42 | /// - Returns: A shape instance to apply to button clip or background. 43 | func shape(theme: Theme) -> AnyShape { 44 | switch self { 45 | case .rounded, .custom, .circle, .square: 46 | return AnyShape(RoundedRectangle(cornerRadius: radius(for: theme))) 47 | } 48 | } 49 | 50 | /// Custom equality check that treats any two `.custom` shapes as equal, 51 | /// regardless of the actual radius value. 52 | public static func == (lhs: CheckboxShape, rhs: CheckboxShape) -> Bool { 53 | switch (lhs, rhs) { 54 | case (.square, .square), (.rounded, .rounded), (.custom, .custom): 55 | return true 56 | default: 57 | return false 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Modifiers/Button/ButtonConfiguration.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A configuration container that defines style overrides for a themed button. 4 | /// 5 | /// `ButtonConfiguration` is used internally and via environment injection to customize 6 | /// how a button is rendered. You can override variant, shape, size, font, and colors independently. 7 | /// If any value is `nil`, the theme's default value will be used automatically. 8 | /// 9 | /// This struct supports granular control and is typically set using modifiers like: 10 | /// `buttonVariant(_:)`, `buttonShape(_:)`, `buttonSize(_:)`, or `buttonLabelStyle(_:)`. 11 | struct ButtonConfiguration { 12 | 13 | /// The visual style variant of the button. 14 | /// 15 | /// Examples include: 16 | /// - `.filled`: Solid background 17 | /// - `.outline`: Transparent background with a stroke 18 | /// - `.text`: Minimalist button with no background or border 19 | /// 20 | /// If `nil`, the variant from `Theme.buttons` will be applied. 21 | var variant: ButtonVariant? = nil 22 | 23 | /// The shape of the button (e.g., `.capsule`, `.rounded`, `.rectangle`). 24 | /// 25 | /// This defines how the button is clipped and styled. 26 | /// If `nil`, the theme-defined shape will be used. 27 | var shape: ButtonShape? = nil 28 | 29 | /// The size of the button, affecting height, padding, and potentially font. 30 | /// 31 | /// Valid options include `.small`, `.medium`, `.large`, and `.fullWidth`. 32 | /// If `nil`, the theme default size will be applied. 33 | var size: ButtonSize? = nil 34 | 35 | /// The font style for the button's label, defined by a `ThemeFontToken`. 36 | /// 37 | /// If unset, the font will be automatically chosen based on the current size 38 | /// or the default from the theme's button configuration. 39 | var font: ThemeFontToken? = nil 40 | 41 | /// An optional override for the background color of the button. 42 | /// 43 | /// If set, this color takes precedence over variant-based colors. 44 | /// If `nil`, the variant or theme determines the background color. 45 | var backgroundColor: Color? = nil 46 | 47 | /// An optional override for the foreground (text/icon) color of the button. 48 | /// 49 | /// If set, this color overrides the one provided by the variant or theme. 50 | var foregroundColor: Color? = nil 51 | } 52 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemeRadii.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Defines standard corner radius values used throughout the design system. 4 | public struct ThemeRadii: Equatable { 5 | 6 | /// No corner radius (0pt). Used for completely square edges. 7 | public let none: CGFloat = .zero 8 | 9 | /// Small corner radius (4pt). Used for subtle rounding on extra small elements or inputs. 10 | public let xs: CGFloat 11 | 12 | /// Small corner radius (4pt). Used for subtle rounding on small elements or inputs. 13 | public let sm: CGFloat 14 | 15 | /// Medium corner radius (8pt). Used for buttons, cards, or modals with moderate rounding. 16 | public let md: CGFloat 17 | 18 | /// Large corner radius (16pt). Ideal for highly rounded containers, cards, or UI highlights. 19 | public let lg: CGFloat 20 | 21 | /// Large corner radius (28pt). Ideal for highly rounded containers, cards, or UI highlights. 22 | public let xl: CGFloat 23 | 24 | /// Fully rounded edges (9999pt). Commonly used for pills, tags, badges, or chip components. 25 | public let pill: CGFloat 26 | 27 | /// A predefined set of corner radius values for light mode. 28 | public static let defaultLight = ThemeRadii( 29 | xs: 4, 30 | sm: 8, 31 | md: 12, 32 | lg: 16, 33 | xl: 28, 34 | pill: 9999 35 | ) 36 | 37 | /// A predefined set of corner radius values for dark mode. 38 | public static let defaultDark = ThemeRadii( 39 | xs: 4, 40 | sm: 8, 41 | md: 12, 42 | lg: 16, 43 | xl: 28, 44 | pill: 9999 45 | ) 46 | 47 | /// Initializes a custom set of radius tokens. 48 | public init(xs: CGFloat, sm: CGFloat, md: CGFloat, lg: CGFloat, xl: CGFloat, pill: CGFloat) { 49 | self.xs = xs 50 | self.sm = sm 51 | self.md = md 52 | self.lg = lg 53 | self.xl = xl 54 | self.pill = pill 55 | } 56 | 57 | subscript(_ token: RadiusToken) -> CGFloat { 58 | switch token { 59 | case .none: return none 60 | case .xs: return xs 61 | case .sm: return sm 62 | case .md: return md 63 | case .lg: return lg 64 | case .xl: return xl 65 | case .pill: return pill 66 | } 67 | } 68 | } 69 | 70 | public enum RadiusToken: String, CaseIterable { 71 | case none 72 | case xs 73 | case sm 74 | case md 75 | case lg 76 | case xl 77 | case pill 78 | } 79 | -------------------------------------------------------------------------------- /docs/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | $(function(){ 6 | var $typeahead = $('[data-typeahead]'); 7 | var $form = $typeahead.parents('form'); 8 | var searchURL = $form.attr('action'); 9 | 10 | function displayTemplate(result) { 11 | return result.name; 12 | } 13 | 14 | function suggestionTemplate(result) { 15 | var t = '
'; 16 | t += '' + result.name + ''; 17 | if (result.parent_name) { 18 | t += '' + result.parent_name + ''; 19 | } 20 | t += '
'; 21 | return t; 22 | } 23 | 24 | $typeahead.one('focus', function() { 25 | $form.addClass('loading'); 26 | 27 | $.getJSON(searchURL).then(function(searchData) { 28 | const searchIndex = lunr(function() { 29 | this.ref('url'); 30 | this.field('name'); 31 | this.field('abstract'); 32 | for (const [url, doc] of Object.entries(searchData)) { 33 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 34 | } 35 | }); 36 | 37 | $typeahead.typeahead( 38 | { 39 | highlight: true, 40 | minLength: 3, 41 | autoselect: true 42 | }, 43 | { 44 | limit: 10, 45 | display: displayTemplate, 46 | templates: { suggestion: suggestionTemplate }, 47 | source: function(query, sync) { 48 | const lcSearch = query.toLowerCase(); 49 | const results = searchIndex.query(function(q) { 50 | q.term(lcSearch, { boost: 100 }); 51 | q.term(lcSearch, { 52 | boost: 10, 53 | wildcard: lunr.Query.wildcard.TRAILING 54 | }); 55 | }).map(function(result) { 56 | var doc = searchData[result.ref]; 57 | doc.url = result.ref; 58 | return doc; 59 | }); 60 | sync(results); 61 | } 62 | } 63 | ); 64 | $form.removeClass('loading'); 65 | $typeahead.trigger('focus'); 66 | }); 67 | }); 68 | 69 | var baseURL = searchURL.slice(0, -"search.json".length); 70 | 71 | $typeahead.on('typeahead:select', function(e, result) { 72 | window.location = baseURL + result.url; 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /Tests/SwiftThemeKitTests/ThemeShadowsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | @testable import SwiftThemeKit 4 | 5 | final class ThemeShadowsTests: XCTestCase { 6 | 7 | func testDefaultLightShadows() { 8 | let shadows = ThemeShadows.defaultLight 9 | 10 | XCTAssertEqual(shadows.none, ThemeShadow(color: .clear, radius: 0, x: 0, y: 0)) 11 | XCTAssertEqual(shadows.sm, ThemeShadow(color: .black.opacity(0.12), radius: 1, x: 0, y: 1)) 12 | XCTAssertEqual(shadows.md, ThemeShadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 4)) 13 | XCTAssertEqual(shadows.lg, ThemeShadow(color: .black.opacity(0.12), radius: 8, x: 0, y: 8)) 14 | 15 | let expectedFocusColor = ThemeColors.defaultLight.shadow.opacity(0.3) 16 | XCTAssertEqual(shadows.focus.color.description, expectedFocusColor.description) 17 | XCTAssertEqual(shadows.focus.radius, 3) 18 | XCTAssertEqual(shadows.focus.x, 0) 19 | XCTAssertEqual(shadows.focus.y, 0) 20 | } 21 | 22 | func testDefaultDarkShadows() { 23 | let shadows = ThemeShadows.defaultDark 24 | 25 | XCTAssertEqual(shadows.none, ThemeShadow(color: .clear, radius: 0, x: 0, y: 0)) 26 | XCTAssertEqual(shadows.sm, ThemeShadow(color: .white.opacity(0.05), radius: 1, x: 0, y: 1)) 27 | XCTAssertEqual(shadows.md, ThemeShadow(color: .white.opacity(0.06), radius: 4, x: 0, y: 4)) 28 | XCTAssertEqual(shadows.lg, ThemeShadow(color: .white.opacity(0.07), radius: 8, x: 0, y: 8)) 29 | 30 | let expectedFocusColor = ThemeColors.defaultDark.shadow.opacity(0.3) 31 | XCTAssertEqual(shadows.focus.color.description, expectedFocusColor.description) 32 | XCTAssertEqual(shadows.focus.radius, 3) 33 | XCTAssertEqual(shadows.focus.x, 0) 34 | XCTAssertEqual(shadows.focus.y, 0) 35 | } 36 | 37 | func testCustomShadowSubscript() { 38 | let sm = ThemeShadow(color: .red, radius: 1, x: 0, y: 1) 39 | let md = ThemeShadow(color: .green, radius: 2, x: 0, y: 2) 40 | let lg = ThemeShadow(color: .blue, radius: 3, x: 0, y: 3) 41 | let focus = ThemeShadow(color: .orange, radius: 4, x: 0, y: 0) 42 | let none = ThemeShadow(color: .clear, radius: 0, x: 0, y: 0) 43 | 44 | let shadows = ThemeShadows(sm: sm, md: md, lg: lg, focus: focus, none: none) 45 | 46 | XCTAssertEqual(shadows[.sm], sm) 47 | XCTAssertEqual(shadows[.md], md) 48 | XCTAssertEqual(shadows[.lg], lg) 49 | XCTAssertEqual(shadows[.focus], focus) 50 | XCTAssertEqual(shadows[.none], none) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | $(function(){ 6 | var $typeahead = $('[data-typeahead]'); 7 | var $form = $typeahead.parents('form'); 8 | var searchURL = $form.attr('action'); 9 | 10 | function displayTemplate(result) { 11 | return result.name; 12 | } 13 | 14 | function suggestionTemplate(result) { 15 | var t = '
'; 16 | t += '' + result.name + ''; 17 | if (result.parent_name) { 18 | t += '' + result.parent_name + ''; 19 | } 20 | t += '
'; 21 | return t; 22 | } 23 | 24 | $typeahead.one('focus', function() { 25 | $form.addClass('loading'); 26 | 27 | $.getJSON(searchURL).then(function(searchData) { 28 | const searchIndex = lunr(function() { 29 | this.ref('url'); 30 | this.field('name'); 31 | this.field('abstract'); 32 | for (const [url, doc] of Object.entries(searchData)) { 33 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 34 | } 35 | }); 36 | 37 | $typeahead.typeahead( 38 | { 39 | highlight: true, 40 | minLength: 3, 41 | autoselect: true 42 | }, 43 | { 44 | limit: 10, 45 | display: displayTemplate, 46 | templates: { suggestion: suggestionTemplate }, 47 | source: function(query, sync) { 48 | const lcSearch = query.toLowerCase(); 49 | const results = searchIndex.query(function(q) { 50 | q.term(lcSearch, { boost: 100 }); 51 | q.term(lcSearch, { 52 | boost: 10, 53 | wildcard: lunr.Query.wildcard.TRAILING 54 | }); 55 | }).map(function(result) { 56 | var doc = searchData[result.ref]; 57 | doc.url = result.ref; 58 | return doc; 59 | }); 60 | sync(results); 61 | } 62 | } 63 | ); 64 | $form.removeClass('loading'); 65 | $typeahead.trigger('focus'); 66 | }); 67 | }); 68 | 69 | var baseURL = searchURL.slice(0, -"search.json".length); 70 | 71 | $typeahead.on('typeahead:select', function(e, result) { 72 | window.location = baseURL + result.url; 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/TextField/TextFieldShape.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Represents the visual shape of a text field used in themed UI components. 4 | /// 5 | /// This enum defines several commonly used button shapes, including: 6 | /// - Square: No rounding. 7 | /// - Rounded: Medium rounding using the theme's default radius. 8 | /// - Capsule: Fully rounded pill shape. 9 | /// - Custom: Developer-defined corner radius. 10 | public enum TextFieldShape: Equatable { 11 | /// A square button with no corner radius. 12 | case square 13 | 14 | /// A rounded text field using the theme's medium radius (`ThemeRadii.md`). 15 | case rounded 16 | 17 | /// A capsule-shaped text field with fully rounded corners (uses `ThemeRadii.pill`). 18 | case capsule 19 | 20 | /// A custom shape with a specific corner radius. 21 | /// - Parameter cornerRadius: The radius in points to use. 22 | case custom(cornerRadius: CGFloat) 23 | 24 | /// Resolves the numeric corner radius based on the selected shape and current theme. 25 | /// - Parameter theme: The theme from which to pull radius values. 26 | /// - Returns: A `CGFloat` representing the radius to apply. 27 | public func radius(for theme: Theme) -> CGFloat { 28 | switch self { 29 | case .square: 30 | return .zero 31 | case .rounded: 32 | return theme.radii.md 33 | case .capsule: 34 | return theme.radii.pill 35 | case .custom(let cornerRadius): 36 | return cornerRadius 37 | } 38 | } 39 | 40 | /// Returns the corresponding `Shape` for this button shape, wrapped in an `AnyShape`. 41 | /// - Parameter theme: The theme to resolve radius values for `rounded` and `custom` types. 42 | /// - Returns: A shape instance to apply to button clip or background. 43 | func shape(theme: Theme) -> AnyShape { 44 | switch self { 45 | case .square: 46 | return AnyShape(RoundedRectangle(cornerRadius: 0)) 47 | case .rounded: 48 | return AnyShape(RoundedRectangle(cornerRadius: radius(for: theme))) 49 | case .capsule: 50 | return AnyShape(Capsule()) 51 | case .custom: 52 | return AnyShape(RoundedRectangle(cornerRadius: radius(for: theme))) 53 | } 54 | } 55 | 56 | /// Custom equality check that treats any two `.custom` shapes as equal, 57 | /// regardless of the actual radius value. 58 | public static func == (lhs: TextFieldShape, rhs: TextFieldShape) -> Bool { 59 | switch (lhs, rhs) { 60 | case (.square, .square), (.rounded, .rounded), (.capsule, .capsule), (.custom, .custom): 61 | return true 62 | default: 63 | return false 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Components/Card.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A flexible, reusable card component that adapts to the current theme. 4 | /// 5 | /// The `Card` view provides a styled container with rounded corners, background color, 6 | /// shadow elevation, and optional padding. It is ideal for grouping related content in a visually distinct block. 7 | /// 8 | /// Example usage: 9 | /// ```swift 10 | /// Card { 11 | /// Text("Profile details") 12 | /// } 13 | /// ``` 14 | /// 15 | /// The card uses the current `Theme` from the environment to style its appearance. 16 | public struct Card: View { 17 | @Environment(\.appTheme) var theme 18 | 19 | private let content: () -> Content 20 | var padding: EdgeInsets? 21 | var backgroundColor: Color? 22 | var elevation: ShadowToken 23 | 24 | /// Creates a new themed `Card` container. 25 | /// 26 | /// - Parameters: 27 | /// - elevation: The shadow elevation applied to the card. Defaults to `.md`. 28 | /// - padding: Optional custom padding for the card’s content. If `nil`, uses default theme spacing. 29 | /// - backgroundColor: Optional override for the card's background color. Defaults to `theme.colors.surfaceContainerHigh`. 30 | /// - content: A view builder closure containing the card's inner content. 31 | public init( 32 | elevation: ShadowToken = .sm, 33 | padding: EdgeInsets? = nil, 34 | backgroundColor: Color? = nil, 35 | @ViewBuilder content: @escaping () -> Content 36 | ) { 37 | self.content = content 38 | self.padding = padding 39 | self.backgroundColor = backgroundColor 40 | self.elevation = elevation 41 | } 42 | 43 | /// The visual representation of the card, composed of the content and its styling. 44 | /// 45 | /// The body applies the current theme’s spacing, shape, and shadow, and clips the view 46 | /// using a medium corner radius defined by the theme. 47 | public var body: some View { 48 | let contentPadding = padding ?? EdgeInsets( 49 | top: theme.spacing.md, 50 | leading: theme.spacing.md, 51 | bottom: theme.spacing.md, 52 | trailing: theme.spacing.md 53 | ) 54 | 55 | let bgColor = backgroundColor ?? theme.colors.surfaceContainerHigh 56 | 57 | content() 58 | .padding(contentPadding) 59 | .background( 60 | theme.shapes[.md] 61 | .fill(bgColor) 62 | ) 63 | .clipShape(.md) 64 | .shadow(elevation) 65 | } 66 | } 67 | 68 | #Preview { 69 | VStack { 70 | Card { 71 | Text("Hello, World!") 72 | .frame(maxWidth: .infinity) 73 | } 74 | } 75 | .padding(.horizontal, 16) 76 | } 77 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeTypography+Copy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension ThemeTypography { 4 | /// Returns a new `ThemeTypography` instance by copying the current one 5 | /// and overriding the specified text styles. 6 | /// 7 | /// Only provided parameters will be changed; all others will retain their existing values. 8 | /// 9 | /// - Parameters: 10 | /// - displayLarge: Override for `displayLarge` 11 | /// - displayMedium: Override for `displayMedium` 12 | /// - displaySmall: Override for `displaySmall` 13 | /// - headlineLarge: Override for `headlineLarge` 14 | /// - headlineMedium: Override for `headlineMedium` 15 | /// - headlineSmall: Override for `headlineSmall` 16 | /// - titleLarge: Override for `titleLarge` 17 | /// - titleMedium: Override for `titleMedium` 18 | /// - titleSmall: Override for `titleSmall` 19 | /// - labelLarge: Override for `labelLarge` 20 | /// - labelMedium: Override for `labelMedium` 21 | /// - labelSmall: Override for `labelSmall` 22 | /// - bodyLarge: Override for `bodyLarge` 23 | /// - bodyMedium: Override for `bodyMedium` 24 | /// - bodySmall: Override for `bodySmall` 25 | /// - buttonText: Override for `buttonText` 26 | /// - Returns: A new `ThemeTypography` with applied overrides. 27 | func copy( 28 | displayLarge: Font? = nil, 29 | displayMedium: Font? = nil, 30 | displaySmall: Font? = nil, 31 | headlineLarge: Font? = nil, 32 | headlineMedium: Font? = nil, 33 | headlineSmall: Font? = nil, 34 | titleLarge: Font? = nil, 35 | titleMedium: Font? = nil, 36 | titleSmall: Font? = nil, 37 | labelLarge: Font? = nil, 38 | labelMedium: Font? = nil, 39 | labelSmall: Font? = nil, 40 | bodyLarge: Font? = nil, 41 | bodyMedium: Font? = nil, 42 | bodySmall: Font? = nil, 43 | buttonText: Font? = nil 44 | ) -> ThemeTypography { 45 | ThemeTypography( 46 | displayLarge: displayLarge ?? self.displayLarge, 47 | displayMedium: displayMedium ?? self.displayMedium, 48 | displaySmall: displaySmall ?? self.displaySmall, 49 | 50 | headlineLarge: headlineLarge ?? self.headlineLarge, 51 | headlineMedium: headlineMedium ?? self.headlineMedium, 52 | headlineSmall: headlineSmall ?? self.headlineSmall, 53 | 54 | titleLarge: titleLarge ?? self.titleLarge, 55 | titleMedium: titleMedium ?? self.titleMedium, 56 | titleSmall: titleSmall ?? self.titleSmall, 57 | 58 | labelLarge: labelLarge ?? self.labelLarge, 59 | labelMedium: labelMedium ?? self.labelMedium, 60 | labelSmall: labelSmall ?? self.labelSmall, 61 | 62 | bodyLarge: bodyLarge ?? self.bodyLarge, 63 | bodyMedium: bodyMedium ?? self.bodyMedium, 64 | bodySmall: bodySmall ?? self.bodySmall, 65 | 66 | buttonText: buttonText ?? self.buttonText 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/SwiftThemeKitTests/ThemeTypographyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | @testable import SwiftThemeKit 4 | 5 | final class ThemeTypographyTests: XCTestCase { 6 | 7 | func testDefaultLightTypographyTokens() { 8 | let typography = ThemeTypography.defaultLight 9 | 10 | XCTAssertEqual(typography[.displayLarge], typography.displayLarge) 11 | XCTAssertEqual(typography[.displayMedium], typography.displayMedium) 12 | XCTAssertEqual(typography[.displaySmall], typography.displaySmall) 13 | 14 | XCTAssertEqual(typography[.headlineLarge], typography.headlineLarge) 15 | XCTAssertEqual(typography[.headlineMedium], typography.headlineMedium) 16 | XCTAssertEqual(typography[.headlineSmall], typography.headlineSmall) 17 | 18 | XCTAssertEqual(typography[.titleLarge], typography.titleLarge) 19 | XCTAssertEqual(typography[.titleMedium], typography.titleMedium) 20 | XCTAssertEqual(typography[.titleSmall], typography.titleSmall) 21 | 22 | XCTAssertEqual(typography[.labelLarge], typography.labelLarge) 23 | XCTAssertEqual(typography[.labelMedium], typography.labelMedium) 24 | XCTAssertEqual(typography[.labelSmall], typography.labelSmall) 25 | 26 | XCTAssertEqual(typography[.bodyLarge], typography.bodyLarge) 27 | XCTAssertEqual(typography[.bodyMedium], typography.bodyMedium) 28 | XCTAssertEqual(typography[.bodySmall], typography.bodySmall) 29 | 30 | XCTAssertEqual(typography[.buttonText], typography.buttonText) 31 | } 32 | 33 | func testDefaultDarkTypographyTokens() { 34 | let typography = ThemeTypography.defaultDark 35 | 36 | XCTAssertEqual(typography[.displayLarge], typography.displayLarge) 37 | XCTAssertEqual(typography[.displayMedium], typography.displayMedium) 38 | XCTAssertEqual(typography[.displaySmall], typography.displaySmall) 39 | 40 | XCTAssertEqual(typography[.headlineLarge], typography.headlineLarge) 41 | XCTAssertEqual(typography[.headlineMedium], typography.headlineMedium) 42 | XCTAssertEqual(typography[.headlineSmall], typography.headlineSmall) 43 | 44 | XCTAssertEqual(typography[.titleLarge], typography.titleLarge) 45 | XCTAssertEqual(typography[.titleMedium], typography.titleMedium) 46 | XCTAssertEqual(typography[.titleSmall], typography.titleSmall) 47 | 48 | XCTAssertEqual(typography[.labelLarge], typography.labelLarge) 49 | XCTAssertEqual(typography[.labelMedium], typography.labelMedium) 50 | XCTAssertEqual(typography[.labelSmall], typography.labelSmall) 51 | 52 | XCTAssertEqual(typography[.bodyLarge], typography.bodyLarge) 53 | XCTAssertEqual(typography[.bodyMedium], typography.bodyMedium) 54 | XCTAssertEqual(typography[.bodySmall], typography.bodySmall) 55 | 56 | XCTAssertEqual(typography[.buttonText], typography.buttonText) 57 | } 58 | 59 | func testThemeFontTokenInitializesCorrectly() { 60 | let token = ThemeFontToken(.headlineLarge, weight: .bold) 61 | XCTAssertEqual(token.style, .headlineLarge) 62 | XCTAssertEqual(token.weight, .bold) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/ThemeShadows.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A collection of predefined shadow styles used to indicate elevation, depth, or focus across the UI. 4 | public struct ThemeShadows: Equatable { 5 | 6 | /// No shadow. Used for flat surfaces and elements that should appear flush with the background. 7 | public let none: ThemeShadow 8 | 9 | /// Small shadow. Typically used for slightly elevated components like input fields or chips. 10 | public let sm: ThemeShadow 11 | 12 | /// Medium shadow. Suitable for cards, modals, and elevated surfaces. 13 | public let md: ThemeShadow 14 | 15 | /// Large shadow. Used for high-elevation components such as floating panels or bottom sheets. 16 | public let lg: ThemeShadow 17 | 18 | /// Focus shadow. Applied to inputs or buttons when they are in a focused or active state. 19 | public let focus: ThemeShadow 20 | 21 | /// Default shadow set optimized for **light mode**. Uses dark shadow colors on light surfaces. 22 | public static let defaultLight = ThemeShadows( 23 | sm: ThemeShadow(color: .black.opacity(0.12), radius: 1, x: 0, y: 1), 24 | md: ThemeShadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 4), 25 | lg: ThemeShadow(color: .black.opacity(0.12), radius: 8, x: 0, y: 8), 26 | focus: ThemeShadow(color: ThemeColors.defaultLight.shadow.opacity(0.3), radius: 3, x: 0, y: 0), 27 | none: ThemeShadow(color: .clear, radius: 0, x: 0, y: 0) 28 | ) 29 | 30 | /// Default shadow set optimized for **dark mode**. Uses subtle light shadows to maintain depth. 31 | public static let defaultDark = ThemeShadows( 32 | sm: ThemeShadow(color: .white.opacity(0.05), radius: 1, x: 0, y: 1), 33 | md: ThemeShadow(color: .white.opacity(0.06), radius: 4, x: 0, y: 4), 34 | lg: ThemeShadow(color: .white.opacity(0.07), radius: 8, x: 0, y: 8), 35 | focus: ThemeShadow(color: ThemeColors.defaultDark.shadow.opacity(0.3), radius: 3, x: 0, y: 0), 36 | none: ThemeShadow(color: .clear, radius: 0, x: 0, y: 0) 37 | ) 38 | 39 | /// Initializes a custom shadow scale set. 40 | /// - Parameters: 41 | /// - none: Shadow style with no elevation. 42 | /// - sm: Small elevation shadow. 43 | /// - md: Medium elevation shadow. 44 | /// - lg: Large elevation shadow. 45 | /// - focus: Shadow used to indicate focus state. 46 | public init(sm: ThemeShadow, 47 | md: ThemeShadow, 48 | lg: ThemeShadow, 49 | focus: ThemeShadow, 50 | none: ThemeShadow) { 51 | self.none = none 52 | self.sm = sm 53 | self.md = md 54 | self.lg = lg 55 | self.focus = focus 56 | } 57 | 58 | subscript(_ token: ShadowToken) -> ThemeShadow { 59 | switch token { 60 | case .none: return none 61 | case .sm: return sm 62 | case .md: return md 63 | case .lg: return lg 64 | case .focus: return focus 65 | } 66 | } 67 | } 68 | 69 | public enum ShadowToken: String, CaseIterable { 70 | case none 71 | case sm 72 | case md 73 | case lg 74 | case focus 75 | } 76 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/Button/ButtonSize.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Defines the size and layout configuration for themed buttons. 4 | /// 5 | /// `ButtonSize` controls padding and font size for different button scales. 6 | /// It also supports full-width layout and fully custom styles. 7 | public enum ButtonSize: Equatable { 8 | 9 | /// Small button size. Compact padding and a small font. 10 | case small 11 | 12 | /// Medium button size. Moderate padding and a larger label font. 13 | case medium 14 | 15 | /// Large button size. Generous padding and body-level typography. 16 | case large 17 | 18 | /// Full-width button. Stretches to fill horizontal space. 19 | /// Padding is applied vertically, and horizontal is handled via `.frame(maxWidth: .infinity)`. 20 | case fullWidth 21 | 22 | /// A custom button size, allowing full control over both padding and font. 23 | /// - Parameters: 24 | /// - EdgeInsets: The padding to apply. 25 | /// - Font: The font to use for the label. 26 | case custom(EdgeInsets, Font) 27 | 28 | /// Computes the edge insets (padding) to apply for the given button size. 29 | /// - Parameter theme: The current theme to use for spacing tokens. 30 | /// - Returns: An `EdgeInsets` value that should be applied to the button. 31 | public func paddingValues(for theme: Theme) -> EdgeInsets { 32 | switch self { 33 | case .small: 34 | return EdgeInsets( 35 | top: theme.spacing.sm, 36 | leading: theme.spacing.md, 37 | bottom: theme.spacing.sm, 38 | trailing: theme.spacing.md 39 | ) 40 | case .medium: 41 | return EdgeInsets( 42 | top: theme.spacing.md, 43 | leading: theme.spacing.lg, 44 | bottom: theme.spacing.md, 45 | trailing: theme.spacing.lg 46 | ) 47 | case .large: 48 | return EdgeInsets( 49 | top: theme.spacing.lg, 50 | leading: theme.spacing.xl, 51 | bottom: theme.spacing.lg, 52 | trailing: theme.spacing.xl 53 | ) 54 | case .fullWidth: 55 | // Width handled with `.frame(maxWidth: .infinity)` 56 | return EdgeInsets( 57 | top: theme.spacing.md, 58 | leading: .infinity, 59 | bottom: theme.spacing.md, 60 | trailing: .infinity 61 | ) 62 | case .custom(let edgeInsets, _): 63 | return edgeInsets 64 | } 65 | } 66 | 67 | /// Resolves the appropriate font to use based on button size. 68 | /// - Parameter theme: The theme from which to retrieve font styles. 69 | /// - Returns: A `Font` appropriate for the current size. 70 | public func font(for theme: Theme) -> Font { 71 | switch self { 72 | case .small: 73 | return theme.typography.labelSmall 74 | case .medium: 75 | return theme.typography.labelLarge 76 | case .large: 77 | return theme.typography.bodyMedium 78 | case .fullWidth: 79 | return theme.typography.bodyMedium 80 | case .custom(_, let font): 81 | return font 82 | } 83 | } 84 | 85 | /// Compares two button sizes for equality. 86 | /// 87 | /// Note: Custom sizes are treated as equal regardless of their actual content. 88 | public static func == (lhs: ButtonSize, rhs: ButtonSize) -> Bool { 89 | switch (lhs, rhs) { 90 | case (.small, .small), 91 | (.medium, .medium), 92 | (.large, .large), 93 | (.fullWidth, .fullWidth), 94 | (.custom, .custom): 95 | return true 96 | default: 97 | return false 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Tests/SwiftThemeKitTests/ThemeColorsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | @testable import SwiftThemeKit 4 | 5 | final class ThemeColorsTests: XCTestCase { 6 | 7 | func testAllColorTokensReturnExpectedColors() { 8 | let theme = ThemeColors.defaultLight 9 | let mirror = Mirror(reflecting: theme) 10 | 11 | for case let (label?, value) in mirror.children { 12 | guard let color = value as? Color else { 13 | XCTFail("Property \(label) is not of type Color") 14 | continue 15 | } 16 | 17 | // Attempt to resolve Color (just to ensure it's instantiable) 18 | _ = UIColor(color) 19 | 20 | // Optional: print token + hex debugging 21 | // print("\(label): \(color)") 22 | } 23 | } 24 | 25 | func testAllColorTokensAreMappedCorrectly() { 26 | let theme = ThemeColors.defaultLight 27 | 28 | XCTAssertEqual(theme[.primary], theme.primary) 29 | XCTAssertEqual(theme[.onPrimary], theme.onPrimary) 30 | XCTAssertEqual(theme[.primaryContainer], theme.primaryContainer) 31 | XCTAssertEqual(theme[.onPrimaryContainer], theme.onPrimaryContainer) 32 | 33 | XCTAssertEqual(theme[.secondary], theme.secondary) 34 | XCTAssertEqual(theme[.onSecondary], theme.onSecondary) 35 | XCTAssertEqual(theme[.secondaryContainer], theme.secondaryContainer) 36 | XCTAssertEqual(theme[.onSecondaryContainer], theme.onSecondaryContainer) 37 | 38 | XCTAssertEqual(theme[.tertiary], theme.tertiary) 39 | XCTAssertEqual(theme[.onTertiary], theme.onTertiary) 40 | XCTAssertEqual(theme[.tertiaryContainer], theme.tertiaryContainer) 41 | XCTAssertEqual(theme[.onTertiaryContainer], theme.onTertiaryContainer) 42 | 43 | XCTAssertEqual(theme[.background], theme.background) 44 | XCTAssertEqual(theme[.onBackground], theme.onBackground) 45 | 46 | XCTAssertEqual(theme[.error], theme.error) 47 | XCTAssertEqual(theme[.onError], theme.onError) 48 | XCTAssertEqual(theme[.errorContainer], theme.errorContainer) 49 | XCTAssertEqual(theme[.onErrorContainer], theme.onErrorContainer) 50 | 51 | XCTAssertEqual(theme[.inverseSurface], theme.inverseSurface) 52 | XCTAssertEqual(theme[.inverseOnSurface], theme.inverseOnSurface) 53 | XCTAssertEqual(theme[.inversePrimary], theme.inversePrimary) 54 | 55 | XCTAssertEqual(theme[.surface], theme.surface) 56 | XCTAssertEqual(theme[.onSurface], theme.onSurface) 57 | XCTAssertEqual(theme[.surfaceVariant], theme.surfaceVariant) 58 | XCTAssertEqual(theme[.onSurfaceVariant], theme.onSurfaceVariant) 59 | 60 | XCTAssertEqual(theme[.surfaceDim], theme.surfaceDim) 61 | XCTAssertEqual(theme[.surfaceBright], theme.surfaceBright) 62 | 63 | XCTAssertEqual(theme[.surfaceContainerLowest], theme.surfaceContainerLowest) 64 | XCTAssertEqual(theme[.surfaceContainerLow], theme.surfaceContainerLow) 65 | XCTAssertEqual(theme[.surfaceContainer], theme.surfaceContainer) 66 | XCTAssertEqual(theme[.surfaceContainerHigh], theme.surfaceContainerHigh) 67 | XCTAssertEqual(theme[.surfaceContainerHighest], theme.surfaceContainerHighest) 68 | 69 | XCTAssertEqual(theme[.outline], theme.outline) 70 | XCTAssertEqual(theme[.outlineVariant], theme.outlineVariant) 71 | XCTAssertEqual(theme[.scrim], theme.scrim) 72 | XCTAssertEqual(theme[.shadow], theme.shadow) 73 | } 74 | 75 | func testDarkThemeSubscriptMapping() { 76 | let theme = ThemeColors.defaultDark 77 | XCTAssertEqual(theme[.primary], theme.primary) 78 | XCTAssertEqual(theme[.onSurface], theme.onSurface) 79 | XCTAssertEqual(theme[.shadow], theme.shadow) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/Button/ButtonShape.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Represents the visual shape of a button used in themed UI components. 4 | /// 5 | /// This enum defines several commonly used button shapes, including: 6 | /// - Square: No rounding. 7 | /// - Rounded: Medium rounding using the theme's default radius. 8 | /// - Capsule: Fully rounded pill shape. 9 | /// - Custom: Developer-defined corner radius. 10 | public enum ButtonShape: Equatable { 11 | /// A square button with no corner radius. 12 | case square 13 | 14 | /// A rounded button using the theme's medium radius (`ThemeRadii.md`). 15 | case rounded 16 | 17 | /// A capsule-shaped button with fully rounded corners (uses `ThemeRadii.pill`). 18 | case capsule 19 | 20 | /// A custom shape with a specific corner radius. 21 | /// - Parameter cornerRadius: The radius in points to use. 22 | case custom(cornerRadius: CGFloat) 23 | 24 | /// Resolves the numeric corner radius based on the selected shape and current theme. 25 | /// - Parameter theme: The theme from which to pull radius values. 26 | /// - Returns: A `CGFloat` representing the radius to apply. 27 | public func radius(for theme: Theme) -> CGFloat { 28 | switch self { 29 | case .square: 30 | return .zero 31 | case .rounded: 32 | return theme.radii.md 33 | case .capsule: 34 | return theme.radii.pill 35 | case .custom(let cornerRadius): 36 | return cornerRadius 37 | } 38 | } 39 | 40 | /// Returns the corresponding `Shape` for this button shape, wrapped in an `AnyShape`. 41 | /// - Parameter theme: The theme to resolve radius values for `rounded` and `custom` types. 42 | /// - Returns: A shape instance to apply to button clip or background. 43 | func shape(theme: Theme) -> AnyShape { 44 | switch self { 45 | case .square: 46 | return AnyShape(RoundedRectangle(cornerRadius: 0)) 47 | case .rounded: 48 | return AnyShape(RoundedRectangle(cornerRadius: radius(for: theme))) 49 | case .capsule: 50 | return AnyShape(Capsule()) 51 | case .custom: 52 | return AnyShape(RoundedRectangle(cornerRadius: radius(for: theme))) 53 | } 54 | } 55 | 56 | /// Custom equality check that treats any two `.custom` shapes as equal, 57 | /// regardless of the actual radius value. 58 | public static func == (lhs: ButtonShape, rhs: ButtonShape) -> Bool { 59 | switch (lhs, rhs) { 60 | case (.square, .square), (.rounded, .rounded), (.capsule, .capsule), (.custom, .custom): 61 | return true 62 | default: 63 | return false 64 | } 65 | } 66 | } 67 | 68 | /// A type-erased wrapper for any SwiftUI `Shape`, allowing dynamic shape selection. 69 | /// 70 | /// `AnyShape` is useful when shape type needs to be determined at runtime or stored in a property. 71 | struct AnyShape: InsettableShape { 72 | /// Internal closure used to build a `Path` from a `CGRect`. 73 | private let pathBuilder: @Sendable (CGRect) -> Path 74 | private let insetBuilder: @Sendable (CGFloat) -> AnyShape 75 | 76 | /// Initializes the wrapper using a concrete `Shape` instance. 77 | /// - Parameter shape: The shape to wrap. 78 | public init(_ shape: S) { 79 | self.pathBuilder = { rect in shape.path(in: rect) } 80 | self.insetBuilder = { amount in AnyShape(shape.inset(by: amount)) } 81 | } 82 | 83 | /// Generates the path for the shape in the given rectangle. 84 | /// - Parameter rect: The bounds in which to draw the shape. 85 | /// - Returns: A `Path` representing the shape. 86 | public func path(in rect: CGRect) -> Path { 87 | pathBuilder(rect) 88 | } 89 | 90 | public func inset(by amount: CGFloat) -> AnyShape { 91 | insetBuilder(amount) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/View+TextField.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension View { 4 | /// Applies the full SwiftThemeKit text field style using the theme’s configuration system. 5 | /// 6 | /// This modifier sets up the base layout, padding, shape, and interaction styling from the theme, 7 | /// using environment-provided values such as `TextFieldVariant`, `TextFieldSize`, `TextFieldShape`, and typography tokens. 8 | /// 9 | /// This is **optional** — you can use `textFieldVariant`, `textFieldSize`, or `textFieldFont` individually 10 | /// if you prefer to build a custom layout. 11 | /// 12 | /// - Returns: A view with the themed text field style applied. 13 | /// 14 | /// ### Example: 15 | /// ```swift 16 | /// TextField("Email", text: $email) 17 | /// .applyThemeTextFieldStyle() 18 | /// .textFieldVariant(.outlined) 19 | /// ``` 20 | @ViewBuilder 21 | func applyThemeTextFieldStyle() -> some View { 22 | self 23 | .modifier(ThemedTextFiedlModifier()) 24 | .modifier(ApplyFinalTextFieldStyleModifier()) 25 | } 26 | 27 | /// Sets the visual variant of the text field, such as `.filled`, `.outlined`, or `.underlined`. 28 | /// 29 | /// This determines the field's border, background, and focus styling. 30 | /// 31 | /// - Parameter variant: The desired text field variant. 32 | /// - Returns: A view with the variant applied to the environment. 33 | @ViewBuilder 34 | func textFieldVariant(_ variant: TextFieldVariant) -> some View { 35 | self.modifier(TextFieldVariantModifier(token: variant)) 36 | } 37 | 38 | /// Overrides the size of the text field, such as `.small`, `.medium`, or `.large`. 39 | /// 40 | /// This affects internal padding, height, and default font unless overridden with `textFieldFont`. 41 | /// 42 | /// - Parameter size: The desired size token. 43 | /// - Returns: A view with the size styling applied. 44 | @ViewBuilder 45 | func textFieldSize(_ size: TextFieldSize) -> some View { 46 | self.modifier(TextFieldSizeModifier(token: size)) 47 | } 48 | 49 | /// Sets the corner shape of the text field (e.g., `.rounded`, `.capsule`, `.rectangle`). 50 | /// 51 | /// The shape is applied to the background or outline and affects how the field is clipped. 52 | /// 53 | /// - Parameter shape: The shape token to apply. 54 | /// - Returns: A view with the shape applied. 55 | @ViewBuilder 56 | func textFieldShape(_ shape: TextFieldShape) -> some View { 57 | self.modifier(TextFieldShapeModifier(token: shape)) 58 | } 59 | 60 | /// Flags the text field as being in an error state, typically changing its color or border. 61 | /// 62 | /// Use this to visually indicate validation failure or incorrect input. 63 | /// 64 | /// - Parameter isError: A Boolean value indicating if the field is in error. 65 | /// - Returns: A view with error styling conditionally applied. 66 | @ViewBuilder 67 | func isError(_ isError: Bool) -> some View { 68 | self.modifier(TextFieldErrorModifier(isError: isError)) 69 | } 70 | 71 | /// Applies a specific font style to the text field label/content using the theme's typography system. 72 | /// 73 | /// This only affects the font (size, weight, line height) and does not change layout or padding. 74 | /// 75 | /// - Parameters: 76 | /// - token: The text style token to apply (e.g., `.bodySmall`, `.labelLarge`). 77 | /// - weight: Optional custom font weight (e.g., `.medium`, `.semibold`). 78 | /// - Returns: A view with the font styling applied. 79 | @ViewBuilder 80 | func textFieldFont(_ token: TextStyleToken, weight: Font.Weight? = nil) -> some View { 81 | let fontToken = ThemeFontToken(token, weight: weight) 82 | self.modifier(TextFieldFontModifier(token: fontToken)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/EnvironmentKeys.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - Environment Keys 4 | 5 | /// An internal environment key used to inject the current `Theme` into the view hierarchy. 6 | /// 7 | /// This enables all SwiftThemeKit components to access theme-defined colors, typography, 8 | /// and shapes via `@Environment(\.appTheme)`. 9 | private struct AppThemeKey: EnvironmentKey { 10 | static let defaultValue: Theme = .defaultLight 11 | } 12 | 13 | /// An internal environment key used to inject `ButtonConfiguration` into the view hierarchy. 14 | /// 15 | /// This allows button styling modifiers like `.buttonVariant(_:)`, `.buttonSize(_:)`, etc., 16 | /// to update and share configuration consistently across the component. 17 | private struct ButtonConfigurationKey: EnvironmentKey { 18 | static let defaultValue: ButtonConfiguration = ButtonConfiguration() 19 | } 20 | 21 | /// An internal environment key used to inject `TextFieldConfiguration` into the view hierarchy. 22 | /// 23 | /// This enables SwiftThemeKit's text field components to access variant, shape, and size 24 | /// defined via `.textFieldVariant(_:)`, `.textFieldShape(_:)`, etc. 25 | private struct TextFieldConfigurationKey: EnvironmentKey { 26 | static let defaultValue: TextFieldConfiguration = TextFieldConfiguration() 27 | } 28 | 29 | /// An internal environment key used to pass down a single `ThemeFontToken` in cases 30 | /// where a view hierarchy needs to share a custom typography override. 31 | /// 32 | /// Used by components like `PlainTextButtonStyle` or custom label styling. 33 | private struct TypographyKey: EnvironmentKey { 34 | static let defaultValue: ThemeFontToken = ThemeFontToken(.bodyMedium) 35 | } 36 | 37 | /// Environment key for text editor configuration 38 | private struct TextEditorConfigurationKey: EnvironmentKey { 39 | static let defaultValue = TextEditorConfiguration() 40 | } 41 | 42 | // MARK: - Environment Values 43 | 44 | public extension EnvironmentValues { 45 | 46 | /// The current `Theme` instance injected into the environment. 47 | /// 48 | /// This is used by all SwiftThemeKit components to access colors, shapes, typography, and spacing. 49 | /// 50 | /// Set by wrapping your app or view in a `ThemeProvider`. 51 | var appTheme: Theme { 52 | get { self[AppThemeKey.self] } 53 | set { self[AppThemeKey.self] = newValue } 54 | } 55 | 56 | /// The current button configuration stored in the environment. 57 | /// 58 | /// Used internally by SwiftThemeKit to resolve button style modifiers such as 59 | /// `.buttonVariant(_:)`, `.buttonShape(_:)`, `.buttonSize(_:)`, etc. 60 | internal var buttonConfiguration: ButtonConfiguration { 61 | get { self[ButtonConfigurationKey.self] } 62 | set { self[ButtonConfigurationKey.self] = newValue } 63 | } 64 | 65 | /// The current text field configuration stored in the environment. 66 | /// 67 | /// Used internally to manage styling applied via modifiers like 68 | /// `.textFieldVariant(_:)`, `.textFieldSize(_:)`, `.textFieldFont(_:)`, etc. 69 | internal var textFieldConfiguration: TextFieldConfiguration { 70 | get { self[TextFieldConfigurationKey.self] } 71 | set { self[TextFieldConfigurationKey.self] = newValue } 72 | } 73 | 74 | /// The currently applied typography style override, when scoped to a local context. 75 | /// 76 | /// This is typically used for lightweight components like label-only buttons or icon captions 77 | /// that apply a custom `ThemeFontToken`. 78 | internal var typographyStyle: ThemeFontToken { 79 | get { self[TypographyKey.self] } 80 | set { self[TypographyKey.self] = newValue } 81 | } 82 | 83 | internal var textEditorConfiguration: TextEditorConfiguration { 84 | get { self[TextEditorConfigurationKey.self] } 85 | set { self[TextEditorConfigurationKey.self] = newValue } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /docs/css/highlight.css: -------------------------------------------------------------------------------- 1 | /*! Jazzy - https://github.com/realm/jazzy 2 | * Copyright Realm Inc. 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | /* Credit to https://gist.github.com/wataru420/2048287 */ 6 | .highlight .c { 7 | color: #999988; 8 | font-style: italic; } 9 | 10 | .highlight .err { 11 | color: #a61717; 12 | background-color: #e3d2d2; } 13 | 14 | .highlight .k { 15 | color: #000000; 16 | font-weight: bold; } 17 | 18 | .highlight .o { 19 | color: #000000; 20 | font-weight: bold; } 21 | 22 | .highlight .cm { 23 | color: #999988; 24 | font-style: italic; } 25 | 26 | .highlight .cp { 27 | color: #999999; 28 | font-weight: bold; } 29 | 30 | .highlight .c1 { 31 | color: #999988; 32 | font-style: italic; } 33 | 34 | .highlight .cs { 35 | color: #999999; 36 | font-weight: bold; 37 | font-style: italic; } 38 | 39 | .highlight .gd { 40 | color: #000000; 41 | background-color: #ffdddd; } 42 | 43 | .highlight .gd .x { 44 | color: #000000; 45 | background-color: #ffaaaa; } 46 | 47 | .highlight .ge { 48 | color: #000000; 49 | font-style: italic; } 50 | 51 | .highlight .gr { 52 | color: #aa0000; } 53 | 54 | .highlight .gh { 55 | color: #999999; } 56 | 57 | .highlight .gi { 58 | color: #000000; 59 | background-color: #ddffdd; } 60 | 61 | .highlight .gi .x { 62 | color: #000000; 63 | background-color: #aaffaa; } 64 | 65 | .highlight .go { 66 | color: #888888; } 67 | 68 | .highlight .gp { 69 | color: #555555; } 70 | 71 | .highlight .gs { 72 | font-weight: bold; } 73 | 74 | .highlight .gu { 75 | color: #aaaaaa; } 76 | 77 | .highlight .gt { 78 | color: #aa0000; } 79 | 80 | .highlight .kc { 81 | color: #000000; 82 | font-weight: bold; } 83 | 84 | .highlight .kd { 85 | color: #000000; 86 | font-weight: bold; } 87 | 88 | .highlight .kp { 89 | color: #000000; 90 | font-weight: bold; } 91 | 92 | .highlight .kr { 93 | color: #000000; 94 | font-weight: bold; } 95 | 96 | .highlight .kt { 97 | color: #445588; } 98 | 99 | .highlight .m { 100 | color: #009999; } 101 | 102 | .highlight .s { 103 | color: #d14; } 104 | 105 | .highlight .na { 106 | color: #008080; } 107 | 108 | .highlight .nb { 109 | color: #0086B3; } 110 | 111 | .highlight .nc { 112 | color: #445588; 113 | font-weight: bold; } 114 | 115 | .highlight .no { 116 | color: #008080; } 117 | 118 | .highlight .ni { 119 | color: #800080; } 120 | 121 | .highlight .ne { 122 | color: #990000; 123 | font-weight: bold; } 124 | 125 | .highlight .nf { 126 | color: #990000; } 127 | 128 | .highlight .nn { 129 | color: #555555; } 130 | 131 | .highlight .nt { 132 | color: #000080; } 133 | 134 | .highlight .nv { 135 | color: #008080; } 136 | 137 | .highlight .ow { 138 | color: #000000; 139 | font-weight: bold; } 140 | 141 | .highlight .w { 142 | color: #bbbbbb; } 143 | 144 | .highlight .mf { 145 | color: #009999; } 146 | 147 | .highlight .mh { 148 | color: #009999; } 149 | 150 | .highlight .mi { 151 | color: #009999; } 152 | 153 | .highlight .mo { 154 | color: #009999; } 155 | 156 | .highlight .sb { 157 | color: #d14; } 158 | 159 | .highlight .sc { 160 | color: #d14; } 161 | 162 | .highlight .sd { 163 | color: #d14; } 164 | 165 | .highlight .s2 { 166 | color: #d14; } 167 | 168 | .highlight .se { 169 | color: #d14; } 170 | 171 | .highlight .sh { 172 | color: #d14; } 173 | 174 | .highlight .si { 175 | color: #d14; } 176 | 177 | .highlight .sx { 178 | color: #d14; } 179 | 180 | .highlight .sr { 181 | color: #009926; } 182 | 183 | .highlight .s1 { 184 | color: #d14; } 185 | 186 | .highlight .ss { 187 | color: #990073; } 188 | 189 | .highlight .bp { 190 | color: #999999; } 191 | 192 | .highlight .vc { 193 | color: #008080; } 194 | 195 | .highlight .vg { 196 | color: #008080; } 197 | 198 | .highlight .vi { 199 | color: #008080; } 200 | 201 | .highlight .il { 202 | color: #009999; } 203 | -------------------------------------------------------------------------------- /docs/docsets/SwiftThemeKit.docset/Contents/Resources/Documents/css/highlight.css: -------------------------------------------------------------------------------- 1 | /*! Jazzy - https://github.com/realm/jazzy 2 | * Copyright Realm Inc. 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | /* Credit to https://gist.github.com/wataru420/2048287 */ 6 | .highlight .c { 7 | color: #999988; 8 | font-style: italic; } 9 | 10 | .highlight .err { 11 | color: #a61717; 12 | background-color: #e3d2d2; } 13 | 14 | .highlight .k { 15 | color: #000000; 16 | font-weight: bold; } 17 | 18 | .highlight .o { 19 | color: #000000; 20 | font-weight: bold; } 21 | 22 | .highlight .cm { 23 | color: #999988; 24 | font-style: italic; } 25 | 26 | .highlight .cp { 27 | color: #999999; 28 | font-weight: bold; } 29 | 30 | .highlight .c1 { 31 | color: #999988; 32 | font-style: italic; } 33 | 34 | .highlight .cs { 35 | color: #999999; 36 | font-weight: bold; 37 | font-style: italic; } 38 | 39 | .highlight .gd { 40 | color: #000000; 41 | background-color: #ffdddd; } 42 | 43 | .highlight .gd .x { 44 | color: #000000; 45 | background-color: #ffaaaa; } 46 | 47 | .highlight .ge { 48 | color: #000000; 49 | font-style: italic; } 50 | 51 | .highlight .gr { 52 | color: #aa0000; } 53 | 54 | .highlight .gh { 55 | color: #999999; } 56 | 57 | .highlight .gi { 58 | color: #000000; 59 | background-color: #ddffdd; } 60 | 61 | .highlight .gi .x { 62 | color: #000000; 63 | background-color: #aaffaa; } 64 | 65 | .highlight .go { 66 | color: #888888; } 67 | 68 | .highlight .gp { 69 | color: #555555; } 70 | 71 | .highlight .gs { 72 | font-weight: bold; } 73 | 74 | .highlight .gu { 75 | color: #aaaaaa; } 76 | 77 | .highlight .gt { 78 | color: #aa0000; } 79 | 80 | .highlight .kc { 81 | color: #000000; 82 | font-weight: bold; } 83 | 84 | .highlight .kd { 85 | color: #000000; 86 | font-weight: bold; } 87 | 88 | .highlight .kp { 89 | color: #000000; 90 | font-weight: bold; } 91 | 92 | .highlight .kr { 93 | color: #000000; 94 | font-weight: bold; } 95 | 96 | .highlight .kt { 97 | color: #445588; } 98 | 99 | .highlight .m { 100 | color: #009999; } 101 | 102 | .highlight .s { 103 | color: #d14; } 104 | 105 | .highlight .na { 106 | color: #008080; } 107 | 108 | .highlight .nb { 109 | color: #0086B3; } 110 | 111 | .highlight .nc { 112 | color: #445588; 113 | font-weight: bold; } 114 | 115 | .highlight .no { 116 | color: #008080; } 117 | 118 | .highlight .ni { 119 | color: #800080; } 120 | 121 | .highlight .ne { 122 | color: #990000; 123 | font-weight: bold; } 124 | 125 | .highlight .nf { 126 | color: #990000; } 127 | 128 | .highlight .nn { 129 | color: #555555; } 130 | 131 | .highlight .nt { 132 | color: #000080; } 133 | 134 | .highlight .nv { 135 | color: #008080; } 136 | 137 | .highlight .ow { 138 | color: #000000; 139 | font-weight: bold; } 140 | 141 | .highlight .w { 142 | color: #bbbbbb; } 143 | 144 | .highlight .mf { 145 | color: #009999; } 146 | 147 | .highlight .mh { 148 | color: #009999; } 149 | 150 | .highlight .mi { 151 | color: #009999; } 152 | 153 | .highlight .mo { 154 | color: #009999; } 155 | 156 | .highlight .sb { 157 | color: #d14; } 158 | 159 | .highlight .sc { 160 | color: #d14; } 161 | 162 | .highlight .sd { 163 | color: #d14; } 164 | 165 | .highlight .s2 { 166 | color: #d14; } 167 | 168 | .highlight .se { 169 | color: #d14; } 170 | 171 | .highlight .sh { 172 | color: #d14; } 173 | 174 | .highlight .si { 175 | color: #d14; } 176 | 177 | .highlight .sx { 178 | color: #d14; } 179 | 180 | .highlight .sr { 181 | color: #009926; } 182 | 183 | .highlight .s1 { 184 | color: #d14; } 185 | 186 | .highlight .ss { 187 | color: #990073; } 188 | 189 | .highlight .bp { 190 | color: #999999; } 191 | 192 | .highlight .vc { 193 | color: #008080; } 194 | 195 | .highlight .vg { 196 | color: #008080; } 197 | 198 | .highlight .vi { 199 | color: #008080; } 200 | 201 | .highlight .il { 202 | color: #009999; } 203 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/ThemeColors+Copy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension ThemeColors { 4 | /// Returns a new `ThemeColors` instance by copying the current one 5 | /// and applying the specified overrides. 6 | /// 7 | /// Only parameters you pass will be changed — others will retain existing values. 8 | func copy( 9 | primary: Color? = nil, 10 | onPrimary: Color? = nil, 11 | primaryContainer: Color? = nil, 12 | onPrimaryContainer: Color? = nil, 13 | 14 | secondary: Color? = nil, 15 | onSecondary: Color? = nil, 16 | secondaryContainer: Color? = nil, 17 | onSecondaryContainer: Color? = nil, 18 | 19 | tertiary: Color? = nil, 20 | onTertiary: Color? = nil, 21 | tertiaryContainer: Color? = nil, 22 | onTertiaryContainer: Color? = nil, 23 | 24 | background: Color? = nil, 25 | onBackground: Color? = nil, 26 | 27 | error: Color? = nil, 28 | onError: Color? = nil, 29 | errorContainer: Color? = nil, 30 | onErrorContainer: Color? = nil, 31 | 32 | inverseSurface: Color? = nil, 33 | inverseOnSurface: Color? = nil, 34 | inversePrimary: Color? = nil, 35 | 36 | surface: Color? = nil, 37 | onSurface: Color? = nil, 38 | surfaceVariant: Color? = nil, 39 | onSurfaceVariant: Color? = nil, 40 | 41 | surfaceDim: Color? = nil, 42 | surfaceBright: Color? = nil, 43 | surfaceContainerLowest: Color? = nil, 44 | surfaceContainerLow: Color? = nil, 45 | surfaceContainer: Color? = nil, 46 | surfaceContainerHigh: Color? = nil, 47 | surfaceContainerHighest: Color? = nil, 48 | 49 | outline: Color? = nil, 50 | outlineVariant: Color? = nil, 51 | scrim: Color? = nil, 52 | shadow: Color? = nil 53 | ) -> ThemeColors { 54 | ThemeColors( 55 | primary: primary ?? self.primary, 56 | onPrimary: onPrimary ?? self.onPrimary, 57 | primaryContainer: primaryContainer ?? self.primaryContainer, 58 | onPrimaryContainer: onPrimaryContainer ?? self.onPrimaryContainer, 59 | 60 | secondary: secondary ?? self.secondary, 61 | onSecondary: onSecondary ?? self.onSecondary, 62 | secondaryContainer: secondaryContainer ?? self.secondaryContainer, 63 | onSecondaryContainer: onSecondaryContainer ?? self.onSecondaryContainer, 64 | 65 | tertiary: tertiary ?? self.tertiary, 66 | onTertiary: onTertiary ?? self.onTertiary, 67 | tertiaryContainer: tertiaryContainer ?? self.tertiaryContainer, 68 | onTertiaryContainer: onTertiaryContainer ?? self.onTertiaryContainer, 69 | 70 | background: background ?? self.background, 71 | onBackground: onBackground ?? self.onBackground, 72 | 73 | error: error ?? self.error, 74 | onError: onError ?? self.onError, 75 | errorContainer: errorContainer ?? self.errorContainer, 76 | onErrorContainer: onErrorContainer ?? self.onErrorContainer, 77 | 78 | inverseSurface: inverseSurface ?? self.inverseSurface, 79 | inverseOnSurface: inverseOnSurface ?? self.inverseOnSurface, 80 | inversePrimary: inversePrimary ?? self.inversePrimary, 81 | 82 | surface: surface ?? self.surface, 83 | onSurface: onSurface ?? self.onSurface, 84 | surfaceVariant: surfaceVariant ?? self.surfaceVariant, 85 | onSurfaceVariant: onSurfaceVariant ?? self.onSurfaceVariant, 86 | 87 | surfaceDim: surfaceDim ?? self.surfaceDim, 88 | surfaceBright: surfaceBright ?? self.surfaceBright, 89 | surfaceContainerLowest: surfaceContainerLowest ?? self.surfaceContainerLowest, 90 | surfaceContainerLow: surfaceContainerLow ?? self.surfaceContainerLow, 91 | surfaceContainer: surfaceContainer ?? self.surfaceContainer, 92 | surfaceContainerHigh: surfaceContainerHigh ?? self.surfaceContainerHigh, 93 | surfaceContainerHighest: surfaceContainerHighest ?? self.surfaceContainerHighest, 94 | 95 | outline: outline ?? self.outline, 96 | outlineVariant: outlineVariant ?? self.outlineVariant, 97 | scrim: scrim ?? self.scrim, 98 | shadow: shadow ?? self.shadow 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Components/RadioButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A customizable radio button component that conforms to the current `Theme`. 4 | /// 5 | /// `RadioButton` supports generic `Hashable` values and automatically updates the selected value. 6 | /// You can display a custom label on either side of the button. 7 | /// 8 | /// Example: 9 | /// ```swift 10 | /// RadioButton(selection: $selectedOption, value: .optionA, labelPosition: .trailing) { 11 | /// Text("Option A") 12 | /// } 13 | /// ``` 14 | public struct RadioButton: View { 15 | @Environment(\.appTheme) private var theme 16 | 17 | /// The currently selected value bound to a parent state. 18 | @Binding var selection: T 19 | 20 | /// The value represented by this individual radio button. 21 | let value: T 22 | 23 | /// Optional label content displayed alongside the radio button. 24 | private var label: AnyView? 25 | 26 | /// Specifies the size of the radio button icon and font. 27 | private var size: RadioButtonSizeToken = .small 28 | 29 | /// Determines whether the label appears on the leading or trailing side. 30 | private var labelPosition: HorizontalPosition = .trailing 31 | 32 | /// Initializes a radio button without a label. 33 | /// 34 | /// - Parameters: 35 | /// - selection: A binding to the currently selected value. 36 | /// - value: The value this radio button represents. 37 | /// - size: The size of the radio button icon and font. 38 | /// - labelPosition: The side on which the label should appear (defaults to `.trailing`). 39 | public init(selection: Binding, 40 | value: T, 41 | size: RadioButtonSizeToken = .small, 42 | labelPosition: HorizontalPosition = .trailing) { 43 | self._selection = selection 44 | self.value = value 45 | self.label = nil 46 | self.size = size 47 | self.labelPosition = labelPosition 48 | } 49 | 50 | /// Initializes a radio button with a custom label view. 51 | /// 52 | /// - Parameters: 53 | /// - selection: A binding to the currently selected value. 54 | /// - value: The value this radio button represents. 55 | /// - size: The size of the radio button icon and font. 56 | /// - labelPosition: The side on which the label should appear (defaults to `.trailing`). 57 | /// - label: A view builder providing the label content. 58 | public init(selection: Binding, 59 | value: T, 60 | size: RadioButtonSizeToken = .small, 61 | labelPosition: HorizontalPosition = .trailing, 62 | @ViewBuilder label: @escaping () -> Label) { 63 | self._selection = selection 64 | self.value = value 65 | self.size = size 66 | self.labelPosition = labelPosition 67 | self.label = AnyView(label()) 68 | } 69 | 70 | /// The visual body of the radio button view. 71 | /// 72 | /// It displays a circle with an optional filled inner circle if selected, 73 | /// along with optional label content. 74 | public var body: some View { 75 | let iconSize = theme.radioButtonSize[size].size 76 | let spacing = theme.spacing[theme.radioButtonSize[size].labelSpacing] 77 | let strokeWidth = theme.stroke[theme.radioButtonSize[size].stroke] 78 | 79 | Button(action: { 80 | selection = value 81 | }) { 82 | HStack(spacing: spacing) { 83 | if let label, labelPosition == .leading { 84 | label 85 | Spacer() 86 | } 87 | 88 | ZStack(alignment: .center) { 89 | Circle() 90 | .strokeBorder( 91 | selection == value ? theme.colors.primary : theme.colors.outline, 92 | lineWidth: strokeWidth 93 | ) 94 | .size(iconSize) 95 | 96 | if selection == value { 97 | Circle() 98 | .fill(theme.colors.primary) 99 | .size(iconSize / 3) 100 | } 101 | } 102 | .size(iconSize) 103 | .contentShape(Rectangle()) 104 | 105 | if let label, labelPosition == .trailing { 106 | label 107 | Spacer() 108 | } 109 | } 110 | .frame(maxWidth: .infinity) 111 | } 112 | .buttonStyle(.plain) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Styles/ThemeButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A comprehensive button style that applies consistent theming across different button variants, 4 | /// shapes, sizes, and font styles. 5 | /// 6 | /// `ThemeButtonStyle` supports: 7 | /// - Filled, Tonal, Outline, Elevated, and Text button variants 8 | /// - Destructive role styling (uses error colors) 9 | /// - Dynamic background and border colors for enabled, disabled, and pressed states 10 | /// - Adaptive padding, shape, font, and shadow based on theme tokens 11 | /// 12 | /// ### Example: 13 | /// ```swift 14 | /// Button("Save") { ... } 15 | /// .buttonStyle(ThemeButtonStyle(variant: .filled, size: .large)) 16 | /// ``` 17 | /// 18 | /// Use this style in conjunction with `applyThemeButtonStyle()`, `buttonVariant()`, etc. 19 | /// for more declarative usage in your design system. 20 | /// 21 | /// - Parameters: 22 | /// - variant: The visual variant of the button (e.g., `.filled`, `.outline`, etc.). 23 | /// - size: The padding and font size configuration. 24 | /// - shape: The corner style for the button. 25 | /// - font: Optional override for the font style and weight. 26 | public struct ThemeButtonStyle: ButtonStyle { 27 | let variant: ButtonVariant 28 | let size: ButtonSize 29 | let shape: ButtonShape 30 | let font: ThemeFontToken? 31 | let backgroundColor: Color? 32 | let foregroundColor: Color? 33 | 34 | @Environment(\.appTheme) private var theme 35 | @Environment(\.isEnabled) private var isEnabled 36 | 37 | public init( 38 | variant: ButtonVariant = .filled, 39 | size: ButtonSize = .medium, 40 | shape: ButtonShape = .rounded, 41 | font: ThemeFontToken? = nil, 42 | backgroundColor: Color? = nil, 43 | foregroundColor: Color? = nil 44 | ) { 45 | self.variant = variant 46 | self.size = size 47 | self.shape = shape 48 | self.font = font 49 | self.backgroundColor = backgroundColor 50 | self.foregroundColor = foregroundColor 51 | } 52 | 53 | public func makeBody(configuration: Configuration) -> some View { 54 | let isPressed = configuration.isPressed 55 | 56 | let bgColor = variant.backgroundColor( 57 | for: theme, 58 | isPressed: isPressed, 59 | role: configuration.role, 60 | overrideColor: backgroundColor 61 | ) 62 | 63 | let borderColor = variant.borderColor( 64 | for: theme, 65 | role: configuration.role, 66 | overrideColor: variant == .outline ? backgroundColor : nil 67 | ) 68 | 69 | let fgColor = variant.foregroundColor( 70 | for: theme, 71 | role: configuration.role, 72 | overrideColor: foregroundColor 73 | ) 74 | 75 | let padding = size.paddingValues(for: theme) 76 | 77 | var shadow: ThemeShadow? = nil 78 | 79 | switch variant { 80 | case .filled, .tonal, .outline, .text: 81 | shadow = nil 82 | case .elevated: 83 | shadow = theme.shadows.md 84 | } 85 | 86 | // Adjust colors for disabled state 87 | let effectiveBgColor = isEnabled ? bgColor : theme.colors.onSurface.opacity(0.12) 88 | let effectiveFgColor = isEnabled ? fgColor : theme.colors.onSurface.opacity(0.32) 89 | 90 | return configuration.label 91 | .foregroundColor(effectiveFgColor) 92 | .if(size != .fullWidth) { $0.padding(padding) } 93 | .if(size == .fullWidth) { 94 | $0.frame(maxWidth: .infinity) 95 | .padding(.top, padding.top) 96 | .padding(.bottom, padding.bottom) 97 | } 98 | .font(getFont()) 99 | .background(effectiveBgColor) 100 | .if(variant == .outline) { 101 | $0.background( 102 | RoundedRectangle(cornerRadius: shape.radius(for: theme)) 103 | .stroke(borderColor, lineWidth: theme.stroke.sm) 104 | ) 105 | } 106 | .clipShape(shape.shape(theme: theme)) 107 | .contentShape(shape.shape(theme: theme)) 108 | .if(shadow != nil) { 109 | $0.shadow(color: shadow!.color, radius: shadow!.radius, x: shadow!.x, y: shadow!.y) 110 | } 111 | .opacity(isPressed ? 0.9 : 1.0) 112 | } 113 | 114 | /// Returns the effective font based on the theme or overrides. 115 | private func getFont() -> Font { 116 | if let font = font { 117 | if let weight = font.weight { 118 | return theme.typography[font.style].weight(weight) 119 | } else { 120 | return theme.typography[font.style] 121 | } 122 | } else { 123 | return size.font(for: theme) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Components/RadioGroup.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A vertically stacked group of radio buttons bound to a single selection. 4 | /// 5 | /// `RadioGroup` allows presenting multiple options where only one can be selected at a time. 6 | /// It supports both simple `String` labels or custom label views. 7 | /// 8 | /// ```swift 9 | /// RadioGroup(selection: $selectedOption, options: MyEnum.allCases) { option in 10 | /// Text(option.rawValue) 11 | /// } 12 | /// ``` 13 | public struct RadioGroup: View { 14 | @Environment(\.appTheme) private var theme 15 | 16 | /// The binding to the currently selected value. 17 | @Binding private var selection: T 18 | 19 | /// Indicates whether a custom label is used for the radio button 20 | private let usesCustomLabel: Bool 21 | 22 | /// The list of options to present as radio buttons. 23 | private let options: [T] 24 | 25 | /// A closure that returns a label view for each option. 26 | private let labelProvider: (T) -> AnyView 27 | 28 | /// Specifies where the label appears relative to the radio circle. 29 | private var labelPosition: HorizontalPosition = .trailing 30 | 31 | /// Specifies the size of the radio button which controls the size of the icon and the font. 32 | private var buttonSize: RadioButtonSizeToken = .small 33 | 34 | /// Creates a `RadioGroup` with string-based labels. 35 | /// 36 | /// - Parameters: 37 | /// - selection: Binding to the selected value. 38 | /// - options: Array of options to display. 39 | /// - buttonSize: The size of the radio button. Default is `small`. 40 | /// - labelPosition: Position of label relative to radio (default is `.trailing`). 41 | /// - labelProvider: Closure that returns a `String` for each option. 42 | public init( 43 | selection: Binding, 44 | options: [T], 45 | buttonSize: RadioButtonSizeToken = .small, 46 | labelPosition: HorizontalPosition = .trailing, 47 | labelProvider: @escaping (T) -> String 48 | ) { 49 | self._selection = selection 50 | self.options = options 51 | self.labelPosition = labelPosition 52 | self.buttonSize = buttonSize 53 | self.usesCustomLabel = false 54 | self.labelProvider = { 55 | AnyView( 56 | Text(labelProvider($0)) 57 | ) 58 | } 59 | } 60 | 61 | /// Creates a `RadioGroup` with custom SwiftUI label views. 62 | /// 63 | /// - Parameters: 64 | /// - selection: Binding to the selected value. 65 | /// - options: Array of options to display. 66 | /// - buttonSize: The size of the radio button. Default is `small`. 67 | /// - labelPosition: Position of label relative to radio (default is `.leading`). 68 | /// - labelProvider: View builder closure that returns a view for each option. 69 | public init( 70 | selection: Binding, 71 | options: [T], 72 | buttonSize: RadioButtonSizeToken = .small, 73 | labelPosition: HorizontalPosition = .trailing, 74 | @ViewBuilder labelProvider: @escaping (T) -> Label 75 | ) { 76 | self._selection = selection 77 | self.options = options 78 | self.buttonSize = buttonSize 79 | self.labelPosition = labelPosition 80 | self.usesCustomLabel = true 81 | self.labelProvider = { 82 | AnyView(labelProvider($0)) 83 | } 84 | } 85 | 86 | /// The view body that renders all radio buttons in a vertical layout. 87 | public var body: some View { 88 | VStack(alignment: .leading, spacing: 12) { 89 | ForEach(options, id: \.self) { option in 90 | RadioButton( 91 | selection: $selection, 92 | value: option, 93 | size: buttonSize, 94 | labelPosition: labelPosition 95 | ) { 96 | if usesCustomLabel { 97 | labelProvider(option) 98 | } else { 99 | let font = theme.typography[theme.platform.radioButtonFontStyle(for: buttonSize)] 100 | 101 | labelProvider(option) 102 | .font(font) 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | 110 | #Preview { 111 | VStack { 112 | RadioGroup(selection: .constant(.paypal), options: PaymentMethod.allCases, labelPosition: .leading) { $0.rawValue } 113 | RadioGroup(selection: .constant(.paypal), options: PaymentMethod.allCases) { 114 | Text($0.rawValue) 115 | .font(.titleLarge) 116 | } 117 | } 118 | .padding(.horizontal, 16) 119 | } 120 | 121 | enum PaymentMethod: String, CaseIterable { 122 | case card = "Credit Card" 123 | case paypal = "PayPal" 124 | case applePay = "Apple Pay" 125 | } 126 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Theme.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A complete representation of a visual theme including color, typography, spacing, radius, and shadow tokens. 4 | /// The `Theme` struct is the root context for all styling logic in a themed SwiftUI application. 5 | public struct Theme: Equatable { 6 | 7 | /// The set of color tokens used throughout the UI. 8 | /// Includes semantic roles like `primary`, `onSurface`, `background`, etc. 9 | public let colors: ThemeColors 10 | 11 | /// The full typography scale used for all text elements, including headings, body text, and labels. 12 | public let typography: ThemeTypography 13 | 14 | /// The standard spacing scale for padding, margins, and layout spacing. 15 | public let spacing: ThemeSpacing 16 | 17 | /// The radius scale used for rounded corners and clip shapes. 18 | public let radii: ThemeRadii 19 | 20 | /// The set of shadow styles representing elevation or focus states. 21 | public let shadows: ThemeShadows 22 | 23 | /// The set of stroke styles 24 | public let stroke: ThemeStroke 25 | 26 | /// The set of shapes styles used in clipShape modifiers 27 | public let shapes: ThemeShapes 28 | 29 | /// The set of buttons default configurations 30 | public let buttons: ThemeButtonDefaults 31 | 32 | /// The set of text field default configurations 33 | public let textFields: ThemeTextFieldDefaults 34 | 35 | /// The set of checkbox size styles 36 | public let checkboxSize: ThemeCheckboxSize 37 | 38 | /// The set of radio button size styles 39 | public let radioButtonSize: ThemeRadioButtonSize 40 | 41 | /// The platform on which the app is running. Default is `.current`. 42 | public let platform: ThemePlatform 43 | 44 | /// Initializes a new theme instance with custom tokens. 45 | /// 46 | /// - Parameters: 47 | /// - colors: The color token configuration. 48 | /// - typography: The typography scale. 49 | /// - spacing: The spacing scale. 50 | /// - radii: The corner radius scale. 51 | /// - shadows: The shadow styles for various elevations. 52 | /// - stroke: The stroke styles. 53 | /// - shapes: The shapes styles used for various components. 54 | /// - buttons: The buttons default values used when no modifier applied. 55 | /// - textFields: The text fields default configuration used when no modifier applied. 56 | /// - checkboxSize: The set of checkbox size styles. 57 | /// - radioButtonSize: The set of radio button size styles. 58 | /// - platform: The platform on which the app is running. Default is `.current`. 59 | public init( 60 | colors: ThemeColors, 61 | typography: ThemeTypography, 62 | spacing: ThemeSpacing, 63 | radii: ThemeRadii, 64 | shadows: ThemeShadows, 65 | stroke: ThemeStroke, 66 | shapes: ThemeShapes, 67 | buttons: ThemeButtonDefaults, 68 | textFields: ThemeTextFieldDefaults, 69 | checkboxSize: ThemeCheckboxSize, 70 | radioButtonSize: ThemeRadioButtonSize, 71 | platform: ThemePlatform = .current 72 | ) { 73 | self.colors = colors 74 | self.typography = typography 75 | self.spacing = spacing 76 | self.radii = radii 77 | self.shadows = shadows 78 | self.stroke = stroke 79 | self.shapes = shapes 80 | self.buttons = buttons 81 | self.textFields = textFields 82 | self.checkboxSize = checkboxSize 83 | self.radioButtonSize = radioButtonSize 84 | self.platform = platform 85 | } 86 | 87 | /// The default theme configuration optimized for light mode. 88 | /// Uses `defaultLight` for colors and shadows, and shared defaults for other scales. 89 | public static let defaultLight: Theme = Theme( 90 | colors: .defaultLight, 91 | typography: .defaultLight, 92 | spacing: .defaultLight, 93 | radii: .defaultLight, 94 | shadows: .defaultLight, 95 | stroke: .defaultLight, 96 | shapes: .defaultLight, 97 | buttons: .defaultLight, 98 | textFields: .defaultLight, 99 | checkboxSize: .defaultLight, 100 | radioButtonSize: .defaultLight, 101 | platform: .current 102 | ) 103 | 104 | /// The default theme configuration optimized for dark mode. 105 | /// Uses `defaultDark` for colors and shadows, and shared defaults for other scales. 106 | public static let defaultDark: Theme = Theme( 107 | colors: .defaultDark, 108 | typography: .defaultDark, 109 | spacing: .defaultDark, 110 | radii: .defaultDark, 111 | shadows: .defaultDark, 112 | stroke: .defaultDark, 113 | shapes: .defaultDark, 114 | buttons: .defaultDark, 115 | textFields: .defaultDark, 116 | checkboxSize: .defaultDark, 117 | radioButtonSize: .defaultDark, 118 | platform: .current 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Styles/ThemeTextFieldStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A custom text field style that applies theming based on a `Theme` context. 4 | /// 5 | /// This style supports Material-style variants like `filled`, `outlined`, and `underlined`, 6 | /// and adjusts typography, padding, shape, color, and focus indicators accordingly. 7 | /// 8 | /// - Parameters: 9 | /// - variant: The visual style of the field (e.g. `.filled`, `.outlined`, `.underlined`). 10 | /// - size: The field size which controls padding and font. 11 | /// - shape: The shape used to clip and stroke the background (e.g. rounded, capsule). 12 | /// - font: Optional font override using a `ThemeFontToken`. If `nil`, falls back to size-defined font. 13 | /// - isError: Whether to display the error state (e.g. red border and text). 14 | public struct ThemeTextFieldStyle: TextFieldStyle { 15 | let variant: TextFieldVariant 16 | var size: TextFieldSize = .medium 17 | let shape: TextFieldShape 18 | var font: ThemeFontToken? = nil 19 | let isError: Bool 20 | 21 | @Environment(\.appTheme) private var theme 22 | @FocusState private var isFocused 23 | @Environment(\.isEnabled) private var isEnabled 24 | @State private var fieldSize: CGSize = .zero 25 | 26 | /// Initializes a new themed text field style with customization options. 27 | public init( 28 | variant: TextFieldVariant, 29 | size: TextFieldSize, 30 | shape: TextFieldShape, 31 | font: ThemeFontToken? = nil, 32 | isError: Bool 33 | ) { 34 | self.variant = variant 35 | self.size = size 36 | self.shape = shape 37 | self.font = font 38 | self.isError = isError 39 | } 40 | 41 | /// The core method that builds the styled text field. 42 | public func _body(configuration: TextField) -> some View { 43 | let bgColor = backgroundColor() 44 | let borderColor: Color = borderColor() 45 | let foregroundColor: Color = isError ? theme.colors.error : theme.colors.onSurface 46 | 47 | return configuration 48 | .textFieldStyle(.plain) // Avoid default styling 49 | .padding(size.padding(theme: theme)) // Apply padding based on size 50 | .measure($fieldSize) // Store size to place underline dynamically 51 | .background(backgroundView(color: bgColor)) // Optional filled background 52 | .overlay(borderOverlay(color: borderColor)) // Draw border depending on variant 53 | .disabled(!isEnabled) 54 | .focused($isFocused) 55 | .foregroundStyle(foregroundColor) 56 | .foregroundColor( 57 | isEnabled 58 | ? theme.colors.onSurface 59 | : theme.colors.onSurfaceVariant.opacity(0.38) 60 | ) 61 | .font(getFont()) 62 | } 63 | 64 | /// Builds the background shape for `.filled` variant. 65 | @ViewBuilder 66 | private func backgroundView(color: Color) -> some View { 67 | switch variant { 68 | case .filled: 69 | shape.shape(theme: theme).fill(color) 70 | default: 71 | Color.clear 72 | } 73 | } 74 | 75 | /// Builds the border or underline for `outlined` and `underlined` variants. 76 | @ViewBuilder 77 | private func borderOverlay(color: Color) -> some View { 78 | switch variant { 79 | case .outlined: 80 | shape.shape(theme: theme) 81 | .strokeBorder(color, lineWidth: isFocused ? theme.stroke.sm : theme.stroke.xs) 82 | case .underlined: 83 | Rectangle() 84 | .frame(height: isFocused ? theme.stroke.sm : theme.stroke.xs) 85 | .foregroundColor(color) 86 | .padding(.top, fieldSize.height) // Push underline to bottom 87 | default: 88 | EmptyView() 89 | } 90 | } 91 | 92 | /// Computes the appropriate border color based on focus, error, and disabled states. 93 | private func borderColor() -> Color { 94 | if isError { 95 | return theme.colors.error 96 | } else if !isEnabled { 97 | return theme.colors.outline.opacity(0.12) 98 | } else if isFocused { 99 | return theme.colors.primary 100 | } else { 101 | return theme.colors.outline 102 | } 103 | } 104 | 105 | private func backgroundColor() -> Color { 106 | if isError { 107 | return theme.colors.errorContainer 108 | } else if !isEnabled { 109 | return theme.colors.surfaceContainerLow 110 | } else { 111 | return theme.colors.surface 112 | } 113 | } 114 | 115 | /// Resolves the font to use based on the theme and optional override. 116 | private func getFont() -> Font { 117 | if let font = font { 118 | if let weight = font.weight { 119 | return theme.typography[font.style].weight(weight) 120 | } else { 121 | return theme.typography[font.style] 122 | } 123 | } else { 124 | return size.font(theme: theme) 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/Checkbox/ThemeCheckboxSize.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Represents the available size tokens for a checkbox component. 4 | /// 5 | /// Use this enum to configure the size of a checkbox when applying modifiers or creating custom styles. 6 | public enum CheckboxSizeToken: String, CaseIterable, Equatable { 7 | /// A small checkbox. Optimized for compact UIs or dense layouts. 8 | case small 9 | 10 | /// A medium checkbox. Suitable for standard form controls and settings. 11 | case medium 12 | 13 | /// A large checkbox. Ideal for touch interfaces, TV platforms, or accessibility. 14 | case large 15 | } 16 | 17 | /// Defines the layout configuration for a checkbox at different size levels. 18 | /// 19 | /// `ThemeCheckboxSize` stores the configuration for small, medium, and large checkboxes, 20 | /// including their visual size, stroke width, font style, and label spacing. The actual 21 | /// values are platform-specific and retrieved from `ThemePlatform`. 22 | /// 23 | /// This struct is injected through the current theme, allowing consistent sizing across the app. 24 | public struct ThemeCheckboxSize: Equatable { 25 | 26 | /// Checkbox configuration for `.small` size. 27 | /// 28 | /// - Size: Compact box. 29 | /// - Font: Smaller label typography. 30 | /// - Label spacing: Tight spacing between box and text. 31 | let small: CheckboxConfiguration 32 | 33 | /// Checkbox configuration for `.medium` size. 34 | /// 35 | /// - Size: Standard checkbox. 36 | /// - Font: Medium body typography. 37 | /// - Label spacing: Balanced layout for most use cases. 38 | let medium: CheckboxConfiguration 39 | 40 | /// Checkbox configuration for `.large` size. 41 | /// 42 | /// - Size: Enlarged for accessibility or large screens. 43 | /// - Font: Larger, prominent typography. 44 | /// - Label spacing: Generous spacing for clarity. 45 | let large: CheckboxConfiguration 46 | 47 | /// The default checkbox sizing configuration for light themes. 48 | /// 49 | /// Sizing values are computed using `ThemePlatform` based on the current platform (iOS, macOS, etc.). 50 | static let defaultLight = ThemeCheckboxSize( 51 | small: .init( 52 | size: ThemePlatform.current.checkboxSize(for: .small), 53 | labelSpacing: ThemePlatform.current.checkboxLabelSpacing(for: .small), 54 | font: ThemePlatform.current.checkboxFontStyle(for: .small), 55 | stroke: ThemePlatform.current.checkboxStrokeWidth(for: .small) 56 | ), 57 | medium: .init( 58 | size: ThemePlatform.current.checkboxSize(for: .medium), 59 | labelSpacing: ThemePlatform.current.checkboxLabelSpacing(for: .medium), 60 | font: ThemePlatform.current.checkboxFontStyle(for: .medium), 61 | stroke: ThemePlatform.current.checkboxStrokeWidth(for: .medium) 62 | ), 63 | large: .init( 64 | size: ThemePlatform.current.checkboxSize(for: .large), 65 | labelSpacing: ThemePlatform.current.checkboxLabelSpacing(for: .large), 66 | font: ThemePlatform.current.checkboxFontStyle(for: .large), 67 | stroke: ThemePlatform.current.checkboxStrokeWidth(for: .large) 68 | ) 69 | ) 70 | 71 | /// The default checkbox sizing configuration for dark themes. 72 | /// 73 | /// The label spacing and stroke may differ slightly to improve contrast and accessibility in dark mode. 74 | static let defaultDark = ThemeCheckboxSize( 75 | small: .init( 76 | size: ThemePlatform.current.checkboxSize(for: .small), 77 | labelSpacing: ThemePlatform.current.checkboxLabelSpacing(for: .small), 78 | font: ThemePlatform.current.checkboxFontStyle(for: .small), 79 | stroke: ThemePlatform.current.checkboxStrokeWidth(for: .small) 80 | ), 81 | medium: .init( 82 | size: ThemePlatform.current.checkboxSize(for: .medium), 83 | labelSpacing: ThemePlatform.current.checkboxLabelSpacing(for: .medium), 84 | font: ThemePlatform.current.checkboxFontStyle(for: .medium), 85 | stroke: ThemePlatform.current.checkboxStrokeWidth(for: .medium) 86 | ), 87 | large: .init( 88 | size: ThemePlatform.current.checkboxSize(for: .large), 89 | labelSpacing: ThemePlatform.current.checkboxLabelSpacing(for: .large), 90 | font: ThemePlatform.current.checkboxFontStyle(for: .large), 91 | stroke: ThemePlatform.current.checkboxStrokeWidth(for: .large) 92 | ) 93 | ) 94 | 95 | /// Returns the checkbox configuration for the given size token. 96 | /// 97 | /// - Parameter token: The desired size category. 98 | /// - Returns: A `CheckboxConfiguration` containing platform-specific sizing info. 99 | subscript(_ token: CheckboxSizeToken) -> CheckboxConfiguration { 100 | switch token { 101 | case .small: return self.small 102 | case .medium: return self.medium 103 | case .large: return self.large 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Extensions/View+Button.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension View { 4 | /// Applies the full SwiftThemeKit button style to the current view using the theme's button system. 5 | /// 6 | /// This modifier is **optional** but recommended when you want to use the default layout, padding, 7 | /// background, and interactive states provided by SwiftThemeKit. 8 | /// 9 | /// You can also apply individual overrides such as `buttonSize`, `buttonVariant`, or `buttonShape` 10 | /// without needing to use this modifier, especially if you are using your own custom button style. 11 | /// 12 | /// - Returns: A view with the themed button styling applied. 13 | @ViewBuilder 14 | func applyThemeButtonStyle() -> some View { 15 | self 16 | .modifier(ThemedButtonModifier()) 17 | .modifier(ApplyFinalButtonStyleModifier()) 18 | } 19 | 20 | /// Overrides the button variant (e.g. `.filled`, `.outline`, `.text`) for this specific button. 21 | /// 22 | /// This is stored in the view environment and affects how `applyThemeButtonStyle()` renders the background, 23 | /// border, and interaction behavior. 24 | /// 25 | /// - Parameter variant: The desired button style variant. 26 | /// - Returns: A view with the custom variant applied to the button context. 27 | @ViewBuilder 28 | func buttonVariant(_ variant: ButtonVariant) -> some View { 29 | self.modifier(ButtonVariantModifier(token: variant)) 30 | } 31 | 32 | /// Overrides the button size (e.g. `.small`, `.medium`, `.large`, `.fullWidth`) for this button. 33 | /// 34 | /// The size affects height, padding, and possibly font size, based on the current theme configuration. 35 | /// 36 | /// - Parameter size: The size token to apply. 37 | /// - Returns: A view with the custom size stored in the environment. 38 | @ViewBuilder 39 | func buttonSize(_ size: ButtonSize) -> some View { 40 | self.modifier(ButtonSizeModifier(token: size)) 41 | } 42 | 43 | /// Overrides the shape used for the button (e.g. `.capsule`, `.rounded`, `.rectangle`). 44 | /// 45 | /// This affects how the button is clipped and how the background and stroke are drawn. 46 | /// 47 | /// - Parameter shape: The shape token to apply to this button. 48 | /// - Returns: A view with the shape override applied. 49 | @ViewBuilder 50 | func buttonShape(_ shape: ButtonShape) -> some View { 51 | self.modifier(ButtonShapeModifier(token: shape)) 52 | } 53 | 54 | /// Applies a minimal, text-only button style without background, padding, or borders. 55 | /// 56 | /// Best for inline links or toolbar actions where no container styling is needed. 57 | /// Can still use theme typography tokens for consistent font sizing. 58 | /// 59 | /// - Parameters: 60 | /// - style: The typography style to apply (default is `.buttonText`). 61 | /// - weight: Optional font weight override. 62 | /// - Returns: A button with stripped-down appearance and themed text. 63 | @ViewBuilder 64 | func plainTextButton(_ style: TextStyleToken = .buttonText, weight: Font.Weight? = nil) -> some View { 65 | self.buttonStyle(PlainTextButtonStyle(token: ThemeFontToken(style, weight: weight))) 66 | } 67 | 68 | /// Overrides only the font style of the button’s label while preserving all other styles (padding, background, etc.). 69 | /// 70 | /// Useful when you want to adjust the font of a button without affecting its variant or layout. 71 | /// 72 | /// - Parameters: 73 | /// - token: The typography token to apply (e.g. `.labelLarge`, `.bodyMedium`). 74 | /// - weight: Optional font weight override. 75 | /// - Returns: A view with the custom font style applied to the label. 76 | @ViewBuilder 77 | func buttonLabelStyle(_ token: TextStyleToken, weight: Font.Weight? = nil) -> some View { 78 | let fontToken = ThemeFontToken(token, weight: weight) 79 | self.modifier(ButtonFontModifier(token: fontToken)) 80 | } 81 | 82 | /// Overrides the background color of the button's label content. 83 | /// 84 | /// This can be used to apply a custom background color instead of the theme-defined one 85 | /// (e.g., for special states or brand-specific overrides). 86 | /// 87 | /// - Parameter color: The color to use as the label's background. 88 | /// - Returns: A view with the background color applied to the button label. 89 | func buttonBackgroundColor(_ color: Color) -> some View { 90 | self.modifier(ButtonBackgroundModifier(color: color)) 91 | } 92 | 93 | /// Overrides the foreground (text/icon) color of the button's label content. 94 | /// 95 | /// This affects the text/icon rendering but does not override other theme styles. 96 | /// 97 | /// - Parameter color: The color to use as the foreground. 98 | /// - Returns: A view with the foreground color applied to the button label. 99 | func buttonForegroundColor(_ color: Color) -> some View { 100 | self.modifier(ButtonForegroundModifier(color: color)) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/RadioButton/ThemeRadioButtonSize.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Represents the available size tokens for a radio button component. 4 | /// 5 | /// Use this enum to configure the size of a radio button when applying modifiers or creating custom styles. 6 | public enum RadioButtonSizeToken: String, CaseIterable, Equatable { 7 | /// A small radio button. Optimized for compact UIs or dense layouts. 8 | case small 9 | 10 | /// A medium radio button. Suitable for standard form controls and settings. 11 | case medium 12 | 13 | /// A large radio button. Ideal for touch interfaces, TV platforms, or accessibility. 14 | case large 15 | } 16 | 17 | /// Defines the layout configuration for a radio button at different size levels. 18 | /// 19 | /// `ThemeRadioButtonSize` stores the configuration for small, medium, and large radio buttons, 20 | /// including their visual size, stroke width, font style, and label spacing. The actual 21 | /// values are platform-specific and retrieved from `ThemePlatform`. 22 | /// 23 | /// This struct is injected through the current theme, allowing consistent sizing across the app. 24 | public struct ThemeRadioButtonSize: Equatable { 25 | 26 | /// Radio button configuration for `.small` size. 27 | /// 28 | /// - Size: Compact box. 29 | /// - Font: Smaller label typography. 30 | /// - Label spacing: Tight spacing between box and text. 31 | let small: RadioButtonConfiguration 32 | 33 | /// Radio button configuration for `.medium` size. 34 | /// 35 | /// - Size: Standard radio button. 36 | /// - Font: Medium body typography. 37 | /// - Label spacing: Balanced layout for most use cases. 38 | let medium: RadioButtonConfiguration 39 | 40 | /// Radio button configuration for `.large` size. 41 | /// 42 | /// - Size: Enlarged for accessibility or large screens. 43 | /// - Font: Larger, prominent typography. 44 | /// - Label spacing: Generous spacing for clarity. 45 | let large: RadioButtonConfiguration 46 | 47 | /// The default radio button sizing configuration for light themes. 48 | /// 49 | /// Sizing values are computed using `ThemePlatform` based on the current platform (iOS, macOS, etc.). 50 | static let defaultLight = ThemeRadioButtonSize( 51 | small: .init( 52 | size: ThemePlatform.current.radioButtonSize(for: .small), 53 | labelSpacing: ThemePlatform.current.radioButtonLabelSpacing(for: .small), 54 | font: ThemePlatform.current.radioButtonFontStyle(for: .small), 55 | stroke: ThemePlatform.current.radioButtonStrokeWidth(for: .small) 56 | ), 57 | medium: .init( 58 | size: ThemePlatform.current.radioButtonSize(for: .medium), 59 | labelSpacing: ThemePlatform.current.radioButtonLabelSpacing(for: .medium), 60 | font: ThemePlatform.current.radioButtonFontStyle(for: .medium), 61 | stroke: ThemePlatform.current.radioButtonStrokeWidth(for: .medium) 62 | ), 63 | large: .init( 64 | size: ThemePlatform.current.radioButtonSize(for: .large), 65 | labelSpacing: ThemePlatform.current.radioButtonLabelSpacing(for: .large), 66 | font: ThemePlatform.current.radioButtonFontStyle(for: .large), 67 | stroke: ThemePlatform.current.radioButtonStrokeWidth(for: .large) 68 | ) 69 | ) 70 | 71 | /// The default radio button sizing configuration for dark themes. 72 | /// 73 | /// The label spacing and stroke may differ slightly to improve contrast and accessibility in dark mode. 74 | static let defaultDark = ThemeRadioButtonSize( 75 | small: .init( 76 | size: ThemePlatform.current.radioButtonSize(for: .small), 77 | labelSpacing: ThemePlatform.current.radioButtonLabelSpacing(for: .small), 78 | font: ThemePlatform.current.radioButtonFontStyle(for: .small), 79 | stroke: ThemePlatform.current.radioButtonStrokeWidth(for: .small) 80 | ), 81 | medium: .init( 82 | size: ThemePlatform.current.radioButtonSize(for: .medium), 83 | labelSpacing: ThemePlatform.current.radioButtonLabelSpacing(for: .medium), 84 | font: ThemePlatform.current.radioButtonFontStyle(for: .medium), 85 | stroke: ThemePlatform.current.radioButtonStrokeWidth(for: .medium) 86 | ), 87 | large: .init( 88 | size: ThemePlatform.current.radioButtonSize(for: .large), 89 | labelSpacing: ThemePlatform.current.radioButtonLabelSpacing(for: .large), 90 | font: ThemePlatform.current.radioButtonFontStyle(for: .large), 91 | stroke: ThemePlatform.current.radioButtonStrokeWidth(for: .large) 92 | ) 93 | ) 94 | 95 | /// Returns the radio button configuration for the given size token. 96 | /// 97 | /// - Parameter token: The desired size category. 98 | /// - Returns: A `RadioButtonConfiguration` containing platform-specific sizing info. 99 | subscript(_ token: RadioButtonSizeToken) -> RadioButtonConfiguration { 100 | switch token { 101 | case .small: return self.small 102 | case .medium: return self.medium 103 | case .large: return self.large 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/Checkbox/ThemePlatform+CheckboxConfiguration.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension ThemePlatform { 4 | 5 | /// Returns the base checkbox size (in points) depending on the current platform. 6 | /// 7 | /// This value acts as a scaling anchor for all checkbox sizing in SwiftThemeKit. 8 | /// 9 | /// - macOS: 18pt 10 | /// - iOS/watchOS: 22pt 11 | /// - tvOS: 30pt 12 | /// 13 | /// - Returns: The platform-specific base size for checkboxes. 14 | func baseCheckboxSize() -> CGFloat { 15 | switch self { 16 | case .macOS: return 18 17 | case .iOS, .watchOS: return 22 18 | case .tvOS: return 30 19 | } 20 | } 21 | 22 | /// Computes the final checkbox size based on the size token and platform. 23 | /// 24 | /// The returned size is scaled proportionally from the base size per platform. 25 | /// 26 | /// - Parameter sizeCategory: The desired size token (`.small`, `.medium`, `.large`). 27 | /// - Returns: The actual rendered size of the checkbox. 28 | func checkboxSize(for sizeCategory: CheckboxSizeToken) -> CGFloat { 29 | let base = baseCheckboxSize() 30 | switch sizeCategory { 31 | case .small: 32 | return base 33 | case .medium: 34 | return base * 1.15 35 | case .large: 36 | return base * 1.15 * 1.15 37 | } 38 | } 39 | 40 | /// Resolves the appropriate typography token for the checkbox label based on size and platform. 41 | /// 42 | /// This ensures font scaling matches platform expectations for readability and visual balance. 43 | /// 44 | /// - Parameter sizeCategory: The selected size token. 45 | /// - Returns: A `TextStyleToken` representing the font style to apply to the label. 46 | func checkboxFontStyle(for sizeCategory: CheckboxSizeToken) -> TextStyleToken { 47 | switch self { 48 | case .macOS: 49 | switch sizeCategory { 50 | case .small: 51 | return .labelMedium // ~12pt 52 | case .medium: 53 | return .bodySmall // ~14pt 54 | case .large: 55 | return .bodyMedium // ~16pt 56 | } 57 | 58 | case .iOS, .watchOS: 59 | switch sizeCategory { 60 | case .small: 61 | return .bodySmall // ~14pt 62 | case .medium: 63 | return .bodyMedium // ~16pt 64 | case .large: 65 | return .headlineSmall // ~18pt 66 | } 67 | 68 | case .tvOS: 69 | switch sizeCategory { 70 | case .small: 71 | return .bodyMedium // ~16pt 72 | case .medium: 73 | return .headlineSmall // ~18pt 74 | case .large: 75 | return .headlineMedium // ~20pt 76 | } 77 | } 78 | } 79 | 80 | /// Returns the spacing between the checkbox and its label, scaled for platform and size. 81 | /// 82 | /// This ensures consistent spacing across devices while allowing for more room 83 | /// on touch interfaces or large screen layouts like tvOS. 84 | /// 85 | /// - Parameter sizeCategory: The checkbox size token. 86 | /// - Returns: A `SpacingToken` representing horizontal spacing between checkbox and label. 87 | func checkboxLabelSpacing(for sizeCategory: CheckboxSizeToken) -> SpacingToken { 88 | switch self { 89 | case .macOS: 90 | switch sizeCategory { 91 | case .small: return .xs // ~4pt 92 | case .medium: return .sm // ~8pt 93 | case .large: return .sm // ~8pt (macOS keeps spacing tight) 94 | } 95 | 96 | case .iOS, .watchOS: 97 | switch sizeCategory { 98 | case .small: return .sm // ~8pt 99 | case .medium: return .md // ~16pt 100 | case .large: return .md // ~16pt 101 | } 102 | 103 | case .tvOS: 104 | switch sizeCategory { 105 | case .small: return .md // ~16pt 106 | case .medium: return .lg // ~32pt 107 | case .large: return .lg // ~32pt 108 | } 109 | } 110 | } 111 | 112 | /// Returns the stroke width to use for the checkbox border depending on size and platform. 113 | /// 114 | /// Larger checkboxes get thicker strokes, and platform styles vary to match system expectations. 115 | /// 116 | /// - Parameter sizeCategory: The selected size token. 117 | /// - Returns: A `StrokeToken` indicating the appropriate border thickness. 118 | func checkboxStrokeWidth(for sizeCategory: CheckboxSizeToken) -> StrokeToken { 119 | switch self { 120 | case .macOS: 121 | switch sizeCategory { 122 | case .small: 123 | return .xs // ~1pt (very thin) 124 | case .medium, .large: 125 | return .sm // ~2pt 126 | } 127 | 128 | case .iOS, .watchOS: 129 | switch sizeCategory { 130 | case .small: 131 | return .sm // ~2pt 132 | case .medium, .large: 133 | return .md // ~4pt 134 | } 135 | 136 | case .tvOS: 137 | switch sizeCategory { 138 | case .small: 139 | return .sm // ~2pt 140 | case .medium, .large: 141 | return .md // ~4pt 142 | } 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Sources/SwiftThemeKit/Tokens/RadioButton/ThemePlatform+RadioButtonConfiguration.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension ThemePlatform { 4 | /// Returns the base checkbox size (in points) depending on the current platform. 5 | /// 6 | /// This value acts as a scaling anchor for all checkbox sizing in SwiftThemeKit. 7 | /// 8 | /// - macOS: 18pt 9 | /// - iOS/watchOS: 22pt 10 | /// - tvOS: 30pt 11 | /// 12 | /// - Returns: The platform-specific base size for checkboxes. 13 | func baseRadioButtonSize() -> CGFloat { 14 | switch self { 15 | case .macOS: return 18 16 | case .iOS, .watchOS: return 22 17 | case .tvOS: return 30 18 | } 19 | } 20 | 21 | /// Computes the final checkbox size based on the size token and platform. 22 | /// 23 | /// The returned size is scaled proportionally from the base size per platform. 24 | /// 25 | /// - Parameter sizeCategory: The desired size token (`.small`, `.medium`, `.large`). 26 | /// - Returns: The actual rendered size of the checkbox. 27 | func radioButtonSize(for sizeCategory: RadioButtonSizeToken) -> CGFloat { 28 | let base = baseRadioButtonSize() 29 | switch sizeCategory { 30 | case .small: 31 | return base 32 | case .medium: 33 | return base * 1.15 34 | case .large: 35 | return base * 1.15 * 1.15 36 | } 37 | } 38 | 39 | /// Resolves the appropriate typography token for the checkbox label based on size and platform. 40 | /// 41 | /// This ensures font scaling matches platform expectations for readability and visual balance. 42 | /// 43 | /// - Parameter sizeCategory: The selected size token. 44 | /// - Returns: A `TextStyleToken` representing the font style to apply to the label. 45 | func radioButtonFontStyle(for sizeCategory: RadioButtonSizeToken) -> TextStyleToken { 46 | switch self { 47 | case .macOS: 48 | switch sizeCategory { 49 | case .small: 50 | return .labelMedium // ~12pt 51 | case .medium: 52 | return .bodySmall // ~14pt 53 | case .large: 54 | return .bodyMedium // ~16pt 55 | } 56 | 57 | case .iOS, .watchOS: 58 | switch sizeCategory { 59 | case .small: 60 | return .bodySmall // ~14pt 61 | case .medium: 62 | return .bodyMedium // ~16pt 63 | case .large: 64 | return .headlineSmall // ~18pt 65 | } 66 | 67 | case .tvOS: 68 | switch sizeCategory { 69 | case .small: 70 | return .bodyMedium // ~16pt 71 | case .medium: 72 | return .headlineSmall // ~18pt 73 | case .large: 74 | return .headlineMedium // ~20pt 75 | } 76 | } 77 | } 78 | 79 | /// Returns the spacing between the checkbox and its label, scaled for platform and size. 80 | /// 81 | /// This ensures consistent spacing across devices while allowing for more room 82 | /// on touch interfaces or large screen layouts like tvOS. 83 | /// 84 | /// - Parameter sizeCategory: The checkbox size token. 85 | /// - Returns: A `SpacingToken` representing horizontal spacing between checkbox and label. 86 | func radioButtonLabelSpacing(for sizeCategory: RadioButtonSizeToken) -> SpacingToken { 87 | switch self { 88 | case .macOS: 89 | switch sizeCategory { 90 | case .small: return .xs // ~4pt 91 | case .medium: return .sm // ~8pt 92 | case .large: return .sm // ~8pt (macOS keeps spacing tight) 93 | } 94 | 95 | case .iOS, .watchOS: 96 | switch sizeCategory { 97 | case .small: return .sm // ~8pt 98 | case .medium: return .md // ~16pt 99 | case .large: return .md // ~16pt 100 | } 101 | 102 | case .tvOS: 103 | switch sizeCategory { 104 | case .small: return .md // ~16pt 105 | case .medium: return .lg // ~32pt 106 | case .large: return .lg // ~32pt 107 | } 108 | } 109 | } 110 | 111 | /// Returns the stroke width to use for the radio button border depending on size and platform. 112 | /// 113 | /// Larger checkboxes get thicker strokes, and platform styles vary to match system expectations. 114 | /// 115 | /// - Parameter sizeCategory: The selected size token. 116 | /// - Returns: A `StrokeToken` indicating the appropriate border thickness. 117 | func radioButtonStrokeWidth(for sizeCategory: RadioButtonSizeToken) -> StrokeToken { 118 | switch self { 119 | case .macOS: 120 | switch sizeCategory { 121 | case .small: 122 | return .xs // ~1pt (very thin) 123 | case .medium, .large: 124 | return .sm // ~2pt 125 | } 126 | 127 | case .iOS, .watchOS: 128 | switch sizeCategory { 129 | case .small: 130 | return .sm // ~2pt 131 | case .medium, .large: 132 | return .md // ~4pt 133 | } 134 | 135 | case .tvOS: 136 | switch sizeCategory { 137 | case .small: 138 | return .sm // ~2pt 139 | case .medium, .large: 140 | return .md // ~4pt 141 | } 142 | } 143 | } 144 | } 145 | --------------------------------------------------------------------------------