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 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>&lt;Modal&gt;</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
Binary file
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
+ }