@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
@@ -36,11 +36,13 @@ export function Pagination(props: PaginationProps) @{
36
36
  >
37
37
  <flexboxlayout className="vx-page-btn" onTap={() => go(current - 1)}><label text="‹" /></flexboxlayout>
38
38
  @for (const e of pages; key e.key) {
39
- {e.page == null
40
- ? <label className="vx-page-ellipsis" text="…" />
41
- : <flexboxlayout className={cx('vx-page-btn', e.page === current && 'vx-page-btn--on')} onTap={() => go(e.page!)}>
39
+ @if (e.page == null) {
40
+ <label className="vx-page-ellipsis" text="…" />
41
+ } @else {
42
+ <flexboxlayout className={cx('vx-page-btn', e.page === current && 'vx-page-btn--on')} onTap={() => go(e.page!)}>
42
43
  <label text={String(e.page)} />
43
- </flexboxlayout>}
44
+ </flexboxlayout>
45
+ }
44
46
  }
45
47
  <flexboxlayout className="vx-page-btn" onTap={() => go(current + 1)}><label text="›" /></flexboxlayout>
46
48
  </flexboxlayout>
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @xplat/macos/renderer */
2
- import { useState } from 'octane';
2
+ import { useEffect, useRef, useState } from 'octane';
3
3
  import type { PinInputProps, TextInputHandle } from "./props";
4
4
  import { cx } from "./cx";
5
5
  import { applyEscapeProps } from "./escape-props";
@@ -12,7 +12,15 @@ export function PinInput(props: PinInputProps) @{
12
12
  const [internal, setInternal] = useState('');
13
13
  const controlled = props.value !== undefined;
14
14
  const value = controlled ? props.value! : internal;
15
- const handles: (TextInputHandle | null)[] = [];
15
+ const handles = useRef<(TextInputHandle | null)[]>([]);
16
+ const pendingFocus = useRef<number | null>(null);
17
+ useEffect(() => {
18
+ const next = pendingFocus.current;
19
+ if (next !== null && next <= value.length) {
20
+ pendingFocus.current = null;
21
+ handles.current[next]?.focus();
22
+ }
23
+ }, [value]);
16
24
 
17
25
  const setValue = (next: string) => {
18
26
  const clipped = next.slice(0, length);
@@ -24,10 +32,16 @@ export function PinInput(props: PinInputProps) @{
24
32
  const cellValue = (i: number) => value[i] ?? '';
25
33
 
26
34
  const enter = (i: number, text: string) => {
35
+ if (props.disabled || i > value.length) return;
27
36
  const ch = text.slice(-1);
28
- const next = (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length);
37
+ const next = ch ? (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length) : value.slice(0, i);
38
+ pendingFocus.current = ch && i < length - 1 ? i + 1 : null;
29
39
  setValue(next);
30
- if (ch && i < length - 1) handles[i + 1]?.focus();
40
+ if (pendingFocus.current !== null && pendingFocus.current <= value.length) {
41
+ const index = pendingFocus.current;
42
+ pendingFocus.current = null;
43
+ handles.current[index]?.focus();
44
+ }
31
45
  };
32
46
 
33
47
  <flexboxlayout ref={(v: any) => applyEscapeProps(v, props)} id={props.id} flexDirection="row" className={cx('vx-pininput', props.disabled && 'vx-pininput--disabled', props.className)} style={props.style}>
@@ -36,10 +50,10 @@ export function PinInput(props: PinInputProps) @{
36
50
  className="vx-pininput-cell"
37
51
  keyboardType="number"
38
52
  secure={props.secure}
39
- editable={!props.disabled}
53
+ editable={!props.disabled && i <= value.length}
40
54
  value={cellValue(i)}
41
55
  onChange={(text: string) => enter(i, text)}
42
- bind={(h: TextInputHandle) => { handles[i] = h; }}
56
+ bind={(h: TextInputHandle) => { handles.current[i] = h; }}
43
57
  />
44
58
  }
45
59
  </flexboxlayout>
@@ -0,0 +1,107 @@
1
+ import { afterEach, expect, it, vi } from 'vitest'
2
+ import {
3
+ createObjectContainer,
4
+ createObjectDriver,
5
+ createUniversalRoot,
6
+ flushUniversalSync,
7
+ } from 'octane/universal/native'
8
+
9
+ vi.mock('./escape-props', () => ({ applyEscapeProps() {} }))
10
+ vi.mock('./TextInput.tsrx', async () => {
11
+ const { defineUniversalComponent, universalPlan, universalValue, useRef } =
12
+ await import('octane/universal/native')
13
+
14
+ const plan = universalPlan('nativescript', {
15
+ kind: 'host',
16
+ type: 'test-input',
17
+ bindings: [
18
+ ['value', 0],
19
+ ['editable', 1],
20
+ ['cell', 2],
21
+ ],
22
+ })
23
+
24
+ return {
25
+ TextInput: defineUniversalComponent('nativescript', (props: any) => {
26
+ const handle = useRef({ focus: vi.fn(), blur: vi.fn(), native: {} })
27
+ props.bind?.(handle.current)
28
+ return universalValue(plan, [
29
+ props.value,
30
+ props.editable,
31
+ { onChange: props.onChange, focus: handle.current.focus },
32
+ ])
33
+ }),
34
+ }
35
+ })
36
+
37
+ import { PinInput } from './PinInput.tsrx'
38
+ const roots: any[] = []
39
+ function mount(props: any = {}) {
40
+ const container = createObjectContainer('nativescript')
41
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
42
+ roots.push(root)
43
+ root.render(PinInput as any, props)
44
+ flushUniversalSync(() => {})
45
+ const fields = (): any[] => {
46
+ const found: any[] = []
47
+ const walk = (node: any) => {
48
+ if (node.type === 'test-input') {
49
+ found.push(node)
50
+ }
51
+
52
+ for (const child of node.children ?? []) {
53
+ walk(child)
54
+ }
55
+ }
56
+
57
+ walk(container)
58
+ return found
59
+ }
60
+
61
+ const enter = (i: number, value: string) =>
62
+ flushUniversalSync(() => {
63
+ fields()[i].props.cell.onChange(value)
64
+ })
65
+
66
+ return { fields, enter }
67
+ }
68
+
69
+ afterEach(() => {
70
+ for (const root of roots.splice(0)) {
71
+ root.unmount()
72
+ }
73
+ })
74
+
75
+ it('clears a native controlled cell and its suffix', () => {
76
+ const change = vi.fn()
77
+ const { enter } = mount({ value: '1234', onValueChange: change })
78
+ enter(1, '')
79
+ expect(change).toHaveBeenCalledWith('1')
80
+ })
81
+
82
+ it('advances native focus after enabling the next cell and preserves handles across edits', async () => {
83
+ const complete = vi.fn()
84
+ const { fields, enter } = mount({ onComplete: complete })
85
+ expect(fields().map((field) => field.props.editable)).toEqual([true, false, false, false])
86
+ for (let i = 0; i < 4; i++) {
87
+ enter(i, String(i + 1))
88
+ await vi.waitFor(() => expect(fields()[i].props.value).toBe(String(i + 1)))
89
+ if (i < 3) {
90
+ await vi.waitFor(() => expect(fields()[i + 1].props.cell.focus).toHaveBeenCalled())
91
+ }
92
+ }
93
+
94
+ expect(complete).toHaveBeenCalledWith('1234')
95
+ enter(1, '')
96
+ await vi.waitFor(() => expect(fields().map((f) => f.props.value)).toEqual(['1', '', '', '']))
97
+ })
98
+
99
+ it('ignores disabled edits and edits beyond the next empty cell', () => {
100
+ const change = vi.fn()
101
+ const { enter } = mount({ onValueChange: change })
102
+ enter(3, '9')
103
+ expect(change).not.toHaveBeenCalled()
104
+ const disabled = mount({ disabled: true, value: '1234', onValueChange: change })
105
+ disabled.enter(1, '9')
106
+ expect(change).not.toHaveBeenCalled()
107
+ })
package/src/PinInput.tsrx CHANGED
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @nativescript-community/octane */
2
- import { useState } from 'octane';
2
+ import { useEffect, useRef, useState } from 'octane';
3
3
  import type { PinInputProps, TextInputHandle } from "./props";
4
4
  import { cx } from "./cx";
5
5
  import { applyEscapeProps } from "./escape-props";
@@ -12,7 +12,15 @@ export function PinInput(props: PinInputProps) @{
12
12
  const [internal, setInternal] = useState('');
13
13
  const controlled = props.value !== undefined;
14
14
  const value = controlled ? props.value! : internal;
15
- const handles: (TextInputHandle | null)[] = [];
15
+ const handles = useRef<(TextInputHandle | null)[]>([]);
16
+ const pendingFocus = useRef<number | null>(null);
17
+ useEffect(() => {
18
+ const next = pendingFocus.current;
19
+ if (next !== null && next <= value.length) {
20
+ pendingFocus.current = null;
21
+ handles.current[next]?.focus();
22
+ }
23
+ }, [value]);
16
24
 
17
25
  const setValue = (next: string) => {
18
26
  const clipped = next.slice(0, length);
@@ -24,10 +32,16 @@ export function PinInput(props: PinInputProps) @{
24
32
  const cellValue = (i: number) => value[i] ?? '';
25
33
 
26
34
  const enter = (i: number, text: string) => {
35
+ if (props.disabled || i > value.length) return;
27
36
  const ch = text.slice(-1);
28
- const next = (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length);
37
+ const next = ch ? (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length) : value.slice(0, i);
38
+ pendingFocus.current = ch && i < length - 1 ? i + 1 : null;
29
39
  setValue(next);
30
- if (ch && i < length - 1) handles[i + 1]?.focus();
40
+ if (pendingFocus.current !== null && pendingFocus.current <= value.length) {
41
+ const index = pendingFocus.current;
42
+ pendingFocus.current = null;
43
+ handles.current[index]?.focus();
44
+ }
31
45
  };
32
46
 
33
47
  <flexboxlayout ref={(v: any) => applyEscapeProps(v, props)} id={props.id} flexDirection="row" className={cx('vx-pininput', props.disabled && 'vx-pininput--disabled', props.className)} style={props.style}>
@@ -36,10 +50,10 @@ export function PinInput(props: PinInputProps) @{
36
50
  className="vx-pininput-cell"
37
51
  keyboardType="number"
38
52
  secure={props.secure}
39
- editable={!props.disabled}
53
+ editable={!props.disabled && i <= value.length}
40
54
  value={cellValue(i)}
41
55
  onChange={(text: string) => enter(i, text)}
42
- bind={(h: TextInputHandle) => { handles[i] = h; }}
56
+ bind={(h: TextInputHandle) => { handles.current[i] = h; }}
43
57
  />
44
58
  }
45
59
  </flexboxlayout>
@@ -0,0 +1,42 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from 'vitest'
3
+ import { act, createRoot } from 'octane'
4
+ import { PinInput } from './PinInput.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
+ const render=(next:any={}) => act(() => root.render(<PinInput {...props} {...next} />));render()
9
+ const inputs=() => Array.from(el.querySelectorAll('input'))
10
+ const enter=(i:number,value:string) => {const field=inputs()[i];field.value=value;act(() => field.dispatchEvent(new Event('input',{bubbles:true})))}
11
+ return {render,inputs,enter}
12
+ }
13
+
14
+ afterEach(() => {for (const root of roots.splice(0)) act(() => root.unmount());document.body.innerHTML=''})
15
+ it('clears the edited cell and suffix of a controlled PIN', () => {
16
+ const change=vi.fn(),complete=vi.fn();const {enter}=mount({value:'1234',onValueChange:change,onComplete:complete});enter(1,'')
17
+ expect(change).toHaveBeenCalledWith('1');expect(complete).not.toHaveBeenCalled()
18
+ })
19
+
20
+ it('keeps editing contiguous and advances focus through an uncontrolled PIN', () => {
21
+ const change=vi.fn(),complete=vi.fn();const {inputs,enter}=mount({onValueChange:change,onComplete:complete})
22
+ expect(inputs().map(i => i.readOnly)).toEqual([false,true,true,true])
23
+ for (let i=0;i<4;i++) {enter(i,String(i+1));if (i<3) expect(document.activeElement).toBe(inputs()[i+1])}
24
+ expect(inputs().map(i=>i.value)).toEqual(['1','2','3','4']);expect(complete).toHaveBeenCalledTimes(1);expect(complete).toHaveBeenCalledWith('1234')
25
+ enter(1,'');expect(inputs().map(i=>i.value)).toEqual(['1','','','']);expect(change).toHaveBeenLastCalledWith('1')
26
+ enter(1,'5');expect(document.activeElement).toBe(inputs()[2]);expect(inputs().map(i=>i.value)).toEqual(['1','5','',''])
27
+ expect(complete).toHaveBeenCalledTimes(1)
28
+ })
29
+
30
+ it('replaces a filled cell without shifting later cells', () => {
31
+ const change=vi.fn(),complete=vi.fn();const {enter}=mount({value:'1234',onValueChange:change,onComplete:complete});enter(1,'29')
32
+ expect(change).toHaveBeenCalledWith('1934');expect(complete).toHaveBeenCalledWith('1934')
33
+ })
34
+
35
+ it('does not relocate input from a later empty cell to the beginning', () => {
36
+ const change=vi.fn();const {enter}=mount({onValueChange:change});enter(3,'9');expect(change).not.toHaveBeenCalled()
37
+ })
38
+
39
+ it('respects disabled cells and controlled updates', () => {
40
+ const {render,inputs}=mount({value:'1234',disabled:true});expect(inputs().every(i=>i.readOnly)).toBe(true)
41
+ render({value:'12',disabled:false});expect(inputs().map(i=>i.value)).toEqual(['1','2','','']);expect(inputs().map(i=>i.readOnly)).toEqual([false,false,false,true])
42
+ })
@@ -1,4 +1,4 @@
1
- import { useState } from 'octane';
1
+ import { useEffect, useRef, useState } from 'octane';
2
2
  import type { PinInputProps, TextInputHandle } from "./props";
3
3
  import { applyEscapeProps } from "./escape-props";
4
4
  import { TextInput } from './TextInput.web.tsrx';
@@ -10,7 +10,15 @@ export function PinInput(props: PinInputProps) @{
10
10
  const [internal, setInternal] = useState('');
11
11
  const controlled = props.value !== undefined;
12
12
  const value = controlled ? props.value! : internal;
13
- const handles: (TextInputHandle | null)[] = [];
13
+ const handles = useRef<(TextInputHandle | null)[]>([]);
14
+ const pendingFocus = useRef<number | null>(null);
15
+ useEffect(() => {
16
+ const next = pendingFocus.current;
17
+ if (next !== null && next <= value.length) {
18
+ pendingFocus.current = null;
19
+ handles.current[next]?.focus();
20
+ }
21
+ }, [value]);
14
22
 
15
23
  const setValue = (next: string) => {
16
24
  const clipped = next.slice(0, length);
@@ -22,10 +30,16 @@ export function PinInput(props: PinInputProps) @{
22
30
  const cellValue = (i: number) => value[i] ?? '';
23
31
 
24
32
  const enter = (i: number, text: string) => {
33
+ if (props.disabled || i > value.length) return;
25
34
  const ch = text.slice(-1);
26
- const next = (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length);
35
+ const next = ch ? (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length) : value.slice(0, i);
36
+ pendingFocus.current = ch && i < length - 1 ? i + 1 : null;
27
37
  setValue(next);
28
- if (ch && i < length - 1) handles[i + 1]?.focus();
38
+ if (pendingFocus.current !== null && pendingFocus.current <= value.length) {
39
+ const index = pendingFocus.current;
40
+ pendingFocus.current = null;
41
+ handles.current[index]?.focus();
42
+ }
29
43
  };
30
44
 
31
45
  <div ref={(e: any) => applyEscapeProps(e, props)} id={props.id} className={['vx-pininput', props.disabled ? 'vx-pininput--disabled' : undefined, props.className]} style={props.style} role="group">
@@ -35,10 +49,10 @@ export function PinInput(props: PinInputProps) @{
35
49
  className="vx-pininput-cell"
36
50
  keyboardType="number"
37
51
  secure={props.secure}
38
- editable={!props.disabled}
52
+ editable={!props.disabled && i <= value.length}
39
53
  value={cellValue(i)}
40
54
  onChange={(text: string) => enter(i, text)}
41
- bind={(h: TextInputHandle) => { handles[i] = h; }}
55
+ bind={(h: TextInputHandle) => { handles.current[i] = h; }}
42
56
  />
43
57
  ))}
44
58
  </div>
package/src/Popover.tsrx CHANGED
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource @nativescript-community/octane */
2
2
  import { useEffect, useRef, useState } from 'octane';
3
- import { GridLayout } from '@nativescript/core';
3
+ import { GridLayout, isIOS, Screen } from '@nativescript/core';
4
4
  import { createNativeScriptRoot } from '@nativescript-community/octane';
5
5
  import type { UniversalComponent } from 'octane/universal';
6
6
  import type { PopoverProps } from './props';
@@ -9,6 +9,7 @@ import { cx } from './cx';
9
9
  import { positionPopover, type PopoverPosition } from './popover-position';
10
10
  import { applyThemeClasses, useThemeScheme } from './theme/theme-scheme';
11
11
  import { rootLayoutFor } from './root-layout.mobile';
12
+ import { createOverlayLifetime } from './overlay-lifetime.mobile';
12
13
 
13
14
  function PopoverLayer(props: PopoverProps & { anchorView: any }) @{
14
15
  const panelRef = useRef<any>(null);
@@ -39,12 +40,20 @@ function PopoverLayer(props: PopoverProps & { anchorView: any }) @{
39
40
  props.placement ?? 'bottom',
40
41
  );
41
42
 
43
+ const insets = isIOS ? panelRef.current?.parent?.getSafeAreaInsets?.() : undefined;
44
+ const scale = isIOS ? Number(Screen.mainScreen.scale) || 1 : 1;
45
+ const panelPosition = {
46
+ ...next,
47
+ left: next.left - Number(insets?.left ?? 0) / scale,
48
+ top: next.top - Number(insets?.top ?? 0) / scale,
49
+ };
50
+
42
51
  setPosition((current) =>
43
- current?.left === next.left &&
44
- current?.top === next.top &&
45
- current?.placement === next.placement
52
+ current?.left === panelPosition.left &&
53
+ current?.top === panelPosition.top &&
54
+ current?.placement === panelPosition.placement
46
55
  ? current
47
- : next,
56
+ : panelPosition,
48
57
  );
49
58
 
50
59
  if (panelSize?.width > 0 && panelSize?.height > 0) setSettled(true);
@@ -73,6 +82,14 @@ function PopoverLayer(props: PopoverProps & { anchorView: any }) @{
73
82
  };
74
83
  }, [props.open, props.anchorView, props.placement, props.children, props.style]);
75
84
 
85
+ useEffect(() => {
86
+ if (position) {
87
+ const panel = panelRef.current;
88
+ const parent = panel?.parent;
89
+ parent?.requestLayout?.();
90
+ }
91
+ }, [position]);
92
+
76
93
  useEffect(() => {
77
94
  applyNativeProps(panelRef.current, props);
78
95
  }, [props.ios, props.android, settled]);
@@ -111,22 +128,15 @@ function PopoverLayer(props: PopoverProps & { anchorView: any }) @{
111
128
  * Overlay: a transparent host opened on the RootLayout enclosing the
112
129
  * anchor, with PopoverLayer positioning itself over the anchor's rect. */
113
130
  export function Popover(props: PopoverProps) @{
114
- const shown = useRef<{ host: any; root: any; unbindTheme?: () => void } | null>(null);
131
+ const shown = useRef<{ host: any; root: any; lifetime: ReturnType<typeof createOverlayLifetime> } | null>(null);
132
+
133
+ const dismiss = useRef(props.onDismiss);
134
+ dismiss.current = props.onDismiss;
115
135
 
116
136
  useEffect(() => {
117
137
  const cur = shown.current;
118
138
  if (!props.open) {
119
- if (!cur) return;
120
- shown.current = null;
121
- cur.unbindTheme?.();
122
- const owner = cur.host.parent as any;
123
- if (owner?.hasChild?.(cur.host)) {
124
- owner.close(cur.host).then(
125
- () => cur.root.unmount?.(),
126
- (error: any) => { console.error('[Popover] close failed', error); cur.root.unmount?.(); },
127
- );
128
- } else cur.root.unmount?.();
129
-
139
+ cur?.lifetime.close();
130
140
  return;
131
141
  }
132
142
 
@@ -144,14 +154,18 @@ export function Popover(props: PopoverProps) @{
144
154
  host.horizontalAlignment = 'stretch';
145
155
  host.verticalAlignment = 'stretch';
146
156
  const root = createNativeScriptRoot(host);
147
- shown.current = { host, root, unbindTheme };
148
- root.render(PopoverLayer as unknown as UniversalComponent, { ...props, anchorView: anchor });
149
- rl.open(host).catch((error: unknown) => {
157
+ const lifetime = createOverlayLifetime(rl, host, root, 'Popover', (dismissed) => {
150
158
  if (shown.current?.host === host) shown.current = null;
151
- console.error('[Popover] open failed', error);
152
- root.unmount?.();
159
+ if (dismissed) dismiss.current?.();
153
160
  });
161
+
162
+ lifetime.own(unbindTheme);
163
+ shown.current = { host, root, lifetime };
164
+ root.render(PopoverLayer as unknown as UniversalComponent, { ...props, anchorView: anchor });
165
+ lifetime.open();
154
166
  }, [props.open, props.children, props.placement, props.className, props.style, props.dismissOnOutsideTap]);
155
167
 
168
+ useEffect(() => () => shown.current?.lifetime.close(), []);
169
+
156
170
  <></>
157
171
  }
@@ -20,7 +20,7 @@ export function Pressable(props: PressableProps) @{
20
20
  accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
21
21
  accessibilityHint={props.accessibilityHint}
22
22
  accessibilityValue={props.accessibilityValue}
23
- accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
23
+ accessibilityState={nativeAccessibilityState({ ...props.accessibilityState, disabled: props.disabled || props.accessibilityState?.disabled }) as any}
24
24
  accessibilityLiveRegion={props.accessibilityLiveRegion as any}
25
25
  onPan={usePan(props.disabled ? undefined : props.onPan)}
26
26
  onSwipe={props.disabled ? undefined : props.onSwipe}
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from 'vitest'
3
+ import { act, createRoot } from 'octane'
4
+ import { Pressable } from './Pressable.web.tsrx'
5
+ const roots: any[] = []
6
+ function mount(props: any = {}) {
7
+ vi.useFakeTimers()
8
+ const el = document.createElement('div');document.body.append(el);const root=createRoot(el);roots.push(root)
9
+ const render = (next:any={}) => act(() => root.render(<Pressable {...props} {...next} />))
10
+ render();const pointer=(type:string) => act(() => el.firstElementChild!.dispatchEvent(new Event(type,{bubbles:true})))
11
+ const advance=() => act(() => vi.advanceTimersByTime(500))
12
+ return {root,render,pointer,advance}
13
+ }
14
+
15
+ afterEach(() => {for (const root of roots.splice(0)) act(() => root.unmount());document.body.innerHTML='';vi.useRealTimers()})
16
+ it.each(['pointerup','pointerleave','pointercancel'])('cancels on %s after a rerender', (event) => {
17
+ const callback=vi.fn();const {render,pointer,advance}=mount({onLongPress:callback})
18
+ pointer('pointerdown');render({className:'updated'});pointer(event);advance();expect(callback).not.toHaveBeenCalled()
19
+ })
20
+
21
+ it('cancels on unmount', () => {
22
+ const callback=vi.fn();const {root,pointer,advance}=mount({onLongPress:callback});pointer('pointerdown');act(() => root.unmount());advance();expect(callback).not.toHaveBeenCalled()
23
+ })
24
+
25
+ it.each([{disabled:true},{onLongPress:undefined}])('cancels when long press becomes unavailable (%j)', (next) => {
26
+ const callback=vi.fn();const {render,pointer,advance}=mount({onLongPress:callback});pointer('pointerdown');render(next);advance();expect(callback).not.toHaveBeenCalled()
27
+ })
28
+
29
+ it('reports one long press through the latest callback', () => {
30
+ const old=vi.fn(),current=vi.fn();const {render,pointer,advance}=mount({onLongPress:old});pointer('pointerdown');render({onLongPress:current});advance();advance()
31
+ expect(old).not.toHaveBeenCalled();expect(current).toHaveBeenCalledTimes(1)
32
+ })
33
+
34
+ it('does not start a disabled long press', () => {
35
+ const callback=vi.fn();const {pointer,advance}=mount({onLongPress:callback,disabled:true});pointer('pointerdown');advance();expect(callback).not.toHaveBeenCalled()
36
+ })
@@ -1,4 +1,4 @@
1
- import { useRef } from 'octane';
1
+ import { useEffect, useRef } from 'octane';
2
2
  import type { PressableProps } from "./props";
3
3
  import { applyEscapeProps } from "./escape-props";
4
4
  import { layoutChildProps } from './layout-child.web';
@@ -6,17 +6,27 @@ import { usePan } from './pan.web.tsrx';
6
6
 
7
7
  export function Pressable(props: PressableProps) @{
8
8
  const el = useRef<HTMLElement | null>(null);
9
- let timer: ReturnType<typeof setTimeout> | null = null;
9
+ const spacePressed = useRef(false);
10
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
11
+ const latest = useRef(props);
12
+ latest.current = props;
10
13
 
11
14
  usePan(el, props.disabled ? undefined : props.onPan);
12
15
 
13
- const cancel = () => { if (timer !== null) { clearTimeout(timer); timer = null; } };
16
+ const cancel = () => { if (timer.current !== null) { clearTimeout(timer.current); timer.current = null; } };
17
+ useEffect(() => { if (props.disabled) spacePressed.current = false; }, [props.disabled]);
18
+ const enabled = !props.disabled && Boolean(props.onLongPress);
19
+ useEffect(() => { if (!enabled) cancel(); }, [enabled]);
20
+ useEffect(() => () => cancel(), []);
14
21
 
15
22
  // Long-press parity with native: ~500ms hold over pointerdown/up.
16
23
  const down = () => {
17
- if (props.disabled || !props.onLongPress) return;
24
+ if (latest.current.disabled || !latest.current.onLongPress) return;
18
25
  cancel();
19
- timer = setTimeout(() => { timer = null; props.onLongPress?.(); }, 500);
26
+ timer.current = setTimeout(() => {
27
+ timer.current = null;
28
+ if (!latest.current.disabled) latest.current.onLongPress?.();
29
+ }, 500);
20
30
  };
21
31
 
22
32
  <div
@@ -28,11 +38,32 @@ export function Pressable(props: PressableProps) @{
28
38
  aria-label={props.accessibilityLabel}
29
39
  aria-description={props.accessibilityHint}
30
40
  aria-valuetext={props.accessibilityValue}
31
- aria-disabled={props.accessibilityState?.disabled ?? props.disabled ?? undefined}
41
+ aria-disabled={props.disabled || props.accessibilityState?.disabled || undefined}
32
42
  aria-selected={props.accessibilityState?.selected}
33
43
  aria-checked={props.accessibilityState?.checked}
34
44
  aria-live={props.accessibilityLiveRegion === 'none' ? 'off' : props.accessibilityLiveRegion}
35
45
  aria-hidden={props.accessible === false ? true : undefined}
46
+ tabIndex={props.onPress && !props.disabled && props.accessible !== false ? 0 : -1}
47
+ onKeyDown={(e: KeyboardEvent) => {
48
+ if (e.target !== e.currentTarget || props.disabled || e.isComposing || !props.onPress) return;
49
+ if (e.key === 'Enter') {
50
+ e.preventDefault();
51
+ if (!e.repeat) props.onPress();
52
+ } else if (e.key === ' ') {
53
+ e.preventDefault();
54
+ spacePressed.current = true;
55
+ }
56
+ }}
57
+ onKeyUp={(e: KeyboardEvent) => {
58
+ if (e.target !== e.currentTarget || e.key !== ' ') return;
59
+ const activate = spacePressed.current;
60
+ spacePressed.current = false;
61
+ if (activate && !props.disabled && props.onPress) {
62
+ e.preventDefault();
63
+ props.onPress();
64
+ }
65
+ }}
66
+ onBlur={() => { spacePressed.current = false; }}
36
67
  onClick={props.disabled ? undefined : props.onPress}
37
68
  onDoubleClick={props.disabled ? undefined : props.onDoublePress}
38
69
  onPointerDown={(e: any) => { if (!props.disabled) { props.onPressIn?.(); down(); } }}
@@ -14,5 +14,6 @@ export function RouteHost(props: RouteHostProps) @{
14
14
  const nested = child
15
15
  child = <Layout>{nested}</Layout>
16
16
  }
17
+
17
18
  <>{child}</>
18
19
  }
package/src/Screen.tsrx CHANGED
@@ -3,7 +3,7 @@ import type { ScreenProps } from "./props";
3
3
  import { cx } from "./cx";
4
4
  import { applyEscapeProps } from "./escape-props";
5
5
  import { registerRootLayout, unregisterRootLayout } from "./root-layout.mobile";
6
- import { attachTapToBlur } from "./tap-to-blur.mobile";
6
+ import { attachTapToBlur } from "./tap-to-blur";
7
7
  // The screen shell is a RootLayout so RootLayout.open() can layer overlays
8
8
  // over it (decision #22). Self-registers on loaded/unloaded — overlay and
9
9
  // sheet services mount on the most recently mounted shell (topRootLayout).
@@ -4,7 +4,8 @@ import type { SearchInputProps } from './props';
4
4
  import { Icon } from './Icon.tsrx';
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
  /** Chrome-reset search field — a styled TextField (not UISearchBar) with a
10
11
  * leading glyph and a self-drawn clear button, matching the web leaf.
@@ -40,13 +41,13 @@ export function SearchInput(props: SearchInputProps) @{
40
41
  ref={(v: any) => {
41
42
  el.current = v;
42
43
  if (!v) return;
43
- props.bind?.({ focus: () => v.focus(), blur: () => v.dismissSoftInput?.() ?? v.blur(), native: v });
44
+ props.bind?.({ focus: () => v.focus(), blur: () => blurText(v), native: v });
44
45
  }}
45
46
  className="vx-search-field"
46
47
  hint={props.placeholder}
47
48
  returnKeyType="search"
48
49
  editable={props.editable ?? true}
49
- onTextChange={(e: any) => setValue(e.value)}
50
+ onTextChange={(e: any) => { if (!isWritingText(e.object)) setValue(e.value); }}
50
51
  onReturnPress={props.onSubmit}
51
52
  onFocus={props.onFocus}
52
53
  onBlur={props.onBlur}
@@ -41,7 +41,7 @@ export function SearchInput(props: SearchInputProps) @{
41
41
  enterKeyHint="search"
42
42
  onInput={(e: any) => setValue(e.target.value)}
43
43
  onKeyDown={(e: any) => {
44
- if (e.key === 'Enter' && !e.shiftKey) props.onSubmit?.();
44
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) props.onSubmit?.();
45
45
  }}
46
46
  onFocus={props.onFocus}
47
47
  onBlur={props.onBlur}