basalt-appkit 0.0.0-rc5 → 0.2.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.
Files changed (43) hide show
  1. package/native/CMakeLists.txt +16 -2
  2. package/native/appkit/AppKitAppWindow.h +24 -0
  3. package/native/appkit/AppKitImageLoader.h +48 -1
  4. package/native/appkit/AppKitImageLoader.mm +228 -10
  5. package/native/appkit/AppKitMenuBar.mm +58 -0
  6. package/native/appkit/AppKitMountingManager.h +34 -0
  7. package/native/appkit/AppKitMountingManager.mm +582 -12
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitSkiaPeer.h +15 -0
  11. package/native/appkit/AppKitTextInput.h +38 -1
  12. package/native/appkit/AppKitTextInput.mm +235 -8
  13. package/native/appkit/AppKitTextPeer.h +60 -0
  14. package/native/appkit/AppKitTextPeer.mm +307 -0
  15. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  16. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  17. package/native/appkit/AppKitWindowControl.mm +38 -3
  18. package/native/appkit/ComponentRegistryAppKit.mm +28 -3
  19. package/native/appkit/CoreTextLayout.h +64 -2
  20. package/native/appkit/CoreTextLayout.mm +431 -71
  21. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  22. package/native/appkit/FontRegistryCoreText.mm +6 -5
  23. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  24. package/native/appkit/RnAppKitView.h +492 -0
  25. package/native/appkit/RnAppKitView.mm +2266 -23
  26. package/native/appkit/RnTextLayout.h +106 -0
  27. package/native/appkit/RnTextLayout.mm +321 -2
  28. package/native/appkit/main_appkit.mm +174 -3
  29. package/native/tests/test_appkit_accessibility.mm +289 -0
  30. package/native/tests/test_appkit_borders.mm +975 -0
  31. package/native/tests/test_appkit_fonts.mm +203 -0
  32. package/native/tests/test_appkit_image.mm +392 -17
  33. package/native/tests/test_appkit_input.mm +209 -0
  34. package/native/tests/test_appkit_mounting.mm +360 -0
  35. package/native/tests/test_appkit_scrollbar.mm +125 -0
  36. package/native/tests/test_appkit_scrollview.mm +371 -0
  37. package/native/tests/test_appkit_selection.mm +349 -0
  38. package/native/tests/test_appkit_services.mm +51 -1
  39. package/native/tests/test_appkit_text.mm +1628 -0
  40. package/native/tests/test_appkit_textinput.mm +819 -0
  41. package/native/tests/test_appkit_titlebar.mm +43 -0
  42. package/native/tests/test_appkit_view.mm +609 -0
  43. package/package.json +4 -3
@@ -21,7 +21,7 @@
21
21
  // touches a view off the main thread.
22
22
  //
23
23
  // What this host can render is what AppKitMountingManager can mount, which today
24
- // is <View>. `js/demo.js` -- a surface driven straight through
24
+ // is <View>. `e2e/demo.js` -- a surface driven straight through
25
25
  // nativeFabricUIManager, with no React and no react-native JavaScript -- is
26
26
  // therefore the bundle this runs, and is the same first light-up the GTK host
27
27
  // had. A React app needs the JavaScript platform layer to answer to a name
@@ -29,9 +29,13 @@
29
29
 
30
30
  #import "AppKitAnimationChoreographer.h"
31
31
  #import "AppKitMountingManager.h"
32
+ #import "CoreTextLayout.h"
33
+
34
+ #include "FontScaling.h"
32
35
 
33
36
  #include <react/io/ImageLoaderModule.h>
34
37
 
38
+ #include "CrashHandler.h"
35
39
  #include "AppIdentity.h"
36
40
  #include "AppPaths.h"
37
41
  #include "DevMenu.h"
@@ -52,6 +56,7 @@
52
56
  #import "AppKitFocus.h"
53
57
  #import "AppKitTouchDispatcher.h"
54
58
  #import "AppKitSnapshot.h"
59
+ #include "SystemLocale.h"
55
60
  #import "RnAppKitView.h"
56
61
 
57
62
  #include "AppearanceModule.h"
@@ -72,6 +77,8 @@
72
77
  #include <csignal>
73
78
  #include "ExpoModules.h"
74
79
  #include "GestureHandlerModule.h"
