@octane-xplat/ui 0.7.2 → 0.9.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 (219) hide show
  1. package/README.md +4 -3
  2. package/dist/native/Button.tsrx.js +1 -1
  3. package/dist/native/CommandPalette.tsrx.js +7 -1
  4. package/dist/native/Grid.tsrx.js +31 -2
  5. package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
  6. package/dist/native/Markdown.tsrx.js +233 -0
  7. package/dist/native/Overlay.tsrx.js +15 -26
  8. package/dist/native/Pagination.tsrx.js +15 -12
  9. package/dist/native/PinInput.tsrx.js +25 -7
  10. package/dist/native/Popover.tsrx.js +33 -23
  11. package/dist/native/Pressable.tsrx.js +4 -1
  12. package/dist/native/Screen.tsrx.js +1 -1
  13. package/dist/native/SearchInput.tsrx.js +6 -3
  14. package/dist/native/Sheet.tsrx.js +23 -38
  15. package/dist/native/Slider.tsrx.js +23 -14
  16. package/dist/native/Text.tsrx.js +16 -0
  17. package/dist/native/TextArea.tsrx.js +6 -3
  18. package/dist/native/TextInput.tsrx.js +6 -3
  19. package/dist/native/VirtualList.tsrx.js +305 -121
  20. package/dist/native/WebView.tsrx.js +17 -2
  21. package/dist/native/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
  22. package/dist/native/escape-props.js +2 -0
  23. package/dist/native/grid-auto-placement.js +83 -0
  24. package/dist/native/grid-placement.js +20 -0
  25. package/dist/native/index.shared.js +7 -5
  26. package/dist/native/keyboard-inset.mobile.js +5 -5
  27. package/dist/native/navigation-request.js +26 -0
  28. package/dist/native/octane-client-build.json +1 -1
  29. package/dist/native/overlay-lifetime.mobile.js +65 -0
  30. package/dist/native/pan.tsrx.js +16 -2
  31. package/dist/native/route-table.js +90 -10
  32. package/dist/native/route.js +26 -16
  33. package/dist/native/shared.js +7 -5
  34. package/dist/native/sheet-service.js +2 -2
  35. package/dist/native/svg.mobile.js +1 -1
  36. package/dist/native/{tap-to-blur.mobile.js → tap-to-blur.ios.js} +20 -32
  37. package/dist/native/text-focus.js +10 -0
  38. package/dist/native/text-write.js +18 -11
  39. package/dist/native/toast-anchor.tsrx.js +1 -1
  40. package/dist/native/ui.js +7 -6
  41. package/dist/native/useMeasure.tsrx.js +17 -18
  42. package/dist/native/vendor/ui-svg/index.android.js +2 -2
  43. package/dist/native/vendor/ui-svg/index.ios.js +2 -2
  44. package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
  45. package/dist/native/virtual-list-cell-pool.js +63 -0
  46. package/dist/web/Button.web.tsrx.js +1 -1
  47. package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
  48. package/dist/web/Markdown.tsrx.js +98 -0
  49. package/dist/web/Overlay.web.tsrx.js +81 -32
  50. package/dist/web/PinInput.web.tsrx.js +24 -10
  51. package/dist/web/Pressable.web.tsrx.js +169 -53
  52. package/dist/web/SearchInput.web.tsrx.js +2 -2
  53. package/dist/web/Sheet.web.tsrx.js +20 -7
  54. package/dist/web/TextArea.web.tsrx.js +22 -17
  55. package/dist/web/TextInput.web.tsrx.js +2 -2
  56. package/dist/web/VirtualList.web.tsrx.js +220 -109
  57. package/dist/web/WebView.web.tsrx.js +3 -3
  58. package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
  59. package/dist/web/modal-focus.web.js +95 -0
  60. package/dist/web/navigation-request.js +26 -0
  61. package/dist/web/octane-client-build.json +1 -1
  62. package/dist/web/pan.web.tsrx.js +27 -10
  63. package/dist/web/route-table.js +90 -10
  64. package/dist/web/route.web.js +88 -22
  65. package/dist/web/sheet-service.web.js +8 -0
  66. package/dist/web/theme/colorScheme.web.js +31 -4
  67. package/dist/web/ui.js +8 -6
  68. package/dist/web/virtual-list-cell-pool.js +63 -0
  69. package/package.json +21 -19
  70. package/platforms/android/native-api-usage.json +7 -7
  71. package/src/CommandPalette.tsrx +8 -1
  72. package/src/Grid.tsrx +32 -1
  73. package/src/Icon.macos.tsrx +1 -0
  74. package/src/KeyboardAvoiding.macos.tsrx +7 -1
  75. package/src/KeyboardAvoiding.mobile.tsrx +2 -2
  76. package/src/KeyboardAvoiding.tsrx +15 -0
  77. package/src/KeyboardAvoiding.web.tsrx +15 -0
  78. package/src/Markdown.tsrx +121 -0
  79. package/src/Meter.tsrx +1 -0
  80. package/src/Overlay.tsrx +15 -30
  81. package/src/Overlay.web.tsrx +12 -3
  82. package/src/Pagination.tsrx +6 -4
  83. package/src/PinInput.macos.tsrx +20 -6
  84. package/src/PinInput.mobile.test.ts +107 -0
  85. package/src/PinInput.tsrx +20 -6
  86. package/src/PinInput.web.test.tsrx +42 -0
  87. package/src/PinInput.web.tsrx +20 -6
  88. package/src/Popover.tsrx +36 -22
  89. package/src/Pressable.tsrx +1 -1
  90. package/src/Pressable.web.test.tsrx +36 -0
  91. package/src/Pressable.web.tsrx +37 -6
  92. package/src/RouteHost.macos.tsrx +1 -0
  93. package/src/Screen.tsrx +1 -1
  94. package/src/SearchInput.tsrx +4 -3
  95. package/src/SearchInput.web.tsrx +1 -1
  96. package/src/Sheet.tsrx +21 -42
  97. package/src/Sheet.web.tsrx +9 -1
  98. package/src/Slider.tsrx +13 -11
  99. package/src/Text.tsrx +36 -0
  100. package/src/TextArea.tsrx +6 -5
  101. package/src/TextArea.web.test.tsrx +39 -0
  102. package/src/TextArea.web.tsrx +7 -5
  103. package/src/TextInput.tsrx +6 -6
  104. package/src/TextInput.web.tsrx +1 -1
  105. package/src/VirtualList.macos.tsrx +93 -6
  106. package/src/VirtualList.mobile.test.tsrx +232 -0
  107. package/src/VirtualList.tsrx +285 -81
  108. package/src/VirtualList.web.tsrx +131 -25
  109. package/src/WebView.tsrx +20 -10
  110. package/src/WebView.web.tsrx +3 -0
  111. package/src/android/BottomNavigationView.android.tsrx +1 -0
  112. package/src/android/MaterialDialog.android.tsrx +1 -0
  113. package/src/anim.macos.tsrx +1 -0
  114. package/src/apply-native-modifiers.native.ts +4 -1
  115. package/src/child-array.ts +12 -7
  116. package/src/colorScheme.macos.ts +1 -0
  117. package/src/controls.macos.tsrx +4 -1
  118. package/src/data-driver.mobile.test.ts +120 -0
  119. package/src/data-lifecycle.mobile.test.ts +251 -0
  120. package/src/escape-props.ts +13 -1
  121. package/src/grid-auto-placement.ts +163 -0
  122. package/src/grid-placement.ts +29 -0
  123. package/src/hosted-unsupported.macos.tsrx +1 -0
  124. package/src/index.macos.ts +23 -3
  125. package/src/index.mobile.ts +0 -1
  126. package/src/index.shared.ts +22 -1
  127. package/src/index.shared.web.ts +291 -0
  128. package/src/index.web.ts +1 -1
  129. package/src/input-readiness.web.test.tsrx +82 -0
  130. package/src/ios/UITabBar.ios.tsrx +1 -0
  131. package/src/keyboard-inset.mobile.test.ts +62 -0
  132. package/src/keyboard-inset.mobile.ts +63 -43
  133. package/src/layout-shells.macos.tsrx +1 -5
  134. package/src/macos-extras.macos.tsrx +2 -0
  135. package/src/modal-focus.web.test.tsrx +63 -0
  136. package/src/modal-focus.web.ts +162 -0
  137. package/src/navigation-request.ts +37 -0
  138. package/src/overlay-lifecycle.mobile.test.ts +316 -0
  139. package/src/overlay-lifetime.mobile.ts +107 -0
  140. package/src/pan.mobile.test.tsrx +116 -0
  141. package/src/pan.tsrx +20 -2
  142. package/src/pan.web.tsrx +35 -7
  143. package/src/platformProps.ts +1 -0
  144. package/src/popover.mobile.test.ts +9 -1
  145. package/src/pressable-accessibility.mobile.test.ts +30 -0
  146. package/src/props.ts +163 -3
  147. package/src/refresh.ts +6 -1
  148. package/src/refresh.web.test.ts +77 -77
  149. package/src/route-android.test.ts +162 -0
  150. package/src/route-history.web.test.ts +80 -0
  151. package/src/route-navigation.web.test.ts +173 -0
  152. package/src/route-table.test.ts +188 -9
  153. package/src/route-table.ts +138 -10
  154. package/src/route.macos.ts +56 -13
  155. package/src/route.ts +54 -24
  156. package/src/route.web.ts +141 -24
  157. package/src/sheet-detents.ts +5 -3
  158. package/src/sheet-service.macos.ts +5 -1
  159. package/src/sheet-service.ts +1 -1
  160. package/src/sheet-service.web.ts +8 -0
  161. package/src/sheet-snap.test.ts +23 -23
  162. package/src/sheet-snap.ts +6 -1
  163. package/src/svg.mobile.ts +9 -3
  164. package/src/tap-to-blur.ios.ts +106 -0
  165. package/src/tap-to-blur.mobile.ts +21 -95
  166. package/src/text-focus.ts +12 -0
  167. package/src/text-input.mobile.test.ts +89 -0
  168. package/src/text-write.ts +21 -11
  169. package/src/theme/colorScheme.linux.ts +2 -4
  170. package/src/theme/colorScheme.web.ts +51 -4
  171. package/src/theme/theme-scheme.macos.ts +8 -2
  172. package/src/theme/tokens.css +1740 -1718
  173. package/src/translate.macos.ts +4 -1
  174. package/src/useMeasure.tsrx +20 -21
  175. package/src/virtual-list-cell-pool.test.ts +53 -0
  176. package/src/virtual-list-cell-pool.ts +81 -0
  177. package/src/virtual-list-layout.test.ts +11 -8
  178. package/src/virtual-list-layout.ts +4 -8
  179. package/src/windows.linux.ts +7 -2
  180. package/src/windows.macos.ts +1 -0
  181. package/types/generated/.tsrx-typegen-manifest.json +16 -3
  182. package/types/generated/Grid.d.ts +0 -1
  183. package/types/generated/KeyboardAvoiding.d.ts +3 -0
  184. package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
  185. package/types/generated/Markdown.d.ts +59 -0
  186. package/types/generated/TextArea.d.ts +2 -2
  187. package/types/generated/TextArea.web.d.ts +1 -1
  188. package/types/generated/TextInput.d.ts +2 -3
  189. package/types/generated/VirtualList.d.ts +3 -3
  190. package/types/generated/escape-props.d.ts +4 -0
  191. package/types/generated/grid-auto-placement.d.ts +13 -0
  192. package/types/generated/grid-placement.d.ts +5 -0
  193. package/types/generated/index.mobile.d.ts +0 -1
  194. package/types/generated/index.shared.d.ts +6 -3
  195. package/types/generated/index.shared.web.d.ts +114 -0
  196. package/types/generated/index.web.d.ts +1 -1
  197. package/types/generated/modal-focus.web.d.ts +3 -0
  198. package/types/generated/navigation-request.d.ts +10 -0
  199. package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
  200. package/types/generated/props.d.ts +111 -3
  201. package/types/generated/route-table.d.ts +14 -3
  202. package/types/generated/svg.mobile.d.ts +2 -2
  203. package/types/generated/tap-to-blur.ios.d.ts +13 -0
  204. package/types/generated/tap-to-blur.mobile.d.ts +1 -15
  205. package/types/generated/text-focus.d.ts +3 -0
  206. package/types/generated/text-write.d.ts +3 -1
  207. package/types/generated/theme/colorScheme.web.d.ts +5 -0
  208. package/types/generated/virtual-list-cell-pool.d.ts +18 -0
  209. package/types/index.macos.d.ts +15 -3
  210. /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
  211. /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
  212. /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
  213. /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
  214. /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
  215. /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
  216. /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
  217. /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
  218. /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
  219. /package/types/generated/vendor/ui-svg/{index.ios.d.ts → src/index.ios.d.ts} +0 -0
