refactor(sidebar): improve dropdown animations and spacing layout

This commit is contained in:
Mohamed Boudra
2026-03-22 20:12:01 +07:00
parent 1e5b809b70
commit e2248015aa
3 changed files with 72 additions and 35 deletions
@@ -1967,12 +1967,7 @@ const styles = StyleSheet.create((theme) => ({
projectBlock: { projectBlock: {
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
}, },
workspaceListContainer: { workspaceListContainer: {},
marginLeft: theme.spacing[3],
paddingLeft: theme.spacing[2],
borderLeftWidth: StyleSheet.hairlineWidth,
borderLeftColor: theme.colors.surface1,
},
emptyText: { emptyText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
textAlign: "center", textAlign: "center",
@@ -1982,7 +1977,7 @@ const styles = StyleSheet.create((theme) => ({
projectRow: { projectRow: {
minHeight: 36, minHeight: 36,
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
flexDirection: "row", flexDirection: "row",
@@ -2051,6 +2046,7 @@ const styles = StyleSheet.create((theme) => ({
projectTitle: { projectTitle: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: "400",
minWidth: 0, minWidth: 0,
flexShrink: 1, flexShrink: 1,
}, },
@@ -2108,7 +2104,8 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 36, minHeight: 36,
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
flexDirection: "column", flexDirection: "column",
alignItems: "stretch", alignItems: "stretch",
@@ -2195,6 +2192,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchText: { workspaceBranchText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: "300",
lineHeight: 20, lineHeight: 20,
opacity: 0.76, opacity: 0.76,
flex: 1, flex: 1,
@@ -24,7 +24,7 @@ import {
type ViewStyle, type ViewStyle,
type StyleProp, type StyleProp,
} from "react-native"; } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native"; import { Check, CheckCircle } from "lucide-react-native";
@@ -226,6 +226,22 @@ export function DropdownMenuTrigger({
); );
} }
function getTransformOrigin(placement: Placement, alignment: Alignment): string {
const vertical = placement === "bottom" ? "top" : placement === "top" ? "bottom" : "center";
const horizontal = alignment === "start" ? "left" : alignment === "end" ? "right" : "center";
return `${vertical} ${horizontal}`;
}
const contentEntering = new Keyframe({
0: { opacity: 0, transform: [{ scale: 0.97 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(150);
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
export function DropdownMenuContent({ export function DropdownMenuContent({
children, children,
side = "bottom", side = "bottom",
@@ -249,9 +265,22 @@ export function DropdownMenuContent({
testID?: string; testID?: string;
}>): ReactElement | null { }>): ReactElement | null {
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent"); const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const [closing, setClosing] = useState(false);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null); const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [actualPlacement, setActualPlacement] = useState<Placement>(side);
// Keep Modal mounted during exit animation
useEffect(() => {
if (open) {
setModalVisible(true);
setClosing(false);
} else if (modalVisible) {
setClosing(true);
}
}, [open, modalVisible]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setOpen(false); setOpen(false);
@@ -314,6 +343,7 @@ export function DropdownMenuContent({
// For fullWidth, x is simply the horizontal padding to center on screen // For fullWidth, x is simply the horizontal padding to center on screen
const x = fullWidth ? horizontalPadding : result.x; const x = fullWidth ? horizontalPadding : result.x;
setPosition({ x, y: result.y }); setPosition({ x, y: result.y });
setActualPlacement(result.actualPlacement);
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]); }, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
const handleContentLayout = useCallback( const handleContentLayout = useCallback(
@@ -324,7 +354,7 @@ export function DropdownMenuContent({
[], [],
); );
if (!open) return null; if (!modalVisible) return null;
const { width: screenWidth } = Dimensions.get("window"); const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth const resolvedWidthStyle: ViewStyle = fullWidth
@@ -337,7 +367,7 @@ export function DropdownMenuContent({
return ( return (
<Modal <Modal
visible={open} visible={modalVisible}
transparent transparent
animationType="none" animationType="none"
statusBarTranslucent={Platform.OS === "android"} statusBarTranslucent={Platform.OS === "android"}
@@ -351,30 +381,38 @@ export function DropdownMenuContent({
onPress={handleClose} onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined} testID={testID ? `${testID}-backdrop` : undefined}
/> />
<Animated.View {!closing ? (
entering={FadeIn.duration(100)} <Animated.View
exiting={FadeOut.duration(100)} entering={contentEntering}
collapsable={false} exiting={contentExiting.withCallback((finished) => {
testID={testID} "worklet";
onLayout={handleContentLayout} if (finished) {
style={[ runOnJS(setModalVisible)(false);
styles.content, }
resolvedWidthStyle, })}
{ collapsable={false}
position: "absolute", testID={testID}
top: position?.y ?? -9999, onLayout={handleContentLayout}
left: position?.x ?? -9999, style={[
}, styles.content,
]} resolvedWidthStyle,
> {
<ScrollView position: "absolute",
bounces={false} top: position?.y ?? -9999,
showsVerticalScrollIndicator left: position?.x ?? -9999,
contentContainerStyle={{ flexGrow: 1 }} transformOrigin: getTransformOrigin(actualPlacement, align),
},
]}
> >
{children} <ScrollView
</ScrollView> bounces={false}
</Animated.View> showsVerticalScrollIndicator
contentContainerStyle={{ flexGrow: 1 }}
>
{children}
</ScrollView>
</Animated.View>
) : null}
</View> </View>
</Modal> </Modal>
); );
@@ -50,7 +50,8 @@ export function buildSidebarProjectRowModel(input: {
}; };
} }
const collapsible = input.project.workspaces.length > 1; const collapsible =
input.project.projectKind === "git" || input.project.workspaces.length > 1;
return { return {
kind: "project_section", kind: "project_section",