@onekeyfe/react-native-native-overlay 3.0.162-alpha.270 → 6.7.0-alpha.335

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 (33) hide show
  1. package/android/build.gradle +6 -0
  2. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolation.kt +37 -0
  3. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt +22 -28
  4. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt +21 -0
  5. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt +7 -2
  6. package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolationTest.kt +112 -0
  7. package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayBackTest.kt +122 -0
  8. package/docs/SPEC.md +91 -10
  9. package/lib/module/OverlayPageHost.web.js +19 -6
  10. package/lib/module/OverlayPageHost.web.js.map +1 -1
  11. package/lib/module/OverlayView.web.js +62 -10
  12. package/lib/module/OverlayView.web.js.map +1 -1
  13. package/lib/module/useOverlayController.js +7 -3
  14. package/lib/module/useOverlayController.js.map +1 -1
  15. package/lib/module/web/overlayLayers.js +11 -9
  16. package/lib/module/web/overlayLayers.js.map +1 -1
  17. package/lib/module/web/useKeyboardShift.js +1 -1
  18. package/lib/module/web/useKeyboardShift.js.map +1 -1
  19. package/lib/module/web/useSheetDrag.js +3 -0
  20. package/lib/module/web/useSheetDrag.js.map +1 -1
  21. package/lib/typescript/src/OverlayPageHost.web.d.ts.map +1 -1
  22. package/lib/typescript/src/OverlayView.web.d.ts.map +1 -1
  23. package/lib/typescript/src/useOverlayController.d.ts.map +1 -1
  24. package/lib/typescript/src/web/overlayLayers.d.ts +2 -0
  25. package/lib/typescript/src/web/overlayLayers.d.ts.map +1 -1
  26. package/lib/typescript/src/web/useSheetDrag.d.ts.map +1 -1
  27. package/package.json +2 -1
  28. package/src/OverlayPageHost.web.tsx +24 -9
  29. package/src/OverlayView.web.tsx +134 -41
  30. package/src/useOverlayController.ts +7 -4
  31. package/src/web/overlayLayers.ts +28 -10
  32. package/src/web/useKeyboardShift.ts +1 -1
  33. package/src/web/useSheetDrag.ts +3 -0
@@ -60,6 +60,10 @@ android {
60
60
  ]
61
61
  }
62
62
  }
63
+
64
+ testOptions {
65
+ unitTests.includeAndroidResources = true
66
+ }
63
67
  }
64
68
 
