react-native-anchored-menu 0.0.14 → 0.0.16
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 +1 -1
- package/src/components/MenuAnchor.tsx +6 -1
- package/src/core/provider.tsx +39 -4
- package/src/hooks/useAnchoredMenu.ts +12 -4
- package/src/hooks/useAnchoredMenuActions.ts +6 -1
- package/src/hooks/useAnchoredMenuState.ts +32 -2
- package/src/hosts/ModalHost.tsx +88 -9
- package/src/hosts/ViewHost.tsx +88 -9
- package/src/types.ts +2 -0
- package/src/utils/measure.ts +21 -11
- package/src/utils/validation.ts +55 -0
- package/src/components/AnchoredMenuLayer.js +0 -26
- package/src/components/MenuAnchor.js +0 -55
- package/src/core/context.js +0 -10
- package/src/core/provider.js +0 -192
- package/src/core/providerRegistry.js +0 -47
- package/src/hooks/useAnchoredMenu.js +0 -20
- package/src/hooks/useAnchoredMenuActions.js +0 -19
- package/src/hooks/useAnchoredMenuState.js +0 -20
- package/src/hosts/ModalHost.js +0 -267
- package/src/hosts/ViewHost.js +0 -271
- package/src/index.js +0 -13
- package/src/utils/measure.js +0 -87
- package/src/utils/position.js +0 -71
- package/src/utils/runtime.js +0 -13
package/package.json
CHANGED
|
@@ -31,7 +31,12 @@ function extractMarginsFromChild(children: React.ReactNode): AnchorMargins {
|
|
|
31
31
|
|
|
32
32
|
export function MenuAnchor({ id, children }: MenuAnchorProps) {
|
|
33
33
|
const actions = useContext(AnchoredMenuActionsContext);
|
|
34
|
-
if (!actions)
|
|
34
|
+
if (!actions) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"[react-native-anchored-menu] MenuAnchor must be used within an AnchoredMenuProvider. " +
|
|
37
|
+
"Make sure to wrap your component tree with <AnchoredMenuProvider> or <AnchoredMenuLayer>."
|
|
38
|
+
);
|
|
39
|
+
}
|
|
35
40
|
|
|
36
41
|
const ref = useRef<View>(null) as AnchorRefObject;
|
|
37
42
|
|
package/src/core/provider.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
|
-
import { Platform } from "react-native";
|
|
2
|
+
import { AppState, Platform } from "react-native";
|
|
3
3
|
import {
|
|
4
4
|
AnchoredMenuActionsContext,
|
|
5
5
|
AnchoredMenuStateContext,
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
* Provider config
|
|
26
26
|
* - defaultHost: which host to use when `open()` doesn't specify one (default: "view")
|
|
27
27
|
* - autoHost: automatically mounts the host implementation (default: true)
|
|
28
|
+
* - autoCloseOnBackground: automatically close menus when app goes to background (default: true)
|
|
28
29
|
*/
|
|
29
30
|
export function AnchoredMenuProvider({
|
|
30
31
|
children,
|
|
@@ -32,6 +33,7 @@ export function AnchoredMenuProvider({
|
|
|
32
33
|
host,
|
|
33
34
|
defaultHost = (host ?? "view") as HostType,
|
|
34
35
|
autoHost = true,
|
|
36
|
+
autoCloseOnBackground = true,
|
|
35
37
|
}: AnchoredMenuProviderProps) {
|
|
36
38
|
const anchorsRef = useRef(new Map<string, any>()); // id -> ref
|
|
37
39
|
const pendingOpenRafRef = useRef<number | null>(null);
|
|
@@ -110,9 +112,33 @@ export function AnchoredMenuProvider({
|
|
|
110
112
|
// Register this provider globally so parents can route `open({ id })` to the correct layer.
|
|
111
113
|
useEffect(() => {
|
|
112
114
|
const entry = { anchors: anchorsRef.current, setRequest };
|
|
113
|
-
|
|
115
|
+
const unregister = registerProvider(entry);
|
|
116
|
+
return () => {
|
|
117
|
+
// Close menu if open when provider unmounts
|
|
118
|
+
if (storeRef.current?.getSnapshot().isOpen) {
|
|
119
|
+
setRequest(null);
|
|
120
|
+
}
|
|
121
|
+
unregister();
|
|
122
|
+
};
|
|
114
123
|
}, [setRequest]);
|
|
115
124
|
|
|
125
|
+
// Auto-close menu when app goes to background to avoid weird states
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!autoCloseOnBackground) return;
|
|
128
|
+
|
|
129
|
+
const subscription = AppState.addEventListener("change", (nextAppState: string) => {
|
|
130
|
+
if (nextAppState === "background" || nextAppState === "inactive") {
|
|
131
|
+
if (storeRef.current?.getSnapshot().isOpen) {
|
|
132
|
+
setRequest(null);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
subscription.remove();
|
|
139
|
+
};
|
|
140
|
+
}, [setRequest, autoCloseOnBackground]);
|
|
141
|
+
|
|
116
142
|
const open = useCallback((payload: OpenMenuOptions) => {
|
|
117
143
|
// Defer by default to avoid "open tap" being interpreted as an outside press
|
|
118
144
|
// when a host mounts a Pressable backdrop during the active gesture.
|
|
@@ -138,8 +164,18 @@ export function AnchoredMenuProvider({
|
|
|
138
164
|
);
|
|
139
165
|
}
|
|
140
166
|
const target = findProviderForAnchorId(anchorId);
|
|
141
|
-
if (target && target.setRequest)
|
|
167
|
+
if (target && target.setRequest) {
|
|
142
168
|
return target.setRequest(payload as MenuRequest);
|
|
169
|
+
}
|
|
170
|
+
// Anchor not found in any provider
|
|
171
|
+
if (__DEV__) {
|
|
172
|
+
// eslint-disable-next-line no-console
|
|
173
|
+
console.warn(
|
|
174
|
+
`[react-native-anchored-menu] Anchor with id="${anchorId}" not found in any provider. ` +
|
|
175
|
+
"Make sure the MenuAnchor is mounted and the id matches."
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return; // Don't open menu if anchor doesn't exist
|
|
143
179
|
}
|
|
144
180
|
|
|
145
181
|
setRequest(payload as MenuRequest);
|
|
@@ -191,4 +227,3 @@ export function AnchoredMenuProvider({
|
|
|
191
227
|
</AnchoredMenuActionsContext.Provider>
|
|
192
228
|
);
|
|
193
229
|
}
|
|
194
|
-
|
|
@@ -3,19 +3,27 @@ import {
|
|
|
3
3
|
AnchoredMenuActionsContext,
|
|
4
4
|
AnchoredMenuStateContext,
|
|
5
5
|
} from "../core/context";
|
|
6
|
+
import { useAnchoredMenuState } from "./useAnchoredMenuState";
|
|
6
7
|
|
|
7
8
|
export function useAnchoredMenu() {
|
|
8
9
|
const actions = useContext(AnchoredMenuActionsContext);
|
|
9
10
|
const state = useContext(AnchoredMenuStateContext);
|
|
10
|
-
if (!actions || !state)
|
|
11
|
+
if (!actions || !state) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"[react-native-anchored-menu] useAnchoredMenu must be used within an AnchoredMenuProvider. " +
|
|
14
|
+
"Make sure to wrap your component tree with <AnchoredMenuProvider> or <AnchoredMenuLayer>."
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Use useAnchoredMenuState to properly subscribe to isOpen changes
|
|
19
|
+
const isOpen = useAnchoredMenuState((s) => s.isOpen);
|
|
11
20
|
|
|
12
21
|
return useMemo(
|
|
13
22
|
() => ({
|
|
14
23
|
open: actions.open,
|
|
15
24
|
close: actions.close,
|
|
16
|
-
isOpen
|
|
25
|
+
isOpen,
|
|
17
26
|
}),
|
|
18
|
-
[actions.open, actions.close,
|
|
27
|
+
[actions.open, actions.close, isOpen]
|
|
19
28
|
);
|
|
20
29
|
}
|
|
21
|
-
|
|
@@ -11,7 +11,12 @@ export function useAnchoredMenuActions(): {
|
|
|
11
11
|
close: () => void;
|
|
12
12
|
} {
|
|
13
13
|
const actions = useContext(AnchoredMenuActionsContext);
|
|
14
|
-
if (!actions)
|
|
14
|
+
if (!actions) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"[react-native-anchored-menu] useAnchoredMenuActions must be used within an AnchoredMenuProvider. " +
|
|
17
|
+
"Make sure to wrap your component tree with <AnchoredMenuProvider> or <AnchoredMenuLayer>."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
15
20
|
|
|
16
21
|
return useMemo(
|
|
17
22
|
() => ({
|
|
@@ -1,7 +1,32 @@
|
|
|
1
|
-
import { useContext,
|
|
1
|
+
import { useContext, useEffect, useState } from "react";
|
|
2
2
|
import { AnchoredMenuStateContext } from "../core/context";
|
|
3
3
|
import type { MenuState } from "../types";
|
|
4
4
|
|
|
5
|
+
// Polyfill for useSyncExternalStore for older React versions
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
let useSyncExternalStore: any;
|
|
8
|
+
try {
|
|
9
|
+
// Try to import from react (React 18+)
|
|
10
|
+
const react = require("react");
|
|
11
|
+
if (react.useSyncExternalStore) {
|
|
12
|
+
useSyncExternalStore = react.useSyncExternalStore;
|
|
13
|
+
} else {
|
|
14
|
+
throw new Error("Not available");
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// Fallback for older React versions - use useState + useEffect
|
|
18
|
+
useSyncExternalStore = (subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => any) => {
|
|
19
|
+
const [state, setState] = useState(() => getSnapshot());
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const unsubscribe = subscribe(() => {
|
|
22
|
+
setState(getSnapshot());
|
|
23
|
+
});
|
|
24
|
+
return unsubscribe;
|
|
25
|
+
}, [subscribe, getSnapshot]);
|
|
26
|
+
return state;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
5
30
|
/**
|
|
6
31
|
* State selector hook.
|
|
7
32
|
*
|
|
@@ -12,7 +37,12 @@ export function useAnchoredMenuState<T = MenuState>(
|
|
|
12
37
|
selector: (state: MenuState) => T = ((s: MenuState) => s) as (state: MenuState) => T
|
|
13
38
|
): T {
|
|
14
39
|
const store = useContext(AnchoredMenuStateContext);
|
|
15
|
-
if (!store)
|
|
40
|
+
if (!store) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"[react-native-anchored-menu] useAnchoredMenuState must be used within an AnchoredMenuProvider. " +
|
|
43
|
+
"Make sure to wrap your component tree with <AnchoredMenuProvider> or <AnchoredMenuLayer>."
|
|
44
|
+
);
|
|
45
|
+
}
|
|
16
46
|
|
|
17
47
|
const getSelectedSnapshot = () => selector(store.getSnapshot());
|
|
18
48
|
return useSyncExternalStore(
|
package/src/hosts/ModalHost.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
measureInWindowStable,
|
|
12
12
|
} from "../utils/measure";
|
|
13
13
|
import { computeMenuPosition } from "../utils/position";
|
|
14
|
+
import { isValidMeasurement, isValidMenuSize } from "../utils/validation";
|
|
14
15
|
import { isFabricEnabled } from "../utils/runtime";
|
|
15
16
|
import type {
|
|
16
17
|
AnchorMeasurement,
|
|
@@ -27,7 +28,12 @@ interface MeasureCache {
|
|
|
27
28
|
export function ModalHost() {
|
|
28
29
|
const actions = useContext(AnchoredMenuActionsContext);
|
|
29
30
|
const store = useContext(AnchoredMenuStateContext);
|
|
30
|
-
if (!actions || !store)
|
|
31
|
+
if (!actions || !store) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"[react-native-anchored-menu] ModalHost must be used within an AnchoredMenuProvider. " +
|
|
34
|
+
"This is usually handled automatically by AnchoredMenuProvider when autoHost=true."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
31
37
|
|
|
32
38
|
const activeHost = useAnchoredMenuState((s) => s.activeHost);
|
|
33
39
|
if (activeHost !== "modal") return null;
|
|
@@ -71,7 +77,7 @@ export function ModalHost() {
|
|
|
71
77
|
let cancelled = false;
|
|
72
78
|
|
|
73
79
|
async function run() {
|
|
74
|
-
if (!req) return;
|
|
80
|
+
if (!req || !actions) return;
|
|
75
81
|
await new Promise((r) => requestAnimationFrame(r));
|
|
76
82
|
await new Promise((r) => requestAnimationFrame(r));
|
|
77
83
|
|
|
@@ -86,11 +92,33 @@ export function ModalHost() {
|
|
|
86
92
|
|
|
87
93
|
const [a, h] = await Promise.all([
|
|
88
94
|
measure(refObj, strategy === "stable" ? { tries } : undefined),
|
|
89
|
-
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
90
97
|
]);
|
|
91
98
|
|
|
92
99
|
if (cancelled) return;
|
|
100
|
+
|
|
101
|
+
// Validate measurements before using them
|
|
102
|
+
if (!isValidMeasurement(a) || !isValidMeasurement(h)) {
|
|
103
|
+
if (__DEV__) {
|
|
104
|
+
console.warn(
|
|
105
|
+
`[react-native-anchored-menu] Invalid measurement for anchor "${req.id}". ` +
|
|
106
|
+
"Menu will not be positioned correctly. This can happen during layout transitions."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
93
112
|
const nextAnchorWin = applyAnchorMargins(a, refObj);
|
|
113
|
+
if (!isValidMeasurement(nextAnchorWin)) {
|
|
114
|
+
if (__DEV__) {
|
|
115
|
+
console.warn(
|
|
116
|
+
`[react-native-anchored-menu] Invalid anchor measurement after applying margins for "${req.id}".`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
setAnchorWin(nextAnchorWin);
|
|
95
123
|
setHostWin(h);
|
|
96
124
|
measureCacheRef.current.set(req.id, {
|
|
@@ -117,7 +145,7 @@ export function ModalHost() {
|
|
|
117
145
|
clearTimeout(remeasureTimeoutRef.current);
|
|
118
146
|
}
|
|
119
147
|
remeasureTimeoutRef.current = setTimeout(async () => {
|
|
120
|
-
if (!req || !hostRef.current) return;
|
|
148
|
+
if (!req || !hostRef.current || !actions) return;
|
|
121
149
|
const refObj = actions.anchors.get(req.id);
|
|
122
150
|
if (!refObj) return;
|
|
123
151
|
|
|
@@ -129,10 +157,30 @@ export function ModalHost() {
|
|
|
129
157
|
|
|
130
158
|
const [a, h] = await Promise.all([
|
|
131
159
|
measure(refObj, strategy === "stable" ? { tries } : undefined),
|
|
132
|
-
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
161
|
+
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
133
162
|
]);
|
|
134
163
|
|
|
164
|
+
// Validate measurements before using them
|
|
165
|
+
if (!isValidMeasurement(a) || !isValidMeasurement(h)) {
|
|
166
|
+
if (__DEV__) {
|
|
167
|
+
console.warn(
|
|
168
|
+
`[react-native-anchored-menu] Invalid measurement during keyboard show for anchor "${req.id}".`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
135
174
|
const nextAnchorWin = applyAnchorMargins(a, refObj);
|
|
175
|
+
if (!isValidMeasurement(nextAnchorWin)) {
|
|
176
|
+
if (__DEV__) {
|
|
177
|
+
console.warn(
|
|
178
|
+
`[react-native-anchored-menu] Invalid anchor measurement after applying margins during keyboard show for "${req.id}".`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
136
184
|
setAnchorWin(nextAnchorWin);
|
|
137
185
|
setHostWin(h);
|
|
138
186
|
measureCacheRef.current.set(req.id, {
|
|
@@ -150,7 +198,7 @@ export function ModalHost() {
|
|
|
150
198
|
clearTimeout(remeasureTimeoutRef.current);
|
|
151
199
|
}
|
|
152
200
|
remeasureTimeoutRef.current = setTimeout(async () => {
|
|
153
|
-
if (!req || !hostRef.current) return;
|
|
201
|
+
if (!req || !hostRef.current || !actions) return;
|
|
154
202
|
const refObj = actions.anchors.get(req.id);
|
|
155
203
|
if (!refObj) return;
|
|
156
204
|
|
|
@@ -162,10 +210,30 @@ export function ModalHost() {
|
|
|
162
210
|
|
|
163
211
|
const [a, h] = await Promise.all([
|
|
164
212
|
measure(refObj, strategy === "stable" ? { tries } : undefined),
|
|
165
|
-
|
|
213
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
214
|
+
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
166
215
|
]);
|
|
167
216
|
|
|
217
|
+
// Validate measurements before using them
|
|
218
|
+
if (!isValidMeasurement(a) || !isValidMeasurement(h)) {
|
|
219
|
+
if (__DEV__) {
|
|
220
|
+
console.warn(
|
|
221
|
+
`[react-native-anchored-menu] Invalid measurement during keyboard hide for anchor "${req.id}".`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
168
227
|
const nextAnchorWin = applyAnchorMargins(a, refObj);
|
|
228
|
+
if (!isValidMeasurement(nextAnchorWin)) {
|
|
229
|
+
if (__DEV__) {
|
|
230
|
+
console.warn(
|
|
231
|
+
`[react-native-anchored-menu] Invalid anchor measurement after applying margins during keyboard hide for "${req.id}".`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
169
237
|
setAnchorWin(nextAnchorWin);
|
|
170
238
|
setHostWin(h);
|
|
171
239
|
measureCacheRef.current.set(req.id, {
|
|
@@ -197,6 +265,17 @@ export function ModalHost() {
|
|
|
197
265
|
|
|
198
266
|
const position = useMemo(() => {
|
|
199
267
|
if (!req || !anchorInHost) return null;
|
|
268
|
+
|
|
269
|
+
// Validate anchor measurement before computing position
|
|
270
|
+
if (!isValidMeasurement(anchorInHost)) {
|
|
271
|
+
if (__DEV__) {
|
|
272
|
+
console.warn(
|
|
273
|
+
`[react-native-anchored-menu] Cannot compute position for anchor "${req.id}": invalid measurement.`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
200
279
|
const viewport: Viewport | undefined =
|
|
201
280
|
hostSize.width && hostSize.height
|
|
202
281
|
? {
|
|
@@ -206,7 +285,7 @@ export function ModalHost() {
|
|
|
206
285
|
: undefined;
|
|
207
286
|
return computeMenuPosition({
|
|
208
287
|
anchor: anchorInHost,
|
|
209
|
-
menuSize,
|
|
288
|
+
menuSize: isValidMenuSize(menuSize) ? menuSize : null,
|
|
210
289
|
viewport,
|
|
211
290
|
placement: req.placement ?? "auto",
|
|
212
291
|
offset: req.offset ?? 8,
|
|
@@ -216,7 +295,7 @@ export function ModalHost() {
|
|
|
216
295
|
});
|
|
217
296
|
}, [req, anchorInHost, menuSize, hostSize, keyboardHeight]);
|
|
218
297
|
|
|
219
|
-
const needsInitialMeasure = menuSize
|
|
298
|
+
const needsInitialMeasure = !isValidMenuSize(menuSize);
|
|
220
299
|
|
|
221
300
|
const statusBarTranslucent =
|
|
222
301
|
req?.statusBarTranslucent ?? (Platform.OS === "android" ? false : true);
|
package/src/hosts/ViewHost.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
measureInWindowStable,
|
|
12
12
|
} from "../utils/measure";
|
|
13
13
|
import { computeMenuPosition } from "../utils/position";
|
|
14
|
+
import { isValidMeasurement, isValidMenuSize } from "../utils/validation";
|
|
14
15
|
import type {
|
|
15
16
|
AnchorMeasurement,
|
|
16
17
|
MenuSize,
|
|
@@ -35,7 +36,12 @@ interface MeasureCache {
|
|
|
35
36
|
export function ViewHost() {
|
|
36
37
|
const actions = useContext(AnchoredMenuActionsContext);
|
|
37
38
|
const store = useContext(AnchoredMenuStateContext);
|
|
38
|
-
if (!actions || !store)
|
|
39
|
+
if (!actions || !store) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"[react-native-anchored-menu] ViewHost must be used within an AnchoredMenuProvider. " +
|
|
42
|
+
"This is usually handled automatically by AnchoredMenuProvider when autoHost=true."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
39
45
|
|
|
40
46
|
const activeHost = useAnchoredMenuState((s) => s.activeHost);
|
|
41
47
|
if (activeHost !== "view") return null;
|
|
@@ -77,7 +83,7 @@ export function ViewHost() {
|
|
|
77
83
|
let cancelled = false;
|
|
78
84
|
|
|
79
85
|
async function run() {
|
|
80
|
-
if (!req || !hostRef.current) return;
|
|
86
|
+
if (!req || !hostRef.current || !actions) return;
|
|
81
87
|
await new Promise((r) => requestAnimationFrame(r));
|
|
82
88
|
|
|
83
89
|
const refObj = actions.anchors.get(req.id); // ref object
|
|
@@ -91,11 +97,33 @@ export function ViewHost() {
|
|
|
91
97
|
|
|
92
98
|
const [a, h] = await Promise.all([
|
|
93
99
|
measure(refObj, strategy === "stable" ? { tries } : undefined),
|
|
94
|
-
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
+
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
95
102
|
]);
|
|
96
103
|
|
|
97
104
|
if (cancelled) return;
|
|
105
|
+
|
|
106
|
+
// Validate measurements before using them
|
|
107
|
+
if (!isValidMeasurement(a) || !isValidMeasurement(h)) {
|
|
108
|
+
if (__DEV__) {
|
|
109
|
+
console.warn(
|
|
110
|
+
`[react-native-anchored-menu] Invalid measurement for anchor "${req.id}". ` +
|
|
111
|
+
"Menu will not be positioned correctly. This can happen during layout transitions."
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
98
117
|
const nextAnchorWin = applyAnchorMargins(a, refObj);
|
|
118
|
+
if (!isValidMeasurement(nextAnchorWin)) {
|
|
119
|
+
if (__DEV__) {
|
|
120
|
+
console.warn(
|
|
121
|
+
`[react-native-anchored-menu] Invalid anchor measurement after applying margins for "${req.id}".`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
99
127
|
setAnchorWin(nextAnchorWin);
|
|
100
128
|
setHostWin(h);
|
|
101
129
|
measureCacheRef.current.set(req.id, {
|
|
@@ -122,7 +150,7 @@ export function ViewHost() {
|
|
|
122
150
|
clearTimeout(remeasureTimeoutRef.current);
|
|
123
151
|
}
|
|
124
152
|
remeasureTimeoutRef.current = setTimeout(async () => {
|
|
125
|
-
if (!req || !hostRef.current) return;
|
|
153
|
+
if (!req || !hostRef.current || !actions) return;
|
|
126
154
|
const refObj = actions.anchors.get(req.id);
|
|
127
155
|
if (!refObj) return;
|
|
128
156
|
|
|
@@ -134,10 +162,30 @@ export function ViewHost() {
|
|
|
134
162
|
|
|
135
163
|
const [a, h] = await Promise.all([
|
|
136
164
|
measure(refObj, strategy === "stable" ? { tries } : undefined),
|
|
137
|
-
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
|
+
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
138
167
|
]);
|
|
139
168
|
|
|
169
|
+
// Validate measurements before using them
|
|
170
|
+
if (!isValidMeasurement(a) || !isValidMeasurement(h)) {
|
|
171
|
+
if (__DEV__) {
|
|
172
|
+
console.warn(
|
|
173
|
+
`[react-native-anchored-menu] Invalid measurement during keyboard show for anchor "${req.id}".`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
140
179
|
const nextAnchorWin = applyAnchorMargins(a, refObj);
|
|
180
|
+
if (!isValidMeasurement(nextAnchorWin)) {
|
|
181
|
+
if (__DEV__) {
|
|
182
|
+
console.warn(
|
|
183
|
+
`[react-native-anchored-menu] Invalid anchor measurement after applying margins during keyboard show for "${req.id}".`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
141
189
|
setAnchorWin(nextAnchorWin);
|
|
142
190
|
setHostWin(h);
|
|
143
191
|
measureCacheRef.current.set(req.id, {
|
|
@@ -155,7 +203,7 @@ export function ViewHost() {
|
|
|
155
203
|
clearTimeout(remeasureTimeoutRef.current);
|
|
156
204
|
}
|
|
157
205
|
remeasureTimeoutRef.current = setTimeout(async () => {
|
|
158
|
-
if (!req || !hostRef.current) return;
|
|
206
|
+
if (!req || !hostRef.current || !actions) return;
|
|
159
207
|
const refObj = actions.anchors.get(req.id);
|
|
160
208
|
if (!refObj) return;
|
|
161
209
|
|
|
@@ -167,10 +215,30 @@ export function ViewHost() {
|
|
|
167
215
|
|
|
168
216
|
const [a, h] = await Promise.all([
|
|
169
217
|
measure(refObj, strategy === "stable" ? { tries } : undefined),
|
|
170
|
-
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
219
|
+
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
171
220
|
]);
|
|
172
221
|
|
|
222
|
+
// Validate measurements before using them
|
|
223
|
+
if (!isValidMeasurement(a) || !isValidMeasurement(h)) {
|
|
224
|
+
if (__DEV__) {
|
|
225
|
+
console.warn(
|
|
226
|
+
`[react-native-anchored-menu] Invalid measurement during keyboard hide for anchor "${req.id}".`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
173
232
|
const nextAnchorWin = applyAnchorMargins(a, refObj);
|
|
233
|
+
if (!isValidMeasurement(nextAnchorWin)) {
|
|
234
|
+
if (__DEV__) {
|
|
235
|
+
console.warn(
|
|
236
|
+
`[react-native-anchored-menu] Invalid anchor measurement after applying margins during keyboard hide for "${req.id}".`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
174
242
|
setAnchorWin(nextAnchorWin);
|
|
175
243
|
setHostWin(h);
|
|
176
244
|
measureCacheRef.current.set(req.id, {
|
|
@@ -201,6 +269,17 @@ export function ViewHost() {
|
|
|
201
269
|
|
|
202
270
|
const position = useMemo(() => {
|
|
203
271
|
if (!req || !anchorInHost) return null;
|
|
272
|
+
|
|
273
|
+
// Validate anchor measurement before computing position
|
|
274
|
+
if (!isValidMeasurement(anchorInHost)) {
|
|
275
|
+
if (__DEV__) {
|
|
276
|
+
console.warn(
|
|
277
|
+
`[react-native-anchored-menu] Cannot compute position for anchor "${req.id}": invalid measurement.`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
204
283
|
const viewport: Viewport | undefined =
|
|
205
284
|
hostSize.width && hostSize.height
|
|
206
285
|
? {
|
|
@@ -211,7 +290,7 @@ export function ViewHost() {
|
|
|
211
290
|
|
|
212
291
|
return computeMenuPosition({
|
|
213
292
|
anchor: anchorInHost,
|
|
214
|
-
menuSize,
|
|
293
|
+
menuSize: isValidMenuSize(menuSize) ? menuSize : null,
|
|
215
294
|
viewport,
|
|
216
295
|
placement: req.placement ?? "auto",
|
|
217
296
|
offset: req.offset ?? 8,
|
|
@@ -221,7 +300,7 @@ export function ViewHost() {
|
|
|
221
300
|
});
|
|
222
301
|
}, [req, anchorInHost, menuSize, hostSize, keyboardHeight]);
|
|
223
302
|
|
|
224
|
-
const needsInitialMeasure = menuSize
|
|
303
|
+
const needsInitialMeasure = !isValidMenuSize(menuSize);
|
|
225
304
|
if (!visible) return null;
|
|
226
305
|
|
|
227
306
|
return (
|
package/src/types.ts
CHANGED
|
@@ -152,6 +152,8 @@ export interface AnchoredMenuProviderProps {
|
|
|
152
152
|
children: ReactNode;
|
|
153
153
|
defaultHost?: HostType;
|
|
154
154
|
autoHost?: boolean;
|
|
155
|
+
/** Whether to automatically close menus when app goes to background/inactive (default: true) */
|
|
156
|
+
autoCloseOnBackground?: boolean;
|
|
155
157
|
/** @deprecated Use defaultHost instead */
|
|
156
158
|
host?: HostType;
|
|
157
159
|
}
|
package/src/utils/measure.ts
CHANGED
|
@@ -4,11 +4,14 @@ import type { AnchorMeasurement, AnchorRefObject } from "../types";
|
|
|
4
4
|
const raf = (): Promise<number> => new Promise((r) => requestAnimationFrame(r));
|
|
5
5
|
|
|
6
6
|
async function measureInWindowOnce(
|
|
7
|
-
target: { current: number | null } | number | null
|
|
7
|
+
target: { current: number | null } | number | null | any
|
|
8
8
|
): Promise<AnchorMeasurement | null> {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// Handle both ref objects and direct node handles
|
|
10
|
+
const nodeHandle = typeof target === "number"
|
|
11
|
+
? target
|
|
12
|
+
: target?.current ?? target;
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
const node = findNodeHandle(nodeHandle as any);
|
|
12
15
|
if (!node) return null;
|
|
13
16
|
return await new Promise<AnchorMeasurement>((resolve) => {
|
|
14
17
|
// UIManager.measureInWindow callback signature varies by RN version
|
|
@@ -24,7 +27,7 @@ async function measureInWindowOnce(
|
|
|
24
27
|
* Useful for very simple layouts where Android/FlatList flakiness isn't a concern.
|
|
25
28
|
*/
|
|
26
29
|
export async function measureInWindowFast(
|
|
27
|
-
target: { current: number | null } | number | null
|
|
30
|
+
target: { current: number | null } | number | null | any
|
|
28
31
|
): Promise<AnchorMeasurement | null> {
|
|
29
32
|
await raf();
|
|
30
33
|
return await measureInWindowOnce(target);
|
|
@@ -39,14 +42,21 @@ export interface MeasureOptions {
|
|
|
39
42
|
* and retries until values stabilize.
|
|
40
43
|
*/
|
|
41
44
|
export async function measureInWindowStable(
|
|
42
|
-
target: { current: number | null } | number | null,
|
|
45
|
+
target: { current: number | null } | number | null | any,
|
|
43
46
|
{ tries = 8 }: MeasureOptions = {}
|
|
44
47
|
): Promise<AnchorMeasurement | null> {
|
|
45
|
-
await new Promise((r) =>
|
|
48
|
+
await new Promise<void>((r) => {
|
|
49
|
+
InteractionManager.runAfterInteractions(() => {
|
|
50
|
+
r();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
46
53
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
// Handle both ref objects and direct node handles
|
|
55
|
+
const nodeHandle = typeof target === "number"
|
|
56
|
+
? target
|
|
57
|
+
: target?.current ?? target;
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
const node = findNodeHandle(nodeHandle as any);
|
|
50
60
|
if (!node) return null;
|
|
51
61
|
|
|
52
62
|
let last: AnchorMeasurement | null = null;
|
|
@@ -77,7 +87,7 @@ export async function measureInWindowStable(
|
|
|
77
87
|
* Uses measureInWindow so coords match overlay/Modal window coordinates.
|
|
78
88
|
*/
|
|
79
89
|
export async function measureAnchorInWindow(
|
|
80
|
-
ref: { current: number | null } | number | null
|
|
90
|
+
ref: { current: number | null } | number | null | any
|
|
81
91
|
): Promise<AnchorMeasurement | null> {
|
|
82
92
|
return await measureInWindowStable(ref, { tries: 8 });
|
|
83
93
|
}
|