@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
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
- import { createPullToRefresh } from './refresh.web';
4
- import { REFRESH_HEADER_HEIGHT } from './refresh-metrics';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { createPullToRefresh } from './refresh.web'
4
+ import { REFRESH_HEADER_HEIGHT } from './refresh-metrics'
5
5
 
6
6
  // Unit-level check of the web pull-to-refresh state machine: synthetic
7
7
  // pointer events drive the controller, assertions read the composed
@@ -10,102 +10,102 @@ import { REFRESH_HEADER_HEIGHT } from './refresh-metrics';
10
10
  // handlers read.
11
11
 
12
12
  function host() {
13
- const wrapper = document.createElement('div');
14
- const scroller = document.createElement('div');
15
- const indicator = document.createElement('div');
16
- wrapper.appendChild(indicator);
17
- wrapper.appendChild(scroller);
18
- document.body.appendChild(wrapper);
19
- return { wrapper, scroller, indicator };
13
+ const wrapper = document.createElement('div')
14
+ const scroller = document.createElement('div')
15
+ const indicator = document.createElement('div')
16
+ wrapper.appendChild(indicator)
17
+ wrapper.appendChild(scroller)
18
+ document.body.appendChild(wrapper)
19
+ return { wrapper, scroller, indicator }
20
20
  }
21
21
 
22
22
  function pointer(type: string, y: number) {
23
- const e = new Event(type, { cancelable: true, bubbles: true });
24
- Object.assign(e, { pointerType: 'mouse', clientY: y, button: 0 });
25
- return e;
23
+ const e = new Event(type, { cancelable: true, bubbles: true })
24
+ Object.assign(e, { pointerType: 'mouse', clientY: y, button: 0 })
25
+ return e
26
26
  }
27
27
 
28
- const ty = (el: HTMLElement) => el.style.transform;
28
+ const ty = (el: HTMLElement) => el.style.transform
29
29
 
30
30
  describe('createPullToRefresh (web)', () => {
31
31
  beforeEach(() => {
32
- vi.useFakeTimers();
33
- });
32
+ vi.useFakeTimers()
33
+ })
34
34
 
35
35
  afterEach(() => {
36
- vi.useRealTimers();
37
- document.body.innerHTML = '';
38
- });
36
+ vi.useRealTimers()
37
+ document.body.innerHTML = ''
38
+ })
39
39
 
40
40
  it('tracks a sub-threshold drag and resets on release', () => {
41
- const { scroller, indicator } = host();
42
- const cfg = { current: { onRefresh: vi.fn() } };
43
- const ctl = createPullToRefresh({ scroller, indicator, cfg });
41
+ const { scroller, indicator } = host()
42
+ const cfg = { current: { onRefresh: vi.fn() } }
43
+ const ctl = createPullToRefresh({ scroller, indicator, cfg })
44
44
 
45
- scroller.dispatchEvent(pointer('pointerdown', 100));
46
- scroller.dispatchEvent(pointer('pointermove', 180));
45
+ scroller.dispatchEvent(pointer('pointerdown', 100))
46
+ scroller.dispatchEvent(pointer('pointermove', 180))
47
47
  // damped: 80 * 0.5 = 40
48
- expect(ty(scroller)).toContain('translateY(40px)');
49
- expect(ty(indicator)).toContain(`translateY(${40 - REFRESH_HEADER_HEIGHT}px)`);
48
+ expect(ty(scroller)).toContain('translateY(40px)')
49
+ expect(ty(indicator)).toContain(`translateY(${40 - REFRESH_HEADER_HEIGHT}px)`)
50
50
 
51
- scroller.dispatchEvent(pointer('pointerup', 180));
52
- expect(cfg.current.onRefresh).not.toHaveBeenCalled();
53
- expect(ty(scroller)).toContain('translateY(0px)');
54
- ctl.detach();
55
- });
51
+ scroller.dispatchEvent(pointer('pointerup', 180))
52
+ expect(cfg.current.onRefresh).not.toHaveBeenCalled()
53
+ expect(ty(scroller)).toContain('translateY(0px)')
54
+ ctl.detach()
55
+ })
56
56
 
