@onekeyfe/react-native-native-overlay 3.0.162-alpha.272 → 6.7.0-8

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 (36) hide show
  1. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt +13 -10
  2. package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayBackTest.kt +157 -0
  3. package/docs/SPEC.md +121 -26
  4. package/ios/NativeOverlayContainerView.swift +20 -7
  5. package/ios/NativeOverlaySheetController.swift +35 -1
  6. package/lib/module/OverlayStore.js +13 -1
  7. package/lib/module/OverlayStore.js.map +1 -1
  8. package/lib/module/OverlayView.web.js +104 -25
  9. package/lib/module/OverlayView.web.js.map +1 -1
  10. package/lib/module/useOverlayController.js +11 -3
  11. package/lib/module/useOverlayController.js.map +1 -1
  12. package/lib/module/web/animateTransition.js +20 -12
  13. package/lib/module/web/animateTransition.js.map +1 -1
  14. package/lib/module/web/overlayLayers.js +16 -0
  15. package/lib/module/web/overlayLayers.js.map +1 -1
  16. package/lib/module/web/useKeyboardShift.js +1 -1
  17. package/lib/module/web/useKeyboardShift.js.map +1 -1
  18. package/lib/module/web/useSheetDrag.js +22 -2
  19. package/lib/module/web/useSheetDrag.js.map +1 -1
  20. package/lib/typescript/src/OverlayStore.d.ts +2 -0
  21. package/lib/typescript/src/OverlayStore.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/animateTransition.d.ts +2 -2
  25. package/lib/typescript/src/web/animateTransition.d.ts.map +1 -1
  26. package/lib/typescript/src/web/overlayLayers.d.ts +1 -0
  27. package/lib/typescript/src/web/overlayLayers.d.ts.map +1 -1
  28. package/lib/typescript/src/web/useSheetDrag.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/OverlayStore.ts +13 -1
  31. package/src/OverlayView.web.tsx +193 -56
  32. package/src/useOverlayController.ts +11 -4
  33. package/src/web/animateTransition.ts +30 -11
  34. package/src/web/overlayLayers.ts +20 -0
  35. package/src/web/useKeyboardShift.ts +1 -1
  36. package/src/web/useSheetDrag.ts +23 -2
@@ -8,6 +8,7 @@ import android.widget.FrameLayout
8
8
  import androidx.activity.ComponentActivity
9
9
  import androidx.activity.OnBackPressedCallback
10
10
  import java.util.WeakHashMap
11
+ import java.lang.ref.WeakReference
11
12
 
12
13
  /** Mirrors `OVERLAY_LEVEL_ORDER`. */
