From 4efc82fa74ef7abe36417605c55f04ee2c8787e6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 1 Jul 2026 17:29:09 +0200 Subject: [PATCH 1/6] A11y: Add programmatic label and keyboard focus indicator to PIN entry field Fixes: https://github.com/element-hq/element-x-android/issues/6391 Fixes: https://github.com/element-hq/element-x-android/issues/6401 Co-Authored-By: Claude Sonnet 4.6 --- .../impl/components/PinEntryTextField.kt | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index 439c6aeaa7f..dbbabb390cf 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -22,8 +22,18 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -32,6 +42,8 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.pinDigitBg +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun PinEntryTextField( @@ -40,15 +52,21 @@ fun PinEntryTextField( onValueChange: (String) -> Unit, modifier: Modifier = Modifier, ) { + var isFocused by remember { mutableStateOf(false) } + val filledCount = pinEntry.digits.count { it is PinDigit.Filled } + val pinFieldLabel = stringResource(CommonStrings.a11y_pin_field) + val digitsEnteredLabel = pluralStringResource(CommonPlurals.a11y_digits_entered, filledCount, filledCount) BasicTextField( - modifier = modifier, + modifier = modifier + .onFocusChanged { isFocused = it.isFocused } + .semantics { contentDescription = "$pinFieldLabel, $digitsEnteredLabel" }, value = pinEntry.toText(), onValueChange = { onValueChange(it) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), decorationBox = { - PinEntryRow(pinEntry = pinEntry, isSecured = isSecured) + PinEntryRow(pinEntry = pinEntry, isSecured = isSecured, isFocused = isFocused) } ) } @@ -58,8 +76,14 @@ fun PinEntryTextField( private fun PinEntryRow( pinEntry: PinEntry, isSecured: Boolean, + isFocused: Boolean = false, ) { FlowRow( + modifier = Modifier.border( + width = 2.dp, + color = if (isFocused) ElementTheme.colors.borderInteractiveHovered else Color.Transparent, + shape = RoundedCornerShape(8.dp), + ), horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(8.dp), ) { From 416da32c4066715fd8f34801c4a9d39405c30447 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 2 Jul 2026 13:38:25 +0200 Subject: [PATCH 2/6] Set focused state only on the digit. --- .../impl/components/PinEntryTextField.kt | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index dbbabb390cf..8014504950f 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -28,12 +28,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -59,7 +58,9 @@ fun PinEntryTextField( BasicTextField( modifier = modifier .onFocusChanged { isFocused = it.isFocused } - .semantics { contentDescription = "$pinFieldLabel, $digitsEnteredLabel" }, + .clearAndSetSemantics { + contentDescription = "$pinFieldLabel, $digitsEnteredLabel" + }, value = pinEntry.toText(), onValueChange = { onValueChange(it) @@ -76,19 +77,18 @@ fun PinEntryTextField( private fun PinEntryRow( pinEntry: PinEntry, isSecured: Boolean, - isFocused: Boolean = false, + isFocused: Boolean, ) { FlowRow( - modifier = Modifier.border( - width = 2.dp, - color = if (isFocused) ElementTheme.colors.borderInteractiveHovered else Color.Transparent, - shape = RoundedCornerShape(8.dp), - ), horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - for (digit in pinEntry.digits) { - PinDigitView(digit = digit, isSecured = isSecured) + pinEntry.digits.forEachIndexed { index, digit -> + PinDigitView( + digit = digit, + isSecured = isSecured, + isFocused = isFocused && index == pinEntry.digits.indexOfFirst { it is PinDigit.Empty } + ) } } } @@ -97,11 +97,16 @@ private fun PinEntryRow( private fun PinDigitView( digit: PinDigit, isSecured: Boolean, + isFocused: Boolean, ) { val shape = RoundedCornerShape(8.dp) val appearanceModifier = when (digit) { PinDigit.Empty -> { - Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape) + if (isFocused) { + Modifier.border(2.dp, ElementTheme.colors.borderFocused, shape) + } else { + Modifier.border(1.dp, ElementTheme.colors.iconSecondary, shape) + } } is PinDigit.Filled -> { Modifier.background(ElementTheme.colors.pinDigitBg, shape) From 25716f6f3a257aa3058cab97b0da705b9028b211 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 2 Jul 2026 15:33:20 +0200 Subject: [PATCH 3/6] Fix regression --- .../features/lockscreen/impl/components/PinEntryTextField.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index 8014504950f..2feaf5f01ea 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -105,7 +105,7 @@ private fun PinDigitView( if (isFocused) { Modifier.border(2.dp, ElementTheme.colors.borderFocused, shape) } else { - Modifier.border(1.dp, ElementTheme.colors.iconSecondary, shape) + Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape) } } is PinDigit.Filled -> { From bf1ace48f2ea062391cd78175abdacaf53ce83d0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 2 Jul 2026 15:38:53 +0200 Subject: [PATCH 4/6] Improve preview. --- .../impl/components/PinEntryTextField.kt | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index 2feaf5f01ea..bbdd5ce0383 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -50,8 +49,9 @@ fun PinEntryTextField( isSecured: Boolean, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, + initialIsFocus: Boolean = false, ) { - var isFocused by remember { mutableStateOf(false) } + var isFocused by remember { mutableStateOf(initialIsFocus) } val filledCount = pinEntry.digits.count { it is PinDigit.Filled } val pinFieldLabel = stringResource(CommonStrings.a11y_pin_field) val digitsEnteredLabel = pluralStringResource(CommonPlurals.a11y_digits_entered, filledCount, filledCount) @@ -137,18 +137,19 @@ private fun PinDigitView( internal fun PinEntryTextFieldPreview() { ElementPreview { val pinEntry = PinEntry.createEmpty(4).fillWith("12") - Column { - PinEntryTextField( - pinEntry = pinEntry, - isSecured = true, - onValueChange = {}, - ) - Spacer(modifier = Modifier.size(16.dp)) - PinEntryTextField( - pinEntry = pinEntry, - isSecured = false, - onValueChange = {}, - ) + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(true, false).forEach { isSecured -> + listOf(true, false).forEach { initialIsFocus -> + PinEntryTextField( + pinEntry = pinEntry, + isSecured = isSecured, + initialIsFocus = initialIsFocus, + onValueChange = {}, + ) + } + } } } } From 06d778859c7187e372a71f5027d3ca4f7945d8e9 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 3 Jul 2026 07:58:47 +0000 Subject: [PATCH 5/6] Update screenshots --- ....lockscreen.impl.components_PinEntryTextField_Day_0_en.png | 4 ++-- ...ockscreen.impl.components_PinEntryTextField_Night_0_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Day_0_en.png index 3ab1c052d2e..3a22d6959fc 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76bb68d4ee420ec14a8c9372225c91407ab452479e0897c374721eb834dc6ba0 -size 9920 +oid sha256:2046f1225b2f58812502196f4b7d1c7a4d87f740b32e40dd82762fade1c42ea2 +size 16167 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Night_0_en.png index 18a2ab94664..10223f5bf67 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d72cda6587357da35dd5a22ff595dd0f9500dac14cf731a348e209bb17ed7b56 -size 9979 +oid sha256:62384687c273cb57dbd25bfaa558d83e3efc527fc4dfff4aca8bf85ab1ce9e4b +size 16446 From bad39d62e998170920943bb2c0a6ed58febfc81a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 3 Jul 2026 09:59:54 +0200 Subject: [PATCH 6/6] Optimize code --- .../features/lockscreen/impl/components/PinEntryTextField.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index bbdd5ce0383..28e005fd1dd 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -83,11 +83,12 @@ private fun PinEntryRow( horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + val focusedIndex = pinEntry.digits.indexOfFirst { it is PinDigit.Empty } pinEntry.digits.forEachIndexed { index, digit -> PinDigitView( digit = digit, isSecured = isSecured, - isFocused = isFocused && index == pinEntry.digits.indexOfFirst { it is PinDigit.Empty } + isFocused = isFocused && index == focusedIndex ) } }