diff --git a/Package.swift b/Package.swift index 160bbe6..a6ce29f 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( ] ), .target( - name: "SlotsExamples", + name: "SlotExamples", dependencies: ["Slots"] ), .testTarget( diff --git a/Sources/SlotExamples/ActionButton.swift b/Sources/SlotExamples/ActionButton.swift new file mode 100644 index 0000000..e9daee8 --- /dev/null +++ b/Sources/SlotExamples/ActionButton.swift @@ -0,0 +1,18 @@ +import Slots +import SwiftUI + +@Slots public struct ActionButton: View { + var action: () -> Void + @Slot(.text.unlabeled) var label: Label + + public var body: some View { + Button(action: action) { + label + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Capsule().fill(.tint)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + } +} diff --git a/Sources/SlotExamples/Banner.swift b/Sources/SlotExamples/Banner.swift new file mode 100644 index 0000000..560e000 --- /dev/null +++ b/Sources/SlotExamples/Banner.swift @@ -0,0 +1,41 @@ +import Slots +import SwiftUI + +public enum BannerStyle { + case info, warning, error +} + +@Slots public struct Banner: View { + @Slot(.text) var message: Message + var style: BannerStyle + + public var body: some View { + HStack { + image + message + Spacer() + } + .padding() + .background(backgroundColor.opacity(0.15)) + .cornerRadius(8) + } + + private var image: some View { + let name: String = + switch style { + case .info: "info.circle.fill" + case .warning: "exclamationmark.triangle.fill" + case .error: "xmark.octagon.fill" + } + return Image(systemName: name) + .foregroundStyle(backgroundColor) + } + + private var backgroundColor: Color { + switch style { + case .info: .blue + case .warning: .orange + case .error: .red + } + } +} diff --git a/Sources/SlotExamples/Card.swift b/Sources/SlotExamples/Card.swift new file mode 100644 index 0000000..56368ed --- /dev/null +++ b/Sources/SlotExamples/Card.swift @@ -0,0 +1,26 @@ +import Slots +import SwiftUI + +@Slots public struct Card: View { + @Slot(.text) var header: Header + @Slot(.systemImage) var media: Media? + @Slot(.text) var body_: Body? + var footer: Footer? + + public var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + .font(.headline) + if let media { media } + if let body_ { + body_ + .font(.body) + .foregroundStyle(.secondary) + } + if let footer { footer } + } + .padding() + .background(RoundedRectangle(cornerRadius: 12).fill(.background)) + .shadow(radius: 2) + } +} diff --git a/Sources/SlotExamples/Chip.swift b/Sources/SlotExamples/Chip.swift new file mode 100644 index 0000000..8bda6e4 --- /dev/null +++ b/Sources/SlotExamples/Chip.swift @@ -0,0 +1,19 @@ +import Slots +import SwiftUI + +@Slots public struct Chip: View { + @Slot(.systemImage) var icon: Icon? + @Slot(.text) var label: Label + var accessory: Accessory + + public var body: some View { + HStack { + if let icon { icon } + label + .font(.caption.weight(.medium)) + accessory + } + .padding() + .background(Capsule().fill(.quaternary)) + } +} diff --git a/Sources/SlotExamples/EmptyState.swift b/Sources/SlotExamples/EmptyState.swift new file mode 100644 index 0000000..7fdb21e --- /dev/null +++ b/Sources/SlotExamples/EmptyState.swift @@ -0,0 +1,23 @@ +import Slots +import SwiftUI + +@Slots public struct EmptyState: View { + @Slot(.systemImage) var icon: Icon? + @Slot(.text) var title: Title + var action: Action? + + public var body: some View { + VStack(spacing: 16) { + if let icon { + icon + .font(.largeTitle) + .foregroundStyle(.secondary) + } + title + .font(.title3.weight(.medium)) + if let action { action } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} diff --git a/Sources/SlotExamples/ListRow.swift b/Sources/SlotExamples/ListRow.swift new file mode 100644 index 0000000..bb2d8d0 --- /dev/null +++ b/Sources/SlotExamples/ListRow.swift @@ -0,0 +1,21 @@ +import Slots +import SwiftUI + +@Slots public struct ListRow: View { + @Slot(.systemImage) var leading: Leading? + @Slot(.text) var content: Content + @Slot(.text) var trailing: Trailing? + + public var body: some View { + HStack { + if let leading { leading } + content + Spacer() + if let trailing { + trailing + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} diff --git a/Sources/SlotExamples/Previews.swift b/Sources/SlotExamples/Previews.swift new file mode 100644 index 0000000..7ea8dcd --- /dev/null +++ b/Sources/SlotExamples/Previews.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct Examples_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + VStack(spacing: 24) { + // Chip examples + Chip(label: "Default", accessory: { EmptyView() }) + Chip(iconSystemName: "star.fill", label: "Featured", accessory: { EmptyView() }) + + // Banner examples + Banner(message: "Sync complete.", style: .info) + Banner(message: "Storage nearly full.", style: .warning) + Banner(message: "Upload failed.", style: .error) + + // ListRow examples + ListRow(content: "Wi-Fi", trailing: "Connected") + ListRow(leadingSystemName: "wifi", content: "Wi-Fi", trailing: "Connected") + + // Card examples + Card(header: "Welcome Back", body_: "Pick up where you left off.") + Card(header: "Photo", mediaSystemName: "photo", body_: "A landscape shot.") + + // EmptyState examples + EmptyState(iconSystemName: "tray", title: "No Messages") + EmptyState( + title: "Nothing Here", + action: { + Button("Refresh") {} + }) + + // ActionButton examples — uses .unlabeled so no label: prefix + ActionButton("Save", action: {}) + ActionButton(action: {}) { Text("Custom Label").bold() } + + // TaskRow examples — uses custom .priority slot option + TaskRow(title: "Buy groceries", badge: .high) + TaskRow(title: "Read article", badge: .low) + TaskRow(title: "No priority") + TaskRow(title: "Custom badge") { + Image(systemName: "star.fill").foregroundStyle(.yellow) + } + + // ToolbarRow examples + ToolbarRow(title: "Inbox") + ToolbarRow( + title: "Details", + leading: { + Button(action: {}) { Image(systemName: "chevron.left") } + }, + trailing: { + Button(action: {}) { Image(systemName: "ellipsis") } + }) + } + .padding() + } + } +} diff --git a/Sources/SlotExamples/Priority.swift b/Sources/SlotExamples/Priority.swift new file mode 100644 index 0000000..9ce641d --- /dev/null +++ b/Sources/SlotExamples/Priority.swift @@ -0,0 +1,56 @@ +import Slots +import SwiftUI + +// MARK: - Custom type and view + +public enum Priority: String, Sendable { + case low, medium, high +} + +public struct PriorityBadge: View { + let priority: Priority + + public var body: some View { + Text(priority.rawValue.capitalized) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15)) + .foregroundStyle(color) + .clipShape(Capsule()) + } + + private var color: Color { + switch priority { + case .low: .green + case .medium: .orange + case .high: .red + } + } +} + +// MARK: - Resolver: maps Priority → PriorityBadge for use as a slot option + +public enum PriorityResolver: SlotResolver { + public typealias Input = Priority + public typealias Output = PriorityBadge + public static func makeView(_ input: Priority) -> PriorityBadge { + PriorityBadge(priority: input) + } +} + +// MARK: - Component using the custom option + +@Slots public struct TaskRow: View { + @Slot(.text) var title: Title + @Slot(.custom(PriorityResolver.self)) var badge: Badge? + + public var body: some View { + HStack { + title + Spacer() + if let badge { badge } + } + .padding(.vertical, 4) + } +} diff --git a/Sources/SlotExamples/ToolbarRow.swift b/Sources/SlotExamples/ToolbarRow.swift new file mode 100644 index 0000000..ec52414 --- /dev/null +++ b/Sources/SlotExamples/ToolbarRow.swift @@ -0,0 +1,20 @@ +import Slots +import SwiftUI + +@Slots public struct ToolbarRow: View { + var leading: Leading? + @Slot(.text) var title: Title + var trailing: Trailing? + + public var body: some View { + HStack { + if let leading { leading } + Spacer() + title.font(.headline) + Spacer() + if let trailing { trailing } + } + .padding(.horizontal) + .padding(.vertical, 8) + } +} diff --git a/Sources/SlotMacros/SlotMacro.swift b/Sources/SlotMacros/SlotMacro.swift index 376ebd9..388d9c3 100644 --- a/Sources/SlotMacros/SlotMacro.swift +++ b/Sources/SlotMacros/SlotMacro.swift @@ -261,6 +261,8 @@ private struct SlotDescriptor { let isOptional: Bool let hasText: Bool let hasSystemImage: Bool + let isUnlabeled: Bool + let customOptions: [CustomSlotOption] let declarationIndex: Int } @@ -272,6 +274,7 @@ private enum SlotMode: Equatable { case string // fix to Text, String param, @_disfavoredOverload case systemImage // fix to Image, {name}SystemName: String param case empty // fix to Never, parameter omitted (stores nil) + case custom(String) // fix to resolver output, resolver input param (stores resolver type name) } // MARK: - Init spec (one init inside an extension) @@ -327,11 +330,14 @@ private func collectSlots( .wrappedType.as(IdentifierTypeSyntax.self)?.name.text, genericNames.contains(inner) { - let options = slotAttr.map { parseSlotOptions(from: $0) } ?? ParsedOptions() + let options = slotAttr.map { parseSlotOptions(from: $0) } ?? SlotOptionsResult() return SlotDescriptor( - name: propertyName, genericParam: inner, isOptional: true, hasText: options.contains(.text), - hasSystemImage: options.contains(.systemImage), declarationIndex: memberIndex) + name: propertyName, genericParam: inner, isOptional: true, + hasText: options.parsed.contains(.text), + hasSystemImage: options.parsed.contains(.systemImage), + isUnlabeled: options.parsed.contains(.unlabeled), + customOptions: options.customOptions, declarationIndex: memberIndex) } // `Icon` — slot only if @Slot annotated @@ -342,8 +348,11 @@ private func collectSlots( let options = parseSlotOptions(from: slotAttr!) return SlotDescriptor( - name: propertyName, genericParam: name, isOptional: false, hasText: options.contains(.text), - hasSystemImage: options.contains(.systemImage), declarationIndex: memberIndex) + name: propertyName, genericParam: name, isOptional: false, + hasText: options.parsed.contains(.text), + hasSystemImage: options.parsed.contains(.systemImage), + isUnlabeled: options.parsed.contains(.unlabeled), + customOptions: options.customOptions, declarationIndex: memberIndex) } // @Slot on a non-generic type is an error @@ -362,21 +371,76 @@ private struct ParsedOptions: OptionSet { let rawValue: Int static let text = ParsedOptions(rawValue: 1 << 0) static let systemImage = ParsedOptions(rawValue: 1 << 1) + static let unlabeled = ParsedOptions(rawValue: 1 << 2) } -private func parseSlotOptions(from attr: AttributeSyntax) -> ParsedOptions { - var result = ParsedOptions() +/// A custom slot option parsed from `@Slot(.custom(Resolver.self))`. +private struct CustomSlotOption: Equatable { + let resolverType: String + let isUnlabeled: Bool +} + +private struct SlotOptionsResult { + var parsed: ParsedOptions = ParsedOptions() + var customOptions: [CustomSlotOption] = [] +} + +private func parseSlotOptions(from attr: AttributeSyntax) -> SlotOptionsResult { + var result = SlotOptionsResult() guard case .argumentList(let args) = attr.arguments else { return result } for arg in args { - switch arg.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text { - case "text": result.insert(.text) - case "systemImage": result.insert(.systemImage) - default: break + let expr = arg.expression + + // Check for chained access like .text.unlabeled or .custom(R.self).unlabeled + if let memberAccess = expr.as(MemberAccessExprSyntax.self), + memberAccess.declName.baseName.text == "unlabeled" + { + if let base = memberAccess.base?.as(MemberAccessExprSyntax.self), + base.declName.baseName.text == "text" + { + result.parsed.insert(.text) + result.parsed.insert(.unlabeled) + } else if let call = memberAccess.base?.as(FunctionCallExprSyntax.self), + let resolverType = parseCustomCall(call) + { + result.customOptions.append(CustomSlotOption(resolverType: resolverType, isUnlabeled: true)) + } + continue + } + + // Simple member access: .text, .systemImage + if let memberAccess = expr.as(MemberAccessExprSyntax.self) { + switch memberAccess.declName.baseName.text { + case "text": result.parsed.insert(.text) + case "systemImage": result.parsed.insert(.systemImage) + default: break + } + continue + } + + // Function call: .custom(Resolver.self) + if let call = expr.as(FunctionCallExprSyntax.self), + let resolverType = parseCustomCall(call) + { + result.customOptions.append(CustomSlotOption(resolverType: resolverType, isUnlabeled: false)) } } return result } +/// Parse `.custom(ResolverType.self)` and return the resolver type name. +private func parseCustomCall(_ call: FunctionCallExprSyntax) -> String? { + guard + let callee = call.calledExpression.as(MemberAccessExprSyntax.self), + callee.declName.baseName.text == "custom", + let firstArg = call.arguments.first, + let metatype = firstArg.expression.as(MemberAccessExprSyntax.self), + metatype.declName.baseName.text == "self", + let typeExpr = metatype.base + else { return nil } + return typeExpr.trimmedDescription +} + // MARK: - Init count limit private let maxInitCount = 512 @@ -386,6 +450,7 @@ private func initCombinationCount(for slots: [SlotDescriptor]) -> Int { var modes = 1 // generic if slot.hasText { modes += 2 } // text + string if slot.hasSystemImage { modes += 1 } + modes += slot.customOptions.count if slot.isOptional { modes += 1 } // empty return count * modes } @@ -401,6 +466,9 @@ private func allCombinations(for slots: [SlotDescriptor]) -> [[SlotMode]] { modes.append(.string) } if slot.hasSystemImage { modes.append(.systemImage) } + for custom in slot.customOptions { + modes.append(.custom(custom.resolverType)) + } if slot.isOptional { modes.append(.empty) } guard !combos.isEmpty else { return modes.map { [$0] } } @@ -446,10 +514,11 @@ private func extensionGroups( )) case .text: constraints.append("\(slot.genericParam) == Text") + let labelPrefix = slot.isUnlabeled ? "_ " : "" if slot.isOptional { entries.append( ParamEntry( - param: "\(slot.name): LocalizedStringKey?", + param: "\(labelPrefix)\(slot.name): LocalizedStringKey?", assignment: "self.\(slot.name) = \(slot.name).map { Text($0) }", tier: .value, declarationIndex: slot.declarationIndex @@ -457,7 +526,7 @@ private func extensionGroups( } else { entries.append( ParamEntry( - param: "\(slot.name): LocalizedStringKey", + param: "\(labelPrefix)\(slot.name): LocalizedStringKey", assignment: "self.\(slot.name) = Text(\(slot.name))", tier: .value, declarationIndex: slot.declarationIndex @@ -465,10 +534,11 @@ private func extensionGroups( } case .string: constraints.append("\(slot.genericParam) == Text") + let labelPrefix = slot.isUnlabeled ? "_ " : "" if slot.isOptional { entries.append( ParamEntry( - param: "\(slot.name): String?", + param: "\(labelPrefix)\(slot.name): String?", assignment: "self.\(slot.name) = \(slot.name).map { Text($0) }", tier: .value, declarationIndex: slot.declarationIndex @@ -476,7 +546,7 @@ private func extensionGroups( } else { entries.append( ParamEntry( - param: "\(slot.name): String", + param: "\(labelPrefix)\(slot.name): String", assignment: "self.\(slot.name) = Text(\(slot.name))", tier: .value, declarationIndex: slot.declarationIndex @@ -503,6 +573,30 @@ private func extensionGroups( declarationIndex: slot.declarationIndex )) } + case .custom(let resolverType): + constraints.append("\(slot.genericParam) == \(resolverType).Output") + let isUnlabeled = + slot.customOptions.first(where: { $0.resolverType == resolverType })?.isUnlabeled + ?? false + let paramLabel = isUnlabeled ? "_ \(slot.name)" : "\(slot.name)" + if slot.isOptional { + entries.append( + ParamEntry( + param: "\(paramLabel): \(resolverType).Input", + assignment: + "self.\(slot.name) = Optional(\(resolverType).makeView(\(slot.name)))", + tier: .value, + declarationIndex: slot.declarationIndex + )) + } else { + entries.append( + ParamEntry( + param: "\(paramLabel): \(resolverType).Input", + assignment: "self.\(slot.name) = \(resolverType).makeView(\(slot.name))", + tier: .value, + declarationIndex: slot.declarationIndex + )) + } case .empty: constraints.append("\(slot.genericParam) == Never") emptyAssignments.append("self.\(slot.name) = nil") diff --git a/Sources/Slots/SlotOption.swift b/Sources/Slots/SlotOption.swift index 557d716..bf2f716 100644 --- a/Sources/Slots/SlotOption.swift +++ b/Sources/Slots/SlotOption.swift @@ -2,8 +2,24 @@ import SwiftUI public struct SlotOption: Sendable, Equatable { private let id: Int + + private init(id: Int) { + self.id = id + } + /// Generate `LocalizedStringKey` → `Text` and `@_disfavoredOverload` `String` → `Text` convenience inits for this slot. public static let text = SlotOption(id: 0) /// Generate `{name}SystemName: String` → `Image(systemName:)` convenience init for this slot. public static let systemImage = SlotOption(id: 1) + + /// Generate a convenience init using a custom `SlotResolver` to map an input type to a view. + /// The macro reads the resolver type from syntax and generates constrained extensions using + /// `Resolver.Input`, `Resolver.Output`, and `Resolver.makeView(_:)`. + public static func custom(_ resolver: R.Type) -> SlotOption { + SlotOption(id: -1) + } + + /// Modifier that removes the parameter label from the generated init. + /// Use as `.text.unlabeled` or `.custom(R.self).unlabeled`. + public var unlabeled: SlotOption { self } } diff --git a/Sources/Slots/SlotResolver.swift b/Sources/Slots/SlotResolver.swift new file mode 100644 index 0000000..70ab36a --- /dev/null +++ b/Sources/Slots/SlotResolver.swift @@ -0,0 +1,7 @@ +import SwiftUI + +public protocol SlotResolver { + associatedtype Input + associatedtype Output: View + static func makeView(_ input: Input) -> Output +} diff --git a/Sources/SlotsExamples/Examples.swift b/Sources/SlotsExamples/Examples.swift deleted file mode 100644 index a139567..0000000 --- a/Sources/SlotsExamples/Examples.swift +++ /dev/null @@ -1,221 +0,0 @@ -import Slots -import SwiftUI - -// MARK: - Chip (2 slots + 1 generic view) - -@Slots public struct Chip: View { - @Slot(.systemImage) var icon: Icon? - @Slot(.text) var label: Label - var accessory: Accessory - - public var body: some View { - HStack { - if let icon { icon } - label - .font(.caption.weight(.medium)) - accessory - } - .padding() - .background(Capsule().fill(.quaternary)) - } -} - -// MARK: - Banner (1 slot) - -public enum BannerStyle { - case info, warning, error -} - -@Slots public struct Banner: View { - @Slot(.text) var message: Message - var style: BannerStyle - - public var body: some View { - HStack { - image - message - Spacer() - } - .padding() - .background(backgroundColor.opacity(0.15)) - .cornerRadius(8) - } - - private var image: some View { - let name: String = - switch style { - case .info: "info.circle.fill" - case .warning: "exclamationmark.triangle.fill" - case .error: "xmark.octagon.fill" - } - return Image(systemName: name) - .foregroundStyle(backgroundColor) - } - - private var backgroundColor: Color { - switch style { - case .info: .blue - case .warning: .orange - case .error: .red - } - } -} - -// MARK: - ListRow (3 slots) - -@Slots public struct ListRow: View { - @Slot(.systemImage) var leading: Leading? - @Slot(.text) var content: Content - @Slot(.text) var trailing: Trailing? - - public var body: some View { - HStack { - if let leading { leading } - content - Spacer() - if let trailing { - trailing - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } -} - -// MARK: - Card (4 slots) - -@Slots public struct Card: View { - @Slot(.text) var header: Header - @Slot(.systemImage) var media: Media? - @Slot(.text) var body_: Body? - var footer: Footer? - - public var body: some View { - VStack(alignment: .leading, spacing: 12) { - header - .font(.headline) - if let media { media } - if let body_ { - body_ - .font(.body) - .foregroundStyle(.secondary) - } - if let footer { footer } - } - .padding() - .background(RoundedRectangle(cornerRadius: 12).fill(.background)) - .shadow(radius: 2) - } -} - -// MARK: - EmptyState (3 slots) - -@Slots public struct EmptyState: View { - @Slot(.systemImage) var icon: Icon? - @Slot(.text) var title: Title - var action: Action? - - public var body: some View { - VStack(spacing: 16) { - if let icon { - icon - .font(.largeTitle) - .foregroundStyle(.secondary) - } - title - .font(.title3.weight(.medium)) - if let action { action } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } -} - -// MARK: - Toolbar (3 slots) - -@Slots public struct ToolbarRow: View { - var leading: Leading? - @Slot(.text) var title: Title - var trailing: Trailing? - - public var body: some View { - HStack { - if let leading { leading } - Spacer() - title.font(.headline) - Spacer() - if let trailing { trailing } - } - .padding(.horizontal) - .padding(.vertical, 8) - } -} - -// MARK: - ActionButton (1 slot + closure property) - -@Slots public struct ActionButton: View { - var action: () -> Void - @Slot(.text) var label: Label - - public var body: some View { - Button(action: action) { - label - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Capsule().fill(.tint)) - .foregroundStyle(.white) - } - .buttonStyle(.plain) - } -} - -// MARK: - Previews - -struct Examples_Previews: PreviewProvider { - static var previews: some View { - ScrollView { - VStack(spacing: 24) { - // Chip examples - Chip(label: "Default", accessory: { EmptyView() }) - Chip(iconSystemName: "star.fill", label: "Featured", accessory: { EmptyView() }) - - // Banner examples - Banner(message: "Sync complete.", style: .info) - Banner(message: "Storage nearly full.", style: .warning) - Banner(message: "Upload failed.", style: .error) - - // ListRow examples - ListRow(content: "Wi-Fi", trailing: "Connected") - ListRow(leadingSystemName: "wifi", content: "Wi-Fi", trailing: "Connected") - - // Card examples - Card(header: "Welcome Back", body_: "Pick up where you left off.") - Card(header: "Photo", mediaSystemName: "photo", body_: "A landscape shot.") - - // EmptyState examples - EmptyState(iconSystemName: "tray", title: "No Messages") - EmptyState( - title: "Nothing Here", - action: { - Button("Refresh") {} - }) - - // ActionButton examples - ActionButton(label: "Save", action: {}) - ActionButton(action: {}) { Text("Custom Label").bold() } - - // ToolbarRow examples - ToolbarRow(title: "Inbox") - ToolbarRow( - title: "Details", - leading: { - Button(action: {}) { Image(systemName: "chevron.left") } - }, - trailing: { - Button(action: {}) { Image(systemName: "ellipsis") } - }) - } - .padding() - } - } -} diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..fd24200 --- /dev/null +++ b/TODO.md @@ -0,0 +1,3 @@ +# TODO + +- Use `` instead of `String` for disfavored text slot inits, matching SwiftUI's `Button` pattern diff --git a/Tests/SlotTests/SlotTests.swift b/Tests/SlotTests/SlotTests.swift index 46e0f47..dfbefbc 100644 --- a/Tests/SlotTests/SlotTests.swift +++ b/Tests/SlotTests/SlotTests.swift @@ -46,6 +46,40 @@ final class SlotTests: XCTestCase { ) } + func testSingleSlotTextUnlabeled() { + assertMacroExpansion( + """ + @Slots + struct Badge: View { + @Slot(.text.unlabeled) var label: Label + var body: some View { EmptyView() } + } + """, + expandedSource: """ + struct Badge: View { + var label: Label + var body: some View { EmptyView() } + + init(@ViewBuilder label: () -> Label) { + self.label = label() + } + } + + extension Badge where Label == Text { + init(_ label: LocalizedStringKey) { + self.label = Text(label) + } + + @_disfavoredOverload + init(_ label: String) { + self.label = Text(label) + } + } + """, + macros: testMacros + ) + } + func testSingleSlotOptional() { assertMacroExpansion( """ @@ -1249,4 +1283,169 @@ final class SlotTests: XCTestCase { ) } + // MARK: - Custom slot option tests + + func testCustomSlotOption() { + assertMacroExpansion( + """ + @Slots + struct Row: View { + @Slot(.custom(IconResolver.self)) var icon: Icon + @Slot(.text) var label: Label + var body: some View { EmptyView() } + } + """, + expandedSource: """ + struct Row: View { + var icon: Icon + var label: Label + var body: some View { EmptyView() } + + init(@ViewBuilder icon: () -> Icon, @ViewBuilder label: () -> Label) { + self.icon = icon() + self.label = label() + } + } + + extension Row where Label == Text { + init(label: LocalizedStringKey, @ViewBuilder icon: () -> Icon) { + self.label = Text(label) + self.icon = icon() + } + + @_disfavoredOverload + init(label: String, @ViewBuilder icon: () -> Icon) { + self.label = Text(label) + self.icon = icon() + } + } + + extension Row where Icon == IconResolver.Output { + init(icon: IconResolver.Input, @ViewBuilder label: () -> Label) { + self.icon = IconResolver.makeView(icon) + self.label = label() + } + } + + extension Row where Icon == IconResolver.Output, Label == Text { + init(icon: IconResolver.Input, label: LocalizedStringKey) { + self.icon = IconResolver.makeView(icon) + self.label = Text(label) + } + + @_disfavoredOverload + init(icon: IconResolver.Input, label: String) { + self.icon = IconResolver.makeView(icon) + self.label = Text(label) + } + } + """, + macros: testMacros + ) + } + + func testCustomSlotOptionOptional() { + assertMacroExpansion( + """ + @Slots + struct Row: View { + @Slot(.custom(IconResolver.self)) var icon: Icon? + var body: some View { EmptyView() } + } + """, + expandedSource: """ + struct Row: View { + var icon: Icon? + var body: some View { EmptyView() } + + init(@ViewBuilder icon: () -> Icon) { + self.icon = Optional(icon()) + } + } + + extension Row where Icon == IconResolver.Output { + init(icon: IconResolver.Input) { + self.icon = Optional(IconResolver.makeView(icon)) + } + } + + extension Row where Icon == Never { + init() { + self.icon = nil + } + } + """, + macros: testMacros + ) + } + + func testCustomSlotOptionUnlabeled() { + assertMacroExpansion( + """ + @Slots + struct Row: View { + @Slot(.custom(IconResolver.self).unlabeled) var icon: Icon + var body: some View { EmptyView() } + } + """, + expandedSource: """ + struct Row: View { + var icon: Icon + var body: some View { EmptyView() } + + init(@ViewBuilder icon: () -> Icon) { + self.icon = icon() + } + } + + extension Row where Icon == IconResolver.Output { + init(_ icon: IconResolver.Input) { + self.icon = IconResolver.makeView(icon) + } + } + """, + macros: testMacros + ) + } + + func testCustomSlotOptionComposesWithBuiltIn() { + assertMacroExpansion( + """ + @Slots + struct Row: View { + @Slot(.text, .custom(IconResolver.self)) var label: Label + var body: some View { EmptyView() } + } + """, + expandedSource: """ + struct Row: View { + var label: Label + var body: some View { EmptyView() } + + init(@ViewBuilder label: () -> Label) { + self.label = label() + } + } + + extension Row where Label == Text { + init(label: LocalizedStringKey) { + self.label = Text(label) + } + + @_disfavoredOverload + init(label: String) { + self.label = Text(label) + } + } + + extension Row where Label == IconResolver.Output { + init(label: IconResolver.Input) { + self.label = IconResolver.makeView(label) + } + } + """, + macros: testMacros + ) + } + }