13
14
  internal enum class NativeOverlayLevel(val order: Int) {
@@ -48,7 +49,7 @@ internal class NativeOverlayHost private constructor(
48
49
  )
49
50
  private val backCallback = object : OnBackPressedCallback(false) {
50
51
  override fun handleOnBackPressed() {
51
- topBlockingEntry()?.let { entry ->
52
+ topBackEntry()?.let { entry ->
52
53
  if (entry.dismissOnBackPress) entry.onRequestDismiss?.invoke("back")
53
54
  }
54
55
  }
@@ -66,7 +67,7 @@ internal class NativeOverlayHost private constructor(
66
67
  * predictive back, so the dispatcher alone never runs first.
67
68
  */
68
69
  fun handleBackKey(event: KeyEvent): Boolean {
69
- val entry = topBlockingEntry() ?: return false
70
+ val entry = topBackEntry() ?: return false
70
71
  if (event.action == KeyEvent.ACTION_UP && !event.isCanceled && entry.dismissOnBackPress) {
71
72
  entry.onRequestDismiss?.invoke("back")
72
73
  }
@@ -108,14 +109,14 @@ internal class NativeOverlayHost private constructor(
108
109
  }
109
110
 
110
111
  fun onEntriesChanged() {
111
- val hasBlocking = topBlockingEntry() != null
112
- if (hasBlocking && !backCallback.isEnabled) {
112
+ val hasBackEntry = topBackEntry() != null
113
+ if (hasBackEntry && !backCallback.isEnabled) {
113
114
  // The most recently added callback wins; re-add so ours runs before
114
115
  // ReactActivity's and react-native-screens' callbacks.
115
116
  backCallback.remove()
116
117
  (activity as? ComponentActivity)?.onBackPressedDispatcher?.addCallback(backCallback)
117
118
  }
118
- backCallback.isEnabled = hasBlocking
119
+ backCallback.isEnabled = hasBackEntry
119
120
  updateAccessibility()
120
121
  }
121
122
 
@@ -133,11 +134,11 @@ internal class NativeOverlayHost private constructor(
133
134
  }
134
135
 
135
136
  /** Global overlays win; otherwise the topmost visible page overlay. */
136
- private fun topBlockingEntry(): NativeOverlayEntryRootView? =
137
- orderedEntries().lastOrNull { it.blocking && it.isShownForInput }
137
+ private fun topBackEntry(): NativeOverlayEntryRootView? =
138
+ orderedEntries().lastOrNull { (it.blocking || it.dismissOnBackPress) && it.isShownForInput }
138
139
  ?: shownPageHosts()
139
140
  .flatMap { it.shownEntries() }
140
- .lastOrNull { it.blocking }
141
+ .lastOrNull { it.blocking || it.dismissOnBackPress }
141
142
 
142
143
  private fun shownPageHosts(): List<NativeOverlayPageHostView> =
143
144
  NativeOverlayPageHostView.allHosts().filter {
@@ -160,9 +161,11 @@ internal class NativeOverlayHost private constructor(
160
161
  }
161
162
 
162
163
  companion object {
163
- private val hosts = WeakHashMap<Activity, NativeOverlayHost>()
164
+ private val hosts = WeakHashMap<Activity, WeakReference<NativeOverlayHost>>()
164
165
 
165
166
  fun of(activity: Activity): NativeOverlayHost =
166
- hosts.getOrPut(activity) { NativeOverlayHost(activity) }
167
+ hosts[activity]?.get() ?: NativeOverlayHost(activity).also {
168
+ hosts[activity] = WeakReference(it)
169
+ }
167
170
  }
168
171
  }
@@ -0,0 +1,157 @@
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 java.lang.ref.WeakReference
10
+ import java.util.WeakHashMap
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertFalse
13
+ import org.junit.Assert.assertNotSame
14
+ import org.junit.Assert.assertSame
15
+ import org.junit.Assert.assertTrue
16
+ import org.junit.Before
17
+ import org.junit.Test
18
+ import org.junit.runner.RunWith
19
+ import org.robolectric.Robolectric
20
+ import org.robolectric.RobolectricTestRunner
21
+ import org.robolectric.annotation.Config
22
+
23
+ @RunWith(RobolectricTestRunner::class)
24
+ @Config(sdk = [34])
25
+ class NativeOverlayBackTest {
26
+ private lateinit var activity: ComponentActivity
27
+ private lateinit var context: ThemedReactContext
28
+ private lateinit var host: NativeOverlayHost
29
+ private val dismissals = mutableListOf<String>()
30
+
31
+ @Before
32
+ fun setUp() {
33
+ activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible().get()
34
+ val reactContext = BridgeReactContext(activity)
35
+ reactContext.onHostResume(activity)
36
+ context = ThemedReactContext(reactContext, activity, "OverlayBackTest", 1)
37
+ host = NativeOverlayHost.of(activity)
38
+ }
39
+
40
+ private fun entry(name: String, blocking: Boolean, dismissible: Boolean, order: Int = 0) =
41
+ NativeOverlayEntryRootView(context).apply {
42
+ this.blocking = blocking
43
+ dismissOnBackPress = dismissible
44
+ stackOrder = order
45
+ isShownForInput = true
46
+ onRequestDismiss = { dismissals.add("$name:$it") }
47
+ }
48
+
49
+ private fun back(action: Int = KeyEvent.ACTION_UP, flags: Int = 0) =
50
+ host.handleBackKey(KeyEvent(0L, 0L, action, KeyEvent.KEYCODE_BACK, 0, 0, 0, 0, flags))
51
+
52
+ @Test
53
+ fun dismissibleNonBlockingGlobalEntryWinsOverLowerBlocker() {
54
+ host.attach(entry("lower", true, true), NativeOverlayLevel.MODAL)
55
+ host.attach(entry("top", false, true), NativeOverlayLevel.TOAST)
56
+ assertTrue(back(KeyEvent.ACTION_DOWN))
57
+ assertEquals(emptyList<String>(), dismissals)
58
+ assertTrue(back())
59
+ assertEquals(listOf("top:back"), dismissals)
60
+ }
61
+
62
+ @Test
63
+ fun dispatcherHandlesNonBlockingEntryAndKeepsPageAccessible() {
64
+ val page = View(activity).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES }
65
+ activity.setContentView(page)
66
+ val top = entry("top", false, true)
67
+ host.attach(top, NativeOverlayLevel.MODAL)
68
+ activity.onBackPressedDispatcher.onBackPressed()
69
+ assertEquals(listOf("top:back"), dismissals)
70
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, page.importantForAccessibility)
71
+ host.detach(top)
72
+ assertFalse(back())
73
+ }
74
+
75
+ @Test
76
+ fun nonDismissibleBlockerConsumesBackWithoutClosingLowerEntry() {
77
+ host.attach(entry("lower", false, true), NativeOverlayLevel.MODAL)
78
+ host.attach(entry("lock", true, false), NativeOverlayLevel.LOCK)
79
+ assertTrue(back())
80
+ assertEquals(emptyList<String>(), dismissals)
81
+ }
82
+
83
+ @Test
84
+ fun passiveOrNotInputReadyEntriesDoNotTakeBack() {
85
+ host.attach(entry("passive", false, false), NativeOverlayLevel.TOAST)
86
+ host.attach(entry("hidden", true, true).apply { isShownForInput = false }, NativeOverlayLevel.LOCK)
87
+ assertFalse(back())
88
+ assertEquals(emptyList<String>(), dismissals)
89
+ }
90
+
91
+ @Test
92
+ fun canceledKeyUpDoesNotDismiss() {
93
+ host.attach(entry("top", false, true), NativeOverlayLevel.MODAL)
94
+ assertTrue(back(flags = KeyEvent.FLAG_CANCELED))
95
+ assertEquals(emptyList<String>(), dismissals)
96
+ }
97
+
98
+ @Test
99
+ fun dismissiblePageEntryWinsOverLowerPageBlockerAndSuspensionSkipsIt() {
100
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-back-test" }
101
+ activity.setContentView(pageHost)
102
+ val lower = entry("lower", true, true).apply { ownerKey = "owner" }
103
+ val top = entry("top", false, true, 1).apply { ownerKey = "owner" }
104
+ pageHost.attach(lower)
105
+ pageHost.attach(top)
106
+ assertTrue(back())
107
+ assertEquals(listOf("top:back"), dismissals)
108
+ pageHost.setSuspendedOwners("owner")
109
+ assertFalse(back())
110
+ pageHost.invalidateHost()
111
+ }
112
+
113
+ @Test
114
+ fun globalEntryTakesBackBeforePageEntryAndDismissalRestoresPageTarget() {
115
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-global-test" }
116
+ activity.setContentView(FrameLayout(activity).apply { addView(pageHost) })
117
+ pageHost.attach(entry("page", false, true))
118
+ val global = entry("global", false, true)
119
+ host.attach(global, NativeOverlayLevel.MODAL)
120
+ assertTrue(back())
121
+ host.detach(global)
122
+ assertTrue(back())
123
+ assertEquals(listOf("global:back", "page:back"), dismissals)
124
+ pageHost.invalidateHost()
125
+ }
126
+
127
+ @Test
128
+ fun hostCacheReusesLiveHostAndSeparatesActivities() {
129
+ assertSame(host, NativeOverlayHost.of(activity))
130
+ val otherActivity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get()
131
+ assertNotSame(host, NativeOverlayHost.of(otherActivity))
132
+ }
133
+
134
+ @Test
135
+ fun hostCacheRebuildsAfterWeakValueIsCleared() {
136
+ val field = NativeOverlayHost::class.java.getDeclaredField("hosts").apply {
137
+ isAccessible = true
138
+ }
139
+ val cache = field.get(null) as WeakHashMap<*, *>
140
+ (cache[activity] as WeakReference<*>).clear()
141
+ val replacement = NativeOverlayHost.of(activity)
142
+ assertNotSame(host, replacement)
143
+ assertSame(replacement, NativeOverlayHost.of(activity))
144
+ }
145
+
146
+ @Test
147
+ fun pageOnlyBackInterceptorRetainsItsHost() {
148
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-only" }
149
+ activity.setContentView(pageHost)
150
+ pageHost.attach(entry("page", false, true))
151
+ host.installBackInterceptor()
152
+ assertTrue(activity.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK)))
153
+ assertEquals(listOf("page:back"), dismissals)
154
+ assertSame(host, NativeOverlayHost.of(activity))
155
+ pageHost.invalidateHost()
156
+ }
157
+ }
package/docs/SPEC.md CHANGED
@@ -28,19 +28,24 @@ TypeScript declarations; React Native generates the Fabric bindings at app
28
28
  build time. Publish `common/cpp`, `src`, `lib`, Android/iOS sources, podspec and
29
29
  autolinking configuration. Web consumers additionally provide `react-dom`.
30
30
 
31
- `react-native-native-sheet` remains separately published for existing consumers;
32
- its compatibility shim/deprecation is a subsequent migration step, not a
33
- silent API change in this release. Sheet behavior derived from that package
34
- retains its original implementation attribution in source comments.
31
+ `react-native-native-sheet` is retired from this repository after the App
32
+ consumer migration. Its source workspace, example dependency, and dedicated
33
+ tests are removed; sheet presentation is owned by this package and has no
34
+ dependency on the retired package. The old `NativeSheet` API is not retained as
35
+ a compatibility shim. Previously published npm versions remain unchanged, and
36
+ this repository cleanup does not publish or change package versions. Sheet
37
+ behavior derived from that package retains its original implementation
38
+ attribution in source comments.
35
39
 
36
40
  ### Release boundary
37
41
 
38
- This branch aligns all 42 publishable workspaces to `3.0.162-alpha.272`.
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.
42
+ The earlier `3.0.162-alpha.272` release remains immutable. This branch now
43
+ includes the current main SDK baseline, with all publishable workspaces aligned
44
+ before release. Use `package-publish` with `npm_dist_tag=next` and an empty
45
+ `only_workspace` to generate a fresh `6.7.0-alpha.N` preview, publish every
46
+ workspace, and verify every registry version. Preserve `latest` and keep both
47
+ repository PRs open. App consumers pin the verified exact preview version and
48
+ refresh dependency and native locks before installed-version acceptance.
44
49
 
45
50
  ## 5.1 Purpose, scope, and non-goals
46
51
 
@@ -85,6 +90,9 @@ Ownership:
85
90
  queues, page visibility, and back/Escape resolution.
86
91
  - **Native hosts** own drawing, hit testing, animation, keyboard, and back-key
87
92
  capture, and report lifecycle back to the store.
93
+ - Android's static host lookup holds weak Activity keys and weak host values;
94
+ an active overlay or installed back callback owns its host. The lookup does
95
+ not itself extend the lifetime of an Activity.
88
96
  - **Content** is the caller's React subtree. The overlay view stays where the
89
97
  caller rendered it. Native code reparents its content into the host, so React
90
98
  context is preserved; there is no root-siblings portal.
@@ -108,6 +116,14 @@ come only from props.
108
116
  - `dismiss(id, reason)` moves an entry to `closing`. `finalize(id)` removes it
109
117
  after the native exit animation. A closing entry that is never finalized is
110
118
  removed after 5 s.
119
+ - `reopen(id)` restores a closing entry to active, cancels its fallback timer,
120
+ and keeps its ordering, content and removal handler. A declarative
121
+ false-to-true change before exit completion uses this path; it does not
122
+ report a completed close. Hosts retarget the in-flight animation, including
123
+ cancelling the host-requested UIKit sheet dismissal when still running.
124
+ - Removing a queued entry detaches its removal handler before notifying store
125
+ subscribers or scheduling its callback. Same-tick reuse of that ID owns a
126
+ separate handler; the old removal cannot invoke or delete the new handler.
111
127
  - `dismissAll({ scope, belowLevel, reason })`, `setPageVisible(ownerKey,
112
128
  visible)`, `removePage(ownerKey)`.
113
129
  - `resolveBack(focusedHostKey?)` returns `dismiss(id)`, `block`, or `pass`.
@@ -136,6 +152,11 @@ visible)`, `removePage(ownerKey)`.
136
152
 
137
153
  - `usePageOverlayHost()` resolves `hostKey` and `ownerKey` from navigation
138
154
  context.
155
+ - A declarative page overlay keeps `scope: 'page'` while either key is
156
+ unavailable. It does not request or present an entry until both keys are
157
+ available; becoming ready while visible requests it in the page lane.
158
+ Hiding or unmounting before readiness creates no entry or close callback.
159
+ Direct store requests still throw for missing page keys.
139
160
  - `useInPageDialog`, `useInModalDialog`, and `useInTabDialog` become aliases of
140
161
  page scope. Tab scope is removed.
141
162
 
@@ -208,6 +229,30 @@ request ─► queued ─► active ─► (enter anim) presented
208
229
  - Threads: store mutations happen on the JS thread. Native animation and layout
209
230
  run on the UI thread. Events are delivered as direct events.
210
231
 
232
+ ### Web presentation updates (OK-50653)
233
+
234
+ For an open Web overlay that keeps its portal host, changing `presentation`
235
+ between sheet and a non-sheet frame MUST preserve the mounted content subtree.
236
+ Its props and React context continue to update normally. Changing the optional
237
+ sheet handle MUST NOT move the content into a different React child slot.
238
+ Closing without `keepContentMounted` still unmounts content after exit; reopening
239
+ creates a fresh content lifecycle. Business entity changes and draft resets
240
+ remain caller-owned; the overlay does not cache or persist form values.
241
+
242
+ The stable sheet shell must not change non-sheet animation origins or keyboard
243
+ avoidance geometry. Switching away from a sheet clears any pan offset and its
244
+ backdrop opacity override, and releases an active pointer capture.
245
+
246
+ Conformance: `src/__tests__/draftRetention.web.test.ts` exercises the real Web
247
+ host, portal and store for presentation changes, live props/context/callbacks,
248
+ close/reopen, animation origins, keyboard geometry and an interrupted pan.
249
+ Chrome extension side-panel and ordinary-tab checks use synthetic content;
250
+ this correction does not claim new iOS/Android runtime verification.
251
+
252
+ These source changes require a new published package version before the app
253
+ can consume them from npm. This fix leaves package versions and lockfiles at
254
+ their existing values and does not publish a release.
255
+
211
256
  ## 5.5 Data, cache, and identity
212
257
 
213
258
  - Identity is the entry `id`: caller-provided, otherwise `overlay-<seq>`.
@@ -216,6 +261,22 @@ request ─► queued ─► active ─► (enter anim) presented
216
261
 
217
262
  ## 5.6 Platform contract
218
263
 
264
+ ### Web keyboard focus containment
265
+
266
+ The topmost active, unsuspended blocking Web entry MUST contain sequential
267
+ keyboard focus. Tab from its last tabbable child wraps to the first;
268
+ Shift+Tab from its first wraps to the last. From the entry root, focus enters
269
+ the first/last child respectively. With no tabbable children, focus stays on
270
+ the entry root. Disabled, negative-tabindex, hidden and inert subtrees are
271
+ excluded. Positive tabindex values precede ordinary DOM-order tab stops.
272
+ Non-blocking, lower, closing, suspended and parked entries MUST NOT run a
273
+ focus loop. Existing focus entry/restore and Escape policies are unchanged.
274
+
275
+ A Web backdrop with `dismissOnPress: true` MUST receive pointer events even
276
+ when its entry is non-blocking. This permits a transparent outside-click
277
+ shield without modal focus or accessibility isolation. A passive backdrop on
278
+ a non-blocking entry remains pointer-transparent.
279
+
219
280
  | Concern | iOS | Android | Web |
220
281
  | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
221
282
  | 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 +285,7 @@ request ─► queued ─► active ─► (enter anim) presented
224
285
  | 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
286
  | Header / tab bar coverage (page) | Covered: the host is above the tab controller and navigation bars. | Covered. | Covered. |
226
287
  | 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. |
288
+ | 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
289
  | 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
290
  | 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
291
  | 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()`. |
@@ -296,6 +357,17 @@ navigation remains a separate runtime acceptance item.
296
357
 
297
358
  Automated (P0):
298
359
 
360
+ - `src/__tests__/focusTrap.web.test.ts` mounts real Web overlays and checks
361
+ both Tab boundaries, root/empty fallback, inaccessible children, positive
362
+ tabindex order, top-entry precedence, page scope, suspension and parking.
363
+ - `src/__tests__/useOverlayController.test.ts` exercises the actual hook and
364
+ page scope providers: missing/empty keys, delayed readiness, one request,
365
+ page suspension/removal, hiding/unmounting before readiness and global scope.
366
+ - `NativeOverlayBackTest` exercises the Android host's key interceptor and
367
+ Back dispatcher with actual entry/page views: dismissible non-blockers,
368
+ blockers, input readiness, canceled key-up, global/page precedence,
369
+ suspension and blocking-only accessibility isolation. It also checks live
370
+ host reuse, Activity isolation and rebuilding after a cleared weak value.
299
371
  - `src/__tests__/pageIsolation.web.test.ts` mounts a real React portal in a
300
372
  DOM host and verifies late suspension, level ordering and restoration after
301
373
  the blocking entry is removed.
@@ -331,7 +403,10 @@ Runtime matrix, required per platform before migrating callers (P1–P3):
331
403
  - switch tab and come back: it restores;
332
404
  - remove the page: it closes with `page-removed`;
333
405
  - the native header and tab bar are covered.
334
- 5. Back/Escape reaches the topmost blocking overlay; the lock swallows it.
406
+ 5. Back/Escape reaches the topmost input-ready overlay that is blocking or
407
+ explicitly dismissible, global before page. A non-dismissible blocker
408
+ consumes Back without closing; a passive non-blocking entry passes through.
409
+ Accessibility isolation remains blocking-only. The lock swallows Back.
335
410
  6. Keyboard: an input in a global sheet, a page sheet and a secure dialog.
336
411
  7. iOS: a system picker opened from an overlay appears above it.
337
412
  8. Split view: page overlays stay in their own pane.
@@ -362,8 +437,12 @@ P3 page scope notes:
362
437
  their global sheet implementation inside the page host.
363
438
  - Android back also resolves page entries: global overlays first, then the
364
439
  topmost visible page overlay.
365
- - A page request without a resolvable host or owner falls back to global
366
- scope.
440
+ - A declarative page overlay waits for a resolvable host and owner; it never
441
+ falls back to global scope. Direct store requests require both keys.
442
+ - On Web, a page overlay also waits for its registered DOM host while closed
443
+ with `keepContentMounted`. Registration mounts parked content in that host;
444
+ close/reopen retains the same DOM and state. Unregistration never moves page
445
+ content into a global layer.
367
446
 
368
447
  P2 sheet notes:
369
448
 
@@ -378,7 +457,11 @@ P2 sheet notes:
378
457
  overlay motion; dragging fades the backdrop; a drag to hidden reports
379
458
  `pan`.
380
459
  - Web renders a bottom surface with pointer drag (40% distance or 0.5 px/ms
381
- flick dismisses, never from scrolled content).
460
+ flick dismisses, never from scrolled content). Reopening kept content after
461
+ a pan dismissal clears the sheet transform, transition and backdrop opacity;
462
+ the next dismissal follows the normal exit path and restores focus.
463
+ Cancelling a tracked pointer resets its drag offset, transition and backdrop
464
+ without dismissing the sheet or swallowing a later click.
382
465
  - A fitted sheet measures its content with `onLayout` before presenting.
