Skip to content

Commit f54c574

Browse files
committed
feat(accessibility): links are focused by keyboard and screen reader
1 parent f8b2377 commit f54c574

File tree

67 files changed

+1417
-665
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+1417
-665
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
bin/
2222
gen/
2323
out/
24-
# Uncomment the following line in case you need and you don't have the release build type files in your app
24+
# Uncomment the following line in case you need and you don't have the release build type files in your sample
2525
# release/
2626

2727
# Gradle files

.idea/runConfigurations/iosApp.xml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/xcode.xml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,20 @@
1414
1515
## Supported HTML tags
1616

17-
| Tag | Description |
18-
|------------------------------------|------------|
19-
| `<b>` | Bold text |
20-
| `<i>` | Italic text |
21-
| `<strike>` | Strikethrough text |
22-
| `<u>` | Underlined text |
23-
| `<a href="...">` | Clickable link |
24-
| `<span style="color: #0000FF">` | Colored text |
25-
| `<span style="color: rgb(r,g,b)">` | Colored text |
26-
| `<font color="#FF0000">` | Colored text |
27-
| `<font color="rgb(r,g,b)">` | Colored text |
17+
| Tag | Description |
18+
|------------------------------------|---------------------------|
19+
| `<b>` | Bold text |
20+
| `<i>` | Italic text |
21+
| `<strike>` | Strikethrough text |
22+
| `<u>` | Underlined text |
23+
| `<ul>` | Unordered list |
24+
| `<ol start="3" type="1">` | Ordered list (a., A., 1.) |
25+
| `<li>` | List item |
26+
| `<a href="...">` | Clickable link |
27+
| `<span style="color: #0000FF">` | Colored text |
28+
| `<span style="color: rgb(r,g,b)">` | Colored text |
29+
| `<font color="#FF0000">` | Colored text |
30+
| `<font color="rgb(r,g,b)">` | Colored text |
2831

2932

3033
## MaterialTheme colors in HtmlText
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package de.charlex.compose.htmltext.core
2+
3+
actual fun isIOS(): Boolean = false
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package de.charlex.compose.htmltext.core
2+
3+
import androidx.compose.foundation.clickable
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.offset
6+
import androidx.compose.foundation.layout.size
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.platform.LocalDensity
12+
import androidx.compose.ui.platform.LocalUriHandler
13+
import androidx.compose.ui.semantics.contentDescription
14+
import androidx.compose.ui.semantics.hideFromAccessibility
15+
import androidx.compose.ui.semantics.onClick
16+
import androidx.compose.ui.semantics.semantics
17+
import androidx.compose.ui.text.AnnotatedString
18+
import androidx.compose.ui.text.TextLayoutResult
19+
import androidx.compose.ui.unit.dp
20+
21+
@Composable
22+
fun BaseHtmlText(
23+
modifier: Modifier = Modifier,
24+
annotatedString: AnnotatedString,
25+
linkBoxModifier: (text: String, link: String) -> Modifier = { _, _ -> Modifier },
26+
onTextLayout: (TextLayoutResult) -> Unit = {},
27+
onUriClick: ((String) -> Unit)? = null,
28+
text: @Composable (modifier: Modifier, onTextLayout: (TextLayoutResult) -> Unit) -> Unit
29+
) {
30+
val urlAnns = remember(annotatedString) {
31+
annotatedString.getStringAnnotations("url", 0, annotatedString.length)
32+
}
33+
val layoutResultState = remember { mutableStateOf<TextLayoutResult?>(null) }
34+
val density = LocalDensity.current
35+
val uriHandler = LocalUriHandler.current
36+
37+
Box(
38+
modifier = Modifier.semantics(mergeDescendants = isIOS().not()) {
39+
contentDescription = annotatedString.text
40+
}
41+
) {
42+
text(
43+
modifier
44+
.semantics {
45+
if(isIOS().not()) {
46+
hideFromAccessibility()
47+
}
48+
}
49+
) {
50+
layoutResultState.value = it
51+
onTextLayout(it)
52+
}
53+
54+
val layoutResult = layoutResultState.value
55+
if (layoutResult != null) {
56+
urlAnns.forEachIndexed { index, ann ->
57+
val rects = remember(layoutResult, ann) {
58+
computeLinkRects(layoutResult, ann.start, ann.end)
59+
}
60+
rects.forEach { r ->
61+
Box(
62+
modifier = linkBoxModifier(annotatedString.text.substring(ann.start, ann.end), ann.item)
63+
.offset(
64+
x = with(density) { r.left.toDp().takeIf { it > 0.dp } ?: 1.dp }, // If we move the first clickable box to x = 1.dp, the screenreader will read the while string first
65+
y = with(density) { r.top.toDp() }
66+
)
67+
.size(
68+
width = with(density) { r.width.toDp() },
69+
height = with(density) { r.height.toDp() }
70+
)
71+
.semantics {
72+
contentDescription = "Link: ${annotatedString.text.substring(ann.start, ann.end)}"
73+
}
74+
.clickable {
75+
onUriClick?.let { it(ann.item) } ?: uriHandler.openUri(ann.item)
76+
}
77+
)
78+
}
79+
}
80+
}
81+
}
82+
}

