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..a55817b --- /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..f959ae9 --- /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") {} + }) + + // 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() } + + // 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/ResolverExample.swift b/Sources/SlotExamples/ResolverExample.swift new file mode 100644 index 0000000..fbadb57 --- /dev/null +++ b/Sources/SlotExamples/ResolverExample.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 + +public enum PriorityResolver: SlotResolver { + public typealias Input = Priority + public typealias Output = PriorityBadge + public static func resolve(_ input: Priority) -> PriorityBadge { + PriorityBadge(priority: input) + } +} + +// MARK: - Component using the resolver + +@Slots public struct TaskRow: View { + @Slot(.text) var title: Title + @Slot(PriorityResolver.self, .unlabeled) 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..92f69cb 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 resolvers: [ResolverOption] let declarationIndex: Int } @@ -271,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) } @@ -308,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 @@ -322,32 +327,43 @@ 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), declarationIndex: memberIndex) + 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) } // `Icon` — slot only if @Slot annotated 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), declarationIndex: memberIndex) + 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))) } @@ -358,22 +374,61 @@ 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 text = OptionFlags(rawValue: 1 << 0) + static let systemImage = OptionFlags(rawValue: 1 << 1) + 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: [ResolverOption] = [] + + mutating func merge(_ other: ParsedOptions) { + flags.formUnion(other.flags) + resolvers.append(contentsOf: other.resolvers) + } } 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 { - switch arg.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text { - case "text": result.insert(.text) - case "systemImage": result.insert(.systemImage) + 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 + { + resolverNames.append(base.trimmedDescription) + continue + } + + // 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 } @@ -386,6 +441,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 } @@ -401,6 +457,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.typeName)) + } if slot.isOptional { modes.append(.empty) } guard !combos.isEmpty else { return modes.map { [$0] } } @@ -446,10 +505,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 +517,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 +525,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 +537,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 +564,27 @@ private func extensionGroups( declarationIndex: slot.declarationIndex )) } + case .resolved(let typeName): + constraints.append("\(slot.genericParam) == \(typeName).Output") + 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 86a3526..139d89d 100644 --- a/Sources/Slots/Slot.swift +++ b/Sources/Slots/Slot.swift @@ -7,5 +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:)` +/// - `.unlabeled` — omit the external parameter label (`_ name:`) in convenience inits @attached(peer) public macro Slot(_ options: SlotOption...) = #externalMacro(module: "SlotMacros", type: "SlotPropertyMacro") + +/// 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, _ options: SlotOption...) = + #externalMacro( + module: "SlotMacros", type: "SlotPropertyMacro" + ) diff --git a/Sources/Slots/SlotOption.swift b/Sources/Slots/SlotOption.swift index 557d716..ededebc 100644 --- a/Sources/Slots/SlotOption.swift +++ b/Sources/Slots/SlotOption.swift @@ -2,8 +2,16 @@ 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) + + /// Omit the external parameter label from the generated init. + public static let unlabeled = SlotOption(id: 2) } 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/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/SlotIntegrationTests.swift b/Tests/SlotTests/SlotIntegrationTests.swift index f78989c..d8ba646 100644 --- a/Tests/SlotTests/SlotIntegrationTests.swift +++ b/Tests/SlotTests/SlotIntegrationTests.swift @@ -2,8 +2,32 @@ import Slots import SwiftUI import XCTest +// MARK: - Resolver + +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 TaskRow: View { + @Slot(.text) var title: Title + @Slot(PriorityResolver.self) var badge: Badge? + var body: some View { EmptyView() } +} + @Slots struct Badge: View { @Slot(.text) var label: Label? @@ -34,6 +58,25 @@ struct Row: View { @MainActor final class SlotIntegrationTests: XCTestCase { + // 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 _: TaskRow = TaskRow { + Text("Party") + } badge: { + Text("Custom") + } + } + // MARK: Badge — single slot, all option combos func testBadgeSingleSlot() { diff --git a/Tests/SlotTests/SlotTests.swift b/Tests/SlotTests/SlotTests.swift index 46e0f47..c5923f1 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( """ @@ -1199,6 +1233,162 @@ 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 + ) + } + + 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() {