: 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 |
--------------------------------------------------------------------------------