@octane-xplat/ui 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +4 -3
  2. package/dist/native/Button.tsrx.js +1 -1
  3. package/dist/native/CommandPalette.tsrx.js +7 -1
  4. package/dist/native/Grid.tsrx.js +31 -2
  5. package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
  6. package/dist/native/Markdown.tsrx.js +233 -0
  7. package/dist/native/Overlay.tsrx.js +15 -26
  8. package/dist/native/Pagination.tsrx.js +15 -12
  9. package/dist/native/PinInput.tsrx.js +25 -7
  10. package/dist/native/Popover.tsrx.js +33 -23
  11. package/dist/native/Pressable.tsrx.js +4 -1
  12. package/dist/native/Screen.tsrx.js +1 -1
  13. package/dist/native/SearchInput.tsrx.js +6 -3
  14. package/dist/native/Sheet.tsrx.js +23 -38
  15. package/dist/native/Slider.tsrx.js +23 -14
  16. package/dist/native/Text.tsrx.js +16 -0
  17. package/dist/native/TextArea.tsrx.js +6 -3
  18. package/dist/native/TextInput.tsrx.js +6 -3
  19. package/dist/native/VirtualList.tsrx.js +305 -121
  20. package/dist/native/WebView.tsrx.js +17 -2
  21. package/dist/native/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
  22. package/dist/native/escape-props.js +2 -0
  23. package/dist/native/grid-auto-placement.js +83 -0
  24. package/dist/native/grid-placement.js +20 -0
  25. package/dist/native/index.shared.js +7 -5
  26. package/dist/native/keyboard-inset.mobile.js +5 -5
  27. package/dist/native/navigation-request.js +26 -0
  28. package/dist/native/octane-client-build.json +1 -1
  29. package/dist/native/overlay-lifetime.mobile.js +65 -0
  30. package/dist/native/pan.tsrx.js +16 -2
  31. package/dist/native/route-table.js +90 -10
  32. package/dist/native/route.js +26 -16
  33. package/dist/native/shared.js +7 -5
  34. package/dist/native/sheet-service.js +2 -2
  35. package/dist/native/svg.mobile.js +1 -1
  36. package/dist/native/{tap-to-blur.mobile.js → tap-to-blur.ios.js} +20 -32
  37. package/dist/native/text-focus.js +10 -0
  38. package/dist/native/text-write.js +18 -11
  39. package/dist/native/toast-anchor.tsrx.js +1 -1
  40. package/dist/native/ui.js +7 -6
  41. package/dist/native/useMeasure.tsrx.js +17 -18
  42. package/dist/native/vendor/ui-svg/index.android.js +2 -2
  43. package/dist/native/vendor/ui-svg/index.ios.js +2 -2
  44. package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
  45. package/dist/native/virtual-list-cell-pool.js +63 -0
  46. package/dist/web/Button.web.tsrx.js +1 -1
  47. package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
  48. package/dist/web/Markdown.tsrx.js +98 -0
  49. package/dist/web/Overlay.web.tsrx.js +81 -32
  50. package/dist/web/PinInput.web.tsrx.js +24 -10
  51. package/dist/web/Pressable.web.tsrx.js +169 -53
  52. package/dist/web/SearchInput.web.tsrx.js +2 -2
  53. package/dist/web/Sheet.web.tsrx.js +20 -7
  54. package/dist/web/TextArea.web.tsrx.js +22 -17
  55. package/dist/web/TextInput.web.tsrx.js +2 -2
  56. package/dist/web/VirtualList.web.tsrx.js +220 -109
  57. package/dist/web/WebView.web.tsrx.js +3 -3
  58. package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
  59. package/dist/web/modal-focus.web.js +95 -0
  60. package/dist/web/navigation-request.js +26 -0
  61. package/dist/web/octane-client-build.json +1 -1
  62. package/dist/web/pan.web.tsrx.js +27 -10
  63. package/dist/web/route-table.js +90 -10
  64. package/dist/web/route.web.js +88 -22
  65. package/dist/web/sheet-service.web.js +8 -0
  66. package/dist/web/theme/colorScheme.web.js +31 -4
  67. package/dist/web/ui.js +8 -6
  68. package/dist/web/virtual-list-cell-pool.js +63 -0
  69. package/package.json +21 -19
  70. package/platforms/android/native-api-usage.json +7 -7
  71. package/src/CommandPalette.tsrx +8 -1
  72. package/src/Grid.tsrx +32 -1
  73. package/src/Icon.macos.tsrx +1 -0
  74. package/src/KeyboardAvoiding.macos.tsrx +7 -1
  75. package/src/KeyboardAvoiding.mobile.tsrx +2 -2
  76. package/src/KeyboardAvoiding.tsrx +15 -0
  77. package/src/KeyboardAvoiding.web.tsrx +15 -0
  78. package/src/Markdown.tsrx +121 -0
  79. package/src/Meter.tsrx +1 -0
  80. package/src/Overlay.tsrx +15 -30
  81. package/src/Overlay.web.tsrx +12 -3
  82. package/src/Pagination.tsrx +6 -4
  83. package/src/PinInput.macos.tsrx +20 -6
  84. package/src/PinInput.mobile.test.ts +107 -0
  85. package/src/PinInput.tsrx +20 -6
  86. package/src/PinInput.web.test.tsrx +42 -0
  87. package/src/PinInput.web.tsrx +20 -6
  88. package/src/Popover.tsrx +36 -22
  89. package/src/Pressable.tsrx +1 -1
  90. package/src/Pressable.web.test.tsrx +36 -0
  91. package/src/Pressable.web.tsrx +37 -6
  92. package/src/RouteHost.macos.tsrx +1 -0
  93. package/src/Screen.tsrx +1 -1
  94. package/src/SearchInput.tsrx +4 -3
  95. package/src/SearchInput.web.tsrx +1 -1
  96. package/src/Sheet.tsrx +21 -42
  97. package/src/Sheet.web.tsrx +9 -1
  98. package/src/Slider.tsrx +13 -11
  99. package/src/Text.tsrx +36 -0
  100. package/src/TextArea.tsrx +6 -5
  101. package/src/TextArea.web.test.tsrx +39 -0
  102. package/src/TextArea.web.tsrx +7 -5
  103. package/src/TextInput.tsrx +6 -6
  104. package/src/TextInput.web.tsrx +1 -1
  105. package/src/VirtualList.macos.tsrx +93 -6
  106. package/src/VirtualList.mobile.test.tsrx +232 -0
  107. package/src/VirtualList.tsrx +285 -81
  108. package/src/VirtualList.web.tsrx +131 -25
  109. package/src/WebView.tsrx +20 -10
  110. package/src/WebView.web.tsrx +3 -0
  111. package/src/android/BottomNavigationView.android.tsrx +1 -0
  112. package/src/android/MaterialDialog.android.tsrx +1 -0
  113. package/src/anim.macos.tsrx +1 -0
  114. package/src/apply-native-modifiers.native.ts +4 -1
  115. package/src/child-array.ts +12 -7
  116. package/src/colorScheme.macos.ts +1 -0
  117. package/src/controls.macos.tsrx +4 -1
  118. package/src/data-driver.mobile.test.ts +120 -0
  119. package/src/data-lifecycle.mobile.test.ts +251 -0
  120. package/src/escape-props.ts +13 -1
  121. package/src/grid-auto-placement.ts +163 -0
  122. package/src/grid-placement.ts +29 -0
  123. package/src/hosted-unsupported.macos.tsrx +1 -0
  124. package/src/index.macos.ts +23 -3
  125. package/src/index.mobile.ts +0 -1
  126. package/src/index.shared.ts +22 -1
  127. package/src/index.shared.web.ts +291 -0
  128. package/src/index.web.ts +1 -1
  129. package/src/input-readiness.web.test.tsrx +82 -0
  130. package/src/ios/UITabBar.ios.tsrx +1 -0
  131. package/src/keyboard-inset.mobile.test.ts +62 -0
  132. package/src/keyboard-inset.mobile.ts +63 -43
  133. package/src/layout-shells.macos.tsrx +1 -5
  134. package/src/macos-extras.macos.tsrx +2 -0
  135. package/src/modal-focus.web.test.tsrx +63 -0
  136. package/src/modal-focus.web.ts +162 -0
  137. package/src/navigation-request.ts +37 -0
  138. package/src/overlay-lifecycle.mobile.test.ts +316 -0
  139. package/src/overlay-lifetime.mobile.ts +107 -0
  140. package/src/pan.mobile.test.tsrx +116 -0
  141. package/src/pan.tsrx +20 -2
  142. package/src/pan.web.tsrx +35 -7
  143. package/src/platformProps.ts +1 -0
  144. package/src/popover.mobile.test.ts +9 -1
  145. package/src/pressable-accessibility.mobile.test.ts +30 -0
  146. package/src/props.ts +163 -3
  147. package/src/refresh.ts +6 -1
  148. package/src/refresh.web.test.ts +77 -77
  149. package/src/route-android.test.ts +162 -0
  150. package/src/route-history.web.test.ts +80 -0
  151. package/src/route-navigation.web.test.ts +173 -0
  152. package/src/route-table.test.ts +188 -9
  153. package/src/route-table.ts +138 -10
  154. package/src/route.macos.ts +56 -13
  155. package/src/route.ts +54 -24
  156. package/src/route.web.ts +141 -24
  157. package/src/sheet-detents.ts +5 -3
  158. package/src/sheet-service.macos.ts +5 -1
  159. package/src/sheet-service.ts +1 -1
  160. package/src/sheet-service.web.ts +8 -0
  161. package/src/sheet-snap.test.ts +23 -23
  162. package/src/sheet-snap.ts +6 -1
  163. package/src/svg.mobile.ts +9 -3
  164. package/src/tap-to-blur.ios.ts +106 -0
  165. package/src/tap-to-blur.mobile.ts +21 -95
  166. package/src/text-focus.ts +12 -0
  167. package/src/text-input.mobile.test.ts +89 -0
  168. package/src/text-write.ts +21 -11
  169. package/src/theme/colorScheme.linux.ts +2 -4
  170. package/src/theme/colorScheme.web.ts +51 -4
  171. package/src/theme/theme-scheme.macos.ts +8 -2
  172. package/src/theme/tokens.css +1740 -1718
  173. package/src/translate.macos.ts +4 -1
  174. package/src/useMeasure.tsrx +20 -21
  175. package/src/virtual-list-cell-pool.test.ts +53 -0
  176. package/src/virtual-list-cell-pool.ts +81 -0
  177. package/src/virtual-list-layout.test.ts +11 -8
  178. package/src/virtual-list-layout.ts +4 -8
  179. package/src/windows.linux.ts +7 -2
  180. package/src/windows.macos.ts +1 -0
  181. package/types/generated/.tsrx-typegen-manifest.json +16 -3
  182. package/types/generated/Grid.d.ts +0 -1
  183. package/types/generated/KeyboardAvoiding.d.ts +3 -0
  184. package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
  185. package/types/generated/Markdown.d.ts +59 -0
  186. package/types/generated/TextArea.d.ts +2 -2
  187. package/types/generated/TextArea.web.d.ts +1 -1
  188. package/types/generated/TextInput.d.ts +2 -3
  189. package/types/generated/VirtualList.d.ts +3 -3
  190. package/types/generated/escape-props.d.ts +4 -0
  191. package/types/generated/grid-auto-placement.d.ts +13 -0
  192. package/types/generated/grid-placement.d.ts +5 -0
  193. package/types/generated/index.mobile.d.ts +0 -1
  194. package/types/generated/index.shared.d.ts +6 -3
  195. package/types/generated/index.shared.web.d.ts +114 -0
  196. package/types/generated/index.web.d.ts +1 -1
  197. package/types/generated/modal-focus.web.d.ts +3 -0
  198. package/types/generated/navigation-request.d.ts +10 -0
  199. package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
  200. package/types/generated/props.d.ts +111 -3
  201. package/types/generated/route-table.d.ts +14 -3
  202. package/types/generated/svg.mobile.d.ts +2 -2
  203. package/types/generated/tap-to-blur.ios.d.ts +13 -0
  204. package/types/generated/tap-to-blur.mobile.d.ts +1 -15
  205. package/types/generated/text-focus.d.ts +3 -0
  206. package/types/generated/text-write.d.ts +3 -1
  207. package/types/generated/theme/colorScheme.web.d.ts +5 -0
  208. package/types/generated/virtual-list-cell-pool.d.ts +18 -0
  209. package/types/index.macos.d.ts +15 -3
  210. /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
  211. /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
  212. /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
  213. /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
  214. /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
  215. /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
  216. /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
  217. /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
  218. /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
  219. /package/types/generated/vendor/ui-svg/{index.ios.d.ts → src/index.ios.d.ts} +0 -0
