diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index 19388783f..3591c49a0 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -164,9 +164,21 @@ Tables expose one focusable element per row (header included), labeled with the Each math block exposes a single focusable element labeled `"Math: "`. The library does **not** convert LaTeX to natural language — the screen reader reads the raw LaTeX source. If you need spoken math, plug in a LaTeX→speech library on the consumer side and translate the labels accordingly. +## Text input (`EnrichedMarkdownTextInput`) + +The editor's accessibility model is intentionally simpler than the renderer's. The input is built on a custom TextKit stack, so its inner `UITextView` is not surfaced to VoiceOver directly — instead the component is presented as a **single accessibility element** that reads its whole content as one paragraph. + +**iOS (VoiceOver):** +- The field is one element whose spoken value is the full plain text (delimiters stripped). When the field is empty, the placeholder is announced instead. +- `accessibilityLabel` / `accessibilityHint` set on the component are forwarded and announced. +- Double-tapping activates the field: it becomes first responder and the keyboard opens for editing. + +**Android (TalkBack):** the input is a native `EditText`, announced and edited as a standard editable text field. + ## Known Limitations - **Inline formatting** (bold / italic / underline / strikethrough / inline code / spoiler) is not split into separate accessibility elements — the entire paragraph is read as one element, exactly as the surrounding text. Screen readers don't apply visual emphasis to bold/italic by default. - **macOS** screen-reader support is still pending — `MarkdownAccessibilityElementBuilder.m` ships a no-op stub for macOS. Tracked as a TODO; iOS implementation can serve as a reference. - **Android** has no rotor concept — `accessibilityLabels.rotor.*` is silently ignored on that platform. - **iOS** blockquote backgrounds may break at link boundaries instead of spanning the full line. Visual only; doesn't affect accessibility. +- **Text input** (`EnrichedMarkdownTextInput`, iOS) is exposed as a single VoiceOver element reading its full plain text as one paragraph. It is **not** announced with the native "text field" role, has no in-field cursor navigation or per-character echo (editing happens via the keyboard after a double-tap activation), and its spoken value refreshes on re-focus rather than live. Exposing the inner `UITextView` for these would be unreliable under the custom TextKit stack (duplicate/flaky announcements). Inline formatting and headings are read as plain text. diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index de8e41dcf..638abe602 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -226,11 +226,43 @@ - (void)setupTextView _placeholderLabel = ENRMCreatePlaceholderLabel(_textView, _formatterStyle.baseFont); #if !TARGET_OS_OSX _placeholderLabel.adjustsFontForContentSizeCategory = YES; + _placeholderLabel.accessibilityElementsHidden = YES; + _placeholderLabel.isAccessibilityElement = NO; #endif [self resetBaseTypingAttributes]; } +#if !TARGET_OS_OSX +#pragma mark - Accessibility + +// The custom TextKit 1 stack leaves the inner UITextView invisible to VoiceOver, so +// the host vends the content itself as one readable element. +- (BOOL)isAccessibilityElement +{ + return YES; +} + +- (NSString *)accessibilityValue +{ + NSString *text = ENRMGetPlainText(_textView); + if (text.length > 0) { + return text; + } + if (_placeholderLabel.text.length > 0) { + return _placeholderLabel.text; + } + return [super accessibilityValue]; +} + +- (BOOL)accessibilityActivate +{ + ENRMFocusTextView(_textView); + [super accessibilityActivate]; + return YES; +} +#endif + #pragma mark - State - (void)updateState:(const facebook::react::State::Shared &)state