57
57
  it('fires onRefresh and docks past the threshold; refreshless release collapses after grace', () => {
58
- const { scroller, indicator } = host();
59
- const cfg = { current: { onRefresh: vi.fn(), refreshing: false } };
60
- const ctl = createPullToRefresh({ scroller, indicator, cfg });
58
+ const { scroller, indicator } = host()
59
+ const cfg = { current: { onRefresh: vi.fn(), refreshing: false } }
60
+ const ctl = createPullToRefresh({ scroller, indicator, cfg })
61
61
 
62
- scroller.dispatchEvent(pointer('pointerdown', 100));
63
- scroller.dispatchEvent(pointer('pointermove', 320));
64
- scroller.dispatchEvent(pointer('pointerup', 320));
62
+ scroller.dispatchEvent(pointer('pointerdown', 100))
63
+ scroller.dispatchEvent(pointer('pointermove', 320))
64
+ scroller.dispatchEvent(pointer('pointerup', 320))
65
65
  // 220 * 0.5 = 110 >= 64 → docked
66
- expect(cfg.current.onRefresh).toHaveBeenCalledTimes(1);
67
- expect(ty(scroller)).toContain(`translateY(${REFRESH_HEADER_HEIGHT}px)`);
68
- expect(ty(indicator)).toContain('translateY(0px)');
66
+ expect(cfg.current.onRefresh).toHaveBeenCalledTimes(1)
67
+ expect(ty(scroller)).toContain(`translateY(${REFRESH_HEADER_HEIGHT}px)`)
68
+ expect(ty(indicator)).toContain('translateY(0px)')
69
69
 
70
70
  // refreshing never arrives → grace collapse
71
- vi.advanceTimersByTime(400);
72
- expect(ty(scroller)).toContain('translateY(0px)');
73
- ctl.detach();
74
- });
71
+ vi.advanceTimersByTime(400)
72
+ expect(ty(scroller)).toContain('translateY(0px)')
73
+ ctl.detach()
74
+ })
75
75
 
76
76
  it('refreshing keeps the dock; sync(false) collapses', () => {
77
- const { scroller, indicator } = host();
78
- const cfg = { current: { onRefresh: vi.fn(), refreshing: false } };
79
- const ctl = createPullToRefresh({ scroller, indicator, cfg });
80
-
81
- scroller.dispatchEvent(pointer('pointerdown', 100));
82
- scroller.dispatchEvent(pointer('pointermove', 320));
83
- cfg.current.refreshing = true;
84
- ctl.sync();
85
- scroller.dispatchEvent(pointer('pointerup', 320));
86
- expect(cfg.current.onRefresh).toHaveBeenCalledTimes(1);
87
-
88
- vi.advanceTimersByTime(400);
89
- expect(ty(scroller)).toContain(`translateY(${REFRESH_HEADER_HEIGHT}px)`);
90
-
91
- cfg.current.refreshing = false;
92
- ctl.sync();
93
- expect(ty(scroller)).toContain('translateY(0px)');
94
- expect(ty(indicator)).toContain(`translateY(${-REFRESH_HEADER_HEIGHT}px)`);
95
- ctl.detach();
96
- });
77
+ const { scroller, indicator } = host()
78
+ const cfg = { current: { onRefresh: vi.fn(), refreshing: false } }
79
+ const ctl = createPullToRefresh({ scroller, indicator, cfg })
80
+
81
+ scroller.dispatchEvent(pointer('pointerdown', 100))
82
+ scroller.dispatchEvent(pointer('pointermove', 320))
83
+ cfg.current.refreshing = true
84
+ ctl.sync()
85
+ scroller.dispatchEvent(pointer('pointerup', 320))
86
+ expect(cfg.current.onRefresh).toHaveBeenCalledTimes(1)
87
+
88
+ vi.advanceTimersByTime(400)
89
+ expect(ty(scroller)).toContain(`translateY(${REFRESH_HEADER_HEIGHT}px)`)
90
+
91
+ cfg.current.refreshing = false
92
+ ctl.sync()
93
+ expect(ty(scroller)).toContain('translateY(0px)')
94
+ expect(ty(indicator)).toContain(`translateY(${-REFRESH_HEADER_HEIGHT}px)`)
95
+ ctl.detach()
96
+ })
97
97
 