383
466
  Hidden content stays mounted inside the zero-width host (clipped on iOS,
384
467
  INVISIBLE on Android), and the host height stays auto: a fixed 0 height
@@ -475,8 +558,10 @@ P4e lock screen notes:
475
558
  interactive (OK-62416).
476
559
  - Android back is swallowed on the lock screen (blocking, not dismissible);
477
560
  it no longer reaches navigation behind the lock.
478
- - The web lock tamper check and the lock screen's body-level inert pass are
479
- unchanged; the lock layer root is a `document.body` child.
561
+ - The web lock tamper check is retained. The body-level inert pass starts
562
+ when the lock node actually mounts, covers later body portals, and restores
563
+ each node's previous inert state on unlock. The lock layer root is a
564
+ `document.body` child.
480
565
 
481
566
  P5a native popover notes:
482
567
 
@@ -484,14 +569,18 @@ P5a native popover notes:
484
569
  (`NativeSheetPresentation`); the Tamagui Popover sheet (`TMPopover.Adapt`),
485
570
  its external backdrop, z-index and back handler are gone, and
486
571
  the removed `nativeSheet` option throws. iPad / wide windows keep the 400–480 pt centered
487
- frame inside the sheet. `usingSheet={false}` still renders no panel on
488
- native (web / desktop floating panels).
572
+ frame inside the sheet. Native keeps this supported sheet presentation
573
+ even when a cross-platform caller sets `usingSheet={false}` for its web panel.
489
574
  - The native popover renders inline in the caller's tree (its contexts
