Skip to content

Fix TextField accessibility - contentDescription was ignored#2680

Merged
m-sasha merged 1 commit intoJetBrains:jb-mainfrom
kdroidFilter:textfield-accessible-name
Feb 2, 2026
Merged

Fix TextField accessibility - contentDescription was ignored#2680
m-sasha merged 1 commit intoJetBrains:jb-mainfrom
kdroidFilter:textfield-accessible-name

Conversation

@kdroidFilter
Copy link
Copy Markdown

@kdroidFilter kdroidFilter commented Jan 13, 2026

Description

Fixes TextField accessibility issue on Desktop (macOS) where contentDescription provided via Modifier.semantics {} was completely ignored by VoiceOver screen readers. Before this fix, TextField components were unusable for screen reader users because the text content was used as both the accessible name (label) and value (content), making unlabeled fields indistinguishable.

The fix updates ComposeAccessible.getAccessibleName() to prioritize contentDescription as the accessible name for text fields, aligning the behavior with iOS accessibility standards.

Before

TextField(
    value = "john@example.com",
    modifier = Modifier.semantics {
        contentDescription = "Email" // ❌ Ignored
    }
)

VoiceOver: "john@example.com" (no label)

After

TextField(
    value = "john@example.com",
    modifier = Modifier.semantics {
        contentDescription = "Email" // ✅ Used as accessible name
    }
)

VoiceOver: "Email", "john@example.com" (label + value)


Visual comparison:

Before fix:
empty-mail-not-fixed
mail-not-fixed

After fix:
empty-mail-fixed
mail-fixed

Testing

Unit Tests:

  • Added tests in AccessibilityTest.kt covering:
    • TextField with contentDescription → uses contentDescription as accessible name
    • TextField without contentDescription → falls back to text content

Manual Testing:

  • Tested with VoiceOver on macOS
  • Verified TextField with and without contentDescription
  • Confirmed proper announcement of label and value

Release Notes

Fixes - Desktop

  • Fix TextField accessibility issue where contentDescription was ignored by screen readers (VoiceOver). TextField now properly uses contentDescription as the accessible name/label, making forms usable with assistive technologies.

Google CLA

Note: I have signed the Google Contributor's License Agreement at https://cla.developers.google.com

@google-cla
Copy link
Copy Markdown

google-cla Bot commented Jan 13, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Comment on lines +255 to +260
if (setText != null) {
return semanticsConfig
.getOrNull(SemanticsProperties.ContentDescription)
?.mergeText()
?: text?.toString()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is there a reason we should not just always return

(semanticsConfig.getOrNull(SemanticsProperties.Text) ?:  semanticsConfig.getOrNull(SemanticsProperties.ContentDescription))
    ?.mergeText()

?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

i.e., why only if setText != null?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The setText != null check is necessary because Text ?: ContentDescription doesn't work for the fallback case.

I tested your suggestion and it fails the textFieldAccessibleNameFallsBackToTextContent test. The issue is that for TextFields, SemanticsProperties.Text may exist in the config but doesn't provide the actual text content—that comes from the text field (via AccessibleText).

So when there's no contentDescription, we need text?.toString() as fallback, not SemanticsProperties.Text.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Err, sorry, I meant the other way around (ContentDescription ?: Text).

My point was that it seems to me the content description should be used (if set) not just for text fields (or editable text components in general).

Copy link
Copy Markdown
Author

@kdroidFilter kdroidFilter Jan 13, 2026

Choose a reason for hiding this comment

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

I understand your point—using ContentDescription ?: Text for all components would be more consistent.

However, this fails the textFieldAccessibleNameFallsBackToTextContent test because SemanticsProperties.Text is null/empty for editable TextFields.

The fallback needs text?.toString() (from the Java AccessibleText API), not SemanticsProperties.Text (from Compose semantics). The setText != null check ensures we use the correct fallback source specifically for text fields.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My question wasn't about semanticsConfig.getOrNull(SemanticsProperties.Text) vs. text (which is just a calculated property of ComposeAccessibleComponent, by the way; it doesn't come from the Java API), although that's a valid question too. It was about using ContentDescription even if setText is null. Is there a reason not to do that?

