react-native-anchored-menu 0.0.12 → 0.0.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-anchored-menu",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Headless anchored context menu / popover for React Native (iOS/Android) with stable measurement (default view host).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,8 +10,9 @@
10
10
  "url": "https://github.com/mahmoudelfekygithub/react-native-anchored-menu/issues"
11
11
  },
12
12
  "homepage": "https://github.com/mahmoudelfekygithub/react-native-anchored-menu#readme",
13
- "main": "src/index.js",
14
- "react-native": "src/index.js",
13
+ "main": "src/index.ts",
14
+ "react-native": "src/index.ts",
15
+ "types": "src/index.ts",
15
16
  "files": [
16
17
  "src",
17
18
  "assets",
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { View } from "react-native";
3
+ import { AnchoredMenuProvider } from "../core/provider";
4
+ import type { AnchoredMenuLayerProps } from "../types";
5
+
6
+ /**
7
+ * AnchoredMenuLayer
8
+ *
9
+ * A convenience wrapper that ensures the "view" host has a stable layout box to fill.
10
+ * Use it at app root and inside RN <Modal> (wrap the full-screen modal container).
11
+ */
12
+ export function AnchoredMenuLayer({
13
+ children,
14
+ style,
15
+ defaultHost = "view",
16
+ ...providerProps
17
+ }: AnchoredMenuLayerProps) {
18
+ return (
19
+ <View style={[{ flex: 1, position: "relative" }, style]}>
20
+ <AnchoredMenuProvider defaultHost={defaultHost} {...providerProps}>
21
+ {children}
22
+ </AnchoredMenuProvider>
23
+ </View>
24
+ );
25
+ }
26
+
@@ -0,0 +1,57 @@
1
+ import React, { useContext, useEffect, useRef } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { AnchoredMenuActionsContext } from "../core/context";
4
+ import type { AnchorMargins, AnchorRefObject, MenuAnchorProps } from "../types";
5
+
6
+ function extractMarginsFromChild(children: React.ReactNode): AnchorMargins {
7
+ try {
8
+ const child = React.Children.only(children) as React.ReactElement;
9
+ const flat = StyleSheet.flatten(child?.props?.style) || {};
10
+ const mv =
11
+ typeof flat.marginVertical === "number" ? flat.marginVertical : undefined;
12
+ const mh =
13
+ typeof flat.marginHorizontal === "number"
14
+ ? flat.marginHorizontal
15
+ : undefined;
16
+ const m = typeof flat.margin === "number" ? flat.margin : 0;
17
+
18
+ const top = (typeof flat.marginTop === "number" ? flat.marginTop : mv) ?? m;
19
+ const bottom =
20
+ (typeof flat.marginBottom === "number" ? flat.marginBottom : mv) ?? m;
21
+ const left =
22
+ (typeof flat.marginLeft === "number" ? flat.marginLeft : mh) ?? m;
23
+ const right =
24
+ (typeof flat.marginRight === "number" ? flat.marginRight : mh) ?? m;
25
+
26
+ return { top, bottom, left, right };
27
+ } catch {
28
+ return { top: 0, bottom: 0, left: 0, right: 0 };
29
+ }
30
+ }
31
+
32
+ export function MenuAnchor({ id, children }: MenuAnchorProps) {
33
+ const actions = useContext(AnchoredMenuActionsContext);
34
+ if (!actions) throw new Error("AnchoredMenuProvider is missing");
35
+
36
+ const ref = useRef<View>(null) as AnchorRefObject;
37
+
38
+ useEffect(() => {
39
+ // Store child margins on the ref object so measurement can exclude margins.
40
+ // This avoids offset differences when the anchored child uses e.g. marginBottom.
41
+ ref.__anchoredMenuMargins = extractMarginsFromChild(children);
42
+
43
+ actions.registerAnchor(id, ref);
44
+
45
+ return () => {
46
+ actions.unregisterAnchor(id);
47
+ };
48
+ }, [actions, id, children]);
49
+
50
+ // collapsable={false} is important for Android measurement reliability
51
+ return (
52
+ <View ref={ref} collapsable={false}>
53
+ {children}
54
+ </View>
55
+ );
56
+ }
57
+
@@ -0,0 +1,12 @@
1
+ import { createContext } from "react";
2
+ import type { MenuActions, MenuStore } from "../types";
3
+
4
+ /**
5
+ * Split contexts to avoid re-rendering all anchors when `request` changes.
6
+ *
7
+ * - Actions context: stable references (open/close/register/unregister/anchors map)
8
+ * - State context: request + derived values that change during open/close
9
+ */
10
+ export const AnchoredMenuActionsContext = createContext<MenuActions | null>(null);
11
+ export const AnchoredMenuStateContext = createContext<MenuStore | null>(null);
12
+
@@ -0,0 +1,194 @@
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
+ import type {
16
+ AnchoredMenuProviderProps,
17
+ HostType,
18
+ MenuRequest,
19
+ MenuState,
20
+ MenuStore,
21
+ OpenMenuOptions,
22
+ } from "../types";
23
+
24
+ /**
25
+ * Provider config
26
+ * - defaultHost: which host to use when `open()` doesn't specify one (default: "view")
27
+ * - autoHost: automatically mounts the host implementation (default: true)
28
+ */
29
+ export function AnchoredMenuProvider({
30
+ children,
31
+ // backwards compatible alias
32
+ host,
33
+ defaultHost = (host ?? "view") as HostType,
34
+ autoHost = true,
35
+ }: AnchoredMenuProviderProps) {
36
+ const anchorsRef = useRef(new Map<string, any>()); // id -> ref
37
+ const pendingOpenRafRef = useRef<number | null>(null);
38
+ const defaultHostRef = useRef(defaultHost);
39
+ defaultHostRef.current = defaultHost;
40
+
41
+ // Tiny external store so open/close doesn't re-render the whole provider subtree.
42
+ const storeRef = useRef<MenuStore | null>(null);
43
+ if (!storeRef.current) {
44
+ const listeners = new Set<() => void>();
45
+ let snapshot: MenuState = {
46
+ request: null,
47
+ activeHost: defaultHost,
48
+ isOpen: false,
49
+ };
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: MenuRequest | null) => {
64
+ const defaultH = defaultHostRef.current ?? "view";
65
+ let nextActiveHost: HostType = (payload?.host ?? defaultH) as HostType;
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: string, ref: any) => {
103
+ anchorsRef.current.set(id, ref);
104
+ }, []);
105
+
106
+ const unregisterAnchor = useCallback((id: string) => {
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
+ }, [setRequest]);
115
+
116
+ const open = useCallback((payload: OpenMenuOptions) => {
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)
142
+ return target.setRequest(payload as MenuRequest);
143
+ }
144
+
145
+ setRequest(payload as MenuRequest);
146
+ };
147
+
148
+ if (payload?.immediate) commit();
149
+ else {
150
+ pendingOpenRafRef.current = requestAnimationFrame(() => {
151
+ pendingOpenRafRef.current = null;
152
+ commit();
153
+ });
154
+ }
155
+ }, []);
156
+
157
+ const close = useCallback(() => {
158
+ if (pendingOpenRafRef.current) {
159
+ cancelAnimationFrame(pendingOpenRafRef.current);
160
+ pendingOpenRafRef.current = null;
161
+ }
162
+ setRequest(null);
163
+ }, []);
164
+
165
+ const actionsValue = useMemo(
166
+ () => ({
167
+ anchors: anchorsRef.current,
168
+ registerAnchor,
169
+ unregisterAnchor,
170
+ open,
171
+ close,
172
+ // provider config
173
+ defaultHost,
174
+ }),
175
+ [registerAnchor, unregisterAnchor, open, close, defaultHost]
176
+ );
177
+
178
+ const stateStore = storeRef.current;
179
+
180
+ return (
181
+ <AnchoredMenuActionsContext.Provider value={actionsValue}>
182
+ <AnchoredMenuStateContext.Provider value={stateStore}>
183
+ {children}
184
+ {autoHost ? (
185
+ <>
186
+ <ModalHost />
187
+ <ViewHost />
188
+ </>
189
+ ) : null}
190
+ </AnchoredMenuStateContext.Provider>
191
+ </AnchoredMenuActionsContext.Provider>
192
+ );
193
+ }
194
+
@@ -0,0 +1,45 @@
1
+ import type { ProviderEntry } from "../types";
2
+
3
+ const providers: ProviderEntry[] = []; // stack order by mount time
4
+
5
+ export function registerProvider(entry: ProviderEntry): () => void {
6
+ providers.push(entry);
7
+ return () => unregisterProvider(entry);
8
+ }
9
+
10
+ export function unregisterProvider(entry: ProviderEntry): void {
11
+ const idx = providers.indexOf(entry);
12
+ if (idx >= 0) providers.splice(idx, 1);
13
+ }
14
+
15
+ export function findProviderForAnchorId(anchorId: string | null | undefined): ProviderEntry | null {
16
+ if (!anchorId) return null;
17
+
18
+ // Prefer most recently mounted provider that has the anchor id.
19
+ for (let i = providers.length - 1; i >= 0; i--) {
20
+ const p = providers[i];
21
+ try {
22
+ if (p?.anchors?.has(anchorId)) return p;
23
+ } catch {
24
+ // ignore
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function findAllProvidersForAnchorId(
31
+ anchorId: string | null | undefined
32
+ ): ProviderEntry[] {
33
+ const matches: ProviderEntry[] = [];
34
+ if (!anchorId) return matches;
35
+ for (let i = 0; i < providers.length; i++) {
36
+ const p = providers[i];
37
+ try {
38
+ if (p?.anchors?.has(anchorId)) matches.push(p);
39
+ } catch {
40
+ // ignore
41
+ }
42
+ }
43
+ return matches;
44
+ }
45
+
@@ -0,0 +1,21 @@
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.getSnapshot().isOpen,
17
+ }),
18
+ [actions.open, actions.close, state]
19
+ );
20
+ }
21
+
@@ -0,0 +1,24 @@
1
+ import { useContext, useMemo } from "react";
2
+ import { AnchoredMenuActionsContext } from "../core/context";
3
+ import type { OpenMenuOptions } from "../types";
4
+
5
+ /**
6
+ * Stable actions-only hook.
7
+ * Components using this won't re-render when menu state changes.
8
+ */
9
+ export function useAnchoredMenuActions(): {
10
+ open: (options: OpenMenuOptions) => void;
11
+ close: () => void;
12
+ } {
13
+ const actions = useContext(AnchoredMenuActionsContext);
14
+ if (!actions) throw new Error("AnchoredMenuProvider is missing");
15
+
16
+ return useMemo(
17
+ () => ({
18
+ open: actions.open,
19
+ close: actions.close,
20
+ }),
21
+ [actions.open, actions.close]
22
+ );
23
+ }
24
+
@@ -0,0 +1,24 @@
1
+ import { useContext, useSyncExternalStore } from "react";
2
+ import { AnchoredMenuStateContext } from "../core/context";
3
+ import type { MenuState } from "../types";
4
+
5
+ /**
6
+ * State selector hook.
7
+ *
8
+ * Usage:
9
+ * const isOpen = useAnchoredMenuState(s => s.isOpen)
10
+ */
11
+ export function useAnchoredMenuState<T = MenuState>(
12
+ selector: (state: MenuState) => T = ((s) => s) as any
13
+ ): T {
14
+ const store = useContext(AnchoredMenuStateContext);
15
+ if (!store) throw new Error("AnchoredMenuProvider is missing");
16
+
17
+ const getSelectedSnapshot = () => selector(store.getSnapshot());
18
+ return useSyncExternalStore(
19
+ store.subscribe,
20
+ getSelectedSnapshot,
21
+ getSelectedSnapshot
22
+ );
23
+ }
24
+