@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
package/src/Sheet.tsrx CHANGED
@@ -7,9 +7,10 @@ import type { SheetProps } from './props';
7
7
  import { cx } from './cx';
8
8
  import { applyThemeClasses } from './theme/theme-scheme';
9
9
  import { rootLayoutFor } from './root-layout.mobile';
10
+ import { createOverlayLifetime } from './overlay-lifetime.mobile';
10
11
  import { attachSheetDetents } from './sheet-detents';
11
12
  import { bindBottomInsetToKeyboard } from './keyboard-inset.mobile';
12
- import { attachTapToBlur } from './tap-to-blur.mobile';
13
+ import { attachTapToBlur } from './tap-to-blur';
13
14
 
14
15
  function SheetBody(props: { children?: any }) @{
15
16
  <>{props.children}</>
@@ -23,28 +24,16 @@ export function Sheet(props: SheetProps) @{
23
24
  const shown = useRef<{
24
25
  host: GridLayout;
25
26
  root: any;
26
- unbindTheme?: () => void;
27
- unbindKeyboard?: () => void;
28
- programmatic?: boolean;
29
- detach?: () => void;
27
+ lifetime: ReturnType<typeof createOverlayLifetime>;
30
28
  } | null>(null);
31
29
 
30
+ const dismiss = useRef(props.onDismiss);
31
+ dismiss.current = props.onDismiss;
32
+
32
33
  useEffect(() => {
33
34
  const cur = shown.current;
34
35
  if (!props.open) {
35
- if (!cur) return;
36
- shown.current = null;
37
- cur.programmatic = true;
38
- cur.unbindTheme?.();
39
- cur.unbindKeyboard?.();
40
- cur.root.unmount?.();
41
- const owner = cur.host.parent as any;
42
- if (owner?.hasChild?.(cur.host)) {
43
- owner.close(cur.host).catch((error: unknown) => {
44
- console.error('[Sheet] close failed', error);
45
- });
46
- }
47
-
36
+ cur?.lifetime.close();
48
37
  return;
49
38
  }
50
39
 
@@ -63,6 +52,12 @@ export function Sheet(props: SheetProps) @{
63
52
  if (props.style) Object.assign(host.style, props.style);
64
53
  Object.assign(host, isIOS ? props.ios : isAndroid ? props.android : undefined);
65
54
  const root = createNativeScriptRoot(host);
55
+ const lifetime = createOverlayLifetime(rl, host, root, 'Sheet', (dismissed) => {
56
+ if (shown.current?.host === host) shown.current = null;
57
+ if (dismissed) dismiss.current?.();
58
+ });
59
+
60
+ lifetime.own(unbindTheme);
66
61
  // Detents: the controller owns enter/slide-out — RootLayout's open
67
62
  // animation always lands translateY at 0, which would fight the
68
63
  // detent offsets, so it's skipped while detents are attached.
@@ -77,23 +72,13 @@ export function Sheet(props: SheetProps) @{
77
72
 
78
73
  // Lift the host above the software keyboard (todo 'Add' row inside a
79
74
  // demo sheet is bottom-anchored and was covered on iOS).
80
- const unbindKeyboard = bindBottomInsetToKeyboard(host);
81
- attachTapToBlur(host);
82
- const opened = { host, root, unbindTheme, unbindKeyboard, programmatic: false, detach: detents?.detach };
83
- shown.current = opened;
75
+ lifetime.own(bindBottomInsetToKeyboard(host));
76
+ lifetime.own(attachTapToBlur(host));
77
+ if (detents) lifetime.own(detents.detach);
78
+ shown.current = { host, root, lifetime };
84
79
  root.render(SheetBody as unknown as UniversalComponent, { children: props.children });
85
- host.on('closed', () => {
86
- if (shown.current === opened) shown.current = null;
87
- opened.detach?.();
88
- opened.unbindKeyboard?.();
89
- opened.unbindTheme?.();
90
- opened.root.unmount?.();
91
- // 'closed' fires for programmatic closes too — onDismiss only when
92
- // the platform dismissed it (shade tap or detent drag-out).
93
- if (!opened.programmatic) props.onDismiss?.();
94
- });
95
80
 
96
- rl.open(host, {
81
+ lifetime.open({
97
82
  ...(props.shadeCover ?? true)
98
83
  ? { shadeCover: { opacity: 0.4, tapToClose: true } }
99
84
  : {},
@@ -105,17 +90,11 @@ export function Sheet(props: SheetProps) @{
105
90
  exitTo: { translateY: 400, duration: 200 },
106
91
  },
107
92
  }),
108
- })
109
- .then(() => detents?.enter())
110
- .catch((error: unknown) => {
111
- if (shown.current === opened) shown.current = null;
112
- opened.unbindKeyboard?.();
113
- opened.unbindTheme?.();
114
- opened.root.unmount?.();
115
- console.error('[Sheet] open failed', error);
116
- });
93
+ }, () => detents?.enter());
117
94
  }, [props.open, props.children, props.className, props.style, props.shadeCover, props.ios, props.android]);
118
95
 
96
+ useEffect(() => () => shown.current?.lifetime.close(), []);
97
+
119
98
  // Zero-size sentinel — the sheet resolves its owning RootLayout from the
120
99
  // declaring component's ancestry, not getRootLayout() (first-mounted shell).
121
100
  <stacklayout ref={(v: any) => { sentinel.current = v; }} style={{ width: 0, height: 0 }} />
@@ -4,6 +4,7 @@ import { cx } from './cx';
4
4
  import { useThemeScheme } from './theme/theme-scheme';
5
5
  import { normalizeDetents } from './sheet-snap';
6
6
  import { attachSheetDetents } from './sheet-detents.web';
7
+ import { isolateModalFocus } from './modal-focus.web';
7
8
 
8
9
  /** In-window bottom sheet — portal layer with a backdrop and a bottom-
9
10
  * anchored panel. Same vocabulary as the native RootLayout sheet.
@@ -13,8 +14,13 @@ import { attachSheetDetents } from './sheet-detents.web';
13
14
  export function Sheet(props: SheetProps) @{
14
15
  const dark = useThemeScheme() === 'dark';
15
16
  const panel = useRef<HTMLElement | null>(null);
17
+ const layer = useRef<HTMLDivElement | null>(null);
16
18
  const dismiss = useRef(props.onDismiss);
17
19
  dismiss.current = props.onDismiss;
20
+ useEffect(() => {
21
+ if (!props.open || props.shadeCover === false || !layer.current || !panel.current) return;
22
+ return isolateModalFocus(layer.current, panel.current, () => dismiss.current?.());
23
+ }, [props.open, props.shadeCover]);
18
24
 
19
25
  const detents = normalizeDetents(props.detents);
20
26
  const maxDetent = detents.length ? detents[detents.length - 1] : 0;
@@ -31,11 +37,13 @@ export function Sheet(props: SheetProps) @{
31
37
 
32
38
  @if (props.open) {
33
39
  <>{createPortal(
34
- <div className={cx('vx-sheet-layer', dark && 'dark')}>
40
+ <div ref={layer} className={cx('vx-sheet-layer', dark && 'dark')}>
35
41
  {(props.shadeCover ?? true) && <div className="vx-sheet-backdrop" onClick={props.onDismiss} />}
36
42
  <div
37
43
  ref={(el: any) => { panel.current = el; }}
38
44
  className={cx('vx-sheet', detents.length && 'vx-sheet-detents', props.className)}
45
+ role={props.shadeCover === false ? undefined : 'dialog'}
46
+ aria-modal={props.shadeCover === false ? undefined : true}
39
47
  style={{
40
48
  ...props.style,
41
49
  ...(detents.length
package/src/Slider.tsrx CHANGED
@@ -22,14 +22,20 @@ export function Slider(props: SliderProps) @{
22
22
  const ratio = Math.min(1, Math.max(0, (value - min) / (max - min)));
23
23
  const width = measure.bounds?.width ?? 0;
24
24
 
25
- // Stable ref identity — an inline ref closure re-fires ref(null)/ref(el)
26
- // per render, which bounces useMeasure's setView and loops forever.
25
+ // Keep one ref identity so native measurement listeners stay attached to
26
+ // the same host across rerenders.
27
27
  const host = useRef<any>(null);
28
28
  const bindHost = useCallback((el: any) => {
29
29
  host.current = el;
30
30
  measure.bind(el);
31
31
  }, []);
32
32
 
33
+ const trackHeight = 4;
34
+ const thumbSize = 20;
35
+ const trackTop = (28 - trackHeight) / 2;
36
+ const thumbTop = (28 - thumbSize) / 2;
37
+ const thumbLeft = Math.min(Math.max(ratio * width - thumbSize / 2, 0), Math.max(width - thumbSize, 0));
38
+
33
39
  useEffect(() => {
34
40
  applyNativeProps(host.current, props);
35
41
  });
@@ -52,7 +58,7 @@ export function Slider(props: SliderProps) @{
52
58
  }
53
59
  });
54
60
 
55
- <gridlayout
61
+ <absolutelayout
56
62
  id={props.id}
57
63
  className={cx('vx-slider', props.className)}
58
64
  style={props.style}
@@ -60,12 +66,8 @@ export function Slider(props: SliderProps) @{
60
66
  onPan={onPan}
61
67
  ref={bindHost}
62
68
  >
63
- <flexboxlayout className="vx-slider-track" />
64
- <flexboxlayout className="vx-slider-fill" horizontalAlignment="left" style={{ width: ratio * width }} />
65
- <flexboxlayout
66
- className="vx-slider-thumb"
67
- horizontalAlignment="left"
68
- style={{ marginLeft: Math.min(Math.max(ratio * width - 10, 0), Math.max(width - 20, 0)) }}
69
- />
70
- </gridlayout>
69
+ <flexboxlayout className="vx-slider-track" style={{ width, translateY: trackTop }} />
70
+ <flexboxlayout className="vx-slider-fill" style={{ width: ratio * width, translateY: trackTop }} />
71
+ <flexboxlayout className="vx-slider-thumb" style={{ translateX: thumbLeft, translateY: thumbTop }} />
72
+ </absolutelayout>
71
73
  }
package/src/Text.tsrx CHANGED
@@ -1,5 +1,6 @@
1
1
  /** @jsxImportSource @nativescript-community/octane */
2
2
  import { createContext, useContext } from 'octane';
3
+ import { Application } from '@nativescript/core';
3
4
  import type { TextProps } from "./props";
4
5
  import { cx } from "./cx";
5
6
  import { applyEscapeProps, nativeAccessibilityRole, nativeAccessibilityState } from "./escape-props";
@@ -24,8 +25,43 @@ function TextScope(props: { children?: any }) {
24
25
 
25
26
  export function Text(props: TextProps) @{
26
27
  const inText = useContext(InText);
28
+ const fontWeightValue = String(props.style?.fontWeight ?? '').toLowerCase();
29
+ const namedWeights: Record<string, number> = {
30
+ normal: 400,
31
+ bold: 700,
32
+ thin: 100,
33
+ 'extra-light': 200,
34
+ light: 300,
35
+ medium: 500,
36
+ 'semi-bold': 600,
37
+ 'extra-bold': 800,
38
+ black: 900,
39
+ };
40
+
41
+ const fontWeight = /^\d+$/.test(fontWeightValue)
42
+ ? Number(fontWeightValue)
43
+ : namedWeights[fontWeightValue];
44
+
45
+ const lineHeight = props.style?.lineHeight;
46
+ // UIFontDescriptor resolves variable fonts to the regular axis default on
47
+ // iOS. Set wght explicitly so CSS fontWeight selects the matching instance.
27
48
  const textStyle = {
28
49
  ...props.style,
50
+ ...((Application.ios || Application.android) &&
51
+ typeof lineHeight === 'number' &&
52
+ Number.isFinite(lineHeight) &&
53
+ lineHeight > 0 &&
54
+ props.style?.height == null &&
55
+ props.style?.minHeight == null
56
+ ? { minHeight: lineHeight }
57
+ : {}),
58
+ ...(Application.ios &&
59
+ props.style?.fontVariationSettings == null &&
60
+ fontWeight !== undefined &&
61
+ fontWeight >= 100 &&
62
+ fontWeight <= 900
63
+ ? { fontVariationSettings: `'wght' ${fontWeight}` }
64
+ : {}),
29
65
  // Clamped text must be allowed to shrink — the shared shrink:0 default
30
66
  // would let the label overflow its row instead of truncating.
31
67
  ...(props.ellipsize || props.numberOfLines ? { flexShrink: 1 } : {}),
package/src/TextArea.tsrx CHANGED
@@ -4,14 +4,15 @@ import { Utils } from '@nativescript/core';
4
4
  import type { TextAreaProps } from "./props";
5
5
  import { cx } from "./cx";
6
6
  import { applyEscapeProps } from "./escape-props";
7
- import { writeText } from './text-write';
7
+ import { isWritingText, writeText } from './text-write';
8
+ import { blurText } from './text-focus';
8
9
 
9
10
  /** Multiline input — <textview>. TextView measures to content natively, so
10
11
  * `autoGrow` is its default; `rows`/`maxRows` have no NS equivalent, so the
11
12
  * leaf converts them to min/maxHeight in dips using the widget's own line
12
13
  * height (iOS `font.lineHeight`, Android `getLineHeight()` px→dip — measured
13
- * on `loaded`, re-fit every commit for prop/style changes). The driver's
14
- * text→textChange echo suppression applies here unchanged. */
14
+ * on `loaded`, re-fit every commit for prop/style changes). Imperative
15
+ * writes suppress their synchronous textChange echo through text-write. */
15
16
  export function TextArea(props: TextAreaProps) @{
16
17
  const el = useRef<any>(null);
17
18
  const submitOnReturn = props.returnKeyType === 'done' || props.returnKeyType === 'send';
@@ -46,7 +47,7 @@ export function TextArea(props: TextAreaProps) @{
46
47
  el.current = v;
47
48
  if (!v) return;
48
49
  applyEscapeProps(v, props);
49
- props.bind?.({ focus: () => v.focus(), blur: () => v.dismissSoftInput?.() ?? v.blur(), native: v });
50
+ props.bind?.({ focus: () => v.focus(), blur: () => blurText(v), native: v });
50
51
  }}
51
52
  className={cx('vx-textarea', props.className)}
52
53
  style={{ ...props.style, ...(props.placeholderTextColor ? { placeholderColor: props.placeholderTextColor } : {}) }}
@@ -55,7 +56,7 @@ export function TextArea(props: TextAreaProps) @{
55
56
  returnKeyType={props.returnKeyType}
56
57
  editable={props.editable ?? true}
57
58
  onLoaded={(e: any) => fit(e.object)}
58
- onTextChange={(e: any) => props.onChange?.(e.value)}
59
+ onTextChange={(e: any) => { if (!isWritingText(e.object)) props.onChange?.(e.value); }}
59
60
  // TextView emits returnPress for every newline. Only an explicit action
60
61
  // key opts into the submit semantic; ordinary Return remains a newline.
61
62
  onReturnPress={submitOnReturn ? props.onSubmit : undefined}
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from 'vitest'
3
+ import { act, createRoot } from 'octane'
4
+ import { TextArea } from './TextArea.web.tsrx'
5
+ const roots:any[]=[]
6
+ function mount(props:any={}) {
7
+ const el=document.createElement('div');document.body.append(el);const root=createRoot(el);roots.push(root)
8
+ let measured=20
9
+ const render=(next:any={}) => act(() => root.render(<TextArea {...props} style={{fontSize:10,lineHeight:"10px",padding:0,borderWidth:0,boxSizing:'border-box'}} {...next} />));
10
+ const descriptor=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'scrollHeight')
11
+ Object.defineProperty(HTMLTextAreaElement.prototype,'scrollHeight',{configurable:true,get:() => measured})
12
+ render();const field=el.querySelector('textarea')!
13
+ const input=(value:string,height:number) => {measured=height;field.value=value;act(() => field.dispatchEvent(new Event('input',{bubbles:true})))}
14
+ const measure=(height:number) => {measured=height}
15
+ return {field,render,input,measure,restore:() => {if(descriptor) Object.defineProperty(HTMLTextAreaElement.prototype,'scrollHeight',descriptor);else delete (HTMLTextAreaElement.prototype as any).scrollHeight}}
16
+ }
17
+
18
+ const restorers:(() => void)[]=[]
19
+ afterEach(() => {for(const root of roots.splice(0)) act(() => root.unmount());for(const restore of restorers.splice(0)) restore();document.body.innerHTML='';vi.restoreAllMocks()})
20
+ it('grows and shrinks an uncontrolled field from actual input', () => {
21
+ const onChange=vi.fn();const m=mount({autoGrow:true,onChange});restorers.push(m.restore)
22
+ m.input('one\ntwo\nthree',80);expect(m.field.style.height).toBe('80px');expect(onChange).toHaveBeenCalledWith('one\ntwo\nthree')
23
+ m.input('one',20);expect(m.field.style.height).toBe('20px');expect(m.field.style.overflowY).toBe('hidden')
24
+ })
25
+
26
+ it('caps uncontrolled growth at maxRows and scrolls past the cap', () => {
27
+ const m=mount({autoGrow:true,rows:2,maxRows:3});restorers.push(m.restore)
28
+ expect(m.field.rows).toBe(2);m.input('many lines',80);expect(m.field.style.height).toBe('30px');expect(m.field.style.overflowY).toBe('auto')
29
+ m.input('short',20);expect(m.field.style.height).toBe('20px');expect(m.field.style.overflowY).toBe('hidden')
30
+ })
31
+
32
+ it('still fits controlled value updates', () => {
33
+ const m=mount({autoGrow:true,value:'one'});restorers.push(m.restore);m.measure(70);m.render({value:'one\ntwo\nthree'})
34
+ expect(m.field.style.height).toBe('70px');expect(m.field.value).toBe('one\ntwo\nthree')
35
+ })
36
+
37
+ it('leaves fixed-row inputs at their declared rows', () => {
38
+ const m=mount({rows:4});restorers.push(m.restore);m.input('many lines',80);expect(m.field.rows).toBe(4);expect(m.field.style.height).toBe('')
39
+ })
@@ -4,7 +4,7 @@ import type { TextAreaProps } from "./props";
4
4
  import { applyEscapeProps } from "./escape-props";
5
5
 
6
6
  /** Multiline input — <textarea>. `autoGrow` re-fits height to content after
7
- * every commit via scrollHeight, capped by `maxRows` translated to a real
7
+ * input and value updates via scrollHeight, capped by `maxRows` translated to a real
8
8
  * max-height from the element's computed line-height; past the cap the
9
9
  * field scrolls. `useLayoutEffect` so the measure lands pre-paint in the
10
10
  * commit that wrote `value`. Deps are explicit — tsrx infers them from the
@@ -13,7 +13,7 @@ import { applyEscapeProps } from "./escape-props";
13
13
  export function TextArea(props: TextAreaProps) @{
14
14
  const el = useRef<HTMLTextAreaElement | null>(null);
15
15
 
16
- useLayoutEffect(() => {
16
+ const fit = () => {
17
17
  const t = el.current;
18
18
  if (!t) return;
19
19
  const cs = getComputedStyle(t);
@@ -35,7 +35,9 @@ export function TextArea(props: TextAreaProps) @{
35
35
  t.style.height = Math.min(need, cap) + 'px';
36
36
  t.style.overflowY = need > cap ? 'auto' : 'hidden';
37
37
  }
38
- }, [props.value, props.rows, props.autoGrow, props.maxRows, props.className, props.style]);
38
+ };
39
+
40
+ useLayoutEffect(fit, [props.value, props.rows, props.autoGrow, props.maxRows, props.className, props.style]);
39
41
 
40
42
  <textarea
41
43
  ref={(node: any) => {
@@ -53,8 +55,8 @@ export function TextArea(props: TextAreaProps) @{
53
55
  readOnly={props.editable === false}
54
56
  placeholder={props.placeholder}
55
57
  rows={props.rows ?? (props.autoGrow ? 1 : 3)}
56
- onInput={(e: any) => props.onChange?.(e.target.value)}
57
- onKeyDown={(e: any) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) props.onSubmit?.(); }}
58
+ onInput={(e: any) => { fit(); props.onChange?.(e.target.value); }}
59
+ onKeyDown={(e: any) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.isComposing && e.keyCode !== 229) props.onSubmit?.(); }}
58
60
  onFocus={props.onFocus}
59
61
  onBlur={props.onBlur}
60
62
  />
@@ -3,11 +3,11 @@ import { useEffect, useRef } from 'octane';
3
3
  import type { TextInputProps } from "./props";
4
4
  import { cx } from "./cx";
5
5
  import { applyEscapeProps } from "./escape-props";
6
- import { writeText } from './text-write';
6
+ import { isWritingText, writeText } from './text-write';
7
+ import { blurText } from './text-focus';
7
8
 
8
- /** Controlled single-line input. The driver suppresses the textChange echo
9
- * of its own `text` writes (upstream #5, since 0.2.1) — no leaf-side
10
- * guard needed. `editable` defaults true at the leaf too: an undefined
9
+ /** Controlled single-line input. Imperative writes suppress their synchronous
10
+ * textChange echo through text-write. `editable` defaults true: an undefined
11
11
  * write coerces ios.userInteractionEnabled to NO (dead field) — the
12
12
  * driver's skip-undefined patch covers it, the ?? stays as documentation.
13
13
  * `text` isn't a bound prop: the leaf writes it imperatively so the
@@ -21,7 +21,7 @@ export function TextInput(props: TextInputProps) @{
21
21
  el.current = v;
22
22
  if (!v) return;
23
23
  applyEscapeProps(v, props);
24
- props.bind?.({ focus: () => v.focus(), blur: () => v.dismissSoftInput?.() ?? v.blur(), native: v });
24
+ props.bind?.({ focus: () => v.focus(), blur: () => blurText(v), native: v });
25
25
  }}
26
26
  id={props.id}
27
27
  className={cx('vx-input', props.className)}
@@ -31,7 +31,7 @@ export function TextInput(props: TextInputProps) @{
31
31
  keyboardType={props.keyboardType === 'default' ? undefined : props.keyboardType}
32
32
  returnKeyType={props.returnKeyType}
33
33
  editable={props.editable ?? true}
34
- onTextChange={(e: any) => props.onChange?.(e.value)}
34
+ onTextChange={(e: any) => { if (!isWritingText(e.object)) props.onChange?.(e.value); }}
35
35
  onReturnPress={props.onSubmit}
36
36
  onFocus={props.onFocus}
37
37
  onBlur={props.onBlur}
@@ -21,7 +21,7 @@ export function TextInput(props: TextInputProps) @{
21
21
  readOnly={props.editable === false}
22
22
  placeholder={props.placeholder}
23
23
  onInput={(e: any) => props.onChange?.(e.target.value)}
24
- onKeyDown={(e: any) => { if (e.key === 'Enter' && !e.shiftKey) props.onSubmit?.(); }}
24
+ onKeyDown={(e: any) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) props.onSubmit?.(); }}
25
25
  onFocus={props.onFocus}
26
26
  onBlur={props.onBlur}
27
27
  />
@@ -70,6 +70,8 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
70
70
  const indexByRowKeyRef = useRef(new Map<string, number>())
71
71
  const scrollRef = useRef<any>(null)
72
72
  const scrollOffsetRef = useRef(0)
73
+ const topSpacerRef = useRef<any>(null)
74
+ const topSpacerHeightRef = useRef(0)
73
75
  const viewportHeightRef = useRef(0)
74
76
  const viewportWidthRef = useRef(0)
75
77
  const headerHeightRef = useRef(0)
@@ -80,6 +82,12 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
80
82
  const estimateAnchorRef = useRef<{ rowKey: string; within: number } | null>(null)
81
83
  const estimateTimerRef = useRef<any>(null)
82
84
  const pendingRowMeasureRef = useRef(new Map<string, number>())
85
+ const programmaticWriteRef = useRef<{ offset: number; until: number } | null>(null)
86
+ /** Convergent deep seek state (FlashList v2's scrollToIndex model): a large
87
+ * external scroll jump is tracked and anchor-restore writes are suppressed
88
+ * while measurements converge — the clip already sits at the seek target,
89
+ * and re-anchoring would rewrite it to the biased estimated prefix. */
90
+ const seekStateRef = useRef<{ target: number; until: number } | null>(null)
83
91
  const hasSeparator = Boolean(props.renderSeparator)
84
92
  const hasSeparatorRef = useRef(hasSeparator)
85
93
  hasSeparatorRef.current = hasSeparator
@@ -146,8 +154,14 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
146
154
  return { rowKey: entry.rowKey, within: listOffset - sizeIndexRef.current.prefix(index) }
147
155
  }
148
156
 
157
+ function activeSeek() {
158
+ const seek = seekStateRef.current
159
+ if (seek && Date.now() > seek.until) {seekStateRef.current = null}
160
+ return seekStateRef.current
161
+ }
162
+
149
163
  function restoreAnchor(anchor: { rowKey: string; within: number } | null) {
150
- if (!anchor) {return}
164
+ if (!anchor || activeSeek()) {return}
151
165
  queuedAnchorRef.current = anchor
152
166
  if (anchorRestoreScheduledRef.current) {return}
153
167
  const scroll = scrollRef.current
@@ -158,6 +172,9 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
158
172
  if (anchorRestoreTimerRef.current !== timer) {return}
159
173
  anchorRestoreTimerRef.current = null
160
174
  anchorRestoreScheduledRef.current = false
175
+ // A restore scheduled before a deep seek must not fire its write
176
+ // mid-convergence — it would move the clip off the seek target.
177
+ if (activeSeek()) {queuedAnchorRef.current = null; return}
161
178
  if (scrollRef.current !== scroll) {return}
162
179
  const queued = queuedAnchorRef.current
163
180
  queuedAnchorRef.current = null
@@ -169,7 +186,8 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
169
186
  headerHeightRef.current + sizeIndexRef.current.prefix(index) + queued.within,
170
187
  )
171
188
 
172
- if (sameSize(nextOffset, scrollViewOffset(scroll))) {return}
189
+ if (sameSize(nextOffset, scrollOffsetRef.current)) {return}
190
+ programmaticWriteRef.current = { offset: nextOffset, until: Date.now() + 250 }
173
191
  scrollViewSetTop(scroll, nextOffset)
174
192
  }, 0)
175
193
 
@@ -184,6 +202,14 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
184
202
  // relayout/anchor cycle count bounded while samples accumulate.
185
203
  estimateTimerRef.current = setTimeout(() => {
186
204
  estimateTimerRef.current = null
205
+ // FlashList v2's scrollToIndex model: correction passes stay
206
+ // paused for the whole seek — sizes keep accumulating in the
207
+ // cache/index and one rebuild runs once the seek expires.
208
+ if (activeSeek()) {
209
+ scheduleEstimateRebuild(null)
210
+ return
211
+ }
212
+
187
213
  const scheduledAnchor = estimateAnchorRef.current
188
214
  estimateAnchorRef.current = null
189
215
  rebuildLayout(entriesRef.current, viewportWidthRef.current)
@@ -207,6 +233,12 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
207
233
  // re-run this whole path (and reschedule estimate rebuilds) forever.
208
234
  if (cached && sameSize(cached.height, height) && sameSize(cached.width, width)) {return}
209
235
 
236
+ // Keep the seek suppression alive through the measurement storm — the
237
+ // seek ends ~400 ms after the last new/changed size lands (Legend's
238
+ // idle-settle model), not on the first quiet rebuild.
239
+ const seek = activeSeek()
240
+ if (seek) {seek.until = Date.now() + 400}
241
+
210
242
  const anchor = queuedAnchorRef.current ?? captureAnchor()
211
243
  sizeCacheRef.current.set(cacheKey, { width, height })
212
244
 
@@ -237,10 +269,58 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
237
269
  }
238
270
 
239
271
  function onScroll(event: AppKitScrollEvent) {
240
- // The event's verticalOffset is the raw clip bounds origin — bottom-up
241
- // on a non-flipped document — so derive the top-down offset ourselves.
242
272
  const scroll = scrollRef.current
243
- scrollOffsetRef.current = scroll ? scrollViewOffset(scroll) : Number(event?.verticalOffset ?? 0)
273
+ // Prefer the spacer-landmark offset: the top spacer's bottom edge sits
274
+ // at content position `range.top`, so measuring the clip against it
275
+ // reports DOM-style scrollTop with no docH term. Estimate rebuilds
276
+ // resize the document constantly and would phantom-drift the
277
+ // docHeight-derived offset even when the clip never moved.
278
+ const clip = scroll?.contentView
279
+ const spacerBottomY = Number(topSpacerRef.current?.frame?.origin?.y ?? Number.NaN)
280
+ const doc = scroll?.documentView
281
+ const flip = typeof doc?.isFlipped === 'function' ? doc.isFlipped() : doc?.isFlipped
282
+ const flipped = flip === true || Number(flip) === 1
283
+ const originY = Number(clip?.bounds?.origin?.y ?? event?.verticalOffset ?? 0)
284
+ const clipH = Number(clip?.bounds?.size?.height ?? 0)
285
+ let nextOffset: number
286
+ if (Number.isFinite(spacerBottomY)) {
287
+ const clipTopY = flipped ? originY : originY + clipH
288
+ const spacerBottomContent = flipped
289
+ ? Number(topSpacerRef.current?.frame?.size?.height ?? 0) + spacerBottomY
290
+ : spacerBottomY
291
+
292
+ nextOffset =
293
+ headerHeightRef.current +
294
+ topSpacerHeightRef.current +
295
+ (flipped ? clipTopY - spacerBottomContent : spacerBottomY - clipTopY)
296
+ } else {
297
+ nextOffset = scroll ? scrollViewOffset(scroll) : Number(event?.verticalOffset ?? 0)
298
+ }
299
+
300
+ if (props.id) {
301
+ const published = ((globalThis as any).__xplatVlistOffsets ??= {} as Record<string, number>)
302
+ published[props.id] = nextOffset
303
+ }
304
+
305
+ let ownWrite = false
306
+ const wrote = programmaticWriteRef.current
307
+ if (wrote !== null) {
308
+ if (sameSize(nextOffset, wrote.offset)) {
309
+ programmaticWriteRef.current = null
310
+ ownWrite = true
311
+ } else if (Date.now() > wrote.until) {
312
+ programmaticWriteRef.current = null
313
+ }
314
+ }
315
+
316
+ const jumpThreshold = Math.max(200, viewportHeightRef.current * 1.5)
317
+ // A jump that doesn't match a pending anchor write is an external seek —
318
+ // including one that lands while a stale write flag is still open.
319
+ if (!ownWrite && Math.abs(nextOffset - scrollOffsetRef.current) > jumpThreshold) {
320
+ seekStateRef.current = { target: nextOffset, until: Date.now() + 1000 }
321
+ }
322
+
323
+ scrollOffsetRef.current = nextOffset
244
324
  viewportHeightRef.current = Number(event?.viewportHeight ?? viewportHeightRef.current)
245
325
  refreshWindow()
246
326
  }
@@ -278,6 +358,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
278
358
 
279
359
  const range = currentRange()
280
360
  rangeRef.current = range
361
+ topSpacerHeightRef.current = range.top
281
362
  void windowVersion
282
363
  const visibleEntries = entries.slice(range.start, range.end)
283
364
 
@@ -311,7 +392,13 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
311
392
  }
312
393
  } @else {
313
394
  <flexboxlayout flexDirection="column">
314
- <flexboxlayout className="vx-virtual-list-spacer" style={{ height: range.top }} />
395
+ <flexboxlayout
396
+ className="vx-virtual-list-spacer"
397
+ style={{ height: range.top }}
398
+ ref={(view: any) => {
399
+ topSpacerRef.current = view
400
+ }}
401
+ />
315
402
  @for (const entry of visibleEntries; key entry.rowKey) {
316
403
  <flexboxlayout
317
404
  flexDirection="column"