490
575
  apply) instead of through the full-window portal. Every popover remains
491
576
  mounted until the exit callback (1 s fallback); content then unmounts
492
577
  unless `keepChildrenMounted`. Reopening cancels pending teardown.
493
578
  - `closePopover()` waits for exit completion; repeated requests share the
494
579
  same completion promise, and completion or unmount clears its timer.
580
+ - Percent sheet snap points set the viewport height on native and web; fit
581
+ mode keeps content measurement. Both keep the header and bottom spacing
582
+ inside the bounded surface. Native Popover and custom Toast also retain
583
+ their deferred-guide blocker while open.
495
584
  - Sheet options are package-owned. Unsupported legacy `sheetProps` keys
496
585
  and floating-panel `zIndex` / `portalProps` throw instead of being ignored.
497
586
 
@@ -501,16 +590,22 @@ P5b web popover notes:
501
590
  level; the Tamagui Popover and `PopoverContent` are gone.
502
591
  - Below `md` with `usingSheet` (the default): an overlay sheet with a
503
592
  header and a dismissing backdrop.
504
- - Otherwise: `presentation="anchored"`, non-blocking and without a backdrop.
593
+ - Otherwise: `presentation="anchored"`, non-blocking, with a transparent
594
+ dismissing backdrop for press-triggered panels. Hover panels have no backdrop.
505
595
  Floating UI (`@floating-ui/dom`) places the panel: offset, flip, shift,
