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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.compose.ui.text.LinkAnnotation
import com.mikepenz.markdown.m3.Markdown
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
Expand Down Expand Up @@ -48,6 +50,42 @@ class MarkdownA11yTest {
)
}

/**
* Issue #569 fix (#570): the paragraph carrying inline links must be a screen-reader
* merge boundary so TalkBack reads it as one unit instead of skipping it. In Compose
* `mergeDescendants` maps to the ANI `isScreenReaderFocusable` flag, so we assert the
* flag directly on the unmerged tree — it is `false` without the fix and `true` with it.
*
* Simultaneously guards #487: the two inline links must survive as addressable
* `LinkAnnotation`s on that same text node (merging must not prune them).
*
* Unlike a pixel snapshot, this distinguishes the patched from the unpatched renderer:
* the merge flag flips while the rendered output stays byte-identical.
*/
@Test
fun link_paragraph_merges_yet_keeps_both_links() = runComposeUiTest {
setContent { Wrap { Markdown(docLinks) } }
val root = onRoot(useUnmergedTree = true).fetchSemanticsNode()
val paragraph = findNodeWhoseTextContains(root, "Anthropic")
?: error("Could not locate paragraph text node containing the link copy")

// #569: paragraph is a screen-reader merge boundary (false on develop, true on #570).
assertTrue(
paragraph.config.isMergingSemanticsOfDescendants,
"Paragraph with inline links must merge descendants so TalkBack reads it — regression of #569"
)

// #487: both inline links remain individually addressable after the merge.
val annotatedString = paragraph.config[SemanticsProperties.Text].first()
val urls = annotatedString.getLinkAnnotations(0, annotatedString.length)
.mapNotNull { (it.item as? LinkAnnotation.Url)?.url }
.toSet()
assertEquals(
setOf("https://anthropic.com", "https://claude.ai"), urls,
"Both inline links must survive merging — regression of #487"
)
}

@Test
fun headings_expose_heading_property() = runComposeUiTest {
setContent { Wrap { Markdown("# Title\n\nBody") } }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.mikepenz.markdown.compose.elements

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -196,9 +197,15 @@ fun MarkdownText(
val segmentDrawModifier = if (extendedSpans != null) {
segmentModifier.drawBehind(extendedSpans)
} else segmentModifier
val hasSegmentLinks = segment.getLinkAnnotations(0, segment.length).isNotEmpty()
val finalModifier = if (hasSegmentLinks) {
segmentDrawModifier.semantics(mergeDescendants = true) { }
} else {
segmentDrawModifier
}
MarkdownBasicText(
text = extended,
modifier = segmentDrawModifier.let { animations.animateTextSize(it) },
modifier = finalModifier.let { animations.animateTextSize(it) },
Comment on lines +200 to +208

@bpappin bpappin May 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me see about adding more testing to this.

but...
The whole point of this change was explicitly to change the semantic structure, so that TalkBack would read the paragraph and links.
It currently skips over the whole paragraph if it has links in it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added new test: inline_links_are_individually_discoverable_and_readable

in: Semantics

"You can use mergeDescendants = true to indicate that a parent Composable's descendants should be merged into its semantic tree. This tells accessibility services like TalkBack to treat the Composable and all of its descendants as a single element, which helps reduce the number of focusable items on screen."

And a little help from gemini to explain it:

Why it relates to LinkAnnotation and text-skipping

  • The issue: In Compose 1.7.0+, LinkAnnotation elements automatically place individual virtual accessibility nodes into the semantics tree so that they can be read. However, because they are children of the text node in the semantics tree, TalkBack attempts to focus on them individually. When TalkBack encounters a complex mix of text and focusable child nodes (the links), it can skip reading the parent text and jump straight to the links, or focus them in an incorrect order.
  • The resolution: By applying Modifier.semantics(mergeDescendants = true) {} to the Text composable, Compose merges these virtual child nodes into the parent Text node. This prevents TalkBack from jumping focus inside the layout flow, forcing it to read the paragraph as a single cohesive unit.
  • Link Clickability: TalkBack still retains access to the links inside the merged parent text. When the merged paragraph is focused, TalkBack parses the raw AnnotatedString content and exposes all URL links inside the standard TalkBack local links menu (accessed via standard swipe gestures), allowing the user to select and trigger them.

This design pattern is standard practice in Compose development for ensuring correct accessibility ordering when rendering rich text.

style = style,
inlineContent = resolvedInlineContent,
onTextLayout = { result ->
Expand All @@ -210,7 +217,18 @@ fun MarkdownText(
}

if (blockImageRanges.isEmpty()) {
textSegment(content, containerModifier(modifier))
val hasLinks = content.getLinkAnnotations(0, content.length).isNotEmpty()
if (hasLinks) {
Box(modifier = containerModifier(modifier)) {
textSegment(content, Modifier)
}
} else {
textSegment(content, modifier.onPlaced {
it.parentLayoutCoordinates?.also { coordinates ->
containerSize.value = coordinates.size.toSize()
}
})
}
Comment on lines +221 to +231

@bpappin bpappin May 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the custom Box modifier chain with the library's containerModifier(modifier), which preserves the provided modifiers, then pass an empty Modifier to the internal textSegment.

} else {
val components = LocalMarkdownComponents.current
val typography = LocalMarkdownTypography.current
Expand Down
Loading