80
+ #include "SafeAreaComponent.h"
81
+ #include "ScreensComponent.h"
75
82
  #include "ReanimatedModule.h"
76
83
  #include "JsRuntimeAccess.h"
77
84
  #include "KeyEvents.h"
@@ -131,7 +138,7 @@ constexpr int kInitialHeight = 700;
131
138
 
132
139
  // The entry point for the *scriptless* mode, where the bundle is a hand-written
133
140
  // script talking to nativeFabricUIManager directly rather than a React app.
134
- // See js/demo.js. Only used when no module name is given.
141
+ // See e2e/demo.js. Only used when no module name is given.
135
142
  constexpr const char *kRenderFunctionName = "basaltRender";
136
143
 
137
144
  // One window, and the surface in it.
@@ -243,7 +250,14 @@ LayoutConstraints constraintsFor(int width, int height) {
243
250
  return LayoutConstraints{
244
251
  .minimumSize = size,
245
252
  .maximumSize = size,
246
- .layoutDirection = LayoutDirection::LeftToRight,
253
+ // The desktop's own reading direction, or whatever `I18nManager` was
254
+ // told. This was `LeftToRight` unconditionally until 2026-10-10, so an
255
+ // app in Arabic -- or one calling `forceRTL(true)` and reloading, which
256
+ // is what React Native's documentation says to do -- laid out
257
+ // left-to-right with a flag that claimed otherwise. See
258
+ // core/SystemLocale.h.
259
+ .layoutDirection = basalt::layoutIsRightToLeft() ? LayoutDirection::RightToLeft
260
+ : LayoutDirection::LeftToRight,
247
261
  };
248
262
  }
249
263
 
