diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_17_5_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_17_5_393x852@3x.png index 1c8d040e..60ea2214 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_17_5_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_17_5_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_18_5_402x874@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_18_5_402x874@3x.png new file mode 100644 index 00000000..9f9f97fa Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_18_5_402x874@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_26_2_402x874@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_26_2_402x874@3x.png new file mode 100644 index 00000000..350e3527 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testCustomContent_26_2_402x874@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testTabBars_17_5_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testTabBars_17_5_393x852@3x.png index 51d25d1d..cb1108c8 100644 Binary files a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testTabBars_17_5_393x852@3x.png and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.AccessibilitySnapshotTests/testTabBars_17_5_393x852@3x.png differ diff --git a/Example/UnitTests/AccessibilityHierarchyParserTests.swift b/Example/UnitTests/AccessibilityHierarchyParserTests.swift index 6b049d26..0a4989dd 100644 --- a/Example/UnitTests/AccessibilityHierarchyParserTests.swift +++ b/Example/UnitTests/AccessibilityHierarchyParserTests.swift @@ -1,5 +1,5 @@ -import AccessibilitySnapshotCore -import AccessibilitySnapshotParser +@testable import AccessibilitySnapshotCore +@testable import AccessibilitySnapshotParser import UIKit import XCTest @@ -33,18 +33,18 @@ final class AccessibilityHierarchyParserTests: XCTestCase { let parser = AccessibilityHierarchyParser() - let ltrElements = parser.parseAccessibilityElements( + let ltrElements = parser.parseAccessibilityHierarchy( in: gridView, userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight), userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone) - ).map { $0.description } + ).flattenToElements().map { $0.description } XCTAssertEqual(ltrElements, ["A", "B", "C", "D"]) - let rtlElements = parser.parseAccessibilityElements( + let rtlElements = parser.parseAccessibilityHierarchy( in: gridView, userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .rightToLeft), userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone) - ).map { $0.description } + ).flattenToElements().map { $0.description } XCTAssertEqual(rtlElements, ["B", "A", "D", "C"]) } @@ -79,21 +79,21 @@ final class AccessibilityHierarchyParserTests: XCTestCase { let parser = AccessibilityHierarchyParser() - let padElements = parser.parseAccessibilityElements( + let padElements = parser.parseAccessibilityHierarchy( in: gridView, userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight), userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .pad) - ).map { $0.description } + ).flattenToElements().map { $0.description } // on pad elements are sorted horizontally XCTAssertEqual(padElements, ["A", "B", "C", "D"]) - let phoneElements = parser.parseAccessibilityElements( + let phoneElements = parser.parseAccessibilityHierarchy( in: gridView, userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight), userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone) - ).map { $0.description } + ).flattenToElements().map { $0.description } // on phone elements are sorted vertically and then left to right XCTAssertEqual(phoneElements, ["C", "D", "B", "A"]) @@ -104,16 +104,894 @@ final class AccessibilityHierarchyParserTests: XCTestCase { elementC.accessibilityFrame = .init(x: 20, y: -padMagicNumber, width: 10, height: 10) elementD.accessibilityFrame = .init(x: 30, y: -padMagicNumber, width: 10, height: 10) - let padAgain = parser.parseAccessibilityElements( + let padAgain = parser.parseAccessibilityHierarchy( in: gridView, userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight), userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .pad) - ).map { $0.description } + ).flattenToElements().map { $0.description } // Now pad elements are sorted vertically and then left to right XCTAssertEqual(padAgain, ["C", "D", "B", "A"]) } + + // MARK: - Container Hierarchy Tree Tests + + func testSemanticGroupWithLabelIsPreserved() { + let rootView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100)) + + let container = UIView(frame: .init(x: 0, y: 0, width: 100, height: 50)) + container.accessibilityContainerType = .semanticGroup + container.accessibilityLabel = "Group Label" + rootView.addSubview(container) + + let element = UIView(frame: .init(x: 10, y: 10, width: 30, height: 30)) + element.isAccessibilityElement = true + element.accessibilityLabel = "Element" + element.accessibilityFrame = CGRect(x: 10, y: 10, width: 30, height: 30) + container.addSubview(element) + + let parser = AccessibilityHierarchyParser() + let hierarchy = parser.parseAccessibilityHierarchy(in: rootView) + + // Should have one container at root level + XCTAssertEqual(hierarchy.count, 1) + + // Verify it's a container with correct label + if case let .container(containerInfo, children) = hierarchy.first { + if case let .semanticGroup(label, _, _) = containerInfo.type { + XCTAssertEqual(label, "Group Label") + } else { + XCTFail("Expected semanticGroup container type") + } + XCTAssertEqual(children.count, 1) + + // Verify child element + if case let .element(childElement, _) = children.first { + XCTAssertEqual(childElement.description, "Element") + } else { + XCTFail("Expected element child") + } + } else { + XCTFail("Expected container at root level") + } + } + + func testSemanticGroupWithoutLabelIsFlattened() { + let rootView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100)) + + let container = UIView(frame: .init(x: 0, y: 0, width: 100, height: 50)) + container.accessibilityContainerType = .semanticGroup + // No label, value, or identifier + rootView.addSubview(container) + + let element = UIView(frame: .init(x: 10, y: 10, width: 30, height: 30)) + element.isAccessibilityElement = true + element.accessibilityLabel = "Element" + element.accessibilityFrame = CGRect(x: 10, y: 10, width: 30, height: 30) + container.addSubview(element) + + let parser = AccessibilityHierarchyParser() + let hierarchy = parser.parseAccessibilityHierarchy(in: rootView) + + // Should have one element at root level (container flattened) + XCTAssertEqual(hierarchy.count, 1) + + // Verify it's an element, not a container + if case let .element(elementInfo, _) = hierarchy.first { + XCTAssertEqual(elementInfo.description, "Element") + } else { + XCTFail("Expected element at root level (container should be flattened)") + } + } + + func testListContainerIsAlwaysPreserved() { + let rootView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100)) + + let listContainer = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100)) + listContainer.accessibilityContainerType = .list + // No label - but should still be preserved + rootView.addSubview(listContainer) + + let item1 = UIView(frame: .init(x: 0, y: 0, width: 100, height: 30)) + item1.isAccessibilityElement = true + item1.accessibilityLabel = "Item 1" + item1.accessibilityFrame = CGRect(x: 0, y: 0, width: 100, height: 30) + listContainer.addSubview(item1) + + let item2 = UIView(frame: .init(x: 0, y: 40, width: 100, height: 30)) + item2.isAccessibilityElement = true + item2.accessibilityLabel = "Item 2" + item2.accessibilityFrame = CGRect(x: 0, y: 40, width: 100, height: 30) + listContainer.addSubview(item2) + + let parser = AccessibilityHierarchyParser() + let hierarchy = parser.parseAccessibilityHierarchy(in: rootView) + + // Should have one list container at root level + XCTAssertEqual(hierarchy.count, 1) + + if case let .container(containerInfo, children) = hierarchy.first { + XCTAssertEqual(containerInfo.type, .list) + XCTAssertEqual(children.count, 2) + } else { + XCTFail("Expected list container at root level") + } + } + + func testLandmarkContainerIsAlwaysPreserved() { + let rootView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100)) + + let landmarkContainer = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100)) + landmarkContainer.accessibilityContainerType = .landmark + rootView.addSubview(landmarkContainer) + + let element = UIView(frame: .init(x: 10, y: 10, width: 30, height: 30)) + element.isAccessibilityElement = true + element.accessibilityLabel = "Landmark Content" + element.accessibilityFrame = CGRect(x: 10, y: 10, width: 30, height: 30) + landmarkContainer.addSubview(element) + + let parser = AccessibilityHierarchyParser() + let hierarchy = parser.parseAccessibilityHierarchy(in: rootView) + + XCTAssertEqual(hierarchy.count, 1) + + if case let .container(containerInfo, _) = hierarchy.first { + XCTAssertEqual(containerInfo.type, .landmark) + } else { + XCTFail("Expected landmark container at root level") + } + } + + func testNestedContainersPreserveHierarchy() { + // Use NestedContainersTestView which mirrors ContainerHierarchyViewController's NestedContainersDemoView + let nestedView = NestedContainersTestView(frame: CGRect(x: 0, y: 0, width: 200, height: 100)) + + let parser = AccessibilityHierarchyParser() + let hierarchy = parser.parseAccessibilityHierarchy(in: nestedView) + + // Should have outer container at root + XCTAssertEqual(hierarchy.count, 1) + + if case let .container(outerInfo, outerChildren) = hierarchy.first { + if case let .semanticGroup(label, _, _) = outerInfo.type { + XCTAssertEqual(label, "Outer Container") + } else { + XCTFail("Expected semanticGroup container type for outer") + } + + // Should have 2 children: "Outer Item" element and inner container + XCTAssertEqual(outerChildren.count, 2) + + // Find the outer item element + let outerElements = outerChildren.compactMap { node -> AccessibilityElement? in + if case let .element(element, _) = node { return element } + return nil + } + XCTAssertEqual(outerElements.count, 1) + XCTAssertEqual(outerElements.first?.description, "Outer Item") + + // Find the inner container + let innerContainers = outerChildren.compactMap { node -> (AccessibilityContainer, [AccessibilityHierarchy])? in + if case let .container(info, children) = node { return (info, children) } + return nil + } + XCTAssertEqual(innerContainers.count, 1) + if let innerContainer = innerContainers.first?.0, + case let .semanticGroup(label, _, _) = innerContainer.type + { + XCTAssertEqual(label, "Inner Container") + } else { + XCTFail("Expected semanticGroup container type for inner") + } + + // Inner container should have 2 element children + if let innerChildren = innerContainers.first?.1 { + let innerElements = innerChildren.compactMap { node -> AccessibilityElement? in + if case let .element(element, _) = node { return element } + return nil + } + XCTAssertEqual(innerElements.count, 2) + XCTAssertEqual(innerElements.map { $0.description }, ["Inner Item 1", "Inner Item 2"]) + } + } else { + XCTFail("Expected outer container") + } + + // Verify flattening produces correct element order + let flattenedElements = hierarchy.flattenToElements() + XCTAssertEqual(flattenedElements.map { $0.description }, ["Outer Item", "Inner Item 1", "Inner Item 2"]) + + // Verify flattenToContainers gets both containers + let containers = hierarchy.flattenToContainers() + XCTAssertEqual(containers.count, 2) + let containerLabels = containers.compactMap { container -> String? in + if case let .semanticGroup(label, _, _) = container.type { return label } + return nil + } + XCTAssertEqual(Set(containerLabels), ["Outer Container", "Inner Container"]) + } + + func testHierarchySortOrder() { + let rootView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100)) + + // Add elements in reverse order + let elementC = UIView(frame: .init(x: 0, y: 60, width: 30, height: 30)) + elementC.isAccessibilityElement = true + elementC.accessibilityLabel = "C" + elementC.accessibilityFrame = CGRect(x: 0, y: 60, width: 30, height: 30) + rootView.addSubview(elementC) + + let elementB = UIView(frame: .init(x: 0, y: 30, width: 30, height: 30)) + elementB.isAccessibilityElement = true + elementB.accessibilityLabel = "B" + elementB.accessibilityFrame = CGRect(x: 0, y: 30, width: 30, height: 30) + rootView.addSubview(elementB) + + let elementA = UIView(frame: .init(x: 0, y: 0, width: 30, height: 30)) + elementA.isAccessibilityElement = true + elementA.accessibilityLabel = "A" + elementA.accessibilityFrame = CGRect(x: 0, y: 0, width: 30, height: 30) + rootView.addSubview(elementA) + + let parser = AccessibilityHierarchyParser() + let hierarchy = parser.parseAccessibilityHierarchy(in: rootView) + let flattenedDescriptions = hierarchy.flattenToElements().map { $0.description } + + // Should be sorted by position (top to bottom) + XCTAssertEqual(flattenedDescriptions, ["A", "B", "C"]) + } + + func testContainerChildrenSortOrder() { + let rootView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 200)) + + let container = UIView(frame: .init(x: 0, y: 0, width: 100, height: 200)) + container.accessibilityContainerType = .list + rootView.addSubview(container) + + // Add in reverse order + let item3 = UIView(frame: .init(x: 0, y: 120, width: 100, height: 30)) + item3.isAccessibilityElement = true + item3.accessibilityLabel = "Third" + item3.accessibilityFrame = CGRect(x: 0, y: 120, width: 100, height: 30) + container.addSubview(item3) + + let item1 = UIView(frame: .init(x: 0, y: 0, width: 100, height: 30)) + item1.isAccessibilityElement = true + item1.accessibilityLabel = "First" + item1.accessibilityFrame = CGRect(x: 0, y: 0, width: 100, height: 30) + container.addSubview(item1) + + let item2 = UIView(frame: .init(x: 0, y: 60, width: 100, height: 30)) + item2.isAccessibilityElement = true + item2.accessibilityLabel = "Second" + item2.accessibilityFrame = CGRect(x: 0, y: 60, width: 100, height: 30) + container.addSubview(item2) + + let parser = AccessibilityHierarchyParser() + let hierarchy = parser.parseAccessibilityHierarchy(in: rootView) + + if case let .container(_, children) = hierarchy.first { + let childDescriptions = children.compactMap { node -> String? in + if case let .element(element, _) = node { return element.description } + return nil + } + // Children should be sorted by position + XCTAssertEqual(childDescriptions, ["First", "Second", "Third"]) + } else { + XCTFail("Expected list container") + } + } + + func testFlattenToContainers() { + let rootView = UIView(frame: .init(x: 0, y: 0, width: 200, height: 200)) + + let list = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100)) + list.accessibilityContainerType = .list + list.accessibilityLabel = "My List" + rootView.addSubview(list) + + let landmark = UIView(frame: .init(x: 100, y: 0, width: 100, height: 100)) + landmark.accessibilityContainerType = .landmark + landmark.accessibilityLabel = "My Landmark" + rootView.addSubview(landmark) + + let listItem = UIView(frame: .init(x: 10, y: 10, width: 30, height: 30)) + listItem.isAccessibilityElement = true + listItem.accessibilityLabel = "List Item" + listItem.accessibilityFrame = CGRect(x: 10, y: 10, width: 30, height: 30) + list.addSubview(listItem) + + let landmarkContent = UIView(frame: .init(x: 110, y: 10, width: 30, height: 30)) + landmarkContent.isAccessibilityElement = true + landmarkContent.accessibilityLabel = "Landmark Content" + landmarkContent.accessibilityFrame = CGRect(x: 110, y: 10, width: 30, height: 30) + landmark.addSubview(landmarkContent) + + let parser = AccessibilityHierarchyParser() + let hierarchy = parser.parseAccessibilityHierarchy(in: rootView) + let containers = hierarchy.flattenToContainers() + + XCTAssertEqual(containers.count, 2) + + let hasListContainer = containers.contains { + if case .list = $0.type { return true } + return false + } + let hasLandmarkContainer = containers.contains { + if case .landmark = $0.type { return true } + return false + } + XCTAssertTrue(hasListContainer) + XCTAssertTrue(hasLandmarkContainer) + } + + // MARK: - Codable Tests + + func testAccessibilityElementCodable() throws { + let element = AccessibilityElement( + description: "Test Button", + label: "Button Label", + value: "Button Value", + traits: [.button, .selected], + identifier: "test-button-id", + hint: "Double tap to activate", + userInputLabels: ["tap button", "press button"], + shape: .frame(CGRect(x: 10, y: 20, width: 100, height: 44)), + activationPoint: CGPoint(x: 60, y: 42), + usesDefaultActivationPoint: true, + customActions: [AccessibilityElement.CustomAction(name: "Delete", image: nil)], + customContent: [], + customRotors: [], + accessibilityLanguage: "en-US", + respondsToUserInteraction: true, + containerContext: nil + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(element) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityElement.self, from: data) + + XCTAssertEqual(decoded.description, element.description) + XCTAssertEqual(decoded.label, element.label) + XCTAssertEqual(decoded.value, element.value) + XCTAssertEqual(decoded.traits, element.traits) + XCTAssertEqual(decoded.identifier, element.identifier) + XCTAssertEqual(decoded.hint, element.hint) + XCTAssertEqual(decoded.userInputLabels, element.userInputLabels) + XCTAssertEqual(decoded.shape, element.shape) + XCTAssertEqual(decoded.activationPoint, element.activationPoint) + XCTAssertEqual(decoded.usesDefaultActivationPoint, element.usesDefaultActivationPoint) + XCTAssertEqual(decoded.customActions.map { $0.name }, element.customActions.map { $0.name }) + XCTAssertEqual(decoded.accessibilityLanguage, element.accessibilityLanguage) + XCTAssertEqual(decoded.respondsToUserInteraction, element.respondsToUserInteraction) + } + + func testAccessibilityContainerCodable() throws { + let container = AccessibilityContainer( + type: .list, + frame: CGRect(x: 0, y: 0, width: 320, height: 200) + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(container) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityContainer.self, from: data) + + XCTAssertEqual(decoded.type, .list) + XCTAssertEqual(decoded.frame, container.frame) + } + + func testAccessibilityHierarchyCodable() throws { + let element1 = AccessibilityElement( + description: "Item 1", + label: "Item 1", + value: nil, + traits: [], + identifier: nil, + hint: nil, + userInputLabels: nil, + shape: .frame(CGRect(x: 0, y: 0, width: 100, height: 44)), + activationPoint: CGPoint(x: 50, y: 22), + usesDefaultActivationPoint: true, + customActions: [], + customContent: [], + customRotors: [], + accessibilityLanguage: nil, + respondsToUserInteraction: false, + containerContext: nil + ) + + let element2 = AccessibilityElement( + description: "Item 2", + label: "Item 2", + value: nil, + traits: [], + identifier: nil, + hint: nil, + userInputLabels: nil, + shape: .frame(CGRect(x: 0, y: 50, width: 100, height: 44)), + activationPoint: CGPoint(x: 50, y: 72), + usesDefaultActivationPoint: true, + customActions: [], + customContent: [], + customRotors: [], + accessibilityLanguage: nil, + respondsToUserInteraction: false, + containerContext: nil + ) + + let container = AccessibilityContainer( + type: .list, + frame: CGRect(x: 0, y: 0, width: 100, height: 100) + ) + + let hierarchy: [AccessibilityHierarchy] = [ + .container(container, children: [ + .element(element1, traversalIndex: 0), + .element(element2, traversalIndex: 1), + ]), + ] + + let encoder = JSONEncoder() + let data = try encoder.encode(hierarchy) + + let decoder = JSONDecoder() + let decoded = try decoder.decode([AccessibilityHierarchy].self, from: data) + + XCTAssertEqual(decoded.count, 1) + + if case let .container(decodedContainer, children) = decoded.first { + XCTAssertEqual(decodedContainer.type, .list) + XCTAssertEqual(children.count, 2) + + if case let .element(child1, index1) = children[0] { + XCTAssertEqual(child1.description, "Item 1") + XCTAssertEqual(index1, 0) + } else { + XCTFail("Expected element child") + } + + if case let .element(child2, index2) = children[1] { + XCTAssertEqual(child2.description, "Item 2") + XCTAssertEqual(index2, 1) + } else { + XCTFail("Expected element child") + } + } else { + XCTFail("Expected container at root") + } + } + + func testShapeCodableWithPath() throws { + let path = UIBezierPath(roundedRect: CGRect(x: 10, y: 20, width: 100, height: 50), cornerRadius: 8) + let shape = AccessibilityElement.Shape.path(path) + + let encoder = JSONEncoder() + let data = try encoder.encode(shape) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityElement.Shape.self, from: data) + + if case let .path(decodedPath) = decoded { + XCTAssertEqual(decodedPath.bounds, path.bounds) + } else { + XCTFail("Expected path shape") + } + } + + func testContainerTypeCodable() throws { + let types: [AccessibilityContainer.ContainerType] = [ + .list, + .landmark, + .tabBar, + .semanticGroup(label: "Test", value: nil, identifier: "test-id"), + .dataTable(rowCount: 3, columnCount: 4), + ] + + for type in types { + let encoder = JSONEncoder() + let data = try encoder.encode(type) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityContainer.ContainerType.self, from: data) + + XCTAssertEqual(decoded, type) + } + } + + func testTraitsCodable() throws { + let traits: UIAccessibilityTraits = [.button, .selected, .header, .link] + + let encoder = JSONEncoder() + let data = try encoder.encode(traits) + + // Verify human-readable format (array of trait names) + let jsonArray = try JSONSerialization.jsonObject(with: data) as! [String] + XCTAssertTrue(jsonArray.contains("button"), "Traits should include 'button'") + XCTAssertTrue(jsonArray.contains("selected"), "Traits should include 'selected'") + XCTAssertTrue(jsonArray.contains("header"), "Traits should include 'header'") + XCTAssertTrue(jsonArray.contains("link"), "Traits should include 'link'") + + let decoder = JSONDecoder() + let decoded = try decoder.decode(UIAccessibilityTraits.self, from: data) + + XCTAssertEqual(decoded, traits) + } + + func testTraitsEmptyEncodesAsEmptyArray() throws { + let traits: UIAccessibilityTraits = [] + + let encoder = JSONEncoder() + let data = try encoder.encode(traits) + + let jsonString = String(data: data, encoding: .utf8)! + XCTAssertEqual(jsonString, "[]", "Empty traits should encode as empty array") + + let decoder = JSONDecoder() + let decoded = try decoder.decode(UIAccessibilityTraits.self, from: data) + + XCTAssertEqual(decoded, traits) + } + + func testShapePathEncodesAsPathElements() throws { + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: 100, y: 0)) + path.addLine(to: CGPoint(x: 100, y: 50)) + path.close() + + let shape = AccessibilityElement.Shape.path(path) + + let encoder = JSONEncoder() + let data = try encoder.encode(shape) + + // Verify round-trip works + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityElement.Shape.self, from: data) + + if case let .path(decodedPath) = decoded { + XCTAssertEqual(decodedPath.bounds, path.bounds) + } else { + XCTFail("Expected path shape") + } + } + + func testCustomActionWithImageCodable() throws { + // Create a simple red image for testing + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 10, height: 10)) + let testImage = renderer.image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 10, height: 10)) + } + + let action = AccessibilityElement.CustomAction(name: "Delete", image: testImage) + + let encoder = JSONEncoder() + let data = try encoder.encode(action) + + // Verify imageData and imageScale are included + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(json["name"] as? String, "Delete") + XCTAssertNotNil(json["imageData"], "Image should be encoded as imageData") + XCTAssertNotNil(json["imageScale"], "Image scale should be encoded") + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityElement.CustomAction.self, from: data) + + XCTAssertEqual(decoded.name, "Delete") + XCTAssertNotNil(decoded.image, "Image should be decoded") + XCTAssertEqual(decoded.image?.size, testImage.size, "Image size should be preserved") + XCTAssertEqual(decoded.image?.scale, testImage.scale, "Image scale should be preserved") + } + + func testCustomActionWithoutImageCodable() throws { + let action = AccessibilityElement.CustomAction(name: "Edit", image: nil) + + let encoder = JSONEncoder() + let data = try encoder.encode(action) + + // Verify imageData is not included when image is nil + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(json["name"] as? String, "Edit") + XCTAssertNil(json["imageData"], "imageData should not be present when image is nil") + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityElement.CustomAction.self, from: data) + + XCTAssertEqual(decoded.name, "Edit") + XCTAssertNil(decoded.image) + } + + // MARK: - Data Table Tests + + func testDataTableContainerWithDimensions() { + let rootView = UIView(frame: .init(x: 0, y: 0, width: 200, height: 200)) + + let dataTable = TestDataTableView( + frame: CGRect(x: 0, y: 0, width: 200, height: 200), + rows: 5, + columns: 4 + ) + rootView.addSubview(dataTable) + + // Add some cells + let cell1 = TestDataTableCell(row: 0, column: 0, label: "A1") + cell1.frame = CGRect(x: 0, y: 0, width: 50, height: 40) + cell1.accessibilityFrame = CGRect(x: 0, y: 0, width: 50, height: 40) + dataTable.addSubview(cell1) + dataTable.cells[CellIndex(row: 0, column: 0)] = cell1 + + let cell2 = TestDataTableCell(row: 0, column: 1, label: "B1") + cell2.frame = CGRect(x: 50, y: 0, width: 50, height: 40) + cell2.accessibilityFrame = CGRect(x: 50, y: 0, width: 50, height: 40) + dataTable.addSubview(cell2) + dataTable.cells[CellIndex(row: 0, column: 1)] = cell2 + + let parser = AccessibilityHierarchyParser() + let hierarchy = parser.parseAccessibilityHierarchy(in: rootView) + + // Should have one container with dataTable type + XCTAssertEqual(hierarchy.count, 1) + + if case let .container(container, children) = hierarchy.first { + if case let .dataTable(rowCount, columnCount) = container.type { + XCTAssertEqual(rowCount, 5) + XCTAssertEqual(columnCount, 4) + } else { + XCTFail("Expected dataTable container type") + } + XCTAssertEqual(children.count, 2) + } else { + XCTFail("Expected dataTable container") + } + } + + func testDataTableContainerCodable() throws { + let container = AccessibilityContainer( + type: .dataTable(rowCount: 5, columnCount: 4), + frame: CGRect(x: 0, y: 0, width: 320, height: 200) + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(container) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityContainer.self, from: data) + + if case let .dataTable(rowCount, columnCount) = decoded.type { + XCTAssertEqual(rowCount, 5) + XCTAssertEqual(columnCount, 4) + } else { + XCTFail("Expected dataTable type") + } + } + + func testSemanticGroupContainerCodable() throws { + let container = AccessibilityContainer( + type: .semanticGroup(label: "Group Label", value: "Group Value", identifier: "group-id"), + frame: CGRect(x: 0, y: 0, width: 200, height: 100) + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(container) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityContainer.self, from: data) + + if case let .semanticGroup(label, value, identifier) = decoded.type { + XCTAssertEqual(label, "Group Label") + XCTAssertEqual(value, "Group Value") + XCTAssertEqual(identifier, "group-id") + } else { + XCTFail("Expected semanticGroup type") + } + } + + func testTabBarContainerCodable() throws { + let container = AccessibilityContainer( + type: .tabBar, + frame: CGRect(x: 0, y: 0, width: 320, height: 49) + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(container) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityContainer.self, from: data) + + XCTAssertEqual(decoded.type, .tabBar) + } + + func testLandmarkContainerCodable() throws { + let container = AccessibilityContainer( + type: .landmark, + frame: CGRect(x: 0, y: 0, width: 320, height: 200) + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(container) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityContainer.self, from: data) + + XCTAssertEqual(decoded.type, .landmark) + } + + // MARK: - High-Importance Custom Content Tests + + func testHighImportanceCustomContentIncludedInDescription() { + let element = AccessibilityElement( + description: "Photo, 42", + label: "Photo", + value: nil, + traits: .image, + identifier: nil, + hint: nil, + userInputLabels: nil, + shape: .frame(.zero), + activationPoint: .zero, + usesDefaultActivationPoint: true, + customActions: [], + customContent: [ + .init(label: "Likes", value: "42", isImportant: true), + .init(label: "Comments", value: "5", isImportant: false), + ], + customRotors: [], + accessibilityLanguage: nil, + respondsToUserInteraction: false, + containerContext: nil + ) + + // High-importance content value should be in description (comma-separated) + XCTAssertTrue(element.description.contains(", 42")) + + // Default-importance content should NOT be in description + XCTAssertFalse(element.description.contains("5")) + } + + func testHighImportanceContentAppearsBeforeTraits() { + let element = AccessibilityElement( + description: "Bailey: beagle, three years. Image.", + label: "Bailey", + value: "beagle", + traits: .image, + identifier: nil, + hint: nil, + userInputLabels: nil, + shape: .frame(.zero), + activationPoint: .zero, + usesDefaultActivationPoint: true, + customActions: [], + customContent: [ + .init(label: "Age", value: "three years", isImportant: true), + ], + customRotors: [], + accessibilityLanguage: nil, + respondsToUserInteraction: false, + containerContext: nil + ) + + // Per WWDC21: "Bailey, beagle, three years. Image." + let desc = element.description + let customContentIndex = desc.range(of: "three years")!.lowerBound + let traitIndex = desc.range(of: "Image")!.lowerBound + XCTAssertTrue(customContentIndex < traitIndex) + } + + func testMultipleHighImportanceContentItems() { + let element = AccessibilityElement( + description: "Tweet, 100, 25", + label: "Tweet", + value: nil, + traits: [], + identifier: nil, + hint: nil, + userInputLabels: nil, + shape: .frame(.zero), + activationPoint: .zero, + usesDefaultActivationPoint: true, + customActions: [], + customContent: [ + .init(label: "Likes", value: "100", isImportant: true), + .init(label: "Retweets", value: "25", isImportant: true), + ], + customRotors: [], + accessibilityLanguage: nil, + respondsToUserInteraction: false, + containerContext: nil + ) + + // Both values should appear + XCTAssertTrue(element.description.contains("100")) + XCTAssertTrue(element.description.contains("25")) + } + + func testHighImportanceContentWithEmptyValue() { + let element = AccessibilityElement( + description: "Status, Verified", + label: "Status", + value: nil, + traits: [], + identifier: nil, + hint: nil, + userInputLabels: nil, + shape: .frame(.zero), + activationPoint: .zero, + usesDefaultActivationPoint: true, + customActions: [], + customContent: [ + .init(label: "Verified", value: "", isImportant: true), + ], + customRotors: [], + accessibilityLanguage: nil, + respondsToUserInteraction: false, + containerContext: nil + ) + + // Should include label when value is empty + XCTAssertTrue(element.description.contains("Verified")) + } + + func testCustomContentCodable() throws { + let content = AccessibilityElement.CustomContent( + label: "Rating", + value: "5 stars", + isImportant: true + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(content) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityElement.CustomContent.self, from: data) + + XCTAssertEqual(decoded.label, "Rating") + XCTAssertEqual(decoded.value, "5 stars") + XCTAssertEqual(decoded.isImportant, true) + } + + func testElementWithCustomContentCodable() throws { + let element = AccessibilityElement( + description: "Photo, 42", + label: "Photo", + value: nil, + traits: .image, + identifier: nil, + hint: nil, + userInputLabels: nil, + shape: .frame(.zero), + activationPoint: .zero, + usesDefaultActivationPoint: true, + customActions: [], + customContent: [ + .init(label: "Likes", value: "42", isImportant: true), + .init(label: "Comments", value: "5", isImportant: false), + ], + customRotors: [], + accessibilityLanguage: nil, + respondsToUserInteraction: false, + containerContext: nil + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(element) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AccessibilityElement.self, from: data) + + XCTAssertEqual(decoded.customContent.count, 2) + XCTAssertEqual(decoded.customContent[0].label, "Likes") + XCTAssertEqual(decoded.customContent[0].value, "42") + XCTAssertEqual(decoded.customContent[0].isImportant, true) + XCTAssertEqual(decoded.customContent[1].label, "Comments") + XCTAssertEqual(decoded.customContent[1].value, "5") + XCTAssertEqual(decoded.customContent[1].isImportant, false) + } } // MARK: - @@ -125,3 +1003,324 @@ private struct TestUserInterfaceLayoutDirectionProvider: UserInterfaceLayoutDire private struct TestUserInterfaceIdiomProvider: UserInterfaceIdiomProviding { var userInterfaceIdiom: UIUserInterfaceIdiom } + +// MARK: - Nested Container Test Views + +/// Reusable container view for testing container hierarchy parsing +private final class TestContainerView: UIView { + let containerType: UIAccessibilityContainerType + + init( + frame: CGRect, + containerType: UIAccessibilityContainerType, + label: String? = nil, + value: String? = nil + ) { + self.containerType = containerType + super.init(frame: frame) + accessibilityLabel = label + accessibilityValue = value + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var accessibilityContainerType: UIAccessibilityContainerType { + get { containerType } + set {} + } +} + +/// Creates a nested hierarchy similar to ContainerHierarchyViewController's NestedContainersDemoView: +/// - Outer semantic group container (with label) +/// - "Outer Item" element +/// - Inner semantic group container (with label) +/// - "Inner Item 1" element +/// - "Inner Item 2" element +private final class NestedContainersTestView: UIView { + let outerContainer: TestContainerView + let innerContainer: TestContainerView + let outerItemLabel: UILabel + let innerItem1Label: UILabel + let innerItem2Label: UILabel + + override init(frame: CGRect) { + // Create outer container + outerContainer = TestContainerView( + frame: CGRect(x: 0, y: 0, width: frame.width, height: frame.height), + containerType: .semanticGroup, + label: "Outer Container" + ) + + // Create outer item + outerItemLabel = UILabel(frame: CGRect(x: 8, y: 8, width: 100, height: 20)) + outerItemLabel.text = "Outer Item" + outerItemLabel.accessibilityFrame = CGRect(x: 8, y: 8, width: 100, height: 20) + + // Create inner container + innerContainer = TestContainerView( + frame: CGRect(x: 8, y: 36, width: frame.width - 16, height: 60), + containerType: .semanticGroup, + label: "Inner Container" + ) + + // Create inner items + innerItem1Label = UILabel(frame: CGRect(x: 8, y: 8, width: 100, height: 20)) + innerItem1Label.text = "Inner Item 1" + innerItem1Label.accessibilityFrame = CGRect(x: 16, y: 44, width: 100, height: 20) + + innerItem2Label = UILabel(frame: CGRect(x: 8, y: 32, width: 100, height: 20)) + innerItem2Label.text = "Inner Item 2" + innerItem2Label.accessibilityFrame = CGRect(x: 16, y: 68, width: 100, height: 20) + + super.init(frame: frame) + + // Build hierarchy + innerContainer.addSubview(innerItem1Label) + innerContainer.addSubview(innerItem2Label) + + outerContainer.addSubview(outerItemLabel) + outerContainer.addSubview(innerContainer) + + addSubview(outerContainer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Verbosity Tests + +final class VerbosityTests: XCTestCase { + func testMinimalVerbosityOmitsTraits() { + let element = makeElement(label: "Submit", traits: .button, hint: "Double tap to activate") + + let result = element.voiceOverDescription(verbosity: .minimal) + XCTAssertEqual(result.description, "Submit") + XCTAssertNil(result.hint) + } + + func testMinimalVerbosityOmitsHints() { + let element = makeElement(label: "Volume", value: "50%", traits: .adjustable) + + let result = element.voiceOverDescription(verbosity: .minimal) + XCTAssertEqual(result.description, "Volume") + XCTAssertNil(result.hint) + } + + func testMinimalVerbosityOmitsContainerContext() { + let element = makeElement(label: "Home", context: .series(index: 1, count: 4)) + + let result = element.voiceOverDescription(verbosity: .minimal) + XCTAssertEqual(result.description, "Home") + } + + func testMinimalVerbosityOmitsValue() { + let element = makeElement(label: "Volume", value: "50%") + + let result = element.voiceOverDescription(verbosity: .minimal) + XCTAssertEqual(result.description, "Volume") + } + + func testMinimalVerbosityOmitsCustomContent() { + let element = makeElement( + label: "Photo", + traits: .image, + customContent: [.init(label: "Likes", value: "42", isImportant: true)] + ) + + let result = element.voiceOverDescription(verbosity: .minimal) + XCTAssertEqual(result.description, "Photo") + XCTAssertFalse(result.description.contains("42")) + } + + func testMinimalVerbosityOmitsTableContext() { + let element = makeElement( + label: "A1", + context: .dataTableCell( + row: 0, column: 0, + rowSpan: 1, columnSpan: 1, + isFirstInRow: true, + rowHeaders: [], columnHeaders: [] + ) + ) + + let result = element.voiceOverDescription(verbosity: .minimal) + XCTAssertEqual(result.description, "A1") + XCTAssertFalse(result.description.contains("Row")) + XCTAssertFalse(result.description.contains("Column")) + } + + func testVerboseVerbosityIncludesAllComponents() { + let element = makeElement(label: "Submit", traits: .button, hint: "Double tap to activate") + + let result = element.voiceOverDescription(verbosity: .verbose) + XCTAssertTrue(result.description.contains("Submit")) + XCTAssertTrue(result.description.contains("Button")) + XCTAssertEqual(result.hint, "Double tap to activate") + } + + func testCustomVerbosityWithHintsDisabled() { + var config = VerbosityConfiguration.verbose + config.includesHints = false + + let element = makeElement(label: "Submit", traits: .button, hint: "Double tap to activate") + + let result = element.voiceOverDescription(verbosity: config) + XCTAssertTrue(result.description.contains("Button")) + XCTAssertNil(result.hint) + } + + func testCustomVerbosityWithTraitsDisabled() { + var config = VerbosityConfiguration.verbose + config.includesTraits = false + + let element = makeElement(label: "Submit", traits: .button, hint: "Double tap to activate") + + let result = element.voiceOverDescription(verbosity: config) + XCTAssertFalse(result.description.contains("Button")) + XCTAssertEqual(result.description, "Submit") + XCTAssertEqual(result.hint, "Double tap to activate") + } + + func testTraitPositionBefore() { + var config = VerbosityConfiguration.verbose + config.traitPosition = .before + + let element = makeElement(label: "Submit", traits: .button) + + let result = element.voiceOverDescription(verbosity: config) + XCTAssertTrue(result.description.hasPrefix("Button.")) + XCTAssertTrue(result.description.contains("Submit")) + } + + func testTraitPositionAfter() { + var config = VerbosityConfiguration.verbose + config.traitPosition = .after + + let element = makeElement(label: "Submit", traits: .button) + + let result = element.voiceOverDescription(verbosity: config) + XCTAssertTrue(result.description.hasPrefix("Submit")) + XCTAssertTrue(result.description.hasSuffix("Button.")) + } + + func testTraitPositionNone() { + var config = VerbosityConfiguration.verbose + config.includesTraits = true + config.traitPosition = .none + + let element = makeElement(label: "Submit", traits: .button) + + let result = element.voiceOverDescription(verbosity: config) + XCTAssertEqual(result.description, "Submit") + XCTAssertFalse(result.description.contains("Button")) + } + + // MARK: - Helpers + + private func makeElement( + label: String, + value: String? = nil, + traits: UIAccessibilityTraits = [], + hint: String? = nil, + customContent: [AccessibilityElement.CustomContent] = [], + context: AccessibilityElement.ContainerContext? = nil + ) -> AccessibilityElement { + AccessibilityElement( + description: "", + label: label, + value: value, + traits: traits, + identifier: nil, + hint: hint, + userInputLabels: nil, + shape: .frame(.zero), + activationPoint: .zero, + usesDefaultActivationPoint: true, + customActions: [], + customContent: customContent, + customRotors: [], + accessibilityLanguage: nil, + respondsToUserInteraction: false, + containerContext: context + ) + } +} + +// MARK: - Data Table Test Views + +private struct CellIndex: Hashable { + let row: Int + let column: Int +} + +/// Test view that conforms to UIAccessibilityContainerDataTable +private final class TestDataTableView: UIView, UIAccessibilityContainerDataTable { + let rows: Int + let columns: Int + var cells: [CellIndex: TestDataTableCell] = [:] + + init(frame: CGRect, rows: Int, columns: Int) { + self.rows = rows + self.columns = columns + super.init(frame: frame) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var accessibilityContainerType: UIAccessibilityContainerType { + get { .dataTable } + set {} + } + + // MARK: - UIAccessibilityContainerDataTable + + func accessibilityDataTableCellElement(forRow row: Int, column: Int) -> UIAccessibilityContainerDataTableCell? { + return cells[CellIndex(row: row, column: column)] + } + + func accessibilityRowCount() -> Int { + return rows + } + + func accessibilityColumnCount() -> Int { + return columns + } +} + +/// Test cell that conforms to UIAccessibilityContainerDataTableCell +private final class TestDataTableCell: UIView, UIAccessibilityContainerDataTableCell { + let row: Int + let column: Int + + init(row: Int, column: Int, label: String) { + self.row = row + self.column = column + super.init(frame: .zero) + isAccessibilityElement = true + accessibilityLabel = label + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIAccessibilityContainerDataTableCell + + func accessibilityRowRange() -> NSRange { + return NSRange(location: row, length: 1) + } + + func accessibilityColumnRange() -> NSRange { + return NSRange(location: column, length: 1) + } +} diff --git a/Sources/AccessibilitySnapshot/AccessibilityPreviews/SwiftUIAccessibilitySnapshotView.swift b/Sources/AccessibilitySnapshot/AccessibilityPreviews/SwiftUIAccessibilitySnapshotView.swift index 5e490da3..f3f50fbd 100644 --- a/Sources/AccessibilitySnapshot/AccessibilityPreviews/SwiftUIAccessibilitySnapshotView.swift +++ b/Sources/AccessibilitySnapshot/AccessibilityPreviews/SwiftUIAccessibilitySnapshotView.swift @@ -128,7 +128,7 @@ public struct SwiftUIAccessibilitySnapshotView: View { viewRenderingMode: .drawHierarchyInRect ) let parser = AccessibilityHierarchyParser() - let markers = parser.parseAccessibilityElements(in: hostingController.view) + let markers = parser.parseAccessibilityHierarchy(in: hostingController.view).flattenToElements() displayMarkers = markers.map { marker in DisplayMarker(marker: marker) diff --git a/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotConfiguration.swift b/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotConfiguration.swift index b14643ee..b71723ed 100644 --- a/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotConfiguration.swift +++ b/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotConfiguration.swift @@ -61,6 +61,10 @@ public struct AccessibilitySnapshotConfiguration { /// Controls when to show elements' accessibility user input labels (used by Voice Control). Defaults to `.whenOverridden`. public let inputLabelDisplayMode: AccessibilityContentDisplayMode + /// Controls what information is included in accessibility descriptions. + /// Defaults to `.verbose` which matches default VoiceOver behavior. + public let verbosity: VerbosityConfiguration + // MARK: - Initialization /// Creates a new accessibility snapshot configuration. @@ -73,6 +77,7 @@ public struct AccessibilitySnapshotConfiguration { /// - includesInputLabels: When to show accessibility user input labels. Defaults to `.whenOverridden`. /// - includesCustomRotors: When to show accessibility custom rotors and their contents. Defaults to `.whenOverridden`. /// - rotorResultLimit: Maximum number of rotor results to collect in each direction. Defaults to `10`. + /// - verbosity: Controls what information is included in accessibility descriptions. Defaults to `.verbose`. public init( viewRenderingMode: ViewRenderingMode, colorRenderingMode: ColorRenderingMode = .monochrome, @@ -80,13 +85,15 @@ public struct AccessibilitySnapshotConfiguration { activationPointDisplay: AccessibilityContentDisplayMode = .whenOverridden, includesInputLabels: AccessibilityContentDisplayMode = .whenOverridden, includesCustomRotors: AccessibilityContentDisplayMode = .whenOverridden, - rotorResultLimit: Int = AccessibilityMarker.defaultRotorResultLimit + rotorResultLimit: Int = AccessibilityMarker.defaultRotorResultLimit, + verbosity: VerbosityConfiguration = .verbose ) { rendering = Rendering(renderMode: viewRenderingMode, colorMode: colorRenderingMode) rotors = Rotors(displayMode: includesCustomRotors, resultLimit: rotorResultLimit) markerColors = overlayColors.isEmpty ? MarkerColors.defaultColors : overlayColors activationPointDisplayMode = activationPointDisplay inputLabelDisplayMode = includesInputLabels + self.verbosity = verbosity } } diff --git a/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotView.swift b/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotView.swift index 7f1361ff..dcc6b1d3 100644 --- a/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotView.swift +++ b/Sources/AccessibilitySnapshot/Core/AccessibilitySnapshotView.swift @@ -129,7 +129,7 @@ public final class AccessibilitySnapshotView: SnapshotAndLegendView { containedView.layoutIfNeeded() let parser = AccessibilityHierarchyParser() - let markers = parser.parseAccessibilityElements(in: containedView, rotorResultLimit: snapshotConfiguration.rotors.resultLimit) + let markers = parser.parseAccessibilityHierarchy(in: containedView, rotorResultLimit: snapshotConfiguration.rotors.resultLimit, verbosity: snapshotConfiguration.verbosity).flattenToElements() var displayMarkers: [DisplayMarker] = [] for (index, marker) in markers.enumerated() { diff --git a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityContainer.swift b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityContainer.swift new file mode 100644 index 00000000..f3c966e1 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityContainer.swift @@ -0,0 +1,33 @@ +import CoreGraphics + +/// Information about a container node +public struct AccessibilityContainer: Equatable, Codable { + /// The type of accessibility container with its associated data + public enum ContainerType: Equatable, Codable { + /// A semantic grouping with optional label, value, and identifier + case semanticGroup(label: String?, value: String?, identifier: String?) + + /// A list container (affects rotor navigation) + case list + + /// A landmark container (affects rotor navigation) + case landmark + + /// A data table with row and column counts + case dataTable(rowCount: Int, columnCount: Int) + + /// A tab bar container (detected via .tabBar trait) + case tabBar + } + + /// The type of container with its associated data + public let type: ContainerType + + /// Container's frame in the root view's coordinate space (for visualization) + public let frame: CGRect + + public init(type: ContainerType, frame: CGRect) { + self.type = type + self.frame = frame + } +} diff --git a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityDescribable.swift b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityDescribable.swift new file mode 100644 index 00000000..552cd0bd --- /dev/null +++ b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityDescribable.swift @@ -0,0 +1,803 @@ +import UIKit + +// MARK: - Protocol + +/// Internal protocol for types that can generate VoiceOver-style descriptions. +/// +/// This protocol unifies description generation for both live UIKit objects (NSObject) +/// and parsed accessibility elements (AccessibilityElement), enabling shared logic +/// for computing what VoiceOver would announce. +/// +/// ## Description Compilation Order +/// +/// The description is built in this exact order to match VoiceOver behavior: +/// +/// ``` +/// 1. INITIALIZE BASE DESCRIPTION +/// - accessibilityLabelOverride(for: context) -> if non-nil, use it +/// - OR if hidesAccessibilityLabel() -> "" +/// - OR accessibilityLabel ?? "" +/// +/// 2. INITIALIZE HINT +/// - accessibilityHint?.nonEmpty() +/// +/// 3. DATA TABLE CONTEXT (if .dataTableCell) +/// - PREPEND: row headers + column headers (pre-formatted strings) +/// - description + trailing period +/// - APPEND: "Spans X rows." (if rowSpan > 1) +/// - APPEND: "Spans X columns." (if columnSpan > 1) +/// - APPEND: "Row X." (if isFirstInRow) +/// - APPEND: "Column X." +/// [Sets descriptionContainsContext = true] +/// +/// 4. APPEND VALUE +/// - if accessibilityValue non-empty && !hidesAccessibilityValue: +/// - If descriptionContainsContext: " {value}" +/// - If has description: "{desc}: {value}" +/// - If no description: "{value}" +/// +/// 4.5. APPEND HIGH-IMPORTANCE CUSTOM CONTENT +/// - for each content where isImportant == true: +/// - "{description}, {value}" +/// - OR "{description}, {label}" (if value is empty) +/// +/// 5. SELECTED TRAIT +/// - if .selected: +/// - "Selected: {description}" +/// - OR "Selected." (if no description) +/// +/// 6. BUILD TRAIT SPECIFIERS ARRAY (in this exact order) +/// - .notEnabled -> "Dimmed." +/// - .button -> "Button." (unless hidden by context/traits) +/// - .backButton -> "Back Button." +/// - .switchButton -> "Switch Button." + "On."/"Off."/"Mixed."/value +/// - .tabBarItem OR ctx -> "Tab." +/// - .textEntry -> "Text Field." + "Is editing." (if editing) +/// - .header -> "Heading." +/// - .link -> "Link." +/// - .adjustable -> "Adjustable." +/// - .image -> "Image." +/// - .searchField -> "Search Field." +/// +/// 7. HINT AS DESCRIPTION FALLBACK +/// - if description is empty: +/// - description = hint +/// - hint = nil +/// +/// 8. APPEND TRAIT SPECIFIERS +/// - if traitSpecifiers not empty: +/// - "{description}. {traits joined by space}" +/// - OR just traits if no description +/// +/// 9. SERIES/TAB/LIST/LANDMARK CONTEXT +/// - .series/.tabBarItem/.tab -> "{desc} X of Y." +/// - .listStart -> "{desc}. List Start." +/// - .listEnd -> "{desc}. List End." +/// - .landmarkStart -> "{desc}. Landmark." +/// - .landmarkEnd -> "{desc}. End." +/// +/// 10. MODIFY HINT FOR SWITCH BUTTON +/// - if .switchButton && enabled: +/// - "{hint}. Double tap to toggle setting." +/// - OR "Double tap to toggle setting." +/// +/// 11. MODIFY HINT FOR TEXT ENTRY +/// - if .textEntry && enabled: +/// - If editing: "Use the rotor to access Misspelled Words" +/// - If scrollable: "Double tap to edit., Use the rotor..." +/// - Else: "Double tap to edit." +/// +/// 12. MODIFY HINT FOR ADJUSTABLE +/// - if .adjustable && !hidesAdjustableHint: +/// - "{hint}. Swipe up or down with one finger to adjust the value." +/// - OR "Swipe up or down with one finger to adjust the value." +/// +/// 13. RETURN (description, hint) +/// ``` +protocol AccessibilityDescribable { + var accessibilityLabel: String? { get } + var accessibilityValue: String? { get } + var accessibilityTraits: UIAccessibilityTraits { get } + var accessibilityHint: String? { get } + var accessibilityLanguage: String? { get } + + /// Custom content items for this element. + /// High-importance items are included in the main description. + var describableCustomContent: [AccessibilityElement.CustomContent] { get } +} + +// MARK: - Default Implementation + +extension AccessibilityDescribable { + /// Generates the description and hint that VoiceOver would read for this element. + /// + /// - Parameters: + /// - context: The context provided by the element's container, if any. + /// - verbosity: Controls what information is included in the description. Defaults to `.verbose`. + /// - Returns: A tuple containing the description and optional hint. + func buildAccessibilityDescription( + context: AccessibilityElement.ContainerContext?, + verbosity: VerbosityConfiguration = .verbose + ) -> (description: String, hint: String?) { + let strings = Strings(locale: accessibilityLanguage) + + var accessibilityDescription = + accessibilityLabelOverride(for: context) ?? + (hidesAccessibilityLabel(backDescriptor: strings.backDescriptor) ? "" : + accessibilityLabel ?? "") + + var hintDescription = accessibilityHint?.nonEmpty() + + let numberFormatter = NumberFormatter() + if let localeIdentifier = accessibilityLanguage { + numberFormatter.locale = Locale(identifier: localeIdentifier) + } + + let descriptionContainsContext: Bool + if verbosity.includesTableContext, let context = context { + switch context { + case let .dataTableCell(row: row, column: column, rowSpan: rowSpan, columnSpan: columnSpan, isFirstInRow: isFirstInRow, rowHeaders: rowHeaders, columnHeaders: columnHeaders): + // Headers are pre-formatted strings, just join them + let headersDescription = (rowHeaders + columnHeaders).joined() + + let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." + + let showsHeight = (rowSpan > 1 && row != NSNotFound) + let showsWidth = (columnSpan > 1 && column != NSNotFound) + let showsRow = (isFirstInRow && row != NSNotFound) + let showsColumn = (column != NSNotFound) + + accessibilityDescription = + headersDescription + + accessibilityDescription + + trailingPeriod + + (showsHeight ? " " + String(format: strings.dataTableRowSpanFormat, numberFormatter.string(from: .init(value: rowSpan))!) : "") + + (showsWidth ? " " + String(format: strings.dataTableColumnSpanFormat, numberFormatter.string(from: .init(value: columnSpan))!) : "") + + (showsRow ? " " + String(format: strings.dataTableRowFormat, numberFormatter.string(from: .init(value: row + 1))!) : "") + + (showsColumn ? " " + String(format: strings.dataTableColumnFormat, numberFormatter.string(from: .init(value: column + 1))!) : "") + + descriptionContainsContext = true + + case .series, .tab, .tabBarItem, .listStart, .listEnd, .landmarkStart, .landmarkEnd: + descriptionContainsContext = false + } + + } else { + descriptionContainsContext = false + } + + if verbosity.includesValue, + let accessibilityValue = accessibilityValue?.nonEmpty(), + !hidesAccessibilityValue(for: context) + { + if let existingDescription = accessibilityDescription.nonEmpty() { + if descriptionContainsContext { + accessibilityDescription += " \(accessibilityValue)" + } else { + accessibilityDescription = "\(existingDescription): \(accessibilityValue)" + } + } else { + accessibilityDescription = accessibilityValue + } + } + + // Append high-importance custom content (after value, before traits) + // Per WWDC21: "Bailey, beagle, three years" - custom content follows value + if verbosity.includesCustomContent { + for content in describableCustomContent where content.isImportant { + let contentDescription = content.value.isEmpty ? content.label : content.value + + if let existingDescription = accessibilityDescription.nonEmpty() { + accessibilityDescription = "\(existingDescription), \(contentDescription)" + } else { + accessibilityDescription = contentDescription + } + } + } + + if accessibilityTraits.contains(.selected) { + if let existingDescription = accessibilityDescription.nonEmpty() { + accessibilityDescription = String(format: strings.selectedTraitFormat, existingDescription) + } else { + accessibilityDescription = strings.selectedTraitName + } + } + + var traitSpecifiers: [String] = [] + + // Only collect traits if verbosity includes them + let shouldIncludeTraits = verbosity.includesTraits && verbosity.traitPosition != .none + + if shouldIncludeTraits { + if accessibilityTraits.contains(.notEnabled) { + traitSpecifiers.append(strings.notEnabledTraitName) + } + + let hidesButtonTraitInContext = context?.hidesButtonTrait ?? false + let hidesButtonTraitFromTraits = [UIAccessibilityTraits.keyboardKey, .switchButton, .tabBarItem, .backButton].contains(where: { accessibilityTraits.contains($0) }) + if accessibilityTraits.contains(.button) && !hidesButtonTraitFromTraits && !hidesButtonTraitInContext { + traitSpecifiers.append(strings.buttonTraitName) + } + + if accessibilityTraits.contains(.backButton) { + traitSpecifiers.append(strings.backButtonTraitName) + } + + if accessibilityTraits.contains(.switchButton) { + if accessibilityTraits.contains(.button) { + // An element can have the private switch button trait without being a UISwitch (for example, by passing + // through the traits of a contained switch). In this case, VoiceOver will still read the "Switch + // Button." trait, but only if the element's traits also include the `.button` trait. + traitSpecifiers.append(strings.switchButtonTraitName) + } + + switch accessibilityValue { + case "1": + traitSpecifiers.append(strings.switchButtonOnStateName) + case "0": + traitSpecifiers.append(strings.switchButtonOffStateName) + case "2": + traitSpecifiers.append(strings.switchButtonMixedStateName) + default: + // Prior to iOS 17 the then private trait would suppress any other accessibility values. + // Once the trait became public in 17 values other than the above are announced with the trait specifiers. + if #available(iOS 17.0, *), let accessibilityValue { + traitSpecifiers.append(accessibilityValue) + } + } + } + + let showsTabTraitInContext = context?.showsTabTrait ?? false + if accessibilityTraits.contains(.tabBarItem) || showsTabTraitInContext { + traitSpecifiers.append(strings.tabTraitName) + } + + if accessibilityTraits.contains(.textEntry) { + if accessibilityTraits.contains(.scrollable) { + // This is a UITextView/TextEditor + } else { + // This is a UITextField/TextField + } + + traitSpecifiers.append(strings.textEntryTraitName) + + if accessibilityTraits.contains(.isEditing) { + traitSpecifiers.append(strings.isEditingTraitName) + } + } + + if accessibilityTraits.contains(.header) { + traitSpecifiers.append(strings.headerTraitName) + } + + if accessibilityTraits.contains(.link) { + traitSpecifiers.append(strings.linkTraitName) + } + + if accessibilityTraits.contains(.adjustable) { + traitSpecifiers.append(strings.adjustableTraitName) + } + + if accessibilityTraits.contains(.image) { + traitSpecifiers.append(strings.imageTraitName) + } + + if accessibilityTraits.contains(.searchField) { + traitSpecifiers.append(strings.searchFieldTraitName) + } + } + + // If the description is empty, use the hint as the description. + if accessibilityDescription.isEmpty { + accessibilityDescription = hintDescription ?? "" + hintDescription = nil + } + + // Add trait specifiers to description based on position preference. + if !traitSpecifiers.isEmpty { + let traits = traitSpecifiers.joined(separator: " ") + + switch verbosity.traitPosition { + case .before: + // "Button. Submit" + if let existingDescription = accessibilityDescription.nonEmpty() { + accessibilityDescription = "\(traits) \(existingDescription)" + } else { + accessibilityDescription = traits + } + + case .after: + // "Submit. Button." (default VoiceOver behavior) + if let existingDescription = accessibilityDescription.nonEmpty() { + let trailingPeriod = existingDescription.hasSuffix(".") ? "" : "." + accessibilityDescription = "\(existingDescription)\(trailingPeriod) \(traits)" + } else { + accessibilityDescription = traits + } + + case .none: + // Traits already not collected, but handle edge case + break + } + } + + if verbosity.includesContainerContext, let context = context { + switch context { + case let .series(index: index, count: count), + let .tabBarItem(index: index, count: count), + let .tab(index: index, count: count): + accessibilityDescription = String(format: + strings.seriesContextFormat, + accessibilityDescription, + numberFormatter.string(from: .init(value: index))!, + numberFormatter.string(from: .init(value: count))!) + + case .listStart: + let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." + accessibilityDescription = String(format: + "%@%@ %@", + accessibilityDescription, + trailingPeriod, + strings.listStartContext) + + case .listEnd: + let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." + accessibilityDescription = String(format: + "%@%@ %@", + accessibilityDescription, + trailingPeriod, + strings.listEndContext) + + case .landmarkStart: + let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." + accessibilityDescription = String(format: + "%@%@ %@", + accessibilityDescription, + trailingPeriod, + strings.landmarkStartContext) + + case .landmarkEnd: + let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." + accessibilityDescription = String(format: + "%@%@ %@", + accessibilityDescription, + trailingPeriod, + strings.landmarkEndContext) + + case .dataTableCell: + break + } + } + + // Only include hints if verbosity allows + if verbosity.includesHints { + if accessibilityTraits.contains(.switchButton) && !accessibilityTraits.contains(.notEnabled) { + if let existingHintDescription = hintDescription?.nonEmpty()?.strippingTrailingPeriod() { + hintDescription = String(format: strings.switchButtonTraitHintFormat, existingHintDescription) + } else { + hintDescription = strings.switchButtonTraitHint + } + } + + if accessibilityTraits.contains(.textEntry) && !accessibilityTraits.contains(.notEnabled) { + if accessibilityTraits.contains(.isEditing) { + hintDescription = strings.textEntryIsEditingTraitHint + } else { + if accessibilityTraits.contains(.scrollable) { + // This is a UITextView/TextEditor + hintDescription = strings.scrollableTextEntryTraitHint + } else { + // This is a UITextField/TextField + hintDescription = strings.textEntryTraitHint + } + } + } + + let hasHintOnly = (accessibilityHint?.nonEmpty() != nil) && (accessibilityLabel?.nonEmpty() == nil) && (accessibilityValue?.nonEmpty() == nil) + let hidesAdjustableHint = accessibilityTraits.contains(.notEnabled) || accessibilityTraits.contains(.switchButton) || hasHintOnly + if accessibilityTraits.contains(.adjustable), !hidesAdjustableHint { + if let existingHintDescription = hintDescription?.nonEmpty()?.strippingTrailingPeriod() { + hintDescription = String(format: strings.adjustableTraitHintFormat, existingHintDescription) + } else { + hintDescription = strings.adjustableTraitHint + } + } + } else { + // Clear hints when verbosity excludes them + hintDescription = nil + } + + return (accessibilityDescription, hintDescription) + } + + // MARK: - Private Methods + + private func accessibilityLabelOverride(for context: AccessibilityElement.ContainerContext?) -> String? { + guard let context = context else { + return nil + } + + switch context { + case .tabBarItem: + return nil + + case .series, .tab, .dataTableCell, .listStart, .listEnd, .landmarkStart, .landmarkEnd: + return nil + } + } + + private func hidesAccessibilityValue(for context: AccessibilityElement.ContainerContext?) -> Bool { + if accessibilityTraits.contains(.switchButton) { + return true + } + + guard let context = context else { + return false + } + + switch context { + case .tabBarItem: + return false + + case .series, .tab, .dataTableCell, .listStart, .listEnd, .landmarkStart, .landmarkEnd: + return false + } + } + + private func hidesAccessibilityLabel(backDescriptor: String) -> Bool { + // To prevent duplication, Back Button elements omit their label if it matches the localized "Back" descriptor. + guard accessibilityTraits.contains(.backButton), + let label = accessibilityLabel else { return false } + return label.lowercased() == backDescriptor.lowercased() + } +} + +// MARK: - Localization + +/// Localized strings for VoiceOver description generation. +struct Strings { + // MARK: - Public Properties + + let selectedTraitName: String + + let selectedTraitFormat: String + + let notEnabledTraitName: String + + let buttonTraitName: String + + let backButtonTraitName: String + + let backDescriptor: String + + let tabTraitName: String + + let headerTraitName: String + + let linkTraitName: String + + let adjustableTraitName: String + + let adjustableTraitHint: String + + let adjustableTraitHintFormat: String + + let imageTraitName: String + + let searchFieldTraitName: String + + let switchButtonTraitName: String + + let switchButtonOnStateName: String + + let switchButtonOffStateName: String + + let switchButtonMixedStateName: String + + let switchButtonTraitHint: String + + let switchButtonTraitHintFormat: String + + let seriesContextFormat: String + + let dataTableRowSpanFormat: String + + let dataTableColumnSpanFormat: String + + let dataTableRowFormat: String + + let dataTableColumnFormat: String + + let listStartContext: String + + let listEndContext: String + + let landmarkStartContext: String + + let landmarkEndContext: String + + let textEntryTraitName: String + + let textEntryTraitHint: String + + let textEntryIsEditingTraitHint: String + + let scrollableTextEntryTraitHint: String + + let isEditingTraitName: String + + // MARK: - Life Cycle + + init(locale: String?) { + selectedTraitName = "Selected.".localized( + key: "trait.selected.description", + comment: "Description for the 'selected' accessibility trait", + locale: locale + ) + selectedTraitFormat = "Selected: %@".localized( + key: "trait.selected.format", + comment: "Format for the description of the selected element; param0: the description of the element", + locale: locale + ) + notEnabledTraitName = "Dimmed.".localized( + key: "trait.not_enabled.description", + comment: "Description for the 'not enabled' accessibility trait", + locale: locale + ) + buttonTraitName = "Button.".localized( + key: "trait.button.description", + comment: "Description for the 'button' accessibility trait", + locale: locale + ) + backButtonTraitName = "Back Button.".localized( + key: "trait.backbutton.description", + comment: "Description for the 'back button' accessibility trait", + locale: locale + ) + backDescriptor = "Back".localized( + key: "back.descriptor", + comment: "Descriptor for the 'back' portion of the 'back button' accessibility trait", + locale: locale + ) + tabTraitName = "Tab.".localized( + key: "trait.tab.description", + comment: "Description for the 'tab' accessibility trait", + locale: locale + ) + headerTraitName = "Heading.".localized( + key: "trait.header.description", + comment: "Description for the 'header' accessibility trait", + locale: locale + ) + linkTraitName = "Link.".localized( + key: "trait.link.description", + comment: "Description for the 'link' accessibility trait", + locale: locale + ) + adjustableTraitName = "Adjustable.".localized( + key: "trait.adjustable.description", + comment: "Description for the 'adjustable' accessibility trait", + locale: locale + ) + adjustableTraitHint = "Swipe up or down with one finger to adjust the value.".localized( + key: "trait.adjustable.hint", + comment: "Hint describing how to use elements with the 'adjustable' accessibility trait", + locale: locale + ) + adjustableTraitHintFormat = "%@. Swipe up or down with one finger to adjust the value.".localized( + key: "trait.adjustable.hint_format", + comment: "Format for hint describing how to use elements with the 'adjustable' accessibility trait; " + + "param0: the existing hint", + locale: locale + ) + imageTraitName = "Image.".localized( + key: "trait.image.description", + comment: "Description for the 'image' accessibility trait", + locale: locale + ) + searchFieldTraitName = "Search Field.".localized( + key: "trait.search_field.description", + comment: "Description for the 'search field' accessibility trait", + locale: locale + ) + switchButtonTraitName = "Switch Button.".localized( + key: "trait.switch_button.description", + comment: "Description for the 'switch button' accessibility trait", + locale: locale + ) + switchButtonOnStateName = "On.".localized( + key: "trait.switch_button.state_on.description", + comment: "Description for the 'switch button' accessibility trait, when the switch is on", + locale: locale + ) + switchButtonOffStateName = "Off.".localized( + key: "trait.switch_button.state_off.description", + comment: "Description for the 'switch button' accessibility trait, when the switch is off", + locale: locale + ) + switchButtonMixedStateName = "Mixed.".localized( + key: "trait.switch_button.state_mixed.description", + comment: "Description for the 'switch button' accessibility trait, when the switch is in a mixed state", + locale: locale + ) + switchButtonTraitHint = "Double tap to toggle setting.".localized( + key: "trait.switch_button.hint", + comment: "Hint describing how to use elements with the 'switch button' accessibility trait", + locale: locale + ) + switchButtonTraitHintFormat = "%@. Double tap to toggle setting.".localized( + key: "trait.switch_button.hint_format", + comment: "Format for hint describing how to use elements with the 'switch button' accessibility trait; " + + "param0: the existing hint", + locale: locale + ) + seriesContextFormat = "%@ %@ of %@.".localized( + key: "context.series.description_format", + comment: "Format for the description of an element in a series; param0: the description of the element, " + + "param1: the index of the element in the series, param2: the number of elements in the series", + locale: locale + ) + dataTableRowSpanFormat = "Spans %@ rows.".localized( + key: "context.data_table.row_span_format", + comment: "Format for the description of the height of a cell in a table; param0: the number of rows the cell spans", + locale: locale + ) + dataTableColumnSpanFormat = "Spans %@ columns.".localized( + key: "context.data_table.column_span_format", + comment: "Format for the description of the width of a cell in a table; param0: the number of columns the cell spans", + locale: locale + ) + dataTableRowFormat = "Row %@.".localized( + key: "context.data_table.row_format", + comment: "Format for the description of the vertical location of a cell in a table; param0: the row in which the cell resides", + locale: locale + ) + dataTableColumnFormat = "Column %@.".localized( + key: "context.data_table.column_format", + comment: "Format for the description of the horizontal location of a cell in a table; param0: the column in which the cell resides", + locale: locale + ) + listStartContext = "List Start.".localized( + key: "context.list_start.description", + comment: "Description of the first element in a list", + locale: locale + ) + listEndContext = "List End.".localized( + key: "context.list_end.description", + comment: "Description of the last element in a list", + locale: locale + ) + landmarkStartContext = "Landmark.".localized( + key: "context.landmark_start.description", + comment: "Description of the first element in a landmark container", + locale: locale + ) + landmarkEndContext = "End.".localized( + key: "context.landmark_end.description", + comment: "Description of the last element in a landmark container", + locale: locale + ) + textEntryTraitName = "Text Field.".localized( + key: "trait.text_field.description", + comment: "Description for the 'text entry' accessibility trait", + locale: locale + ) + textEntryTraitHint = "Double tap to edit.".localized( + key: "trait.text_field.hint", + comment: "Hint describing how to use elements with the 'text entry' accessibility trait", + locale: locale + ) + textEntryIsEditingTraitHint = "Use the rotor to access Misspelled Words".localized( + key: "trait.text_field_is_editing.hint", + comment: "Hint describing how to use elements with the 'text entry' accessibility trait when they are being edited", + locale: locale + ) + scrollableTextEntryTraitHint = "Double tap to edit., Use the rotor to access Misspelled Words".localized( + key: "trait.scrollable_text_field.hint", + comment: "Hint describing how to use elements with the 'text entry' and 'scrollable' accessibility traits", + locale: locale + ) + isEditingTraitName = "Is editing.".localized( + key: "trait.text_field_is_editing.description", + comment: "Description for the 'is editing' accessibility trait", + locale: locale + ) + } +} + +// MARK: - String Extensions + +extension String { + /// Returns the string if it is non-empty, otherwise nil. + func nonEmpty() -> String? { + return isEmpty ? nil : self + } + + func strippingTrailingPeriod() -> String { + if hasSuffix(".") { + return String(dropLast()) + } else { + return self + } + } +} + +// MARK: - Private Accessibility Traits + +extension UIAccessibilityTraits { + static let textEntry = UIAccessibilityTraits(rawValue: 1 << 18) // 0x0000000000040000 + + static let isEditing = UIAccessibilityTraits(rawValue: 1 << 21) // 0x0000000000200000 + + static let backButton = UIAccessibilityTraits(rawValue: 1 << 27) // 0x0000000008000000 + + static let tabBarItem = UIAccessibilityTraits(rawValue: 1 << 28) // 0x0000000010000000 + + static let scrollable = UIAccessibilityTraits(rawValue: 1 << 47) // 0x0000800000000000 + + static let switchButton = UIAccessibilityTraits(rawValue: 1 << 53) // 0x0020000000000000 +} + +// MARK: - ContainerContext Description Extensions + +extension AccessibilityElement.ContainerContext { + var hidesButtonTrait: Bool { + switch self { + case .series, .tabBarItem, .dataTableCell, .listStart, .listEnd, .landmarkStart, .landmarkEnd: + return false + + case .tab: + return true + } + } + + var showsTabTrait: Bool { + switch self { + case .series, .dataTableCell, .listStart, .listEnd, .landmarkStart, .landmarkEnd: + return false + + case .tab, .tabBarItem: + return true + } + } +} + +// MARK: - NSObject Conformance + +// NSObject already has accessibilityLabel, accessibilityValue, accessibilityTraits, +// accessibilityHint, and accessibilityLanguage - so conformance is nearly empty. +// All description logic is provided by the protocol's default implementation. +extension NSObject: AccessibilityDescribable { + var describableCustomContent: [AccessibilityElement.CustomContent] { + if #available(iOS 14.0, *) { + if let provider = self as? AXCustomContentProvider { + #if swift(>=5.9) + if #available(iOS 17.0, *) { + if let customContentBlock = provider.accessibilityCustomContentBlock { + if let content = customContentBlock?() { + return content.map { .init(from: $0) } + } + } + } + #endif + if let content = provider.accessibilityCustomContent { + return content.map { .init(from: $0) } + } + } + } + return [] + } +} + +// MARK: - AccessibilityElement Conformance + +extension AccessibilityElement: AccessibilityDescribable { + // Map stored property names to protocol requirements. + // All description logic is provided by the protocol's default implementation. + var accessibilityLabel: String? { label } + var accessibilityValue: String? { value } + var accessibilityTraits: UIAccessibilityTraits { traits } + var accessibilityHint: String? { hint } + var describableCustomContent: [CustomContent] { customContent } + // accessibilityLanguage already matches the protocol requirement name +} diff --git a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityElement.swift b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityElement.swift new file mode 100644 index 00000000..c21cb988 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityElement.swift @@ -0,0 +1,310 @@ +import UIKit + +/// A type alias for backwards compatibility. +public typealias AccessibilityMarker = AccessibilityElement + +public struct AccessibilityElement: Equatable, Codable { + /// Default number of rotor results to collect in each direction. + public static let defaultRotorResultLimit: Int = 10 + + // MARK: - Public Types + + /// Represents the container context in which an accessibility element is contained. + /// + /// This enum captures all the information VoiceOver needs to announce container-specific + /// context, such as position in a series, table cell location, or list boundaries. + /// + /// `ContainerContext` is designed to be: + /// - **Codable**: Can be serialized to JSON for storage/transmission + /// - **Equatable**: Can be compared for equality + /// - **Reference-free**: Contains only primitive data, no object references + public enum ContainerContext: Equatable, Codable { + /// Element is part of a series. Reads as "`index` of `count`." + case series(index: Int, count: Int) + + /// Element is a tab bar item. Reads as "Tab. `index` of `count`." + /// + /// Used for items in a `UITabBar`. + case tabBarItem(index: Int, count: Int) + + /// Element is in a tab bar container. Reads as "Tab. `index` of `count`." + /// + /// Used for containers with the `.tabBar` accessibility trait. + case tab(index: Int, count: Int) + + /// Element is a cell in a data table. + /// + /// - `row`: Row index (0-based), or `NSNotFound` if not applicable + /// - `column`: Column index (0-based), or `NSNotFound` if not applicable + /// - `rowSpan`: Number of rows the cell spans + /// - `columnSpan`: Number of columns the cell spans + /// - `isFirstInRow`: Whether this is the first cell VoiceOver reads in its row + /// - `rowHeaders`: Pre-formatted header strings for the row + /// - `columnHeaders`: Pre-formatted header strings for the column + case dataTableCell( + row: Int, + column: Int, + rowSpan: Int, + columnSpan: Int, + isFirstInRow: Bool, + rowHeaders: [String], + columnHeaders: [String] + ) + + /// Element is the first element in a list. + case listStart + + /// Element is the last element in a list. + case listEnd + + /// Element is the first element in a landmark container. + case landmarkStart + + /// Element is the last element in a landmark container. + case landmarkEnd + } + + public enum Shape: Equatable { + /// Accessibility frame, in the coordinate space of the view being snapshotted. + case frame(CGRect) + + /// Accessibility path, in the coordinate space of the view being snapshotted. + case path(UIBezierPath) + } + + public struct CustomRotor: Equatable, CustomStringConvertible, Codable { + public struct ResultMarker: Equatable, CustomStringConvertible, Codable { + public let elementDescription: String + public let rangeDescription: String? + public let shape: Shape? + + public var description: String { + guard let rangeDescription else { + return elementDescription + } + return "\(elementDescription) \(rangeDescription)" + } + } + + public var name: String + public var resultMarkers: [AccessibilityElement.CustomRotor.ResultMarker] = [] + public let limit: UIAccessibilityCustomRotor.CollectedRotorResults.Limit + + init?(from: UIAccessibilityCustomRotor, parentElement: NSObject, root: UIView, resultLimit: Int) { + guard from.isKnownRotorType else { return nil } + name = from.displayName(locale: parentElement.accessibilityLanguage) + let collected = from.collectAllResults(nextLimit: resultLimit, previousLimit: resultLimit) + limit = collected.limit + resultMarkers = collected.results.compactMap { result in + guard let element = result.targetElement as? NSObject else { return nil } + // Rotor results can point to any element in the hierarchy, so we don't know + // their actual container context. Pass nil to avoid using the wrong context. + var description = element.buildAccessibilityDescription(context: nil).description + var shape: Shape? = AccessibilityHierarchyParser.accessibilityShape(for: element, in: root) + + if let range = result.targetRange, + let input = element as? UITextInput + { + if let path = input.accessibilityPath(for: range) { + let converted = root.convert(path, from: input as? UIView) + shape = .path(converted) + } + if let substring = input.text(in: range) { + description = substring + } + return ResultMarker(elementDescription: description, rangeDescription: range.formatted(in: input), shape: shape) + } + return ResultMarker(elementDescription: description, rangeDescription: nil, shape: shape) + } + } + + public var description: String { + return name + ": " + resultMarkers.map { $0.description }.joined(separator: "\n") + } + } + + public struct CustomContent: Codable, Equatable { + public var label: String + public var value: String + public var isImportant: Bool + + public init(label: String, value: String, isImportant: Bool) { + self.label = label + self.value = value + self.isImportant = isImportant + } + + @available(iOS 14.0, *) + init(from: AXCustomContent) { + label = from.label + value = from.value + isImportant = from.importance == .high + } + } + + public struct CustomAction: Equatable, Codable { + public var name: String + public var image: UIImage? + + init(name: String, image: UIImage?) { + self.name = name + self.image = image + } + + @available(iOS 14.0, *) + init(from: UIAccessibilityCustomAction) { + name = from.name + image = from.image + } + + private enum CodingKeys: String, CodingKey { + case name + case imageData + case imageScale + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + + if let imageData = try container.decodeIfPresent(Data.self, forKey: .imageData) { + let scale = try container.decodeIfPresent(CGFloat.self, forKey: .imageScale) ?? 1.0 + image = UIImage(data: imageData, scale: scale) + } else { + image = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + + if let image = image, let pngData = image.pngData() { + try container.encode(pngData, forKey: .imageData) + try container.encode(image.scale, forKey: .imageScale) + } + } + } + + // MARK: - Public Properties + + /// The description of the accessibility element that will be read by VoiceOver when the element is brought into + /// focus. + public let description: String + + public let label: String? + + public let value: String? + + public let traits: UIAccessibilityTraits + + /// A unique identifier for the element, primarily used in UI tests for locating and interacting with elements. + /// This identifier is not visible to users. + public let identifier: String? + + /// A hint that will be read by VoiceOver if focus remains on the element after the `description` is read. + public let hint: String? + + /// The labels that will be used by Voice Control for user input. + /// These labels are displayed based on the `AccessibilityContentDisplayMode` configuration: + /// - `.always`: Always shows user input labels + /// - `.whenOverridden`: Shows labels only when they differ from default values (future enhancement) + /// - `.never`: Never shows user input labels + public let userInputLabels: [String]? + + /// The shape that will be highlighted on screen while the element is in focus. + public let shape: Shape + + /// The accessibility activation point, in the coordinate space of the view being snapshotted. + public let activationPoint: CGPoint + + /// Whether or not the `activationPoint` is the default activation point for the object. + /// + /// For most elements, the default activation point is the midpoint of the element's accessibility frame. Certain + /// elements have distinct defaults - for example, a `UISlider` puts its activation point at the center of its thumb + /// by default. + public let usesDefaultActivationPoint: Bool + + /// The custom actions supported by the element. + public let customActions: [CustomAction] + + /// Any custom content included by the element. + public let customContent: [CustomContent] + + /// Any custom rotors included by the element. + public let customRotors: [CustomRotor] + + /// The language code of the language used to localize strings in the description. + public let accessibilityLanguage: String? + + /// Whether the element performs an action based on user interaction. + public let respondsToUserInteraction: Bool + + /// The container context in which this element was parsed. + public let containerContext: ContainerContext? + + // MARK: - Initialization + + init( + description: String, + label: String?, + value: String?, + traits: UIAccessibilityTraits, + identifier: String?, + hint: String?, + userInputLabels: [String]?, + shape: Shape, + activationPoint: CGPoint, + usesDefaultActivationPoint: Bool, + customActions: [CustomAction], + customContent: [CustomContent], + customRotors: [CustomRotor], + accessibilityLanguage: String?, + respondsToUserInteraction: Bool, + containerContext: ContainerContext? + ) { + self.description = description + self.label = label + self.value = value + self.traits = traits + self.identifier = identifier + self.hint = hint + self.userInputLabels = userInputLabels + self.shape = shape + self.activationPoint = activationPoint + self.usesDefaultActivationPoint = usesDefaultActivationPoint + self.customActions = customActions + self.customContent = customContent + self.customRotors = customRotors + self.accessibilityLanguage = accessibilityLanguage + self.respondsToUserInteraction = respondsToUserInteraction + self.containerContext = containerContext + } +} + +// MARK: - Computed Description + +public extension AccessibilityElement { + /// Computes the VoiceOver description using the element's stored properties and container context. + /// + /// This uses the default verbose verbosity setting. + var voiceOverDescription: (description: String, hint: String?) { + voiceOverDescription(verbosity: .verbose) + } + + /// Computes the VoiceOver description with the specified verbosity configuration. + /// + /// - Parameter verbosity: Controls what information is included in the description. + /// - Returns: A tuple containing the description and optional hint. + /// + /// Example: + /// ```swift + /// let minimal = element.voiceOverDescription(verbosity: .minimal) // Just label + /// let verbose = element.voiceOverDescription(verbosity: .verbose) // Everything + /// ``` + func voiceOverDescription( + verbosity: VerbosityConfiguration + ) -> (description: String, hint: String?) { + buildAccessibilityDescription(context: containerContext, verbosity: verbosity) + } +} diff --git a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchy+Codable.swift b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchy+Codable.swift new file mode 100644 index 00000000..5b046182 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchy+Codable.swift @@ -0,0 +1,183 @@ +import UIKit + +// MARK: - UIKit Type Codable Extensions + +#if compiler(>=6.0) + extension UIAccessibilityTraits: @retroactive Codable {} +#else + extension UIAccessibilityTraits: Codable {} +#endif + +// MARK: - UIAccessibilityTraits Codable + +public extension UIAccessibilityTraits { + /// Known trait names for human-readable encoding + private static let knownTraits: [(trait: UIAccessibilityTraits, name: String)] = [ + (.button, "button"), + (.link, "link"), + (.image, "image"), + (.selected, "selected"), + (.playsSound, "playsSound"), + (.keyboardKey, "keyboardKey"), + (.staticText, "staticText"), + (.summaryElement, "summaryElement"), + (.notEnabled, "notEnabled"), + (.updatesFrequently, "updatesFrequently"), + (.searchField, "searchField"), + (.startsMediaSession, "startsMediaSession"), + (.adjustable, "adjustable"), + (.allowsDirectInteraction, "allowsDirectInteraction"), + (.causesPageTurn, "causesPageTurn"), + (.header, "header"), + (.tabBar, "tabBar"), + // Private traits (defined in UIAccessibility+SnapshotAdditions.swift) + (.textEntry, "textEntry"), + (.isEditing, "isEditing"), + (.backButton, "backButton"), + (.tabBarItem, "tabBarItem"), + (.scrollable, "scrollable"), + (.switchButton, "switchButton"), + ] + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let traitNames = try container.decode([String].self) + + var traits = UIAccessibilityTraits() + var unknownValues: UInt64 = 0 + + for name in traitNames { + if let known = Self.knownTraits.first(where: { $0.name == name }) { + traits.insert(known.trait) + } else if name.hasPrefix("unknown("), name.hasSuffix(")") { + // Parse unknown raw values: "unknown(12345)" + let startIndex = name.index(name.startIndex, offsetBy: 8) + let endIndex = name.index(name.endIndex, offsetBy: -1) + if let rawValue = UInt64(name[startIndex ..< endIndex]) { + unknownValues |= rawValue + } + } + } + + self = UIAccessibilityTraits(rawValue: traits.rawValue | unknownValues) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + var traitNames: [String] = [] + var remainingRawValue = rawValue + + for (trait, name) in Self.knownTraits { + if contains(trait) { + traitNames.append(name) + remainingRawValue &= ~trait.rawValue + } + } + + // Encode any unknown traits as raw values for forward compatibility + if remainingRawValue != 0 { + traitNames.append("unknown(\(remainingRawValue))") + } + + try container.encode(traitNames) + } +} + +// MARK: - Shape Codable + +extension AccessibilityElement.Shape: Codable { + private enum CodingKeys: String, CodingKey { + case type + case frame + case pathElements + } + + private enum ShapeType: String, Codable { + case frame + case path + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ShapeType.self, forKey: .type) + + switch type { + case .frame: + let frame = try container.decode(CGRect.self, forKey: .frame) + self = .frame(frame) + + case .path: + let elements = try container.decode([PathElement].self, forKey: .pathElements) + let path = UIBezierPath() + for element in elements { + element.apply(to: path) + } + self = .path(path) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case let .frame(frame): + try container.encode(ShapeType.frame, forKey: .type) + try container.encode(frame, forKey: .frame) + + case let .path(path): + try container.encode(ShapeType.path, forKey: .type) + let elements = PathElement.elements(from: path.cgPath) + try container.encode(elements, forKey: .pathElements) + } + } +} + +// MARK: - PathElement for Path Encoding + +/// Represents a single element of a CGPath for Codable serialization +private enum PathElement: Codable, Equatable { + case move(to: CGPoint) + case line(to: CGPoint) + case quadCurve(to: CGPoint, control: CGPoint) + case curve(to: CGPoint, control1: CGPoint, control2: CGPoint) + case closeSubpath + + func apply(to path: UIBezierPath) { + switch self { + case let .move(to): + path.move(to: to) + case let .line(to): + path.addLine(to: to) + case let .quadCurve(to, control): + path.addQuadCurve(to: to, controlPoint: control) + case let .curve(to, control1, control2): + path.addCurve(to: to, controlPoint1: control1, controlPoint2: control2) + case .closeSubpath: + path.close() + } + } + + static func elements(from cgPath: CGPath) -> [PathElement] { + var elements: [PathElement] = [] + + cgPath.applyWithBlock { elementPointer in + let element = elementPointer.pointee + switch element.type { + case .moveToPoint: + elements.append(.move(to: element.points[0])) + case .addLineToPoint: + elements.append(.line(to: element.points[0])) + case .addQuadCurveToPoint: + elements.append(.quadCurve(to: element.points[1], control: element.points[0])) + case .addCurveToPoint: + elements.append(.curve(to: element.points[2], control1: element.points[0], control2: element.points[1])) + case .closeSubpath: + elements.append(.closeSubpath) + @unknown default: + break + } + } + + return elements + } +} diff --git a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchy.swift b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchy.swift new file mode 100644 index 00000000..1290c4f1 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchy.swift @@ -0,0 +1,100 @@ +// MARK: - Hierarchy Node + +/// A node in the accessibility hierarchy tree +public enum AccessibilityHierarchy: Equatable, Codable { + /// A leaf node representing an accessibility element + /// - Parameters: + /// - AccessibilityElement: The accessibility element data + /// - traversalIndex: Position in VoiceOver traversal order + case element(AccessibilityElement, traversalIndex: Int) + + /// A container node that groups child elements + /// - Parameters: + /// - AccessibilityContainer: Container metadata (type, label, value, identifier, frame) + /// - children: Child nodes within this container + case container(AccessibilityContainer, children: [AccessibilityHierarchy]) + + /// Child nodes (empty for leaf elements, contains children for containers) + public var children: [AccessibilityHierarchy] { + switch self { + case .element: + return [] + case let .container(_, children): + return children + } + } + + /// Sort index for ordering in legend display. + /// For elements: returns the traversal index. + /// For containers: returns the minimum sort index of its children (recursively). + public var sortIndex: Int { + switch self { + case let .element(_, index): + return index + case let .container(_, children): + // Return minimum sort index of children, or Int.max if no children + return children.map { $0.sortIndex }.min() ?? Int.max + } + } +} + +// MARK: - Hierarchy Utilities + +public extension AccessibilityHierarchy { + /// Recursively visits each node in the hierarchy tree + func forEach(_ apply: (AccessibilityHierarchy) -> Void) { + apply(self) + for child in children { + child.forEach(apply) + } + } +} + +public extension Array where Element == AccessibilityHierarchy { + /// Flattens an array of hierarchy nodes into a single array of elements in VoiceOver traversal order + func flattenToElements() -> [AccessibilityElement] { + var elementPairs: [(index: Int, element: AccessibilityElement)] = [] + + func collectElements(from node: AccessibilityHierarchy) { + switch node { + case let .element(element, index): + elementPairs.append((index, element)) + case let .container(_, children): + for child in children { + collectElements(from: child) + } + } + } + + for node in self { + collectElements(from: node) + } + + return elementPairs + .sorted { $0.index < $1.index } + .map { $0.element } + } + + /// Flattens an array of hierarchy nodes into a single array of containers in depth-first order + func flattenToContainers() -> [AccessibilityContainer] { + var containers: [AccessibilityContainer] = [] + + func collectContainers(from node: AccessibilityHierarchy) { + switch node { + case .element: + break + case let .container(container, children): + containers.append(container) + for child in children { + collectContainers(from: child) + } + } + } + + for node in self { + collectContainers(from: node) + } + + return containers + } +} diff --git a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchyParser.swift b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchyParser.swift index 26ce0bd5..6aa3ec90 100644 --- a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchyParser.swift +++ b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchyParser.swift @@ -2,154 +2,7 @@ import Accessibility import SwiftUI import UIKit -public struct AccessibilityMarker: Equatable { - /// Default number of rotor results to collect in each direction. - public static let defaultRotorResultLimit: Int = 10 - - // MARK: - Public Types - - public enum Shape: Equatable { - /// Accessibility frame, in the coordinate space of the view being snapshotted. - case frame(CGRect) - - /// Accessibility path, in the coordinate space of the view being snapshotted. - case path(UIBezierPath) - } - - public struct CustomRotor: Equatable, CustomStringConvertible { - public struct ResultMarker: Equatable, CustomStringConvertible { - public let elementDescription: String - public let rangeDescription: String? - public let shape: Shape? - - public var description: String { - guard let rangeDescription else { - return elementDescription - } - return "\(elementDescription) \(rangeDescription)" - } - } - - public var name: String - public var resultMarkers: [AccessibilityMarker.CustomRotor.ResultMarker] = [] - public let limit: UIAccessibilityCustomRotor.CollectedRotorResults.Limit - - init?(from: UIAccessibilityCustomRotor, parentElement: NSObject, root: UIView, context: AccessibilityHierarchyParser.Context? = nil, resultLimit: Int) { - guard from.isKnownRotorType else { return nil } - name = from.displayName(locale: parentElement.accessibilityLanguage) - let collected = from.collectAllResults(nextLimit: resultLimit, previousLimit: resultLimit) - limit = collected.limit - resultMarkers = collected.results.compactMap { result in - guard let element = result.targetElement as? NSObject else { return nil } - var description = element.accessibilityDescription(context: context).description - var shape: Shape? = AccessibilityHierarchyParser.accessibilityShape(for: element, in: root) - - if let range = result.targetRange, - let input = element as? UITextInput - { - if let path = input.accessibilityPath(for: range) { - let converted = root.convert(path, from: input as? UIView) - shape = .path(converted) - } - if let substring = input.text(in: range) { - description = substring - } - return ResultMarker(elementDescription: description, rangeDescription: range.formatted(in: input), shape: shape) - } - return ResultMarker(elementDescription: description, rangeDescription: nil, shape: shape) - } - } - - public var description: String { - return name + ": " + resultMarkers.map { $0.description }.joined(separator: "\n") - } - } - - public struct CustomContent: Codable, Equatable { - public var label: String - public var value: String - public var isImportant: Bool - - @available(iOS 14.0, *) - init(from: AXCustomContent) { - label = from.label - value = from.value - isImportant = from.importance == .high - } - } - - public struct CustomAction: Equatable { - public var name: String - public var image: UIImage? - - init(name: String, image: UIImage?) { - self.name = name - self.image = image - } - - @available(iOS 14.0, *) - init(from: UIAccessibilityCustomAction) { - name = from.name - image = from.image - } - } - - // MARK: - Public Properties - - /// The description of the accessibility element that will be read by VoiceOver when the element is brought into - /// focus. - public var description: String - - public var label: String? - - public var value: String? - - public var traits: UIAccessibilityTraits - - /// A unique identifier for the element, primarily used in UI tests for locating and interacting with elements. - /// This identifier is not visible to users. - public var identifier: String? - - /// A hint that will be read by VoiceOver if focus remains on the element after the `description` is read. - public var hint: String? - - /// The labels that will be used by Voice Control for user input. - /// These labels are displayed based on the `AccessibilityContentDisplayMode` configuration: - /// - `.always`: Always shows user input labels - /// - `.whenOverridden`: Shows labels only when they differ from default values (future enhancement) - /// - `.never`: Never shows user input labels - public var userInputLabels: [String]? - - /// The shape that will be highlighted on screen while the element is in focus. - public var shape: Shape - - /// The accessibility activation point, in the coordinate space of the view being snapshotted. - public var activationPoint: CGPoint - - /// Whether or not the `activationPoint` is the default activation point for the object. - /// - /// For most elements, the default activation point is the midpoint of the element's accessibility frame. Certain - /// elements have distinct defaults - for example, a `UISlider` puts its activation point at the center of its thumb - /// by default. - public var usesDefaultActivationPoint: Bool - - /// The names of the custom actions supported by the element. - public var customActions: [CustomAction] - - /// Any custom content included by the element. - public var customContent: [CustomContent] - - /// Any custom rotors included by the element. - public var customRotors: [CustomRotor] - - /// The language code of the language used to localize strings in the description. - public var accessibilityLanguage: String? - - /// whether the element performs an action based on user interaction. - public var respondsToUserInteraction: Bool -} - -// MARK: - +// MARK: - Providing Protocols public protocol UserInterfaceLayoutDirectionProviding { var userInterfaceLayoutDirection: UIUserInterfaceLayoutDirection { get } @@ -166,74 +19,6 @@ extension UIDevice: UserInterfaceIdiomProviding {} // MARK: - public final class AccessibilityHierarchyParser { - // MARK: - Public Types - - /// Represents a context in which elements are contained. - public enum Context { - /// Indicates the element is part of a series of elements. - /// Reads as "`index` of `count`." - /// - /// - `index`: The index of the item in the series. - /// - `count`: The total number of items in the series. - case series(index: Int, count: Int) - - /// Indicates the element is part of a series of tab bar items. - /// Reads as "Tab. `index` of `count`." - /// - /// This is used for the items of a `UITabBar`. There is a similar context for tab bar items in a container with - /// the `.tabBar` trait which behaves slightly differently. See `Context.tab`. - /// - /// - `index`: The index of this tab in the tab bar. - /// - `count`: The total number of tabs in the tab bar. - /// - `item`: The `UITabBarItem` representing this tab. - case tabBarItem(index: Int, count: Int, item: UITabBarItem) - - /// Indicates the element is part of a series of tab bar items. - /// Reads as "Tab. `index` of `count`." - /// - /// This is used for tab bars that use the `.tabBar` trait, not `UITabBar`s, which use a different mechanism for - /// distinguishing their tabs. See `Context.tabBarItem`. - /// - /// - `index`: The index of this tab in the tab bar. - /// - `count`: The total number of tabs in the tab bar. - case tab(index: Int, count: Int) - - /// Indicates the element is a cell in a data table. - /// - /// - `row`: The row of the cell in the table. - /// - `column`: The column of the cell in the table. - /// - `width`: The number of columns the cell spans. - /// - `height`: The number of rows the cell spans. - /// - `isFirstInRow`: Whether or not the cell is the first in its row that VoiceOver will read. - /// - `rowHeaders`: The cells for which row header data will be read for this cell. - /// - `columnHeaders`: The cells for which column header data will be read for this cell. - case dataTableCell( - row: Int, - column: Int, - width: Int, - height: Int, - isFirstInRow: Bool, - rowHeaders: [NSObject], - columnHeaders: [NSObject] - ) - - /// Indicates the element is the first element in a list. - case listStart - - /// Indicates the element is the last element in a list. - /// - /// If an element is the only element in the list, it will only get a `listStart` context. - case listEnd - - /// Indicates the element is the first element in a landmark container. - case landmarkStart - - /// Indicates the element is the last element in a landmark container. - /// - /// If an element is the only element in the landmark container, it will only get a `landmarkStart` context. - case landmarkEnd - } - // MARK: - Life Cycle public init() {} @@ -243,7 +28,7 @@ public final class AccessibilityHierarchyParser { /// Parses the accessibility hierarchy starting from the `root` view and returns markers for each element in the /// hierarchy, in the order VoiceOver will iterate through them when using flick navigation. /// - /// The returned `AccessibilityMarker` objects include user input labels that are displayed based on the + /// The returned `AccessibilityElement` objects include user input labels that are displayed based on the /// `AccessibilityContentDisplayMode` configuration set in the snapshot testing methods: /// - `.always`: Always includes user input labels in the markers, including default (derived) labels. /// - `.whenOverridden`: Includes labels only when they differ from default values. @@ -254,15 +39,55 @@ public final class AccessibilityHierarchyParser { /// - parameter rotorResultLimit: Maximum number of rotor results to collect in each direction. Defaults to 10. /// - parameter userInterfaceLayoutDirectionProvider: The provider of the device's user interface layout direction. /// In most cases, this should use the default value, `UIApplication.shared`. + @available(*, deprecated, message: "Use parseAccessibilityHierarchy(in:) and flattenToElements() instead") public func parseAccessibilityElements( in root: UIView, - rotorResultLimit: Int = AccessibilityMarker.defaultRotorResultLimit, + rotorResultLimit: Int = AccessibilityElement.defaultRotorResultLimit, userInterfaceLayoutDirectionProvider: UserInterfaceLayoutDirectionProviding = UIApplication.shared, - userInterfaceIdiomProvider: UserInterfaceIdiomProviding = UIDevice.current - ) -> [AccessibilityMarker] { + userInterfaceIdiomProvider: UserInterfaceIdiomProviding = UIDevice.current, + verbosity: VerbosityConfiguration = .verbose + ) -> [AccessibilityElement] { + return parseAccessibilityHierarchy( + in: root, + rotorResultLimit: rotorResultLimit, + userInterfaceLayoutDirectionProvider: userInterfaceLayoutDirectionProvider, + userInterfaceIdiomProvider: userInterfaceIdiomProvider, + verbosity: verbosity + ).flattenToElements() + } + + /// Parses the accessibility hierarchy starting from the `root` view and returns a tree structure + /// with containers grouping their child elements. + /// + /// This method uses the same element parsing logic as `parseAccessibilityElements` but additionally + /// tracks containers (semanticGroup, list, landmark, dataTable, tabBar) and nests elements within them. + /// + /// Container inclusion rules based on VoiceOver behavior: + /// - `.semanticGroup` with label/value/identifier: INCLUDE (label is announced) + /// - `.list`, `.landmark`, `.dataTable`: INCLUDE (affects rotor navigation) + /// - Views with `.tabBar` trait: INCLUDE (affects tab navigation) + /// - `.semanticGroup` without properties: EXCLUDE (no announcement) + /// - `.none` containers: EXCLUDE (no special behavior) + /// + /// Each element node includes a `traversalIndex` indicating its position in VoiceOver's navigation order. + /// Use `flattenToElements()` on the result to get the same output as `parseAccessibilityElements`. + /// + /// - parameter root: The root view of the accessibility hierarchy + /// - parameter rotorResultLimit: Maximum number of rotor results to collect in each direction. Defaults to 10. + /// - parameter userInterfaceLayoutDirectionProvider: Provider of the device's UI layout direction + /// - parameter userInterfaceIdiomProvider: Provider of the device's interface idiom + /// - returns: Array of root-level hierarchy nodes with containers grouping their children + public func parseAccessibilityHierarchy( + in root: UIView, + rotorResultLimit: Int = AccessibilityElement.defaultRotorResultLimit, + userInterfaceLayoutDirectionProvider: UserInterfaceLayoutDirectionProviding = UIApplication.shared, + userInterfaceIdiomProvider: UserInterfaceIdiomProviding = UIDevice.current, + verbosity: VerbosityConfiguration = .verbose + ) -> [AccessibilityHierarchy] { let userInterfaceLayoutDirection = userInterfaceLayoutDirectionProvider.userInterfaceLayoutDirection let userInterfaceIdiom = userInterfaceIdiomProvider.userInterfaceIdiom + // Parse elements using the same logic as parseAccessibilityElements let accessibilityNodes = root.recursiveAccessibilityHierarchy() let uncontextualizedElements = sortedElements( @@ -273,7 +98,7 @@ public final class AccessibilityHierarchyParser { userInterfaceIdiom: userInterfaceIdiom ) - let accessibilityElements = uncontextualizedElements.map { element in + let contextualizedElements = uncontextualizedElements.map { element in ContextualElement( object: element.object, context: context( @@ -285,32 +110,12 @@ public final class AccessibilityHierarchyParser { ) } - return accessibilityElements.map { element in - let (description, hint) = element.object.accessibilityDescription(context: element.context) - - let activationPoint = element.object.accessibilityActivationPoint - - return AccessibilityMarker( - description: description, - label: element.object.accessibilityLabel, - value: element.object.accessibilityValue, - traits: element.object.accessibilityTraits, - identifier: element.object.identifier, - hint: hint, - userInputLabels: element.object.accessibilityUserInputLabels, - shape: Self.accessibilityShape(for: element.object, in: root), - activationPoint: root.convert(activationPoint, from: nil), - usesDefaultActivationPoint: activationPoint.approximatelyEquals( - Self.defaultActivationPoint(for: element.object), - tolerance: 1 / (root.window?.screen ?? UIScreen.main).scale - ), - customActions: element.object.accessibilityCustomActions?.map { AccessibilityMarker.CustomAction(name: $0.name, image: $0.image) } ?? [], - customContent: element.object.customContent, - customRotors: element.object.customRotors(in: root, context: element.context, resultLimit: rotorResultLimit), - accessibilityLanguage: element.object.accessibilityLanguage, - respondsToUserInteraction: element.object.accessibilityRespondsToUserInteraction - ) + let elements: [AccessibilityElement] = contextualizedElements.map { element in + buildElement(from: element.object, context: element.context, in: root, rotorResultLimit: rotorResultLimit, verbosity: verbosity) } + + // Map AccessibilityNode tree to AccessibilityHierarchy tree + return mapNodesToHierarchy(accessibilityNodes, sortedElements: uncontextualizedElements, elements: elements, in: root) } // MARK: - Private Types @@ -320,7 +125,7 @@ public final class AccessibilityHierarchyParser { private struct ContextualElement { var object: NSObject - var context: Context? + var context: AccessibilityElement.ContainerContext? } fileprivate enum ContextProvider { @@ -331,16 +136,42 @@ public final class AccessibilityHierarchyParser { case dataTable(UIAccessibilityContainerDataTable) } - /// Representation of an accessibility element, made up of the element `object` itself and the `contextProvider` - /// that will provide its context, if applicable. - private struct Element { - var object: NSObject + // MARK: - Private Methods - var contextProvider: ContextProvider? + /// Builds an AccessibilityElement from an NSObject and its context + private func buildElement( + from object: NSObject, + context: AccessibilityElement.ContainerContext?, + in root: UIView, + rotorResultLimit: Int, + verbosity: VerbosityConfiguration = .verbose + ) -> AccessibilityElement { + let (description, hint) = object.buildAccessibilityDescription(context: context, verbosity: verbosity) + let activationPoint = object.accessibilityActivationPoint + + return AccessibilityElement( + description: description, + label: object.accessibilityLabel, + value: object.accessibilityValue, + traits: object.accessibilityTraits, + identifier: object.identifier, + hint: hint, + userInputLabels: object.accessibilityUserInputLabels, + shape: Self.accessibilityShape(for: object, in: root), + activationPoint: root.convert(activationPoint, from: nil), + usesDefaultActivationPoint: activationPoint.approximatelyEquals( + Self.defaultActivationPoint(for: object), + tolerance: 1 / (root.window?.screen ?? UIScreen.main).scale + ), + customActions: object.accessibilityCustomActions?.map { AccessibilityElement.CustomAction(name: $0.name, image: $0.image) } ?? [], + customContent: object.customContent, + customRotors: object.customRotors(in: root, resultLimit: rotorResultLimit), + accessibilityLanguage: object.accessibilityLanguage, + respondsToUserInteraction: object.accessibilityRespondsToUserInteraction, + containerContext: context + ) } - // MARK: - Private Methods - /// Returns the elements in the provided `nodes` tree in the order in which VoiceOver will iterate through them. /// /// - parameter nodes: The nodes to sort. @@ -355,7 +186,7 @@ public final class AccessibilityHierarchyParser { in root: UIView, userInterfaceLayoutDirection: UIUserInterfaceLayoutDirection, userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom - ) -> [Element] { + ) -> [(object: NSObject, contextProvider: ContextProvider?)] { // VoiceOver flick navigation iterates through elements in a horizontal, then vertical order. The horizontal // ordering matches the application's user interface layout direction. The vertical ordering is always // top-to-bottom. There are a couple exceptions to the order of iteration: @@ -397,14 +228,14 @@ public final class AccessibilityHierarchyParser { } .map { $0.0 } - var sortedElements: [Element] = [] + var sortedElements: [(object: NSObject, contextProvider: ContextProvider?)] = [] for node in sortedNodes { switch node { case let .element(element, contextProvider): - sortedElements.append(.init(object: element, contextProvider: contextProvider)) + sortedElements.append((object: element, contextProvider: contextProvider)) - case let .group(elements, explicitlyOrdered, _): + case let .group(elements, explicitlyOrdered, _, _): sortedElements.append( contentsOf: self.sortedElements( for: elements, @@ -426,7 +257,7 @@ public final class AccessibilityHierarchyParser { from contextProvider: ContextProvider?, userInterfaceLayoutDirection: UIUserInterfaceLayoutDirection, userInterfaceIdiom: UIUserInterfaceIdiom - ) -> Context? { + ) -> AccessibilityElement.ContainerContext? { guard let contextProvider = contextProvider else { return nil } @@ -457,8 +288,7 @@ public final class AccessibilityHierarchyParser { return .tabBarItem( index: tabIndex + 1, - count: tabBarItems.count, - item: tabBarItems[tabIndex] + count: tabBarItems.count ) } @@ -542,9 +372,9 @@ public final class AccessibilityHierarchyParser { dataTable.accessibilityDataTableCellElement(forRow: rowRange.location, column: $0) != nil } - let rowHeaders: [NSObject] + let rowHeaderObjects: [NSObject] if isFirstInRow, let allHeaders = dataTable.accessibilityHeaderElements?(forRow: row) { - rowHeaders = allHeaders.filter { header in + rowHeaderObjects = allHeaders.filter { header in true // The cell is not read as a header for itself. && header !== cell @@ -553,12 +383,12 @@ public final class AccessibilityHierarchyParser { } as! [NSObject] } else { - rowHeaders = [] + rowHeaderObjects = [] } - let columnHeaders: [NSObject] + let columnHeaderObjects: [NSObject] if let allHeaders = dataTable.accessibilityHeaderElements?(forColumn: column) { - columnHeaders = allHeaders.filter { header in + columnHeaderObjects = allHeaders.filter { header in let headerRow = header.accessibilityRowRange().location let headerColumn = header.accessibilityColumnRange().location @@ -577,17 +407,31 @@ public final class AccessibilityHierarchyParser { } as! [NSObject] } else { - columnHeaders = [] + columnHeaderObjects = [] + } + + // Pre-format header strings + func formatHeader(_ header: NSObject) -> String { + switch (header.accessibilityLabel?.nonEmpty(), header.accessibilityValue?.nonEmpty()) { + case (nil, nil): + return "" + case let (.some(label), nil): + return "\(label). " + case let (nil, .some(value)): + return "\(value). " + case let (.some(label), .some(value)): + return "\(label): \(value). " + } } return .dataTableCell( row: row, column: column, - width: columnRange.length, - height: rowRange.length, + rowSpan: rowRange.length, + columnSpan: columnRange.length, isFirstInRow: isFirstInRow, - rowHeaders: rowHeaders, - columnHeaders: columnHeaders + rowHeaders: rowHeaderObjects.map(formatHeader), + columnHeaders: columnHeaderObjects.map(formatHeader) ) } } @@ -599,29 +443,81 @@ public final class AccessibilityHierarchyParser { /// Used for memoization of accessibility hierarchy parsing when determining element contexts. private var viewToElementsMap: [UIView: [NSObject]] = [:] -} -private extension AccessibilityHierarchyParser { - /// Returns a CGRect that can be used for sorting by position. - static func accessibilitySortFrame(for node: AccessibilityNode, in root: UIView) -> CGRect { - switch node { - case let .element(frameProvider, _), - let .group(_, _, frameProvider?): - switch accessibilityShape(for: frameProvider, in: root, preferPath: false) { - case let .frame(rect): - return rect - default: - return frameProvider.accessibilityFrame - } + // MARK: - Private Hierarchy Methods - case let .group(elements, _, _): - return elements.reduce(CGRect.null) { $0.union(accessibilitySortFrame(for: $1, in: root)) } + /// Maps AccessibilityNode tree to AccessibilityHierarchy tree + private func mapNodesToHierarchy( + _ nodes: [AccessibilityNode], + sortedElements: [(object: NSObject, contextProvider: ContextProvider?)], + elements: [AccessibilityElement], + in root: UIView + ) -> [AccessibilityHierarchy] { + // Build lookup: object identity → traversal index + var indexLookup: [ObjectIdentifier: Int] = [:] + for (index, element) in sortedElements.enumerated() { + indexLookup[ObjectIdentifier(element.object)] = index } + + func mapNode(_ node: AccessibilityNode) -> [AccessibilityHierarchy] { + switch node { + case let .element(object, _): + guard let index = indexLookup[ObjectIdentifier(object)], + index < elements.count else { return [] } + return [.element(elements[index], traversalIndex: index)] + + case let .group(children, _, _, containerInfo): + let mappedChildren = children.flatMap { mapNode($0) }.sorted { lhs, rhs in + lhs.sortIndex < rhs.sortIndex + } + + if let info = containerInfo { + let frame = root.convert(info.view.bounds, from: info.view) + + // Convert UIAccessibilityContainerType + associated data to our ContainerType + let containerType: AccessibilityContainer.ContainerType + if info.traits.contains(.tabBar) { + containerType = .tabBar + } else { + switch info.type { + case .semanticGroup: + containerType = .semanticGroup(label: info.label, value: info.value, identifier: info.identifier) + case .list: + containerType = .list + case .landmark: + containerType = .landmark + case .dataTable: + containerType = .dataTable(rowCount: info.rowCount ?? 0, columnCount: info.columnCount ?? 0) + case .none: + // Should not reach here since containerInfo(for:) returns nil for .none + containerType = .semanticGroup(label: info.label, value: info.value, identifier: info.identifier) + @unknown default: + containerType = .semanticGroup(label: info.label, value: info.value, identifier: info.identifier) + } + } + + let container = AccessibilityContainer( + type: containerType, + frame: frame + ) + return [.container(container, children: mappedChildren)] + } + + // Not a meaningful container - flatten children + return mappedChildren + } + } + + return nodes.flatMap { mapNode($0) } } +} +// MARK: - Internal Helpers + +extension AccessibilityHierarchyParser { /// Returns the shape of the accessibility element in the root view's coordinate space. /// Voiceover prefers an accessibilityPath if available when drawing the bounding box, but the accessibilityFrame is always used for sort order. - static func accessibilityShape(for element: NSObject, in root: UIView, preferPath: Bool = true) -> AccessibilityMarker.Shape { + static func accessibilityShape(for element: NSObject, in root: UIView, preferPath: Bool = true) -> AccessibilityElement.Shape { if let accessibilityPath = element.accessibilityPath, preferPath { return .path(root.convert(accessibilityPath, from: nil)) @@ -651,8 +547,41 @@ private extension AccessibilityHierarchyParser { } } +// MARK: - Fileprivate Helpers + +private extension AccessibilityHierarchyParser { + /// Returns a CGRect that can be used for sorting by position. + static func accessibilitySortFrame(for node: AccessibilityNode, in root: UIView) -> CGRect { + switch node { + case let .element(frameProvider, _), + let .group(_, _, frameProvider?, _): + switch accessibilityShape(for: frameProvider, in: root, preferPath: false) { + case let .frame(rect): + return rect + default: + return frameProvider.accessibilityFrame + } + + case let .group(elements, _, _, _): + return elements.reduce(CGRect.null) { $0.union(accessibilitySortFrame(for: $1, in: root)) } + } + } +} + // MARK: - +/// Captures container information at node creation time, avoiding the need to re-derive it later. +private struct ContainerInfo { + let view: UIView + let type: UIAccessibilityContainerType + let label: String? + let value: String? + let identifier: String? + let traits: UIAccessibilityTraits + let rowCount: Int? + let columnCount: Int? +} + private enum AccessibilityNode { /// Represents a single accessibility element. case element(NSObject, contextProvider: AccessibilityHierarchyParser.ContextProvider?) @@ -665,7 +594,8 @@ private enum AccessibilityNode { /// - `frameOverrideProvider`: The object whose accessibility frame is used to determine the group's ordering in the /// accessibility hierarchy. When `nil`, the group is ordered according to the first element in the group that would /// be selected. - case group([AccessibilityNode], explicitlyOrdered: Bool, frameOverrideProvider: NSObject?) + /// - `container`: Container info if this group represents a meaningful accessibility container. + case group([AccessibilityNode], explicitlyOrdered: Bool, frameOverrideProvider: NSObject?, container: ContainerInfo?) } // MARK: - @@ -704,10 +634,13 @@ private extension NSObject { ) ) } + // Capture container info - this path always creates a group, so just capture for metadata + let container = (self as? UIView).flatMap { containerInfo(for: $0) } recursiveAccessibilityHierarchy.append(.group( accessibilityHierarchyOfElements, explicitlyOrdered: true, - frameOverrideProvider: overridesElementFrame(with: contextProvider) ? self : nil + frameOverrideProvider: overridesElementFrame(with: contextProvider) ? self : nil, + container: container )) } else if let `self` = self as? UIView { @@ -729,11 +662,13 @@ private extension NSObject { ) } - if shouldGroupAccessibilityChildren { + // Capture container info if this is a meaningful container + let container = containerInfo(for: self) + + if shouldGroupAccessibilityChildren || container != nil { recursiveAccessibilityHierarchy.append( - .group(accessibilityHierarchyOfSubviews, explicitlyOrdered: false, frameOverrideProvider: nil) + .group(accessibilityHierarchyOfSubviews, explicitlyOrdered: false, frameOverrideProvider: nil, container: container) ) - } else { recursiveAccessibilityHierarchy.append(contentsOf: accessibilityHierarchyOfSubviews) } @@ -742,6 +677,43 @@ private extension NSObject { return recursiveAccessibilityHierarchy } + /// Creates ContainerInfo for a view if it represents a meaningful accessibility container. + /// Returns nil if the view is not a container worth visualizing. + private func containerInfo(for view: UIView) -> ContainerInfo? { + let containerType = view.accessibilityContainerType + let traits = view.accessibilityTraits + let label = view.accessibilityLabel + let value = view.accessibilityValue + let identifier = (view as UIAccessibilityIdentification).accessibilityIdentifier + + // Extract data table dimensions if applicable + let (rowCount, columnCount): (Int?, Int?) = { + guard containerType == .dataTable, + let dataTable = view as? UIAccessibilityContainerDataTable + else { + return (nil, nil) + } + return (dataTable.accessibilityRowCount(), dataTable.accessibilityColumnCount()) + }() + + // tabBar trait always creates container + if traits.contains(.tabBar) { + return ContainerInfo(view: view, type: containerType, label: label, value: value, identifier: identifier, traits: traits, rowCount: nil, columnCount: nil) + } + + // list, landmark, dataTable always create container + if containerType == .list || containerType == .landmark || containerType == .dataTable { + return ContainerInfo(view: view, type: containerType, label: label, value: value, identifier: identifier, traits: traits, rowCount: rowCount, columnCount: columnCount) + } + + // semanticGroup only if has label/value/identifier + if containerType == .semanticGroup, label != nil || value != nil || identifier != nil { + return ContainerInfo(view: view, type: containerType, label: label, value: value, identifier: identifier, traits: traits, rowCount: nil, columnCount: nil) + } + + return nil + } + /// Whether or not the object provides context to elements beneath it in the hierarchy. /// /// Some elements can provide context in multiple roles, which can be differentiated using the @@ -828,7 +800,7 @@ private extension UIView { } private extension NSObject { - var customContent: [AccessibilityMarker.CustomContent] { + var customContent: [AccessibilityElement.CustomContent] { // Github runs tests on specific iOS versions against specific versions of Xcode in CI. // Forward deployment on old versions of Xcode require a compile time check which require differentiation by swift version rather than iOS SDK. // See https://swiftversion.net/ for mapping swift version to Xcode versions. @@ -859,9 +831,9 @@ private extension NSObject { return [] } - func customRotors(in root: UIView, context: AccessibilityHierarchyParser.Context?, resultLimit: Int) -> [AccessibilityMarker.CustomRotor] { + func customRotors(in root: UIView, resultLimit: Int) -> [AccessibilityElement.CustomRotor] { accessibilityCustomRotors?.compactMap { - .init(from: $0, parentElement: self, root: root, context: context, resultLimit: resultLimit) + .init(from: $0, parentElement: self, root: root, resultLimit: resultLimit) } ?? [] } @@ -929,7 +901,7 @@ private extension CGPoint { } } -private extension UITextRange { +extension UITextRange { func formatted(in input: UITextInput?) -> String { guard let input else { return "\(self)" } diff --git a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/UIAccessibility+RotorAdditions.swift b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/UIAccessibility+RotorAdditions.swift new file mode 100644 index 00000000..5d238b61 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/UIAccessibility+RotorAdditions.swift @@ -0,0 +1,327 @@ +import UIKit + +// MARK: - Rotor Extensions + +extension UIAccessibilityCustomRotor { + var isKnownRotorType: Bool { + switch systemRotorType { + case .none, .link, .visitedLink, .heading, .headingLevel1, .headingLevel2, .headingLevel3, .headingLevel4, .headingLevel5, .headingLevel6, .boldText, .italicText, .underlineText, .misspelledWord, .image, .textField, .table, .list, .landmark: + return true + @unknown default: + return false + } + } + + func displayName(locale: String? = nil) -> String { + guard name.isEmpty else { + return name + } + + switch systemRotorType { + case .none: + return "None".localized( + key: "rotor.none.description", + comment: "Description for a rotor with no type", + locale: locale + ) + case .link: + return "Links".localized( + key: "rotor.link.description", + comment: "Description for the 'links' rotor", + locale: locale + ) + case .visitedLink: + return "Visited Links".localized( + key: "rotor.visited_link.description", + comment: "Description for the 'visited links' rotor", + locale: locale + ) + case .heading: + return "Headings".localized( + key: "rotor.heading.description", + comment: "Description for the 'headings' rotor", + locale: locale + ) + case .headingLevel1: + return "Heading 1".localized( + key: "rotor.heading_level1.description", + comment: "Description for the 'heading level 1' rotor", + locale: locale + ) + case .headingLevel2: + return "Heading 2".localized( + key: "rotor.heading_level2.description", + comment: "Description for the 'heading level 2' rotor", + locale: locale + ) + case .headingLevel3: + return "Heading 3".localized( + key: "rotor.heading_level3.description", + comment: "Description for the 'heading level 3' rotor", + locale: locale + ) + case .headingLevel4: + return "Heading 4".localized( + key: "rotor.heading_level4.description", + comment: "Description for the 'heading level 4' rotor", + locale: locale + ) + case .headingLevel5: + return "Heading 5".localized( + key: "rotor.heading_level5.description", + comment: "Description for the 'heading level 5' rotor", + locale: locale + ) + case .headingLevel6: + return "Heading 6".localized( + key: "rotor.heading_level6.description", + comment: "Description for the 'heading level 6' rotor", + locale: locale + ) + case .boldText: + return "Bold Text".localized( + key: "rotor.bold_text.description", + comment: "Description for the 'bold text' rotor", + locale: locale + ) + case .italicText: + return "Italic Text".localized( + key: "rotor.italic_text.description", + comment: "Description for the 'italic text' rotor", + locale: locale + ) + case .underlineText: + return "Underlined Text".localized( + key: "rotor.underline_text.description", + comment: "Description for the 'underlined text' rotor", + locale: locale + ) + case .misspelledWord: + return "Misspelled Words".localized( + key: "rotor.misspelled_word.description", + comment: "Description for the 'misspelled words' rotor", + locale: locale + ) + case .image: + return "Images".localized( + key: "rotor.image.description", + comment: "Description for the 'images' rotor", + locale: locale + ) + case .textField: + return "Text Fields".localized( + key: "rotor.text_field.description", + comment: "Description for the 'text fields' rotor", + locale: locale + ) + case .table: + return "Tables".localized( + key: "rotor.table.description", + comment: "Description for the 'tables' rotor", + locale: locale + ) + case .list: + return "Lists".localized( + key: "rotor.list.description", + comment: "Description for the 'lists' rotor", + locale: locale + ) + case .landmark: + return "Landmarks".localized( + key: "rotor.landmark.description", + comment: "Description for the 'landmarks' rotor", + locale: locale + ) + @unknown default: + return String(format: + "Unknown Rotor Type, Raw value: %lld".localized( + key: "rotor.unknown.description_format", + comment: "Format for description of an unknown rotor type; param0: the raw value", + locale: locale + ), + systemRotorType.rawValue) + } + } + + public struct CollectedRotorResults: Equatable { + // Maximum number of results to count before stopping enumeration. + // When this limit is reached, we stop counting and report "99+ More Results" + public static let maximumCount: Int = 99 + + public enum Limit: Equatable, Codable { + case none + case underMaxCount(Int) + case greaterThanMaxCount + + func combine(_ other: Limit) -> Limit { + switch (self, other) { + case (.none, .none): + return .none + case (_, .greaterThanMaxCount), (.greaterThanMaxCount, _): + return .greaterThanMaxCount + case let (.underMaxCount(count), .none), let (.none, .underMaxCount(count)): + return .underMaxCount(count) + case let (.underMaxCount(a), .underMaxCount(b)): + if a + b <= maximumCount { + return .underMaxCount(a + b) + } + return .greaterThanMaxCount + } + } + } + + public let results: [UIAccessibilityCustomRotorItemResult] + public let limit: Limit + + init(results: [UIAccessibilityCustomRotorItemResult], limit: Limit = .none) { + self.results = results + self.limit = limit + } + } + + // Collects rotor results in both directions to capture all accessible items. + // Some rotors only provide results in one direction, so we check both. + // Intelligently merges results, removing duplicates and handling edge cases. + func collectAllResults(nextLimit: Int, previousLimit: Int) -> CollectedRotorResults { + let forwards = iterateResults(direction: .next, limit: nextLimit) + let backwards = iterateResults(direction: .previous, limit: nextLimit) + + // Its common that backwards and forwards contain the same elements with differing orders. + + let forwardsSet = resultSet(forwards.results) + let backwardsSet = resultSet(backwards.results) + + if forwardsSet == backwardsSet { return forwards } + if forwardsSet.isSuperset(of: backwardsSet) { return forwards } + if backwardsSet.isSuperset(of: forwardsSet) { return backwards } + + // When starting iteration without a currentItem, both directions often return + // the same first element. Drop one copy before merging to avoid duplicates. + // Example: forward=[A,B,C], backward=[A,D,E] -> result=[E,D,A,B,C] not [E,D,A,A,B,C] + if forwards.results.first?.compare(backwards.results.first) ?? false { + let results = backwards.results.dropFirst().reversed() + forwards.results + return .init(results: results, limit: backwards.limit.combine(forwards.limit)) + } + + let results = (backwards.results.reversed() + forwards.results).removingDuplicates() + return .init(results: results, limit: backwards.limit.combine(forwards.limit)) + } + + func iterateResults(direction: UIAccessibilityCustomRotor.Direction, limit: Int) -> CollectedRotorResults { + var results: [UIAccessibilityCustomRotorItemResult] = [] + let predicate = UIAccessibilityCustomRotorSearchPredicate() + var loopDetection: [Int] = [] + + predicate.searchDirection = direction + + while results.count < limit { + guard let result = itemSearchBlock(predicate), !result.compare(predicate.currentItem) else { break } + + if let hashable = _hashableRotorResult(result), + resultSet(results).contains(hashable) + { + loopDetection.append(results.count) + } + // Loop detection: Track when we encounter duplicate results. + // If we see 3 duplicates in a row (sequential indices), we're in an infinite loop. + // Example: [A,B,C,D,E,C,D,E,C,D,E] - indices [5,7,9] are sequential, stop at index 5. + // Non-sequential duplicates are OK (e.g., A->B->C->A->D->E->F is not a loop). + if loopDetection.count >= 3 { + if loopDetection.isSequential() { + break + } + // indices are not sequential, this is not a loop. + else { + loopDetection = [] + } + } + + results.append(result) + predicate.currentItem = result + } + + // Reset the results array to end at the first duplicated element + if !loopDetection.isEmpty, loopDetection.isSequential(), loopDetection.last == results.count { + results = Array(results.prefix(upTo: loopDetection.first!)) + } + + if let last = results.last { + predicate.currentItem = last + } + + let limited = results.count <= limit ? countAdditionalResults(predicate) : .none + return .init(results: results, limit: limited) + } + + private func countAdditionalResults(_ predicate: UIAccessibilityCustomRotorSearchPredicate, maxCount: Int = CollectedRotorResults.maximumCount) -> CollectedRotorResults.Limit { + // We have a ton of elements, more than we can display in a snapshot. lets get a count of how many there are up to our max count. + var count = 0 + var result: UIAccessibilityCustomRotorItemResult? + while count < maxCount, let next = itemSearchBlock(predicate) { + if next.targetElement == nil || (next.targetElement as? NSObject)?.isEqual(result?.targetElement as? NSObject) ?? false { + break + } + result = next + count += 1 + predicate.currentItem = next + } + if count == 0 { + // this is unlikely + return .none + } + if count >= maxCount { + return .greaterThanMaxCount + } + return .underMaxCount(count) + } + + // Helper for duplicate detection in rotor results. + // NSObject uses identity equality (===), but we need value equality + // to detect when a rotor returns the same logical item multiple times. + private struct _hashableRotorResult: Hashable { + var element: NSObject + var range: UITextRange? + init?(_ result: UIAccessibilityCustomRotorItemResult) { + guard let element = result.targetElement as? NSObject else { return nil } + self.element = element + range = result.targetRange + } + } + + private func resultSet(_ results: [UIAccessibilityCustomRotorItemResult]) -> Set<_hashableRotorResult> { + Set(results.compactMap { _hashableRotorResult($0) }) + } +} + +private extension UIAccessibilityCustomRotorItemResult { + func compare(_ other: UIAccessibilityCustomRotorItemResult?) -> Bool { + guard let other else { return false } + + // 'any NSObjectProtocol' cannot be used as a type conforming to protocol 'Equatable' because 'Equatable' has static requirements + let target = targetElement as? NSObject + let otherTarget = other.targetElement as? NSObject + return target == otherTarget && targetRange == other.targetRange + } +} + +extension Array where Element: UIAccessibilityCustomRotorItemResult { + func compareWith(_ other: [Element]) -> Bool { + guard count == other.count else { return false } + return zip(self, other).allSatisfy { $0.compare($1) } + } + + func removingDuplicates() -> [Element] { + reduce(into: []) { array, element in + if !array.contains(where: { $0.compare(element) }) { + array.append(element) + } + } + } +} + +extension Array where Element == Int { + func isSequential() -> Bool { + guard count > 1 else { return true } + return zip(self, dropFirst()).allSatisfy { $1 == $0 + 1 } + } +} diff --git a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/UIAccessibility+SnapshotAdditions.swift b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/UIAccessibility+SnapshotAdditions.swift deleted file mode 100644 index 755cf62c..00000000 --- a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/UIAccessibility+SnapshotAdditions.swift +++ /dev/null @@ -1,936 +0,0 @@ -import UIKit - -extension NSObject { - /// Returns a tuple consisting of the `description` and (optionally) a `hint` that VoiceOver will read for the object. - func accessibilityDescription(context: AccessibilityHierarchyParser.Context?) -> (description: String, hint: String?) { - let strings = Strings(locale: accessibilityLanguage) - - var accessibilityDescription = - accessibilityLabelOverride(for: context) ?? - (hidesAccessibilityLabel(backDescriptor: strings.backDescriptor) ? "" : - accessibilityLabel ?? "") - - var hintDescription = accessibilityHint?.nonEmpty() - - let numberFormatter = NumberFormatter() - if let localeIdentifier = accessibilityLanguage { - numberFormatter.locale = Locale(identifier: localeIdentifier) - } - - let descriptionContainsContext: Bool - if let context = context { - switch context { - case let .dataTableCell(row: row, column: column, width: width, height: height, isFirstInRow: isFirstInRow, rowHeaders: rowHeaders, columnHeaders: columnHeaders): - let headersDescription = (rowHeaders + columnHeaders).map { header -> String in - switch (header.accessibilityLabel?.nonEmpty(), header.accessibilityValue?.nonEmpty()) { - case (nil, nil): - return "" - case let (.some(label), nil): - return "\(label). " - case let (nil, .some(value)): - return "\(value). " - case let (.some(label), .some(value)): - return "\(label): \(value). " - } - }.reduce("", +) - - let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." - - let showsHeight = (height > 1 && row != NSNotFound) - let showsWidth = (width > 1 && column != NSNotFound) - let showsRow = (isFirstInRow && row != NSNotFound) - let showsColumn = (column != NSNotFound) - - accessibilityDescription = - headersDescription - + accessibilityDescription - + trailingPeriod - + (showsHeight ? " " + String(format: strings.dataTableRowSpanFormat, numberFormatter.string(from: .init(value: height))!) : "") - + (showsWidth ? " " + String(format: strings.dataTableColumnSpanFormat, numberFormatter.string(from: .init(value: width))!) : "") - + (showsRow ? " " + String(format: strings.dataTableRowFormat, numberFormatter.string(from: .init(value: row + 1))!) : "") - + (showsColumn ? " " + String(format: strings.dataTableColumnFormat, numberFormatter.string(from: .init(value: column + 1))!) : "") - - descriptionContainsContext = true - - case .series, .tab, .tabBarItem, .listStart, .listEnd, .landmarkStart, .landmarkEnd: - descriptionContainsContext = false - } - - } else { - descriptionContainsContext = false - } - - if let accessibilityValue = accessibilityValue?.nonEmpty(), !hidesAccessibilityValue(for: context) { - if let existingDescription = accessibilityDescription.nonEmpty() { - if descriptionContainsContext { - accessibilityDescription += " \(accessibilityValue)" - } else { - accessibilityDescription = "\(existingDescription): \(accessibilityValue)" - } - } else { - accessibilityDescription = accessibilityValue - } - } - - if accessibilityTraits.contains(.selected) { - if let existingDescription = accessibilityDescription.nonEmpty() { - accessibilityDescription = String(format: strings.selectedTraitFormat, existingDescription) - } else { - accessibilityDescription = strings.selectedTraitName - } - } - - var traitSpecifiers: [String] = [] - - if accessibilityTraits.contains(.notEnabled) { - traitSpecifiers.append(strings.notEnabledTraitName) - } - - let hidesButtonTraitInContext = context?.hidesButtonTrait ?? false - let hidesButtonTraitFromTraits = [UIAccessibilityTraits.keyboardKey, .switchButton, .tabBarItem, .backButton].contains(where: { accessibilityTraits.contains($0) }) - if accessibilityTraits.contains(.button) && !hidesButtonTraitFromTraits && !hidesButtonTraitInContext { - traitSpecifiers.append(strings.buttonTraitName) - } - - if accessibilityTraits.contains(.backButton) { - traitSpecifiers.append(strings.backButtonTraitName) - } - - if accessibilityTraits.contains(.switchButton) { - if accessibilityTraits.contains(.button) { - // An element can have the private switch button trait without being a UISwitch (for example, by passing - // through the traits of a contained switch). In this case, VoiceOver will still read the "Switch - // Button." trait, but only if the element's traits also include the `.button` trait. - traitSpecifiers.append(strings.switchButtonTraitName) - } - - switch accessibilityValue { - case "1": - traitSpecifiers.append(strings.switchButtonOnStateName) - case "0": - traitSpecifiers.append(strings.switchButtonOffStateName) - case "2": - traitSpecifiers.append(strings.switchButtonMixedStateName) - default: - // Prior to iOS 17 the then private trait would suppress any other accessibility values. - // Once the trait became public in 17 values other than the above are announced with the trait specifiers. - if #available(iOS 17.0, *), let accessibilityValue { - traitSpecifiers.append(accessibilityValue) - } - } - } - - let showsTabTraitInContext = context?.showsTabTrait ?? false - if accessibilityTraits.contains(.tabBarItem) || showsTabTraitInContext { - traitSpecifiers.append(strings.tabTraitName) - } - - if accessibilityTraits.contains(.textEntry) { - if accessibilityTraits.contains(.scrollable) { - // This is a UITextView/TextEditor - } else { - // This is a UITextField/TextField - } - - traitSpecifiers.append(strings.textEntryTraitName) - - if accessibilityTraits.contains(.isEditing) { - traitSpecifiers.append(strings.isEditingTraitName) - } - } - - if accessibilityTraits.contains(.header) { - traitSpecifiers.append(strings.headerTraitName) - } - - if accessibilityTraits.contains(.link) { - traitSpecifiers.append(strings.linkTraitName) - } - - if accessibilityTraits.contains(.adjustable) { - traitSpecifiers.append(strings.adjustableTraitName) - } - - if accessibilityTraits.contains(.image) { - traitSpecifiers.append(strings.imageTraitName) - } - - if accessibilityTraits.contains(.searchField) { - traitSpecifiers.append(strings.searchFieldTraitName) - } - - // If the description is empty, use the hint as the description. - if accessibilityDescription.isEmpty { - accessibilityDescription = hintDescription ?? "" - hintDescription = nil - } - - // Add trait specifiers to description. - if !traitSpecifiers.isEmpty { - if let existingDescription = accessibilityDescription.nonEmpty() { - let trailingPeriod = existingDescription.hasSuffix(".") ? "" : "." - accessibilityDescription = "\(existingDescription)\(trailingPeriod) \(traitSpecifiers.joined(separator: " "))" - } else { - accessibilityDescription = traitSpecifiers.joined(separator: " ") - } - } - - if let context = context { - switch context { - case let .series(index: index, count: count), - let .tabBarItem(index: index, count: count, item: _), - let .tab(index: index, count: count): - accessibilityDescription = String(format: - strings.seriesContextFormat, - accessibilityDescription, - numberFormatter.string(from: .init(value: index))!, - numberFormatter.string(from: .init(value: count))!) - - case .listStart: - let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." - accessibilityDescription = String(format: - "%@%@ %@", - accessibilityDescription, - trailingPeriod, - strings.listStartContext) - - case .listEnd: - let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." - accessibilityDescription = String(format: - "%@%@ %@", - accessibilityDescription, - trailingPeriod, - strings.listEndContext) - - case .landmarkStart: - let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." - accessibilityDescription = String(format: - "%@%@ %@", - accessibilityDescription, - trailingPeriod, - strings.landmarkStartContext) - - case .landmarkEnd: - let trailingPeriod = accessibilityDescription.hasSuffix(".") ? "" : "." - accessibilityDescription = String(format: - "%@%@ %@", - accessibilityDescription, - trailingPeriod, - strings.landmarkEndContext) - - case .dataTableCell: - break - } - } - - if accessibilityTraits.contains(.switchButton) && !accessibilityTraits.contains(.notEnabled) { - if let existingHintDescription = hintDescription?.nonEmpty()?.strippingTrailingPeriod() { - hintDescription = String(format: strings.switchButtonTraitHintFormat, existingHintDescription) - } else { - hintDescription = strings.switchButtonTraitHint - } - } - - if accessibilityTraits.contains(.textEntry) && !accessibilityTraits.contains(.notEnabled) { - if accessibilityTraits.contains(.isEditing) { - hintDescription = strings.textEntryIsEditingTraitHint - } else { - if accessibilityTraits.contains(.scrollable) { - // This is a UITextView/TextEditor - hintDescription = strings.scrollableTextEntryTraitHint - } else { - // This is a UITextField/TextField - hintDescription = strings.textEntryTraitHint - } - } - } - - let hasHintOnly = (accessibilityHint?.nonEmpty() != nil) && (accessibilityLabel?.nonEmpty() == nil) && (accessibilityValue?.nonEmpty() == nil) - let hidesAdjustableHint = accessibilityTraits.contains(.notEnabled) || accessibilityTraits.contains(.switchButton) || hasHintOnly - if accessibilityTraits.contains(.adjustable), !hidesAdjustableHint { - if let existingHintDescription = hintDescription?.nonEmpty()?.strippingTrailingPeriod() { - hintDescription = String(format: strings.adjustableTraitHintFormat, existingHintDescription) - } else { - hintDescription = strings.adjustableTraitHint - } - } - - return (accessibilityDescription, hintDescription) - } - - // MARK: - Private Methods - - private func accessibilityLabelOverride(for context: AccessibilityHierarchyParser.Context?) -> String? { - guard let context = context else { - return nil - } - - switch context { - case .tabBarItem(index: _, count: _, item: _): - return nil - - case .series, .tab, .dataTableCell, .listStart, .listEnd, .landmarkStart, .landmarkEnd: - return nil - } - } - - private func hidesAccessibilityValue(for context: AccessibilityHierarchyParser.Context?) -> Bool { - if accessibilityTraits.contains(.switchButton) { - return true - } - - guard let context = context else { - return false - } - - switch context { - case .tabBarItem(index: _, count: _, item: _): - return false - - case .series, .tab, .dataTableCell, .listStart, .listEnd, .landmarkStart, .landmarkEnd: - return false - } - } - - private func hidesAccessibilityLabel(backDescriptor: String) -> Bool { - // To prevent duplication, Back Button elements omit their label if it matches the localized "Back" descriptor. - guard accessibilityTraits.contains(.backButton), - let label = accessibilityLabel else { return false } - return label.lowercased() == backDescriptor.lowercased() - } - - // MARK: - Private Static Properties - - // MARK: - Private - - private struct Strings { - // MARK: - Public Properties - - let selectedTraitName: String - - let selectedTraitFormat: String - - let notEnabledTraitName: String - - let buttonTraitName: String - - let backButtonTraitName: String - - let backDescriptor: String - - let tabTraitName: String - - let headerTraitName: String - - let linkTraitName: String - - let adjustableTraitName: String - - let adjustableTraitHint: String - - let adjustableTraitHintFormat: String - - let imageTraitName: String - - let searchFieldTraitName: String - - let switchButtonTraitName: String - - let switchButtonOnStateName: String - - let switchButtonOffStateName: String - - let switchButtonMixedStateName: String - - let switchButtonTraitHint: String - - let switchButtonTraitHintFormat: String - - let seriesContextFormat: String - - let dataTableRowSpanFormat: String - - let dataTableColumnSpanFormat: String - - let dataTableRowFormat: String - - let dataTableColumnFormat: String - - let listStartContext: String - - let listEndContext: String - - let landmarkStartContext: String - - let landmarkEndContext: String - - let textEntryTraitName: String - - let textEntryTraitHint: String - - let textEntryIsEditingTraitHint: String - - let scrollableTextEntryTraitHint: String - - let isEditingTraitName: String - - // MARK: - Life Cycle - - init(locale: String?) { - selectedTraitName = "Selected.".localized( - key: "trait.selected.description", - comment: "Description for the 'selected' accessibility trait", - locale: locale - ) - selectedTraitFormat = "Selected: %@".localized( - key: "trait.selected.format", - comment: "Format for the description of the selected element; param0: the description of the element", - locale: locale - ) - notEnabledTraitName = "Dimmed.".localized( - key: "trait.not_enabled.description", - comment: "Description for the 'not enabled' accessibility trait", - locale: locale - ) - buttonTraitName = "Button.".localized( - key: "trait.button.description", - comment: "Description for the 'button' accessibility trait", - locale: locale - ) - backButtonTraitName = "Back Button.".localized( - key: "trait.backbutton.description", - comment: "Description for the 'back button' accessibility trait", - locale: locale - ) - backDescriptor = "Back".localized( - key: "back.descriptor", - comment: "Descriptor for the 'back' portion of the 'back button' accessibility trait", - locale: locale - ) - tabTraitName = "Tab.".localized( - key: "trait.tab.description", - comment: "Description for the 'tab' accessibility trait", - locale: locale - ) - headerTraitName = "Heading.".localized( - key: "trait.header.description", - comment: "Description for the 'header' accessibility trait", - locale: locale - ) - linkTraitName = "Link.".localized( - key: "trait.link.description", - comment: "Description for the 'link' accessibility trait", - locale: locale - ) - adjustableTraitName = "Adjustable.".localized( - key: "trait.adjustable.description", - comment: "Description for the 'adjustable' accessibility trait", - locale: locale - ) - adjustableTraitHint = "Swipe up or down with one finger to adjust the value.".localized( - key: "trait.adjustable.hint", - comment: "Hint describing how to use elements with the 'adjustable' accessibility trait", - locale: locale - ) - adjustableTraitHintFormat = "%@. Swipe up or down with one finger to adjust the value.".localized( - key: "trait.adjustable.hint_format", - comment: "Format for hint describing how to use elements with the 'adjustable' accessibility trait; " + - "param0: the existing hint", - locale: locale - ) - imageTraitName = "Image.".localized( - key: "trait.image.description", - comment: "Description for the 'image' accessibility trait", - locale: locale - ) - searchFieldTraitName = "Search Field.".localized( - key: "trait.search_field.description", - comment: "Description for the 'search field' accessibility trait", - locale: locale - ) - switchButtonTraitName = "Switch Button.".localized( - key: "trait.switch_button.description", - comment: "Description for the 'switch button' accessibility trait", - locale: locale - ) - switchButtonOnStateName = "On.".localized( - key: "trait.switch_button.state_on.description", - comment: "Description for the 'switch button' accessibility trait, when the switch is on", - locale: locale - ) - switchButtonOffStateName = "Off.".localized( - key: "trait.switch_button.state_off.description", - comment: "Description for the 'switch button' accessibility trait, when the switch is off", - locale: locale - ) - switchButtonMixedStateName = "Mixed.".localized( - key: "trait.switch_button.state_mixed.description", - comment: "Description for the 'switch button' accessibility trait, when the switch is in a mixed state", - locale: locale - ) - switchButtonTraitHint = "Double tap to toggle setting.".localized( - key: "trait.switch_button.hint", - comment: "Hint describing how to use elements with the 'switch button' accessibility trait", - locale: locale - ) - switchButtonTraitHintFormat = "%@. Double tap to toggle setting.".localized( - key: "trait.switch_button.hint_format", - comment: "Format for hint describing how to use elements with the 'switch button' accessibility trait; " + - "param0: the existing hint", - locale: locale - ) - seriesContextFormat = "%@ %@ of %@.".localized( - key: "context.series.description_format", - comment: "Format for the description of an element in a series; param0: the description of the element, " + - "param1: the index of the element in the series, param2: the number of elements in the series", - locale: locale - ) - dataTableRowSpanFormat = "Spans %@ rows.".localized( - key: "context.data_table.row_span_format", - comment: "Format for the description of the height of a cell in a table; param0: the number of rows the cell spans", - locale: locale - ) - dataTableColumnSpanFormat = "Spans %@ columns.".localized( - key: "context.data_table.column_span_format", - comment: "Format for the description of the width of a cell in a table; param0: the number of columns the cell spans", - locale: locale - ) - dataTableRowFormat = "Row %@.".localized( - key: "context.data_table.row_format", - comment: "Format for the description of the vertical location of a cell in a table; param0: the row in which the cell resides", - locale: locale - ) - dataTableColumnFormat = "Column %@.".localized( - key: "context.data_table.column_format", - comment: "Format for the description of the horizontal location of a cell in a table; param0: the column in which the cell resides", - locale: locale - ) - listStartContext = "List Start.".localized( - key: "context.list_start.description", - comment: "Description of the first element in a list", - locale: locale - ) - listEndContext = "List End.".localized( - key: "context.list_end.description", - comment: "Description of the last element in a list", - locale: locale - ) - landmarkStartContext = "Landmark.".localized( - key: "context.landmark_start.description", - comment: "Description of the first element in a landmark container", - locale: locale - ) - landmarkEndContext = "End.".localized( - key: "context.landmark_end.description", - comment: "Description of the last element in a landmark container", - locale: locale - ) - textEntryTraitName = "Text Field.".localized( - key: "trait.text_field.description", - comment: "Description for the 'text entry' accessibility trait", - locale: locale - ) - textEntryTraitHint = "Double tap to edit.".localized( - key: "trait.text_field.hint", - comment: "Hint describing how to use elements with the 'text entry' accessibility trait", - locale: locale - ) - textEntryIsEditingTraitHint = "Use the rotor to access Misspelled Words".localized( - key: "trait.text_field_is_editing.hint", - comment: "Hint describing how to use elements with the 'text entry' accessibility trait when they are being edited", - locale: locale - ) - scrollableTextEntryTraitHint = "Double tap to edit., Use the rotor to access Misspelled Words".localized( - key: "trait.scrollable_text_field.hint", - comment: "Hint describing how to use elements with the 'text entry' and 'scrollable' accessibility traits", - locale: locale - ) - isEditingTraitName = "Is editing.".localized( - key: "trait.text_field_is_editing.description", - comment: "Description for the 'is editing' accessibility trait", - locale: locale - ) - } - } -} - -// MARK: - - -extension String { - /// Returns the string if it is non-empty, otherwise nil. - func nonEmpty() -> String? { - return isEmpty ? nil : self - } - - func strippingTrailingPeriod() -> String { - if hasSuffix(".") { - return String(dropLast()) - } else { - return self - } - } -} - -// MARK: - - -extension UIAccessibilityTraits { - static let textEntry = UIAccessibilityTraits(rawValue: 1 << 18) // 0x0000000000040000 - - static let isEditing = UIAccessibilityTraits(rawValue: 1 << 21) // 0x0000000000200000 - - static let backButton = UIAccessibilityTraits(rawValue: 1 << 27) // 0x0000000008000000 - - static let tabBarItem = UIAccessibilityTraits(rawValue: 1 << 28) // 0x0000000010000000 - - static let scrollable = UIAccessibilityTraits(rawValue: 1 << 47) // 0x0000800000000000 - - static let switchButton = UIAccessibilityTraits(rawValue: 1 << 53) // 0x0020000000000000 -} - -// MARK: - - -extension AccessibilityHierarchyParser.Context { - var hidesButtonTrait: Bool { - switch self { - case .series, .tabBarItem, .dataTableCell, .listStart, .listEnd, .landmarkStart, .landmarkEnd: - return false - - case .tab: - return true - } - } - - var showsTabTrait: Bool { - switch self { - case .series, .dataTableCell, .listStart, .listEnd, .landmarkStart, .landmarkEnd: - return false - - case .tab, .tabBarItem: - return true - } - } -} - -extension UIAccessibilityCustomRotor { - var isKnownRotorType: Bool { - switch systemRotorType { - case .none, .link, .visitedLink, .heading, .headingLevel1, .headingLevel2, .headingLevel3, .headingLevel4, .headingLevel5, .headingLevel6, .boldText, .italicText, .underlineText, .misspelledWord, .image, .textField, .table, .list, .landmark: - return true - @unknown default: - return false - } - } - - func displayName(locale: String? = nil) -> String { - guard name.isEmpty else { - return name - } - - switch systemRotorType { - case .none: - return "None".localized( - key: "rotor.none.description", - comment: "Description for a rotor with no type", - locale: locale - ) - case .link: - return "Links".localized( - key: "rotor.link.description", - comment: "Description for the 'links' rotor", - locale: locale - ) - case .visitedLink: - return "Visited Links".localized( - key: "rotor.visited_link.description", - comment: "Description for the 'visited links' rotor", - locale: locale - ) - case .heading: - return "Headings".localized( - key: "rotor.heading.description", - comment: "Description for the 'headings' rotor", - locale: locale - ) - case .headingLevel1: - return "Heading 1".localized( - key: "rotor.heading_level1.description", - comment: "Description for the 'heading level 1' rotor", - locale: locale - ) - case .headingLevel2: - return "Heading 2".localized( - key: "rotor.heading_level2.description", - comment: "Description for the 'heading level 2' rotor", - locale: locale - ) - case .headingLevel3: - return "Heading 3".localized( - key: "rotor.heading_level3.description", - comment: "Description for the 'heading level 3' rotor", - locale: locale - ) - case .headingLevel4: - return "Heading 4".localized( - key: "rotor.heading_level4.description", - comment: "Description for the 'heading level 4' rotor", - locale: locale - ) - case .headingLevel5: - return "Heading 5".localized( - key: "rotor.heading_level5.description", - comment: "Description for the 'heading level 5' rotor", - locale: locale - ) - case .headingLevel6: - return "Heading 6".localized( - key: "rotor.heading_level6.description", - comment: "Description for the 'heading level 6' rotor", - locale: locale - ) - case .boldText: - return "Bold Text".localized( - key: "rotor.bold_text.description", - comment: "Description for the 'bold text' rotor", - locale: locale - ) - case .italicText: - return "Italic Text".localized( - key: "rotor.italic_text.description", - comment: "Description for the 'italic text' rotor", - locale: locale - ) - case .underlineText: - return "Underlined Text".localized( - key: "rotor.underline_text.description", - comment: "Description for the 'underlined text' rotor", - locale: locale - ) - case .misspelledWord: - return "Misspelled Words".localized( - key: "rotor.misspelled_word.description", - comment: "Description for the 'misspelled words' rotor", - locale: locale - ) - case .image: - return "Images".localized( - key: "rotor.image.description", - comment: "Description for the 'images' rotor", - locale: locale - ) - case .textField: - return "Text Fields".localized( - key: "rotor.text_field.description", - comment: "Description for the 'text fields' rotor", - locale: locale - ) - case .table: - return "Tables".localized( - key: "rotor.table.description", - comment: "Description for the 'tables' rotor", - locale: locale - ) - case .list: - return "Lists".localized( - key: "rotor.list.description", - comment: "Description for the 'lists' rotor", - locale: locale - ) - case .landmark: - return "Landmarks".localized( - key: "rotor.landmark.description", - comment: "Description for the 'landmarks' rotor", - locale: locale - ) - @unknown default: - return String(format: - "Unknown Rotor Type, Raw value: %lld".localized( - key: "rotor.unknown.description_format", - comment: "Format for description of an unknown rotor type; param0: the raw value", - locale: locale - ), - systemRotorType.rawValue) - } - } - - public struct CollectedRotorResults: Equatable { - // Maximum number of results to count before stopping enumeration. - // When this limit is reached, we stop counting and report "99+ More Results" - public static let maximumCount: Int = 99 - - public enum Limit: Equatable { - case none - case underMaxCount(Int) - case greaterThanMaxCount - - func combine(_ other: Limit) -> Limit { - switch (self, other) { - case (.none, .none): - return .none - case (_, .greaterThanMaxCount), (.greaterThanMaxCount, _): - return .greaterThanMaxCount - case let (.underMaxCount(count), .none), let (.none, .underMaxCount(count)): - return .underMaxCount(count) - case let (.underMaxCount(a), .underMaxCount(b)): - if a + b <= maximumCount { - return .underMaxCount(a + b) - } - return .greaterThanMaxCount - } - } - } - - public let results: [UIAccessibilityCustomRotorItemResult] - public let limit: Limit - - init(results: [UIAccessibilityCustomRotorItemResult], limit: Limit = .none) { - self.results = results - self.limit = limit - } - } - - // Collects rotor results in both directions to capture all accessible items. - // Some rotors only provide results in one direction, so we check both. - // Intelligently merges results, removing duplicates and handling edge cases. - func collectAllResults(nextLimit: Int, previousLimit: Int) -> CollectedRotorResults { - let forwards = iterateResults(direction: .next, limit: nextLimit) - let backwards = iterateResults(direction: .previous, limit: nextLimit) - - // Its common that backwards and forwards contain the same elements with differing orders. - - let forwardsSet = resultSet(forwards.results) - let backwardsSet = resultSet(backwards.results) - - if forwardsSet == backwardsSet { return forwards } - if forwardsSet.isSuperset(of: backwardsSet) { return forwards } - if backwardsSet.isSuperset(of: forwardsSet) { return backwards } - - // When starting iteration without a currentItem, both directions often return - // the same first element. Drop one copy before merging to avoid duplicates. - // Example: forward=[A,B,C], backward=[A,D,E] -> result=[E,D,A,B,C] not [E,D,A,A,B,C] - if forwards.results.first?.compare(backwards.results.first) ?? false { - let results = backwards.results.dropFirst().reversed() + forwards.results - return .init(results: results, limit: backwards.limit.combine(forwards.limit)) - } - - let results = (backwards.results.reversed() + forwards.results).removingDuplicates() - return .init(results: results, limit: backwards.limit.combine(forwards.limit)) - } - - func iterateResults(direction: UIAccessibilityCustomRotor.Direction, limit: Int) -> CollectedRotorResults { - var results: [UIAccessibilityCustomRotorItemResult] = [] - let predicate = UIAccessibilityCustomRotorSearchPredicate() - var loopDetection: [Int] = [] - - predicate.searchDirection = direction - - while results.count < limit { - guard let result = itemSearchBlock(predicate), !result.compare(predicate.currentItem) else { break } - - if let hashable = _hashableRotorResult(result), - resultSet(results).contains(hashable) - { - loopDetection.append(results.count) - } - // Loop detection: Track when we encounter duplicate results. - // If we see 3 duplicates in a row (sequential indices), we're in an infinite loop. - // Example: [A,B,C,D,E,C,D,E,C,D,E] - indices [5,7,9] are sequential, stop at index 5. - // Non-sequential duplicates are OK (e.g., A->B->C->A->D->E->F is not a loop). - if loopDetection.count >= 3 { - if loopDetection.isSequential() { - break - } - // indices are not sequential, this is not a loop. - else { - loopDetection = [] - } - } - - results.append(result) - predicate.currentItem = result - } - - // Reset the results array to end at the first duplicated element - if !loopDetection.isEmpty, loopDetection.isSequential(), loopDetection.last == results.count { - results = Array(results.prefix(upTo: loopDetection.first!)) - } - - if let last = results.last { - predicate.currentItem = last - } - - let limited = results.count <= limit ? countAdditionalResults(predicate) : .none - return .init(results: results, limit: limited) - } - - private func countAdditionalResults(_ predicate: UIAccessibilityCustomRotorSearchPredicate, maxCount: Int = CollectedRotorResults.maximumCount) -> CollectedRotorResults.Limit { - // We have a ton of elements, more than we can display in a snapshot. lets get a count of how many there are up to our max count. - var count = 0 - var result: UIAccessibilityCustomRotorItemResult? - while count < maxCount, let next = itemSearchBlock(predicate) { - if next.targetElement == nil || (next.targetElement as? NSObject)?.isEqual(result?.targetElement as? NSObject) ?? false { - break - } - result = next - count += 1 - predicate.currentItem = next - } - if count == 0 { - // this is unlikely - return .none - } - if count >= maxCount { - return .greaterThanMaxCount - } - return .underMaxCount(count) - } - - // Helper for duplicate detection in rotor results. - // NSObject uses identity equality (===), but we need value equality - // to detect when a rotor returns the same logical item multiple times. - private struct _hashableRotorResult: Hashable { - var element: NSObject - var range: UITextRange? - init?(_ result: UIAccessibilityCustomRotorItemResult) { - guard let element = result.targetElement as? NSObject else { return nil } - self.element = element - range = result.targetRange - } - } - - private func resultSet(_ results: [UIAccessibilityCustomRotorItemResult]) -> Set<_hashableRotorResult> { - Set(results.compactMap { _hashableRotorResult($0) }) - } -} - -private extension UIAccessibilityCustomRotorItemResult { - func compare(_ other: UIAccessibilityCustomRotorItemResult?) -> Bool { - guard let other else { return false } - - // 'any NSObjectProtocol' cannot be used as a type conforming to protocol 'Equatable' because 'Equatable' has static requirements - let target = targetElement as? NSObject - let otherTarget = other.targetElement as? NSObject - return target == otherTarget && targetRange == other.targetRange - } -} - -extension Array where Element: UIAccessibilityCustomRotorItemResult { - func compareWith(_ other: [Element]) -> Bool { - guard count == other.count else { return false } - return zip(self, other).allSatisfy { $0.compare($1) } - } - - func removingDuplicates() -> [Element] { - reduce(into: []) { array, element in - if !array.contains(where: { $0.compare(element) }) { - array.append(element) - } - } - } -} - -extension Array where Element == Int { - func isSequential() -> Bool { - guard count > 1 else { return true } - return zip(self, dropFirst()).allSatisfy { $1 == $0 + 1 } - } -} diff --git a/Sources/AccessibilitySnapshot/Parser/Swift/Classes/VerbosityConfiguration.swift b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/VerbosityConfiguration.swift new file mode 100644 index 00000000..46d113c4 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Parser/Swift/Classes/VerbosityConfiguration.swift @@ -0,0 +1,144 @@ +import Foundation + +/// Configuration for controlling what information is included in VoiceOver descriptions. +/// +/// This mirrors the verbosity settings available in iOS at: +/// Settings > Accessibility > VoiceOver > Verbosity +/// +/// ## Usage +/// +/// Use the built-in presets for common configurations: +/// ```swift +/// element.voiceOverDescription(verbosity: .minimal) // Just the label +/// element.voiceOverDescription(verbosity: .verbose) // Everything (default) +/// ``` +/// +/// Or customize individual settings: +/// ```swift +/// var config = VerbosityConfiguration.verbose +/// config.includesHints = false +/// element.voiceOverDescription(verbosity: config) +/// ``` +public struct VerbosityConfiguration: Equatable { + // MARK: - Trait Position + + /// Controls where trait announcements (Button, Link, etc.) appear relative to the description. + /// + /// This mirrors iOS 18.4's Settings > Accessibility > VoiceOver > Verbosity > Controls setting. + public enum TraitPosition: Equatable { + /// Announce trait before the label: "Button. Submit" + case before + + /// Announce trait after the label: "Submit. Button." (default VoiceOver behavior) + case after + + /// Don't announce traits at all: "Submit" + case none + } + + // MARK: - Properties + + /// Include trait announcements (Button, Link, Heading, Image, etc.). + /// + /// When `false`, trait specifiers are omitted regardless of `traitPosition`. + /// Corresponds to iOS trait-related verbosity settings. + public var includesTraits: Bool + + /// Controls where trait announcements appear relative to the description. + /// + /// Only applies when `includesTraits` is `true`. + /// - `.before`: "Button. Submit" + /// - `.after`: "Submit. Button." (default) + /// - `.none`: Equivalent to `includesTraits = false` + public var traitPosition: TraitPosition + + /// Include usage hints (e.g., "Double tap to activate", "Swipe up or down to adjust"). + /// + /// Corresponds to iOS Settings > Accessibility > VoiceOver > Verbosity > Speak Hints. + public var includesHints: Bool + + /// Include container context announcements (e.g., "1 of 5", "List Start", "Landmark"). + /// + /// This includes: + /// - Series position: "1 of 5" + /// - Tab position: "Tab. 2 of 4" + /// - List boundaries: "List Start", "List End" + /// - Landmark boundaries: "Landmark", "End" + public var includesContainerContext: Bool + + /// Include data table context (row/column headers, position, spans). + /// + /// Corresponds to iOS Settings > Accessibility > VoiceOver > Verbosity > Table Headers + /// and Row & Column Numbers. + /// + /// This includes: + /// - Row and column headers + /// - Row and column position: "Row 2. Column 3." + /// - Span information: "Spans 2 rows." + public var includesTableContext: Bool + + /// Include the accessibility value after the label. + /// + /// When `true`, values are announced: "Volume: 50%" + /// When `false`, only the label is announced: "Volume" + public var includesValue: Bool + + /// Include high-importance custom content in the description. + /// + /// Per WWDC21, high-importance `AXCustomContent` values appear in the main + /// VoiceOver announcement. For example: "Bailey, beagle, three years. Image." + /// + /// When `false`, custom content is only available via the More Content rotor. + public var includesCustomContent: Bool + + // MARK: - Initialization + + /// Creates a verbosity configuration with the specified settings. + public init( + includesTraits: Bool = true, + traitPosition: TraitPosition = .after, + includesHints: Bool = true, + includesContainerContext: Bool = true, + includesTableContext: Bool = true, + includesValue: Bool = true, + includesCustomContent: Bool = true + ) { + self.includesTraits = includesTraits + self.traitPosition = traitPosition + self.includesHints = includesHints + self.includesContainerContext = includesContainerContext + self.includesTableContext = includesTableContext + self.includesValue = includesValue + self.includesCustomContent = includesCustomContent + } + + // MARK: - Presets + + /// Minimal verbosity: only the label is announced. + /// + /// All additional context, traits, hints, and values are omitted. + /// Use this to test the most basic VoiceOver experience. + public static let minimal = VerbosityConfiguration( + includesTraits: false, + traitPosition: .none, + includesHints: false, + includesContainerContext: false, + includesTableContext: false, + includesValue: false, + includesCustomContent: false + ) + + /// Verbose: all information is included (default). + /// + /// This matches the default VoiceOver behavior where all context, + /// traits, hints, and values are announced. + public static let verbose = VerbosityConfiguration( + includesTraits: true, + traitPosition: .after, + includesHints: true, + includesContainerContext: true, + includesTableContext: true, + includesValue: true, + includesCustomContent: true + ) +} diff --git a/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift b/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift index 70805d64..2027b51b 100644 --- a/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift +++ b/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift @@ -32,7 +32,8 @@ public extension Snapshotting where Value == UIView, Format == UIImage { drawHierarchyInKeyWindow: Bool = false, markerColors: [UIColor] = [], showUserInputLabels: Bool = true, - shouldRunInHostApplication: Bool = true + shouldRunInHostApplication: Bool = true, + verbosity: VerbosityConfiguration = .verbose ) -> Snapshotting { guard !shouldRunInHostApplication || isRunningInHostApplication else { fatalError("Accessibility snapshot tests cannot be run in a test target without a host application") @@ -47,7 +48,8 @@ public extension Snapshotting where Value == UIView, Format == UIImage { colorRenderingMode: useMonochromeSnapshot ? .monochrome : .fullColor, overlayColors: markerColors, activationPointDisplay: activationPointDisplayMode, - includesInputLabels: showUserInputLabels ? .whenOverridden : .never + includesInputLabels: showUserInputLabels ? .whenOverridden : .never, + verbosity: verbosity ) let containerView = AccessibilitySnapshotView(containedView: view, snapshotConfiguration: configuration) @@ -242,7 +244,8 @@ public extension Snapshotting where Value == UIViewController, Format == UIImage drawHierarchyInKeyWindow: Bool = false, markerColors: [UIColor] = [], showUserInputLabels: Bool = true, - shouldRunInHostApplication: Bool = true + shouldRunInHostApplication: Bool = true, + verbosity: VerbosityConfiguration = .verbose ) -> Snapshotting { return Snapshotting .accessibilityImage( @@ -251,7 +254,8 @@ public extension Snapshotting where Value == UIViewController, Format == UIImage drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, markerColors: markerColors, showUserInputLabels: showUserInputLabels, - shouldRunInHostApplication: shouldRunInHostApplication + shouldRunInHostApplication: shouldRunInHostApplication, + verbosity: verbosity ) .pullback { viewController in viewController.view