@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.
- package/README.md +4 -3
- package/dist/native/Button.tsrx.js +1 -1
- package/dist/native/CommandPalette.tsrx.js +7 -1
- package/dist/native/Grid.tsrx.js +31 -2
- package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
- package/dist/native/Markdown.tsrx.js +233 -0
- package/dist/native/Overlay.tsrx.js +15 -26
- package/dist/native/Pagination.tsrx.js +15 -12
- package/dist/native/PinInput.tsrx.js +25 -7
- package/dist/native/Popover.tsrx.js +33 -23
- package/dist/native/Pressable.tsrx.js +4 -1
- package/dist/native/Screen.tsrx.js +1 -1
- package/dist/native/SearchInput.tsrx.js +6 -3
- package/dist/native/Sheet.tsrx.js +23 -38
- package/dist/native/Slider.tsrx.js +23 -14
- package/dist/native/Text.tsrx.js +16 -0
- package/dist/native/TextArea.tsrx.js +6 -3
- package/dist/native/TextInput.tsrx.js +6 -3
- package/dist/native/VirtualList.tsrx.js +305 -121
- package/dist/native/WebView.tsrx.js +17 -2
- package/dist/native/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
- package/dist/native/escape-props.js +2 -0
- package/dist/native/grid-auto-placement.js +83 -0
- package/dist/native/grid-placement.js +20 -0
- package/dist/native/index.shared.js +7 -5
- package/dist/native/keyboard-inset.mobile.js +5 -5
- package/dist/native/navigation-request.js +26 -0
- package/dist/native/octane-client-build.json +1 -1
- package/dist/native/overlay-lifetime.mobile.js +65 -0
- package/dist/native/pan.tsrx.js +16 -2
- package/dist/native/route-table.js +90 -10
- package/dist/native/route.js +26 -16
- package/dist/native/shared.js +7 -5
- package/dist/native/sheet-service.js +2 -2
- package/dist/native/svg.mobile.js +1 -1
- package/dist/native/{tap-to-blur.mobile.js → tap-to-blur.ios.js} +20 -32
- package/dist/native/text-focus.js +10 -0
- package/dist/native/text-write.js +18 -11
- package/dist/native/toast-anchor.tsrx.js +1 -1
- package/dist/native/ui.js +7 -6
- package/dist/native/useMeasure.tsrx.js +17 -18
- package/dist/native/vendor/ui-svg/index.android.js +2 -2
- package/dist/native/vendor/ui-svg/index.ios.js +2 -2
- package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
- package/dist/native/virtual-list-cell-pool.js +63 -0
- package/dist/web/Button.web.tsrx.js +1 -1
- package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
- package/dist/web/Markdown.tsrx.js +98 -0
- package/dist/web/Overlay.web.tsrx.js +81 -32
- package/dist/web/PinInput.web.tsrx.js +24 -10
- package/dist/web/Pressable.web.tsrx.js +169 -53
- package/dist/web/SearchInput.web.tsrx.js +2 -2
- package/dist/web/Sheet.web.tsrx.js +20 -7
- package/dist/web/TextArea.web.tsrx.js +22 -17
- package/dist/web/TextInput.web.tsrx.js +2 -2
- package/dist/web/VirtualList.web.tsrx.js +220 -109
- package/dist/web/WebView.web.tsrx.js +3 -3
- package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
- package/dist/web/modal-focus.web.js +95 -0
- package/dist/web/navigation-request.js +26 -0
- package/dist/web/octane-client-build.json +1 -1
- package/dist/web/pan.web.tsrx.js +27 -10
- package/dist/web/route-table.js +90 -10
- package/dist/web/route.web.js +88 -22
- package/dist/web/sheet-service.web.js +8 -0
- package/dist/web/theme/colorScheme.web.js +31 -4
- package/dist/web/ui.js +8 -6
- package/dist/web/virtual-list-cell-pool.js +63 -0
- package/package.json +21 -19
- package/platforms/android/native-api-usage.json +7 -7
- package/src/CommandPalette.tsrx +8 -1
- package/src/Grid.tsrx +32 -1
- package/src/Icon.macos.tsrx +1 -0
- package/src/KeyboardAvoiding.macos.tsrx +7 -1
- package/src/KeyboardAvoiding.mobile.tsrx +2 -2
- package/src/KeyboardAvoiding.tsrx +15 -0
- package/src/KeyboardAvoiding.web.tsrx +15 -0
- package/src/Markdown.tsrx +121 -0
- package/src/Meter.tsrx +1 -0
- package/src/Overlay.tsrx +15 -30
- package/src/Overlay.web.tsrx +12 -3
- package/src/Pagination.tsrx +6 -4
- package/src/PinInput.macos.tsrx +20 -6
- package/src/PinInput.mobile.test.ts +107 -0
- package/src/PinInput.tsrx +20 -6
- package/src/PinInput.web.test.tsrx +42 -0
- package/src/PinInput.web.tsrx +20 -6
- package/src/Popover.tsrx +36 -22
- package/src/Pressable.tsrx +1 -1
- package/src/Pressable.web.test.tsrx +36 -0
- package/src/Pressable.web.tsrx +37 -6
- package/src/RouteHost.macos.tsrx +1 -0
- package/src/Screen.tsrx +1 -1
- package/src/SearchInput.tsrx +4 -3
- package/src/SearchInput.web.tsrx +1 -1
- package/src/Sheet.tsrx +21 -42
- package/src/Sheet.web.tsrx +9 -1
- package/src/Slider.tsrx +13 -11
- package/src/Text.tsrx +36 -0
- package/src/TextArea.tsrx +6 -5
- package/src/TextArea.web.test.tsrx +39 -0
- package/src/TextArea.web.tsrx +7 -5
- package/src/TextInput.tsrx +6 -6
- package/src/TextInput.web.tsrx +1 -1
- package/src/VirtualList.macos.tsrx +93 -6
- package/src/VirtualList.mobile.test.tsrx +232 -0
- package/src/VirtualList.tsrx +285 -81
- package/src/VirtualList.web.tsrx +131 -25
- package/src/WebView.tsrx +20 -10
- package/src/WebView.web.tsrx +3 -0
- package/src/android/BottomNavigationView.android.tsrx +1 -0
- package/src/android/MaterialDialog.android.tsrx +1 -0
- package/src/anim.macos.tsrx +1 -0
- package/src/apply-native-modifiers.native.ts +4 -1
- package/src/child-array.ts +12 -7
- package/src/colorScheme.macos.ts +1 -0
- package/src/controls.macos.tsrx +4 -1
- package/src/data-driver.mobile.test.ts +120 -0
- package/src/data-lifecycle.mobile.test.ts +251 -0
- package/src/escape-props.ts +13 -1
- package/src/grid-auto-placement.ts +163 -0
- package/src/grid-placement.ts +29 -0
- package/src/hosted-unsupported.macos.tsrx +1 -0
- package/src/index.macos.ts +23 -3
- package/src/index.mobile.ts +0 -1
- package/src/index.shared.ts +22 -1
- package/src/index.shared.web.ts +291 -0
- package/src/index.web.ts +1 -1
- package/src/input-readiness.web.test.tsrx +82 -0
- package/src/ios/UITabBar.ios.tsrx +1 -0
- package/src/keyboard-inset.mobile.test.ts +62 -0
- package/src/keyboard-inset.mobile.ts +63 -43
- package/src/layout-shells.macos.tsrx +1 -5
- package/src/macos-extras.macos.tsrx +2 -0
- package/src/modal-focus.web.test.tsrx +63 -0
- package/src/modal-focus.web.ts +162 -0
- package/src/navigation-request.ts +37 -0
- package/src/overlay-lifecycle.mobile.test.ts +316 -0
- package/src/overlay-lifetime.mobile.ts +107 -0
- package/src/pan.mobile.test.tsrx +116 -0
- package/src/pan.tsrx +20 -2
- package/src/pan.web.tsrx +35 -7
- package/src/platformProps.ts +1 -0
- package/src/popover.mobile.test.ts +9 -1
- package/src/pressable-accessibility.mobile.test.ts +30 -0
- package/src/props.ts +163 -3
- package/src/refresh.ts +6 -1
- package/src/refresh.web.test.ts +77 -77
- package/src/route-android.test.ts +162 -0
- package/src/route-history.web.test.ts +80 -0
- package/src/route-navigation.web.test.ts +173 -0
- package/src/route-table.test.ts +188 -9
- package/src/route-table.ts +138 -10
- package/src/route.macos.ts +56 -13
- package/src/route.ts +54 -24
- package/src/route.web.ts +141 -24
- package/src/sheet-detents.ts +5 -3
- package/src/sheet-service.macos.ts +5 -1
- package/src/sheet-service.ts +1 -1
- package/src/sheet-service.web.ts +8 -0
- package/src/sheet-snap.test.ts +23 -23
- package/src/sheet-snap.ts +6 -1
- package/src/svg.mobile.ts +9 -3
- package/src/tap-to-blur.ios.ts +106 -0
- package/src/tap-to-blur.mobile.ts +21 -95
- package/src/text-focus.ts +12 -0
- package/src/text-input.mobile.test.ts +89 -0
- package/src/text-write.ts +21 -11
- package/src/theme/colorScheme.linux.ts +2 -4
- package/src/theme/colorScheme.web.ts +51 -4
- package/src/theme/theme-scheme.macos.ts +8 -2
- package/src/theme/tokens.css +1740 -1718
- package/src/translate.macos.ts +4 -1
- package/src/useMeasure.tsrx +20 -21
- package/src/virtual-list-cell-pool.test.ts +53 -0
- package/src/virtual-list-cell-pool.ts +81 -0
- package/src/virtual-list-layout.test.ts +11 -8
- package/src/virtual-list-layout.ts +4 -8
- package/src/windows.linux.ts +7 -2
- package/src/windows.macos.ts +1 -0
- package/types/generated/.tsrx-typegen-manifest.json +16 -3
- package/types/generated/Grid.d.ts +0 -1
- package/types/generated/KeyboardAvoiding.d.ts +3 -0
- package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
- package/types/generated/Markdown.d.ts +59 -0
- package/types/generated/TextArea.d.ts +2 -2
- package/types/generated/TextArea.web.d.ts +1 -1
- package/types/generated/TextInput.d.ts +2 -3
- package/types/generated/VirtualList.d.ts +3 -3
- package/types/generated/escape-props.d.ts +4 -0
- package/types/generated/grid-auto-placement.d.ts +13 -0
- package/types/generated/grid-placement.d.ts +5 -0
- package/types/generated/index.mobile.d.ts +0 -1
- package/types/generated/index.shared.d.ts +6 -3
- package/types/generated/index.shared.web.d.ts +114 -0
- package/types/generated/index.web.d.ts +1 -1
- package/types/generated/modal-focus.web.d.ts +3 -0
- package/types/generated/navigation-request.d.ts +10 -0
- package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
- package/types/generated/props.d.ts +111 -3
- package/types/generated/route-table.d.ts +14 -3
- package/types/generated/svg.mobile.d.ts +2 -2
- package/types/generated/tap-to-blur.ios.d.ts +13 -0
- package/types/generated/tap-to-blur.mobile.d.ts +1 -15
- package/types/generated/text-focus.d.ts +3 -0
- package/types/generated/text-write.d.ts +3 -1
- package/types/generated/theme/colorScheme.web.d.ts +5 -0
- package/types/generated/virtual-list-cell-pool.d.ts +18 -0
- package/types/index.macos.d.ts +15 -3
- /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
- /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
- /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
- /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
- /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
- /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
- /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
- /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
- /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
- /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
|
+
}
|