@@ -0,0 +1,106 @@
1
+ /** Tap-outside-to-blur: iOS keeps a focused UITextField/UITextView as first
2
+ * responder until something else takes it, so a tap on blank chrome never
3
+ * dismisses the keyboard on its own.
4
+ *
5
+ * iOS: a single window-level UITapGestureRecognizer (ref-counted across
6
+ * attach calls) sees every touch in the app window — popups/sheets included —
7
+ * where a recognizer on an intermediate container proved unreliable. On each
8
+ * tap we hit-test and resign first responder unless the hit view is an
9
+ * editable text view (otherwise the field would focus-then-blur in the same
10
+ * tap — recognizers fire on touch-up, after the field's touch-down focus).
11
+ * Keyboard taps live in a separate UIWindow, so they never reach it.
12
+ * `cancelsTouchesInView` stays false so taps still reach buttons. */
13
+ export function attachTapToBlur(view: any): () => void {
14
+ const marker = '__xplatTapToBlur'
15
+ if (!view || view[marker]) {
16
+ return () => {}
17
+ }
18
+
19
+ view[marker] = true
20
+
21
+ iosAttach()
22
+ return () => {
23
+ view[marker] = false
24
+ iosDetach()
25
+ }
26
+ }
27
+
28
+ let iosCount = 0
29
+ let iosTarget: any = null
30
+ let iosRecognizer: any = null
31
+ let iosWindow: any = null
32
+
33
+ function iosAttach() {
34
+ iosCount++
35
+ if (iosRecognizer) {
36
+ return
37
+ }
38
+
39
+ const appWin = appWindow()
40
+ if (!appWin) {
41
+ return
42
+ }
43
+
44
+ iosWindow = appWin
45
+ // Resolve Objective-C bridge globals only when the iOS view is attached.
46
+ @NativeClass
47
+ class TapToBlurRecognizerTarget extends NSObject implements UIGestureRecognizerDelegate {
48
+ static ObjCProtocols = [UIGestureRecognizerDelegate]
49
+ static ObjCExposedMethods = {
50
+ tap: { returns: interop.types.void, params: [UITapGestureRecognizer] },
51
+ }
52
+
53
+ gestureRecognizerShouldReceiveTouch(_recognizer: any, touch: any) {
54
+ let hit = touch.view
55
+ while (hit) {
56
+ const textField = hit.isKindOfClass(UITextField.class())
57
+ const textView = hit.isKindOfClass(UITextView.class())
58
+ if (textField || textView) {
59
+ return false
60
+ }
61
+
62
+ hit = hit.superview
63
+ }
64
+
65
+ return true
66
+ }
67
+
68
+ tap(_recognizer: any) {
69
+ iosWindow.endEditing(true)
70
+ }
71
+ }
72
+
73
+ iosTarget = TapToBlurRecognizerTarget.new()
74
+ iosRecognizer = UITapGestureRecognizer.alloc().initWithTargetAction(iosTarget, 'tap')
75
+ iosRecognizer.delegate = iosTarget
76
+ iosRecognizer.cancelsTouchesInView = false
77
+ appWin.addGestureRecognizer(iosRecognizer)
78
+ }
79
+
80
+ function iosDetach() {
81
+ iosCount--
82
+ if (iosCount > 0 || !iosRecognizer) {
83
+ return
84
+ }
85
+
86
+ iosWindow?.removeGestureRecognizer(iosRecognizer)
87
+ iosRecognizer = null
88
+ iosTarget = null
89
+ iosWindow = null
90
+ }
91
+
92
+ function appWindow() {
93
+ const scenes = UIApplication.sharedApplication.connectedScenes
94
+ // connectedScenes is an NSSet — `allObjects` marshals to a JS array.
95
+ for (const scene of (scenes as any).allObjects ?? []) {
96
+ if (scene instanceof UIWindowScene) {
97
+ for (const w of (scene.windows as any)?.allObjects ?? scene.windows ?? []) {
98
+ if (w.isKeyWindow) {
99
+ return w
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ return UIApplication.sharedApplication.keyWindow
106
+ }
@@ -1,108 +1,34 @@
1
- import { Application } from '@nativescript/core';
1
+ import { Application } from '@nativescript/core'
2
2
 
3
- /** Tap-outside-to-blur: iOS keeps a focused UITextField/UITextView as first
4
- * responder until something else takes it, so a tap on blank chrome never
5
- * dismisses the keyboard on its own.
6
- *
7
- * iOS: a single window-level UITapGestureRecognizer (ref-counted across
8
- * attach calls) sees every touch in the app window — popups/sheets included —
9
- * where a recognizer on an intermediate container proved unreliable. On each
10
- * tap we hit-test and resign first responder unless the hit view is an
11
- * editable text view (otherwise the field would focus-then-blur in the same
12
- * tap — recognizers fire on touch-up, after the field's touch-down focus).
13
- * Keyboard taps live in a separate UIWindow, so they never reach it.
14
- * `cancelsTouchesInView` stays false so taps still reach buttons.
15
- *
16
- * Android: hides the soft keyboard from the currently focused view when a
17
- * tap lands on the attached container. */
3
+ /** Hide the Android soft keyboard when the user taps the attached view. */
18
4
  export function attachTapToBlur(view: any): () => void {
19
- const marker = '__xplatTapToBlur';
20
- if (!view || view[marker]) {return () => {};}
21
- view[marker] = true;
22
-
23
- if (Application.ios) {
24
- iosAttach();
25
- return () => {
26
- view[marker] = false;
27
- iosDetach();
28
- };
5
+ const marker = '__xplatTapToBlur'
6
+ if (!view || view[marker]) {
7
+ return () => {}
29
8
  }
30
9
 
31
- if (Application.android) {
32
- const handler = (_args: any) => {
33
- const activity = Application.android.foregroundActivity;
34
- const focused = activity?.currentFocus;
35
- if (!focused) {return;}
36
- const imm = activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE);
37
- imm?.hideSoftInputFromWindow(focused.getWindowToken(), 0);
38
- };
10
+ view[marker] = true
39
11
 
40
- view.on('tap', handler);
12
+ if (!Application.android) {
41
13
  return () => {
42
- view[marker] = false;
43
- view.off('tap', handler);
44
- };
14
+ view[marker] = false
15
+ }
45
16
  }
46
17
 
47
- return () => {
48
- view[marker] = false;
49
- };
50
- }
51
-
52
- let iosCount = 0;
53
- let iosTarget: any = null;
54
- let iosRecognizer: any = null;
55
- let iosWindow: any = null;
56
-
57
- function iosAttach() {
58
- iosCount++;
59
- if (iosRecognizer) {return;}
60
- const appWin = appWindow();
61
- if (!appWin) {return;}
62
- iosWindow = appWin;
63
-
64
- const Owner = class extends NSObject {
65
- static ObjCExposedMethods = {
66
- tap: { returns: interop.types.void, params: [UITapGestureRecognizer] },
67
- };
68
- tap(recognizer: any) {
69
- const point = recognizer.locationInView(iosWindow);
70
- // hitTest can land on a text field's internal subview — walk up.
71
- let hit = iosWindow.hitTest(point, null);
72
- while (hit) {
73
- if (hit instanceof UITextField || hit instanceof UITextView) {return;}
74
- hit = hit.superview;
75
- }
76
-
77
- iosWindow.endEditing(true);
18
+ const handler = (_args: any) => {
19
+ const activity = Application.android.foregroundActivity
20
+ const focused = activity?.getCurrentFocus()
21
+ if (!focused) {
22
+ return
78
23
  }
79
- };
80
-
81
- iosTarget = Owner.new();
82
- iosRecognizer = UITapGestureRecognizer.alloc().initWithTargetAction(iosTarget, 'tap');
83
- iosRecognizer.cancelsTouchesInView = false;
84
- appWin.addGestureRecognizer(iosRecognizer);
85
- }
86
-
87
- function iosDetach() {
88
- iosCount--;
89
- if (iosCount > 0 || !iosRecognizer) {return;}
90
- iosWindow?.removeGestureRecognizer(iosRecognizer);
91
- iosRecognizer = null;
92
- iosTarget = null;
93
- iosWindow = null;
94
- }
95
24
 
96
- function appWindow() {
97
- const scenes = UIApplication.sharedApplication.connectedScenes;
98
- // connectedScenes is an NSSet — `allObjects` marshals to a JS array.
99
- for (const scene of (scenes as any).allObjects ?? []) {
100
- if (scene instanceof UIWindowScene) {
101
- for (const w of (scene.windows as any)?.allObjects ?? scene.windows ?? []) {
102
- if (w.isKeyWindow) {return w;}
103
- }
104
- }
25
+ const imm = activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE)
26
+ imm?.hideSoftInputFromWindow(focused.getWindowToken(), 0)
105
27
  }
106
28
 
107
- return UIApplication.sharedApplication.keyWindow;
29
+ view.on('tap', handler)
30
+ return () => {
31
+ view[marker] = false
32
+ view.off('tap', handler)
33
+ }
108
34
  }
@@ -0,0 +1,12 @@
1
+ /** Dismiss the keyboard and release native editing focus. NativeScript's
2
+ * dismissSoftInput returns void; its return value is not a fallback signal. */
3
+ export function blurText(view: any): void {
4
+ if (typeof view.dismissSoftInput === 'function') {
5
+ view.dismissSoftInput()
6
+ } else {
7
+ view.blur?.()
8
+ }
9
+
10
+ // Android dismissSoftInput hides the IME but retains EditText focus.
11
+ view.android?.clearFocus?.()
12
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createObjectContainer,
4
+ createObjectDriver,
5
+ createUniversalRoot,
6
+ flushUniversalSync,
7
+ } from 'octane/universal/native'
8
+
9
+ vi.mock('@nativescript/core', () => ({
10
+ isIOS: false,
11
+ isAndroid: true,
12
+ Utils: { layout: { toDeviceIndependentPixels: (value: number) => value } },
13
+ }))
14
+
15
+ vi.mock('./escape-props', () => ({ applyEscapeProps: () => {} }))
16
+ vi.mock('./Icon.tsrx', async () => {
17
+ const { defineUniversalComponent } = await import('octane/universal/native')
18
+ return { Icon: defineUniversalComponent('nativescript', () => null) }
19
+ })
20
+
21
+ import { TextInput } from './TextInput.tsrx'
22
+ import { TextArea } from './TextArea.tsrx'
23
+ import { SearchInput } from './SearchInput.tsrx'
24
+
25
+ // NativeScript Property raises a synchronous textChange on script writes.
26
+ // Model that event here, through the real compiled leaves and event dispatch.
27
+ // This is regression coverage, not keyboard/IME/device evidence.
28
+ describe('native controlled input writes', () => {
29
+ for (const [name, Component] of [
30
+ ['TextInput', TextInput],
31
+ ['TextArea', TextArea],
32
+ ['SearchInput', SearchInput],
33
+ ] as const) {
34
+ it(`${name} does not report controlled writes as user edits`, async () => {
35
+ const container = createObjectContainer('nativescript')
36
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
37
+ const change = vi.fn()
38
+ let node: any
39
+ let inputHandle: any
40
+ let text = ''
41
+ let selection = [2, 4]
42
+ const bind = (handle: any) => {
43
+ inputHandle = handle
44
+ node = handle.native
45
+ node.style = {}
46
+ node.android = {
47
+ getSelectionStart: () => selection[0],
48
+ getSelectionEnd: () => selection[1],
49
+ setSelection: (start: number, end: number) => {
50
+ selection = [start, end]
51
+ },
52
+ }
53
+
54
+ Object.defineProperty(node, 'text', {
55
+ configurable: true,
56
+ get: () => text,
57
+ set: (value) => {
58
+ text = value
59
+ container.dispatchEvent(node, 'textchange', { object: node, value })
60
+ },
61
+ })
62
+ }
63
+
64
+ root.render(Component as any, { value: 'hello', onChange: change, bind, icon: false })
65
+ await vi.waitFor(() => expect(text).toBe('hello'))
66
+ expect(change).not.toHaveBeenCalled()
67
+ expect(selection).toEqual([2, 4])
68
+ root.render(Component as any, { value: 'x', onChange: change, bind, icon: false })
69
+ await vi.waitFor(() => expect(text).toBe('x'))
70
+ expect(change).not.toHaveBeenCalled()
71
+ expect(selection).toEqual([1, 1])
72
+ // A native edit still reaches the callback exactly once.
73
+ text = 'xy'
74
+ flushUniversalSync(() =>
75
+ container.dispatchEvent(node, 'textchange', { object: node, value: text }),
76
+ )
77
+
78
+ expect(change).toHaveBeenCalledExactlyOnceWith('xy')
79
+ const dismiss = vi.fn()
80
+ const clearFocus = vi.fn()
81
+ node.android.clearFocus = clearFocus
82
+ node.dismissSoftInput = dismiss // NativeScript returns void and has no blur method.
83
+ expect(() => inputHandle.blur()).not.toThrow()
84
+ expect(dismiss).toHaveBeenCalledTimes(1)
85
+ expect(clearFocus).toHaveBeenCalledTimes(1)
86
+ root.unmount()
87
+ })
88
+ }
89
+ })
package/src/text-write.ts CHANGED
@@ -1,4 +1,11 @@
1
- /** Controlled `text` write for TextInput/TextArea leaves. The driver's
1
+ const writing = new WeakSet<object>()
2
+
3
+ /** Whether a textChange is the synchronous echo of our controlled write. */
4
+ export function isWritingText(view: object): boolean {
5
+ return writing.has(view)
6
+ }
7
+
8
+ /** Controlled `text` write for TextInput/TextArea/SearchInput leaves. The driver's
2
9
  * generic prop path writes view.text verbatim; on Android the EditText
3
10
  * setText resets the cursor to 0 on every controlled write. Park and
4
11
  * restore the selection around the write (clamped to the new length so an
@@ -13,16 +20,19 @@ export function writeText(view: any, value: string | undefined | null): void {
13
20
  }
14
21
 
15
22
  const et = view.android
16
- if (!et) {
23
+ const start = et?.getSelectionStart?.() ?? -1
24
+ const end = et?.getSelectionEnd?.() ?? -1
25
+ const nested = writing.has(view)
26
+ writing.add(view)
27
+ try {
17
28
  view.text = next
18
- return
19
- }
20
-
21
- const start = et.getSelectionStart?.() ?? -1
22
- const end = et.getSelectionEnd?.() ?? -1
23
- view.text = next
24
- if (start >= 0) {
25
- const len = next.length
26
- et.setSelection(Math.min(start, len), Math.min(end >= 0 ? end : start, len))
29
+ if (start >= 0) {
30
+ const len = next.length
31
+ et.setSelection(Math.min(start, len), Math.min(end >= 0 ? end : start, len))
32
+ }
33
+ } finally {
34
+ if (!nested) {
35
+ writing.delete(view)
36
+ }
27
37
  }
28
38
  }
@@ -13,15 +13,13 @@ export type { ColorScheme } from '../props'
13
13
  import type { ColorScheme } from '../props'
14
14
 
15
15
  const bridged = () =>
16
- typeof window !== 'undefined' &&
17
- (window as any).webkit?.messageHandlers?.xplat !== undefined
16
+ typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.xplat !== undefined
18
17
 
19
18
  let lastScheme: ColorScheme =
20
19
  ((window as any).__xplatColorScheme as ColorScheme | undefined) ?? 'light'
21
20
 
22
21
  function getSystemScheme(): ColorScheme {
23
- return typeof matchMedia === 'function' &&
24
- matchMedia('(prefers-color-scheme: dark)').matches
22
+ return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches
25
23
  ? 'dark'
26
24
  : 'light'
27
25
  }
@@ -3,16 +3,63 @@ import { useSyncExternalStore } from 'octane'
3
3
  export type { ColorScheme } from '../props'
4
4
  import type { ColorScheme } from '../props'
5
5
 
6
- export function getColorScheme(): ColorScheme {
6
+ type HostTransport = {
7
+ listen(listener: (message: string) => void): () => void
8
+ }
9
+
10
+ const hostTransport = () =>
11
+ typeof window === 'undefined'
12
+ ? null
13
+ : (((window as any).__xplatHostTransport as HostTransport | undefined) ?? null)
14
+
15
+ let hostedScheme: ColorScheme =
16
+ typeof window === 'undefined'
17
+ ? 'light'
18
+ : ((window as any).__xplatHostSnapshot?.colorScheme ??
19
+ (window as any).__xplatColorScheme ??
20
+ 'light')
21
+
22
+ function getSystemScheme(): ColorScheme {
7
23
  return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches
8
24
  ? 'dark'
9
25
  : 'light'
10
26
  }
11
27
 
28
+ export function getColorScheme(): ColorScheme {
29
+ return hostTransport() ? hostedScheme : getSystemScheme()
30
+ }
31
+
12
32
  export function subscribeSystemScheme(cb: () => void): () => void {
13
- const mq = matchMedia('(prefers-color-scheme: dark)')
14
- mq.addEventListener('change', cb)
15
- return () => mq.removeEventListener('change', cb)
33
+ const transport = hostTransport()
34
+ if (!transport) {
35
+ const mq = matchMedia('(prefers-color-scheme: dark)')
36
+ mq.addEventListener('change', cb)
37
+ return () => mq.removeEventListener('change', cb)
38
+ }
39
+
40
+ const onTransportMessage = (message: string) => {
41
+ try {
42
+ const packet = JSON.parse(message) as { type?: string; name?: string; payload?: unknown }
43
+ if (packet.type === 'event' && packet.name === 'appearance.change') {
44
+ hostedScheme = packet.payload === 'dark' ? 'dark' : 'light'
45
+ cb()
46
+ }
47
+ } catch {
48
+ // Host messages are strings; unrelated malformed traffic is ignored.
49
+ }
50
+ }
51
+
52
+ const onCompatEvent = (event: Event) => {
53
+ hostedScheme = (event as CustomEvent).detail === 'dark' ? 'dark' : 'light'
54
+ cb()
55
+ }
56
+
57
+ const offTransport = transport.listen(onTransportMessage)
58
+ window.addEventListener('xplat:appearance.change', onCompatEvent)
59
+ return () => {
60
+ offTransport()
61
+ window.removeEventListener('xplat:appearance.change', onCompatEvent)
62
+ }
16
63
  }
17
64
 
18
65
  export function useColorScheme(): ColorScheme {
@@ -9,7 +9,10 @@ const listeners = new Set<() => void>()
9
9
  const emit = () => listeners.forEach((listener) => listener())
10
10
 
11
11
  export function setThemePreference(value: ThemePreference): void {
12
- if (preference === value) {return}
12
+ if (preference === value) {
13
+ return
14
+ }
15
+
13
16
  preference = value
14
17
  emit()
15
18
  }
@@ -44,7 +47,10 @@ export function onThemeSchemeChange(callback: () => void): () => void {
44
47
  }
45
48
 
46
49
  export function applyThemeClasses(view: any, base = ''): () => void {
47
- const apply = () => { view.className = cx(base, themeSchemeClasses()) }
50
+ const apply = () => {
51
+ view.className = cx(base, themeSchemeClasses())
52
+ }
53
+
48
54
  apply()
49
55
  return onThemeSchemeChange(apply)
50
56
  }