Files
paseo/packages/app/src/mobile-panels/presentation.tsx
T
Mohamed Boudra 3778d0ad7b Fix mobile sidebar swipe and drag failures (#2709)
* fix(app): prevent text selection during Android swipes

Gesture interception did not cancel the native view where the touch began, leaving Android's pending text-selection action alive.

* fix(app): restore iOS sidebar close swipes

Fabric could fail to attach close handlers when their target lived inside retained but display-hidden panel content. Keep a registered native gesture host mounted outside the presentation wrapper so close swipes work over nested lists without activating rows.

* fix(app): recover sidebar drags after cancellation

Cancelled native drags could leave a lifted row and the shared outer list locked. Scope terminal cleanup to the owning gesture session and release the lock on cancellation or unmount.

* test(app): clean up partial drag fixtures
2026-08-01 04:05:54 +08:00

126 lines
4.2 KiB
TypeScript

import { useMemo, type ComponentProps, type ReactNode } from "react";
import { Pressable, StyleSheet, View } from "react-native";
import { GestureDetector, type GestureType } from "react-native-gesture-handler";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { isWeb } from "@/constants/platform";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
import { getMobilePanelFrame } from "./model";
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
type OverlayPanel = Exclude<MobilePanelView, "agent">;
interface MobilePanelOverlayProps {
children: ReactNode;
closeGesture: GestureType;
panel: OverlayPanel;
panelStyle?: ComponentProps<typeof Animated.View>["style"];
}
export function MobilePanelOverlay({
children,
closeGesture,
panel,
panelStyle,
}: MobilePanelOverlayProps) {
const { position, windowWidth } = useMobilePanelsRuntime();
const target = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const isOpen = target === panel;
const isPresented = useIsMobilePanelPresented(panel);
const isLeft = panel === "agent-list";
const sidebarAnimatedStyle = useAnimatedStyle(() => {
const frame = getMobilePanelFrame(position.value, windowWidth);
return {
transform: [{ translateX: isLeft ? frame.leftTranslateX : frame.rightTranslateX }],
};
}, [isLeft, windowWidth]);
const backdropAnimatedStyle = useAnimatedStyle(() => {
const frame = getMobilePanelFrame(position.value, windowWidth);
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
}, [isLeft, windowWidth]);
const overlayStyle = useMemo(
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }],
[isPresented],
);
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
const backdropStyle = useMemo(
() => [styles.backdrop, backdropAnimatedStyle],
[backdropAnimatedStyle],
);
const combinedPanelStyle = useMemo(
() => [
styles.panel,
positionedPanelStyle,
{ width: windowWidth },
panelStyle,
sidebarAnimatedStyle,
],
[panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth],
);
let overlayPointerEvents: "auto" | "box-none" | "none";
if (!isWeb) {
overlayPointerEvents = "box-none";
} else {
overlayPointerEvents = isOpen ? "auto" : "none";
}
return (
<GestureDetector gesture={closeGesture} touchAction="pan-y">
{/* Fabric needs an always-mounted native host to attach the close handler while the
retained panel content is hidden. nativeID keeps that host registered. */}
<View
collapsable={false}
nativeID={`${panel}-gesture-host`}
pointerEvents={overlayPointerEvents}
style={styles.overlay}
>
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Pressable
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
onPress={showMobileAgent}
pointerEvents={isOpen ? "auto" : "none"}
style={StyleSheet.absoluteFillObject}
testID={`${panel}-backdrop`}
>
<Animated.View pointerEvents="none" style={backdropStyle} />
</Pressable>
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
</Animated.View>
</View>
</View>
</GestureDetector>
);
}
// Reanimated owns only these static native styles and the derived transform.
// Theme values stay inline at call sites, avoiding Unistyles patching the same
// native node after Fabric commits.
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
position: "absolute",
top: 0,
bottom: 0,
overflow: "hidden",
},
leftPanel: {
left: 0,
},
rightPanel: {
right: 0,
},
});