common/src/commonMain/kotlin/HtmlParser.kt

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
11
package de.charlex.compose.htmltext.core
22

3+
import androidx.compose.runtime.getValue
4+
import androidx.compose.runtime.mutableStateOf
5+
import androidx.compose.runtime.remember
6+
import androidx.compose.runtime.setValue
37
import androidx.compose.ui.graphics.Color
48
import androidx.compose.ui.text.AnnotatedString
59
import androidx.compose.ui.text.AnnotatedString.Builder
10+
import androidx.compose.ui.text.AnnotatedString.Builder.BulletScope
11+
import androidx.compose.ui.text.ParagraphStyle
12+
import androidx.compose.ui.text.PlatformParagraphStyle
613
import androidx.compose.ui.text.SpanStyle
14+
import androidx.compose.ui.text.TextStyle
715
import androidx.compose.ui.text.font.FontStyle
816
import androidx.compose.ui.text.font.FontWeight
17+
import androidx.compose.ui.text.style.Hyphens
18+
import androidx.compose.ui.text.style.LineBreak
19+
import androidx.compose.ui.text.style.LineHeightStyle
920
import androidx.compose.ui.text.style.TextDecoration
21+
import androidx.compose.ui.text.style.TextIndent
22+
import androidx.compose.ui.text.withStyle
23+
import androidx.compose.ui.unit.TextUnit
24+
import androidx.compose.ui.unit.sp
1025
import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlHandler
1126
import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlParser
27+
import kotlin.compareTo
28+
import kotlin.text.append
1229

1330

1431
fun htmlToAnnotatedString(
@@ -19,7 +36,9 @@ fun htmlToAnnotatedString(
1936
),
2037
colorMapping: Map<Color, Color> = emptyMap(),
2138
bulletChar: String = "•",
22-
indentPerLevel: Int = 2,
39+
indentPerLevel: TextUnit = 15.sp,
40+
extraIndentUnorderedRestLines: TextUnit = 8.sp,
41+
extraIndentOrderedRestLines: TextUnit = 15.sp,
2342
orderedSeparator: String = "."
2443
): AnnotatedString {
2544
val builder = Builder()
@@ -35,7 +54,7 @@ fun htmlToAnnotatedString(
3554
val ordered: Boolean,
3655
var itemCount: Int,
3756
val startIndex: Int = 1,
38-
val type: Char? = null // a, A, i, I, 1
57+
val type: Char? = null // a, A, 1
3958
)
4059

4160
val tagStack = mutableListOf<TagInfo>()
@@ -67,34 +86,15 @@ fun htmlToAnnotatedString(
6786
}
6887
}
6988

70-
fun lastChar(): Char? = if (builder.length > 0) builder.toAnnotatedString().text.last() else null
71-
72-
fun toRoman(num: Int): String { // einfache Umsetzung bis 3999
73-
if (num <= 0) return num.toString()
74-
val numerals = listOf(
75-
1000 to "M", 900 to "CM", 500 to "D", 400 to "CD",
76-
100 to "C", 90 to "XC", 50 to "L", 40 to "XL",
77-
10 to "X", 9 to "IX", 5 to "V", 4 to "IV", 1 to "I"
78-
)
79-
var n = num
80-
val sb = StringBuilder()
81-
for ((value, symbol) in numerals) {
82-
while (n >= value) {
83-
sb.append(symbol)
84-
n -= value
85-
}
86-
}
87-
return sb.toString()
88-
}
89-
9089
fun formatOrdered(index: Int, type: Char?): String = when (type) {
9190
'a' -> ('a' + (index - 1) % 26).toString()
9291
'A' -> ('A' + (index - 1) % 26).toString()
93-
'i' -> toRoman(index).lowercase()
94-
'I' -> toRoman(index)
9592
else -> index.toString()
9693
}
9794