506
596
  and a max height from the available space. `autoUpdate` follows scroll
507
597
  and resize.
508
- - The enter animation scales an ancestor of the panel, so the panel measures
509
- smaller while it runs. The panel is positioned again on `onPresented`.
510
- - Anchored panels close on an outside press. Presses on the trigger, on the
511
- panel, or inside an overlay ranked above it do not close it, so a nested
512
- Select or a Dialog opened from the panel keeps it open. They also close on
513
- Escape, unless `hoverable`.
598
+ - For a single positioned child, Web anchored scale/fade runs on that content,
599
+ with a local transform origin. Its full-window positioning ancestors remain
600
+ untransformed, so Floating UI uses the same coordinate space during and after
601
+ the animation. Completion positioning must not correct coordinates distorted
602
+ by the enter scale. Multi-child roots retain their shared full-window motion.
603
+ - Press-triggered anchored panels close on a backdrop press without activating
604
+ the page underneath. Presses on the panel or inside an overlay ranked above
605
+ it do not close it, so a nested Select or a Dialog opened from the panel
606
+ keeps it open. They also close on Escape, unless `hoverable`.
607
+ - Press-triggered anchored panels own a focus scope and forward caller
608
+ open/close autofocus callbacks. Hover panels never move or trap focus.
514
609
  - `hoverable` opens and closes on mouse enter / leave of the trigger and