98
98
  it('ignores drags that start below the top edge', () => {
99
- const { scroller } = host();
100
- Object.defineProperty(scroller, 'scrollTop', { value: 30, writable: true });
101
- const cfg = { current: { onRefresh: vi.fn() } };
102
- const ctl = createPullToRefresh({ scroller, indicator: document.createElement('div'), cfg });
103
-
104
- scroller.dispatchEvent(pointer('pointerdown', 100));
105
- scroller.dispatchEvent(pointer('pointermove', 320));
106
- scroller.dispatchEvent(pointer('pointerup', 320));
107
- expect(cfg.current.onRefresh).not.toHaveBeenCalled();
108
- expect(ty(scroller)).toBe('');
109
- ctl.detach();
110
- });
111
- });
99
+ const { scroller } = host()
100
+ Object.defineProperty(scroller, 'scrollTop', { value: 30, writable: true })
101
+ const cfg = { current: { onRefresh: vi.fn() } }
102
+ const ctl = createPullToRefresh({ scroller, indicator: document.createElement('div'), cfg })
103
+
104
+ scroller.dispatchEvent(pointer('pointerdown', 100))
105
+ scroller.dispatchEvent(pointer('pointermove', 320))
106
+ scroller.dispatchEvent(pointer('pointerup', 320))
107
+ expect(cfg.current.onRefresh).not.toHaveBeenCalled()
108
+ expect(ty(scroller)).toBe('')
109
+ ctl.detach()
110
+ })
111
+ })
@@ -0,0 +1,162 @@
1
+ import { beforeEach, expect, it, vi } from 'vitest'
2
+ import { defineRoutes } from './route-table'
3
+ const native = vi.hoisted(() => {
4
+ class View {
5
+ isLoaded = true
6
+ frame: any
7
+ actionBar = { title: '' }
8
+ modal: any = null
9
+ closeCallback: (() => void) | undefined
10
+ showModal(host: any, options: any) {
11
+ this.modal = host
12
+ host.closeCallback = () => {
13
+ this.modal = null
14
+ options.closeCallback()
15
+ }
16
+ }
17
+ closeModal() {
18
+ this.closeCallback?.()
19
+ }
20
+ }
21
+
22
+ class Frame extends View {
23
+ currentPage: any = new View()
24
+ backStack: any[] = []
25
+ listeners = new Map<string, () => void>()
26
+ constructor() {
27
+ super()
28
+ this.currentPage.frame = this
29
+ }
30
+ on(name: string, cb: () => void) {
31
+ this.listeners.set(name, cb)
32
+ }
33
+ navigate({ create }: any) {
34
+ this.backStack.push(this.currentPage)
35
+ this.currentPage = create()
36
+ this.currentPage.frame = this
37
+ this.listeners.get('navigatedTo')?.()
38
+ }
39
+ goBack() {
40
+ if (this.backStack.length) {
41
+ this.currentPage = this.backStack.pop()
42
+ }
43
+
44
+ this.listeners.get('navigatedTo')?.()
45
+ }
46
+ callLoaded() {
47
+ this.isLoaded = true
48
+ }
49
+ }
50
+
51
+ const events = new Map<string, (event: any) => void>()
52
+ const app: any = {
53
+ android: { on: (name: string, cb: any) => events.set(name, cb) },
54
+ AndroidApplication: { activityBackPressedEvent: 'back' },
55
+ getRootView: () => null,
56
+ }
57
+
58
+ return { View, Frame, app, events, roots: [] as any[] }
59
+ })
60
+
61
+ vi.mock('@nativescript/core', () => ({
62
+ Application: native.app,
63
+ Frame: native.Frame,
64
+ Page: native.View,
65
+ GridLayout: native.View,
66
+ }))
67
+
68
+ vi.mock('octane', () => ({ useSyncExternalStore: vi.fn(), hookSlots: vi.fn() }))
69
+ vi.mock('@nativescript-community/octane', () => ({
70
+ createNativeScriptRoot: (host: any) => {
71
+ const root = { host, render: vi.fn(), unmount: vi.fn() }
72
+ native.roots.push(root)
73
+ return root
74
+ },
75
+ }))
76
+
77
+ vi.mock('./route-host.mobile', () => ({ RouteHost: () => null }))
78
+ vi.mock('./stacks', async () => import('./stacks.ts'))
79
+ let router: typeof import('./route')
80
+ let stacks: typeof import('./stacks')
81
+ let frame: InstanceType<typeof native.Frame>
82
+ beforeEach(async () => {
83
+ vi.resetModules()
84
+ delete native.app.__octaneXplatBackWired
85
+ delete native.app.__octaneXplatBackPress
86
+ native.events.clear()
87
+ native.roots.length = 0
88
+ router = await import('./route.ts')
89
+ stacks = await import('./stacks.ts')
90
+ frame = new native.Frame()
91
+ stacks.registerStack('root', frame as any)
92
+ router.registerRoutes(defineRoutes([{ path: 'detail', screen: () => null }]))
93
+ })
94
+
95
+ const push = (stack: string, id: string, presentation?: 'modal') =>
96
+ router.pushRoute({ stack, name: 'detail', params: { id }, presentation })
97
+
98
+ const back = () => {
99
+ const event = { cancel: false }
100
+ native.events.get('back')!(event)
101
+ return event.cancel
102
+ }
103
+
104
+ it('uses route arrays even when Android has a registered named Frame', () => {
105
+ const named = new native.Frame()
106
+ stacks.registerStack('first', named as any)
107
+ push('first', 'one')
108
+ push('first', 'two')
109
+ expect(named.backStack).toHaveLength(0)
110
+ expect(router.routeFor('first')?.params.id).toBe('two')
111
+ router.popRoute('first')
112
+ expect(router.routeFor('first')?.params.id).toBe('one')
113
+ })
114
+
115
+ it('orders interceptors then modal, root, latest named stack and base fall-through', () => {
116
+ push('first', 'one')
117
+ push('second', 'two')
118
+ push('root', 'root')
119
+ push('root', 'modal', 'modal')
120
+ const calls: string[] = []
121
+ const older = router.addBackInterceptor(() => {
122
+ calls.push('old')
123
+ return false
124
+ })
125
+
126
+ const newest = router.addBackInterceptor(() => {
127
+ calls.push('new')
128
+ return true
129
+ })
130
+
131
+ expect(back()).toBe(true)
132
+ expect(calls).toEqual(['new'])
133
+ newest()
134
+ expect(back()).toBe(true)
135
+ expect(calls).toEqual(['new', 'old'])
136
+ expect(router.currentModalRoute()).toBeNull()
137
+ expect(router.routeFor('root')?.params.id).toBe('root')
138
+ older()
139
+ expect(back()).toBe(true)
140
+ expect(router.routeFor('root')).toBeNull()
141
+ expect(back()).toBe(true)
142
+ expect(router.routeFor('second')).toBeNull()
143
+ expect(router.routeFor('first')?.params.id).toBe('one')
144
+ expect(back()).toBe(true)
145
+ expect(back()).toBe(false)
146
+ })
147
+
148
+ it('unmounts the separate modal root exactly once when dismissed', () => {
149
+ push('root', 'modal', 'modal')
150
+ const modalRoot = native.roots.at(-1)
151
+ router.popRoute()
152
+ expect(modalRoot.unmount).toHaveBeenCalledTimes(1)
153
+ expect(router.currentModalRoute()).toBeNull()
154
+ })
155
+
156
+ it('unmounts when the modal host invokes its close callback', () => {
157
+ push('root', 'modal', 'modal')
158
+ const modalRoot = native.roots.at(-1)
159
+ modalRoot.host.closeModal()
160
+ expect(modalRoot.unmount).toHaveBeenCalledTimes(1)
161
+ expect(router.currentModalRoute()).toBeNull()
162
+ })
@@ -0,0 +1,80 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, expect, it, vi } from 'vitest'
3
+ import { defineRoutes } from './route-table'
4
+ vi.mock('octane', () => ({ useSyncExternalStore: vi.fn() }))
5
+
6
+ let router: typeof import('./route.web')
7
+ beforeEach(async () => {
8
+ vi.resetModules()
9
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
10
+ history.replaceState(null, '', '/')
11
+ router = await import('./route.web')
12
+ router.registerRoutes(
13
+ defineRoutes([
14
+ {
15
+ path: 'detail',
16
+ screen: () => null,
17
+ beforeLoad: () => ({ guarded: true }),
18
+ loader: () => 'loaded',
19
+ },
20
+ { path: 'about', screen: () => null, presentation: 'modal' },
21
+ ]),
22
+ )
23
+ })
24
+
25
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 25))
26
+
27
+ it('restores guard context and loader data on back and forward', async () => {
28
+ router.pushRoute({ stack: 'root', name: 'detail', params: {} })
29
+ await settle()
30
+ router.pushRoute({ stack: 'root', name: 'about', params: {} })
31
+ history.back()
32
+ await settle()
33
+ expect(router.routeFor('root')).toMatchObject({
34
+ context: { guarded: true },
35
+ loaderData: 'loaded',
36
+ })
37
+
38
+ history.forward()
39
+ await settle()
40
+ expect(router.currentModalRoute()?.name).toBe('about')
41
+ expect(router.routeFor('root')?.name).toBe('detail')
42
+ })
43
+
44
+ it('keeps loaded route state when addRoutes updates the registry', async () => {
45
+ router.pushRoute({ stack: 'root', name: 'detail', params: {} })
46
+ await settle()
47
+ router.addRoutes(defineRoutes([{ path: 'extra', screen: () => null }]))
48
+ expect(router.routeFor('root')).toMatchObject({
49
+ context: { guarded: true },
50
+ loaderData: 'loaded',
51
+ })
52
+ })
53
+
54
+ it('counts the visible modal as a back affordance from the base shell', () => {
55
+ router.pushRoute({ stack: 'root', name: 'about', params: {} })
56
+ expect(router.canGoBack()).toBe(true)
57
+ })
58
+
59
+ it('rejects unmatched incoming links without changing history', () => {
60
+ const length = history.length
61
+ expect(router.pushDeepLink('xplat://missing')).toBe(false)
62
+ expect(history.length).toBe(length)
63
+ expect(router.currentRoute()).toBeNull()
64
+ })
65
+
66
+ it('loads a direct URL without adding history or running its guard', async () => {
67
+ const guard = vi.fn()
68
+ const loader = vi.fn(() => ({ entries: ['baked'] }))
69
+ history.replaceState(null, '', '/baked')
70
+ const length = history.length
71
+ router.registerRoutes(
72
+ defineRoutes([{ path: 'baked', screen: () => null, beforeLoad: guard, loader }]),
73
+ )
74
+
75
+ await settle()
76
+ expect(router.routeFor('root')).toMatchObject({ loaderData: { entries: ['baked'] } })
77
+ expect(history.length).toBe(length)
78
+ expect(guard).not.toHaveBeenCalled()
79
+ expect(loader).toHaveBeenCalledTimes(1)
80
+ })
@@ -0,0 +1,173 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
3
+ import type { Route, RouteManifest } from './props'
4
+
5
+ const registration = vi.hoisted(() => ({
6
+ listener: null as null | ((name: string, frame: any) => void),
7
+ }))
8
+
9
+ vi.mock('@nativescript/core', () => ({
10
+ Application: { android: null },
11
+ Frame: class {},
12
+ GridLayout: class {},
13
+ Page: class {},
14
+ }))
15
+
16
+ vi.mock('@nativescript-community/octane', () => ({
17
+ createNativeScriptRoot: () => ({ render() {}, unmount() {} }),
18
+ }))
19
+
20
+ vi.mock('./stacks', () => ({
21
+ getStack: () => undefined,
22
+ onStackRegistered(listener: typeof registration.listener) {
23
+ registration.listener = listener
24
+ },
25
+ stackEntries: () => new Map().entries(),
26
+ }))
27
+
28
+ vi.mock('./route-host.mobile', () => ({ RouteHost: () => null }))
29
+
30
+ function deferred<T>() {
31
+ let resolve!: (value: T) => void
32
+ let reject!: (error: unknown) => void
33
+ const promise = new Promise<T>((yes, no) => {
34
+ resolve = yes
35
+ reject = no
36
+ })
37
+
38
+ return { promise, resolve, reject }
39
+ }
40
+
41
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
42
+
43
+ describe.each(['web', 'macos', 'native'] as const)('%s navigation ownership', (target) => {
44
+ let router:
45
+ | typeof import('./route.web')
46
+ | typeof import('./route.macos')
47
+ | typeof import('./route')
48
+
49
+ beforeAll(async () => {
50
+ router =
51
+ target === 'web'
52
+ ? await import('./route.web')
53
+ : target === 'macos'
54
+ ? await import('./route.macos')
55
+ : await import('./route.ts')
56
+ }, 60000)
57
+
58
+ function setup(slow: Partial<RouteManifest['routes'][number]> = {}) {
59
+ router.registerRoutes({
60
+ screens: { slow: () => null, fast: () => null, redirected: () => null },
61
+ layouts: {},
62
+ routes: ['slow', 'fast', 'redirected'].map((name) => ({
63
+ name,
64
+ file: name,
65
+ segments: [name],
66
+ params: [],
67
+ ...(name === 'slow' ? slow : {}),
68
+ })),
69
+ })
70
+ }
71
+
72
+ const route = (name: string, stack = 'audit'): Route => ({ name, stack, params: {} })
73
+ if (target === 'native') {
74
+ it('invalidates pending work when a newly registered frame goes back', async () => {
75
+ const wait = deferred<unknown>()
76
+ setup({ loader: () => wait.promise })
77
+ const on = vi.fn()
78
+ registration.listener!('audit', { on, currentPage: null })
79
+ router.pushRoute(route('fast'))
80
+ router.pushRoute(route('slow'))
81
+ on.mock.calls[0][1]({ isBackNavigation: true })
82
+ wait.resolve('old')
83
+ await settle()
84
+ expect(router.routeFor('audit')?.name).toBe('fast')
85
+ })
86
+ }
87
+
88
+ it('ignores a superseded loader result', async () => {
89
+ const wait = deferred<unknown>()
90
+ setup({ loader: () => wait.promise })
91
+ router.pushRoute(route('slow'))
92
+ await settle()
93
+ router.pushRoute(route('fast'))
94
+ wait.resolve('old')
95
+ await settle()
96
+ expect(router.routeFor('audit')?.name).toBe('fast')
97
+ })
98
+
99
+ it('ignores a superseded loader rejection', async () => {
100
+ const wait = deferred<unknown>()
101
+ setup({ loader: () => wait.promise })
102
+ router.pushRoute(route('slow'))
103
+ await settle()
104
+ router.pushRoute(route('fast'))
105
+ wait.reject(new Error('old'))
106
+ await settle()
107
+ expect(router.routeFor('audit')?.name).toBe('fast')
108
+ })
109
+
110
+ it('ignores a superseded guard and its redirect', async () => {
111
+ const wait = deferred<unknown>()
112
+ setup({
113
+ beforeLoad: async () => {
114
+ await wait.promise
115
+ router.redirect(route('redirected'))
116
+ },
117
+ })
118
+
119
+ router.pushRoute(route('slow'))
120
+ router.pushRoute(route('fast'))
121
+ wait.resolve(null)
122
+ await settle()
123
+ expect(router.routeFor('audit')?.name).toBe('fast')
124
+ })
125
+
126
+ it('preserves active guard context and loader data', async () => {
127
+ setup({ beforeLoad: async () => ({ allowed: true }), loader: async () => 'loaded' })
128
+ router.pushRoute(route('slow'))
129
+ await settle()
130
+ expect(router.routeFor('audit')).toMatchObject({
131
+ name: 'slow',
132
+ context: { allowed: true },
133
+ loaderData: 'loaded',
134
+ })
135
+ })
136
+
137
+ it('preserves an active redirect', async () => {
138
+ setup({ beforeLoad: async () => router.redirect(route('redirected')) })
139
+ router.pushRoute(route('slow'))
140
+ await settle()
141
+ expect(router.routeFor('audit')?.name).toBe('redirected')
142
+ })
143
+
144
+ it('invalidates pending work when back is requested', async () => {
145
+ const wait = deferred<unknown>()
146
+ setup({ loader: () => wait.promise })
147
+ router.pushRoute(route('fast'))
148
+ router.pushRoute(route('slow'))
149
+ await settle()
150
+ if (target === 'web') {
151
+ vi.spyOn(history, 'back').mockImplementation(() => {})
152
+ }
153
+
154
+ router.popRoute('audit')
155
+ const before = router.routeFor('audit')
156
+ wait.resolve('old')
157
+ await settle()
158
+ expect(router.routeFor('audit')).toEqual(before)
159
+ vi.restoreAllMocks()
160
+ })
161
+
162
+ it('uses one history on web and independent named stacks elsewhere', async () => {
163
+ const wait = deferred<unknown>()
164
+ setup({ loader: () => wait.promise })
165
+ router.pushRoute(route('slow'))
166
+ await settle()
167
+ router.pushRoute(route('fast', 'other'))
168
+ wait.resolve('loaded')
169
+ await settle()
170
+ expect(router.routeFor('audit')?.name ?? null).toBe(target === 'web' ? null : 'slow')
171
+ expect(router.routeFor('other')?.name ?? null).toBe('fast')
172
+ })
173
+ })