react-native-anchored-menu 0.0.10 → 0.0.12

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/README.md CHANGED
@@ -11,6 +11,11 @@ A **headless, anchor-based menu / popover system for React Native** designed to
11
11
  This library focuses on **correct measurement and positioning**, not UI.
12
12
  You fully control how the menu looks and behaves.
13
13
 
14
+ ## 🔎 Links
15
+
16
+ - **GitHub**: `https://github.com/mahmoudelfekygithub/react-native-anchored-menu`
17
+ - **npm**: `https://www.npmjs.com/package/react-native-anchored-menu`
18
+
14
19
  ---
15
20
 
16
21
  ## 🎬 Demo
@@ -156,24 +161,24 @@ export function ExampleModalMenu() {
156
161
  <AnchoredMenuLayer>
157
162
  <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
158
163
  <MenuAnchor id="modal-menu">
159
- <Pressable
164
+ <Pressable
160
165
  onPress={() =>
161
- open({
166
+ open({
162
167
  id: "modal-menu",
163
168
  host: "view",
164
- render: ({ close }) => (
169
+ render: ({ close }) => (
165
170
  <View style={{ backgroundColor: "#111", padding: 12, borderRadius: 8 }}>
166
171
  <Pressable onPress={close}>
167
172
  <Text style={{ color: "#fff" }}>Action</Text>
168
- </Pressable>
169
- </View>
170
- ),
171
- })
172
- }
173
- >
173
+ </Pressable>
174
+ </View>
175
+ ),
176
+ })
177
+ }
178
+ >
174
179
  <Text>Open menu</Text>
175
- </Pressable>
176
- </MenuAnchor>
180
+ </Pressable>
181
+ </MenuAnchor>
177
182
 
178
183
  <Pressable onPress={() => setVisible(false)}>
179
184
  <Text>Close modal</Text>
package/docs/.nojekyll ADDED
@@ -0,0 +1,2 @@
1
+
2
+
@@ -0,0 +1,2 @@
1
+ google-site-verification: google58625de8c50ff46e.html
2
+
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "react-native-anchored-menu",
3
- "version": "0.0.10",
4
- "description": "Headless anchored menu/popover for React Native with stable measurement (view host by default).",
3
+ "version": "0.0.12",
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",
7
7
  "url": "git+https://github.com/mahmoudelfekygithub/react-native-anchored-menu.git"
@@ -15,15 +15,25 @@
15
15
  "files": [
16
16
  "src",
17
17
  "assets",
18
+ "docs",
18
19
  "README.md",
19
20
  "LICENSE"
20
21
  ],
21
22
  "keywords": [
22
23
  "react-native",
24
+ "react native",
23
25
  "context-menu",
26
+ "contextmenu",
24
27
  "popover",
28
+ "popover-menu",
29
+ "anchored",
30
+ "anchored-menu",
25
31
  "menu",
26
- "anchor"
32
+ "dropdown",
33
+ "tooltip",
34
+ "overlay",
35
+ "anchor",
36
+ "positioning"
27
37
  ],
28
38
  "license": "MIT",
