basalt-appkit 0.0.0-rc5 → 0.1.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/native/CMakeLists.txt +16 -2
- package/native/appkit/AppKitAppWindow.h +24 -0
- package/native/appkit/AppKitImageLoader.h +48 -1
- package/native/appkit/AppKitImageLoader.mm +228 -10
- package/native/appkit/AppKitMenuBar.mm +58 -0
- package/native/appkit/AppKitMountingManager.h +34 -0
- package/native/appkit/AppKitMountingManager.mm +576 -9
- package/native/appkit/AppKitScrollView.h +48 -0
- package/native/appkit/AppKitScrollView.mm +210 -0
- package/native/appkit/AppKitTextInput.h +38 -1
- package/native/appkit/AppKitTextInput.mm +235 -8
- package/native/appkit/AppKitTextPeer.h +60 -0
- package/native/appkit/AppKitTextPeer.mm +307 -0
- package/native/appkit/AppKitTouchDispatcher.h +56 -0
- package/native/appkit/AppKitTouchDispatcher.mm +140 -0
- package/native/appkit/AppKitWindowControl.mm +38 -3
- package/native/appkit/ComponentRegistryAppKit.mm +20 -0
- package/native/appkit/CoreTextLayout.h +64 -2
- package/native/appkit/CoreTextLayout.mm +431 -71
- package/native/appkit/CoreTextLayoutManager.mm +57 -6
- package/native/appkit/FontRegistryCoreText.mm +6 -5
- package/native/appkit/PlatformServicesAppKit.mm +101 -20
- package/native/appkit/RnAppKitView.h +492 -0
- package/native/appkit/RnAppKitView.mm +2266 -23
- package/native/appkit/RnTextLayout.h +106 -0
- package/native/appkit/RnTextLayout.mm +321 -2
- package/native/appkit/main_appkit.mm +174 -3
- package/native/tests/test_appkit_accessibility.mm +289 -0
- package/native/tests/test_appkit_borders.mm +975 -0
- package/native/tests/test_appkit_fonts.mm +203 -0
- package/native/tests/test_appkit_image.mm +392 -17
- package/native/tests/test_appkit_input.mm +209 -0
- package/native/tests/test_appkit_mounting.mm +360 -0
- package/native/tests/test_appkit_scrollbar.mm +125 -0
- package/native/tests/test_appkit_scrollview.mm +371 -0
- package/native/tests/test_appkit_selection.mm +349 -0
- package/native/tests/test_appkit_services.mm +51 -1
- package/native/tests/test_appkit_text.mm +1628 -0
- package/native/tests/test_appkit_textinput.mm +819 -0
- package/native/tests/test_appkit_titlebar.mm +43 -0
- package/native/tests/test_appkit_view.mm +609 -0
- 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>. `
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|