About the question of semanticsConfig.getOrNull(SemanticsProperties.Text) vs. text (which itself is EditableText ?: Text): I don't think it's correct to use EditableText in getAccessibleName (or getAccessibleDescription.

So my suggestion is:

  • In getAccessibleName return ContentDescription ?: Text.
  • In getAccessibleDescription return ContentDescription.

Do you know of any cases where this would result in undesirable behavior from the OS accessibility system?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hmm, getAccessibleDescription already returns ContentDescription.

So just the first suggestion then.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ah, sorry, I hadn’t understood it properly.

On my side, I also didn’t fully understand why EditableText was being used in getAccessibleName, but I intentionally kept it to avoid introducing overly large changes. My goal was really to fix this specific bug, without further altering the existing behavior.

To my knowledge, this should not cause any issues with screen readers: they should be able to read the content correctly through the AccessibleText interface.

Would you like me to apply the proposed changes?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Would you like me to apply the proposed changes?

Yes, let's do that, and if/when anyone complains we'll fix it (and document exactly why).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I've implemented your suggestion.
However, when running all AccessibilityTest tests together, 10 tests fail with assert(activeControllers.isEmpty()) in the cleanup. These same tests pass when run individually.

@m-sasha m-sasha self-requested a review January 14, 2026 19:13
@m-sasha
Copy link
Copy Markdown

m-sasha commented Jan 14, 2026

Please format the PR summary according to https://raw.githubusercontent.com/JetBrains/compose-multiplatform-core/refs/heads/jb-main/.github/PULL_REQUEST_TEMPLATE.md

Also, if you haven't already, sign the Google Contributor's License Agreement at https://cla.developers.google.com to let us upstream your code to Google's AOSP repository

@kdroidFilter
Copy link
Copy Markdown
Author

@MSasha I've reformatted the PR according to the template. The description, testing details, and release notes are now properly structured.

Regarding the Google CLA, I've signed it at https://cla.developers.google.com

@m-sasha
Copy link
Copy Markdown

m-sasha commented Feb 2, 2026

@kdroidFilter Please rebase on latest jb-main and apply this patch:

Subject: [PATCH] Moved a11y-related parts of ComposeSceneMediator.DesktopSemanticsOwnerManager into ComposeSceneAccessibility
---
Index: compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt
--- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt	(revision 349e958f6b83ded1856b44ee29fc2658453028d3)
+++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt	(date 1770028677392)
@@ -630,7 +630,14 @@
         val deferredWindow = CompletableDeferred<ComposeWindow>()
         launchTestWindowApplication {
             val focusRequester = remember { FocusRequester() }
-            TextField(rememberTextFieldState("text"), Modifier.focusRequester(focusRequester))
+            TextField(
+                state = rememberTextFieldState("Hello, World"),
+                modifier = Modifier
+                    .focusRequester(focusRequester)
+                    .semantics {
+                        contentDescription = "text"
+                    }
+            )
             LaunchedEffect(Unit) { focusRequester.requestFocus() }
             LaunchedEffect(Unit) { deferredWindow.complete(this@launchTestWindowApplication.window) }
         }

@kdroidFilter kdroidFilter force-pushed the textfield-accessible-name branch from 86c0a9e to 8c63496 Compare February 2, 2026 18:05
@kdroidFilter
Copy link
Copy Markdown
Author

kdroidFilter commented Feb 2, 2026

@m-sasha I've rebased on the latest jb-main and applied the patch.

@m-sasha
Copy link
Copy Markdown

m-sasha commented Feb 2, 2026

Thanks, running the CI tests now.

- Changed getAccessibleName() to prioritize contentDescription over text
- Added tests for TextField with and without contentDescription
- Applied patch to initiallyFocusedElementNotifiesSystemOfFocus test
@kdroidFilter kdroidFilter force-pushed the textfield-accessible-name branch from 8c63496 to eb896e9 Compare February 2, 2026 19:09
@kdroidFilter
Copy link
Copy Markdown
Author

@m-sasha I had an issue during the rebase - some deleted tests were reintroduced, which caused the CI to fail. I've fixed this now. Could you please restart the ci?

@m-sasha
Copy link
Copy Markdown

m-sasha commented Feb 2, 2026

Running

@kdroidFilter
Copy link
Copy Markdown
Author

@m-sasha
All checks are now passing :)

@m-sasha m-sasha merged commit 98f13d1 into JetBrains:jb-main Feb 2, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants