@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/route.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * Every drop path warns loudly — silent no-ops are how apps ship a
16
16
  * default route on native while working fine on web. */
17
17
 
18
+ import { createNavigationRequests, type NavigationRequest } from './navigation-request'
18
19
  import { Application, Frame, GridLayout, Page } from '@nativescript/core'
19
20
  import { createNativeScriptRoot } from '@nativescript-community/octane'
20
21
  import type { UniversalComponent } from 'octane/universal/native'
@@ -38,6 +39,8 @@ export type { Route } from './props'
38
39
 
39
40
  // ---------- screen registry ----------
40
41
 
42
+ const navigationRequests = createNavigationRequests()
43
+
41
44
  // Effective tables = the file-derived base manifest merged under every
42
45
  // addRoutes layer. Keeping the split means an HMR re-run of routes.gen.*
43
46
  // (registerRoutes again) can't wipe routes an app added at runtime.
@@ -120,13 +123,17 @@ function warnOnce(key: string, msg: string): void {
120
123
  /** Attach the route-store emission hook once per frame. `navigatedTo`
121
124
  * fires on push AND on pop (the revealed page re-fires it), so one
122
125
  * listener keeps every useRoute snapshot honest. */
123
- function trackFrame(frame: Frame): void {
126
+ function trackFrame(frame: Frame, stack: string): void {
124
127
  if (tracked.has(frame)) {
125
128
  return
126
129
  }
127
130
 
128
131
  tracked.add(frame)
129
- frame.on('navigatedTo', () => {
132
+ frame.on('navigatedTo', (event) => {
133
+ if ((event as typeof event & { isBackNavigation?: boolean })?.isBackNavigation) {
134
+ navigationRequests.invalidate(stack)
135
+ }
136
+
130
137
  // A page re-shown by pop can stay unloaded when the frame's nav
131
138
  // bookkeeping stalls mid-transition (the iOS strand of #11444 — the
132
139
  // same hole the isLoaded/callLoaded workaround in commitRoute covers
@@ -146,7 +153,7 @@ function trackFrame(frame: Frame): void {
146
153
  function resolveStack(stack: string): Frame | undefined {
147
154
  const frame = getStack(stack)
148
155
  if (frame) {
149
- trackFrame(frame)
156
+ trackFrame(frame, stack)
150
157
  }
151
158
 
152
159
  return frame
@@ -163,8 +170,8 @@ function rearmFrameLoaded(frame: Frame): void {
163
170
  }
164
171
  }
165
172
 
166
- onStackRegistered((_name, frame) => {
167
- trackFrame(frame)
173
+ onStackRegistered((name, frame) => {
174
+ trackFrame(frame, name)
168
175
  ensureBackWired()
169
176
  emit()
170
177
  })
@@ -273,11 +280,12 @@ function ensureBackWired(): void {
273
280
  }
274
281
 
275
282
  app.__octaneXplatBackWired = true
276
- Application.android.on(Application.AndroidApplication.activityBackPressedEvent, (e: {
277
- cancel: boolean
278
- }) => {
279
- app.__octaneXplatBackPress?.(e)
280
- })
283
+ Application.android.on(
284
+ Application.AndroidApplication.activityBackPressedEvent,
285
+ (e: { cancel: boolean }) => {
286
+ app.__octaneXplatBackPress?.(e)
287
+ },
288
+ )
281
289
  }
282
290
 
283
291
  function presentationFor(name: string): Route['presentation'] {
@@ -299,10 +307,18 @@ function contextFor(r: Route): Record<string, unknown> {
299
307
  return r.context ?? {}
300
308
  }
301
309
 
302
- async function prepareRoute(r: Route, redirects = 0): Promise<void> {
310
+ async function prepareRoute(r: Route, request: NavigationRequest, redirects = 0): Promise<void> {
311
+ if (!request.isCurrent()) {
312
+ return
313
+ }
314
+
315
+ if (!request.claim(r.stack)) {
316
+ return
317
+ }
318
+
303
319
  const beforeLoad = metaFor(r.name)?.beforeLoad
304
320
  if (!beforeLoad) {
305
- commitRoute(r)
321
+ commitRoute(r, request)
306
322
  return
307
323
  }
308
324
 
@@ -314,13 +330,20 @@ async function prepareRoute(r: Route, redirects = 0): Promise<void> {
314
330
  try {
315
331
  const returned = await beforeLoad({ params: r.params, context: contextFor(r) })
316
332
  const context = returned ? { ...contextFor(r), ...returned } : contextFor(r)
317
- commitRoute({
318
- ...r,
319
- context,
320
- })
333
+ commitRoute(
334
+ {
335
+ ...r,
336
+ context,
337
+ },
338
+ request,
339
+ )
321
340
  } catch (e) {
341
+ if (!request.isCurrent()) {
342
+ return
343
+ }
344
+
322
345
  if (e instanceof RouteRedirect) {
323
- await prepareRoute(e.route, redirects + 1)
346
+ await prepareRoute(e.route, request, redirects + 1)
324
347
  return
325
348
  }
326
349
 
@@ -333,15 +356,20 @@ export function redirect(r: Route): never {
333
356
  }
334
357
 
335
358
  export function pushRoute(r: Route): void {
359
+ const request = navigationRequests.begin(r.stack)
336
360
  if (metaFor(r.name)?.beforeLoad) {
337
- void prepareRoute(r)
361
+ void prepareRoute(r, request)
338
362
  return
339
363
  }
340
364
 
341
- commitRoute(r)
365
+ commitRoute(r, request)
342
366
  }
343
367
 
344
- function commitRoute(r: Route): void {
368
+ function commitRoute(r: Route, request: NavigationRequest): void {
369
+ if (!request.isCurrent()) {
370
+ return
371
+ }
372
+
345
373
  const C = screenFor(r.name)
346
374
  if (!C) {
347
375
  warnOnce(
@@ -362,8 +390,8 @@ function commitRoute(r: Route): void {
362
390
  void Promise.resolve()
363
391
  .then(() => loader(r.params))
364
392
  .then(
365
- (loaderData) => commitRoute({ ...r, loaderData }),
366
- (loaderError) => commitRoute({ ...r, loaderError }),
393
+ (loaderData) => commitRoute({ ...r, loaderData }, request),
394
+ (loaderError) => commitRoute({ ...r, loaderError }, request),
367
395
  )
368
396
 
369
397
  return
@@ -484,6 +512,7 @@ function pushModal(frame: Frame, r: Route, C: any): void {
484
512
  }
485
513
 
486
514
  modalHosts.splice(i, 1)
515
+ root.unmount?.()
487
516
  emit()
488
517
  }
489
518
 
@@ -532,7 +561,6 @@ function pushModal(frame: Frame, r: Route, C: any): void {
532
561
  emit()
533
562
  } catch (e) {
534
563
  entry.dismiss()
535
- root.unmount?.()
536
564
  console.warn('[octane-xplat] modal pushRoute(' + r.name + ') threw: ' + (e as Error)?.message)
537
565
  }
538
566
  }
@@ -542,8 +570,10 @@ function pushModal(frame: Frame, r: Route, C: any): void {
542
570
  * where back at the app root is a no-op; loud only when the stack
543
571
  * itself doesn't exist. */
544
572
  export function popRoute(stack = 'root'): void {
573
+ navigationRequests.invalidate(stack)
545
574
  const modal = modalHosts[modalHosts.length - 1]
546
575
  if (modal) {
576
+ navigationRequests.invalidate(modal.route.stack)
547
577
  // Bookkeeping first: iOS drops dismissViewController completions
548
578
  // that race a still-in-flight presentation, so the NS closeCallback
549
579
  // isn't guaranteed to run — the route store can't gate on it.
@@ -639,7 +669,7 @@ export function currentModalRoute(): Route | null {
639
669
  * `onDeepLink(pushDeepLink)` plus one `consumeInitialUrl()` call. */
640
670
  export function pushDeepLink(url: string): boolean {
641
671
  const r = matchUrl(routes, linkPath(url))
642
- if (!r) {
672
+ if (!r || !screenFor(r.name)) {
643
673
  warnOnce('link:' + url, `pushDeepLink('${url}') dropped — no route matches.`)
644
674
  return false
645
675
  }
package/src/route.web.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createNavigationRequests, type NavigationRequest } from './navigation-request'
1
2
  import { useSyncExternalStore } from 'octane'
2
3
 
3
4
  /** Web route store — the browser half of the nav contract. One linear URL
@@ -27,6 +28,8 @@ import {
27
28
 
28
29
  // ---------- screen registry ----------
29
30
 
31
+ const navigationRequests = createNavigationRequests()
32
+
30
33
  // Effective tables = the file-derived base manifest merged under every
31
34
  // addRoutes layer. Keeping the split means an HMR re-run of routes.gen.*
32
35
  // (registerRoutes again) can't wipe routes an app added at runtime.
@@ -46,7 +49,7 @@ function rebuildRegistry(): void {
46
49
  // Registration can land after the first lazy parse (deep link read
47
50
  // before the routes module ran) — re-parse with patterns present.
48
51
  if (current !== undefined) {
49
- current = parse()
52
+ restoreRouteState()
50
53
  emit()
51
54
  }
52
55
  }
@@ -66,7 +69,8 @@ export function registerScreens(table: ScreenTable, manifest?: RouteMeta[]): voi
66
69
  export function registerRoutes(manifest: RouteManifest): void {
67
70
  baseManifest = manifest
68
71
  rebuildRegistry()
69
- applyHead(read())
72
+ read()
73
+ applyHead(modalRoute ?? current ?? null)
70
74
  }
71
75
 
72
76
  /** Layer a programmatic manifest (from `defineRoutes`) over the registered
@@ -76,7 +80,8 @@ export function registerRoutes(manifest: RouteManifest): void {
76
80
  export function addRoutes(manifest: RouteManifest): void {
77
81
  dynamicManifests.push(manifest)
78
82
  rebuildRegistry()
79
- applyHead(read())
83
+ read()
84
+ applyHead(modalRoute ?? current ?? null)
80
85
  }
81
86
 
82
87
  export function screenFor(name: string): ScreenTable[string] | undefined {
@@ -100,6 +105,82 @@ let current: Route | null | undefined
100
105
  let modalRoute: Route | null = null
101
106
  let historyDepth = history.state?.__octaneXplatDepth ?? 0
102
107
 
108
+ // History stores an identity rather than loader results: loaders may return
109
+ // values that structured clone cannot serialize. Retain the prepared route
110
+ // for this document's back/forward traversal; fresh boots still parse the URL.
111
+ const historyRoutes = new Map<string, { current: Route | null; modal: Route | null }>()
112
+ const historyEntryPrefix = Math.random().toString(36).slice(2)
113
+ let nextHistoryEntry = 0
114
+
115
+ function rememberRouteState(entry = historyEntryPrefix + ':' + ++nextHistoryEntry): string {
116
+ historyRoutes.set(entry, { current: current ?? null, modal: modalRoute })
117
+ return entry
118
+ }
119
+
120
+ function restoreRouteState(): void {
121
+ const saved = historyRoutes.get(history.state?.__octaneXplatEntry)
122
+ if (saved) {
123
+ current = saved.current
124
+ modalRoute = saved.modal
125
+ } else {
126
+ const route = parse()
127
+ modalRoute = route?.presentation === 'modal' ? route : null
128
+ current = modalRoute ? null : route
129
+ if (route) {
130
+ hydrateRoute(route)
131
+ }
132
+ }
133
+ }
134
+
135
+ // Direct URLs and history entries from a previous document have no prepared
136
+ // loader result. Run their loader without pushing another history entry or
137
+ // changing the documented bootstrap guard boundary.
138
+ function hydrateRoute(route: Route): void {
139
+ const loader = routeLoaders[route.name] ?? metaFor(route.name)?.loader
140
+ if (
141
+ !loader ||
142
+ Object.prototype.hasOwnProperty.call(route, 'loaderData') ||
143
+ Object.prototype.hasOwnProperty.call(route, 'loaderError')
144
+ ) {
145
+ return
146
+ }
147
+
148
+ const finish = (result: Pick<Route, 'loaderData' | 'loaderError'>) => {
149
+ const prepared = { ...route, ...result }
150
+ let changed = false
151
+ if (current === route) {
152
+ current = prepared
153
+ changed = true
154
+ }
155
+
156
+ if (modalRoute === route) {
157
+ modalRoute = prepared
158
+ changed = true
159
+ }
160
+
161
+ for (const saved of historyRoutes.values()) {
162
+ if (saved.current === route) {
163
+ saved.current = prepared
164
+ }
165
+
166
+ if (saved.modal === route) {
167
+ saved.modal = prepared
168
+ }
169
+ }
170
+
171
+ if (changed) {
172
+ emit()
173
+ }
174
+ }
175
+
176
+ void Promise.resolve()
177
+ .then(() => loader(route.params))
178
+ .then(
179
+ (loaderData) => finish({ loaderData }),
180
+ (loaderError) => finish({ loaderError }),
181
+ )
182
+ }
183
+
103
184
  const ROUTE_HEAD_ATTR = 'data-octane-xplat-route-head'
104
185
 
105
186
  // Scroll positions keyed by URL — restored on popstate (native keeps
@@ -123,10 +204,10 @@ function parse(): Route | null {
123
204
 
124
205
  function read(): Route | null {
125
206
  if (current === undefined) {
126
- current = parse()
207
+ restoreRouteState()
127
208
  }
128
209
 
129
- return current
210
+ return current ?? null
130
211
  }
131
212
 
132
213
  function emit() {
@@ -171,10 +252,14 @@ function contextFor(r: Route): Record<string, unknown> {
171
252
  return r.context ?? {}
172
253
  }
173
254
 
174
- async function prepareRoute(r: Route, redirects = 0): Promise<void> {
255
+ async function prepareRoute(r: Route, request: NavigationRequest, redirects = 0): Promise<void> {
256
+ if (!request.isCurrent()) {
257
+ return
258
+ }
259
+
175
260
  const beforeLoad = metaFor(r.name)?.beforeLoad
176
261
  if (!beforeLoad) {
177
- commitRoute(r)
262
+ commitRoute(r, request)
178
263
  return
179
264
  }
180
265
 
@@ -186,13 +271,20 @@ async function prepareRoute(r: Route, redirects = 0): Promise<void> {
186
271
  try {
187
272
  const returned = await beforeLoad({ params: r.params, context: contextFor(r) })
188
273
  const context = returned ? { ...contextFor(r), ...returned } : contextFor(r)
189
- commitRoute({
190
- ...r,
191
- context,
192
- })
274
+ commitRoute(
275
+ {
276
+ ...r,
277
+ context,
278
+ },
279
+ request,
280
+ )
193
281
  } catch (e) {
282
+ if (!request.isCurrent()) {
283
+ return
284
+ }
285
+
194
286
  if (e instanceof RouteRedirect) {
195
- await prepareRoute(e.route, redirects + 1)
287
+ await prepareRoute(e.route, request, redirects + 1)
196
288
  return
197
289
  }
198
290
 
@@ -205,15 +297,20 @@ export function redirect(r: Route): never {
205
297
  }
206
298
 
207
299
  export function pushRoute(r: Route): void {
300
+ const request = navigationRequests.begin('history')
208
301
  if (metaFor(r.name)?.beforeLoad) {
209
- void prepareRoute(r)
302
+ void prepareRoute(r, request)
210
303
  return
211
304
  }
212
305
 
213
- commitRoute(r)
306
+ commitRoute(r, request)
214
307
  }
215
308
 
216
- function commitRoute(r: Route): void {
309
+ function commitRoute(r: Route, request: NavigationRequest): void {
310
+ if (!request.isCurrent()) {
311
+ return
312
+ }
313
+
217
314
  const route: Route = { ...r, presentation: r.presentation ?? presentationFor(r.name) }
218
315
  const loader = routeLoaders[route.name] ?? routes.find((meta) => meta.name === route.name)?.loader
219
316
  if (
@@ -224,17 +321,26 @@ function commitRoute(r: Route): void {
224
321
  void Promise.resolve()
225
322
  .then(() => loader(route.params))
226
323
  .then(
227
- (loaderData) => commitRoute({ ...route, loaderData }),
228
- (loaderError) => commitRoute({ ...route, loaderError }),
324
+ (loaderData) => commitRoute({ ...route, loaderData }, request),
325
+ (loaderError) => commitRoute({ ...route, loaderError }, request),
229
326
  )
230
327
 
231
328
  return
232
329
  }
233
330
 
234
331
  saveScroll()
332
+ read()
333
+ // Include the entry we are leaving, even if it was the initial URL.
334
+ history.replaceState(
335
+ {
336
+ ...history.state,
337
+ __octaneXplatDepth: historyDepth,
338
+ __octaneXplatEntry: rememberRouteState(history.state?.__octaneXplatEntry),
339
+ },
340
+ '',
341
+ )
342
+
235
343
  historyDepth += 1
236
- history.pushState({ __octaneXplatDepth: historyDepth }, '', buildRoutePath(routes, route))
237
- lastKey = scrollKey()
238
344
  if (route.presentation === 'modal') {
239
345
  modalRoute = route
240
346
  } else {
@@ -242,6 +348,14 @@ function commitRoute(r: Route): void {
242
348
  modalRoute = null
243
349
  }
244
350
 
351
+ history.pushState(
352
+ { __octaneXplatDepth: historyDepth, __octaneXplatEntry: rememberRouteState() },
353
+ '',
354
+ buildRoutePath(routes, route),
355
+ )
356
+
357
+ lastKey = scrollKey()
358
+
245
359
  applyHead(modalRoute ?? current ?? null)
246
360
  emit()
247
361
  }
@@ -254,6 +368,7 @@ function presentationFor(name: string): Route['presentation'] {
254
368
  * per-stack pops aren't expressible, so `stack` is accepted for parity
255
369
  * and ignored. A pushed modal is the top entry, so back dismisses it. */
256
370
  export function popRoute(_stack = 'root'): void {
371
+ navigationRequests.invalidate('history')
257
372
  history.back()
258
373
  }
259
374
 
@@ -275,12 +390,12 @@ export function addBackInterceptor(_fn: () => boolean): () => void {
275
390
  }
276
391
 
277
392
  window.addEventListener('popstate', () => {
393
+ navigationRequests.invalidate('history')
278
394
  saveScroll()
279
395
  historyDepth = history.state?.__octaneXplatDepth ?? 0
280
396
  lastKey = scrollKey()
281
- modalRoute = null
282
- current = parse()
283
- applyHead(current)
397
+ restoreRouteState()
398
+ applyHead(modalRoute ?? current ?? null)
284
399
  restoreScroll()
285
400
  emit()
286
401
  })
@@ -304,6 +419,7 @@ export function currentRoute(): Route | null {
304
419
 
305
420
  /** The modal route overlaying the current one, if any. */
306
421
  export function currentModalRoute(): Route | null {
422
+ read()
307
423
  return modalRoute
308
424
  }
309
425
 
@@ -312,7 +428,7 @@ export function currentModalRoute(): Route | null {
312
428
  * apps wire `onDeepLink(pushDeepLink)`). */
313
429
  export function pushDeepLink(url: string): boolean {
314
430
  const r = matchUrl(routes, linkPath(url))
315
- if (!r) {
431
+ if (!r || !screenFor(r.name)) {
316
432
  console.warn(`[octane-xplat] pushDeepLink('${url}') dropped — no route matches.`)
317
433
  return false
318
434
  }
@@ -339,7 +455,8 @@ export function useRoute(stack: string): Route | null {
339
455
  /** Whether the selected conceptual stack has an in-app route to pop. A
340
456
  * browser's pre-app history is intentionally not counted. */
341
457
  export function canGoBack(stack = 'root'): boolean {
342
- const route = read()
458
+ read()
459
+ const route = modalRoute ?? current
343
460
  return historyDepth > 0 && route?.stack === stack
344
461
  }
345
462
 
@@ -1,4 +1,4 @@
1
- import { FlexboxLayout, Screen } from '@nativescript/core'
1
+ import { FlexboxLayout, Screen, type View } from '@nativescript/core'
2
2
  import { detentOffset, normalizeDetents, snapDetentIndex } from './sheet-snap'
3
3
 
4
4
  const SNAP_MS = 200
@@ -38,7 +38,7 @@ export function attachSheetDetents(
38
38
 
39
39
  // Parentless at attach (before RootLayout.open) — fall back to screen
40
40
  // dips; every gesture-time read uses the real parent height.
41
- const vh = () => (host.parent?.getActualSize?.().height || Screen.mainScreen.heightDIPs)
41
+ const vh = () => host.parent?.getActualSize?.().height || Screen.mainScreen.heightDIPs
42
42
  const max = sorted[sorted.length - 1]
43
43
  let cur = 0
44
44
 
@@ -50,7 +50,9 @@ export function attachSheetDetents(
50
50
  grabber.verticalAlignment = 'top'
51
51
  const grip = new FlexboxLayout()
52
52
  grip.className = 'vx-sheet-grip'
53
- grabber.addChild(grip)
53
+ // FlexboxLayout is a View at runtime; NativeScript's generated symbol
54
+ // setter index signatures make its Android declaration incompatible.
55
+ grabber.addChild(grip as unknown as View)
54
56
  host.addChild(grabber)
55
57
 
56
58
  let dragging = false
@@ -33,7 +33,10 @@ export const openSheet: OpenSheet = (Component, params, options: SheetOpenOption
33
33
  return new Promise<ModalOpenResult>((resolve) => {
34
34
  let finished = false
35
35
  const finish = (result?: ModalOpenResult) => {
36
- if (finished) return
36
+ if (finished) {
37
+ return
38
+ }
39
+
37
40
  finished = true
38
41
  active.delete(entry)
39
42
  try {
@@ -41,6 +44,7 @@ export const openSheet: OpenSheet = (Component, params, options: SheetOpenOption
41
44
  } catch (error) {
42
45
  console.error('[octane-xplat] sheet close failed', error)
43
46
  }
47
+
44
48
  resolve(result)
45
49
  }
46
50
 
@@ -6,7 +6,7 @@ import { topRootLayout } from './root-layout.mobile'
6
6
  import { applyThemeClasses } from './theme/theme-scheme'
7
7
  import { attachSheetDetents } from './sheet-detents'
8
8
  import { bindBottomInsetToKeyboard } from './keyboard-inset.mobile'
9
- import { attachTapToBlur } from './tap-to-blur.mobile'
9
+ import { attachTapToBlur } from './tap-to-blur'
10
10
 
11
11
  interface ActiveSheet {
12
12
  host: GridLayout
@@ -2,6 +2,7 @@ import { createRoot } from 'octane'
2
2
  import type { ModalOpenResult, OpenSheet } from './props'
3
3
  import { applyThemeClasses } from './theme/theme-scheme'
4
4
  import { attachSheetDetents } from './sheet-detents.web'
5
+ import { isolateModalFocus } from './modal-focus.web'
5
6
 
6
7
  interface ActiveSheet {
7
8
  layer: HTMLElement
@@ -28,6 +29,7 @@ export const openSheet: OpenSheet = (Component, params, options = {}) =>
28
29
  }
29
30
 
30
31
  let finished = false
32
+ let releaseFocus: (() => void) | undefined
31
33
  const finish = (result?: ModalOpenResult) => {
32
34
  if (finished) {
33
35
  return
@@ -35,6 +37,7 @@ export const openSheet: OpenSheet = (Component, params, options = {}) =>
35
37
 
36
38
  finished = true
37
39
  active.delete(entry)
40
+ releaseFocus?.()
38
41
  unbindTheme()
39
42
  detents?.detach()
40
43
  root.unmount()
@@ -61,4 +64,9 @@ export const openSheet: OpenSheet = (Component, params, options = {}) =>
61
64
  const root = createRoot(panel)
62
65
  root.render(Component, { params, close: finish })
63
66
  active.add(entry)
67
+ if (options.shadeCover ?? true) {
68
+ panel.setAttribute('role', 'dialog')
69
+ panel.setAttribute('aria-modal', 'true')
70
+ releaseFocus = isolateModalFocus(layer, panel, () => finish())
71
+ }
64
72
  })
@@ -1,46 +1,46 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { detentOffset, normalizeDetents, snapDetentIndex } from './sheet-snap';
1
+ import { describe, expect, it } from 'vitest'
2
+ import { detentOffset, normalizeDetents, snapDetentIndex } from './sheet-snap'
3
3
 
4
4
  // Pure math behind the sheet detents: fractions → sorted snap points →
5
5
  // resting translateY offsets → release-time snap targets. The DOM/NS
6
6
  // attachments are thin adapters over this.
7
7
 
8
- const D = [0.25, 0.5, 0.9];
9
- const VH = 800;
8
+ const D = [0.25, 0.5, 0.9]
9
+ const VH = 800
10
10
 
11
11
  describe('normalizeDetents', () => {
12
12
  it('sorts, filters, and dedupes', () => {
13
- expect(normalizeDetents([0.9, 0.25, 0, 1.5, 0.25])).toEqual([0.25, 0.9]);
14
- expect(normalizeDetents()).toEqual([]);
15
- expect(normalizeDetents([0])).toEqual([]);
16
- });
17
- });
13
+ expect(normalizeDetents([0.9, 0.25, 0, 1.5, 0.25])).toEqual([0.25, 0.9])
14
+ expect(normalizeDetents()).toEqual([])
15
+ expect(normalizeDetents([0])).toEqual([])
16
+ })
17
+ })
18
18
 
19
19
  describe('detentOffset', () => {
20
20
  it('parks the largest-detent-sized panel per detent', () => {
21
- expect(detentOffset(D, 0, VH)).toBe((0.9 - 0.25) * VH);
22
- expect(detentOffset(D, 2, VH)).toBe(0);
23
- });
24
- });
21
+ expect(detentOffset(D, 0, VH)).toBe((0.9 - 0.25) * VH)
22
+ expect(detentOffset(D, 2, VH)).toBe(0)
23
+ })
24
+ })
25
25
 
26
26
  describe('snapDetentIndex', () => {
27
27
  it('snaps to the nearest detent on a slow release', () => {
28
28
  // frac 0.55 → detent 0.5 wins
29
- expect(snapDetentIndex((0.9 - 0.55) * VH, 0, D, VH)).toBe(1);
30
- expect(snapDetentIndex(0, 0, D, VH)).toBe(2); // fully open
31
- });
29
+ expect(snapDetentIndex((0.9 - 0.55) * VH, 0, D, VH)).toBe(1)
30
+ expect(snapDetentIndex(0, 0, D, VH)).toBe(2) // fully open
31
+ })
32
32
 
33
33
  it('dismisses below half of the smallest detent', () => {
34
34
  // frac 0.1 < 0.25/2
35
- expect(snapDetentIndex((0.9 - 0.1) * VH, 0, D, VH)).toBe(-1);
36
- });
35
+ expect(snapDetentIndex((0.9 - 0.1) * VH, 0, D, VH)).toBe(-1)
36
+ })
37
37
 
38
38
  it('fling direction overrides proximity', () => {
39
39
  // frac 0.6 (nearer 0.5) flung up → 0.9
40
- expect(snapDetentIndex((0.9 - 0.6) * VH, -600, D, VH)).toBe(2);
40
+ expect(snapDetentIndex((0.9 - 0.6) * VH, -600, D, VH)).toBe(2)
41
41
  // frac 0.4 (nearer 0.5) flung down → 0.25
42
- expect(snapDetentIndex((0.9 - 0.4) * VH, 600, D, VH)).toBe(0);
42
+ expect(snapDetentIndex((0.9 - 0.4) * VH, 600, D, VH)).toBe(0)
43
43
  // flung down at/below the smallest detent → dismiss
44
- expect(snapDetentIndex((0.9 - 0.25) * VH, 600, D, VH)).toBe(-1);
45
- });
46
- });
44
+ expect(snapDetentIndex((0.9 - 0.25) * VH, 600, D, VH)).toBe(-1)
45
+ })
46
+ })
package/src/sheet-snap.ts CHANGED
@@ -27,7 +27,12 @@ export function detentOffset(detents: readonly number[], index: number, vh: numb
27
27
  * `vy` (positive = dragging down). Returns the detent index, or -1 to
28
28
  * dismiss: released below half of the smallest detent, or a downward
29
29
  * fling while at/below it. */
30
- export function snapDetentIndex(ty: number, vy: number, detents: readonly number[], vh: number): number {
30
+ export function snapDetentIndex(
31
+ ty: number,
32
+ vy: number,
33
+ detents: readonly number[],
34
+ vh: number,
35
+ ): number {
31
36
  const max = detents[detents.length - 1]
32
37
  const frac = Math.min(Math.max(max - ty / vh, 0), 1)
33
38
 
package/src/svg.mobile.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  import { registerElement } from '@nativescript-community/octane'
2
- import type { Attributes } from '@nativescript-community/octane/intrinsics'
3
- import { SVGView } from './vendor/ui-svg'
2
+ import { SVGView } from './vendor/ui-svg/src'
4
3
  import type { IconGlyph } from './props'
5
4
 
5
+ // The emitted declaration keeps the import() form: declaration emit elides
6
+ // type-only imports referenced solely inside `declare module`, and the
7
+ // augmentation must still resolve Attributes when the module isn't otherwise
8
+ // loaded in the consumer's program.
6
9
  declare module '@nativescript-community/octane/intrinsics' {
7
10
  interface NativeScriptElements {
8
- svgview: Omit<Attributes<typeof SVGView>, 'src'> & {
11
+ svgview: Omit<
12
+ import('@nativescript-community/octane/intrinsics').Attributes<typeof SVGView>,
13
+ 'src'
14
+ > & {
9
15
  /** SVGView awaits promise srcs — remote .svg URLs arrive as fetched markup. */
10
16
  src?: string | Promise<string>
11
17
  stretch?: 'none' | 'fill' | 'aspectFit' | 'aspectFill'