react-native-anchored-menu 0.0.1
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/LICENSE +23 -0
- package/README.md +207 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo2.gif +0 -0
- package/package.json +25 -0
- package/src/components/AnchoredMenuLayer.js +26 -0
- package/src/components/MenuAnchor.js +55 -0
- package/src/core/context.js +10 -0
- package/src/core/provider.js +192 -0
- package/src/core/providerRegistry.js +47 -0
- package/src/hooks/useAnchoredMenu.js +20 -0
- package/src/hooks/useAnchoredMenuActions.js +19 -0
- package/src/hooks/useAnchoredMenuState.js +20 -0
- package/src/hosts/ModalHost.js +183 -0
- package/src/hosts/ViewHost.js +187 -0
- package/src/index.js +13 -0
- package/src/utils/measure.js +87 -0
- package/src/utils/position.js +63 -0
- package/src/utils/runtime.js +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mahmoud Elfeky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# react-native-anchored-menu
|
|
2
|
+
|
|
3
|
+
A **headless, anchor-based menu / popover system for React Native** designed to work reliably across:
|
|
4
|
+
|
|
5
|
+
- iOS & Android
|
|
6
|
+
- FlatList / SectionList
|
|
7
|
+
- Complex layouts
|
|
8
|
+
- New Architecture (Fabric)
|
|
9
|
+
- Modal & non-modal contexts
|
|
10
|
+
|
|
11
|
+
This library focuses on **correct measurement and positioning**, not UI.
|
|
12
|
+
You fully control how the menu looks and behaves.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## ๐ฌ Demo
|
|
17
|
+
|
|
18
|
+
<table>
|
|
19
|
+
<tr>
|
|
20
|
+
<td>
|
|
21
|
+
<strong>View host (inside normal screens)</strong><br />
|
|
22
|
+
<img src="assets/demo1.gif" width="320" />
|
|
23
|
+
</td>
|
|
24
|
+
<td>
|
|
25
|
+
<strong>View host inside native <code><Modal></code> (and nested modals)</strong><br />
|
|
26
|
+
<img src="assets/demo2.gif" width="320" />
|
|
27
|
+
</td>
|
|
28
|
+
</tr>
|
|
29
|
+
</table>
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## โจ Why this library exists
|
|
34
|
+
|
|
35
|
+
Most React Native menu / popover libraries break in at least one of these cases:
|
|
36
|
+
|
|
37
|
+
- Wrong position on Android
|
|
38
|
+
- Unreliable measurement inside FlatList
|
|
39
|
+
- Broken behavior with Fabric
|
|
40
|
+
- Rendering behind or inside unexpected layers
|
|
41
|
+
- Forced UI and styling
|
|
42
|
+
|
|
43
|
+
**react-native-anchored-menu** solves these by:
|
|
44
|
+
|
|
45
|
+
- Using **stable anchor measurement**
|
|
46
|
+
- Separating **state (Provider)** from **rendering (Hosts)**
|
|
47
|
+
- Supporting multiple rendering strategies (View / Modal)
|
|
48
|
+
- Staying **100% headless**
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## โ
Features
|
|
53
|
+
|
|
54
|
+
- ๐ Anchor menus to any component
|
|
55
|
+
- ๐ Accurate positioning (`auto`, `top`, `bottom`)
|
|
56
|
+
- ๐ง FlatList-safe measurement
|
|
57
|
+
- ๐ช Works inside and outside native `<Modal>`
|
|
58
|
+
- ๐งฉ Fully headless render API
|
|
59
|
+
- ๐งน Tap outside to dismiss
|
|
60
|
+
- ๐ Auto-close on scroll (optional)
|
|
61
|
+
- ๐ RTL-aware positioning
|
|
62
|
+
- ๐งฑ Multiple host strategies
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## ๐ฆ Installation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install react-native-anchored-menu
|
|
70
|
+
# or
|
|
71
|
+
yarn add react-native-anchored-menu
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
No native linking required.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## ๐ Basic Usage
|
|
79
|
+
|
|
80
|
+
### 1๏ธโฃ Wrap your app
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { AnchoredMenuProvider } from "react-native-anchored-menu";
|
|
84
|
+
|
|
85
|
+
export default function Root() {
|
|
86
|
+
return (
|
|
87
|
+
<AnchoredMenuProvider>
|
|
88
|
+
<App />
|
|
89
|
+
</AnchoredMenuProvider>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> โ ๏ธ You **do NOT need** to manually mount any host by default.
|
|
95
|
+
> Hosts are automatically mounted internally.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
### 2๏ธโฃ Add an anchor
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { MenuAnchor } from "react-native-anchored-menu";
|
|
103
|
+
|
|
104
|
+
<MenuAnchor id="profile-menu">
|
|
105
|
+
<Pressable>
|
|
106
|
+
<Text>Open menu</Text>
|
|
107
|
+
</Pressable>
|
|
108
|
+
</MenuAnchor>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### 3๏ธโฃ Open the menu
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { useAnchoredMenuActions } from "react-native-anchored-menu";
|
|
117
|
+
|
|
118
|
+
const { open, close } = useAnchoredMenuActions();
|
|
119
|
+
|
|
120
|
+
open({
|
|
121
|
+
id: "profile-menu",
|
|
122
|
+
render: ({ close }) => (
|
|
123
|
+
<View style={{ backgroundColor: "#111", padding: 12, borderRadius: 8 }}>
|
|
124
|
+
<Pressable onPress={close}>
|
|
125
|
+
<Text style={{ color: "#fff" }}>Logout</Text>
|
|
126
|
+
</Pressable>
|
|
127
|
+
</View>
|
|
128
|
+
),
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## ๐ง API
|
|
135
|
+
|
|
136
|
+
### `useAnchoredMenuActions()`
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const { open, close } = useAnchoredMenuActions();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `useAnchoredMenuState(selector?)`
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const isOpen = useAnchoredMenuState((s) => s.isOpen);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
> `useAnchoredMenu()` is still available for backwards compatibility, but the split hooks are recommended
|
|
149
|
+
> to reduce re-renders in large trees.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
### `open(options)`
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
open({
|
|
157
|
+
id: string;
|
|
158
|
+
|
|
159
|
+
placement?: "auto" | "top" | "bottom";
|
|
160
|
+
align?: "start" | "center" | "end";
|
|
161
|
+
offset?: number;
|
|
162
|
+
margin?: number;
|
|
163
|
+
rtlAware?: boolean;
|
|
164
|
+
|
|
165
|
+
render?: ({ close, anchor }) => ReactNode;
|
|
166
|
+
content?: ReactNode;
|
|
167
|
+
|
|
168
|
+
host?: "view" | "modal";
|
|
169
|
+
|
|
170
|
+
animationType?: "fade" | "none";
|
|
171
|
+
statusBarTranslucent?: boolean;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Measurement strategy.
|
|
175
|
+
* - "stable" (default): waits for interactions and retries for correctness (best for FlatList/Android)
|
|
176
|
+
* - "fast": one-frame measure (lowest latency, less reliable on complex layouts)
|
|
177
|
+
*/
|
|
178
|
+
measurement?: "stable" | "fast";
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Only used when `measurement="stable"` (default: 8).
|
|
182
|
+
*/
|
|
183
|
+
measurementTries?: number;
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## ๐งญ Placement Behavior
|
|
190
|
+
|
|
191
|
+
- `auto` โ below if space allows, otherwise above
|
|
192
|
+
- `top` โ prefer above, fallback below
|
|
193
|
+
- `bottom` โ prefer below, fallback above
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## ๐งฑ Host System
|
|
198
|
+
|
|
199
|
+
- Default host: **view**
|
|
200
|
+
- Hosts are auto-mounted
|
|
201
|
+
- `modal` host is disabled on Fabric and falls back to `view`
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## ๐ License
|
|
206
|
+
|
|
207
|
+
MIT ยฉ Mahmoud Elfeky
|
package/assets/demo1.gif
ADDED
|
Binary file
|
package/assets/demo2.gif
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-anchored-menu",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Headless anchored menu/popover for React Native with stable measurement (view host by default).",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"react-native": "src/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"assets",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"react-native",
|
|
15
|
+
"context-menu",
|
|
16
|
+
"popover",
|
|
17
|
+
"menu",
|
|
18
|
+
"anchor"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": "*",
|
|
23
|
+
"react-native": "*"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { AnchoredMenuProvider } from "../core/provider";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* AnchoredMenuLayer
|
|
7
|
+
*
|
|
8
|
+
* A convenience wrapper that ensures the "view" host has a stable layout box to fill.
|
|
9
|
+
* Use it at app root and inside RN <Modal> (wrap the full-screen modal container).
|
|
10
|
+
*/
|
|
11
|
+
export function AnchoredMenuLayer({
|
|
12
|
+
children,
|
|
13
|
+
style,
|
|
14
|
+
defaultHost = "view",
|
|
15
|
+
...providerProps
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<View style={[{ flex: 1, position: "relative" }, style]}>
|
|
19
|
+
<AnchoredMenuProvider defaultHost={defaultHost} {...providerProps}>
|
|
20
|
+
{children}
|
|
21
|
+
</AnchoredMenuProvider>
|
|
22
|
+
</View>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React, { useContext, useEffect, useRef } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { AnchoredMenuActionsContext } from "../core/context";
|
|
4
|
+
|
|
5
|
+
function extractMarginsFromChild(children) {
|
|
6
|
+
try {
|
|
7
|
+
const child = React.Children.only(children);
|
|
8
|
+
const flat = StyleSheet.flatten(child?.props?.style) || {};
|
|
9
|
+
const mv =
|
|
10
|
+
typeof flat.marginVertical === "number" ? flat.marginVertical : undefined;
|
|
11
|
+
const mh =
|
|
12
|
+
typeof flat.marginHorizontal === "number"
|
|
13
|
+
? flat.marginHorizontal
|
|
14
|
+
: undefined;
|
|
15
|
+
const m = typeof flat.margin === "number" ? flat.margin : 0;
|
|
16
|
+
|
|
17
|
+
const top = (typeof flat.marginTop === "number" ? flat.marginTop : mv) ?? m;
|
|
18
|
+
const bottom =
|
|
19
|
+
(typeof flat.marginBottom === "number" ? flat.marginBottom : mv) ?? m;
|
|
20
|
+
const left =
|
|
21
|
+
(typeof flat.marginLeft === "number" ? flat.marginLeft : mh) ?? m;
|
|
22
|
+
const right =
|
|
23
|
+
(typeof flat.marginRight === "number" ? flat.marginRight : mh) ?? m;
|
|
24
|
+
|
|
25
|
+
return { top, bottom, left, right };
|
|
26
|
+
} catch {
|
|
27
|
+
return { top: 0, bottom: 0, left: 0, right: 0 };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function MenuAnchor({ id, children }) {
|
|
32
|
+
const actions = useContext(AnchoredMenuActionsContext);
|
|
33
|
+
if (!actions) throw new Error("AnchoredMenuProvider is missing");
|
|
34
|
+
|
|
35
|
+
const ref = useRef(null);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
// Store child margins on the ref object so measurement can exclude margins.
|
|
39
|
+
// This avoids offset differences when the anchored child uses e.g. marginBottom.
|
|
40
|
+
ref.__anchoredMenuMargins = extractMarginsFromChild(children);
|
|
41
|
+
|
|
42
|
+
actions.registerAnchor(id, ref);
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
actions.unregisterAnchor(id);
|
|
46
|
+
};
|
|
47
|
+
}, [actions, id, children]);
|
|
48
|
+
|
|
49
|
+
// collapsable={false} is important for Android measurement reliability
|
|
50
|
+
return (
|
|
51
|
+
<View ref={ref} collapsable={false}>
|
|
52
|
+
{children}
|
|
53
|
+
</View>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React, { createContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split contexts to avoid re-rendering all anchors when `request` changes.
|
|
5
|
+
*
|
|
6
|
+
* - Actions context: stable references (open/close/register/unregister/anchors map)
|
|
7
|
+
* - State context: request + derived values that change during open/close
|
|
8
|
+
*/
|
|
9
|
+
export const AnchoredMenuActionsContext = createContext(null);
|
|
10
|
+
export const AnchoredMenuStateContext = createContext(null);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
AnchoredMenuActionsContext,
|
|
5
|
+
AnchoredMenuStateContext,
|
|
6
|
+
} from "./context";
|
|
7
|
+
import { ModalHost } from "../hosts/ModalHost";
|
|
8
|
+
import { ViewHost } from "../hosts/ViewHost";
|
|
9
|
+
import { isFabricEnabled } from "../utils/runtime";
|
|
10
|
+
import {
|
|
11
|
+
findAllProvidersForAnchorId,
|
|
12
|
+
findProviderForAnchorId,
|
|
13
|
+
registerProvider,
|
|
14
|
+
} from "./providerRegistry";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Provider config
|
|
18
|
+
* - defaultHost: which host to use when `open()` doesn't specify one (default: "view")
|
|
19
|
+
* - autoHost: automatically mounts the host implementation (default: true)
|
|
20
|
+
*
|
|
21
|
+
* request shape (open payload)
|
|
22
|
+
* Modal/View host:
|
|
23
|
+
* {
|
|
24
|
+
* id,
|
|
25
|
+
* host?: "modal" | "view",
|
|
26
|
+
* placement?, offset?, margin?, align?, rtlAware?,
|
|
27
|
+
* animationType?,
|
|
28
|
+
* statusBarTranslucent?, // Android-only, for modal host
|
|
29
|
+
* render?: fn,
|
|
30
|
+
* content?: node
|
|
31
|
+
* }
|
|
32
|
+
*/
|
|
33
|
+
export function AnchoredMenuProvider({
|
|
34
|
+
children,
|
|
35
|
+
// backwards compatible alias
|
|
36
|
+
host,
|
|
37
|
+
defaultHost = host ?? "view",
|
|
38
|
+
autoHost = true,
|
|
39
|
+
}) {
|
|
40
|
+
const anchorsRef = useRef(new Map()); // id -> ref
|
|
41
|
+
const pendingOpenRafRef = useRef(null);
|
|
42
|
+
const defaultHostRef = useRef(defaultHost);
|
|
43
|
+
defaultHostRef.current = defaultHost;
|
|
44
|
+
|
|
45
|
+
// Tiny external store so open/close doesn't re-render the whole provider subtree.
|
|
46
|
+
const storeRef = useRef(null);
|
|
47
|
+
if (!storeRef.current) {
|
|
48
|
+
const listeners = new Set();
|
|
49
|
+
let snapshot = { request: null, activeHost: defaultHost, isOpen: false };
|
|
50
|
+
storeRef.current = {
|
|
51
|
+
getSnapshot: () => snapshot,
|
|
52
|
+
subscribe: (listener) => {
|
|
53
|
+
listeners.add(listener);
|
|
54
|
+
return () => listeners.delete(listener);
|
|
55
|
+
},
|
|
56
|
+
_setSnapshot: (next) => {
|
|
57
|
+
snapshot = next;
|
|
58
|
+
listeners.forEach((l) => l());
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const setRequest = useCallback((payload) => {
|
|
64
|
+
const defaultH = defaultHostRef.current ?? "view";
|
|
65
|
+
let nextActiveHost = payload?.host ?? defaultH;
|
|
66
|
+
|
|
67
|
+
// Guard: unknown host -> view
|
|
68
|
+
if (nextActiveHost !== "view" && nextActiveHost !== "modal") {
|
|
69
|
+
if (__DEV__) {
|
|
70
|
+
// eslint-disable-next-line no-console
|
|
71
|
+
console.warn(
|
|
72
|
+
`[react-native-anchored-menu] Unknown host="${String(
|
|
73
|
+
nextActiveHost
|
|
74
|
+
)}". Falling back to host="view".`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
nextActiveHost = "view";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Defensive: ModalHost can trigger internal React/Fabric issues in some environments.
|
|
81
|
+
if (
|
|
82
|
+
nextActiveHost === "modal" &&
|
|
83
|
+
isFabricEnabled() &&
|
|
84
|
+
Platform.OS !== "web"
|
|
85
|
+
) {
|
|
86
|
+
nextActiveHost = "view";
|
|
87
|
+
if (__DEV__) {
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.warn(
|
|
90
|
+
'[react-native-anchored-menu] host="modal" is disabled when Fabric is enabled; falling back to host="view".'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
storeRef.current._setSnapshot({
|
|
96
|
+
request: payload ?? null,
|
|
97
|
+
activeHost: payload ? nextActiveHost : defaultH,
|
|
98
|
+
isOpen: !!payload,
|
|
99
|
+
});
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const registerAnchor = useCallback((id, ref) => {
|
|
103
|
+
anchorsRef.current.set(id, ref);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const unregisterAnchor = useCallback((id) => {
|
|
107
|
+
anchorsRef.current.delete(id);
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
// Register this provider globally so parents can route `open({ id })` to the correct layer.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const entry = { anchors: anchorsRef.current, setRequest };
|
|
113
|
+
return registerProvider(entry);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const open = useCallback((payload) => {
|
|
117
|
+
// Defer by default to avoid "open tap" being interpreted as an outside press
|
|
118
|
+
// when a host mounts a Pressable backdrop during the active gesture.
|
|
119
|
+
if (pendingOpenRafRef.current) {
|
|
120
|
+
cancelAnimationFrame(pendingOpenRafRef.current);
|
|
121
|
+
pendingOpenRafRef.current = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const commit = () => {
|
|
125
|
+
if (!payload) return setRequest(null);
|
|
126
|
+
|
|
127
|
+
// If the anchor isn't registered in this provider, route to a nested provider that has it.
|
|
128
|
+
const anchorId = payload.id;
|
|
129
|
+
const hasLocalAnchor = anchorsRef.current?.has?.(anchorId);
|
|
130
|
+
|
|
131
|
+
if (!hasLocalAnchor) {
|
|
132
|
+
const matches = findAllProvidersForAnchorId(anchorId);
|
|
133
|
+
if (matches.length > 1) {
|
|
134
|
+
// eslint-disable-next-line no-console
|
|
135
|
+
console.warn(
|
|
136
|
+
`[react-native-anchored-menu] Multiple MenuAnchors registered with id="${anchorId}". ` +
|
|
137
|
+
"Using the most recently mounted provider. Consider unique ids per screen/modal."
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const target = findProviderForAnchorId(anchorId);
|
|
141
|
+
if (target && target.setRequest) return target.setRequest(payload);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setRequest(payload);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (payload?.immediate) commit();
|
|
148
|
+
else {
|
|
149
|
+
pendingOpenRafRef.current = requestAnimationFrame(() => {
|
|
150
|
+
pendingOpenRafRef.current = null;
|
|
151
|
+
commit();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
const close = useCallback(() => {
|
|
157
|
+
if (pendingOpenRafRef.current) {
|
|
158
|
+
cancelAnimationFrame(pendingOpenRafRef.current);
|
|
159
|
+
pendingOpenRafRef.current = null;
|
|
160
|
+
}
|
|
161
|
+
setRequest(null);
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
const actionsValue = useMemo(
|
|
165
|
+
() => ({
|
|
166
|
+
anchors: anchorsRef.current,
|
|
167
|
+
registerAnchor,
|
|
168
|
+
unregisterAnchor,
|
|
169
|
+
open,
|
|
170
|
+
close,
|
|
171
|
+
// provider config
|
|
172
|
+
defaultHost,
|
|
173
|
+
}),
|
|
174
|
+
[registerAnchor, unregisterAnchor, open, close, defaultHost]
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const stateStore = storeRef.current;
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<AnchoredMenuActionsContext.Provider value={actionsValue}>
|
|
181
|
+
<AnchoredMenuStateContext.Provider value={stateStore}>
|
|
182
|
+
{children}
|
|
183
|
+
{autoHost ? (
|
|
184
|
+
<>
|
|
185
|
+
<ModalHost />
|
|
186
|
+
<ViewHost />
|
|
187
|
+
</>
|
|
188
|
+
) : null}
|
|
189
|
+
</AnchoredMenuStateContext.Provider>
|
|
190
|
+
</AnchoredMenuActionsContext.Provider>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal global provider registry.
|
|
3
|
+
*
|
|
4
|
+
* Purpose: allow `open({ id })` called from a parent provider to route to a nested provider
|
|
5
|
+
* that owns the requested anchor id (e.g. inside RN <Modal>).
|
|
6
|
+
*
|
|
7
|
+
* This avoids requiring consumers to call `useAnchoredMenu()` inside the nested subtree.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const providers = []; // stack order by mount time
|
|
11
|
+
|
|
12
|
+
export function registerProvider(entry) {
|
|
13
|
+
providers.push(entry);
|
|
14
|
+
return () => unregisterProvider(entry);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function unregisterProvider(entry) {
|
|
18
|
+
const idx = providers.indexOf(entry);
|
|
19
|
+
if (idx >= 0) providers.splice(idx, 1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function findProviderForAnchorId(anchorId) {
|
|
23
|
+
if (!anchorId) return null;
|
|
24
|
+
|
|
25
|
+
// Prefer most recently mounted provider that has the anchor id.
|
|
26
|
+
for (let i = providers.length - 1; i >= 0; i--) {
|
|
27
|
+
const p = providers[i];
|
|
28
|
+
try {
|
|
29
|
+
if (p?.anchors?.has(anchorId)) return p;
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function findAllProvidersForAnchorId(anchorId) {
|
|
36
|
+
const matches = [];
|
|
37
|
+
if (!anchorId) return matches;
|
|
38
|
+
for (let i = 0; i < providers.length; i++) {
|
|
39
|
+
const p = providers[i];
|
|
40
|
+
try {
|
|
41
|
+
if (p?.anchors?.has(anchorId)) matches.push(p);
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
return matches;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useContext, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
AnchoredMenuActionsContext,
|
|
4
|
+
AnchoredMenuStateContext,
|
|
5
|
+
} from "../core/context";
|
|
6
|
+
|
|
7
|
+
export function useAnchoredMenu() {
|
|
8
|
+
const actions = useContext(AnchoredMenuActionsContext);
|
|
9
|
+
const state = useContext(AnchoredMenuStateContext);
|
|
10
|
+
if (!actions || !state) throw new Error("AnchoredMenuProvider is missing");
|
|
11
|
+
|
|
12
|
+
return useMemo(
|
|
13
|
+
() => ({
|
|
14
|
+
open: actions.open,
|
|
15
|
+
close: actions.close,
|
|
16
|
+
isOpen: state.isOpen,
|
|
17
|
+
}),
|
|
18
|
+
[actions.open, actions.close, state.isOpen]
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useContext, useMemo } from "react";
|
|
2
|
+
import { AnchoredMenuActionsContext } from "../core/context";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stable actions-only hook.
|
|
6
|
+
* Components using this won't re-render when menu state changes.
|
|
7
|
+
*/
|
|
8
|
+
export function useAnchoredMenuActions() {
|
|
9
|
+
const actions = useContext(AnchoredMenuActionsContext);
|
|
10
|
+
if (!actions) throw new Error("AnchoredMenuProvider is missing");
|
|
11
|
+
|
|
12
|
+
return useMemo(
|
|
13
|
+
() => ({
|
|
14
|
+
open: actions.open,
|
|
15
|
+
close: actions.close,
|
|
16
|
+
}),
|
|
17
|
+
[actions.open, actions.close]
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useContext, useSyncExternalStore } from "react";
|
|
2
|
+
import { AnchoredMenuStateContext } from "../core/context";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* State selector hook.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const isOpen = useAnchoredMenuState(s => s.isOpen)
|
|
9
|
+
*/
|
|
10
|
+
export function useAnchoredMenuState(selector = (s) => s) {
|
|
11
|
+
const store = useContext(AnchoredMenuStateContext);
|
|
12
|
+
if (!store) throw new Error("AnchoredMenuProvider is missing");
|
|
13
|
+
|
|
14
|
+
const getSelectedSnapshot = () => selector(store.getSnapshot());
|
|
15
|
+
return useSyncExternalStore(
|
|
16
|
+
store.subscribe,
|
|
17
|
+
getSelectedSnapshot,
|
|
18
|
+
getSelectedSnapshot
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Modal, Platform, Pressable, View } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
AnchoredMenuActionsContext,
|
|
5
|
+
AnchoredMenuStateContext,
|
|
6
|
+
} from "../core/context";
|
|
7
|
+
import { useAnchoredMenuState } from "../hooks/useAnchoredMenuState";
|
|
8
|
+
import {
|
|
9
|
+
applyAnchorMargins,
|
|
10
|
+
measureInWindowFast,
|
|
11
|
+
measureInWindowStable,
|
|
12
|
+
} from "../utils/measure";
|
|
13
|
+
import { computeMenuPosition } from "../utils/position";
|
|
14
|
+
import { isFabricEnabled } from "../utils/runtime";
|
|
15
|
+
|
|
16
|
+
export function ModalHost() {
|
|
17
|
+
const actions = useContext(AnchoredMenuActionsContext);
|
|
18
|
+
const store = useContext(AnchoredMenuStateContext);
|
|
19
|
+
if (!actions || !store) throw new Error("AnchoredMenuProvider is missing");
|
|
20
|
+
|
|
21
|
+
const activeHost = useAnchoredMenuState((s) => s.activeHost);
|
|
22
|
+
if (activeHost !== "modal") return null;
|
|
23
|
+
if (isFabricEnabled() && Platform.OS !== "web") return null;
|
|
24
|
+
|
|
25
|
+
const req = useAnchoredMenuState((s) => s.request);
|
|
26
|
+
const visible = !!req;
|
|
27
|
+
|
|
28
|
+
const hostRef = useRef(null);
|
|
29
|
+
const [hostSize, setHostSize] = useState({ width: 0, height: 0 });
|
|
30
|
+
|
|
31
|
+
const [anchorWin, setAnchorWin] = useState(null);
|
|
32
|
+
const [hostWin, setHostWin] = useState(null);
|
|
33
|
+
const [menuSize, setMenuSize] = useState({ width: 0, height: 0 });
|
|
34
|
+
const measureCacheRef = useRef(new Map()); // id -> { t, anchorWin, hostWin }
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!req) {
|
|
38
|
+
setAnchorWin(null);
|
|
39
|
+
setHostWin(null);
|
|
40
|
+
setMenuSize({ width: 0, height: 0 });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// reset on open / anchor change
|
|
44
|
+
setAnchorWin(null);
|
|
45
|
+
setHostWin(null);
|
|
46
|
+
setMenuSize({ width: 0, height: 0 });
|
|
47
|
+
|
|
48
|
+
// Warm start: if we recently measured this anchor, seed state so the menu can appear faster.
|
|
49
|
+
const cached = measureCacheRef.current.get(req.id);
|
|
50
|
+
if (cached && Date.now() - cached.t < 300) {
|
|
51
|
+
setAnchorWin(cached.anchorWin);
|
|
52
|
+
setHostWin(cached.hostWin);
|
|
53
|
+
}
|
|
54
|
+
}, [req?.id, req]);
|
|
55
|
+
|
|
56
|
+
// Measure after Modal is visible (hostRef exists only then)
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
|
|
60
|
+
async function run() {
|
|
61
|
+
if (!req) return;
|
|
62
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
63
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
64
|
+
|
|
65
|
+
const refObj = actions.anchors.get(req.id); // ref object
|
|
66
|
+
if (!refObj || !hostRef.current) return;
|
|
67
|
+
|
|
68
|
+
const strategy = req?.measurement ?? "stable"; // "stable" | "fast"
|
|
69
|
+
const tries =
|
|
70
|
+
typeof req?.measurementTries === "number" ? req.measurementTries : 8;
|
|
71
|
+
const measure =
|
|
72
|
+
strategy === "fast" ? measureInWindowFast : measureInWindowStable;
|
|
73
|
+
|
|
74
|
+
const [a, h] = await Promise.all([
|
|
75
|
+
measure(refObj, strategy === "stable" ? { tries } : undefined),
|
|
76
|
+
measure(hostRef, strategy === "stable" ? { tries } : undefined),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
if (cancelled) return;
|
|
80
|
+
const nextAnchorWin = applyAnchorMargins(a, refObj);
|
|
81
|
+
setAnchorWin(nextAnchorWin);
|
|
82
|
+
setHostWin(h);
|
|
83
|
+
measureCacheRef.current.set(req.id, {
|
|
84
|
+
t: Date.now(),
|
|
85
|
+
anchorWin: nextAnchorWin,
|
|
86
|
+
hostWin: h,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
run();
|
|
91
|
+
return () => {
|
|
92
|
+
cancelled = true;
|
|
93
|
+
};
|
|
94
|
+
}, [req?.id, req, actions.anchors]);
|
|
95
|
+
|
|
96
|
+
// window coords -> host coords (avoids status bar / inset mismatches)
|
|
97
|
+
const anchorInHost = useMemo(() => {
|
|
98
|
+
if (!anchorWin || !hostWin) return null;
|
|
99
|
+
return {
|
|
100
|
+
...anchorWin,
|
|
101
|
+
pageX: anchorWin.pageX - hostWin.pageX,
|
|
102
|
+
pageY: anchorWin.pageY - hostWin.pageY,
|
|
103
|
+
};
|
|
104
|
+
}, [anchorWin, hostWin]);
|
|
105
|
+
|
|
106
|
+
const position = useMemo(() => {
|
|
107
|
+
if (!req || !anchorInHost) return null;
|
|
108
|
+
const viewport =
|
|
109
|
+
hostSize.width && hostSize.height
|
|
110
|
+
? { width: hostSize.width, height: hostSize.height }
|
|
111
|
+
: undefined;
|
|
112
|
+
return computeMenuPosition({
|
|
113
|
+
anchor: anchorInHost,
|
|
114
|
+
menuSize,
|
|
115
|
+
viewport,
|
|
116
|
+
placement: req.placement ?? "auto",
|
|
117
|
+
offset: req.offset ?? 8,
|
|
118
|
+
margin: req.margin ?? 8,
|
|
119
|
+
align: req.align ?? "start",
|
|
120
|
+
rtlAware: req.rtlAware ?? true,
|
|
121
|
+
});
|
|
122
|
+
}, [req, anchorInHost, menuSize, hostSize]);
|
|
123
|
+
|
|
124
|
+
const needsInitialMeasure = menuSize.width === 0 || menuSize.height === 0;
|
|
125
|
+
|
|
126
|
+
const statusBarTranslucent =
|
|
127
|
+
req?.statusBarTranslucent ?? (Platform.OS === "android" ? false : true);
|
|
128
|
+
|
|
129
|
+
if (!visible) return null;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Modal
|
|
133
|
+
visible={visible}
|
|
134
|
+
transparent
|
|
135
|
+
animationType={req.animationType ?? "fade"}
|
|
136
|
+
statusBarTranslucent={statusBarTranslucent}
|
|
137
|
+
onRequestClose={actions.close}
|
|
138
|
+
>
|
|
139
|
+
<View
|
|
140
|
+
ref={hostRef}
|
|
141
|
+
collapsable={false}
|
|
142
|
+
style={{ flex: 1 }}
|
|
143
|
+
onLayout={(e) => {
|
|
144
|
+
const { width, height } = e.nativeEvent.layout;
|
|
145
|
+
if (width !== hostSize.width || height !== hostSize.height) {
|
|
146
|
+
setHostSize({ width, height });
|
|
147
|
+
}
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
{/* Tap outside to dismiss */}
|
|
151
|
+
<Pressable style={{ flex: 1 }} onPress={actions.close}>
|
|
152
|
+
{visible && !!anchorInHost && !!position ? (
|
|
153
|
+
<View
|
|
154
|
+
style={{
|
|
155
|
+
position: "absolute",
|
|
156
|
+
// Keep in-place so layout runs on iOS; hide visually until measured to avoid flicker.
|
|
157
|
+
top: position.top,
|
|
158
|
+
left: position.left,
|
|
159
|
+
opacity: needsInitialMeasure ? 0 : 1,
|
|
160
|
+
}}
|
|
161
|
+
pointerEvents={needsInitialMeasure ? "none" : "auto"}
|
|
162
|
+
onStartShouldSetResponder={() => true}
|
|
163
|
+
onLayout={(e) => {
|
|
164
|
+
const { width, height } = e.nativeEvent.layout;
|
|
165
|
+
if (width !== menuSize.width || height !== menuSize.height) {
|
|
166
|
+
setMenuSize({ width, height });
|
|
167
|
+
}
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{typeof req.render === "function"
|
|
171
|
+
? req.render({
|
|
172
|
+
close: actions.close,
|
|
173
|
+
anchor: anchorWin,
|
|
174
|
+
anchorInHost,
|
|
175
|
+
})
|
|
176
|
+
: req.content}
|
|
177
|
+
</View>
|
|
178
|
+
) : null}
|
|
179
|
+
</Pressable>
|
|
180
|
+
</View>
|
|
181
|
+
</Modal>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Pressable, View } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
AnchoredMenuActionsContext,
|
|
5
|
+
AnchoredMenuStateContext,
|
|
6
|
+
} from "../core/context";
|
|
7
|
+
import { useAnchoredMenuState } from "../hooks/useAnchoredMenuState";
|
|
8
|
+
import {
|
|
9
|
+
applyAnchorMargins,
|
|
10
|
+
measureInWindowFast,
|
|
11
|
+
measureInWindowStable,
|
|
12
|
+
} from "../utils/measure";
|
|
13
|
+
import { computeMenuPosition } from "../utils/position";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ViewHost (non-native-modal host)
|
|
17
|
+
*
|
|
18
|
+
* Renders the menu as an absolutely-positioned overlay View, without presenting
|
|
19
|
+
* a native <Modal>. This is safe to use inside an existing RN <Modal>.
|
|
20
|
+
*
|
|
21
|
+
* NOTE: Must be mounted inside a parent that can cover the intended area
|
|
22
|
+
* (usually at the app root, or inside your RN Modal content).
|
|
23
|
+
*/
|
|
24
|
+
export function ViewHost() {
|
|
25
|
+
const actions = useContext(AnchoredMenuActionsContext);
|
|
26
|
+
const store = useContext(AnchoredMenuStateContext);
|
|
27
|
+
if (!actions || !store) throw new Error("AnchoredMenuProvider is missing");
|
|
28
|
+
|
|
29
|
+
const activeHost = useAnchoredMenuState((s) => s.activeHost);
|
|
30
|
+
if (activeHost !== "view") return null;
|
|
31
|
+
|
|
32
|
+
const req = useAnchoredMenuState((s) => s.request);
|
|
33
|
+
const visible = !!req;
|
|
34
|
+
|
|
35
|
+
const hostRef = useRef(null);
|
|
36
|
+
const [hostSize, setHostSize] = useState({ width: 0, height: 0 });
|
|
37
|
+
|
|
38
|
+
const [anchorWin, setAnchorWin] = useState(null);
|
|
39
|
+
const [hostWin, setHostWin] = useState(null);
|
|
40
|
+
const [menuSize, setMenuSize] = useState({ width: 0, height: 0 });
|
|
41
|
+
const measureCacheRef = useRef(new Map()); // id -> { t, anchorWin, hostWin }
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!req) {
|
|
45
|
+
setAnchorWin(null);
|
|
46
|
+
setHostWin(null);
|
|
47
|
+
setMenuSize({ width: 0, height: 0 });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// reset on open / anchor change
|
|
51
|
+
setAnchorWin(null);
|
|
52
|
+
setHostWin(null);
|
|
53
|
+
setMenuSize({ width: 0, height: 0 });
|
|
54
|
+
|
|
55
|
+
// Warm start: if we recently measured this anchor, seed state so the menu can appear faster.
|
|
56
|
+
const cached = measureCacheRef.current.get(req.id);
|
|
57
|
+
if (cached && Date.now() - cached.t < 300) {
|
|
58
|
+
setAnchorWin(cached.anchorWin);
|
|
59
|
+
setHostWin(cached.hostWin);
|
|
60
|
+
}
|
|
61
|
+
}, [req?.id, req]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
let cancelled = false;
|
|
65
|
+
|
|
66
|
+
async function run() {
|
|
67
|
+
if (!req || !hostRef.current) return;
|
|
68
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
69
|
+
|
|
70
|
+
const refObj = actions.anchors.get(req.id); // ref object
|
|
71
|
+
if (!refObj) return;
|
|
72
|
+
|
|
73
|
+
const strategy = req?.measurement ?? "stable"; // "stable" | "fast"
|
|
74
|
+
const tries =
|
|
75
|
+
typeof req?.measurementTries === "number" ? req.measurementTries : 8;
|
|
76
|
+
const measure =
|
|
77
|
+
strategy === "fast" ? measureInWindowFast : measureInWindowStable;
|
|
78
|
+
|
|
79
|
+
const [a, h] = await Promise.all([
|
|
80
|
+
measure(refObj, strategy === "stable" ? { tries } : undefined),
|
|
81
|
+
measure(hostRef, strategy === "stable" ? { tries } : undefined),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
if (cancelled) return;
|
|
85
|
+
const nextAnchorWin = applyAnchorMargins(a, refObj);
|
|
86
|
+
setAnchorWin(nextAnchorWin);
|
|
87
|
+
setHostWin(h);
|
|
88
|
+
measureCacheRef.current.set(req.id, {
|
|
89
|
+
t: Date.now(),
|
|
90
|
+
anchorWin: nextAnchorWin,
|
|
91
|
+
hostWin: h,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
run();
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
};
|
|
99
|
+
}, [req?.id, req, actions.anchors]);
|
|
100
|
+
|
|
101
|
+
const anchorInHost = useMemo(() => {
|
|
102
|
+
if (!anchorWin || !hostWin) return null;
|
|
103
|
+
return {
|
|
104
|
+
...anchorWin,
|
|
105
|
+
pageX: anchorWin.pageX - hostWin.pageX,
|
|
106
|
+
pageY: anchorWin.pageY - hostWin.pageY,
|
|
107
|
+
};
|
|
108
|
+
}, [anchorWin, hostWin]);
|
|
109
|
+
|
|
110
|
+
const position = useMemo(() => {
|
|
111
|
+
if (!req || !anchorInHost) return null;
|
|
112
|
+
const viewport =
|
|
113
|
+
hostSize.width && hostSize.height
|
|
114
|
+
? { width: hostSize.width, height: hostSize.height }
|
|
115
|
+
: undefined;
|
|
116
|
+
|
|
117
|
+
return computeMenuPosition({
|
|
118
|
+
anchor: anchorInHost,
|
|
119
|
+
menuSize,
|
|
120
|
+
viewport,
|
|
121
|
+
placement: req.placement ?? "auto",
|
|
122
|
+
offset: req.offset ?? 8,
|
|
123
|
+
margin: req.margin ?? 8,
|
|
124
|
+
align: req.align ?? "start",
|
|
125
|
+
rtlAware: req.rtlAware ?? true,
|
|
126
|
+
});
|
|
127
|
+
}, [req, anchorInHost, menuSize, hostSize]);
|
|
128
|
+
|
|
129
|
+
const needsInitialMeasure = menuSize.width === 0 || menuSize.height === 0;
|
|
130
|
+
if (!visible) return null;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<View
|
|
134
|
+
ref={hostRef}
|
|
135
|
+
collapsable={false}
|
|
136
|
+
style={{
|
|
137
|
+
position: "absolute",
|
|
138
|
+
top: 0,
|
|
139
|
+
right: 0,
|
|
140
|
+
bottom: 0,
|
|
141
|
+
left: 0,
|
|
142
|
+
zIndex: 9999,
|
|
143
|
+
elevation: 9999,
|
|
144
|
+
}}
|
|
145
|
+
pointerEvents="box-none"
|
|
146
|
+
onLayout={(e) => {
|
|
147
|
+
const { width, height } = e.nativeEvent.layout;
|
|
148
|
+
if (width !== hostSize.width || height !== hostSize.height) {
|
|
149
|
+
setHostSize({ width, height });
|
|
150
|
+
}
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
{/* Tap outside to dismiss */}
|
|
154
|
+
<Pressable style={{ flex: 1 }} onPress={actions.close}>
|
|
155
|
+
{visible && !!anchorInHost && !!position ? (
|
|
156
|
+
<View
|
|
157
|
+
style={{
|
|
158
|
+
position: "absolute",
|
|
159
|
+
// Keep in-place so layout runs on iOS; hide visually until measured to avoid flicker.
|
|
160
|
+
top: position.top,
|
|
161
|
+
left: position.left,
|
|
162
|
+
zIndex: 10000,
|
|
163
|
+
elevation: 10000,
|
|
164
|
+
opacity: needsInitialMeasure ? 0 : 1,
|
|
165
|
+
}}
|
|
166
|
+
pointerEvents={needsInitialMeasure ? "none" : "auto"}
|
|
167
|
+
onStartShouldSetResponder={() => true}
|
|
168
|
+
onLayout={(e) => {
|
|
169
|
+
const { width, height } = e.nativeEvent.layout;
|
|
170
|
+
if (width !== menuSize.width || height !== menuSize.height) {
|
|
171
|
+
setMenuSize({ width, height });
|
|
172
|
+
}
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
{typeof req.render === "function"
|
|
176
|
+
? req.render({
|
|
177
|
+
close: actions.close,
|
|
178
|
+
anchor: anchorWin,
|
|
179
|
+
anchorInHost,
|
|
180
|
+
})
|
|
181
|
+
: req.content}
|
|
182
|
+
</View>
|
|
183
|
+
) : null}
|
|
184
|
+
</Pressable>
|
|
185
|
+
</View>
|
|
186
|
+
);
|
|
187
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { AnchoredMenuProvider } from "./core/provider";
|
|
2
|
+
export { MenuAnchor } from "./components/MenuAnchor";
|
|
3
|
+
export { AnchoredMenuLayer } from "./components/AnchoredMenuLayer";
|
|
4
|
+
export { useAnchoredMenu } from "./hooks/useAnchoredMenu";
|
|
5
|
+
export { useAnchoredMenuActions } from "./hooks/useAnchoredMenuActions";
|
|
6
|
+
export { useAnchoredMenuState } from "./hooks/useAnchoredMenuState";
|
|
7
|
+
|
|
8
|
+
export { ModalHost } from "./hosts/ModalHost";
|
|
9
|
+
export { ViewHost } from "./hosts/ViewHost";
|
|
10
|
+
|
|
11
|
+
// Backwards-compat / convenience: allow default import
|
|
12
|
+
// import AnchoredMenuProvider from 'react-native-anchored-menu'
|
|
13
|
+
export { AnchoredMenuProvider as default } from "./core/provider";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { InteractionManager, UIManager, findNodeHandle } from "react-native";
|
|
2
|
+
|
|
3
|
+
const raf = () => new Promise((r) => requestAnimationFrame(r));
|
|
4
|
+
|
|
5
|
+
async function measureInWindowOnce(target) {
|
|
6
|
+
const node = findNodeHandle(target?.current ?? target);
|
|
7
|
+
if (!node) return null;
|
|
8
|
+
return await new Promise((resolve) => {
|
|
9
|
+
UIManager.measureInWindow(node, (x, y, width, height) => {
|
|
10
|
+
resolve({ pageX: x, pageY: y, width, height });
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Faster (less stable) measurement: one RAF + single measureInWindow call.
|
|
17
|
+
* Useful for very simple layouts where Android/FlatList flakiness isn't a concern.
|
|
18
|
+
*/
|
|
19
|
+
export async function measureInWindowFast(target) {
|
|
20
|
+
await raf();
|
|
21
|
+
return await measureInWindowOnce(target);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* More reliable measurement for Android/FlatList: waits for interactions + next frame(s)
|
|
26
|
+
* and retries until values stabilize.
|
|
27
|
+
*/
|
|
28
|
+
export async function measureInWindowStable(target, { tries = 8 } = {}) {
|
|
29
|
+
await new Promise((r) => InteractionManager.runAfterInteractions(r));
|
|
30
|
+
|
|
31
|
+
const node = findNodeHandle(target?.current ?? target);
|
|
32
|
+
if (!node) return null;
|
|
33
|
+
|
|
34
|
+
let last = null;
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < tries; i++) {
|
|
37
|
+
await raf();
|
|
38
|
+
|
|
39
|
+
const m = await measureInWindowOnce({ current: node });
|
|
40
|
+
|
|
41
|
+
const looksInvalid = m.pageX === 0 && m.pageY === 0 && i < tries - 1;
|
|
42
|
+
const stable =
|
|
43
|
+
last &&
|
|
44
|
+
Math.abs(m.pageX - last.pageX) < 1 &&
|
|
45
|
+
Math.abs(m.pageY - last.pageY) < 1;
|
|
46
|
+
|
|
47
|
+
if (!looksInvalid && (stable || i >= 1)) return m;
|
|
48
|
+
|
|
49
|
+
last = m;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return last;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* FlatList-safe: waits for interactions and next frame before measuring.
|
|
57
|
+
* Uses measureInWindow so coords match overlay/Modal window coordinates.
|
|
58
|
+
*/
|
|
59
|
+
export async function measureAnchorInWindow(ref) {
|
|
60
|
+
return await measureInWindowStable(ref, { tries: 8 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Adjust measured rect to ignore margins applied on the anchored child.
|
|
65
|
+
* `MenuAnchor` stores margins on the ref object as `__anchoredMenuMargins`.
|
|
66
|
+
*/
|
|
67
|
+
export function applyAnchorMargins(measured, refObj) {
|
|
68
|
+
if (!measured) return measured;
|
|
69
|
+
const m = refObj?.__anchoredMenuMargins;
|
|
70
|
+
if (!m) return measured;
|
|
71
|
+
|
|
72
|
+
const top = typeof m.top === "number" ? m.top : 0;
|
|
73
|
+
const bottom = typeof m.bottom === "number" ? m.bottom : 0;
|
|
74
|
+
const left = typeof m.left === "number" ? m.left : 0;
|
|
75
|
+
const right = typeof m.right === "number" ? m.right : 0;
|
|
76
|
+
|
|
77
|
+
const width = Math.max(0, measured.width - left - right);
|
|
78
|
+
const height = Math.max(0, measured.height - top - bottom);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
...measured,
|
|
82
|
+
pageX: measured.pageX + left,
|
|
83
|
+
pageY: measured.pageY + top,
|
|
84
|
+
width,
|
|
85
|
+
height,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Dimensions, I18nManager } from "react-native";
|
|
2
|
+
|
|
3
|
+
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Computes menu top/left with optional flip + clamping.
|
|
7
|
+
* If menuSize is unknown (0), it still places, but clamping/flip is limited.
|
|
8
|
+
*/
|
|
9
|
+
export function computeMenuPosition({
|
|
10
|
+
anchor,
|
|
11
|
+
menuSize,
|
|
12
|
+
viewport,
|
|
13
|
+
placement = "auto", // 'auto' | 'top' | 'bottom'
|
|
14
|
+
offset = 8,
|
|
15
|
+
margin = 8,
|
|
16
|
+
align = "start", // 'start' | 'center' | 'end'
|
|
17
|
+
rtlAware = true,
|
|
18
|
+
}) {
|
|
19
|
+
const { width: SW, height: SH } = viewport ?? Dimensions.get("window");
|
|
20
|
+
const mW = menuSize?.width || 0;
|
|
21
|
+
const mH = menuSize?.height || 0;
|
|
22
|
+
|
|
23
|
+
// X alignment
|
|
24
|
+
let left = anchor.pageX;
|
|
25
|
+
if (align === "center" && mW) left = anchor.pageX + anchor.width / 2 - mW / 2;
|
|
26
|
+
if (align === "end" && mW) left = anchor.pageX + anchor.width - mW;
|
|
27
|
+
|
|
28
|
+
// Optional RTL hook (kept as no-op unless you customize)
|
|
29
|
+
if (rtlAware && I18nManager.isRTL) {
|
|
30
|
+
// no-op by default
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (mW) left = clamp(left, margin, SW - mW - margin);
|
|
34
|
+
else left = Math.max(margin, left);
|
|
35
|
+
|
|
36
|
+
// Candidate vertical positions
|
|
37
|
+
const belowTop = anchor.pageY + anchor.height + offset;
|
|
38
|
+
const aboveTop = anchor.pageY - mH - offset;
|
|
39
|
+
|
|
40
|
+
// Fit checks (meaningful when menu height is known)
|
|
41
|
+
const fitsAbove = mH ? aboveTop >= margin : true;
|
|
42
|
+
const fitsBelow = mH ? belowTop + mH <= SH - margin : true;
|
|
43
|
+
|
|
44
|
+
let top;
|
|
45
|
+
|
|
46
|
+
// Placement policy:
|
|
47
|
+
// - "top": prefer above, fallback below if it doesn't fit
|
|
48
|
+
// - "bottom": prefer below, fallback above if it doesn't fit
|
|
49
|
+
// - "auto": prefer below if it fits, else above
|
|
50
|
+
if (placement === "top") {
|
|
51
|
+
top = fitsAbove ? aboveTop : belowTop;
|
|
52
|
+
} else if (placement === "bottom") {
|
|
53
|
+
top = fitsBelow ? belowTop : aboveTop;
|
|
54
|
+
} else {
|
|
55
|
+
top = fitsBelow ? belowTop : aboveTop;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Final clamp
|
|
59
|
+
if (mH) top = clamp(top, margin, SH - mH - margin);
|
|
60
|
+
else top = Math.max(margin, top);
|
|
61
|
+
|
|
62
|
+
return { top, left };
|
|
63
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort detection of Fabric/New Architecture.
|
|
3
|
+
* Used only to avoid known-crashy code paths (e.g. nesting RN <Modal> in some setups).
|
|
4
|
+
*/
|
|
5
|
+
export function isFabricEnabled() {
|
|
6
|
+
try {
|
|
7
|
+
// In Fabric, nativeFabricUIManager is typically defined.
|
|
8
|
+
// (Heuristic; varies by RN version)
|
|
9
|
+
return !!global?.nativeFabricUIManager;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|