95+
var possibleNextLineBreakInList by mutableStateOf(false)
96+
var closedSubList by mutableStateOf(false)
97+
9898
val ksoupHtmlParser = KsoupHtmlParser(
9999
handler = object : KsoupHtmlHandler {
100100
override fun onOpenTag(name: String, attributes: Map<String, String>, isImplied: Boolean) {
@@ -111,9 +111,7 @@ fun htmlToAnnotatedString(
111111
}
112112

113113
// Farbe aus Attribut oder Style-Attribut extrahieren
114-
val rawColor = attributes["color"]
115-
?: attributes["style"]?.substringAfter("color:", "").orEmpty()
116-
.substringBefore(";").ifBlank { null }
114+
val rawColor = attributes["color"] ?: attributes["style"]?.substringAfter("color:", "").orEmpty().substringBefore(";").ifBlank { null }
117115
val parsedColor = parseColorAttr(rawColor)
118116
if (parsedColor != null) {
119117
val mapped = colorMapping.getOrElse(parsedColor) { parsedColor }
@@ -124,23 +122,36 @@ fun htmlToAnnotatedString(
124122
val ordered = lowerName == "ol"
125123
val startIndex = attributes["start"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1
126124
val typeAttr = attributes["type"]?.firstOrNull()?.let { ch ->
127-
if (ch in listOf('a', 'A', 'i', 'I', '1')) ch else null
125+
if (ch in listOf('a', 'A', '1')) ch else null
128126
}
129127
listStack.add(ListContext(ordered = ordered, itemCount = 0, startIndex = startIndex, type = typeAttr))
128+
129+
val depth = listStack.size
130+
131+
val restLineIndent = when {
132+
ordered -> ((indentPerLevel * depth).value + extraIndentOrderedRestLines.value).sp
133+
else -> ((indentPerLevel * depth).value + extraIndentUnorderedRestLines.value).sp
134+
}
135+
136+
builder.pushStyle(
137+
ParagraphStyle(
138+
textIndent = TextIndent(
139+
firstLine = indentPerLevel * depth,
140+
restLine = restLineIndent
141+
)
142+
)
143+
)
130144
}
131145

132146
if (lowerName == "li") {
133147
val currentList = listStack.lastOrNull()
134-
val depth = listStack.size
135148
if (currentList != null) {
136-
val prev = lastChar()
137-
if (currentList.itemCount > 0) {
138-
if (prev != '\n') builder.append('\n')
139-
} else {
140-
if (prev != null && prev != '\n') builder.append('\n')
149+
if(possibleNextLineBreakInList) {
150+
println(builder.toAnnotatedString().text)
151+
builder.append('\n')
152+
possibleNextLineBreakInList = false
141153
}
142-
val indent = " ".repeat(depth * indentPerLevel)
143-
builder.append(indent)
154+
144155
if (currentList.ordered) {
145156
val numberIndex = currentList.startIndex + currentList.itemCount
146157
val formatted = formatOrdered(numberIndex, currentList.type)
@@ -181,16 +192,28 @@ fun htmlToAnnotatedString(
181192
}
182193
}
183194
}
195+
196+
if(lowerName == "li") {
197+
val currentList = listStack.lastOrNull()
198+
if (currentList != null && currentList.itemCount > 0 && closedSubList.not()) {
199+
possibleNextLineBreakInList = true
200+
} else {
201+
closedSubList = false
202+
}
203+
204+
}
205+
184206
if (lowerName == "ul" || lowerName == "ol") {
185207
listStack.removeLastOrNull()
208+
closedSubList = listStack.isNotEmpty()
209+
possibleNextLineBreakInList = false
210+
builder.pop()
186211
}
187212
}
188213
}
189214
)
190215

191216
ksoupHtmlParser.write(html)
192-
193217
ksoupHtmlParser.end()
194-
195218
return builder.toAnnotatedString()
196-
}
219+
}

common/src/commonMain/kotlin/HtmlTextModifier.kt

Lines changed: 0 additions & 72 deletions
This file was deleted.

0 commit comments

Comments
 (0)