react-native-anchored-menu 0.0.15 → 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 +6 -1
- package/src/hosts/ModalHost.tsx +79 -3
- package/src/hosts/ViewHost.tsx +79 -3
- package/src/types.ts +2 -0
- 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
|
() => ({
|
|
@@ -37,7 +37,12 @@ export function useAnchoredMenuState<T = MenuState>(
|
|
|
37
37
|
selector: (state: MenuState) => T = ((s: MenuState) => s) as (state: MenuState) => T
|
|
38
38
|
): T {
|
|
39
39
|
const store = useContext(AnchoredMenuStateContext);
|
|
40
|
-
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
|
+
}
|
|
41
46
|
|
|
42
47
|
const getSelectedSnapshot = () => selector(store.getSnapshot());
|
|
43
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;
|
|
@@ -91,7 +97,28 @@ export function ModalHost() {
|
|
|
91
97
|
]);
|
|
92
98
|
|
|
93
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
|
+
|
|
94
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
|
+
|
|
95
122
|
setAnchorWin(nextAnchorWin);
|
|
96
123
|
setHostWin(h);
|
|
97
124
|
measureCacheRef.current.set(req.id, {
|
|
@@ -134,7 +161,26 @@ export function ModalHost() {
|
|
|
134
161
|
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
135
162
|
]);
|
|
136
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
|
+
|
|
137
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
|
+
|
|
138
184
|
setAnchorWin(nextAnchorWin);
|
|
139
185
|
setHostWin(h);
|
|
140
186
|
measureCacheRef.current.set(req.id, {
|
|
@@ -168,7 +214,26 @@ export function ModalHost() {
|
|
|
168
214
|
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
169
215
|
]);
|
|
170
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
|
+
|
|
171
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
|
+
|
|
172
237
|
setAnchorWin(nextAnchorWin);
|
|
173
238
|
setHostWin(h);
|
|
174
239
|
measureCacheRef.current.set(req.id, {
|
|
@@ -200,6 +265,17 @@ export function ModalHost() {
|
|
|
200
265
|
|
|
201
266
|
const position = useMemo(() => {
|
|
202
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
|
+
|
|
203
279
|
const viewport: Viewport | undefined =
|
|
204
280
|
hostSize.width && hostSize.height
|
|
205
281
|
? {
|
|
@@ -209,7 +285,7 @@ export function ModalHost() {
|
|
|
209
285
|
: undefined;
|
|
210
286
|
return computeMenuPosition({
|
|
211
287
|
anchor: anchorInHost,
|
|
212
|
-
menuSize,
|
|
288
|
+
menuSize: isValidMenuSize(menuSize) ? menuSize : null,
|
|
213
289
|
viewport,
|
|
214
290
|
placement: req.placement ?? "auto",
|
|
215
291
|
offset: req.offset ?? 8,
|
|
@@ -219,7 +295,7 @@ export function ModalHost() {
|
|
|
219
295
|
});
|
|
220
296
|
}, [req, anchorInHost, menuSize, hostSize, keyboardHeight]);
|
|
221
297
|
|
|
222
|
-
const needsInitialMeasure = menuSize
|
|
298
|
+
const needsInitialMeasure = !isValidMenuSize(menuSize);
|
|
223
299
|
|
|
224
300
|
const statusBarTranslucent =
|
|
225
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;
|
|
@@ -96,7 +102,28 @@ export function ViewHost() {
|
|
|
96
102
|
]);
|
|
97
103
|
|
|
98
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
|
+
|
|
99
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
|
+
|
|
100
127
|
setAnchorWin(nextAnchorWin);
|
|
101
128
|
setHostWin(h);
|
|
102
129
|
measureCacheRef.current.set(req.id, {
|
|
@@ -139,7 +166,26 @@ export function ViewHost() {
|
|
|
139
166
|
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
140
167
|
]);
|
|
141
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
|
+
|
|
142
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
|
+
|
|
143
189
|
setAnchorWin(nextAnchorWin);
|
|
144
190
|
setHostWin(h);
|
|
145
191
|
measureCacheRef.current.set(req.id, {
|
|
@@ -173,7 +219,26 @@ export function ViewHost() {
|
|
|
173
219
|
measure(hostRef as any, strategy === "stable" ? { tries } : undefined),
|
|
174
220
|
]);
|
|
175
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
|
+
|
|
176
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
|
+
|
|
177
242
|
setAnchorWin(nextAnchorWin);
|
|
178
243
|
setHostWin(h);
|
|
179
244
|
measureCacheRef.current.set(req.id, {
|
|
@@ -204,6 +269,17 @@ export function ViewHost() {
|
|
|
204
269
|
|
|
205
270
|
const position = useMemo(() => {
|
|
206
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
|
+
|
|
207
283
|
const viewport: Viewport | undefined =
|
|
208
284
|
hostSize.width && hostSize.height
|
|
209
285
|
? {
|
|
@@ -214,7 +290,7 @@ export function ViewHost() {
|
|
|
214
290
|
|
|
215
291
|
return computeMenuPosition({
|
|
216
292
|
anchor: anchorInHost,
|
|
217
|
-
menuSize,
|
|
293
|
+
menuSize: isValidMenuSize(menuSize) ? menuSize : null,
|
|
218
294
|
viewport,
|
|
219
295
|
placement: req.placement ?? "auto",
|
|
220
296
|
offset: req.offset ?? 8,
|
|
@@ -224,7 +300,7 @@ export function ViewHost() {
|
|
|
224
300
|
});
|
|
225
301
|
}, [req, anchorInHost, menuSize, hostSize, keyboardHeight]);
|
|
226
302
|
|
|
227
|
-
const needsInitialMeasure = menuSize
|
|
303
|
+
const needsInitialMeasure = !isValidMenuSize(menuSize);
|
|
228
304
|
if (!visible) return null;
|
|
229
305
|
|
|
230
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
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AnchorMeasurement, MenuSize } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates that a measurement has valid dimensions.
|
|
5
|
+
* Returns true if the measurement is valid (non-null and has positive dimensions).
|
|
6
|
+
*/
|
|
7
|
+
export function isValidMeasurement(
|
|
8
|
+
measurement: AnchorMeasurement | null | undefined
|
|
9
|
+
): measurement is AnchorMeasurement {
|
|
10
|
+
if (!measurement) return false;
|
|
11
|
+
return (
|
|
12
|
+
typeof measurement.width === "number" &&
|
|
13
|
+
typeof measurement.height === "number" &&
|
|
14
|
+
typeof measurement.pageX === "number" &&
|
|
15
|
+
typeof measurement.pageY === "number" &&
|
|
16
|
+
measurement.width > 0 &&
|
|
17
|
+
measurement.height > 0 &&
|
|
18
|
+
!isNaN(measurement.width) &&
|
|
19
|
+
!isNaN(measurement.height) &&
|
|
20
|
+
!isNaN(measurement.pageX) &&
|
|
21
|
+
!isNaN(measurement.pageY)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validates that a menu size has valid dimensions.
|
|
27
|
+
* Returns true if the size is valid (non-null and has positive dimensions).
|
|
28
|
+
*/
|
|
29
|
+
export function isValidMenuSize(size: MenuSize | null | undefined): size is MenuSize {
|
|
30
|
+
if (!size) return false;
|
|
31
|
+
return (
|
|
32
|
+
typeof size.width === "number" &&
|
|
33
|
+
typeof size.height === "number" &&
|
|
34
|
+
size.width > 0 &&
|
|
35
|
+
size.height > 0 &&
|
|
36
|
+
!isNaN(size.width) &&
|
|
37
|
+
!isNaN(size.height)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Checks if a measurement looks invalid (e.g., all zeros, which can happen
|
|
43
|
+
* on Android during layout transitions).
|
|
44
|
+
*/
|
|
45
|
+
export function isInvalidMeasurement(
|
|
46
|
+
measurement: AnchorMeasurement | null | undefined
|
|
47
|
+
): boolean {
|
|
48
|
+
if (!measurement) return true;
|
|
49
|
+
return (
|
|
50
|
+
measurement.pageX === 0 &&
|
|
51
|
+
measurement.pageY === 0 &&
|
|
52
|
+
measurement.width === 0 &&
|
|
53
|
+
measurement.height === 0
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -1,26 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,55 +0,0 @@
|
|
|
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
|
-
}
|
package/src/core/context.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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);
|