65
69
  repositories {
@@ -73,6 +77,8 @@ dependencies {
73
77
  implementation "com.facebook.react:react-android"
74
78
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
75
79
  implementation "com.google.android.material:material:1.14.0-alpha05"
80
+ testImplementation "junit:junit:4.13.2"
81
+ testImplementation "org.robolectric:robolectric:4.17"
76
82
  }
77
83
 
78
84
  react {
@@ -0,0 +1,37 @@
1
+ package com.onekey.nativeoverlay
2
+
3
+ import android.view.View
4
+ import android.view.ViewGroup
5
+
6
+ /** Restores the caller's flags when a blocking overlay no longer covers it. */
7
+ internal class NativeOverlayAccessibilityIsolation {
8
+ private val hidden = mutableMapOf<View, Int>()
9
+
10
+ fun update(
11
+ root: ViewGroup,
12
+ activeContainer: View?,
13
+ entriesBelow: List<View>,
14
+ globalHost: View,
15
+ ) {
16
+ val below = mutableSetOf<View>()
17
+ if (activeContainer != null) {
18
+ var node: View = activeContainer
19
+ while (node !== root && node.parent is ViewGroup) {
20
+ val parent = node.parent as ViewGroup
21
+ for (index in 0 until parent.childCount) {
22
+ val sibling = parent.getChildAt(index)
23
+ if (sibling !== node && sibling !== globalHost) below.add(sibling)
24
+ }
25
+ node = parent
26
+ }
27
+ if (node === root) below.addAll(entriesBelow) else below.clear()
28
+ }
29
+ hidden.keys.filter { it !in below }.forEach { view ->
30
+ view.importantForAccessibility = hidden.remove(view) ?: View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
31
+ }
32
+ below.forEach { view ->
33
+ if (view !in hidden) hidden[view] = view.importantForAccessibility
34
+ view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
35
+ }
36
+ }
37
+ }
@@ -2,7 +2,6 @@ package com.onekey.nativeoverlay
2
2
 
3
3
  import android.app.Activity
4
4
  import android.view.KeyEvent
5
- import android.view.View
6
5
  import android.view.ViewGroup
7
6
  import android.view.Window
8
7
  import android.widget.FrameLayout
@@ -49,12 +48,12 @@ internal class NativeOverlayHost private constructor(
49
48
  )
50
49
  private val backCallback = object : OnBackPressedCallback(false) {
51
50
  override fun handleOnBackPressed() {
52
- topBlockingEntry()?.let { entry ->
51
+ topBackEntry()?.let { entry ->
53
52
  if (entry.dismissOnBackPress) entry.onRequestDismiss?.invoke("back")
54
53
  }
55
54
  }
56
55
  }
57
- private val hiddenForAccessibility = mutableMapOf<View, Int>()
56
+ private val accessibilityIsolation = NativeOverlayAccessibilityIsolation()
58
57
 
59
58
  init {
60
59
  clipChildren = false
@@ -67,7 +66,7 @@ internal class NativeOverlayHost private constructor(
67
66
  * predictive back, so the dispatcher alone never runs first.
68
67
  */
69
68
  fun handleBackKey(event: KeyEvent): Boolean {
70
- val entry = topBlockingEntry() ?: return false
69
+ val entry = topBackEntry() ?: return false
71
70
  if (event.action == KeyEvent.ACTION_UP && !event.isCanceled && entry.dismissOnBackPress) {
72
71
  entry.onRequestDismiss?.invoke("back")
73
72
  }
@@ -109,14 +108,14 @@ internal class NativeOverlayHost private constructor(
109
108
  }
110
109
 
111
110
  fun onEntriesChanged() {
112
- val hasBlocking = topBlockingEntry() != null
113
- if (hasBlocking && !backCallback.isEnabled) {
111
+ val hasBackEntry = topBackEntry() != null
112
+ if (hasBackEntry && !backCallback.isEnabled) {
114
113
  // The most recently added callback wins; re-add so ours runs before
115
114
  // ReactActivity's and react-native-screens' callbacks.
116
115
  backCallback.remove()
117
116
  (activity as? ComponentActivity)?.onBackPressedDispatcher?.addCallback(backCallback)
118
117
  }
119
- backCallback.isEnabled = hasBlocking
118
+ backCallback.isEnabled = hasBackEntry
120
119
  updateAccessibility()
121
120
  }
122
121
 
@@ -134,35 +133,30 @@ internal class NativeOverlayHost private constructor(
134
133
  }
135
134
 
136
135
  /** Global overlays win; otherwise the topmost visible page overlay. */
137
- private fun topBlockingEntry(): NativeOverlayEntryRootView? =
138
- orderedEntries().lastOrNull { it.blocking && it.isShownForInput }
139
- ?: NativeOverlayPageHostView.allHosts()
140
- .filter { it.isAttachedToWindow && it.isShown }
136
+ private fun topBackEntry(): NativeOverlayEntryRootView? =
137
+ orderedEntries().lastOrNull { (it.blocking || it.dismissOnBackPress) && it.isShownForInput }
138
+ ?: shownPageHosts()
141
139
  .flatMap { it.shownEntries() }
142
- .lastOrNull { it.blocking }
140
+ .lastOrNull { it.blocking || it.dismissOnBackPress }
141
+
142
+ private fun shownPageHosts(): List<NativeOverlayPageHostView> =
143
+ NativeOverlayPageHostView.allHosts().filter {
144
+ it.isAttachedToWindow && it.isShown && it.rootView === activity.window.decorView
145
+ }
143
146
 
144
147
  /** TalkBack only reads the topmost blocking overlay and what is above it. */
145
148
  private fun updateAccessibility() {
146
149
  val entries = orderedEntries()
147
150
  val topIndex = entries.indexOfLast { it.blocking && it.isShownForInput }
148
- val content = parent as? ViewGroup
149
- val below = mutableListOf<View>()
151
+ val content = activity.findViewById<ViewGroup>(android.R.id.content) ?: return
150
152
  if (topIndex >= 0) {
151
- content?.let { group ->
152
- (0 until group.childCount).map(group::getChildAt).filter { it !== this }.forEach(below::add)
153
- }
154
- below.addAll(entries.subList(0, topIndex))
155
- }
156
- val restore = hiddenForAccessibility.keys.filter { it !in below }
157
- restore.forEach { view ->
158
- view.importantForAccessibility = hiddenForAccessibility.remove(view) ?: IMPORTANT_FOR_ACCESSIBILITY_AUTO
159
- }
160
- below.forEach { view ->
161
- if (view !in hiddenForAccessibility) {
162
- hiddenForAccessibility[view] = view.importantForAccessibility
163
- view.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
164
- }
153
+ accessibilityIsolation.update(content, this, entries.take(topIndex), this)
154
+ return
165
155
  }
156
+ val pageEntry = shownPageHosts().flatMap { it.shownEntries() }.lastOrNull { it.blocking }
157
+ val pageHost = pageEntry?.parent as? NativeOverlayPageHostView
158
+ val below = pageHost?.shownEntries()?.takeWhile { it !== pageEntry }.orEmpty()
159
+ accessibilityIsolation.update(content, pageHost, below, this)
166
160
  }
167
161
 
168
162
  companion object {
@@ -3,6 +3,7 @@ package com.onekey.nativeoverlay
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context
5
5
  import android.view.ViewGroup
6
+ import com.facebook.react.bridge.ReactContext
6
7
  import com.facebook.react.uimanager.PointerEvents
7
8
  import com.facebook.react.uimanager.ReactPointerEventsView
8
9
  import java.lang.ref.WeakReference
@@ -33,6 +34,7 @@ class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPoi
33
34
  if (owners == suspendedOwners) return
34
35
  suspendedOwners = owners
35
36
  entries().forEach(::applySuspension)
37
+ onEntriesChanged()
36
38
  }
37
39
 
38
40
  internal fun attach(entry: NativeOverlayEntryRootView) {
@@ -44,10 +46,12 @@ class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPoi
44
46
  }
45
47
  layoutEntries()
46
48
  applySuspension(entry)
49
+ onEntriesChanged()
47
50
  }
48
51
 
49
52
  internal fun detach(entry: NativeOverlayEntryRootView) {
50
53
  removeView(entry)
54
+ onEntriesChanged()
51
55
  }
52
56
 
53
57
  /** Visible, input-ready entries in bottom-to-top order. */
@@ -56,6 +60,23 @@ class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPoi
56
60
 
57
61
  fun invalidateHost() {
58
62
  if (hostKey.isNotEmpty() && registry[hostKey]?.get() === this) registry.remove(hostKey)
63
+ onEntriesChanged()
64
+ }
65
+
66
+ override fun onAttachedToWindow() {
67
+ super.onAttachedToWindow()
68
+ onEntriesChanged()
69
+ }
70
+
71
+ override fun onDetachedFromWindow() {
72
+ super.onDetachedFromWindow()
73
+ onEntriesChanged()
74
+ }
75
+
76
+ private fun onEntriesChanged() {
77
+ (context as? ReactContext)?.currentActivity?.let {
78
+ NativeOverlayHost.of(it).onEntriesChanged()
79
+ }
59
80
  }
60
81
 
61
82
  private var layoutScheduled = false
@@ -135,6 +135,7 @@ class NativeOverlayView(
135
135
  config = OverlayAnimationConfig.parse(animationConfig)
136
136
  }
137
137
  entry?.let(::applyEntryConfiguration)
138
+ notifyHostEntriesChanged()
138
139
  if (visible) {
139
140
  cycleOpen = true
140
141
  if (phase == Phase.HIDDEN || phase == Phase.EXITING) present()
@@ -200,7 +201,7 @@ class NativeOverlayView(
200
201
  cancelAnimators()
201
202
  phase = Phase.ENTERING
202
203
  target.isShownForInput = true
203
- host?.onEntriesChanged()
204
+ notifyHostEntriesChanged()
204
205
  if (blocking) {
205
206
  target.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)
206
207
  }
@@ -226,7 +227,7 @@ class NativeOverlayView(
226
227
  cancelAnimators()
227
228
  phase = Phase.EXITING
228
229
  target.isShownForInput = false
229
- host?.onEntriesChanged()
230
+ notifyHostEntriesChanged()
230
231
  val transition = config.exit.reduceMotionAdjusted(isReduceMotionEnabled())
231
232
  if (transition.kind == "none") return finishDismiss("programmatic")
232
233
  animate(target, hiddenState(target, transition), 0f, transition.motion) {
@@ -389,6 +390,10 @@ class NativeOverlayView(
389
390
  }
390
391
  }
391
392
 
393
+ private fun notifyHostEntriesChanged() {
394
+ reactContext.currentActivity?.let { NativeOverlayHost.of(it).onEntriesChanged() }
395
+ }
396
+
392
397
  /**
393
398
  * While hidden, keep the React child inside this invisible view. Fabric only
394
399
  * reports onLayout for attached views, and fitted sheets size themselves
@@ -0,0 +1,112 @@
1
+ package com.onekey.nativeoverlay
2
+
3
+ import android.view.View
4
+ import android.widget.FrameLayout
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Test
7
+ import org.junit.runner.RunWith
8
+ import org.robolectric.RobolectricTestRunner
9
+ import org.robolectric.RuntimeEnvironment
10
+ import org.robolectric.annotation.Config
11
+
12
+ @RunWith(RobolectricTestRunner::class)
13
+ @Config(sdk = [34])
14
+ class NativeOverlayAccessibilityIsolationTest {
15
+ private val context get() = RuntimeEnvironment.getApplication()
16
+
17
+ private class Tree(context: android.content.Context) {
18
+ val root = FrameLayout(context)
19
+ val reactRoot = FrameLayout(context)
20
+ val card = FrameLayout(context)
21
+ val content = View(context).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES }
22
+ val chrome = View(context)
23
+ val pageHost = FrameLayout(context)
24
+ val lower = View(context)
25
+ val blocker = View(context)
26
+ val above = View(context)
27
+ val globalHost = FrameLayout(context)
28
+
29
+ init {
30
+ root.addView(reactRoot)
31
+ root.addView(globalHost)
32
+ reactRoot.addView(card)
33
+ reactRoot.addView(chrome)
34
+ card.addView(content)
35
+ card.addView(pageHost)
36
+ pageHost.addView(lower)
37
+ pageHost.addView(blocker)
38
+ pageHost.addView(above)
39
+ }
40
+ }
41
+
42
+ @Test
43
+ fun pageBlockerHidesPageAndNavigationWithoutHidingTheHost() {
44
+ val tree = Tree(context)
45
+ val isolation = NativeOverlayAccessibilityIsolation()
46
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
47
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.content.importantForAccessibility)
48
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.chrome.importantForAccessibility)
49
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.pageHost.importantForAccessibility)
50
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.blocker.importantForAccessibility)
51
+ }
52
+
53
+ @Test
54
+ fun entriesAbovePageBlockerAndGlobalHostStayAccessible() {
55
+ val tree = Tree(context)
56
+ NativeOverlayAccessibilityIsolation().update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
57
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.lower.importantForAccessibility)
58
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.above.importantForAccessibility)
59
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.globalHost.importantForAccessibility)
60
+ }
61
+
62
+ @Test
63
+ fun dismissalOrSuspensionRestoresOriginalFlags() {
64
+ val tree = Tree(context)
65
+ tree.lower.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
66
+ val isolation = NativeOverlayAccessibilityIsolation()
67
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
68
+ isolation.update(tree.root, null, emptyList(), tree.globalHost)
69
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, tree.content.importantForAccessibility)
70
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO, tree.lower.importantForAccessibility)
71
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.chrome.importantForAccessibility)
72
+ }
73
+
74
+ @Test
75
+ fun globalBlockerHidesReactRootThenPageIsolationReturns() {
76
+ val tree = Tree(context)
77
+ val globalLower = View(context)
78
+ tree.globalHost.addView(globalLower)
79
+ val isolation = NativeOverlayAccessibilityIsolation()
80
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
81
+ isolation.update(tree.root, tree.globalHost, listOf(globalLower), tree.globalHost)
82
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.reactRoot.importantForAccessibility)
83
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, globalLower.importantForAccessibility)
84
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, tree.content.importantForAccessibility)
85
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
86
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.reactRoot.importantForAccessibility)
87
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, globalLower.importantForAccessibility)
88
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.content.importantForAccessibility)
89
+ }
90
+
91
+ @Test
92
+ fun detachedHostRestoresIsolationInsteadOfHidingAnotherTree() {
93
+ val tree = Tree(context)
94
+ val isolation = NativeOverlayAccessibilityIsolation()
95
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
96
+ tree.card.removeView(tree.pageHost)
97
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
98
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, tree.content.importantForAccessibility)
99
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.lower.importantForAccessibility)
100
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.chrome.importantForAccessibility)
101
+ }
102
+
103
+ @Test
104
+ fun changingTheBlockingEntryRestoresEntriesNoLongerBelowIt() {
105
+ val tree = Tree(context)
106
+ val isolation = NativeOverlayAccessibilityIsolation()
107
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower, tree.blocker), tree.globalHost)
108
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
109
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.blocker.importantForAccessibility)
110
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.lower.importantForAccessibility)
111
+ }
112
+ }
@@ -0,0 +1,122 @@
1
+ package com.onekey.nativeoverlay
2
+
3
+ import android.view.KeyEvent
4
+ import android.view.View
5
+ import android.widget.FrameLayout
6
+ import androidx.activity.ComponentActivity
7
+ import com.facebook.react.bridge.BridgeReactContext
8
+ import com.facebook.react.uimanager.ThemedReactContext
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertFalse
11
+ import org.junit.Assert.assertTrue
12
+ import org.junit.Before
13
+ import org.junit.Test
14
+ import org.junit.runner.RunWith
15
+ import org.robolectric.Robolectric
16
+ import org.robolectric.RobolectricTestRunner
17
+ import org.robolectric.annotation.Config
18
+
19
+ @RunWith(RobolectricTestRunner::class)
20
+ @Config(sdk = [34])
21
+ class NativeOverlayBackTest {
22
+ private lateinit var activity: ComponentActivity
23
+ private lateinit var context: ThemedReactContext
24
+ private lateinit var host: NativeOverlayHost
25
+ private val dismissals = mutableListOf<String>()
26
+
27
+ @Before
28
+ fun setUp() {
29
+ activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible().get()
30
+ val reactContext = BridgeReactContext(activity)
31
+ reactContext.onHostResume(activity)
32
+ context = ThemedReactContext(reactContext, activity, "OverlayBackTest", 1)
33
+ host = NativeOverlayHost.of(activity)
34
+ }
35
+
36
+ private fun entry(name: String, blocking: Boolean, dismissible: Boolean, order: Int = 0) =
37
+ NativeOverlayEntryRootView(context).apply {
38
+ this.blocking = blocking
39
+ dismissOnBackPress = dismissible
40
+ stackOrder = order
41
+ isShownForInput = true
42
+ onRequestDismiss = { dismissals.add("$name:$it") }
43
+ }
44
+
45
+ private fun back(action: Int = KeyEvent.ACTION_UP, flags: Int = 0) =
46
+ host.handleBackKey(KeyEvent(0L, 0L, action, KeyEvent.KEYCODE_BACK, 0, 0, 0, 0, flags))
47
+
48
+ @Test
49
+ fun dismissibleNonBlockingGlobalEntryWinsOverLowerBlocker() {
50
+ host.attach(entry("lower", true, true), NativeOverlayLevel.MODAL)
51
+ host.attach(entry("top", false, true), NativeOverlayLevel.TOAST)
52
+ assertTrue(back(KeyEvent.ACTION_DOWN))
53
+ assertEquals(emptyList<String>(), dismissals)
54
+ assertTrue(back())
55
+ assertEquals(listOf("top:back"), dismissals)
56
+ }
57
+
58
+ @Test
59
+ fun dispatcherHandlesNonBlockingEntryAndKeepsPageAccessible() {
60
+ val page = View(activity).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES }
61
+ activity.setContentView(page)
62
+ val top = entry("top", false, true)
63
+ host.attach(top, NativeOverlayLevel.MODAL)
64
+ activity.onBackPressedDispatcher.onBackPressed()
65
+ assertEquals(listOf("top:back"), dismissals)
66
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, page.importantForAccessibility)
67
+ host.detach(top)
68
+ assertFalse(back())
69
+ }
70
+
71
+ @Test
72
+ fun nonDismissibleBlockerConsumesBackWithoutClosingLowerEntry() {
73
+ host.attach(entry("lower", false, true), NativeOverlayLevel.MODAL)
74
+ host.attach(entry("lock", true, false), NativeOverlayLevel.LOCK)
75
+ assertTrue(back())
76
+ assertEquals(emptyList<String>(), dismissals)
77
+ }
78
+
79
+ @Test
80
+ fun passiveOrNotInputReadyEntriesDoNotTakeBack() {
81
+ host.attach(entry("passive", false, false), NativeOverlayLevel.TOAST)
82
+ host.attach(entry("hidden", true, true).apply { isShownForInput = false }, NativeOverlayLevel.LOCK)
83
+ assertFalse(back())
84
+ assertEquals(emptyList<String>(), dismissals)
85
+ }
86
+
87
+ @Test
88
+ fun canceledKeyUpDoesNotDismiss() {
89
+ host.attach(entry("top", false, true), NativeOverlayLevel.MODAL)
90
+ assertTrue(back(flags = KeyEvent.FLAG_CANCELED))
91
+ assertEquals(emptyList<String>(), dismissals)
92
+ }
93
+
94
+ @Test
95
+ fun dismissiblePageEntryWinsOverLowerPageBlockerAndSuspensionSkipsIt() {
96
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-back-test" }
97
+ activity.setContentView(pageHost)
98
+ val lower = entry("lower", true, true).apply { ownerKey = "owner" }
99
+ val top = entry("top", false, true, 1).apply { ownerKey = "owner" }
100
+ pageHost.attach(lower)
101
+ pageHost.attach(top)
102
+ assertTrue(back())
103
+ assertEquals(listOf("top:back"), dismissals)
104
+ pageHost.setSuspendedOwners("owner")
105
+ assertFalse(back())
106
+ pageHost.invalidateHost()
107
+ }
108
+
109
+ @Test
110
+ fun globalEntryTakesBackBeforePageEntryAndDismissalRestoresPageTarget() {
111
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-global-test" }
112
+ activity.setContentView(FrameLayout(activity).apply { addView(pageHost) })
113
+ pageHost.attach(entry("page", false, true))
114
+ val global = entry("global", false, true)
115
+ host.attach(global, NativeOverlayLevel.MODAL)
116
+ assertTrue(back())
117
+ host.detach(global)
118
+ assertTrue(back())
119
+ assertEquals(listOf("global:back", "page:back"), dismissals)
120
+ pageHost.invalidateHost()
121
+ }
122
+ }
package/docs/SPEC.md CHANGED
@@ -35,12 +35,13 @@ retains its original implementation attribution in source comments.
35
35
 
36
36
  ### Release boundary
37
37
 
38
- This branch aligns all 42 publishable workspaces to `3.0.162-alpha.270`.
39
- Publish with the existing `package-publish` workflow dispatch, `npm_dist_tag=next`
40
- and an empty `only_workspace`, which publishes and registry-verifies all 42.
41
- Do not merge either repository PR or change the `latest` tag. The app-monorepo
42
- workspace copy is removed only after this release succeeds and every target
43
- version and tarball is available. Versions already published on npm are immutable.
38
+ The earlier `3.0.162-alpha.272` release remains immutable. This branch now
39
+ includes the current main SDK baseline, with all publishable workspaces aligned
40
+ before release. Use `package-publish` with `npm_dist_tag=next` and an empty
41
+ `only_workspace` to generate a fresh `6.7.0-alpha.N` preview, publish every
42
+ workspace, and verify every registry version. Preserve `latest` and keep both
43
+ repository PRs open. App consumers pin the verified exact preview version and
44
+ refresh dependency and native locks before installed-version acceptance.
44
45
 
45
46
  ## 5.1 Purpose, scope, and non-goals
46
47
 
@@ -136,6 +137,11 @@ visible)`, `removePage(ownerKey)`.
136
137
 
137
138
  - `usePageOverlayHost()` resolves `hostKey` and `ownerKey` from navigation
138
139
  context.
140
+ - A declarative page overlay keeps `scope: 'page'` while either key is
141
+ unavailable. It does not request or present an entry until both keys are
142
+ available; becoming ready while visible requests it in the page lane.
143
+ Hiding or unmounting before readiness creates no entry or close callback.
144
+ Direct store requests still throw for missing page keys.
139
145
  - `useInPageDialog`, `useInModalDialog`, and `useInTabDialog` become aliases of
140
146
  page scope. Tab scope is removed.
141
147
 
@@ -208,6 +214,30 @@ request ─► queued ─► active ─► (enter anim) presented
208
214
  - Threads: store mutations happen on the JS thread. Native animation and layout
209
215
  run on the UI thread. Events are delivered as direct events.
210
216
 
217
+ ### Web presentation updates (OK-50653)
218
+
219
+ For an open Web overlay that keeps its portal host, changing `presentation`
220
+ between sheet and a non-sheet frame MUST preserve the mounted content subtree.
221
+ Its props and React context continue to update normally. Changing the optional
222
+ sheet handle MUST NOT move the content into a different React child slot.
223
+ Closing without `keepContentMounted` still unmounts content after exit; reopening
224
+ creates a fresh content lifecycle. Business entity changes and draft resets
225
+ remain caller-owned; the overlay does not cache or persist form values.
226
+
227
+ The stable sheet shell must not change non-sheet animation origins or keyboard
228
+ avoidance geometry. Switching away from a sheet clears any pan offset and its
229
+ backdrop opacity override, and releases an active pointer capture.
230
+
231
+ Conformance: `src/__tests__/draftRetention.web.test.ts` exercises the real Web
232
+ host, portal and store for presentation changes, live props/context/callbacks,
233
+ close/reopen, animation origins, keyboard geometry and an interrupted pan.
234
+ Chrome extension side-panel and ordinary-tab checks use synthetic content;
235
+ this correction does not claim new iOS/Android runtime verification.
236
+
237
+ These source changes require a new published package version before the app
238
+ can consume them from npm. This fix leaves package versions and lockfiles at
239
+ their existing values and does not publish a release.
240
+
211
241
  ## 5.5 Data, cache, and identity
212
242
 
213
243
  - Identity is the entry `id`: caller-provided, otherwise `overlay-<seq>`.
@@ -216,6 +246,17 @@ request ─► queued ─► active ─► (enter anim) presented
216
246
 
217
247
  ## 5.6 Platform contract
218
248
 
249
+ ### Web keyboard focus containment
250
+
251
+ The topmost active, unsuspended blocking Web entry MUST contain sequential
252
+ keyboard focus. Tab from its last tabbable child wraps to the first;
253
+ Shift+Tab from its first wraps to the last. From the entry root, focus enters
254
+ the first/last child respectively. With no tabbable children, focus stays on
255
+ the entry root. Disabled, negative-tabindex, hidden and inert subtrees are
256
+ excluded. Positive tabindex values precede ordinary DOM-order tab stops.
257
+ Non-blocking, lower, closing, suspended and parked entries MUST NOT run a
258
+ focus loop. Existing focus entry/restore and Escape policies are unchanged.
259
+
219
260
  | Concern | iOS | Android | Web |
220
261
  | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
221
262
  | Global level host | One passthrough `UIWindow` per active level per `UIWindowScene`, ordered by `windowLevel`. `lock` sits above RN Alert (2001). | One `OverlayHost` FrameLayout in `android.R.id.content` above the ReactRootView, with one child container per level. | Sibling `div[data-onekey-layer]` roots under `body`, one z-index band per level (`OVERLAY_WEB_Z_INDEX_BASE`). |
@@ -224,7 +265,7 @@ request ─► queued ─► active ─► (enter anim) presented
224
265
  | Page host | Fabric `PageOverlayHost` as the last child of the root-route screen. Custom-drawn sheet, because UIKit sheets cannot be page-scoped. | The same host inside the root-route screen. The ReactRootView already dispatches touches, so no nested RootView. | `position: absolute` host inside the root-route card. |
225
266
  | Header / tab bar coverage (page) | Covered: the host is above the tab controller and navigation bars. | Covered. | Covered. |
226
267
  | Touch passthrough (toast, debug, box-none) | `hitTest` returns nil on the window or root view. | A non-blocking entry hit-tests its React content with `TouchTargetHelper` (honoring `box-none`) and falls through to the FrameLayout sibling on a miss. | `pointer-events: none` root, `auto` per entry. |
227
- | Back / Escape | Escape via `accessibilityPerformEscape`. The interactive pop gesture is disabled while a blocking page overlay is shown. | A `Window.Callback` wrapper consumes `KEYCODE_BACK` while a blocking overlay is shown. `ReactActivity.onBackPressed` hands back to JS BackHandler (react-navigation) before the `OnBackPressedDispatcher`, and the app opts out of predictive back, so a dispatcher callback alone never runs first. The dispatcher callback is kept for the predictive-back path. The IME still gets back first to close the keyboard. | Capture-phase `keydown` that ignores IME composition. |
268
+ | Back / Escape | Escape via `accessibilityPerformEscape`. The interactive pop gesture is disabled while a blocking page overlay is shown. | A `Window.Callback` wrapper consumes `KEYCODE_BACK` for the topmost input-ready blocking or dismissible overlay. `ReactActivity.onBackPressed` hands back to JS BackHandler (react-navigation) before the `OnBackPressedDispatcher`, and the app opts out of predictive back, so a dispatcher callback alone never runs first. The dispatcher callback is kept for the predictive-back path. The IME still gets back first to close the keyboard. | Capture-phase `keydown` that ignores IME composition. |
228
269
  | Modality / accessibility | `accessibilityViewIsModal` on the blocking level window; `.screenChanged` posted on present. | `IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS` on the content below. | `inert` on the app root and lower layers, `role=dialog`, `aria-modal`, focus trap and restore. |
229
270
  | Keyboard | System sheet avoidance; `keyboardLayoutGuide` for custom presentations. The main window regains key status on dismiss. | `WindowInsetsAnimationCompat` per sheet (the app uses `adjustPan`). | `visualViewport`. |
230
271
  | Animation engine | `UIViewPropertyAnimator` + `UISpringTimingParameters(mass:stiffness:damping:)`, which maps 1:1 and supports damping ratio > 1. | `SpringAnimation` with `stiffness = k/m` and `dampingRatio`; `PathInterpolator` for timing curves. | WAAPI `element.animate`. Timing presets use `cubic-bezier`; custom springs use `linear()`. |
@@ -273,10 +314,47 @@ Known divergences:
273
314
  such as the DatePicker month-row interpolation, are clipped inside the
274
315
  content.
275
316
 
317
+ ### Page interaction and accessibility isolation (review correction)
318
+
319
+ The existing ordering and suspension contract also applies when a page entry
320
+ mounts after its owner was suspended. Such entries MUST remain hidden and inert
321
+ until that owner resumes. Resuming an owner MUST NOT clear isolation imposed by
322
+ a higher blocking entry. Web page isolation compares level first, then request
323
+ order within that level; dismissal restores entries no longer below a blocker.
324
+
325
+ On Android, a blocking page entry MUST hide underlying page/navigation siblings
326
+ from accessibility while preserving the page host and the blocking entry. Entries
327
+ above it, including non-blocking global overlays, remain accessible. Global
328
+ blockers still win over page entries. Attachment, detachment, suspension,
329
+ resumption, input-readiness and blocking changes MUST refresh isolation and
330
+ restore each view's original accessibility flag when it is no longer covered.
331
+
332
+ These are corrections within the existing contract, not new public props. DOM
333
+ and Robolectric tests will verify lifecycle and native flags; manual TalkBack
334
+ navigation remains a separate runtime acceptance item.
335
+
276
336
  ## 5.9 Conformance and acceptance
277
337
 
278
338
  Automated (P0):
279
339
 
340
+ - `src/__tests__/focusTrap.web.test.ts` mounts real Web overlays and checks
341
+ both Tab boundaries, root/empty fallback, inaccessible children, positive
342
+ tabindex order, top-entry precedence, page scope, suspension and parking.
343
+ - `src/__tests__/useOverlayController.test.ts` exercises the actual hook and
344
+ page scope providers: missing/empty keys, delayed readiness, one request,
345
+ page suspension/removal, hiding/unmounting before readiness and global scope.
346
+ - `NativeOverlayBackTest` exercises the Android host's key interceptor and
347
+ Back dispatcher with actual entry/page views: dismissible non-blockers,
348
+ blockers, input readiness, canceled key-up, global/page precedence,
349
+ suspension and blocking-only accessibility isolation.
350
+ - `src/__tests__/pageIsolation.web.test.ts` mounts a real React portal in a
351
+ DOM host and verifies late suspension, level ordering and restoration after
352
+ the blocking entry is removed.
353
+ - `NativeOverlayAccessibilityIsolationTest` runs six Robolectric cases against
354
+ Android views: page/navigation siblings, entries above the blocker, original
355
+ flags, global/page transitions, detached hosts and changing blockers. These
356
+ tests do not replace manual TalkBack navigation.
357
+
280
358
  - `src/__tests__/OverlayStore.test.ts` covers:
281
359
  - level ordering regardless of request order;
282
360
  - page below global;
@@ -304,7 +382,10 @@ Runtime matrix, required per platform before migrating callers (P1–P3):
304
382
  - switch tab and come back: it restores;
305
383
  - remove the page: it closes with `page-removed`;
306
384
  - the native header and tab bar are covered.
307
- 5. Back/Escape reaches the topmost blocking overlay; the lock swallows it.
385
+ 5. Back/Escape reaches the topmost input-ready overlay that is blocking or
386
+ explicitly dismissible, global before page. A non-dismissible blocker
387
+ consumes Back without closing; a passive non-blocking entry passes through.
388
+ Accessibility isolation remains blocking-only. The lock swallows Back.
308
389
  6. Keyboard: an input in a global sheet, a page sheet and a secure dialog.
309
390
  7. iOS: a system picker opened from an overlay appears above it.
310
391
  8. Split view: page overlays stay in their own pane.
@@ -335,8 +416,8 @@ P3 page scope notes:
335
416
  their global sheet implementation inside the page host.
336
417
  - Android back also resolves page entries: global overlays first, then the
337
418
  topmost visible page overlay.
338
- - A page request without a resolvable host or owner falls back to global
339
- scope.
419
+ - A declarative page overlay waits for a resolvable host and owner; it never
420
+ falls back to global scope. Direct store requests require both keys.
340
421
 
341
422
  P2 sheet notes:
342
423