fix(app): unstick iOS image picker and land reusable Maestro primitives
On iOS, tapping "+" → "Add image" in the Composer left an invisible backdrop trapping touches after the native photo picker dismissed. Root cause: the dropdown menu's onSelect fired while the RN Modal was still completing UIKit dismissal, so PHPicker presented over a dismissing Modal that never cleanly released. DropdownMenu now defers the selected item's onSelect until Modal onDismiss (UIKit-level completion) plus a short buffer on iOS, then invokes it. Same DropdownMenu instance — no per-call opt-in. Along the way, the Maestro repro surfaced a controlled-TextInput character-drop race: fast inputText on a controlled input drops characters mid-type. Direct-host and pair-link inputs are now ref-backed uncontrolled inputs with stable testIDs. New reusable E2E primitives under packages/app/maestro/: - flows/land-in-chat.yaml — clearState + direct connect to 127.0.0.1:6767, lands in the composer. Compose on this for any composer-level fixture. - image-picker-repro.yaml — regression for the zombie-backdrop bug. docs/MOBILE_TESTING.md gains the patterns: reach-the-composer via land-in-chat, uncontrolled inputs for Maestro-typed fields, and the dropdown → native-presenter dismissal timing on iOS. Existing relay-pairing and relay-then-direct fixtures updated to use stable testIDs instead of ambiguous text / point-based taps.
This commit is contained in:
@@ -95,6 +95,37 @@ Two reusable flows handle Expo dev client screens after launch:
|
|||||||
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
||||||
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
||||||
|
|
||||||
|
### Reach the composer
|
||||||
|
|
||||||
|
`flows/land-in-chat.yaml` is the canonical "get into a chat" primitive. It `clearState`s, runs `launch.yaml`, taps the welcome screen's direct-connection option, types `127.0.0.1:6767`, submits, and waits for `message-input-root`. Compose any composer-level fixture on top of it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
appId: sh.paseo
|
||||||
|
---
|
||||||
|
- runFlow: flows/land-in-chat.yaml
|
||||||
|
# ...your scenario here, starting from a ready composer
|
||||||
|
```
|
||||||
|
|
||||||
|
See `image-picker-repro.yaml` for an example.
|
||||||
|
|
||||||
|
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
||||||
|
|
||||||
|
### Inputs that Maestro types into
|
||||||
|
|
||||||
|
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
||||||
|
|
||||||
|
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
|
||||||
|
|
||||||
|
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
|
||||||
|
|
||||||
|
### Dropdowns that launch native presenters (iOS)
|
||||||
|
|
||||||
|
On iOS, when a dropdown menu (`DropdownMenu` / RN `Modal`) item needs to launch a native presenter like `PHPickerViewController` (image picker) or a `UIDocumentPicker`, the callback **must not fire while the `Modal` is still dismissing**. UIKit dismissal completion spans multiple frames beyond React unmount; launching a native presenter mid-dismissal leaves an invisible backdrop mounted that traps every subsequent touch.
|
||||||
|
|
||||||
|
`DropdownMenu` handles this by deferring the selected item's `onSelect` until `Modal.onDismiss` fires (UIKit-level dismissal complete), then adds a small extra buffer before invoking it. See `components/ui/dropdown-menu.tsx`'s `selectItem` / `flushPendingSelect`.
|
||||||
|
|
||||||
|
When building a new component that composes a dropdown with a native presenter, reuse this dropdown — do not invent a new timing shim.
|
||||||
|
|
||||||
## Self-verification loops
|
## Self-verification loops
|
||||||
|
|
||||||
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# NOTE: Maestro requires a config section for every flow file, even when used
|
||||||
|
# via `runFlow`. Keep this aligned with the dev build application id.
|
||||||
|
appId: sh.paseo
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- runFlow: launch.yaml
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "welcome-screen"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "welcome-direct-connection"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "add-host-modal"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-input"
|
||||||
|
- eraseText
|
||||||
|
- inputText: "127.0.0.1:6767"
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "direct-host-input"
|
||||||
|
text: "127.0.0.1:6767"
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-submit"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-root"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "message-input-attach-button"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
appId: sh.paseo
|
||||||
|
---
|
||||||
|
- runFlow: flows/land-in-chat.yaml
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "message-input-attach-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-attachment-menu"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "message-input-attachment-menu-item-image"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Allow Full Access"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Allow Full Access"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Allow Access to All Photos"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Allow Access to All Photos"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Cancel"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-root"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
# If the dropdown Modal left an invisible backdrop mounted behind the picker,
|
||||||
|
# this tap is swallowed and the attachment menu never reopens.
|
||||||
|
- tapOn:
|
||||||
|
id: "message-input-attach-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-attachment-menu"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "message-input-attachment-menu-backdrop"
|
||||||
@@ -8,7 +8,15 @@ appId: sh.paseo
|
|||||||
|
|
||||||
- eraseText
|
- eraseText
|
||||||
- inputText: ${PAIRING_OFFER_URL}
|
- inputText: ${PAIRING_OFFER_URL}
|
||||||
- tapOn: "Pair"
|
|
||||||
|
# Prove the input received the URL intact. If this fails, iOS keyboard input
|
||||||
|
# dropped characters and the controlled input path needs a source fix.
|
||||||
|
- assertVisible:
|
||||||
|
id: "pair-link-input"
|
||||||
|
text: ${PAIRING_OFFER_URL}
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "pair-link-submit"
|
||||||
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "New agent"
|
visible: "New agent"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ appId: sh.paseo
|
|||||||
- eraseText
|
- eraseText
|
||||||
- inputText: ${PAIRING_OFFER_URL}
|
- inputText: ${PAIRING_OFFER_URL}
|
||||||
- tapOn:
|
- tapOn:
|
||||||
point: "75%,36%"
|
id: "pair-link-submit"
|
||||||
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "New agent"
|
visible: "New agent"
|
||||||
|
|||||||
@@ -247,8 +247,9 @@ export function AdaptiveModalSheet({
|
|||||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
|
accessible={false}
|
||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader} testID={testID}>
|
||||||
<View style={styles.headerTitleGroup}>
|
<View style={styles.headerTitleGroup}>
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export function AddHostMethodModal({
|
|||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handleDirect}
|
onPress={handleDirect}
|
||||||
accessibilityLabel="Direct connection"
|
accessibilityLabel="Direct connection"
|
||||||
|
testID="add-host-method-direct"
|
||||||
>
|
>
|
||||||
<Link2 size={18} color={theme.colors.foreground} />
|
<Link2 size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
@@ -93,6 +94,7 @@ export function AddHostMethodModal({
|
|||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handlePaste}
|
onPress={handlePaste}
|
||||||
accessibilityLabel="Paste pairing link"
|
accessibilityLabel="Paste pairing link"
|
||||||
|
testID="add-host-method-pair-link"
|
||||||
>
|
>
|
||||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
|
|||||||
@@ -155,29 +155,34 @@ export function AddHostModal({
|
|||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const hostInputRef = useRef<TextInput>(null);
|
const hostInputRef = useRef<TextInput>(null);
|
||||||
|
const endpointRawRef = useRef("");
|
||||||
|
|
||||||
const [endpointRaw, setEndpointRaw] = useState("");
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const clearInput = useCallback(() => {
|
||||||
|
endpointRawRef.current = "";
|
||||||
|
hostInputRef.current?.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setEndpointRaw("");
|
clearInput();
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
onClose();
|
onClose();
|
||||||
}, [isSaving, onClose]);
|
}, [isSaving, clearInput, onClose]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setEndpointRaw("");
|
clearInput();
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
(onCancel ?? onClose)();
|
(onCancel ?? onClose)();
|
||||||
}, [isSaving, onCancel, onClose]);
|
}, [isSaving, clearInput, onCancel, onClose]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
|
|
||||||
const raw = endpointRaw.trim();
|
const raw = endpointRawRef.current.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
setErrorMessage("Host is required");
|
setErrorMessage("Host is required");
|
||||||
return;
|
return;
|
||||||
@@ -240,16 +245,7 @@ export function AddHostModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
|
||||||
daemons,
|
|
||||||
endpointRaw,
|
|
||||||
handleClose,
|
|
||||||
isMobile,
|
|
||||||
isSaving,
|
|
||||||
onSaved,
|
|
||||||
targetServerId,
|
|
||||||
upsertDirectConnection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
@@ -264,8 +260,12 @@ export function AddHostModal({
|
|||||||
<Text style={styles.label}>Host</Text>
|
<Text style={styles.label}>Host</Text>
|
||||||
<AdaptiveTextInput
|
<AdaptiveTextInput
|
||||||
ref={hostInputRef}
|
ref={hostInputRef}
|
||||||
value={endpointRaw}
|
testID="direct-host-input"
|
||||||
onChangeText={setEndpointRaw}
|
nativeID="direct-host-input"
|
||||||
|
accessibilityLabel="direct-host-input"
|
||||||
|
onChangeText={(next) => {
|
||||||
|
endpointRawRef.current = next;
|
||||||
|
}}
|
||||||
placeholder="hostname:port"
|
placeholder="hostname:port"
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
@@ -289,6 +289,7 @@ export function AddHostModal({
|
|||||||
onPress={() => void handleSave()}
|
onPress={() => void handleSave()}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
|
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
|
||||||
|
testID="direct-host-submit"
|
||||||
>
|
>
|
||||||
{isSaving ? "Connecting..." : "Connect"}
|
{isSaving ? "Connecting..." : "Connect"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Alert, Text, View } from "react-native";
|
import { Alert, Text, TextInput, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Link } from "lucide-react-native";
|
import { Link } from "lucide-react-native";
|
||||||
@@ -69,27 +69,33 @@ export function PairLinkModal({
|
|||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const [offerUrl, setOfferUrl] = useState("");
|
const offerUrlRef = useRef("");
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const clearInput = useCallback(() => {
|
||||||
|
offerUrlRef.current = "";
|
||||||
|
inputRef.current?.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setOfferUrl("");
|
clearInput();
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
onClose();
|
onClose();
|
||||||
}, [isSaving, onClose]);
|
}, [isSaving, clearInput, onClose]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setOfferUrl("");
|
clearInput();
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
(onCancel ?? onClose)();
|
(onCancel ?? onClose)();
|
||||||
}, [isSaving, onCancel, onClose]);
|
}, [isSaving, clearInput, onCancel, onClose]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
const raw = offerUrl.trim();
|
const raw = offerUrlRef.current.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
||||||
return;
|
return;
|
||||||
@@ -159,16 +165,7 @@ export function PairLinkModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
|
||||||
daemons,
|
|
||||||
handleClose,
|
|
||||||
isMobile,
|
|
||||||
isSaving,
|
|
||||||
offerUrl,
|
|
||||||
onSaved,
|
|
||||||
targetServerId,
|
|
||||||
upsertDaemonFromOfferUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
@@ -182,11 +179,13 @@ export function PairLinkModal({
|
|||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Pairing link</Text>
|
<Text style={styles.label}>Pairing link</Text>
|
||||||
<AdaptiveTextInput
|
<AdaptiveTextInput
|
||||||
|
ref={inputRef}
|
||||||
testID="pair-link-input"
|
testID="pair-link-input"
|
||||||
nativeID="pair-link-input"
|
nativeID="pair-link-input"
|
||||||
accessibilityLabel="pair-link-input"
|
accessibilityLabel="pair-link-input"
|
||||||
value={offerUrl}
|
onChangeText={(next) => {
|
||||||
onChangeText={setOfferUrl}
|
offerUrlRef.current = next;
|
||||||
|
}}
|
||||||
placeholder="https://app.paseo.sh/#offer=..."
|
placeholder="https://app.paseo.sh/#offer=..."
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ interface Rect {
|
|||||||
type DropdownMenuContextValue = {
|
type DropdownMenuContextValue = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
|
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
|
||||||
|
flushPendingSelect: () => void;
|
||||||
triggerRef: React.RefObject<View | null>;
|
triggerRef: React.RefObject<View | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,19 +169,56 @@ export function DropdownMenu({
|
|||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
}>): ReactElement {
|
}>): ReactElement {
|
||||||
const triggerRef = useRef<View>(null);
|
const triggerRef = useRef<View>(null);
|
||||||
|
const pendingSelectRef = useRef<(() => void) | null>(null);
|
||||||
const [isOpen, setIsOpen] = useControllableOpenState({
|
const [isOpen, setIsOpen] = useControllableOpenState({
|
||||||
open,
|
open,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flushPendingSelect = useCallback(() => {
|
||||||
|
const pendingSelect = pendingSelectRef.current;
|
||||||
|
pendingSelectRef.current = null;
|
||||||
|
if (!pendingSelect) return;
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
// Native presenters such as PHPicker can hang if launched while an RN
|
||||||
|
// Modal is still completing dismissal on UIKit's side.
|
||||||
|
setTimeout(pendingSelect, 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingSelect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
|
||||||
|
if (!closeOnSelect) {
|
||||||
|
onSelect?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
pendingSelectRef.current = onSelect ?? null;
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
onSelect?.();
|
||||||
|
},
|
||||||
|
[setIsOpen],
|
||||||
|
);
|
||||||
|
|
||||||
const value = useMemo<DropdownMenuContextValue>(
|
const value = useMemo<DropdownMenuContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
open: isOpen,
|
open: isOpen,
|
||||||
setOpen: setIsOpen,
|
setOpen: setIsOpen,
|
||||||
|
selectItem,
|
||||||
|
flushPendingSelect,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
}),
|
}),
|
||||||
[isOpen, setIsOpen],
|
[flushPendingSelect, isOpen, selectItem, setIsOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
|
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
|
||||||
@@ -266,7 +305,8 @@ export function DropdownMenuContent({
|
|||||||
horizontalPadding?: number;
|
horizontalPadding?: number;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}>): ReactElement | null {
|
}>): ReactElement | null {
|
||||||
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
|
const { open, setOpen, triggerRef, flushPendingSelect } =
|
||||||
|
useDropdownMenuContext("DropdownMenuContent");
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const webScrollbarStyle = useWebScrollbarStyle();
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
@@ -288,6 +328,12 @@ export function DropdownMenuContent({
|
|||||||
}
|
}
|
||||||
}, [open, modalVisible]);
|
}, [open, modalVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && !modalVisible) {
|
||||||
|
flushPendingSelect();
|
||||||
|
}
|
||||||
|
}, [flushPendingSelect, modalVisible, open]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [setOpen]);
|
}, [setOpen]);
|
||||||
@@ -377,6 +423,7 @@ export function DropdownMenuContent({
|
|||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
statusBarTranslucent={Platform.OS === "android"}
|
statusBarTranslucent={Platform.OS === "android"}
|
||||||
|
onDismiss={flushPendingSelect}
|
||||||
onRequestClose={handleClose}
|
onRequestClose={handleClose}
|
||||||
>
|
>
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
@@ -501,7 +548,7 @@ export function DropdownMenuItem({
|
|||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
}>): ReactElement {
|
}>): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
|
const { selectItem } = useDropdownMenuContext("DropdownMenuItem");
|
||||||
|
|
||||||
// Derive state from status prop (preferred) or legacy loading prop
|
// Derive state from status prop (preferred) or legacy loading prop
|
||||||
const isPending = status === "pending" || loading;
|
const isPending = status === "pending" || loading;
|
||||||
@@ -539,10 +586,7 @@ export function DropdownMenuItem({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (isDisabled) return;
|
if (isDisabled) return;
|
||||||
if (closeOnSelect) {
|
selectItem(onSelect, closeOnSelect);
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
onSelect?.();
|
|
||||||
}}
|
}}
|
||||||
style={({ pressed, hovered }) => [
|
style={({ pressed, hovered }) => [
|
||||||
styles.item,
|
styles.item,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
import {
|
import {
|
||||||
|
|||||||
Reference in New Issue
Block a user