react-native-a11y-order 0.11.0-rc → 0.11.0-rc1

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.
Files changed (79) hide show
  1. package/README.md +8 -1
  2. package/android/src/main/java/com/a11yorder/core/A11yManagedFocusView.java +29 -0
  3. package/android/src/main/java/com/a11yorder/core/A11yViewOrder.java +1 -1
  4. package/android/src/main/java/com/a11yorder/modules/A11yAnnounceModule.java +20 -7
  5. package/android/src/main/java/com/a11yorder/views/A11yIndexView/A11yIndexViewManager.java +0 -6
  6. package/android/src/oldarch/A11yAnnounceModuleSpec.java +8 -1
  7. package/android/src/oldarch/A11yIndexViewManagerSpec.java +0 -1
  8. package/ios/helpers/RNAOSpeechAttributes.h +35 -0
  9. package/ios/helpers/RNAOSpeechAttributes.mm +64 -0
  10. package/ios/modules/RNAOA11yAnnounceModule.h +13 -9
  11. package/ios/modules/RNAOA11yAnnounceModule.mm +220 -14
  12. package/ios/services/RNAOA11yAnnounceService/RNAOA11yAnnounceService.h +11 -1
  13. package/ios/services/RNAOA11yAnnounceService/RNAOA11yAnnounceService.mm +55 -51
  14. package/ios/views/RNAOA11yIndexView/RNAOA11yIndexView.mm +0 -4
  15. package/ios/views/RNAOA11yIndexView/RNAOA11yIndexViewManager.mm +0 -6
  16. package/ios/views/RNAOA11yPaneTitleView/RNAOA11yPaneTitleView.mm +12 -2
  17. package/ios/views/base/{RNAOA11yAutoFocusView.h → RNAOA11yManagedFocusView.h} +5 -7
  18. package/ios/views/base/{RNAOA11yAutoFocusView.mm → RNAOA11yManagedFocusView.mm} +2 -19
  19. package/ios/views/base/RNAOA11yViewOrder.h +3 -3
  20. package/lib/commonjs/index.js +22 -4
  21. package/lib/commonjs/index.js.map +1 -1
  22. package/lib/commonjs/index.web.js +4 -4
  23. package/lib/commonjs/modules/A11yAnnounceModule.android.js +37 -3
  24. package/lib/commonjs/modules/A11yAnnounceModule.android.js.map +1 -1
  25. package/lib/commonjs/modules/A11yAnnounceModule.js +66 -8
  26. package/lib/commonjs/modules/A11yAnnounceModule.js.map +1 -1
  27. package/lib/commonjs/nativeSpecs/A11yIndexNativeComponent.ts +0 -1
  28. package/lib/commonjs/nativeSpecs/NativeA11yAnnounceModule.js +2 -0
  29. package/lib/commonjs/nativeSpecs/NativeA11yAnnounceModule.js.map +1 -1
  30. package/lib/module/index.js +1 -1
  31. package/lib/module/index.js.map +1 -1
  32. package/lib/module/index.web.js +1 -1
  33. package/lib/module/index.web.js.map +1 -1
  34. package/lib/module/modules/A11yAnnounceModule.android.js +32 -2
  35. package/lib/module/modules/A11yAnnounceModule.android.js.map +1 -1
  36. package/lib/module/modules/A11yAnnounceModule.js +62 -7
  37. package/lib/module/modules/A11yAnnounceModule.js.map +1 -1
  38. package/lib/module/nativeSpecs/A11yIndexNativeComponent.ts +0 -1
  39. package/lib/module/nativeSpecs/NativeA11yAnnounceModule.js +4 -0
  40. package/lib/module/nativeSpecs/NativeA11yAnnounceModule.js.map +1 -1
  41. package/lib/typescript/src/components/A11yIndex/A11yIndex.d.ts +3 -126
  42. package/lib/typescript/src/components/A11yIndex/A11yIndex.d.ts.map +1 -1
  43. package/lib/typescript/src/components/A11yIndex/A11yIndex.types.d.ts +0 -4
  44. package/lib/typescript/src/components/A11yIndex/A11yIndex.types.d.ts.map +1 -1
  45. package/lib/typescript/src/components/A11yIndex/A11yIndex.web.d.ts +1 -125
  46. package/lib/typescript/src/components/A11yIndex/A11yIndex.web.d.ts.map +1 -1
  47. package/lib/typescript/src/components/A11yOrder/A11yOrder.d.ts +1 -124
  48. package/lib/typescript/src/components/A11yOrder/A11yOrder.d.ts.map +1 -1
  49. package/lib/typescript/src/components/A11yView/A11yView.d.ts +2 -126
  50. package/lib/typescript/src/components/A11yView/A11yView.d.ts.map +1 -1
  51. package/lib/typescript/src/index.d.ts +7 -377
  52. package/lib/typescript/src/index.d.ts.map +1 -1
  53. package/lib/typescript/src/index.web.d.ts +11 -162
  54. package/lib/typescript/src/index.web.d.ts.map +1 -1
  55. package/lib/typescript/src/modules/A11yAnnounceModule.android.d.ts +25 -2
  56. package/lib/typescript/src/modules/A11yAnnounceModule.android.d.ts.map +1 -1
  57. package/lib/typescript/src/modules/A11yAnnounceModule.d.ts +112 -4
  58. package/lib/typescript/src/modules/A11yAnnounceModule.d.ts.map +1 -1
  59. package/lib/typescript/src/nativeSpecs/A11yCardNativeComponent.d.ts +1 -3
  60. package/lib/typescript/src/nativeSpecs/A11yCardNativeComponent.d.ts.map +1 -1
  61. package/lib/typescript/src/nativeSpecs/A11yIndexNativeComponent.d.ts +1 -4
  62. package/lib/typescript/src/nativeSpecs/A11yIndexNativeComponent.d.ts.map +1 -1
  63. package/lib/typescript/src/nativeSpecs/A11yLockNativeComponent.d.ts +1 -3
  64. package/lib/typescript/src/nativeSpecs/A11yLockNativeComponent.d.ts.map +1 -1
  65. package/lib/typescript/src/nativeSpecs/A11yOrderNativeComponent.d.ts +1 -3
  66. package/lib/typescript/src/nativeSpecs/A11yOrderNativeComponent.d.ts.map +1 -1
  67. package/lib/typescript/src/nativeSpecs/A11yPaneTitleNativeComponent.d.ts +1 -3
  68. package/lib/typescript/src/nativeSpecs/A11yPaneTitleNativeComponent.d.ts.map +1 -1
  69. package/lib/typescript/src/nativeSpecs/NativeA11yAnnounceModule.d.ts +22 -2
  70. package/lib/typescript/src/nativeSpecs/NativeA11yAnnounceModule.d.ts.map +1 -1
  71. package/package.json +1 -1
  72. package/src/components/A11yIndex/A11yIndex.types.ts +0 -5
  73. package/src/index.ts +12 -1
  74. package/src/index.web.ts +1 -1
  75. package/src/modules/A11yAnnounceModule.android.ts +18 -2
  76. package/src/modules/A11yAnnounceModule.ts +153 -9
  77. package/src/nativeSpecs/A11yIndexNativeComponent.ts +0 -1
  78. package/src/nativeSpecs/NativeA11yAnnounceModule.ts +31 -1
  79. package/android/src/main/java/com/a11yorder/core/A11yAutoFocusView.java +0 -61
package/README.md CHANGED
@@ -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
- | [`A11yModule`](./docs/leftovers/announce.md) | Reliable programmatic announcements on iOS. |
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 A11yAutoFocusView {
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 com.a11yorder.A11yAnnounceModuleSpec;
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.facebook.proguard.annotations.DoNotStrip;
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 announce(String message) {
27
- //stub
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
- // Pods
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
- @end
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.m
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
- + (BOOL)requiresMainQueueSetup
22
- {
23
- return YES;
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
- RCT_EXPORT_METHOD(announce: (nonnull NSString*) title) {
30
- [[RNAOA11yAnnounceService shared] announce: title];
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
- - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
35
- return std::make_shared<facebook::react::NativeA11yAnnounceModuleSpecJSI>(params);
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