29
39
  "peerDependencies": {
@@ -1,5 +1,5 @@
1
1
  import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
2
- import { Modal, Platform, Pressable, View } from "react-native";
2
+ import { Keyboard, Modal, Platform, Pressable, View } from "react-native";
3
3
  import {
4
4
  AnchoredMenuActionsContext,
5
5
  AnchoredMenuStateContext,
@@ -31,7 +31,9 @@ export function ModalHost() {
31
31
  const [anchorWin, setAnchorWin] = useState(null);
32
32
  const [hostWin, setHostWin] = useState(null);
33
33
  const [menuSize, setMenuSize] = useState({ width: 0, height: 0 });
34
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
34
35
  const measureCacheRef = useRef(new Map()); // id -> { t, anchorWin, hostWin }
36
+ const remeasureTimeoutRef = useRef(null);
35
37
 
36
38
  useEffect(() => {
37
39
  if (!req) {
@@ -93,6 +95,85 @@ export function ModalHost() {
93
95
  };
94
96
  }, [req?.id, req, actions.anchors]);
95
97
 
98
+ // Re-measure when keyboard shows/hides
99
+ useEffect(() => {
100
+ if (!req) return;
101
+
102
+ const showSubscription = Keyboard.addListener("keyboardDidShow", (e) => {
103
+ setKeyboardHeight(e.endCoordinates.height);
104
+ // Debounce re-measurement to avoid multiple rapid calls
105
+ if (remeasureTimeoutRef.current) {
106
+ clearTimeout(remeasureTimeoutRef.current);
107
+ }
108
+ remeasureTimeoutRef.current = setTimeout(async () => {
109
+ if (!req || !hostRef.current) return;
110
+ const refObj = actions.anchors.get(req.id);
111
+ if (!refObj) return;
112
+
113
+ const strategy = req?.measurement ?? "stable";
114
+ const tries =
115
+ typeof req?.measurementTries === "number" ? req.measurementTries : 8;
116
+ const measure =
117
+ strategy === "fast" ? measureInWindowFast : measureInWindowStable;
118
+
119
+ const [a, h] = await Promise.all([
120
+ measure(refObj, strategy === "stable" ? { tries } : undefined),
121
+ measure(hostRef, strategy === "stable" ? { tries } : undefined),
122
+ ]);
123
+
124
+ const nextAnchorWin = applyAnchorMargins(a, refObj);
125
+ setAnchorWin(nextAnchorWin);
126
+ setHostWin(h);
127
+ measureCacheRef.current.set(req.id, {
128
+ t: Date.now(),
129
+ anchorWin: nextAnchorWin,
130
+ hostWin: h,
131
+ });
132
+ }, 100);
133
+ });
134
+
135
+ const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
136
+ setKeyboardHeight(0);
137
+ // Re-measure when keyboard hides
138
+ if (remeasureTimeoutRef.current) {
139
+ clearTimeout(remeasureTimeoutRef.current);
140
+ }
141
+ remeasureTimeoutRef.current = setTimeout(async () => {
142
+ if (!req || !hostRef.current) return;
143
+ const refObj = actions.anchors.get(req.id);
144
+ if (!refObj) return;
145
+
146
+ const strategy = req?.measurement ?? "stable";
147
+ const tries =
148
+ typeof req?.measurementTries === "number" ? req.measurementTries : 8;
149
+ const measure =
150
+ strategy === "fast" ? measureInWindowFast : measureInWindowStable;
151
+
152
+ const [a, h] = await Promise.all([
153
+ measure(refObj, strategy === "stable" ? { tries } : undefined),
154
+ measure(hostRef, strategy === "stable" ? { tries } : undefined),
155
+ ]);
156
+
157
+ const nextAnchorWin = applyAnchorMargins(a, refObj);
158
+ setAnchorWin(nextAnchorWin);
159
+ setHostWin(h);
160
+ measureCacheRef.current.set(req.id, {
161
+ t: Date.now(),
162
+ anchorWin: nextAnchorWin,
163
+ hostWin: h,
164
+ });
165
+ }, 100);
166
+ });
167
+
168
+ return () => {
169
+ showSubscription.remove();
170
+ hideSubscription.remove();
171
+ if (remeasureTimeoutRef.current) {
172
+ clearTimeout(remeasureTimeoutRef.current);
173
+ }
174
+ };
175
+ }, [req?.id, req, actions.anchors]);
176
+
96
177
  // window coords -> host coords (avoids status bar / inset mismatches)
97
178
  const anchorInHost = useMemo(() => {
98
179
  if (!anchorWin || !hostWin) return null;
@@ -107,7 +188,10 @@ export function ModalHost() {
107
188
  if (!req || !anchorInHost) return null;
108
189
  const viewport =
109
190
  hostSize.width && hostSize.height
110
- ? { width: hostSize.width, height: hostSize.height }
191
+ ? {
192
+ width: hostSize.width,
193
+ height: hostSize.height - keyboardHeight, // Adjust viewport for keyboard
194
+ }
111
195
  : undefined;
112
196
  return computeMenuPosition({
113
197
  anchor: anchorInHost,
@@ -146,37 +230,37 @@ export function ModalHost() {
146
230
  setHostSize({ width, height });
147
231
  }
148
232
  }}
149
- >
150
- {/* Tap outside to dismiss */}
233
+ >
234
+ {/* Tap outside to dismiss */}
151
235
  <Pressable style={{ flex: 1 }} onPress={actions.close}>
152
236
  {visible && !!anchorInHost && !!position ? (
153
- <View
154
- style={{
155
- position: "absolute",
237
+ <View
238
+ style={{
239
+ position: "absolute",
156
240
  // Keep in-place so layout runs on iOS; hide visually until measured to avoid flicker.
157
- top: position.top,
158
- left: position.left,
241
+ top: position.top,
242
+ left: position.left,
159
243
  opacity: needsInitialMeasure ? 0 : 1,
160
- }}
244
+ }}
161
245
  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"
246
+ onStartShouldSetResponder={() => true}
247
+ onLayout={(e) => {
248
+ const { width, height } = e.nativeEvent.layout;
249
+ if (width !== menuSize.width || height !== menuSize.height) {
250
+ setMenuSize({ width, height });
251
+ }
252
+ }}
253
+ >
254
+ {typeof req.render === "function"
171
255
  ? req.render({
172
256
  close: actions.close,
173
257
  anchor: anchorWin,
174
258
  anchorInHost,
175
259
  })
176
- : req.content}
177
- </View>
260
+ : req.content}
261
+ </View>
178
262
  ) : null}
179
- </Pressable>
263
+ </Pressable>
180
264
  </View>
181
265
  </Modal>
182
266
  );
@@ -1,5 +1,5 @@
1
1
  import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
2
- import { Pressable, View } from "react-native";
2
+ import { Keyboard, Pressable, View } from "react-native";
3
3
  import {
4
4
  AnchoredMenuActionsContext,
5
5
  AnchoredMenuStateContext,
@@ -38,7 +38,9 @@ export function ViewHost() {
38
38
  const [anchorWin, setAnchorWin] = useState(null);
39
39
  const [hostWin, setHostWin] = useState(null);
40
40
  const [menuSize, setMenuSize] = useState({ width: 0, height: 0 });
41
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
41
42
  const measureCacheRef = useRef(new Map()); // id -> { t, anchorWin, hostWin }
43
+ const remeasureTimeoutRef = useRef(null);
42
44
 
43
45
  useEffect(() => {
44
46
  if (!req) {
@@ -98,6 +100,85 @@ export function ViewHost() {
98
100
  };
99
101
  }, [req?.id, req, actions.anchors]);
100
102
 
103
+ // Re-measure when keyboard shows/hides
104
+ useEffect(() => {
105
+ if (!req) return;
106
+
107
+ const showSubscription = Keyboard.addListener("keyboardDidShow", (e) => {
108
+ setKeyboardHeight(e.endCoordinates.height);
109
+ // Debounce re-measurement to avoid multiple rapid calls
110
+ if (remeasureTimeoutRef.current) {
111
+ clearTimeout(remeasureTimeoutRef.current);
112
+ }
113
+ remeasureTimeoutRef.current = setTimeout(async () => {
114
+ if (!req || !hostRef.current) return;
115
+ const refObj = actions.anchors.get(req.id);
116
+ if (!refObj) return;
117
+
118
+ const strategy = req?.measurement ?? "stable";
119
+ const tries =
120
+ typeof req?.measurementTries === "number" ? req.measurementTries : 8;
121
+ const measure =
122
+ strategy === "fast" ? measureInWindowFast : measureInWindowStable;
123
+
124
+ const [a, h] = await Promise.all([
125
+ measure(refObj, strategy === "stable" ? { tries } : undefined),
126
+ measure(hostRef, strategy === "stable" ? { tries } : undefined),
127
+ ]);
128
+
129
+ const nextAnchorWin = applyAnchorMargins(a, refObj);
130
+ setAnchorWin(nextAnchorWin);
131
+ setHostWin(h);
132
+ measureCacheRef.current.set(req.id, {
133
+ t: Date.now(),
134
+ anchorWin: nextAnchorWin,
135
+ hostWin: h,
136
+ });
137
+ }, 100);
138
+ });
139
+
140
+ const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
141
+ setKeyboardHeight(0);
142
+ // Re-measure when keyboard hides
143
+ if (remeasureTimeoutRef.current) {
144
+ clearTimeout(remeasureTimeoutRef.current);
145
+ }
146
+ remeasureTimeoutRef.current = setTimeout(async () => {
147
+ if (!req || !hostRef.current) return;
148
+ const refObj = actions.anchors.get(req.id);
149
+ if (!refObj) return;
150
+
151
+ const strategy = req?.measurement ?? "stable";
152
+ const tries =
153
+ typeof req?.measurementTries === "number" ? req.measurementTries : 8;
154
+ const measure =
155
+ strategy === "fast" ? measureInWindowFast : measureInWindowStable;
156
+
157
+ const [a, h] = await Promise.all([
158
+ measure(refObj, strategy === "stable" ? { tries } : undefined),
159
+ measure(hostRef, strategy === "stable" ? { tries } : undefined),
160
+ ]);
161
+
162
+ const nextAnchorWin = applyAnchorMargins(a, refObj);
163
+ setAnchorWin(nextAnchorWin);
164
+ setHostWin(h);
165
+ measureCacheRef.current.set(req.id, {
166
+ t: Date.now(),
167
+ anchorWin: nextAnchorWin,
168
+ hostWin: h,
169
+ });
170
+ }, 100);
171
+ });
172
+
173
+ return () => {
174
+ showSubscription.remove();
175
+ hideSubscription.remove();
176
+ if (remeasureTimeoutRef.current) {
177
+ clearTimeout(remeasureTimeoutRef.current);
178
+ }
179
+ };
180
+ }, [req?.id, req, actions.anchors]);
181
+
101
182
  const anchorInHost = useMemo(() => {
102
183
  if (!anchorWin || !hostWin) return null;
103
184
  return {
@@ -111,7 +192,10 @@ export function ViewHost() {
111
192
  if (!req || !anchorInHost) return null;
112
193
  const viewport =
113
194
  hostSize.width && hostSize.height
114
- ? { width: hostSize.width, height: hostSize.height }
195
+ ? {
196
+ width: hostSize.width,
197
+ height: hostSize.height - keyboardHeight, // Adjust viewport for keyboard
198
+ }
115
199
  : undefined;
116
200
 
117
201
  return computeMenuPosition({
@@ -25,9 +25,17 @@ export function computeMenuPosition({
25
25
  if (align === "center" && mW) left = anchor.pageX + anchor.width / 2 - mW / 2;
26
26
  if (align === "end" && mW) left = anchor.pageX + anchor.width - mW;
27
27
 
28
- // Optional RTL hook (kept as no-op unless you customize)
28
+ // RTL-aware alignment: flip start/end when RTL is enabled
29
29
  if (rtlAware && I18nManager.isRTL) {
30
- // no-op by default
30
+ // In RTL, "start" means right side, "end" means left side
31
+ if (align === "start" && mW) {
32
+ // Align to right edge of anchor
33
+ left = anchor.pageX + anchor.width - mW;
34
+ } else if (align === "end" && mW) {
35
+ // Align to left edge of anchor
36
+ left = anchor.pageX;
37
+ }
38
+ // "center" stays the same in RTL
31
39
  }
32
40
 
33
41
  if (mW) left = clamp(left, margin, SW - mW - margin);