@@ -327,9 +341,26 @@ facebook::react::TurboModuleProviders makeTurboModuleProviders(
327
341
  if (name == facebook::react::ImageLoaderModule::kModuleName) {
328
342
  return std::make_shared<facebook::react::ImageLoaderModule>(jsInvoker, imageLoader);
329
343
  }
344
+ // `Dimensions` and `PixelRatio`, which ReactCxxPlatform answers with a
345
+ // hardcoded 1280x720 and a zero scale. Ours answers with the window
346
+ // this host actually made; see core/DeviceMetrics.h.
347
+ // `AppState`, whose every method upstream leaves empty. See
348
+ // core/AppStateHost.h.
349
+ if (name == basalt::DesktopAppStateModule::kModuleName) {
350
+ return std::make_shared<basalt::DesktopAppStateModule>(jsInvoker);
351
+ }
352
+ if (name == basalt::DesktopDeviceInfoModule::kModuleName) {
353
+ return std::make_shared<basalt::DesktopDeviceInfoModule>(jsInvoker);
354
+ }
330
355
  if (name == basalt::DesktopAppModule::kModuleName) {
331
356
  return std::make_shared<basalt::DesktopAppModule>(jsInvoker);
332
357
  }
358
+ if (name == basalt::DesktopSafeAreaModule::kModuleName) {
359
+ return std::make_shared<basalt::DesktopSafeAreaModule>(jsInvoker);
360
+ }
361
+ if (name == basalt::DesktopScreensModule::kModuleName) {
362
+ return std::make_shared<basalt::DesktopScreensModule>(jsInvoker);
363
+ }
333
364
  if (name == basalt::DesktopAppearanceModule::kModuleName) {
334
365
  return std::make_shared<basalt::DesktopAppearanceModule>(jsInvoker);
335
366
  }
@@ -354,6 +385,15 @@ facebook::react::TurboModuleProviders makeTurboModuleProviders(
354
385
  if (name == basalt::DesktopShareModule::kModuleName) {
355
386
  return std::make_shared<basalt::DesktopShareModule>(jsInvoker);
356
387
  }
388
+ // `Settings`, kept in a file beside the app's identity rather than in
389
+ // this desktop's own settings store; see core/SettingsStore.h.
390
+ if (name == basalt::DesktopSettingsManagerModule::kModuleName) {
391
+ return std::make_shared<basalt::DesktopSettingsManagerModule>(jsInvoker);
392
+ }
393
+ // `ActionSheetIOS`, which is a popup menu here; see core/ActionSheet.h.
394
+ if (name == basalt::DesktopActionSheetModule::kModuleName) {
395
+ return std::make_shared<basalt::DesktopActionSheetModule>(jsInvoker);
396
+ }
357
397
  // The native file dialogs, which React Native has no API for and which
358
398
  // are the first thing a desktop app reaches for. See core/DialogModule.h.
359
399
  if (name == basalt::DesktopDialogModule::kModuleName) {
@@ -516,6 +556,65 @@ void dumpMenuIfRequested() {
516
556
  }
517
557
  }
518
558
 
559
+ // BASALT_SNAPSHOT: write the window as a PNG on the way out.
560
+ //
561
+ // The tree dump below says a view *has* a colour, a frame and a transform. It
562
+ // cannot say that the right pixels reached the screen, and the two bugs found
563
+ // on 2026-10-10 were both of that kind: `padding` on a `<Text>` laid the box out
564
+ // and drew the text in the corner, and an inline `<View>` stayed where the words
565
+ // used to be. Both were found by looking at a picture of React Native's own
566
+ // LogBox, which is an argument for being able to take one without a camera.
567
+ //
568
+ // One file per surface, the way the dump writes one section per surface: the
569
+ // app's window at the path given, and a second window or the error inspector
570
+ // beside it as `<name>.window<n>.png` and `<name>.logbox.png`. LogBox is a root
571
+ // of its own, and it is the screen most worth looking at -- React Native's own
572
+ // interface, drawn by this platform's text and layout.
573
+ //
574
+ // `RnAppKitWriteSnapshot` is the renderer the demo and the mount harness
575
+ // already use, and it needs no window server: a mounted tree is rendered
576
+ // through its own layer tree, a detached one gets an offscreen window.
577
+ // `/tmp/out.png` and "logbox" become `/tmp/out.logbox.png`. The suffix goes
578
+ // before the extension so the file is still a PNG to everything that looks at
579
+ // its name.
580
+ NSString *snapshotSibling(NSString *path, NSString *suffix) {
581
+ NSString *extension = path.pathExtension;
582
+ if (extension.length == 0) {
583
+ return [path stringByAppendingFormat:@".%@", suffix];
584
+ }
585
+ return [[path.stringByDeletingPathExtension stringByAppendingFormat:@".%@", suffix]
586
+ stringByAppendingPathExtension:extension];
587
+ }
588
+
589
+ void writeSnapshotOf(RnAppKitView *root, NSString *path) {
590
+ if (root == nil) {
591
+ return;
592
+ }
593
+ if (RnAppKitWriteSnapshot(root, path)) {
594
+ NSLog(@"wrote a snapshot to %@", path);
595
+ } else {
596
+ NSLog(@"could not write %@", path);
597
+ }
598
+ }
599
+
600
+ void snapshotIfRequested() {
601
+ const char *path = getenv("BASALT_SNAPSHOT");
602
+ if (path == nullptr) {
603
+ return;
604
+ }
605
+ NSString *base = [NSString stringWithUTF8String:path];
606
+ writeSnapshotOf(gHost.main().root, base);
607
+ for (const auto &other : gHost.windows) {
608
+ if (other->surfaceId == kSurfaceId || other->root == nil) {
609
+ continue;
610
+ }
611
+ writeSnapshotOf(other->root,
612
+ snapshotSibling(base, [NSString stringWithFormat:@"window%d",
613
+ (int)other->surfaceId]));
614
+ }
615
+ writeSnapshotOf(gHost.logBoxRoot, snapshotSibling(base, @"logbox"));
616
+ }
617
+
519
618
  void dumpTreeIfRequested() {
520
619
  const char *path = getenv("BASALT_DUMP_TREE");
521
620
  if (path == nullptr || gHost.main().root == nil) {
@@ -616,6 +715,7 @@ void endAnyOpenSheets() {
616
715
  void shutdown() {
617
716
  // Before the surface stops, which tears the tree down.
618
717
  dumpTreeIfRequested();
718
+ snapshotIfRequested();
619
719
  dumpMenuIfRequested();
620
720
 
621
721
  if (gHost.choreographer != nullptr) {
@@ -685,6 +785,49 @@ void shutdown() {
685
785
  // Every window, including the app's own. What this does *not* cover is Cmd-Q:
686
786
  // terminating goes through applicationShouldTerminate:, which never asks a
687
787
  // window whether it minds -- that is the handler further down.
788
+ // `AppState`, from the two facts AppKit keeps: whether this application is
789
+ // frontmost, and whether any of it is on screen. Hiding the app (Cmd-H) and
790
+ // miniaturising every window are both "background"; losing focus to another
791
+ // application while still visible is "inactive". See core/AppStateHost.h.
792
+ - (void)rnReportAppState {
793
+ const bool focused = NSApp.isActive;
794
+ bool visible = !NSApp.isHidden;
795
+ if (visible) {
796
+ visible = NO;
797
+ for (NSWindow *window in NSApp.windows) {
798
+ if (window.isVisible && !window.isMiniaturized) {
799
+ visible = YES;
800
+ break;
801
+ }
802
+ }
803
+ }
804
+ basalt::notifyAppState(basalt::appStateFor(focused, visible));
805
+ }
806
+
807
+ - (void)applicationDidBecomeActive:(NSNotification *)notification {
808
+ [self rnReportAppState];
809
+ }
810
+
811
+ - (void)applicationDidResignActive:(NSNotification *)notification {
812
+ [self rnReportAppState];
813
+ }
814
+
815
+ - (void)applicationDidHide:(NSNotification *)notification {
816
+ [self rnReportAppState];
817
+ }
818
+
819
+ - (void)applicationDidUnhide:(NSNotification *)notification {
820
+ [self rnReportAppState];
821
+ }
822
+
823
+ - (void)windowDidMiniaturize:(NSNotification *)notification {
824
+ [self rnReportAppState];
825
+ }
826
+
827
+ - (void)windowDidDeminiaturize:(NSNotification *)notification {
828
+ [self rnReportAppState];
829
+ }
830
+
688
831
  - (BOOL)windowShouldClose:(NSWindow *)sender {
689
832
  for (const auto &candidate : gHost.windows) {
690
833
  if (candidate->window != sender) {
@@ -753,6 +896,13 @@ void shutdown() {
753
896
  // and React Native's C++ platform answers "what size is the screen" with
754
897
  // zero. Told here, where the window's size is already being handed to Fabric.
755
898
  gHost.mountingManager->setSurfaceSize((float)width, (float)height);
899
+ // And `Dimensions.get('window')`, which is the area React lays out in rather
900
+ // than the window's frame -- the two differ by this host's title bar. The
901
+ // main surface only: a second window has a surface of its own and
902
+ // `Dimensions` is singular.
903
+ if (resized->surfaceId == kSurfaceId) {
904
+ basalt::notifySurfaceSize(width, height);
905
+ }
756
906
  // The window moved or was resized, which `useWindow()` hands an app as live
757
907
  // bounds. Here rather than in a watcher of its own: this is already the one
758
908
  // place that learns it.
@@ -1021,6 +1171,11 @@ std::vector<facebook::react::SurfaceId> hostWindows() {
1021
1171
  } // namespace basalt
1022
1172
 
1023
1173
  int main(int argc, const char *argv[]) {
1174
+
1175
+ // What `initialWindowMetrics` answers, before any JavaScript asks. The window
1176
+ // is this size until something resizes it, and the provider's own layout
1177
+ // corrects it after that. See core/SafeAreaComponent.h.
1178
+ basalt::setInitialWindowFrame(kInitialWidth, kInitialHeight);
1024
1179
  // glog, before anything logs through it.
1025
1180
  //
1026
1181
  // Without this every run opens with "WARNING: Logging before
@@ -1031,6 +1186,9 @@ int main(int argc, const char *argv[]) {
1031
1186
  // arrives before anything this project writes.
1032
1187
  FLAGS_logtostderr = true;
1033
1188
  google::InitGoogleLogging(argv[0]);
1189
+ // Straight after, so a crash between here and the first window still
1190
+ // says where. See core/CrashHandler.h.
1191
+ basalt::installCrashHandler();
1034
1192
 
1035
1193
  @autoreleasepool {
1036
1194
  gHost.bundlePath = argc > 1 ? argv[1] : "build/main.jsbundle.js";
@@ -1085,6 +1243,15 @@ int main(int argc, const char *argv[]) {
1085
1243
  // Expo app is during its first import.
1086
1244
  basalt::startObservingColorScheme();
1087
1245
 
1246
+ // The platform's text scale, which on macOS is 1: appKitTextScale() is
1247
+ // where that is explained. Handed over the same way GTK hands over the one
1248
+ // it reads from GtkSettings, so core/FontScaling.h is the only thing that
1249
+ // decides what `allowFontScaling` means.
1250
+ // Which language this desktop is in and which way it reads, asked once and
1251
+ // on this thread. See core/SystemLocale.h.
1252
+ basalt::setSystemLocale(basalt::systemLocale());
1253
+ basalt::setSystemFontScale(basalt::appKitTextScale());
1254
+
1088
1255
  // Before ReactHost, so the beat is being induced from the first event on.
1089
1256
  gHost.runLoopObserver = basalt::installRunLoopObserver(gHost.runLoopObserverManager);
1090
1257
 
@@ -1226,6 +1393,10 @@ int main(int argc, const char *argv[]) {
1226
1393
  // SurfaceHandler::start calls AppRegistry.runApplication. Empty: the
1227
1394
  // surface is registered without JS being called at all, leaving it for a
1228
1395
  // script to commit into through nativeFabricUIManager by hand.
1396
+ // Before the surface starts, so that an app reading `Dimensions` while its
1397
+ // first render runs is told the size it is being laid out at rather than a
1398
+ // zero. See core/DeviceMetrics.h.
1399
+ basalt::notifySurfaceSize(kInitialWidth, kInitialHeight);
1229
1400
  gHost.reactHost->startSurface(kSurfaceId,
1230
1401
  gHost.moduleName,
1231
1402
  folly::dynamic::object(),
@@ -168,3 +168,292 @@ TEST(accessibility_role_is_reported_in_the_tree) {
168
168
  EXPECT(std::string([plain describeTree].UTF8String).find("role=") == std::string::npos);
169
169
  }
170
170
  }
171
+
172
+ // accessibilityValue: a range, a position in it, and a text form a screen reader
173
+ // prefers over the number. The parts are independent in React Native, which is
174
+ // the difficulty: an absent one must stay absent rather than become zero.
175
+
176
+ TEST(accessibility_value_reaches_appkit) {
177
+ @autoreleasepool {
178
+ RnAppKitView *view = viewWithRole(@"adjustable");
179
+
180
+ [view setRnAccessibleValueMin:@0 max:@10 now:@7 text:nil];
181
+
182
+ EXPECT([view.accessibilityMinValue isEqual:@0]);
183
+ EXPECT([view.accessibilityMaxValue isEqual:@10]);
184
+ EXPECT([view.accessibilityValue isEqual:@7]);
185
+ EXPECT(view.isAccessibilityElement);
186
+ }
187
+ }
188
+
189
+ TEST(an_accessibility_value_without_a_range_does_not_invent_one) {
190
+ @autoreleasepool {
191
+ RnAppKitView *view = viewWithRole(@"adjustable");
192
+
193
+ // `now` alone. A view that never said what its range is must not be
194
+ // announced as sitting at the bottom of one.
195
+ [view setRnAccessibleValueMin:nil max:nil now:@3 text:nil];
196
+
197
+ EXPECT(view.accessibilityMinValue == nil);
198
+ EXPECT(view.accessibilityMaxValue == nil);
199
+ EXPECT([view.accessibilityValue isEqual:@3]);
200
+ }
201
+ }
202
+
203
+ TEST(a_text_accessibility_value_is_preferred_over_the_number) {
204
+ @autoreleasepool {
205
+ RnAppKitView *view = viewWithRole(@"adjustable");
206
+
207
+ // "Tuesday" reads better than "3", which is why React Native carries both.
208
+ [view setRnAccessibleValueMin:@0 max:@6 now:@3 text:@"Tuesday"];
209
+
210
+ EXPECT([view.accessibilityValue isEqual:@"Tuesday"]);
211
+ // The range still stands: a screen reader may announce position as well.
212
+ EXPECT([view.accessibilityMaxValue isEqual:@6]);
213
+ }
214
+ }
215
+
216
+ TEST(an_explicit_accessibility_value_wins_over_a_checked_state) {
217
+ @autoreleasepool {
218
+ RnAppKitView *view = viewWithRole(@"checkbox");
219
+
220
+ // AppKit carries both in one property, so the order matters and this is the
221
+ // assertion that pins it: the app's own value is not overwritten by a value
222
+ // derived from a checkbox.
223
+ [view setRnAccessibleStateDisabled:RnAppKitAccessibleUnset
224
+ checked:RnAppKitAccessibleTrue
225
+ selected:RnAppKitAccessibleUnset
226
+ expanded:RnAppKitAccessibleUnset
227
+ busy:RnAppKitAccessibleUnset];
228
+ EXPECT([view.accessibilityValue isEqual:@YES]);
229
+
230
+ [view setRnAccessibleValueMin:nil max:nil now:nil text:@"Mixed"];
231
+ EXPECT([view.accessibilityValue isEqual:@"Mixed"]);
232
+ }
233
+ }
234
+
235
+ TEST(a_view_with_no_accessibility_value_keeps_none) {
236
+ @autoreleasepool {
237
+ RnAppKitView *view = viewWithRole(@"adjustable");
238
+
239
+ [view setRnAccessibleValueMin:nil max:nil now:nil text:nil];
240
+
241
+ EXPECT(view.accessibilityMinValue == nil);
242
+ EXPECT(view.accessibilityMaxValue == nil);
243
+ EXPECT(view.accessibilityValue == nil);
244
+ }
245
+ }
246
+
247
+ TEST(a_role_can_change_after_the_view_is_mounted) {
248
+ @autoreleasepool {
249
+ RnAppKitView *view = viewWithRole(@"button");
250
+ EXPECT([view.accessibilityRole isEqual:NSAccessibilityButtonRole]);
251
+
252
+ // AppKit's role is a settable property, so unlike GTK, where it is
253
+ // construct-only and the backlog records the limitation, the role itself
254
+ // follows. This pins that rather than assuming it: the mounting manager
255
+ // applies the role on every props update, and a change to apply it only at
256
+ // creation would be invisible without this.
257
+ [view setRnAccessibleRole:@"checkbox"];
258
+ EXPECT([view.accessibilityRole isEqual:NSAccessibilityCheckBoxRole]);
259
+ }
260
+ }
261
+
262
+ // `accessibilityLabelledBy`: another view, named by its `nativeID`, whose text
263
+ // names this one.
264
+ //
265
+ // The first half is the mapping, which is this file's business and is one
266
+ // property: AppKit's `accessibilityTitleUIElement` is a single element where the
267
+ // prop and GTK's relation are both lists, so the first is used.
268
+ //
269
+ // The second half -- resolving a `nativeID` to a view, and resolving it *late*,
270
+ // since Fabric mounts a field before the label that follows it -- is in
271
+ // core/LabelRegistry.h with its own tests, and the mounting manager's use of it
272
+ // is checked through a real transaction in test_appkit_mounting.mm.
273
+ TEST(accessibility_labelled_by_points_at_the_labelling_view) {
274
+ @autoreleasepool {
275
+ RnAppKitView *field = [RnAppKitView viewWithTag:10];
276
+ RnAppKitView *label = [RnAppKitView viewWithTag:20];
277
+
278
+ EXPECT(field.accessibilityTitleUIElement == nil);
279
+ [field setRnLabelledBy:@[label]];
280
+ EXPECT(field.accessibilityTitleUIElement == label);
281
+ // And it is worth announcing now: its name lives on another view, so a field
282
+ // left out of the tree would be unreachable with the label read out beside
283
+ // nothing.
284
+ EXPECT(field.isAccessibilityElement);
285
+
286
+ // Taken off again, which is what an unmounted label leaves behind.
287
+ [field setRnLabelledBy:@[]];
288
+ EXPECT(field.accessibilityTitleUIElement == nil);
289
+ }
290
+ }
291
+
292
+ // Several labels: AppKit takes the first and the rest are still reported, so the
293
+ // two hosts' dumps match and the difference stays in what each platform does.
294
+ TEST(accessibility_labelled_by_takes_the_first_of_several) {
295
+ @autoreleasepool {
296
+ RnAppKitView *field = [RnAppKitView viewWithTag:10];
297
+ RnAppKitView *heading = [RnAppKitView viewWithTag:20];
298
+ RnAppKitView *hint = [RnAppKitView viewWithTag:30];
299
+
300
+ [field setRnLabelledBy:@[heading, hint]];
301
+ EXPECT(field.accessibilityTitleUIElement == heading);
302
+
303
+ const std::string described = [field describeTree].UTF8String;
304
+ EXPECT(described.find("labelled-by=20,30") != std::string::npos);
305
+ }
306
+ }
307
+
308
+ // `testID`, which on this platform is `accessibilityIdentifier`: what XCTest,
309
+ // Appium and the Accessibility Inspector look a view up by, and the nearest
310
+ // thing macOS has to UIA's automation id.
311
+ TEST(a_test_id_becomes_the_accessibility_identifier) {
312
+ @autoreleasepool {
313
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
314
+ EXPECT(view.accessibilityIdentifier == nil || view.accessibilityIdentifier.length == 0);
315
+
316
+ view.rnTestId = @"save-button";
317
+ // Through a nil-safe read, because the sabotage that proves this test --
318
+ // taking the identifier away -- would otherwise make `std::string(nullptr)`
319
+ // crash the whole suite instead of failing this one test. A test that dies
320
+ // reports nothing about the other 543.
321
+ const char *identifier = view.accessibilityIdentifier.UTF8String;
322
+ EXPECT_EQ(std::string(identifier != nullptr ? identifier : ""), std::string("save-button"));
323
+ // And React Native's spelling in the dump, which is what the cross-platform
324
+ // diff compares.
325
+ EXPECT(std::string([view describeTree].UTF8String).find("testid=save-button")
326
+ != std::string::npos);
327
+
328
+ // Taken away again: nil rather than an empty identifier, because a runner
329
+ // searching for "" would otherwise match the first view it saw.
330
+ view.rnTestId = @"";
331
+ EXPECT(view.accessibilityIdentifier == nil);
332
+ EXPECT(std::string([view describeTree].UTF8String).find("testid=") == std::string::npos);
333
+ }
334
+ }
335
+
336
+ // `accessibilityViewIsModal`, which AppKit spells `accessibilityModal`: inside
337
+ // a modal element VoiceOver stops offering the siblings behind it.
338
+ TEST(a_modal_view_sets_accessibility_modal) {
339
+ @autoreleasepool {
340
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
341
+ EXPECT(!view.isAccessibilityModal);
342
+ EXPECT(std::string([view describeTree].UTF8String).find(" modal") == std::string::npos);
343
+
344
+ view.rnAccessibleModal = YES;
345
+ EXPECT(view.isAccessibilityModal);
346
+ EXPECT(std::string([view describeTree].UTF8String).find(" modal") != std::string::npos);
347
+
348
+ // And back, because a <Modal> that closes has to stop trapping the reader.
349
+ view.rnAccessibleModal = NO;
350
+ EXPECT(!view.isAccessibilityModal);
351
+ EXPECT(std::string([view describeTree].UTF8String).find(" modal") == std::string::npos);
352
+ }
353
+ }
354
+
355
+ // `experimental_accessibilityOrder`: the children a view wants read, in the
356
+ // order it gave rather than the order they mounted.
357
+ //
358
+ // AppKit expresses it by replacing `accessibilityChildren`, which is what
359
+ // VoiceOver walks in place of the view hierarchy. GTK says the same thing as an
360
+ // `aria-flowto` relation, its accessible tree following the widget tree with no
361
+ // equivalent override; both print the resolved tags, so the dumps compare.
362
+ TEST(an_accessibility_order_replaces_the_accessibility_children) {
363
+ @autoreleasepool {
364
+ RnAppKitView *parent = [RnAppKitView viewWithTag:10];
365
+ RnAppKitView *first = [RnAppKitView viewWithTag:20];
366
+ RnAppKitView *second = [RnAppKitView viewWithTag:30];
367
+
368
+ // What AppKit answers for itself, which is not nil: `accessibilityChildren`
369
+ // is computed from the view hierarchy when nothing overrides it, so the
370
+ // assertion below is about the content and not about the property being
371
+ // unset. Measured rather than assumed -- the first version of this test
372
+ // expected nil and failed on the line before the feature was exercised.
373
+ const NSUInteger byHierarchy = parent.accessibilityChildren.count;
374
+
375
+ // Deliberately not the tag order: the prop exists to override it.
376
+ [parent setRnAccessibilityOrder:@[second, first]];
377
+ EXPECT(parent.accessibilityChildren.count == 2);
378
+ EXPECT(parent.accessibilityChildren.firstObject == second);
379
+ EXPECT(parent.accessibilityChildren.lastObject == first);
380
+ EXPECT(std::string([parent describeTree].UTF8String).find("a11y-order=30,20")
381
+ != std::string::npos);
382
+
383
+ // Taken away again puts the view hierarchy back, which is what a view that
384
+ // stopped asking means. The setter passes nil rather than an empty array:
385
+ // an empty array is a claim that the view has no accessible children at
386
+ // all, where nil is "ask the hierarchy", which is what this reads back.
387
+ [parent setRnAccessibilityOrder:@[]];
388
+ EXPECT_EQ(parent.accessibilityChildren.count, byHierarchy);
389
+ EXPECT(std::string([parent describeTree].UTF8String).find("a11y-order=")
390
+ == std::string::npos);
391
+ }
392
+ }
393
+
394
+ // Subroles, which are how macOS says the four things a role cannot.
395
+ //
396
+ // The role maps a search field to a text field, a switch and a toggle button to
397
+ // check boxes, and a tab to a radio button -- each losing the part that says
398
+ // what it is. GTK needs none of this, those being roles of their own there,
399
+ // which is why it was a macOS entry.
400
+ TEST(a_role_that_macos_says_with_a_subrole_gets_one) {
401
+ @autoreleasepool {
402
+ RnAppKitView *search = viewWithRole(@"search");
403
+ EXPECT_EQ(search.accessibilityRole, NSAccessibilityTextFieldRole);
404
+ EXPECT_EQ(search.accessibilitySubrole, NSAccessibilitySearchFieldSubrole);
405
+
406
+ RnAppKitView *switched = viewWithRole(@"switch");
407
+ EXPECT_EQ(switched.accessibilityRole, NSAccessibilityCheckBoxRole);
408
+ EXPECT_EQ(switched.accessibilitySubrole, NSAccessibilitySwitchSubrole);
409
+
410
+ RnAppKitView *toggle = viewWithRole(@"togglebutton");
411
+ EXPECT_EQ(toggle.accessibilityRole, NSAccessibilityCheckBoxRole);
412
+ EXPECT_EQ(toggle.accessibilitySubrole, NSAccessibilityToggleSubrole);
413
+
414
+ RnAppKitView *tab = viewWithRole(@"tab");
415
+ EXPECT_EQ(tab.accessibilityRole, NSAccessibilityRadioButtonRole);
416
+ EXPECT_EQ(tab.accessibilitySubrole, NSAccessibilityTabButtonSubrole);
417
+ }
418
+ }
419
+
420
+ // And nothing else gets one: a subrole AppKit does not know tells VoiceOver
421
+ // less than none, and a view with no subrole is read by its role.
422
+ TEST(an_ordinary_role_gets_no_subrole) {
423
+ @autoreleasepool {
424
+ RnAppKitView *button = viewWithRole(@"button");
425
+ EXPECT(button.accessibilitySubrole == nil);
426
+
427
+ RnAppKitView *text = viewWithRole(@"text");
428
+ EXPECT(text.accessibilitySubrole == nil);
429
+ }
430
+ }
431
+
432
+ // A role that goes away takes its subrole with it. Without that a view whose
433
+ // prop was removed would keep announcing itself as a switch.
434
+ TEST(a_role_taken_away_takes_its_subrole) {
435
+ @autoreleasepool {
436
+ RnAppKitView *view = viewWithRole(@"switch");
437
+ EXPECT_EQ(view.accessibilitySubrole, NSAccessibilitySwitchSubrole);
438
+
439
+ [view setRnAccessibleRole:nil];
440
+ EXPECT(view.accessibilitySubrole == nil);
441
+ EXPECT(!view.isAccessibilityElement);
442
+ }
443
+ }
444
+
445
+ // And a role *changing* replaces the subrole rather than keeping the old one,
446
+ // which is the case AppKit allows and GTK cannot: a GtkAccessible's role is
447
+ // construct-only.
448
+ TEST(a_role_change_replaces_the_subrole) {
449
+ @autoreleasepool {
450
+ RnAppKitView *view = viewWithRole(@"switch");
451
+ EXPECT_EQ(view.accessibilitySubrole, NSAccessibilitySwitchSubrole);
452
+
453
+ [view setRnAccessibleRole:@"search"];
454
+ EXPECT_EQ(view.accessibilitySubrole, NSAccessibilitySearchFieldSubrole);
455
+
456
+ [view setRnAccessibleRole:@"button"];
457
+ EXPECT(view.accessibilitySubrole == nil);
458
+ }
459
+ }