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 +16 -11
- package/docs/.nojekyll +2 -0
- package/docs/google58625de8c50ff46e.html +2 -0
- package/package.json +13 -3
- package/src/hosts/ModalHost.js +106 -22
- package/src/hosts/ViewHost.js +86 -2
- package/src/utils/position.js +10 -2
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
|
-
|
|
164
|
+
<Pressable
|
|
160
165
|
onPress={() =>
|
|
161
|
-
|
|
166
|
+
open({
|
|
162
167
|
id: "modal-menu",
|
|
163
168
|
host: "view",
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
</Pressable>
|
|
174
|
+
</View>
|
|
175
|
+
),
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
>
|
|
174
179
|
<Text>Open menu</Text>
|
|
175
|
-
|
|
176
|
-
|
|
180
|
+
</Pressable>
|
|
181
|
+
</MenuAnchor>
|
|
177
182
|
|
|
178
183
|
<Pressable onPress={() => setVisible(false)}>
|
|
179
184
|
<Text>Close modal</Text>
|
package/docs/.nojekyll
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-anchored-menu",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Headless anchored menu/popover for React Native with stable measurement (view host
|
|
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
|
-
"
|
|
32
|
+
"dropdown",
|
|
33
|
+
"tooltip",
|
|
34
|
+
"overlay",
|
|
35
|
+
"anchor",
|
|
36
|
+
"positioning"
|
|
27
37
|
],
|
|
28
38
|
"license": "MIT",
|
|
29
39
|
"peerDependencies": {
|
package/src/hosts/ModalHost.js
CHANGED
|
@@ -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
|
-
? {
|
|
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
|
-
|
|
233
|
+
>
|
|
234
|
+
{/* Tap outside to dismiss */}
|
|
151
235
|
<Pressable style={{ flex: 1 }} onPress={actions.close}>
|
|
152
236
|
{visible && !!anchorInHost && !!position ? (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
241
|
+
top: position.top,
|
|
242
|
+
left: position.left,
|
|
159
243
|
opacity: needsInitialMeasure ? 0 : 1,
|
|
160
|
-
|
|
244
|
+
}}
|
|
161
245
|
pointerEvents={needsInitialMeasure ? "none" : "auto"}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
177
|
-
|
|
260
|
+
: req.content}
|
|
261
|
+
</View>
|
|
178
262
|
) : null}
|
|
179
|
-
|
|
263
|
+
</Pressable>
|
|
180
264
|
</View>
|
|
181
265
|
</Modal>
|
|
182
266
|
);
|
package/src/hosts/ViewHost.js
CHANGED
|
@@ -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
|
-
? {
|
|
195
|
+
? {
|
|
196
|
+
width: hostSize.width,
|
|
197
|
+
height: hostSize.height - keyboardHeight, // Adjust viewport for keyboard
|
|
198
|
+
}
|
|
115
199
|
: undefined;
|
|
116
200
|
|
|
117
201
|
return computeMenuPosition({
|
package/src/utils/position.js
CHANGED
|
@@ -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
|
-
//
|
|
28
|
+
// RTL-aware alignment: flip start/end when RTL is enabled
|
|
29
29
|
if (rtlAware && I18nManager.isRTL) {
|
|
30
|
-
//
|
|
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);
|