Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ let package = Package(
"A2UICore",
"BasicCatalog",
"BasicCatalogSwiftUI",
.product(name: "Yams", package: "Yams"),
],
path: "swift/swiftui/Tests/A2UISwiftUITests"
),
Expand Down
42 changes: 42 additions & 0 deletions swift/swiftui/Sources/A2UISwiftUI/A2UIAccessibilityModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,30 @@ public struct A2UIAccessibilityModifier: ViewModifier {
attributes?.label.flatMap { $0.isEmpty ? nil : $0 }
}

/// The effective accessibility label, falling back to inferred text from the node or its child.
public var effectiveLabel: String? {
if let explicit = label {
return explicit
}
if let inferred = node.accessibilityAttributes?.label, !inferred.isEmpty {
return inferred
}
if let child = node.child(for: "child") {
return child.accessibilityAttributes?.label ?? child.string(for: "text")
}
return nil
}

/// The accessibility hint, taken from the explicit `description`.
public var hint: String? {
attributes?.description.flatMap { $0.isEmpty ? nil : $0 }
}

/// The politeness level for live announcements (`"off"`, `"polite"`, or `"assertive"`).
public var live: String? {
attributes?.live
}

/// Whether the component and its subtree are hidden from assistive technologies.
public var isHidden: Bool {
attributes?.hidden == true
Expand All @@ -77,6 +96,29 @@ public struct A2UIAccessibilityModifier: ViewModifier {
attributes?.live == "assertive"
}

/// The semantic role mapped for this component node.
public var role: String {
switch node.type {
case "Button":
return "button"
case "TextField":
return "textbox"
case "CheckBox":
return "checkbox"
case "ChoicePicker":
let variant = node.string(for: "variant") ?? "mutuallyExclusive"
return variant == "mutuallyExclusive" ? "radiogroup" : "group"
default:
return node.type.lowercased()
}
}

/// The checked state for toggle/checkbox controls, or `nil` for other components.
public var isChecked: Bool? {
guard node.type == "CheckBox" else { return nil }
return node.bool(for: "value") ?? false
}

/// The text announced when it changes: the explicit label of a live component. `nil` when
/// nothing is announced.
public var announcementText: String? {
Expand Down
228 changes: 227 additions & 1 deletion swift/swiftui/Sources/A2UISwiftUI/Surface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public struct Surface: View {
public let catalogs: [String: Catalog<ComponentImplementation>]
public let defaultCatalogID: String?
public let surfaceID: String
private let lifecycleState = LifecycleState()

public init(
viewModel: SurfaceViewModel,
Expand Down Expand Up @@ -58,6 +59,25 @@ public struct Surface: View {
self.init(viewModel: viewModel, catalogs: [catalog], defaultCatalogID: catalog.id)
}

/// Whether this surface view is currently mounted and observing model state.
public var isMounted: Bool {
lifecycleState.isMounted
}

/// The number of active reactive subscriptions held by this mounted surface.
public var activeSubscriptionCount: Int {
guard lifecycleState.isMounted else { return 0 }
let count = viewModel.componentsModel.components.count
return viewModel.rootNode != nil ? max(1, count) : count
}

/// Unmounts the surface, disposing its data model subscriptions and resolver state.
public func unmount() {
lifecycleState.isMounted = false
viewModel.dataModel.dispose()
viewModel.nodeResolver.clearReportedExpressionErrors()
}

public var body: some View {
if let rootNode = viewModel.rootNode {
ComponentNodeView(node: rootNode)
Expand All @@ -71,7 +91,213 @@ public struct Surface: View {
}

extension Surface {
/// Resolves the view builder for a node from available component catalogs and invokes it to render.
fileprivate final class LifecycleState: @unchecked Sendable {
var isMounted = true
}

/// Represents a resolved or fallback node in the surface's view hierarchy.
public struct ViewNode {
public let id: String
public let instanceID: String
public let type: String?
public let state: String
public let node: Node?
public let children: [ViewNode]

public init(
id: String,
instanceID: String,
type: String? = nil,
state: String = "resolved",
node: Node? = nil,
children: [ViewNode] = []
) {
self.id = id
self.instanceID = instanceID
self.type = type
self.state = state
self.node = node
self.children = children
}
}

/// Returns whether any registered UI catalog has a builder for the component type.
public func hasBuilder(forComponentType type: String, catalogID: String? = nil) -> Bool {
if let targetID = catalogID ?? defaultCatalogID, let catalog = catalogs[targetID] {
return catalog.components[type] != nil
}
return catalogs.values.contains { $0.components[type] != nil }
}

/// Invokes an action on `node`, enforcing `requiresUserActivation` for local function calls.
public func invokeAction(
on node: Node,
property: String = "action",
userActivated: Bool = true
) {
guard node.isValid else { return }
if !userActivated, let fnName = extractFunctionCallName(on: node, property: property),
viewModel.function(named: fnName, catalogID: node.catalogID)?.api.requiresUserActivation
== true
{
let error = FunctionError.executionFailed(
name: fnName,
message: "Function '\(fnName)' requires user activation."
)
viewModel.handleFunctionError(error, functionName: fnName)
return
}
node.action(for: property)?()
}

private func extractFunctionCallName(on node: Node, property: String) -> String? {
if let resolvedAction = node.action(for: property),
case .function(let call, _) = resolvedAction.identity
{
return call
}
let baseID = String(node.id.split(separator: ":").first ?? Substring(node.id))
guard
let rawAction = viewModel.componentsModel.get(baseID)?.properties[property],
let fnCall = rawAction["functionCall"]
else {
return nil
}
return fnCall["@call"]?.stringValue ?? fnCall["call"]?.stringValue
}
Comment thread
gspencergoog marked this conversation as resolved.
Resolved

/// Resolves the surface view hierarchy, emitting fallback placeholders for pending,
/// unknown-type, or cyclic components and reporting diagnostics to `handler`.
public func resolveViewTree(
reportingDiagnosticsTo handler: (any ActionHandling)? = nil
) -> ViewNode? {
guard viewModel.componentsModel.get("root") != nil else { return nil }
return buildViewNode(
componentID: "root",
resolvedNode: viewModel.rootNode,
visited: [],
handler: handler
)
}

private func buildViewNode(
componentID: String,
resolvedNode: Node?,
visited: Set<String>,
handler: (any ActionHandling)?
) -> ViewNode {
if visited.contains(componentID) {
reportDiagnostic(
code: "CYCLIC_REFERENCE",
message: "Cyclic component reference to '\(componentID)'",
componentID: componentID,
to: handler
)
return ViewNode(id: componentID, instanceID: componentID, state: "cyclic")
}
guard let rawComponent = viewModel.componentsModel.get(componentID) else {
return ViewNode(id: componentID, instanceID: componentID, state: "pending")
}
let explicitCatalogID = rawComponent.catalogID ?? rawComponent.properties["catalogId"]?.stringValue
if !catalogs.isEmpty,
!hasBuilder(forComponentType: rawComponent.type, catalogID: explicitCatalogID)
{
reportDiagnostic(
code: "UNKNOWN_COMPONENT_TYPE",
message: "Unknown component type '\(rawComponent.type)'",
componentID: componentID,
to: handler
)
return ViewNode(id: componentID, instanceID: componentID, state: "unknown-type")
}
var nextVisited = visited
nextVisited.insert(componentID)
let children = resolveChildViewNodes(
rawComponent: rawComponent,
resolvedNode: resolvedNode,
visited: nextVisited,
handler: handler
)
return ViewNode(
id: componentID,
instanceID: resolvedNode?.id ?? componentID,
type: rawComponent.type,
state: "resolved",
node: resolvedNode,
children: children
)
}

private func resolveChildViewNodes(
rawComponent: ComponentModel,
resolvedNode: Node?,
visited: Set<String>,
handler: (any ActionHandling)?
) -> [ViewNode] {
if let childID = rawComponent.properties["child"]?.stringValue {
let matching = findResolvedChild(id: childID, in: resolvedNode)
return [
buildViewNode(
componentID: childID,
resolvedNode: matching,
visited: visited,
handler: handler
)
]
}
guard let childrenVal = rawComponent.properties["children"] else { return [] }
if case .array(let items) = childrenVal {
let ids = items.compactMap(\.stringValue)
return ids.map { childID in
let matching = findResolvedChild(id: childID, in: resolvedNode)
return buildViewNode(
componentID: childID,
resolvedNode: matching,
visited: visited,
handler: handler
)
}
}
return (resolvedNode?.children(for: "children") ?? []).map { childNode in
let baseID = String(childNode.id.split(separator: ":").first ?? Substring(childNode.id))
return ViewNode(
id: baseID,
instanceID: childNode.id,
type: childNode.type,
state: "resolved",
node: childNode
)
}
}

private func findResolvedChild(id: String, in parentNode: Node?) -> Node? {
if let direct = parentNode?.child(for: "child"), direct.id == id {
return direct
}
return parentNode?.children(for: "children").first { $0.id == id }
}

private func reportDiagnostic(
code: String,
message: String,
componentID: String,
to handler: (any ActionHandling)?
) {
let error = RendererError.generic(
GenericError(
code: code,
surfaceID: surfaceID,
message: message,
expression: componentID,
version: viewModel.protocolVersion
)
)
handler?.handle(error: error, from: surfaceID)
}
}

extension Surface {
/// Resolves the view builder for a node from available component catalogs and renders it.
///
/// - Parameters:
/// - node: The resolved engine node to render.
Expand Down
7 changes: 6 additions & 1 deletion swift/swiftui/Sources/BasicCatalog/A2UIButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ public struct A2UIButton: View {
self.node = node
}

/// Whether the button is disabled due to a blocking validation error (`!node.isValid`) or explicit `disabled` property.
public var isDisabled: Bool {
!node.isValid || node.bool(for: "disabled") == true
}
Comment thread
gspencergoog marked this conversation as resolved.
Resolved

private var childNode: Node? {
node.child(for: "child")
}
Expand Down Expand Up @@ -71,7 +76,7 @@ public struct A2UIButton: View {
.opacity(node.isValid ? 1.0 : 0.5)
}
.buttonStyle(.plain)
.disabled(!node.isValid)
.disabled(isDisabled)
.accessibilityIdentifier("A2UIButton_\(node.id)")
}

Expand Down
28 changes: 26 additions & 2 deletions swift/swiftui/Sources/BasicCatalog/A2UITextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,33 @@ public struct A2UITextField: View {
node.string(for: "variant") ?? "shortText"
}

private var valueBinding: Binding<String> {
/// Two-way binding for the text field's `value` property.
public var valueBinding: Binding<String> {
node.binding(for: "value", default: "")
}

private var validationErrors: [String] {
/// Whether all blocking (`severity == .error`) validation checks pass.
public var isValid: Bool {
node.isValid
}

/// Active blocking validation error messages.
public var validationErrors: [String] {
node.validationErrors
}

/// Structured validation results for all failing checks, including warnings and info.
public var validationResults: [ValidationResult] {
node.validationResults
}

private var validationWarnings: [String] {
validationResults.compactMap { result in
guard result.severity == .warning || result.severity == .info else { return nil }
return result.message
}
}

private var hasError: Bool {
!validationErrors.isEmpty
}
Expand Down Expand Up @@ -105,6 +124,11 @@ public struct A2UITextField: View {
.font(.caption)
.foregroundStyle(Color.red)
.accessibilityIdentifier("A2UITextField_Error_\(node.id)")
} else if let firstWarning = validationWarnings.first {
Text(firstWarning)
.font(.caption)
.foregroundStyle(Color.orange)
.accessibilityIdentifier("A2UITextField_Warning_\(node.id)")
}
}
}
Expand Down
Loading
Loading