Fixed accessibility merging

commit_hash:bd1ab1f35f3c7f568d0355c82eb0f0ad2fa12e50
This commit is contained in:
booster
2026-04-30 18:23:53 +03:00
parent 8e868feec9
commit 5f737443ce
16 changed files with 177 additions and 154 deletions
+1 -1
View File
@@ -19572,7 +19572,6 @@
"client/ios/DivKit/Extensions/DivBlendModeExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivBlendModeExtensions.swift",
"client/ios/DivKit/Extensions/DivChangeTransitionExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivChangeTransitionExtensions.swift",
"client/ios/DivKit/Extensions/DivContainer/DivCollectionItemsBuilderExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivContainer/DivCollectionItemsBuilderExtensions.swift",
"client/ios/DivKit/Extensions/DivContainer/DivContainer+Accessibility.swift":"divkit/public/client/ios/DivKit/Extensions/DivContainer/DivContainer+Accessibility.swift",
"client/ios/DivKit/Extensions/DivContainer/DivContainerExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivContainer/DivContainerExtensions.swift",
"client/ios/DivKit/Extensions/DivContainer/DivContainerItemsExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivContainer/DivContainerItemsExtensions.swift",
"client/ios/DivKit/Extensions/DivContainer/DivContainerSizeModifier.swift":"divkit/public/client/ios/DivKit/Extensions/DivContainer/DivContainerSizeModifier.swift",
@@ -20396,6 +20395,7 @@
"client/ios/LayoutKit/LayoutKit/Blocks/AspectRatioConstrainedSize.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Blocks/AspectRatioConstrainedSize.swift",
"client/ios/LayoutKit/LayoutKit/Blocks/BackgroundBlock.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Blocks/BackgroundBlock.swift",
"client/ios/LayoutKit/LayoutKit/Blocks/Block+Accessibility.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Blocks/Block+Accessibility.swift",
"client/ios/LayoutKit/LayoutKit/Blocks/Block+AccessibilityMerge.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Blocks/Block+AccessibilityMerge.swift",
"client/ios/LayoutKit/LayoutKit/Blocks/Block+Animation.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Blocks/Block+Animation.swift",
"client/ios/LayoutKit/LayoutKit/Blocks/Block+CAAnimationRendering.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Blocks/Block+CAAnimationRendering.swift",
"client/ios/LayoutKit/LayoutKit/Blocks/Block+Debugging.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Blocks/Block+Debugging.swift",
@@ -1,12 +1,15 @@
import LayoutKit
import VGSL
extension DivAccessibility {
func resolve(
_ expressionResolver: ExpressionResolver,
id: String?,
block: AccessibilityContaining,
customParams: CustomAccessibilityParams
) -> AccessibilityElement {
if resolveMode(expressionResolver) == .exclude {
let mode = resolveMode(expressionResolver)
if mode == .exclude {
return AccessibilityElement(
traits: .none,
strings: AccessibilityElement.Strings(label: nil),
@@ -20,6 +23,10 @@ extension DivAccessibility {
} else if let description = resolveDescription(expressionResolver) {
label = description
}
if mode == .merge, (label ?? "").isEmpty,
let merged = block.mergedAccessibilityLabel {
label = merged
}
if label == nil, type != .auto {
label = ""
}
@@ -187,6 +187,7 @@ final class DivBaseBlockBuilder {
let accessibilityElement = (div.accessibility ?? DivAccessibility()).resolve(
expressionResolver,
id: context.currentDivId,
block: block,
customParams: customAccessibilityParams
)
@@ -1,73 +0,0 @@
import Foundation
extension DivContainer {
func resolveAccessibilityDescription(_ context: DivBlockModelingContext) -> String? {
guard let accessibility else {
return nil
}
let expressionResolver = context.expressionResolver
switch accessibility.resolveMode(expressionResolver) {
case .default:
return accessibility.resolveDescription(expressionResolver)
case .merge:
if let description = accessibility.resolveDescription(expressionResolver) {
return description
}
return nonNilItems.resolveMergedDescription(context)
case .exclude:
return nil
}
}
}
extension [Div] {
fileprivate func resolveMergedDescription(_ context: DivBlockModelingContext) -> String? {
var result = ""
func traverse(div: Div) {
if let descritpion = div.resolveDescription(context), !descritpion.isEmpty {
result = result.isEmpty ? descritpion : result + " " + descritpion
}
div.children.forEach(traverse)
}
forEach(traverse)
return result.isEmpty ? nil : result
}
}
extension Div {
fileprivate func resolveDescription(_ context: DivBlockModelingContext) -> String? {
let expressionResolver = context.expressionResolver
let accessibility = value.accessibility
guard accessibility?.resolveMode(expressionResolver) != .exclude else {
return nil
}
switch self {
case .divContainer,
.divCustom,
.divGallery,
.divGifImage,
.divGrid,
.divImage,
.divIndicator,
.divInput,
.divPager,
.divSelect,
.divSeparator,
.divSlider,
.divState,
.divSwitch,
.divTabs,
.divVideo:
return accessibility?.resolveDescription(expressionResolver)
case let .divText(divText):
let extensionDescription = context
.getExtensionHandlers(for: divText)
.compactMap { $0.accessibilityElement?.strings.label }
.reduce(nil) { $0?.appending(" " + $1) ?? $1 }
return extensionDescription ??
accessibility?.resolveDescription(expressionResolver) ??
divText.resolveText(expressionResolver)
}
}
}
@@ -9,9 +9,6 @@ extension DivContainer: DivBlockModeling {
to: { try makeBaseBlock(context: context) },
context: context,
actionsHolder: self,
customAccessibilityParams: CustomAccessibilityParams { [unowned self] in
resolveAccessibilityDescription(context)
},
applyPaddings: false,
clipToBounds: resolveClipToBounds(context.expressionResolver)
)
@@ -1,5 +1,6 @@
@testable import DivKit
import DivKitTestsSupport
import LayoutKit
import VGSL
import XCTest
@@ -159,6 +160,7 @@ extension DivAccessibility {
resolve(
DivBlockModelingContext.default.expressionResolver,
id: id,
block: EmptyBlock.zeroSized,
customParams: CustomAccessibilityParams(
defaultTraits: defaultTraits,
descriptionProvider: customDescriptionProvider
@@ -1,105 +1,158 @@
@testable import DivKit
import DivKitTestsSupport
@testable import LayoutKit
import XCTest
final class DivContainerAccessibilityDescriptionTests: XCTestCase {
func test_Nil_ForContainerWithoutAccessibility() {
let description = divContainer()
.resolveAccessibilityDescription()
func test_Nil_ForContainerWithoutAccessibility() throws {
let label = try resolveLabel(
divContainer()
)
XCTAssertNil(description)
XCTAssertNil(label)
}
func test_Nil_ForExcludeMode() {
let description = divContainer(
accessibility: DivAccessibility(
description: .value("Container description"),
mode: .value(.exclude)
func test_Hidden_ForExcludeMode() throws {
let block = try makeBlock(
divContainer(
accessibility: DivAccessibility(
description: .value("Container description"),
mode: .value(.exclude)
)
)
).resolveAccessibilityDescription()
)
XCTAssertNil(description)
XCTAssertEqual(block.accessibilityElement?.hideElementWithChildren, true)
}
func test_Description_ForContainerWithDescription() {
let description = divContainer(
accessibility: DivAccessibility(
description: .value("Container description")
func test_Description_ForContainerWithDescription() throws {
let label = try resolveLabel(
divContainer(
accessibility: DivAccessibility(
description: .value("Container description")
)
)
).resolveAccessibilityDescription()
)
XCTAssertEqual(description, "Container description")
XCTAssertEqual(label, "Container description")
}
func test_MergedDescription_ForMergeMode() {
let description = divContainer(
accessibility: DivAccessibility(mode: .value(.merge)),
items: [
divText(text: "Hello!"),
divContainer(
accessibility: DivAccessibility(
description: .value("Nested container description")
)
),
]
).resolveAccessibilityDescription()
func test_MergedDescription_ForMergeMode() throws {
let label = try resolveLabel(
divContainer(
accessibility: DivAccessibility(mode: .value(.merge)),
items: [
divText(text: "Hello!"),
divContainer(
accessibility: DivAccessibility(
description: .value("Nested container description")
)
),
]
)
)
XCTAssertEqual(description, "Hello! Nested container description")
XCTAssertEqual(label, "Hello! Nested container description")
}
func test_MergedDescription_NotContainsExcludedItem() {
let description = divContainer(
accessibility: DivAccessibility(mode: .value(.merge)),
items: [
divText(text: "Hello!"),
divText(
accessibility: DivAccessibility(mode: .value(.exclude)),
text: "Excluded"
),
]
).resolveAccessibilityDescription()
func test_MergedDescription_NotContainsExcludedItem() throws {
let label = try resolveLabel(
divContainer(
accessibility: DivAccessibility(mode: .value(.merge)),
items: [
divText(text: "Hello!"),
divText(
accessibility: DivAccessibility(mode: .value(.exclude)),
text: "Excluded"
),
]
)
)
XCTAssertEqual(description, "Hello!")
XCTAssertEqual(label, "Hello!")
}
func test_MergedDescription_ContainsDescriptionFromNestedContainer() {
let description = divContainer(
accessibility: DivAccessibility(mode: .value(.merge)),
items: [
divText(text: "Hello!"),
divContainer(
items: [
divText(text: "Nested item"),
]
),
]
).resolveAccessibilityDescription()
func test_MergedDescription_ContainsTextFromNestedContainer() throws {
let label = try resolveLabel(
divContainer(
accessibility: DivAccessibility(mode: .value(.merge)),
items: [
divText(text: "Hello!"),
divContainer(
items: [
divText(text: "Nested item"),
]
),
]
)
)
XCTAssertEqual(description, "Hello! Nested item")
XCTAssertEqual(label, "Hello! Nested item")
}
func test_Description_HasHigherPriorityThanMergedDescription() {
let description = divContainer(
accessibility: DivAccessibility(
description: .value("Container description"),
mode: .value(.merge)
),
items: [
divText(text: "Hello!"),
divContainer(
accessibility: DivAccessibility(
description: .value("Nested container description")
)
func test_Description_HasHigherPriorityThanMergedDescription() throws {
let label = try resolveLabel(
divContainer(
accessibility: DivAccessibility(
description: .value("Container description"),
mode: .value(.merge)
),
]
).resolveAccessibilityDescription()
items: [
divText(text: "Hello!"),
divContainer(
accessibility: DivAccessibility(
description: .value("Nested container description")
)
),
]
)
)
XCTAssertEqual(description, "Container description")
}
}
extension Div {
fileprivate func resolveAccessibilityDescription() -> String? {
(value as! DivContainer).resolveAccessibilityDescription(.default)
XCTAssertEqual(label, "Container description")
}
func test_MergedDescription_OnlyContainsVisibleContent() throws {
let label = try resolveLabel(
divContainer(
accessibility: DivAccessibility(mode: .value(.merge)),
items: [
divText(text: "One"),
divText(text: "Two"),
divText(text: "Three", visibility: .value(.gone)),
divText(text: "Four", visibility: .value(.invisible)),
]
)
)
XCTAssertEqual(label, "One Two")
}
func test_MergedDescription_OnlyContainsActiveStateContent() throws {
let label = try resolveLabel(
divContainer(
accessibility: DivAccessibility(mode: .value(.merge)),
items: [
divState(
divId: "switcher",
states: [
divStateState(div: divText(text: "Active"), stateId: "a"),
divStateState(div: divText(text: "Inactive"), stateId: "b"),
]
),
]
)
)
XCTAssertEqual(label, "Active")
}
private func makeBlock(_ div: Div) throws -> Block {
try div.value.makeBlock(context: DivBlockModelingContext())
}
private func resolveLabel(_ div: Div) throws -> String? {
try makeBlock(div).accessibilityElement?.strings.label.flatMap {
$0.isEmpty ? nil : $0
}
}
}
@@ -2,8 +2,10 @@ import VGSL
public protocol AccessibilityContaining {
var accessibilityElement: AccessibilityElement? { get }
var accessibilityChildren: [AccessibilityContaining] { get }
}
extension AccessibilityContaining {
public var accessibilityElement: AccessibilityElement? { nil }
public var accessibilityChildren: [AccessibilityContaining] { [] }
}
@@ -0,0 +1,18 @@
import VGSL
extension AccessibilityContaining {
public var mergedAccessibilityLabel: String? {
if let element = accessibilityElement {
if element.hideElementWithChildren {
return nil
}
if let label = element.strings.label, !label.isEmpty {
return label
}
}
let parts = accessibilityChildren
.compactMap(\.mergedAccessibilityLabel)
.filter { !$0.isEmpty }
return parts.isEmpty ? nil : parts.joined(separator: " ")
}
}
@@ -106,6 +106,8 @@ public final class ContainerBlock: BlockWithLayout {
private var cached = CachedSizes()
public var accessibilityChildren: [AccessibilityContaining] { children.map(\.content) }
public var isVerticallyResizable: Bool { heightTrait.isResizable }
public var isHorizontallyResizable: Bool { widthTrait.isResizable }
@@ -14,6 +14,8 @@ public final class GalleryBlock: BlockWithTraits {
model.path
}
public var accessibilityChildren: [AccessibilityContaining] { model.items.map(\.content) }
public var intrinsicContentWidth: CGFloat {
switch widthTrait {
case let .fixed(value):
@@ -79,6 +79,8 @@ public final class GridBlock: BlockWithTraits, BlockWithLayout {
private var cachedIntrinsicWidth: CGFloat?
private var cachedIntrinsicHeight: (width: CGFloat, height: CGFloat)?
public var accessibilityChildren: [AccessibilityContaining] { items.map(\.contents) }
public var intrinsicContentWidth: CGFloat {
if case let .fixed(value) = widthTrait {
return value
@@ -18,6 +18,8 @@ public final class LayeredBlock: BlockWithTraits, BlockWithLayout {
public let heightTrait: LayoutTrait
public let children: [Child]
public var accessibilityChildren: [AccessibilityContaining] { children.map(\.content) }
public var intrinsicContentWidth: CGFloat {
switch widthTrait {
case let .fixed(width):
@@ -23,6 +23,8 @@ public final class PagerBlock: BlockWithTraits {
gallery.path
}
public var accessibilityChildren: [AccessibilityContaining] { gallery.items.map(\.content) }
public var intrinsicContentWidth: CGFloat {
switch widthTrait {
case let .fixed(value):
@@ -12,6 +12,10 @@ public final class TabsBlock: BlockWithTraits {
model.contentsModel.path
}
public var accessibilityChildren: [AccessibilityContaining] {
model.contentsModel.pages.map(\.block)
}
public var intrinsicContentWidth: CGFloat {
if case let .fixed(value) = widthTrait {
return value
@@ -13,6 +13,8 @@ extension WrapperBlock {
public var isEmpty: Bool { child.isEmpty }
public var accessibilityChildren: [AccessibilityContaining] { [child] }
public var reuseId: String {
child.reuseId
}