515
610
  panel. The default close delay is 100 ms, so the pointer can cross the gap.
516
611
  - `keepChildrenMounted` maps to `keepContentMounted`. On web a closed entry
@@ -323,8 +323,17 @@ final class NativeOverlayEntryView: UIView {
323
323
  }
324
324
  if visible {
325
325
  cycleOpen = true
326
- // A sheet that is still animating out is re-presented after it finishes.
327
- if phase == .hidden || (phase == .exiting && !usesSystemSheet) {
326
+ if phase == .exiting, usesSystemSheet, let controller = sheetController,
327
+ controller.cancelHostDismissal(completion: { [weak self, weak controller] in
328
+ DispatchQueue.main.async {
329
+ guard let self, self.sheetController === controller, self.phase == .entering else { return }
330
+ self.phase = .shown
331
+ self.didPresent()
332
+ if !self.visible { self.dismissSheet() }
333
+ }
334
+ }) {
335
+ phase = .entering
336
+ } else if phase == .hidden || (phase == .exiting && !usesSystemSheet) {
328
337
  present()
329
338
  }
330
339
  } else if cycleOpen {
@@ -473,13 +482,13 @@ final class NativeOverlayEntryView: UIView {
473
482
  reportContentOffset()
474
483
  NativeOverlayWindowManager.shared.entryRemoved(from: hostWindow)
475
484
  guard let reason, cycleOpen else { return }
476
- cycleOpen = visible
477
- onDismissed?(["reason": reason])
478
- // Reopened while the exit animation ran; UIKit-initiated dismissals
479
- // ("system") are left for JS to settle instead.
485
+ // A reopen at UIKit's completion boundary keeps the same JS entry.
480
486
  if reason == "programmatic", visible, window != nil {
481
487
  present()
488
+ return
482
489
  }
490
+ cycleOpen = visible
491
+ onDismissed?(["reason": reason])
483
492
  }
484
493
 
485
494
  private func didPresent() {
@@ -568,12 +577,16 @@ final class NativeOverlayEntryView: UIView {
568
577
  finishDismiss(reason: "programmatic")
569
578
  return
570
579
  }
580
+ if controller.resumeHostDismissal() {
581
+ phase = .exiting
582
+ return
583
+ }
571
584
  // Closing mid-presentation is handled by the present completion.
572
585
  guard !controller.isBeingPresented, phase != .exiting else { return }
573
586
  phase = .exiting
574
587
  controller.dismissRequestedByHost = true
575
588
  controller.dismiss(animated: true) { [weak self, weak controller] in
576
- guard let self, self.sheetController === controller else { return }
589
+ guard let self, self.sheetController === controller, self.phase == .exiting else { return }
577
590
  self.finishDismiss(reason: "programmatic")
578
591
  }
579
592
  }
@@ -31,6 +31,7 @@ private final class NativeOverlaySheetTransitionAnimator: NSObject,
31
31
  private let presenting: Bool
32
32
  private let motion: NativeOverlayMotion
33
33
  private var animator: UIViewPropertyAnimator?
34
+ private var cancellationCompletion: (() -> Void)?
34
35
 
35
36
  init(presenting: Bool, motion: NativeOverlayMotion) {
36
37
  self.presenting = presenting
@@ -90,15 +91,32 @@ private final class NativeOverlaySheetTransitionAnimator: NSObject,
90
91
  animator.addCompletion { position in
91
92
  let completed = position == .end && !transitionContext.transitionWasCancelled
92
93
  if !completed {
94
+ sheetView.alpha = self.presenting && reduceMotion ? 0 : 1
93
95
  sheetView.transform = self.presenting ? offscreen : .identity
94
96
  sheetController.setDimmingAlpha(self.presenting ? 0 : 1)
95
97
  }
96
98
  transitionContext.completeTransition(completed)
99
+ if position == .start { self.cancellationCompletion?() }
100
+ self.cancellationCompletion = nil
97
101
  }
98
102
  self.animator = animator
99
103
  return animator
100
104
  }
101
105
 
106
+ func reverseDismissal(completion: @escaping () -> Void) -> Bool {
107
+ guard !presenting, let animator, animator.isRunning, !animator.isReversed else { return false }
108
+ cancellationCompletion = completion
109
+ animator.isReversed = true
110
+ return true
111
+ }
112
+
113
+ func resumeDismissal() -> Bool {
114
+ guard !presenting, let animator, animator.isRunning, animator.isReversed else { return false }
115
+ cancellationCompletion = nil
116
+ animator.isReversed = false
117
+ return true
118
+ }
119
+
102
120
  func animationEnded(_ transitionCompleted: Bool) {
103
121
  animator = nil
104
122
  }
@@ -119,6 +137,7 @@ final class NativeOverlaySheetController: UIViewController,
119
137
  private var dimmingView: UIView?
120
138
  private var backdropTap: UITapGestureRecognizer?
121
139
  private var heightAnimator: UIViewPropertyAnimator?
140
+ private var dismissalAnimator: NativeOverlaySheetTransitionAnimator?
122
141
  private var shadowSuppressionDisplayLink: CADisplayLink?
123
142
  /// Set while the host itself asked UIKit to dismiss.
124
143
  var dismissRequestedByHost = false
@@ -230,7 +249,22 @@ final class NativeOverlaySheetController: UIViewController,
230
249
  func animationController(
231
250
  forDismissed dismissed: UIViewController
232
251
  ) -> UIViewControllerAnimatedTransitioning? {
233
- NativeOverlaySheetTransitionAnimator(presenting: false, motion: style.exitMotion)
252
+ let animator = NativeOverlaySheetTransitionAnimator(presenting: false, motion: style.exitMotion)
253
+ dismissalAnimator = animator
254
+ return animator
255
+ }
256
+
257
+ func cancelHostDismissal(completion: @escaping () -> Void) -> Bool {
258
+ guard dismissRequestedByHost,
259
+ dismissalAnimator?.reverseDismissal(completion: completion) == true else { return false }
260
+ dismissRequestedByHost = false
261
+ return true
262
+ }
263
+
264
+ func resumeHostDismissal() -> Bool {
265
+ guard dismissalAnimator?.resumeDismissal() == true else { return false }
266
+ dismissRequestedByHost = true
267
+ return true
234
268
  }
235
269
 
236
270
  override func viewWillAppear(_ animated: Bool) {
@@ -97,6 +97,16 @@ export class OverlayStore {
97
97
  }
98
98
  }
99
99
 
100
+ /** Reverses an unfinished close without creating a new presentation cycle. */
101
+ reopen(id) {
102
+ const entry = this.shown.get(id);
103
+ if (!entry || entry.phase !== 'closing') return;
104
+ this.clearClosingTimer(id);
105
+ entry.phase = 'active';
106
+ entry.dismissReason = undefined;
107
+ this.emit();
108
+ }
109
+
100
110
  /** Removes a closing (or active) entry once its exit animation finished. */
101
111
  finalize(id) {
102
112
  const entry = this.shown.get(id);
@@ -207,7 +217,9 @@ export class OverlayStore {
207
217
  if (queuedIndex !== -1) {
208
218
  this.queued = this.queued.filter(e => e.id !== id);
209
219
  // Queued entries never rendered, so they leave without an exit animation.
210
- queueMicrotask(() => this.notifyRemoved(id, reason));
220
+ const handlers = this.handlers.get(id);
221
+ this.handlers.delete(id);
222
+ queueMicrotask(() => handlers?.onRemoved?.(reason));
211
223
  return true;
212
224
  }
213
225
  const entry = this.shown.get(id);