@@ -0,0 +1,162 @@
1
+ type Scope = {
2
+ layer: HTMLElement
3
+ panel: HTMLElement
4
+ trigger: HTMLElement | null
5
+ }
6
+
7
+ const scopes: Scope[] = []
8
+ const originalInert = new Map<HTMLElement, boolean>()
9
+ let observer: MutationObserver | undefined
10
+
11
+ function topScope(): Scope | undefined {
12
+ return scopes[scopes.length - 1]
13
+ }
14
+
15
+ function updateIsolation(): void {
16
+ const top = topScope()
17
+ if (!top) {
18
+ for (const [element, inert] of originalInert) {
19
+ if (!inert) {
20
+ element.removeAttribute('inert')
21
+ }
22
+ }
23
+
24
+ originalInert.clear()
25
+ return
26
+ }
27
+
28
+ for (const element of Array.from(top.layer.ownerDocument.body.children) as HTMLElement[]) {
29
+ if (!originalInert.has(element)) {
30
+ originalInert.set(element, element.hasAttribute('inert'))
31
+ }
32
+
33
+ const inert = originalInert.get(element) || !element.contains(top.layer)
34
+ element.toggleAttribute('inert', inert)
35
+ }
36
+ }
37
+
38
+ function tabStops(panel: HTMLElement): HTMLElement[] {
39
+ return Array.from(
40
+ panel.querySelectorAll<HTMLElement>(
41
+ 'a[href],button,input,textarea,select,[tabindex],[contenteditable="true"]',
42
+ ),
43
+ ).filter((element) => {
44
+ if (
45
+ element.tabIndex < 0 ||
46
+ element.matches(':disabled') ||
47
+ element.closest('[inert],[hidden]')
48
+ ) {
49
+ return false
50
+ }
51
+
52
+ for (let parent: HTMLElement | null = element; parent; parent = parent.parentElement) {
53
+ const style = getComputedStyle(parent)
54
+ if (style.display === 'none' || style.visibility === 'hidden') {
55
+ return false
56
+ }
57
+ }
58
+
59
+ return true
60
+ })
61
+ }
62
+
63
+ /** Own keyboard focus only for a modal body portal. Inert preserves the
64
+ * background's state while hiding it from both focus and the browser AX tree. */
65
+ export function isolateModalFocus(
66
+ layer: HTMLElement,
67
+ panel: HTMLElement,
68
+ dismiss: () => void,
69
+ ): () => void {
70
+ const document = layer.ownerDocument
71
+ const scope: Scope = { layer, panel, trigger: document.activeElement as HTMLElement | null }
72
+ const tabIndex = panel.getAttribute('tabindex')
73
+ if (tabIndex === null) {
74
+ panel.tabIndex = -1
75
+ }
76
+
77
+ scopes.push(scope)
78
+ updateIsolation()
79
+
80
+ // An outer modal may have made this newly appended portal inert before
81
+ // its effect ran. Restore the top layer before attempting to focus it.
82
+ ;(tabStops(panel)[0] ?? panel).focus({ preventScroll: true })
83
+ if (!observer) {
84
+ observer = new MutationObserver(updateIsolation)
85
+ observer.observe(document.body, { childList: true })
86
+ }
87
+
88
+ const focusInside = () => (tabStops(panel)[0] ?? panel).focus({ preventScroll: true })
89
+ const onFocus = (event: FocusEvent) => {
90
+ if (topScope() === scope && !panel.contains(event.target as Node)) {
91
+ focusInside()
92
+ }
93
+ }
94
+
95
+ const onKey = (event: KeyboardEvent) => {
96
+ if (topScope() !== scope || event.isComposing || event.keyCode === 229) {
97
+ return
98
+ }
99
+
100
+ if (event.key === 'Escape') {
101
+ event.preventDefault()
102
+ event.stopPropagation()
103
+ dismiss()
104
+ } else if (event.key === 'Tab') {
105
+ const stops = tabStops(panel)
106
+ const index = stops.indexOf(document.activeElement as HTMLElement)
107
+ if (
108
+ !stops.length ||
109
+ index < 0 ||
110
+ (event.shiftKey ? index === 0 : index === stops.length - 1)
111
+ ) {
112
+ event.preventDefault()
113
+
114
+ ;(stops.length ? stops[event.shiftKey ? stops.length - 1 : 0] : panel).focus({
115
+ preventScroll: true,
116
+ })
117
+ }
118
+ }
119
+ }
120
+
121
+ document.addEventListener('focusin', onFocus)
122
+ document.addEventListener('keydown', onKey, true)
123
+ return () => {
124
+ const wasTop = topScope() === scope
125
+ const index = scopes.indexOf(scope)
126
+ if (index < 0) {
127
+ return
128
+ }
129
+
130
+ // If a lower modal closes first, its children's return targets disappear.
131
+ for (const other of scopes) {
132
+ if (other !== scope && other.trigger && scope.layer.contains(other.trigger)) {
133
+ other.trigger = scope.trigger
134
+ }
135
+ }
136
+
137
+ scopes.splice(index, 1)
138
+ document.removeEventListener('focusin', onFocus)
139
+ document.removeEventListener('keydown', onKey, true)
140
+ if (tabIndex === null) {
141
+ panel.removeAttribute('tabindex')
142
+ }
143
+
144
+ updateIsolation()
145
+ if (!scopes.length) {
146
+ observer?.disconnect()
147
+ observer = undefined
148
+ }
149
+
150
+ if (wasTop) {
151
+ const trigger = scope.trigger
152
+ if (trigger?.isConnected && !trigger.closest('[inert]')) {
153
+ trigger.focus({ preventScroll: true })
154
+ } else {
155
+ const remaining = topScope()
156
+ if (remaining) {
157
+ ;(tabStops(remaining.panel)[0] ?? remaining.panel).focus({ preventScroll: true })
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,37 @@
1
+ /** A redirect shares its request with the original stack. Claiming another
2
+ * stack supersedes its pending navigation without reviving a stale request. */
3
+ export interface NavigationRequest {
4
+ isCurrent(): boolean
5
+ claim(stack: string): boolean
6
+ }
7
+
8
+ export function createNavigationRequests() {
9
+ const versions = new Map<string, number>()
10
+ const invalidate = (stack: string) => {
11
+ const version = (versions.get(stack) ?? 0) + 1
12
+ versions.set(stack, version)
13
+ return version
14
+ }
15
+
16
+ return {
17
+ invalidate,
18
+ begin(stack: string): NavigationRequest {
19
+ const owned = new Map([[stack, invalidate(stack)]])
20
+ const isCurrent = () => [...owned].every(([name, version]) => versions.get(name) === version)
21
+ return {
22
+ isCurrent,
23
+ claim(name: string) {
24
+ if (!isCurrent()) {
25
+ return false
26
+ }
27
+
28
+ if (!owned.has(name)) {
29
+ owned.set(name, invalidate(name))
30
+ }
31
+
32
+ return true
33
+ },
34
+ }
35
+ },
36
+ }
37
+ }
@@ -0,0 +1,316 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createObjectContainer,
4
+ createObjectDriver,
5
+ createUniversalRoot,
6
+ flushUniversalSync,
7
+ } from 'octane/universal/native'
8
+
9
+ const state = vi.hoisted(() => ({
10
+ hosts: [] as any[],
11
+ roots: [] as any[],
12
+ subscriptions: new Set<() => void>(),
13
+ owner: null as any,
14
+ open: null as any,
15
+ cleanup: [] as any[],
16
+ enter: vi.fn(),
17
+ }))
18
+
19
+ vi.mock('@nativescript/core', () => ({
20
+ isIOS: false,
21
+ isAndroid: false,
22
+ Screen: { mainScreen: { scale: 1 } },
23
+ GridLayout: class {
24
+ className = ''
25
+ style = {}
26
+ parent: any
27
+ handlers = new Map<string, Set<() => void>>()
28
+ constructor() {
29
+ state.hosts.push(this)
30
+ }
31
+ on(event: string, handler: () => void) {
32
+ const hs = this.handlers.get(event) ?? new Set()
33
+ hs.add(handler)
34
+ this.handlers.set(event, hs)
35
+ }
36
+ off(event: string, handler: () => void) {
37
+ this.handlers.get(event)?.delete(handler)
38
+ }
39
+ notify(event: string) {
40
+ for (const handler of this.handlers.get(event) ?? []) {
41
+ handler()
42
+ }
43
+ }
44
+ },
45
+ }))
46
+
47
+ vi.mock('@nativescript-community/octane', async (original) => ({
48
+ ...(await original<Record<string, unknown>>()),
49
+ createNativeScriptRoot: () => {
50
+ const root = { render: vi.fn(), unmount: vi.fn() }
51
+ state.roots.push(root)
52
+ return root
53
+ },
54
+ }))
55
+
56
+ vi.mock('./root-layout.mobile', () => ({ rootLayoutFor: () => state.owner }))
57
+ vi.mock('./theme/theme-scheme', () => ({
58
+ useThemeScheme: () => 'light',
59
+ applyThemeClasses: (host: any, base: string) => {
60
+ host.className = base
61
+ const listener = () => {
62
+ host.className = base
63
+ }
64
+
65
+ state.subscriptions.add(listener)
66
+ return () => {
67
+ state.subscriptions.delete(listener)
68
+ }
69
+ },
70
+ }))
71
+
72
+ vi.mock('./keyboard-inset.mobile', () => ({
73
+ bindBottomInsetToKeyboard: () => {
74
+ const off = vi.fn()
75
+ state.cleanup.push(off)
76
+ return off
77
+ },
78
+ }))
79
+
80
+ vi.mock('./tap-to-blur', () => ({
81
+ attachTapToBlur: () => {
82
+ const off = vi.fn()
83
+ state.cleanup.push(off)
84
+ return off
85
+ },
86
+ }))
87
+
88
+ vi.mock('./sheet-detents', () => ({
89
+ attachSheetDetents: () => {
90
+ const off = vi.fn()
91
+ state.cleanup.push(off)
92
+ return { detach: off, enter: state.enter }
93
+ },
94
+ }))
95
+
96
+ import { Overlay } from './Overlay.tsrx'
97
+ import { Sheet } from './Sheet.tsrx'
98
+ import { Popover } from './Popover.tsrx'
99
+ const mounted: any[] = []
100
+ const settle = async () => {
101
+ flushUniversalSync(() => {})
102
+ await new Promise((r) => setTimeout(r, 0))
103
+ flushUniversalSync(() => {})
104
+ }
105
+
106
+ function mount(Component: any, props: any = {}) {
107
+ const root = createUniversalRoot(
108
+ createObjectContainer('nativescript'),
109
+ createObjectDriver('nativescript'),
110
+ )
111
+
112
+ mounted.push(root)
113
+ const render = (extra: any = {}) => {
114
+ root.render(Component, {
115
+ open: true,
116
+ children: 'content',
117
+ anchor: { current: {} },
118
+ ...props,
119
+ ...extra,
120
+ })
121
+
122
+ flushUniversalSync(() => {})
123
+ }
124
+
125
+ render()
126
+ return { root, render }
127
+ }
128
+
129
+ beforeEach(() => {
130
+ state.hosts.length = 0
131
+ state.roots.length = 0
132
+ state.cleanup.length = 0
133
+ state.subscriptions.clear()
134
+ state.enter.mockClear()
135
+ state.open = null
136
+ state.owner = {
137
+ children: new Set(),
138
+ popups: new Set(),
139
+ open: vi.fn((host: any) => {
140
+ state.owner.children.add(host)
141
+ state.owner.popups.add(host)
142
+ host.parent = state.owner
143
+ return state.open ?? Promise.resolve()
144
+ }),
145
+ hasChild: (host: any) => state.owner.children.has(host),
146
+ getPopupIndex: (host: any) => (state.owner.popups.has(host) ? 0 : -1),
147
+ removeChild: vi.fn((host: any) => {
148
+ state.owner.children.delete(host)
149
+ host.parent = null
150
+ }),
151
+ close: vi.fn(async (host: any) => {
152
+ state.owner.popups.delete(host)
153
+ host.notify('closed')
154
+ state.owner.removeChild(host)
155
+ }),
156
+ }
157
+ })
158
+
159
+ afterEach(async () => {
160
+ for (const root of mounted.splice(0)) {
161
+ root.unmount()
162
+ }
163
+
164
+ await settle()
165
+ })
166
+
167
+ describe.each([
168
+ ['Overlay', Overlay],
169
+ ['Sheet', Sheet],
170
+ ['Popover', Popover],
171
+ ] as const)('%s native lifetime', (_, Component) => {
172
+ it('releases its host, root and subscriptions when its owner unmounts', async () => {
173
+ const dismiss = vi.fn()
174
+ const { root } = mount(Component, { onDismiss: dismiss, detents: [0.5, 1] })
175
+ await settle()
176
+ root.unmount()
177
+ await settle()
178
+ expect(state.owner.children.size).toBe(0)
179
+ expect(state.roots[0].unmount).toHaveBeenCalledTimes(1)
180
+ expect(state.subscriptions.size).toBe(0)
181
+ expect(dismiss).not.toHaveBeenCalled()
182
+ for (const off of state.cleanup) {
183
+ expect(off).toHaveBeenCalledTimes(1)
184
+ }
185
+ })
186
+
187
+ it('releases everything on programmatic close without reporting dismissal', async () => {
188
+ const dismiss = vi.fn()
189
+ const { render } = mount(Component, { onDismiss: dismiss, detents: [0.5, 1] })
190
+ await settle()
191
+ render({ open: false })
192
+ await settle()
193
+ expect(state.owner.children.size).toBe(0)
194
+ expect(state.roots[0].unmount).toHaveBeenCalledTimes(1)
195
+ expect(state.subscriptions.size).toBe(0)
196
+ expect(dismiss).not.toHaveBeenCalled()
197
+ })
198
+
199
+ it('ignores late open completion after unmount', async () => {
200
+ let finish!: () => void
201
+ state.open = new Promise<void>((r) => {
202
+ finish = r
203
+ })
204
+
205
+ const { root } = mount(Component, { detents: [0.5, 1] })
206
+ await settle()
207
+ root.unmount()
208
+ await settle()
209
+ finish()
210
+ await settle()
211
+ expect(state.owner.children.size).toBe(0)
212
+ expect(state.roots[0].unmount).toHaveBeenCalledTimes(1)
213
+ expect(state.enter).not.toHaveBeenCalled()
214
+ })
215
+
216
+ it('cleans up a rejected open', async () => {
217
+ let fail!: (e: Error) => void
218
+ state.open = new Promise((_, reject) => {
219
+ fail = reject
220
+ })
221
+
222
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
223
+ mount(Component, { detents: [0.5, 1] })
224
+ await settle()
225
+ fail(new Error('open failed'))
226
+ await settle()
227
+ expect(state.owner.children.size).toBe(0)
228
+ expect(state.subscriptions.size).toBe(0)
229
+ expect(state.roots[0].unmount).toHaveBeenCalledTimes(1)
230
+ for (const off of state.cleanup) {
231
+ expect(off).toHaveBeenCalledTimes(1)
232
+ }
233
+
234
+ error.mockRestore()
235
+ })
236
+
237
+ it('does not remove a host already closing through RootLayout', async () => {
238
+ const { root } = mount(Component)
239
+ await settle()
240
+ const host = state.hosts[0]
241
+ state.owner.popups.delete(host)
242
+ root.unmount()
243
+ await settle()
244
+ expect(state.owner.close).not.toHaveBeenCalled()
245
+ expect(state.owner.removeChild).not.toHaveBeenCalled()
246
+ host.notify('closed')
247
+ state.owner.removeChild(host)
248
+ expect(state.roots[0].unmount).toHaveBeenCalledTimes(1)
249
+ })
250
+
251
+ it('detaches a host when its close animation rejects', async () => {
252
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
253
+ const { root } = mount(Component)
254
+ await settle()
255
+ state.owner.close.mockImplementation(async (host: any) => {
256
+ state.owner.popups.delete(host)
257
+ throw new Error('close failed')
258
+ })
259
+
260
+ root.unmount()
261
+ await settle()
262
+ expect(state.owner.children.size).toBe(0)
263
+ expect(state.subscriptions.size).toBe(0)
264
+ error.mockRestore()
265
+ })
266
+
267
+ it('keeps a reopened host alive when the old open rejects later', async () => {
268
+ let fail!: (e: Error) => void
269
+ state.open = new Promise((_, reject) => {
270
+ fail = reject
271
+ })
272
+
273
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
274
+ const { render } = mount(Component)
275
+ await settle()
276
+ render({ open: false })
277
+ await settle()
278
+ state.open = null
279
+ render({ open: true })
280
+ await settle()
281
+ fail(new Error('old open'))
282
+ await settle()
283
+ expect(state.owner.children.size).toBe(1)
284
+ expect(state.roots[0].unmount).toHaveBeenCalledTimes(1)
285
+ expect(state.roots[1].unmount).not.toHaveBeenCalled()
286
+ error.mockRestore()
287
+ })
288
+
289
+ it('uses the latest dismissal callback and never reenters a platform close', async () => {
290
+ const old = vi.fn(),
291
+ current = vi.fn()
292
+
293
+ const { render } = mount(Component, { onDismiss: old })
294
+ await settle()
295
+ render({ onDismiss: current, children: 'updated' })
296
+ await settle()
297
+ await state.owner.close(state.hosts[0])
298
+ await settle()
299
+ expect(old).not.toHaveBeenCalled()
300
+ expect(current).toHaveBeenCalledTimes(1)
301
+ expect(state.owner.close).toHaveBeenCalledTimes(1)
302
+ expect(state.subscriptions.size).toBe(0)
303
+ })
304
+ })
305
+
306
+ it('Overlay replaces theme subscriptions when content or classes update', async () => {
307
+ const { render } = mount(Overlay)
308
+ await settle()
309
+ render({ className: 'new', children: 'updated' })
310
+ await settle()
311
+ expect(state.subscriptions.size).toBe(1)
312
+ expect(state.hosts[0].className).toContain('new')
313
+ render({ open: false })
314
+ await settle()
315
+ expect(state.subscriptions.size).toBe(0)
316
+ })
@@ -0,0 +1,107 @@
1
+ import type { GridLayout, RootLayout, RootLayoutOptions } from '@nativescript/core'
2
+
3
+ /** Own the resources of one independently mounted overlay. RootLayout emits
4
+ * closed before removing the host, so that notification must never reenter close. */
5
+ export function createOverlayLifetime(
6
+ owner: RootLayout,
7
+ host: GridLayout,
8
+ root: { unmount(): void },
9
+ label: string,
10
+ onFinish: (dismissed: boolean) => void,
11
+ ) {
12
+ // RootLayout removes closing hosts from its private popup registry before
13
+ // their exit animation finishes. Inspect that runtime seam to avoid reentry.
14
+ const registry = owner as unknown as {
15
+ hasChild(view: GridLayout): boolean
16
+ getPopupIndex(view: GridLayout): number
17
+ }
18
+
19
+ let released = false
20
+ let closing = false
21
+ const cleanups = new Set<() => void>()
22
+ const report = (action: string, error: unknown) =>
23
+ console.error(`[${label}] ${action} failed`, error)
24
+
25
+ const release = (dismissed: boolean) => {
26
+ if (released) {
27
+ return
28
+ }
29
+
30
+ released = true
31
+ host.off('closed', onClosed)
32
+ for (const cleanup of cleanups) {
33
+ cleanup()
34
+ }
35
+
36
+ cleanups.clear()
37
+ root.unmount()
38
+ onFinish(dismissed)
39
+ }
40
+
41
+ const onClosed = () => {
42
+ closing = true
43
+ release(true)
44
+ }
45
+
46
+ const detach = () => {
47
+ if (closing || !registry.hasChild(host)) {
48
+ return
49
+ }
50
+
51
+ // A shade tap or another caller may already be animating the close.
52
+ if (registry.getPopupIndex(host) === -1) {
53
+ return
54
+ }
55
+
56
+ closing = true
57
+ const failed = (error: unknown) => {
58
+ report('close', error)
59
+ if (registry.hasChild(host)) {
60
+ owner.removeChild(host)
61
+ }
62
+ }
63
+
64
+ try {
65
+ void owner.close(host).catch(failed)
66
+ } catch (error) {
67
+ failed(error)
68
+ }
69
+ }
70
+
71
+ host.on('closed', onClosed)
72
+ return {
73
+ own(cleanup: () => void) {
74
+ if (released) {
75
+ cleanup()
76
+ } else {
77
+ cleanups.add(cleanup)
78
+ }
79
+ },
80
+ close() {
81
+ release(false)
82
+ detach()
83
+ },
84
+ open(options?: RootLayoutOptions, afterOpen?: () => void | Promise<void>) {
85
+ const failed = (error: unknown) => {
86
+ report('open', error)
87
+ release(false)
88
+ detach()
89
+ }
90
+
91
+ try {
92
+ void owner
93
+ .open(host, options)
94
+ .then(() => {
95
+ if (released) {
96
+ detach()
97
+ } else {
98
+ return afterOpen?.()
99
+ }
100
+ })
101
+ .catch(failed)
102
+ } catch (error) {
103
+ failed(error)
104
+ }
105
+ },
106
+ }
107
+ }