From 6fd16e1cdd1d95e413d383a02d79958a4e8e0884 Mon Sep 17 00:00:00 2001 From: Kyle Bashour Date: Mon, 30 Mar 2026 13:49:57 -0700 Subject: [PATCH 1/3] Add .text.unlabeled option, split examples into separate files, rename to SlotExamples Add `.unlabeled` modifier to `.text` slot option, generating `_ name:` instead of `name:` in text/string convenience inits to match `Button(_ title:)` ergonomics. Split monolithic Examples.swift into one file per component and rename target from SlotsExamples to SlotExamples. Co-Authored-By: Claude Opus 4.6 (1M context) --- Package.swift | 2 +- Sources/SlotExamples/ActionButton.swift | 18 ++ Sources/SlotExamples/Banner.swift | 41 +++++ Sources/SlotExamples/Card.swift | 26 +++ Sources/SlotExamples/Chip.swift | 19 ++ Sources/SlotExamples/EmptyState.swift | 23 +++ Sources/SlotExamples/ListRow.swift | 21 +++ Sources/SlotExamples/Previews.swift | 50 ++++++ Sources/SlotExamples/ToolbarRow.swift | 20 +++ Sources/SlotMacros/SlotMacro.swift | 37 +++- Sources/Slots/SlotOption.swift | 14 +- Sources/SlotsExamples/Examples.swift | 221 ------------------------ TODO.md | 3 + Tests/SlotTests/SlotTests.swift | 34 ++++ 14 files changed, 299 insertions(+), 230 deletions(-) create mode 100644 Sources/SlotExamples/ActionButton.swift create mode 100644 Sources/SlotExamples/Banner.swift create mode 100644 Sources/SlotExamples/Card.swift create mode 100644 Sources/SlotExamples/Chip.swift create mode 100644 Sources/SlotExamples/EmptyState.swift create mode 100644 Sources/SlotExamples/ListRow.swift create mode 100644 Sources/SlotExamples/Previews.swift create mode 100644 Sources/SlotExamples/ToolbarRow.swift delete mode 100644 Sources/SlotsExamples/Examples.swift create mode 100644 TODO.md 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..34a3fbc --- /dev/null +++ b/Sources/SlotExamples/Previews.swift @@ -0,0 +1,50 @@ +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() } + + // 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/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..a6cddd8 100644 --- a/Sources/SlotMacros/SlotMacro.swift +++ b/Sources/SlotMacros/SlotMacro.swift @@ -261,6 +261,7 @@ private struct SlotDescriptor { let isOptional: Bool let hasText: Bool let hasSystemImage: Bool + let isUnlabeled: Bool let declarationIndex: Int } @@ -331,7 +332,8 @@ private func collectSlots( return SlotDescriptor( name: propertyName, genericParam: inner, isOptional: true, hasText: options.contains(.text), - hasSystemImage: options.contains(.systemImage), declarationIndex: memberIndex) + hasSystemImage: options.contains(.systemImage), isUnlabeled: options.contains(.unlabeled), + declarationIndex: memberIndex) } // `Icon` — slot only if @Slot annotated @@ -343,7 +345,8 @@ private func collectSlots( return SlotDescriptor( name: propertyName, genericParam: name, isOptional: false, hasText: options.contains(.text), - hasSystemImage: options.contains(.systemImage), declarationIndex: memberIndex) + hasSystemImage: options.contains(.systemImage), isUnlabeled: options.contains(.unlabeled), + declarationIndex: memberIndex) } // @Slot on a non-generic type is an error @@ -362,13 +365,31 @@ 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() guard case .argumentList(let args) = attr.arguments else { return result } for arg in args { - switch arg.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text { + let expr = arg.expression + + // Check for chained access like `.text.unlabeled` + if let outer = expr.as(MemberAccessExprSyntax.self), + outer.declName.baseName.text == "unlabeled", + let inner = outer.base?.as(MemberAccessExprSyntax.self) + { + switch inner.declName.baseName.text { + case "text": + result.insert(.text) + result.insert(.unlabeled) + default: break + } + continue + } + + // Simple access like `.text` or `.systemImage` + switch expr.as(MemberAccessExprSyntax.self)?.declName.baseName.text { case "text": result.insert(.text) case "systemImage": result.insert(.systemImage) default: break @@ -446,10 +467,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 +479,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 +487,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 +499,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 diff --git a/Sources/Slots/SlotOption.swift b/Sources/Slots/SlotOption.swift index 557d716..1c36228 100644 --- a/Sources/Slots/SlotOption.swift +++ b/Sources/Slots/SlotOption.swift @@ -1,9 +1,21 @@ import SwiftUI public struct SlotOption: Sendable, Equatable { - private let id: Int + let id: Int + let isUnlabeled: Bool + + private init(id: Int, isUnlabeled: Bool = false) { + self.id = id + self.isUnlabeled = isUnlabeled + } + /// 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) + + /// Omit the external parameter label (`_ name:`) in text/string convenience inits, matching `Button(_ title:)` ergonomics. + public var unlabeled: SlotOption { + SlotOption(id: id, isUnlabeled: true) + } } 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..4a45a81 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( """ From 109c4c460eddc51ddea17650610ef8190c932478 Mon Sep 17 00:00:00 2001 From: Kyle Bashour Date: Mon, 30 Mar 2026 19:14:03 -0700 Subject: [PATCH 2/3] Add SlotResolver protocol for custom slot resolution strategies Introduce a SlotResolver protocol that lets consumers define custom type-to-View mappings beyond the built-in .text and .systemImage options. Resolvers are used via @Slot(MyResolver.self) and generate constrained convenience inits that accept the resolver's Input type. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SlotExamples/ResolverExample.swift | 37 ++++++ Sources/SlotMacros/SlotMacro.swift | 94 ++++++++++++--- Sources/Slots/Slot.swift | 10 ++ Sources/Slots/SlotResolver.swift | 7 ++ Tests/SlotTests/SlotIntegrationTests.swift | 54 +++++++++ Tests/SlotTests/SlotTests.swift | 127 +++++++++++++++++++++ 6 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 Sources/SlotExamples/ResolverExample.swift create mode 100644 Sources/Slots/SlotResolver.swift diff --git a/Sources/SlotExamples/ResolverExample.swift b/Sources/SlotExamples/ResolverExample.swift new file mode 100644 index 0000000..6ffe1b3 --- /dev/null +++ b/Sources/SlotExamples/ResolverExample.swift @@ -0,0 +1,37 @@ +import Foundation +import Slots +import SwiftUI + +struct DateResolver: SlotResolver { + typealias Input = Date + typealias Output = Text + static func resolve(_ input: Date) -> Text { + Text(input, style: .date) + } +} + +@Slots +struct EventRow: View { + @Slot(.text) var title: Title + @Slot(DateResolver.self) var when_: When + + var body: some View { + HStack { + title + Spacer() + when_ + } + } +} + +struct ResolverExample_Previews: PreviewProvider { + static var previews: some View { + VStack { + EventRow(title: "Birthday Party", when_: Date()) + EventRow(title: "Meeting") { + Text("Tomorrow") + } + } + .padding() + } +} diff --git a/Sources/SlotMacros/SlotMacro.swift b/Sources/SlotMacros/SlotMacro.swift index a6cddd8..2640a0c 100644 --- a/Sources/SlotMacros/SlotMacro.swift +++ b/Sources/SlotMacros/SlotMacro.swift @@ -262,6 +262,7 @@ private struct SlotDescriptor { let hasText: Bool let hasSystemImage: Bool let isUnlabeled: Bool + let resolvers: [String] let declarationIndex: Int } @@ -272,6 +273,7 @@ private enum SlotMode: Equatable { case text // fix to Text, LocalizedStringKey param, preferred case string // fix to Text, String param, @_disfavoredOverload case systemImage // fix to Image, {name}SystemName: String param + case resolved(typeName: String) // fix to Resolver.Output, Resolver.Input param case empty // fix to Never, parameter omitted (stores nil) } @@ -309,10 +311,12 @@ private func collectSlots( !varDecl.modifiers.contains(where: { skippedModifiers.contains($0.name.text) }) else { return [] } - let slotAttr = varDecl.attributes.first(where: { + let slotAttrs = varDecl.attributes.filter { $0.as(AttributeSyntax.self)?.attributeName .as(IdentifierTypeSyntax.self)?.name.text == "Slot" - })?.as(AttributeSyntax.self) + }.compactMap { $0.as(AttributeSyntax.self) } + + let hasSlotAttr = !slotAttrs.isEmpty return varDecl.bindings.compactMap { binding -> SlotDescriptor? in guard @@ -323,16 +327,23 @@ private func collectSlots( let propertyName = identifier.identifier.text + // Merge options from all @Slot attributes + var options = ParsedOptions() + for attr in slotAttrs { + options.merge(parseSlotOptions(from: attr)) + } + // `Icon?` — always a slot, @Slot annotation optional if let inner = typeAnnotation.as(OptionalTypeSyntax.self)? .wrappedType.as(IdentifierTypeSyntax.self)?.name.text, genericNames.contains(inner) { - let options = slotAttr.map { parseSlotOptions(from: $0) } ?? ParsedOptions() - return SlotDescriptor( - name: propertyName, genericParam: inner, isOptional: true, hasText: options.contains(.text), - hasSystemImage: options.contains(.systemImage), isUnlabeled: options.contains(.unlabeled), + name: propertyName, genericParam: inner, isOptional: true, + hasText: options.flags.contains(.text), + hasSystemImage: options.flags.contains(.systemImage), + isUnlabeled: options.flags.contains(.unlabeled), + resolvers: options.resolvers, declarationIndex: memberIndex) } @@ -340,17 +351,19 @@ private func collectSlots( if let name = typeAnnotation.as(IdentifierTypeSyntax.self)?.name.text, genericNames.contains(name) { - guard slotAttr != nil else { return nil } - let options = parseSlotOptions(from: slotAttr!) + guard hasSlotAttr else { return nil } return SlotDescriptor( - name: propertyName, genericParam: name, isOptional: false, hasText: options.contains(.text), - hasSystemImage: options.contains(.systemImage), isUnlabeled: options.contains(.unlabeled), + name: propertyName, genericParam: name, isOptional: false, + hasText: options.flags.contains(.text), + hasSystemImage: options.flags.contains(.systemImage), + isUnlabeled: options.flags.contains(.unlabeled), + resolvers: options.resolvers, declarationIndex: memberIndex) } // @Slot on a non-generic type is an error - if let attr = slotAttr { + if let attr = slotAttrs.first { context.diagnose( Diagnostic(node: attr, message: SlotError.cannotResolveGenericForSlot(propertyName))) } @@ -361,11 +374,21 @@ private func collectSlots( // MARK: - Parsing @Slot options -private struct ParsedOptions: OptionSet { +private struct OptionFlags: 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) + static let text = OptionFlags(rawValue: 1 << 0) + static let systemImage = OptionFlags(rawValue: 1 << 1) + static let unlabeled = OptionFlags(rawValue: 1 << 2) +} + +private struct ParsedOptions { + var flags: OptionFlags = [] + var resolvers: [String] = [] + + mutating func merge(_ other: ParsedOptions) { + flags.formUnion(other.flags) + resolvers.append(contentsOf: other.resolvers) + } } private func parseSlotOptions(from attr: AttributeSyntax) -> ParsedOptions { @@ -374,6 +397,15 @@ private func parseSlotOptions(from attr: AttributeSyntax) -> ParsedOptions { for arg in args { let expr = arg.expression + // Check for metatype like `SomeResolver.self` + if let memberAccess = expr.as(MemberAccessExprSyntax.self), + memberAccess.declName.baseName.text == "self", + let base = memberAccess.base + { + result.resolvers.append(base.trimmedDescription) + continue + } + // Check for chained access like `.text.unlabeled` if let outer = expr.as(MemberAccessExprSyntax.self), outer.declName.baseName.text == "unlabeled", @@ -381,8 +413,8 @@ private func parseSlotOptions(from attr: AttributeSyntax) -> ParsedOptions { { switch inner.declName.baseName.text { case "text": - result.insert(.text) - result.insert(.unlabeled) + result.flags.insert(.text) + result.flags.insert(.unlabeled) default: break } continue @@ -390,8 +422,8 @@ private func parseSlotOptions(from attr: AttributeSyntax) -> ParsedOptions { // Simple access like `.text` or `.systemImage` switch expr.as(MemberAccessExprSyntax.self)?.declName.baseName.text { - case "text": result.insert(.text) - case "systemImage": result.insert(.systemImage) + case "text": result.flags.insert(.text) + case "systemImage": result.flags.insert(.systemImage) default: break } } @@ -407,6 +439,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.resolvers.count if slot.isOptional { modes += 1 } // empty return count * modes } @@ -422,6 +455,9 @@ private func allCombinations(for slots: [SlotDescriptor]) -> [[SlotMode]] { modes.append(.string) } if slot.hasSystemImage { modes.append(.systemImage) } + for resolver in slot.resolvers { + modes.append(.resolved(typeName: resolver)) + } if slot.isOptional { modes.append(.empty) } guard !combos.isEmpty else { return modes.map { [$0] } } @@ -526,6 +562,26 @@ private func extensionGroups( declarationIndex: slot.declarationIndex )) } + case .resolved(let typeName): + constraints.append("\(slot.genericParam) == \(typeName).Output") + if slot.isOptional { + entries.append( + ParamEntry( + param: "\(slot.name): \(typeName).Input?", + assignment: + "self.\(slot.name) = \(slot.name).map { \(typeName).resolve($0) }", + tier: .value, + declarationIndex: slot.declarationIndex + )) + } else { + entries.append( + ParamEntry( + param: "\(slot.name): \(typeName).Input", + assignment: "self.\(slot.name) = \(typeName).resolve(\(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/Slot.swift b/Sources/Slots/Slot.swift index 86a3526..19f15f8 100644 --- a/Sources/Slots/Slot.swift +++ b/Sources/Slots/Slot.swift @@ -7,5 +7,15 @@ import SwiftUI /// /// - `.text` — add `init` variants accepting `LocalizedStringKey` and `String` (disfavored), both stored as `Text(...)` /// - `.systemImage` — add an `init` variant accepting `{name}SystemName: String`, stored as `Image(systemName:)` +/// - `MyResolver.self` — add an `init` variant accepting the resolver's `Input`, stored via `MyResolver.resolve(_:)` @attached(peer) public macro Slot(_ options: SlotOption...) = #externalMacro(module: "SlotMacros", type: "SlotPropertyMacro") + +/// Marks a non-optional generic property as a slot with a custom resolver. +/// +/// The resolver's `Input` type becomes the parameter type and `Output` becomes the view type. +@attached(peer) +public macro Slot(_ resolver: R.Type) = + #externalMacro( + module: "SlotMacros", type: "SlotPropertyMacro" + ) diff --git a/Sources/Slots/SlotResolver.swift b/Sources/Slots/SlotResolver.swift new file mode 100644 index 0000000..24b15fb --- /dev/null +++ b/Sources/Slots/SlotResolver.swift @@ -0,0 +1,7 @@ +import SwiftUI + +public protocol SlotResolver { + associatedtype Input + associatedtype Output: View + static func resolve(_ input: Input) -> Output +} diff --git a/Tests/SlotTests/SlotIntegrationTests.swift b/Tests/SlotTests/SlotIntegrationTests.swift index f78989c..3d6e7b1 100644 --- a/Tests/SlotTests/SlotIntegrationTests.swift +++ b/Tests/SlotTests/SlotIntegrationTests.swift @@ -1,9 +1,35 @@ +import Foundation import Slots import SwiftUI import XCTest +// MARK: - Resolvers + +struct DateResolver: SlotResolver { + typealias Input = Date + typealias Output = Text + static func resolve(_ input: Date) -> Text { + Text(input, style: .date) + } +} + // MARK: - Test components +@Slots +struct EventRow: View { + @Slot(.text) var title: Title + @Slot(DateResolver.self) var when_: When + var body: some View { EmptyView() } +} + +@Slots +struct EventCard: View { + @Slot(.text) var title: Title + @Slot(DateResolver.self) var when_: When + var footer: Footer? + var body: some View { EmptyView() } +} + @Slots struct Badge: View { @Slot(.text) var label: Label? @@ -34,6 +60,34 @@ struct Row: View { @MainActor final class SlotIntegrationTests: XCTestCase { + // MARK: EventRow — resolver slot + + func testEventRowResolver() { + // resolver input: Date → Text + let _: EventRow = EventRow(title: "Party", when_: Date()) + // generic when_ slot + let _: EventRow = EventRow(title: "Party") { Text("Tomorrow") } + // String title (disfavored), resolver when_ + let _: EventRow = EventRow(title: "Party" as String, when_: Date()) + // both generic + let _: EventRow = EventRow { + Text("Party") + } when_: { + Text("Tomorrow") + } + } + + // MARK: EventCard — resolver + optional slot + + func testEventCardResolver() { + // resolver when_, text title, generic footer + let _: EventCard = EventCard(title: "Party", when_: Date()) { Text("See you!") } + // resolver when_, text title, no footer + let _: EventCard = EventCard(title: "Party", when_: Date()) + // generic when_, text title, no footer + let _: EventCard = EventCard(title: "Party") { Text("Tomorrow") } + } + // MARK: Badge — single slot, all option combos func testBadgeSingleSlot() { diff --git a/Tests/SlotTests/SlotTests.swift b/Tests/SlotTests/SlotTests.swift index 4a45a81..d2fab73 100644 --- a/Tests/SlotTests/SlotTests.swift +++ b/Tests/SlotTests/SlotTests.swift @@ -1233,6 +1233,133 @@ final class SlotTests: XCTestCase { ) } + // MARK: - Resolver tests + + func testResolverOnRequiredSlot() { + assertMacroExpansion( + """ + @Slots + struct EventRow: View { + @Slot(SomeResolver.self) var label: Label + var body: some View { EmptyView() } + } + """, + expandedSource: """ + struct EventRow: View { + var label: Label + var body: some View { EmptyView() } + + init(@ViewBuilder label: () -> Label) { + self.label = label() + } + } + + extension EventRow where Label == SomeResolver.Output { + init(label: SomeResolver.Input) { + self.label = SomeResolver.resolve(label) + } + } + """, + macros: testMacros + ) + } + + func testResolverOnOptionalSlot() { + assertMacroExpansion( + """ + @Slots + struct EventRow: View { + @Slot(SomeResolver.self) var icon: Icon? + var body: some View { EmptyView() } + } + """, + expandedSource: """ + struct EventRow: View { + var icon: Icon? + var body: some View { EmptyView() } + + init(@ViewBuilder icon: () -> Icon) { + self.icon = Optional(icon()) + } + } + + extension EventRow where Icon == SomeResolver.Output { + init(icon: SomeResolver.Input?) { + self.icon = icon.map { + SomeResolver.resolve($0) + } + } + } + + extension EventRow where Icon == Never { + init() { + self.icon = nil + } + } + """, + macros: testMacros + ) + } + + func testResolverWithTextOnOtherSlot() { + assertMacroExpansion( + """ + @Slots + struct EventCard: View { + @Slot(.text) var title: Title + @Slot(DateResolver.self) var when_: When + var body: some View { EmptyView() } + } + """, + expandedSource: """ + struct EventCard: View { + var title: Title + var when_: When + var body: some View { EmptyView() } + + init(@ViewBuilder title: () -> Title, @ViewBuilder when_: () -> When) { + self.title = title() + self.when_ = when_() + } + } + + extension EventCard where When == DateResolver.Output { + init(when_: DateResolver.Input, @ViewBuilder title: () -> Title) { + self.when_ = DateResolver.resolve(when_) + self.title = title() + } + } + + extension EventCard where Title == Text { + init(title: LocalizedStringKey, @ViewBuilder when_: () -> When) { + self.title = Text(title) + self.when_ = when_() + } + + @_disfavoredOverload + init(title: String, @ViewBuilder when_: () -> When) { + self.title = Text(title) + self.when_ = when_() + } + } + + extension EventCard where Title == Text, When == DateResolver.Output { + init(title: LocalizedStringKey, when_: DateResolver.Input) { + self.title = Text(title) + self.when_ = DateResolver.resolve(when_) + } + + @_disfavoredOverload + init(title: String, when_: DateResolver.Input) { + self.title = Text(title) + self.when_ = DateResolver.resolve(when_) + } + } + """, + macros: testMacros + ) + } + // MARK: - Init limit test func testTooManyInitsEmitsError() { From 97814737472e85def67a98d08abfd9c35b99e554 Mon Sep 17 00:00:00 2001 From: Kyle Bashour Date: Mon, 30 Mar 2026 19:31:12 -0700 Subject: [PATCH 3/3] Add .unlabeled as standalone option, improve resolver example and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make .unlabeled a static SlotOption instead of an instance chaining property, so it works uniformly: @Slot(.text, .unlabeled) and @Slot(R.self, .unlabeled). Replace DateResolver example with a more compelling Priority → PriorityBadge resolver. Simplify macro parsing by removing the .text.unlabeled chaining branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SlotExamples/ActionButton.swift | 2 +- Sources/SlotExamples/Previews.swift | 8 +++ Sources/SlotExamples/ResolverExample.swift | 65 +++++++++++------- Sources/SlotMacros/SlotMacro.swift | 77 +++++++++++----------- Sources/Slots/Slot.swift | 7 +- Sources/Slots/SlotOption.swift | 12 ++-- Tests/SlotTests/SlotIntegrationTests.swift | 69 ++++++++----------- Tests/SlotTests/SlotTests.swift | 31 ++++++++- 8 files changed, 158 insertions(+), 113 deletions(-) diff --git a/Sources/SlotExamples/ActionButton.swift b/Sources/SlotExamples/ActionButton.swift index e9daee8..a55817b 100644 --- a/Sources/SlotExamples/ActionButton.swift +++ b/Sources/SlotExamples/ActionButton.swift @@ -3,7 +3,7 @@ import SwiftUI @Slots public struct ActionButton: View { var action: () -> Void - @Slot(.text.unlabeled) var label: Label + @Slot(.text, .unlabeled) var label: Label public var body: some View { Button(action: action) { diff --git a/Sources/SlotExamples/Previews.swift b/Sources/SlotExamples/Previews.swift index 34a3fbc..f959ae9 100644 --- a/Sources/SlotExamples/Previews.swift +++ b/Sources/SlotExamples/Previews.swift @@ -29,6 +29,14 @@ struct Examples_Previews: PreviewProvider { Button("Refresh") {} }) + // TaskRow examples — uses custom resolver slot with .unlabeled + TaskRow(title: "Buy groceries", .high) + TaskRow(title: "Read article", .low) + TaskRow(title: "No priority") + TaskRow(title: "Custom badge") { + Image(systemName: "star.fill").foregroundStyle(.yellow) + } + // ActionButton examples — uses .unlabeled so no label: prefix ActionButton("Save", action: {}) ActionButton(action: {}) { Text("Custom Label").bold() } diff --git a/Sources/SlotExamples/ResolverExample.swift b/Sources/SlotExamples/ResolverExample.swift index 6ffe1b3..fbadb57 100644 --- a/Sources/SlotExamples/ResolverExample.swift +++ b/Sources/SlotExamples/ResolverExample.swift @@ -1,37 +1,56 @@ -import Foundation import Slots import SwiftUI -struct DateResolver: SlotResolver { - typealias Input = Date - typealias Output = Text - static func resolve(_ input: Date) -> Text { - Text(input, style: .date) +// 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 + +public enum PriorityResolver: SlotResolver { + public typealias Input = Priority + public typealias Output = PriorityBadge + public static func resolve(_ input: Priority) -> PriorityBadge { + PriorityBadge(priority: input) } } -@Slots -struct EventRow: View { +// MARK: - Component using the resolver + +@Slots public struct TaskRow: View { @Slot(.text) var title: Title - @Slot(DateResolver.self) var when_: When + @Slot(PriorityResolver.self, .unlabeled) var badge: Badge? - var body: some View { + public var body: some View { HStack { title Spacer() - when_ - } - } -} - -struct ResolverExample_Previews: PreviewProvider { - static var previews: some View { - VStack { - EventRow(title: "Birthday Party", when_: Date()) - EventRow(title: "Meeting") { - Text("Tomorrow") - } + if let badge { badge } } - .padding() + .padding(.vertical, 4) } } diff --git a/Sources/SlotMacros/SlotMacro.swift b/Sources/SlotMacros/SlotMacro.swift index 2640a0c..92f69cb 100644 --- a/Sources/SlotMacros/SlotMacro.swift +++ b/Sources/SlotMacros/SlotMacro.swift @@ -262,7 +262,7 @@ private struct SlotDescriptor { let hasText: Bool let hasSystemImage: Bool let isUnlabeled: Bool - let resolvers: [String] + let resolvers: [ResolverOption] let declarationIndex: Int } @@ -381,9 +381,14 @@ private struct OptionFlags: OptionSet { static let unlabeled = OptionFlags(rawValue: 1 << 2) } +private struct ResolverOption: Equatable { + let typeName: String + let isUnlabeled: Bool +} + private struct ParsedOptions { var flags: OptionFlags = [] - var resolvers: [String] = [] + var resolvers: [ResolverOption] = [] mutating func merge(_ other: ParsedOptions) { flags.formUnion(other.flags) @@ -394,6 +399,9 @@ private struct ParsedOptions { private func parseSlotOptions(from attr: AttributeSyntax) -> ParsedOptions { var result = ParsedOptions() guard case .argumentList(let args) = attr.arguments else { return result } + + // First pass: collect resolver type names and flags + var resolverNames: [String] = [] for arg in args { let expr = arg.expression @@ -402,31 +410,25 @@ private func parseSlotOptions(from attr: AttributeSyntax) -> ParsedOptions { memberAccess.declName.baseName.text == "self", let base = memberAccess.base { - result.resolvers.append(base.trimmedDescription) - continue - } - - // Check for chained access like `.text.unlabeled` - if let outer = expr.as(MemberAccessExprSyntax.self), - outer.declName.baseName.text == "unlabeled", - let inner = outer.base?.as(MemberAccessExprSyntax.self) - { - switch inner.declName.baseName.text { - case "text": - result.flags.insert(.text) - result.flags.insert(.unlabeled) - default: break - } + resolverNames.append(base.trimmedDescription) continue } - // Simple access like `.text` or `.systemImage` + // Simple access like `.text`, `.systemImage`, or `.unlabeled` switch expr.as(MemberAccessExprSyntax.self)?.declName.baseName.text { case "text": result.flags.insert(.text) case "systemImage": result.flags.insert(.systemImage) + case "unlabeled": result.flags.insert(.unlabeled) default: break } } + + // Build resolver options, applying unlabeled flag to all resolvers on this attribute + let isUnlabeled = result.flags.contains(.unlabeled) + for name in resolverNames { + result.resolvers.append(ResolverOption(typeName: name, isUnlabeled: isUnlabeled)) + } + return result } @@ -456,7 +458,7 @@ private func allCombinations(for slots: [SlotDescriptor]) -> [[SlotMode]] { } if slot.hasSystemImage { modes.append(.systemImage) } for resolver in slot.resolvers { - modes.append(.resolved(typeName: resolver)) + modes.append(.resolved(typeName: resolver.typeName)) } if slot.isOptional { modes.append(.empty) } @@ -564,24 +566,25 @@ private func extensionGroups( } case .resolved(let typeName): constraints.append("\(slot.genericParam) == \(typeName).Output") - if slot.isOptional { - entries.append( - ParamEntry( - param: "\(slot.name): \(typeName).Input?", - assignment: - "self.\(slot.name) = \(slot.name).map { \(typeName).resolve($0) }", - tier: .value, - declarationIndex: slot.declarationIndex - )) - } else { - entries.append( - ParamEntry( - param: "\(slot.name): \(typeName).Input", - assignment: "self.\(slot.name) = \(typeName).resolve(\(slot.name))", - tier: .value, - declarationIndex: slot.declarationIndex - )) - } + let isUnlabeled = + slot.resolvers.first(where: { $0.typeName == typeName })?.isUnlabeled ?? false + let labelPrefix = isUnlabeled ? "_ " : "" + let resolve = "\(typeName).resolve(\(slot.name))" + let param = + slot.isOptional + ? "\(labelPrefix)\(slot.name): \(typeName).Input?" + : "\(labelPrefix)\(slot.name): \(typeName).Input" + let assignment = + slot.isOptional + ? "self.\(slot.name) = \(slot.name).map { \(typeName).resolve($0) }" + : "self.\(slot.name) = \(resolve)" + entries.append( + ParamEntry( + param: param, + assignment: assignment, + tier: .value, + declarationIndex: slot.declarationIndex + )) case .empty: constraints.append("\(slot.genericParam) == Never") emptyAssignments.append("self.\(slot.name) = nil") diff --git a/Sources/Slots/Slot.swift b/Sources/Slots/Slot.swift index 19f15f8..139d89d 100644 --- a/Sources/Slots/Slot.swift +++ b/Sources/Slots/Slot.swift @@ -7,15 +7,16 @@ import SwiftUI /// /// - `.text` — add `init` variants accepting `LocalizedStringKey` and `String` (disfavored), both stored as `Text(...)` /// - `.systemImage` — add an `init` variant accepting `{name}SystemName: String`, stored as `Image(systemName:)` -/// - `MyResolver.self` — add an `init` variant accepting the resolver's `Input`, stored via `MyResolver.resolve(_:)` +/// - `.unlabeled` — omit the external parameter label (`_ name:`) in convenience inits @attached(peer) public macro Slot(_ options: SlotOption...) = #externalMacro(module: "SlotMacros", type: "SlotPropertyMacro") -/// Marks a non-optional generic property as a slot with a custom resolver. +/// Marks a generic property as a slot with a custom resolver. /// /// The resolver's `Input` type becomes the parameter type and `Output` becomes the view type. +/// Pass `.unlabeled` to omit the external parameter label. @attached(peer) -public macro Slot(_ resolver: R.Type) = +public macro Slot(_ resolver: R.Type, _ options: SlotOption...) = #externalMacro( module: "SlotMacros", type: "SlotPropertyMacro" ) diff --git a/Sources/Slots/SlotOption.swift b/Sources/Slots/SlotOption.swift index 1c36228..ededebc 100644 --- a/Sources/Slots/SlotOption.swift +++ b/Sources/Slots/SlotOption.swift @@ -1,12 +1,10 @@ import SwiftUI public struct SlotOption: Sendable, Equatable { - let id: Int - let isUnlabeled: Bool + private let id: Int - private init(id: Int, isUnlabeled: Bool = false) { + private init(id: Int) { self.id = id - self.isUnlabeled = isUnlabeled } /// Generate `LocalizedStringKey` → `Text` and `@_disfavoredOverload` `String` → `Text` convenience inits for this slot. @@ -14,8 +12,6 @@ public struct SlotOption: Sendable, Equatable { /// Generate `{name}SystemName: String` → `Image(systemName:)` convenience init for this slot. public static let systemImage = SlotOption(id: 1) - /// Omit the external parameter label (`_ name:`) in text/string convenience inits, matching `Button(_ title:)` ergonomics. - public var unlabeled: SlotOption { - SlotOption(id: id, isUnlabeled: true) - } + /// Omit the external parameter label from the generated init. + public static let unlabeled = SlotOption(id: 2) } diff --git a/Tests/SlotTests/SlotIntegrationTests.swift b/Tests/SlotTests/SlotIntegrationTests.swift index 3d6e7b1..d8ba646 100644 --- a/Tests/SlotTests/SlotIntegrationTests.swift +++ b/Tests/SlotTests/SlotIntegrationTests.swift @@ -1,32 +1,30 @@ -import Foundation import Slots import SwiftUI import XCTest -// MARK: - Resolvers +// MARK: - Resolver -struct DateResolver: SlotResolver { - typealias Input = Date - typealias Output = Text - static func resolve(_ input: Date) -> Text { - Text(input, style: .date) +enum Priority: String, Sendable { case low, medium, high } + +struct PriorityBadge: View { + let priority: Priority + var body: some View { Text(priority.rawValue) } +} + +enum PriorityResolver: SlotResolver { + typealias Input = Priority + typealias Output = PriorityBadge + static func resolve(_ input: Priority) -> PriorityBadge { + PriorityBadge(priority: input) } } // MARK: - Test components @Slots -struct EventRow: View { - @Slot(.text) var title: Title - @Slot(DateResolver.self) var when_: When - var body: some View { EmptyView() } -} - -@Slots -struct EventCard: View { +struct TaskRow: View { @Slot(.text) var title: Title - @Slot(DateResolver.self) var when_: When - var footer: Footer? + @Slot(PriorityResolver.self) var badge: Badge? var body: some View { EmptyView() } } @@ -60,34 +58,25 @@ struct Row: View { @MainActor final class SlotIntegrationTests: XCTestCase { - // MARK: EventRow — resolver slot - - func testEventRowResolver() { - // resolver input: Date → Text - let _: EventRow = EventRow(title: "Party", when_: Date()) - // generic when_ slot - let _: EventRow = EventRow(title: "Party") { Text("Tomorrow") } - // String title (disfavored), resolver when_ - let _: EventRow = EventRow(title: "Party" as String, when_: Date()) + // MARK: TaskRow — resolver slot (optional) + + func testTaskRowResolver() { + // resolver input: Priority → PriorityBadge + let _: TaskRow = TaskRow(title: "Buy groceries", badge: .high) + // generic badge slot + let _: TaskRow = TaskRow(title: "Meeting") { Text("Important") } + // no badge → Badge == Never + let _: TaskRow = TaskRow(title: "No priority") + // String title (disfavored), resolver badge + let _: TaskRow = TaskRow(title: "Task" as String, badge: .low) // both generic - let _: EventRow = EventRow { + let _: TaskRow = TaskRow { Text("Party") - } when_: { - Text("Tomorrow") + } badge: { + Text("Custom") } } - // MARK: EventCard — resolver + optional slot - - func testEventCardResolver() { - // resolver when_, text title, generic footer - let _: EventCard = EventCard(title: "Party", when_: Date()) { Text("See you!") } - // resolver when_, text title, no footer - let _: EventCard = EventCard(title: "Party", when_: Date()) - // generic when_, text title, no footer - let _: EventCard = EventCard(title: "Party") { Text("Tomorrow") } - } - // MARK: Badge — single slot, all option combos func testBadgeSingleSlot() { diff --git a/Tests/SlotTests/SlotTests.swift b/Tests/SlotTests/SlotTests.swift index d2fab73..c5923f1 100644 --- a/Tests/SlotTests/SlotTests.swift +++ b/Tests/SlotTests/SlotTests.swift @@ -51,7 +51,7 @@ final class SlotTests: XCTestCase { """ @Slots struct Badge: View { - @Slot(.text.unlabeled) var label: Label + @Slot(.text, .unlabeled) var label: Label var body: some View { EmptyView() } } """, @@ -1360,6 +1360,35 @@ final class SlotTests: XCTestCase { ) } + func testResolverUnlabeled() { + assertMacroExpansion( + """ + @Slots + struct Row: View { + @Slot(SomeResolver.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 == SomeResolver.Output { + init(_ icon: SomeResolver.Input) { + self.icon = SomeResolver.resolve(icon) + } + } + """, + macros: testMacros + ) + } + // MARK: - Init limit test func testTooManyInitsEmitsError() {