react-native-a11y-order 1.0.0-rc → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/android/src/main/java/com/a11yorder/core/A11yManagedFocusView.java +29 -0
- package/android/src/main/java/com/a11yorder/core/A11yViewOrder.java +1 -1
- package/android/src/main/java/com/a11yorder/modules/A11yAnnounceModule.java +20 -7
- package/android/src/main/java/com/a11yorder/views/A11yIndexView/A11yIndexViewManager.java +0 -6
- package/android/src/oldarch/A11yAnnounceModuleSpec.java +8 -1
- package/android/src/oldarch/A11yIndexViewManagerSpec.java +0 -1
- package/ios/helpers/RNAOSpeechAttributes.h +35 -0
- package/ios/helpers/RNAOSpeechAttributes.mm +64 -0
- package/ios/modules/RNAOA11yAnnounceModule.h +13 -9
- package/ios/modules/RNAOA11yAnnounceModule.mm +220 -14
- package/ios/services/RNAOA11yAnnounceService/RNAOA11yAnnounceService.h +11 -1
- package/ios/services/RNAOA11yAnnounceService/RNAOA11yAnnounceService.mm +55 -51
- package/ios/views/RNAOA11yIndexView/RNAOA11yIndexView.mm +0 -4
- package/ios/views/RNAOA11yIndexView/RNAOA11yIndexViewManager.mm +5 -5
- package/ios/views/RNAOA11yPaneTitleView/RNAOA11yPaneTitleView.mm +12 -2
- package/ios/views/base/{RNAOA11yAutoFocusView.h → RNAOA11yManagedFocusView.h} +5 -7
- package/ios/views/base/{RNAOA11yAutoFocusView.mm → RNAOA11yManagedFocusView.mm} +2 -19
- package/ios/views/base/RNAOA11yViewOrder.h +3 -3
- package/lib/commonjs/components/A11yCard/A11yCard.ios.js +8 -1
- package/lib/commonjs/components/A11yCard/A11yCard.ios.js.map +1 -1
- package/lib/commonjs/components/A11yIndex/A11yIndex.js +3 -2
- package/lib/commonjs/components/A11yIndex/A11yIndex.js.map +1 -1
- package/lib/commonjs/index.js +22 -4
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +4 -4
- package/lib/commonjs/modules/A11yAnnounceModule.android.js +37 -3
- package/lib/commonjs/modules/A11yAnnounceModule.android.js.map +1 -1
- package/lib/commonjs/modules/A11yAnnounceModule.js +66 -8
- package/lib/commonjs/modules/A11yAnnounceModule.js.map +1 -1
- package/lib/commonjs/nativeSpecs/A11yIndexNativeComponent.ts +0 -1
- package/lib/commonjs/nativeSpecs/NativeA11yAnnounceModule.js +2 -0
- package/lib/commonjs/nativeSpecs/NativeA11yAnnounceModule.js.map +1 -1
- package/lib/module/components/A11yCard/A11yCard.ios.js +8 -1
- package/lib/module/components/A11yCard/A11yCard.ios.js.map +1 -1
- package/lib/module/components/A11yIndex/A11yIndex.js +3 -2
- package/lib/module/components/A11yIndex/A11yIndex.js.map +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/modules/A11yAnnounceModule.android.js +32 -2
- package/lib/module/modules/A11yAnnounceModule.android.js.map +1 -1
- package/lib/module/modules/A11yAnnounceModule.js +62 -7
- package/lib/module/modules/A11yAnnounceModule.js.map +1 -1
- package/lib/module/nativeSpecs/A11yIndexNativeComponent.ts +0 -1
- package/lib/module/nativeSpecs/NativeA11yAnnounceModule.js +4 -0
- package/lib/module/nativeSpecs/NativeA11yAnnounceModule.js.map +1 -1
- package/lib/typescript/src/components/A11yCard/A11yCard.ios.d.ts.map +1 -1
- package/lib/typescript/src/components/A11yIndex/A11yIndex.d.ts +0 -1
- package/lib/typescript/src/components/A11yIndex/A11yIndex.d.ts.map +1 -1
- package/lib/typescript/src/components/A11yIndex/A11yIndex.types.d.ts +0 -4
- package/lib/typescript/src/components/A11yIndex/A11yIndex.types.d.ts.map +1 -1
- package/lib/typescript/src/components/A11yIndex/A11yIndex.web.d.ts +0 -1
- package/lib/typescript/src/components/A11yIndex/A11yIndex.web.d.ts.map +1 -1
- package/lib/typescript/src/components/A11yView/A11yView.d.ts +0 -1
- package/lib/typescript/src/components/A11yView/A11yView.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/index.web.d.ts +1 -2
- package/lib/typescript/src/index.web.d.ts.map +1 -1
- package/lib/typescript/src/modules/A11yAnnounceModule.android.d.ts +25 -2
- package/lib/typescript/src/modules/A11yAnnounceModule.android.d.ts.map +1 -1
- package/lib/typescript/src/modules/A11yAnnounceModule.d.ts +112 -4
- package/lib/typescript/src/modules/A11yAnnounceModule.d.ts.map +1 -1
- package/lib/typescript/src/nativeSpecs/A11yIndexNativeComponent.d.ts +0 -1
- package/lib/typescript/src/nativeSpecs/A11yIndexNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/nativeSpecs/NativeA11yAnnounceModule.d.ts +21 -1
- package/lib/typescript/src/nativeSpecs/NativeA11yAnnounceModule.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/A11yCard/A11yCard.ios.tsx +8 -1
- package/src/components/A11yIndex/A11yIndex.tsx +5 -4
- package/src/components/A11yIndex/A11yIndex.types.ts +0 -5
- package/src/index.ts +12 -1
- package/src/index.web.ts +1 -1
- package/src/modules/A11yAnnounceModule.android.ts +18 -2
- package/src/modules/A11yAnnounceModule.ts +153 -9
- package/src/nativeSpecs/A11yIndexNativeComponent.ts +0 -1
- package/src/nativeSpecs/NativeA11yAnnounceModule.ts +31 -1
- package/android/src/main/java/com/a11yorder/core/A11yAutoFocusView.java +0 -61
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# React Native A11y Order
|
|
4
4
|
|
|
5
5
|
<div>
|
|
6
|
-
<img align="right" width="35%" src="/.github/images/
|
|
6
|
+
<img align="right" width="35%" src="/.github/images/a11y-order-ios.gif">
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
9
|
Native-first React Native library for controlling screen reader focus order on iOS (VoiceOver) and Android (TalkBack).
|
|
@@ -40,6 +40,8 @@ Get started with the [getting started guide](./docs/getting-started/getting-star
|
|
|
40
40
|
|
|
41
41
|
## What's available
|
|
42
42
|
|
|
43
|
+
**Components**
|
|
44
|
+
|
|
43
45
|
| Export | Purpose |
|
|
44
46
|
| :-- | :-- |
|
|
45
47
|
| [`A11y.Order`](./docs/guides/a11y-order.md) | Container that defines a named focus-order sequence. |
|
|
@@ -50,7 +52,12 @@ Get started with the [getting started guide](./docs/getting-started/getting-star
|
|
|
50
52
|
| [`A11y.FocusFrame`](./docs/components/A11yFocusTrap.md) | Root boundary required by `A11y.FocusTrap`; detects focus escaping the region. |
|
|
51
53
|
| [`A11y.PaneTitle`](./docs/components/A11yPaneTitle.md) | Announces screen or panel transitions to VoiceOver/TalkBack. |
|
|
52
54
|
| [`A11y.ScreenChange`](./docs/components/A11yPaneTitle.md) | Shorthand for `A11y.PaneTitle` with `type="activity"` pre-set. |
|
|
53
|
-
|
|
55
|
+
|
|
56
|
+
**API**
|
|
57
|
+
|
|
58
|
+
| Export | Purpose |
|
|
59
|
+
| :-- | :-- |
|
|
60
|
+
| [`ScreenReader`](./docs/api/ScreenReader.md) | Reliable programmatic announcements for VoiceOver and TalkBack. |
|
|
54
61
|
|
|
55
62
|
---
|
|
56
63
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package com.a11yorder.core;
|
|
2
|
+
|
|
3
|
+
import android.content.Context;
|
|
4
|
+
import android.view.View;
|
|
5
|
+
import android.view.accessibility.AccessibilityEvent;
|
|
6
|
+
|
|
7
|
+
import com.a11yorder.services.focus.A11yFocusDelegate;
|
|
8
|
+
import com.a11yorder.services.focus.A11yFocusProtocol;
|
|
9
|
+
import com.facebook.react.bridge.ReactContext;
|
|
10
|
+
|
|
11
|
+
public class A11yManagedFocusView extends A11yScreenReaderView implements A11yFocusProtocol {
|
|
12
|
+
private final A11yFocusDelegate a11yFocusDelegate;
|
|
13
|
+
|
|
14
|
+
public A11yManagedFocusView(Context context) {
|
|
15
|
+
super(context);
|
|
16
|
+
this.a11yFocusDelegate = new A11yFocusDelegate((ReactContext) context, this);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Override
|
|
20
|
+
public boolean isViewFocused() {
|
|
21
|
+
View focusTarget = this.isFocusable() ? this : this.getSubChild();
|
|
22
|
+
if (focusTarget == null) return false;
|
|
23
|
+
return focusTarget.isAccessibilityFocused();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public void focus() {
|
|
27
|
+
a11yFocusDelegate.requestFocus();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -5,7 +5,7 @@ import android.view.View;
|
|
|
5
5
|
|
|
6
6
|
import com.a11yorder.services.order.A11yOrderService;
|
|
7
7
|
|
|
8
|
-
public class A11yViewOrder extends
|
|
8
|
+
public class A11yViewOrder extends A11yManagedFocusView {
|
|
9
9
|
private final A11yOrderService orderService;
|
|
10
10
|
|
|
11
11
|
public A11yViewOrder(Context context) {
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
package com.a11yorder.modules;
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import android.content.Context;
|
|
5
|
-
import android.content.SharedPreferences;
|
|
6
|
-
import android.util.Log;
|
|
3
|
+
import androidx.annotation.Nullable;
|
|
7
4
|
|
|
8
|
-
import com.
|
|
5
|
+
import com.a11yorder.A11yAnnounceModuleSpec;
|
|
6
|
+
import com.facebook.react.bridge.Promise;
|
|
9
7
|
import com.facebook.react.bridge.ReactApplicationContext;
|
|
10
8
|
import com.facebook.react.bridge.ReactMethod;
|
|
9
|
+
import com.facebook.react.bridge.ReadableMap;
|
|
11
10
|
|
|
12
11
|
public class A11yAnnounceModule extends A11yAnnounceModuleSpec {
|
|
13
12
|
|
|
@@ -22,8 +21,22 @@ public class A11yAnnounceModule extends A11yAnnounceModuleSpec {
|
|
|
22
21
|
return NAME;
|
|
23
22
|
}
|
|
24
23
|
|
|
24
|
+
// stub — announcement is handled by the JS layer on Android
|
|
25
|
+
@Override
|
|
26
|
+
@ReactMethod
|
|
27
|
+
public void announce(String message, @Nullable ReadableMap options, Promise promise) {
|
|
28
|
+
promise.resolve(null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Override
|
|
32
|
+
@ReactMethod
|
|
33
|
+
public void cancel(String id, Promise promise) {
|
|
34
|
+
promise.resolve(null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Override
|
|
25
38
|
@ReactMethod
|
|
26
|
-
public void
|
|
27
|
-
|
|
39
|
+
public void cancelAll(Promise promise) {
|
|
40
|
+
promise.resolve(null);
|
|
28
41
|
}
|
|
29
42
|
}
|
|
@@ -80,12 +80,6 @@ public class A11yIndexViewManager extends com.a11yorder.A11yIndexViewManagerSpec
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
@Override
|
|
84
|
-
@ReactProp(name = "autoFocus")
|
|
85
|
-
public void setAutoFocus(A11yIndexView view, boolean value) {
|
|
86
|
-
view.setAutoFocus(value);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
83
|
@Override
|
|
90
84
|
public void setDescendantFocusChangedEnabled(A11yIndexView view, boolean value) {
|
|
91
85
|
//stub
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
package com.a11yorder;
|
|
2
2
|
|
|
3
|
+
import androidx.annotation.Nullable;
|
|
4
|
+
import com.facebook.react.bridge.Promise;
|
|
3
5
|
import com.facebook.react.bridge.ReactApplicationContext;
|
|
4
6
|
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
|
7
|
+
import com.facebook.react.bridge.ReadableMap;
|
|
5
8
|
|
|
6
9
|
public abstract class A11yAnnounceModuleSpec extends ReactContextBaseJavaModule {
|
|
7
10
|
protected A11yAnnounceModuleSpec(ReactApplicationContext context) {
|
|
8
11
|
super(context);
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
public abstract void announce(String message);
|
|
14
|
+
public abstract void announce(String message, @Nullable ReadableMap options, Promise promise);
|
|
15
|
+
|
|
16
|
+
public abstract void cancel(String id, Promise promise);
|
|
17
|
+
|
|
18
|
+
public abstract void cancelAll(Promise promise);
|
|
12
19
|
}
|
|
@@ -13,7 +13,6 @@ public abstract class A11yIndexViewManagerSpec<T extends A11yIndexView> extends
|
|
|
13
13
|
|
|
14
14
|
public abstract void focus(T view);
|
|
15
15
|
|
|
16
|
-
public abstract void setAutoFocus(A11yIndexView view, boolean value);
|
|
17
16
|
public abstract void setDescendantFocusChangedEnabled(A11yIndexView view, boolean value);
|
|
18
17
|
|
|
19
18
|
public abstract void setContainerType(A11yIndexView view, int value);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//
|
|
2
|
+
// RNAOSpeechAttributes.h
|
|
3
|
+
// react-native-a11y-order
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
#ifndef RNAOSpeechAttributes_h
|
|
7
|
+
#define RNAOSpeechAttributes_h
|
|
8
|
+
|
|
9
|
+
#import <Foundation/Foundation.h>
|
|
10
|
+
#import <UIKit/UIKit.h>
|
|
11
|
+
|
|
12
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds an NSAttributedString suitable for UIAccessibilityPostNotification
|
|
16
|
+
* from a flat options dictionary. Keys consumed:
|
|
17
|
+
*
|
|
18
|
+
* priority NSString "low" | "default" | "high" | "critical"
|
|
19
|
+
* queue NSNumber BOOL (defaults to YES; "critical" forces NO)
|
|
20
|
+
* language NSString BCP-47 tag
|
|
21
|
+
* pitch NSNumber 0.0–2.0 (skipped at 1.0 ± 0.001)
|
|
22
|
+
* spellOut NSNumber BOOL
|
|
23
|
+
* punctuation NSNumber BOOL
|
|
24
|
+
* ipaNotation NSString
|
|
25
|
+
*/
|
|
26
|
+
@interface RNAOSpeechAttributes : NSObject
|
|
27
|
+
|
|
28
|
+
+ (NSAttributedString *)attributedStringFor:(NSString *)message
|
|
29
|
+
options:(NSDictionary *)options;
|
|
30
|
+
|
|
31
|
+
@end
|
|
32
|
+
|
|
33
|
+
NS_ASSUME_NONNULL_END
|
|
34
|
+
|
|
35
|
+
#endif /* RNAOSpeechAttributes_h */
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//
|
|
2
|
+
// RNAOSpeechAttributes.mm
|
|
3
|
+
// react-native-a11y-order
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
#import "RNAOSpeechAttributes.h"
|
|
7
|
+
|
|
8
|
+
@implementation RNAOSpeechAttributes
|
|
9
|
+
|
|
10
|
+
+ (NSAttributedString *)attributedStringFor:(NSString *)message
|
|
11
|
+
options:(NSDictionary *)options
|
|
12
|
+
{
|
|
13
|
+
NSMutableDictionary<NSAttributedStringKey, id> *attrs = [NSMutableDictionary new];
|
|
14
|
+
|
|
15
|
+
BOOL shouldQueue = options[@"queue"] ? [options[@"queue"] boolValue] : YES;
|
|
16
|
+
|
|
17
|
+
if (@available(iOS 11.0, *)) {
|
|
18
|
+
attrs[UIAccessibilitySpeechAttributeQueueAnnouncement] = @(shouldQueue);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (@available(iOS 17.0, *)) {
|
|
22
|
+
NSString *priority = options[@"priority"] ?: @"default";
|
|
23
|
+
UIAccessibilityPriority nativePriority = UIAccessibilityPriorityDefault;
|
|
24
|
+
if ([priority isEqualToString:@"low"])
|
|
25
|
+
nativePriority = UIAccessibilityPriorityLow;
|
|
26
|
+
else if ([priority isEqualToString:@"high"])
|
|
27
|
+
nativePriority = UIAccessibilityPriorityHigh;
|
|
28
|
+
attrs[UIAccessibilitySpeechAttributeAnnouncementPriority] = nativePriority;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
id langVal = options[@"language"];
|
|
32
|
+
if ([langVal isKindOfClass:[NSString class]]) {
|
|
33
|
+
attrs[UIAccessibilitySpeechAttributeLanguage] = langVal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Pitch only written when it deviates from default 1.0.
|
|
37
|
+
id pitchVal = options[@"pitch"];
|
|
38
|
+
if (pitchVal && ![pitchVal isEqual:[NSNull null]]) {
|
|
39
|
+
CGFloat raw = [pitchVal floatValue];
|
|
40
|
+
if (fabs(raw - 1.0f) > 0.001f) {
|
|
41
|
+
attrs[UIAccessibilitySpeechAttributePitch] = @(MAX(0.0f, MIN(2.0f, raw)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (@available(iOS 13.0, *)) {
|
|
46
|
+
if ([options[@"spellOut"] boolValue]) {
|
|
47
|
+
attrs[UIAccessibilitySpeechAttributeSpellOut] = @YES;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (@available(iOS 11.0, *)) {
|
|
52
|
+
if ([options[@"punctuation"] boolValue]) {
|
|
53
|
+
attrs[UIAccessibilitySpeechAttributePunctuation] = @YES;
|
|
54
|
+
}
|
|
55
|
+
id ipaVal = options[@"ipaNotation"];
|
|
56
|
+
if ([ipaVal isKindOfClass:[NSString class]]) {
|
|
57
|
+
attrs[UIAccessibilitySpeechAttributeIPANotation] = ipaVal;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [[NSAttributedString alloc] initWithString:message attributes:attrs];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@end
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
//
|
|
2
2
|
// RNAOA11yAnnounceModule.h
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// Created by Artur Kalach on 06/12/2025.
|
|
3
|
+
// react-native-a11y-order
|
|
6
4
|
//
|
|
7
5
|
|
|
8
6
|
#ifndef RNAOA11yAnnounceModule_h
|
|
9
7
|
#define RNAOA11yAnnounceModule_h
|
|
10
8
|
|
|
11
|
-
|
|
12
9
|
#import <Foundation/Foundation.h>
|
|
13
10
|
|
|
14
11
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
@@ -16,19 +13,26 @@
|
|
|
16
13
|
|
|
17
14
|
@interface RNAOA11yAnnounceModule : NSObject <NativeA11yAnnounceModuleSpec>
|
|
18
15
|
|
|
19
|
-
@end
|
|
20
|
-
|
|
21
16
|
#else
|
|
22
17
|
|
|
23
18
|
#import <React/RCTBridgeModule.h>
|
|
24
19
|
|
|
25
|
-
|
|
26
20
|
@interface RNAOA11yAnnounceModule : NSObject <RCTBridgeModule>
|
|
27
21
|
|
|
28
|
-
- (void)announce:(NSString *)message
|
|
22
|
+
- (void)announce:(NSString *)message
|
|
23
|
+
options:(NSDictionary *)options
|
|
24
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
25
|
+
reject:(RCTPromiseRejectBlock)reject;
|
|
29
26
|
|
|
30
|
-
|
|
27
|
+
- (void)cancel:(NSString *)announcementId
|
|
28
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
29
|
+
reject:(RCTPromiseRejectBlock)reject;
|
|
30
|
+
|
|
31
|
+
- (void)cancelAll:(RCTPromiseResolveBlock)resolve
|
|
32
|
+
reject:(RCTPromiseRejectBlock)reject;
|
|
31
33
|
|
|
32
34
|
#endif
|
|
33
35
|
|
|
36
|
+
@end
|
|
37
|
+
|
|
34
38
|
#endif /* RNAOA11yAnnounceModule_h */
|
|
@@ -1,40 +1,246 @@
|
|
|
1
1
|
//
|
|
2
|
-
// RNAOA11yAnnounceModule.
|
|
2
|
+
// RNAOA11yAnnounceModule.mm
|
|
3
3
|
// react-native-a11y-order
|
|
4
4
|
//
|
|
5
|
-
// Created by Artur Kalach on 06/12/2025.
|
|
6
|
-
//
|
|
7
5
|
|
|
8
6
|
#import <Foundation/Foundation.h>
|
|
7
|
+
#import <UIKit/UIKit.h>
|
|
9
8
|
#import "RNAOA11yAnnounceModule.h"
|
|
10
9
|
#import "RNAOA11yAnnounceService.h"
|
|
10
|
+
#import "RNAOSpeechAttributes.h"
|
|
11
11
|
|
|
12
12
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
13
|
-
|
|
14
13
|
using namespace facebook::react;
|
|
15
|
-
|
|
16
14
|
#endif
|
|
17
15
|
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// MARK: - Pending entry
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
@interface RNAOPendingAnnouncement : NSObject
|
|
21
|
+
@property (nonatomic, copy) RCTPromiseResolveBlock resolve;
|
|
22
|
+
@property (nonatomic, copy) NSString *text; // for direct-mode finish-notification matching
|
|
23
|
+
@property (nonatomic, assign) BOOL isDirect;
|
|
24
|
+
@end
|
|
25
|
+
@implementation RNAOPendingAnnouncement
|
|
26
|
+
@end
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// MARK: - Private interface
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
@interface RNAOA11yAnnounceModule ()
|
|
34
|
+
|
|
35
|
+
// Single source of truth: announcementId → entry.
|
|
36
|
+
@property (nonatomic, strong) NSMutableDictionary<NSString *, RNAOPendingAnnouncement *> *pending;
|
|
37
|
+
|
|
38
|
+
// At most one direct announcement is in-flight; pointer into `pending`.
|
|
39
|
+
@property (nonatomic, copy, nullable) NSString *currentDirectId;
|
|
40
|
+
|
|
41
|
+
@end
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
// MARK: - Implementation
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
18
48
|
@implementation RNAOA11yAnnounceModule
|
|
19
49
|
|
|
50
|
+
+ (BOOL)requiresMainQueueSetup { return YES; }
|
|
20
51
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
52
|
+
RCT_EXPORT_MODULE(A11yAnnounceModule);
|
|
53
|
+
|
|
54
|
+
- (instancetype)init {
|
|
55
|
+
if (self = [super init]) {
|
|
56
|
+
_pending = [NSMutableDictionary new];
|
|
57
|
+
[[NSNotificationCenter defaultCenter]
|
|
58
|
+
addObserver:self
|
|
59
|
+
selector:@selector(_handleAnnouncementFinished:)
|
|
60
|
+
name:UIAccessibilityAnnouncementDidFinishNotification
|
|
61
|
+
object:nil];
|
|
62
|
+
}
|
|
63
|
+
return self;
|
|
24
64
|
}
|
|
25
65
|
|
|
66
|
+
- (void)dealloc {
|
|
67
|
+
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
68
|
+
}
|
|
26
69
|
|
|
27
|
-
RCT_EXPORT_MODULE(A11yAnnounceModule);
|
|
28
70
|
|
|
29
|
-
|
|
30
|
-
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// MARK: - announce (arch-split: C++ struct vs NSDictionary)
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* calm: true — Routes through RNAOA11yAnnounceService (300 ms debounce +
|
|
77
|
+
* navigation-lock + VoiceOver-state guard). Promise resolves
|
|
78
|
+
* when the service **actually fires** the announcement, not just
|
|
79
|
+
* when it is enqueued, so `await` is meaningful.
|
|
80
|
+
*
|
|
81
|
+
* calm: false — Posts via UIAccessibilityPostNotification with speech attrs.
|
|
82
|
+
* Promise resolves when UIAccessibilityAnnouncementDidFinishNotification
|
|
83
|
+
* fires ('spoken') or the announcement is interrupted ('fired').
|
|
84
|
+
*/
|
|
85
|
+
- (void)_announceMessage:(NSString *)message
|
|
86
|
+
opts:(NSDictionary *)opts
|
|
87
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
88
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
89
|
+
{
|
|
90
|
+
if (!message || [message stringByTrimmingCharactersInSet:
|
|
91
|
+
[NSCharacterSet whitespaceAndNewlineCharacterSet]].length == 0) {
|
|
92
|
+
message = @"";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
NSString *announcementId = [[NSUUID UUID] UUIDString];
|
|
96
|
+
BOOL calm = [opts[@"calm"] boolValue];
|
|
97
|
+
|
|
98
|
+
RNAOPendingAnnouncement *entry = [RNAOPendingAnnouncement new];
|
|
99
|
+
entry.resolve = resolve;
|
|
100
|
+
entry.text = message;
|
|
101
|
+
entry.isDirect = !calm;
|
|
102
|
+
self.pending[announcementId] = entry;
|
|
103
|
+
|
|
104
|
+
if (calm) {
|
|
105
|
+
__weak RNAOA11yAnnounceModule *weakSelf = self;
|
|
106
|
+
[[RNAOA11yAnnounceService shared] announce:message onFired:^{
|
|
107
|
+
// Guard against cancel:id that already removed the entry — avoids double-resolve.
|
|
108
|
+
[weakSelf _resolveId:announcementId status:@"fired"];
|
|
109
|
+
}];
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Direct mode: at most one in-flight; supersede any previous.
|
|
114
|
+
if (self.currentDirectId && ![self.currentDirectId isEqualToString:announcementId]) {
|
|
115
|
+
[self _resolveId:self.currentDirectId status:@"fired"];
|
|
116
|
+
}
|
|
117
|
+
self.currentDirectId = announcementId;
|
|
118
|
+
|
|
119
|
+
NSAttributedString *attrStr = [RNAOSpeechAttributes attributedStringFor:message options:opts];
|
|
120
|
+
NSTimeInterval delayMs = [opts[@"delayMs"] doubleValue];
|
|
121
|
+
|
|
122
|
+
dispatch_block_t post = ^{
|
|
123
|
+
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, attrStr);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (delayMs > 0) {
|
|
127
|
+
dispatch_after(
|
|
128
|
+
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayMs * NSEC_PER_MSEC)),
|
|
129
|
+
dispatch_get_main_queue(),
|
|
130
|
+
post
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
dispatch_async(dispatch_get_main_queue(), post);
|
|
134
|
+
}
|
|
31
135
|
}
|
|
32
136
|
|
|
33
137
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
34
|
-
|
|
35
|
-
|
|
138
|
+
|
|
139
|
+
- (void)announce:(NSString *)message
|
|
140
|
+
options:(JS::NativeA11yAnnounceModule::AnnounceOptions &)options
|
|
141
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
142
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
143
|
+
{
|
|
144
|
+
NSMutableDictionary *opts = [NSMutableDictionary new];
|
|
145
|
+
|
|
146
|
+
if (options.calm().has_value()) opts[@"calm"] = @(*options.calm());
|
|
147
|
+
if (options.priority()) opts[@"priority"] = options.priority();
|
|
148
|
+
if (options.queue().has_value()) opts[@"queue"] = @(*options.queue());
|
|
149
|
+
if (options.delayMs().has_value()) opts[@"delayMs"] = @(*options.delayMs());
|
|
150
|
+
// Speech options (flat at bridge level, grouped under `speech` in JS API)
|
|
151
|
+
if (options.language()) opts[@"language"] = options.language();
|
|
152
|
+
if (options.pitch().has_value()) opts[@"pitch"] = @(*options.pitch());
|
|
153
|
+
if (options.spellOut().has_value()) opts[@"spellOut"] = @(*options.spellOut());
|
|
154
|
+
if (options.punctuation().has_value()) opts[@"punctuation"] = @(*options.punctuation());
|
|
155
|
+
if (options.ipaNotation()) opts[@"ipaNotation"] = options.ipaNotation();
|
|
156
|
+
|
|
157
|
+
[self _announceMessage:message opts:opts resolve:resolve reject:reject];
|
|
36
158
|
}
|
|
37
159
|
|
|
160
|
+
#else
|
|
161
|
+
|
|
162
|
+
RCT_EXPORT_METHOD(announce:(NSString *)message
|
|
163
|
+
options:(NSDictionary *)options
|
|
164
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
165
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
166
|
+
{
|
|
167
|
+
NSDictionary *opts = ([options isKindOfClass:[NSDictionary class]]) ? options : @{};
|
|
168
|
+
[self _announceMessage:message opts:opts resolve:resolve reject:reject];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#endif // RCT_NEW_ARCH_ENABLED
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
175
|
+
// MARK: - cancel / cancelAll
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
RCT_EXPORT_METHOD(cancel:(NSString *)announcementId
|
|
179
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
180
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
181
|
+
{
|
|
182
|
+
[self _resolveId:announcementId status:@"cancelled"];
|
|
183
|
+
resolve(@{ @"id": announcementId ?: @"", @"status": @"cancelled" });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
RCT_EXPORT_METHOD(cancelAll:(RCTPromiseResolveBlock)resolve
|
|
187
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
188
|
+
{
|
|
189
|
+
[[RNAOA11yAnnounceService shared] cancelAll];
|
|
190
|
+
|
|
191
|
+
NSDictionary<NSString *, RNAOPendingAnnouncement *> *snapshot = [self.pending copy];
|
|
192
|
+
[self.pending removeAllObjects];
|
|
193
|
+
self.currentDirectId = nil;
|
|
194
|
+
|
|
195
|
+
for (NSString *aid in snapshot) {
|
|
196
|
+
snapshot[aid].resolve(@{ @"id": aid, @"status": @"cancelled" });
|
|
197
|
+
}
|
|
198
|
+
resolve(@{ @"id": @"", @"status": @"cancelled" });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
203
|
+
// MARK: - VoiceOver finish notification (direct mode only)
|
|
204
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
- (void)_handleAnnouncementFinished:(NSNotification *)note {
|
|
207
|
+
NSString *directId = self.currentDirectId;
|
|
208
|
+
if (!directId) return;
|
|
209
|
+
|
|
210
|
+
RNAOPendingAnnouncement *entry = self.pending[directId];
|
|
211
|
+
if (!entry) return;
|
|
212
|
+
|
|
213
|
+
NSString *spokenText = note.userInfo[UIAccessibilityAnnouncementKeyStringValue];
|
|
214
|
+
if (![entry.text isEqualToString:spokenText]) return;
|
|
215
|
+
|
|
216
|
+
BOOL wasSuccessful = [note.userInfo[UIAccessibilityAnnouncementKeyWasSuccessful] boolValue];
|
|
217
|
+
[self _resolveId:directId status:wasSuccessful ? @"spoken" : @"fired"];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
// MARK: - Resolve helper
|
|
223
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
- (void)_resolveId:(NSString *)announcementId status:(NSString *)status {
|
|
226
|
+
if (!announcementId) return;
|
|
227
|
+
RNAOPendingAnnouncement *entry = self.pending[announcementId];
|
|
228
|
+
if (!entry) return;
|
|
229
|
+
|
|
230
|
+
[self.pending removeObjectForKey:announcementId];
|
|
231
|
+
if ([self.currentDirectId isEqualToString:announcementId]) {
|
|
232
|
+
self.currentDirectId = nil;
|
|
233
|
+
}
|
|
234
|
+
entry.resolve(@{ @"id": announcementId, @"status": status });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
#ifdef RCT_NEW_ARCH_ENABLED
|
|
239
|
+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
240
|
+
(const facebook::react::ObjCTurboModule::InitParams &)params
|
|
241
|
+
{
|
|
242
|
+
return std::make_shared<facebook::react::NativeA11yAnnounceModuleSpecJSI>(params);
|
|
243
|
+
}
|
|
38
244
|
#endif
|
|
39
|
-
@end
|
|
40
245
|
|
|
246
|
+
@end
|
|
@@ -15,10 +15,20 @@
|
|
|
15
15
|
|
|
16
16
|
+ (instancetype)shared;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Enqueues `announcement` and schedules a navigation-aware debounced post.
|
|
20
|
+
* `onFired` is called on the main queue when the service actually speaks
|
|
21
|
+
* the announcement. Pass nil if no completion callback is needed.
|
|
22
|
+
* If cancelAll is called before the service fires, `onFired` is NOT called.
|
|
23
|
+
*/
|
|
24
|
+
- (void)announce:(NSString *)announcement onFired:(nullable dispatch_block_t)onFired;
|
|
25
|
+
|
|
26
|
+
/** Convenience — equivalent to announce:onFired:nil. */
|
|
18
27
|
- (void)announce:(NSString *)announcement;
|
|
28
|
+
|
|
29
|
+
- (void)cancelAll;
|
|
19
30
|
- (void)temporarilyLockAnnounce;
|
|
20
31
|
- (void)temporarilyLockAnnounce:(NSTimeInterval)interval;
|
|
21
|
-
//@property (nonatomic, assign) BOOL announceLock;
|
|
22
32
|
|
|
23
33
|
@end
|
|
24
34
|
|