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,9 +381,15 @@ export function DropdownMenuContent({
onPress={handleClose} onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined} testID={testID ? `${testID}-backdrop` : undefined}
/> />
{!closing ? (
<Animated.View <Animated.View
entering={FadeIn.duration(100)} entering={contentEntering}
exiting={FadeOut.duration(100)} exiting={contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(setModalVisible)(false);
}
})}
collapsable={false} collapsable={false}
testID={testID} testID={testID}
onLayout={handleContentLayout} onLayout={handleContentLayout}
@@ -364,6 +400,7 @@ export function DropdownMenuContent({
position: "absolute", position: "absolute",
top: position?.y ?? -9999, top: position?.y ?? -9999,
left: position?.x ?? -9999, left: position?.x ?? -9999,
transformOrigin: getTransformOrigin(actualPlacement, align),
}, },
]} ]}
> >
@@ -375,6 +412,7 @@ export function DropdownMenuContent({
{children} {children}
</ScrollView> </ScrollView>
</Animated.View> </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",