diff --git a/Sources/GroveViews/Views/Button/AsyncButton.swift b/Sources/GroveViews/Views/Button/AsyncButton.swift index 3635ba054..0e558ffad 100644 --- a/Sources/GroveViews/Views/Button/AsyncButton.swift +++ b/Sources/GroveViews/Views/Button/AsyncButton.swift @@ -83,7 +83,7 @@ public struct AsyncButton: View { private let role: ButtonRole? private let action: @MainActor () async throws -> Void - private let label: Label + private let label: Label? @Environment(\.defaultErrorDescription) private var defaultErrorDescription @@ -113,20 +113,11 @@ public struct AsyncButton: View { } public var body: some View { - Button(role: role, action: submitAction) { - switch processingStyle { - case .overlay: - label - .processingOverlay(isProcessing: isConsideredProcessing) - case .listRow: - ListRow { - label - .foregroundStyle(consideredDisabled ? .tertiary : .primary) - } content: { - if isConsideredProcessing { - ProgressView() - } - } + Group { + if let label { + customLabelButton(label) + } else if #available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *) { + roleOnlyButton } } .disabled(consideredDisabled) @@ -141,6 +132,27 @@ public struct AsyncButton: View { } } + @available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *) + @ViewBuilder + private var roleOnlyButton: some View { + if let role { + switch processingStyle { + case .overlay: + Button(role: role, action: submitAction) + .processingOverlay(isProcessing: isConsideredProcessing) + case .listRow: + Button(role: role, action: submitAction) + .foregroundStyle(consideredDisabled ? .tertiary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .trailing) { + if isConsideredProcessing { + ProgressView() + } + } + } + } + } + /// Creates an async button that generates its label from a provided localized string. /// - Parameters: /// - title: The localized string used to generate the Label. @@ -244,6 +256,66 @@ public struct AsyncButton: View { self.label = label() } + /// Creates an async button with a system-provided label for the supplied role. + /// - Parameters: + /// - role: A button role that determines the system-provided label. + /// - action: An asynchronous button action. + @available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *) + public init( + role: ButtonRole, + action: @MainActor @escaping () async -> Void + ) where Label == DefaultButtonLabel { + self.role = role + self._viewState = .constant(.idle) + self.action = action + self.label = nil + } + + /// Creates an async throwing button with a system-provided label for the supplied role. + /// - Parameters: + /// - role: A button role that determines the system-provided label. + /// - state: A ``ViewState`` binding that is used to propagate any error caught in the button action. + /// It may also be used to externally control or observe the button's processing state. + /// - action: An asynchronous button action. + @available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *) + public init( + role: ButtonRole, + state: Binding, + action: @MainActor @escaping () async throws -> Void + ) where Label == DefaultButtonLabel { + self.role = role + self._viewState = state + self.action = action + self.label = nil + } + + @ViewBuilder + private func customLabelButton(_ label: Label) -> some View { + Button(role: role, action: submitAction) { + switch processingStyle { + case .overlay: + label + .processingOverlay(isProcessing: isConsideredProcessing) + case .listRow: + ListRow { + label + .foregroundStyle(consideredDisabled ? .tertiary : .primary) + } content: { + if isConsideredProcessing { + ProgressView() + } + } + } + } + .accessibilityRepresentation { + Button(role: role, action: submitAction) { + label + } + if isConsideredProcessing { + ProgressView() + } + } + } private func submitAction() { guard buttonState == .idle else { diff --git a/Tests/GroveViewsTests/UITests/TestApp/ViewsTests/AsyncButtonToolbarTest.swift b/Tests/GroveViewsTests/UITests/TestApp/ViewsTests/AsyncButtonToolbarTest.swift index 503010a15..12a3030e5 100644 --- a/Tests/GroveViewsTests/UITests/TestApp/ViewsTests/AsyncButtonToolbarTest.swift +++ b/Tests/GroveViewsTests/UITests/TestApp/ViewsTests/AsyncButtonToolbarTest.swift @@ -11,17 +11,51 @@ import SwiftUI struct AsyncButtonToolbarTestSheet: View { + @State private var cancelState: ViewState = .idle + @State private var didCancel = false + @State private var didFinishListRowAction = false + @State private var didFinishOverlayAction = false + @State private var didFinishStatelessAction = false @State private var didTap = false + @State private var listRowState: ViewState = .idle + @State private var overlayState: ViewState = .idle var body: some View { NavigationStack { Form { + LabeledContent("Did cancel", value: didCancel.description) LabeledContent("Did tap", value: didTap.description) + Section("Role-only processing") { + AsyncButton(role: .confirm, state: $overlayState) { + try await Task.sleep(for: .seconds(2)) + didFinishOverlayAction = true + } + .accessibilityIdentifier("Role Only Overlay") + LabeledContent("Overlay completed", value: didFinishOverlayAction.description) + + AsyncButton(role: .confirm, state: $listRowState) { + try await Task.sleep(for: .seconds(2)) + didFinishListRowAction = true + } + .asyncButtonProcessingStyle(.listRow) + .accessibilityIdentifier("Role Only List Row") + LabeledContent("List row completed", value: didFinishListRowAction.description) + } + Section("Role-only without state") { + AsyncButton(role: .confirm) { + didFinishStatelessAction = true + } + .accessibilityIdentifier("Role Only Stateless") + LabeledContent("Stateless completed", value: didFinishStatelessAction.description) + } } .navigationTitle("AsyncButtonInToolbar") .toolbar { ToolbarItem(placement: .cancellationAction) { - DismissButton() + AsyncButton(role: .cancel, state: $cancelState) { + didCancel = true + } + .accessibilityIdentifier("Role Only Cancel") } ToolbarItem(placement: .primaryAction) { AsyncButton("Tap Me!") { diff --git a/Tests/GroveViewsTests/UITests/TestAppUITests/GroveViews/ViewsTests.swift b/Tests/GroveViewsTests/UITests/TestAppUITests/GroveViews/ViewsTests.swift index 7c8c1cec0..47fce3040 100644 --- a/Tests/GroveViewsTests/UITests/TestAppUITests/GroveViews/ViewsTests.swift +++ b/Tests/GroveViewsTests/UITests/TestAppUITests/GroveViews/ViewsTests.swift @@ -168,12 +168,45 @@ final class ViewsTests: XCTestCase { let app = XCUIApplication() XCTAssertTrue(app.launchAndWait(for: app.buttons["AsyncButton Toolbar Behaviour"])) app.buttons["AsyncButton Toolbar Behaviour"].tap() + XCTAssert(app.staticTexts["Did cancel, false"].waitForExistence(timeout: 2)) XCTAssert(app.staticTexts["Did tap, false"].waitForExistence(timeout: 2)) + let cancelButton = app.navigationBars["AsyncButtonInToolbar"].buttons["Role Only Cancel"] + XCTAssert(cancelButton.waitForExistence(timeout: 2)) + XCTAssertEqual(cancelButton.label, "Cancel") + cancelButton.tap() + XCTAssert(app.staticTexts["Did cancel, true"].waitForExistence(timeout: 2)) let toolbarButton = app.navigationBars["AsyncButtonInToolbar"].buttons["Tap Me!"] XCTAssert(toolbarButton.wait(for: \.isHittable, toEqual: true, timeout: 2)) toolbarButton.tap() XCTAssert(app.staticTexts["Did tap, true"].waitForExistence(timeout: 2)) + + let overlayButton = app.buttons["Role Only Overlay"] + XCTAssert(overlayButton.waitForExistence(timeout: 2)) + XCTAssertEqual(overlayButton.label, "Done") + overlayButton.tap() + XCTAssert(app.activityIndicators.firstMatch.waitForExistence(timeout: 2)) + XCTAssertFalse(overlayButton.isEnabled) + XCTAssert(app.staticTexts["Overlay completed, true"].waitForExistence(timeout: 4)) + XCTAssert(app.activityIndicators.firstMatch.waitForNonExistence(timeout: 2)) + XCTAssertTrue(overlayButton.isEnabled) + + let listRowButton = app.buttons["Role Only List Row"] + XCTAssert(listRowButton.waitForExistence(timeout: 2)) + XCTAssertEqual(listRowButton.label, "Done") + listRowButton.tap() + XCTAssert(app.activityIndicators.firstMatch.waitForExistence(timeout: 2)) + XCTAssertFalse(listRowButton.isEnabled) + XCTAssert(app.staticTexts["List row completed, true"].waitForExistence(timeout: 4)) + XCTAssert(app.activityIndicators.firstMatch.waitForNonExistence(timeout: 2)) + XCTAssertTrue(listRowButton.isEnabled) + + let statelessButton = app.buttons["Role Only Stateless"] + XCTAssert(statelessButton.waitForExistence(timeout: 2)) + XCTAssertEqual(statelessButton.label, "Done") + XCTAssert(app.staticTexts["Stateless completed, false"].waitForExistence(timeout: 2)) + statelessButton.tap() + XCTAssert(app.staticTexts["Stateless completed, true"].waitForExistence(timeout: 2)) } @MainActor