Files
paseo/packages/app/src/components/sidebar-resize-handle.tsx
T
1e0ce69c54 Make the sidebar resize handle usable by touch (#2953)
* fix(app): make the sidebar resize handle usable by touch

The drag handle was a 10px strip at `right: -5`, so half of it sat outside
the sidebar's bounds. Web hit-testing does not clip to the parent box, but
iOS `hitTest:` and Gesture Handler's Android traversal both do, leaving a
~5px target with no visible affordance — hover is the only cue, and hover
never fires on native.

Split the handle by input capability rather than by platform: a fine pointer
keeps the hairline straddling the border, and touch gets a 24x88 grab target
that sits fully inside the sidebar with a permanent grip. The pan gesture now
requires horizontal intent and fails on vertical drift, so a finger on the
grip still scrolls the list behind it.

`useHasFinePointer` replaces the copy of the same media query that lived in
the review surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(app): simplify touch resize handle styles

* fix(app): reveal touch resize grip while pressed

Keep the touch grip out of sidebar content while idle, then expose it for the full resize gesture lifecycle so it remains visible during an active drag.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-08 22:32:24 +02:00

173 lines
4.6 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { Pressable, View } from "react-native";
import { GestureDetector, type GestureType } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import {
resolveSidebarResizeHandleGeometry,
type SidebarResizeEdge,
} from "@/components/sidebar-resize-handle-layout";
import { isWeb } from "@/constants/platform";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
interface SidebarResizeHandleProps {
edge: SidebarResizeEdge;
gesture: GestureType;
pressed: boolean;
testID: string;
}
const HIGHLIGHT_DELAY_MS = 100;
const webResizeCursorStyle = isWeb
? ({
cursor: "col-resize",
} as object)
: null;
function edgeOffsetStyle(edge: SidebarResizeEdge, edgeOffset: number) {
return edge === "left" ? { left: edgeOffset } : { right: edgeOffset };
}
export function SidebarResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) {
const finePointer = useHasFinePointer();
if (finePointer) {
return <PointerResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
}
return <TouchResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
}
function PointerResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) {
const [highlighted, setHighlighted] = useState(false);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const geometry = resolveSidebarResizeHandleGeometry(true);
const hitAreaStyle = [
styles.hitArea,
{ width: geometry.width },
edgeOffsetStyle(edge, geometry.edgeOffset),
webResizeCursorStyle,
];
const cancelHighlightTimer = useCallback(() => {
if (highlightTimerRef.current === null) return;
clearTimeout(highlightTimerRef.current);
highlightTimerRef.current = null;
}, []);
const handleHoverIn = useCallback(() => {
cancelHighlightTimer();
highlightTimerRef.current = setTimeout(() => {
highlightTimerRef.current = null;
setHighlighted(true);
}, HIGHLIGHT_DELAY_MS);
}, [cancelHighlightTimer]);
const handleHoverOut = useCallback(() => {
cancelHighlightTimer();
setHighlighted(false);
}, [cancelHighlightTimer]);
useEffect(() => cancelHighlightTimer, [cancelHighlightTimer]);
return (
<GestureDetector gesture={gesture}>
<Pressable
testID={testID}
style={hitAreaStyle}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
{highlighted ? (
<View pointerEvents="none" testID={`${testID}-highlight`} style={styles.highlight} />
) : null}
</Pressable>
</GestureDetector>
);
}
function TouchResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) {
const geometry = resolveSidebarResizeHandleGeometry(false);
// `box-none` keeps the full-height column out of hit-testing so only the
// grab target steals taps from the rows behind it.
const layerStyle = [
styles.touchLayer,
{ width: geometry.width },
edgeOffsetStyle(edge, geometry.edgeOffset),
];
const targetStyle = [
styles.touchTarget,
{ width: geometry.width, height: geometry.height ?? undefined },
];
const gripStyle = [
styles.grip,
edge === "left" ? styles.leftEdgeGrip : styles.rightEdgeGrip,
pressed ? styles.visibleGrip : styles.hiddenGrip,
];
return (
<View pointerEvents="box-none" style={layerStyle}>
<GestureDetector gesture={gesture}>
<View
testID={testID}
role="separator"
aria-orientation="vertical"
collapsable={false}
style={targetStyle}
>
<View pointerEvents="none" testID={`${testID}-grip`} style={gripStyle} />
</View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
hitArea: {
position: "absolute",
top: 0,
bottom: 0,
zIndex: 10,
},
highlight: {
position: "absolute",
top: 0,
bottom: 0,
left: 5,
width: 1,
backgroundColor: theme.colors.foreground,
opacity: 0.25,
},
touchLayer: {
position: "absolute",
top: 0,
bottom: 0,
zIndex: 10,
alignItems: "center",
justifyContent: "center",
},
touchTarget: {
alignItems: "center",
justifyContent: "center",
},
grip: {
width: 4,
height: 36,
borderRadius: 2,
backgroundColor: theme.colors.foreground,
},
hiddenGrip: {
opacity: 0,
},
visibleGrip: {
opacity: 0.3,
},
leftEdgeGrip: {
alignSelf: "flex-start",
marginLeft: theme.spacing[0.5],
},
rightEdgeGrip: {
alignSelf: "flex-end",
marginRight: theme.spacing[0.5],
},
}));