Files
hipmi-mobile/components/Drawer/NavbarMenu.tsx
bagasbanuna 5c931b069c Fixed navbar admin
User & Admin Layout
- app/(application)/(user)/home.tsx
- app/(application)/admin/_layout.tsx

Components
- components/Drawer/NavbarMenu.tsx
- components/index.ts

Docs
- docs/prompt-for-qwen-code.md

Backup Component
- components/Drawer/NavbarMenu.back.tsx

New Components
- components/Drawer/NavbarMenu_V2.tsx
- components/_ShareComponent/BasicWrapper.tsx

New Admin Screen
- screens/Admin/listPageAdmin_V2.tsx

### No Issue
2026-02-11 17:40:08 +08:00

390 lines
11 KiB
TypeScript

import { AccentColor, MainColor } from "@/constants/color-palet";
import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router";
import React, { useEffect, useRef, useState } from "react";
import {
Animated,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
export interface NavbarItem {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
color?: string;
link?: string;
links?: {
label: string;
link: string;
}[];
initiallyOpened?: boolean;
}
interface NavbarMenuProps {
items: NavbarItem[];
onClose?: () => void;
}
export default function NavbarMenu({ items, onClose }: NavbarMenuProps) {
const pathname = usePathname();
const [activeLink, setActiveLink] = useState<string | null>(null);
const [openKeys, setOpenKeys] = useState<string[]>([]); // Untuk kontrol dropdown
// Normalisasi path: hapus trailing slash
const normalizePath = (path: string) => path.replace(/\/+$/, "");
const normalizedPathname = pathname ? normalizePath(pathname) : "";
// Fungsi untuk mengecek apakah path cocok dengan item menu
// Ini akan mengecek kecocokan eksak atau pola path
const isActivePath = (itemPath: string | undefined): boolean => {
if (!itemPath || !normalizedPathname) return false;
// Cocokan eksak
if (normalizePath(itemPath) === normalizedPathname) return true;
// Cocokan pola path seperti /user-access/[id]/index dengan /user-access/index
// atau /donation/[id]/detail dengan /donation/index
const normalizedItemPath = normalizePath(itemPath);
// Jika path item adalah bagian dari path saat ini (prefix match)
if (normalizedPathname.startsWith(normalizedItemPath + '/')) return true;
// Jika path saat ini adalah bagian dari path item (misalnya /user-access/detail cocok dengan /user-access)
if (normalizedItemPath.startsWith(normalizedPathname + '/')) return true;
// Jika path item adalah bagian dari path saat ini tanpa id (misalnya /user-access/[id]/index cocok dengan /user-access/index)
const itemParts = normalizedItemPath.split('/');
const currentParts = normalizedPathname.split('/');
// Jika panjangnya sama dan hanya berbeda di bagian dinamis [id]
if (itemParts.length === currentParts.length) {
let match = true;
for (let i = 0; i < itemParts.length; i++) {
// Jika bagian path item adalah placeholder [id], abaikan
if (itemParts[i].startsWith('[') && itemParts[i].endsWith(']')) continue;
// Jika bagian path saat ini adalah ID (angka), abaikan
if (/^\d+$/.test(currentParts[i])) continue;
// Jika tidak cocok dan bukan placeholder atau ID, maka tidak cocok
if (itemParts[i] !== currentParts[i]) {
match = false;
break;
}
}
if (match) return true;
}
// Tambahkan logika khusus untuk menangani file index.tsx sebagai halaman dashboard
// Jika path saat ini adalah versi index dari path item (misalnya /admin/event/index cocok dengan /admin/event)
if (normalizedPathname === normalizedItemPath + '/index') return true;
return false;
};
// Fungsi untuk menentukan item mana yang paling spesifik aktif
// Ini akan memastikan hanya satu item yang aktif pada satu waktu
const findMostSpecificActiveItem = (): { parentLabel?: string; subItemLink?: string } | null => {
// Cek setiap item menu
for (const item of items) {
// Jika item memiliki sub-menu
if (item.links && item.links.length > 0) {
// Urutkan sub-menu berdasarkan panjang path (terpanjang dulu untuk prioritas lebih spesifik)
const sortedSubItems = [...item.links].sort((a, b) => {
if (a.link && b.link) {
return b.link.length - a.link.length; // Urutan menurun (terpanjang dulu)
}
return 0;
});
// Cek setiap sub-menu dalam urutan yang telah diurutkan
for (const subItem of sortedSubItems) {
if (isActivePath(subItem.link)) {
return { parentLabel: item.label, subItemLink: subItem.link };
}
}
}
// Jika tidak ada sub-menu yang cocok, cek item utama
if (isActivePath(item.link)) {
return { parentLabel: item.label };
}
}
return null;
};
// Hitung item aktif terlebih dahulu
const mostSpecificActive = findMostSpecificActiveItem();
// Set activeLink saat pathname berubah
useEffect(() => {
if (normalizedPathname) {
setActiveLink(normalizedPathname);
}
}, [normalizedPathname]);
// Fungsi untuk menentukan apakah dropdown harus tetap terbuka
// Dropdown tetap terbuka jika salah satu dari sub-menu cocok dengan path saat ini
const shouldDropdownBeOpen = (item: NavbarItem): boolean => {
if (!normalizedPathname || !item.links || item.links.length === 0) return false;
// Cek apakah salah satu sub-menu cocok dengan path saat ini
return item.links.some(subItem => isActivePath(subItem.link));
};
// Toggle dropdown
const toggleOpen = (label: string) => {
setOpenKeys((prev) =>
prev.includes(label) ? prev.filter((key) => key !== label) : [label]
);
};
return (
<View
style={{
// flex: 1,
// backgroundColor: MainColor.black,
marginBottom: 20,
}}
>
<ScrollView
contentContainerStyle={{
paddingVertical: 10, // Opsional: tambahkan padding
}}
// showsVerticalScrollIndicator={false} // Opsional: sembunyikan indikator scroll
>
{items.map((item) => (
<MenuItem
key={item.label}
item={item}
onClose={onClose}
activeLink={activeLink}
setActiveLink={setActiveLink}
isOpen={openKeys.includes(item.label) || shouldDropdownBeOpen(item)}
toggleOpen={() => toggleOpen(item.label)}
isActivePath={isActivePath}
isMostSpecificActive={(menuItem) => {
if (!mostSpecificActive) return false;
// Jika item memiliki sub-menu
if (menuItem.links && menuItem.links.length > 0) {
// Jika item ini adalah parent dari sub-menu yang aktif, menu utama tidak aktif
return false;
}
// Jika tidak ada sub-menu, hanya periksa kecocokan langsung
return mostSpecificActive.parentLabel === menuItem.label && !mostSpecificActive.subItemLink;
}}
/>
))}
</ScrollView>
</View>
);
}
// Komponen Item Menu
function MenuItem({
item,
onClose,
activeLink,
setActiveLink,
isOpen,
toggleOpen,
isActivePath,
isMostSpecificActive,
}: {
item: NavbarItem;
onClose?: () => void;
activeLink: string | null;
setActiveLink: (link: string | null) => void;
isOpen: boolean;
toggleOpen: () => void;
isActivePath: (itemPath: string | undefined) => boolean;
isMostSpecificActive: (item: NavbarItem) => boolean;
}) {
const isActive = isMostSpecificActive(item);
const animatedHeight = useRef(new Animated.Value(0)).current;
// Animasi saat isOpen berubah
React.useEffect(() => {
Animated.timing(animatedHeight, {
toValue: isOpen ? (item.links ? item.links.length * 40 : 0) : 0,
duration: 200,
useNativeDriver: false,
}).start();
}, [isOpen, item.links, animatedHeight]);
// Jika ada submenu
if (item.links && item.links.length > 0) {
return (
<View>
{/* Parent Item */}
<TouchableOpacity style={styles.parentItem} onPress={toggleOpen}>
<Ionicons
name={item.icon}
size={16}
color={MainColor.white}
style={styles.icon}
/>
<Text style={styles.parentText}>
{item.label}
</Text>
<Ionicons
name={isOpen ? "chevron-up" : "chevron-down"}
size={16}
color={MainColor.white}
/>
</TouchableOpacity>
{/* Submenu (Animated) */}
<Animated.View
style={[
styles.submenu,
// {
// backgroundColor: "red",
// },
{
height: animatedHeight,
opacity: animatedHeight.interpolate({
inputRange: [0, item.links.length * 40],
outputRange: [0, 1],
extrapolate: "clamp",
}),
},
]}
>
{item.links.map((subItem, index) => {
// Untuk sub-item, kita gunakan logika aktif berdasarkan isActivePath
const isSubActive = isActivePath(subItem.link);
return (
<TouchableOpacity
key={index}
style={[styles.subItem, isSubActive && styles.subItemActive]}
onPress={() => {
setActiveLink(subItem.link);
onClose?.();
router.push(subItem.link as any);
}}
>
<Ionicons
name="radio-button-on-outline"
size={16}
color={isSubActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.subText,
isSubActive && { color: MainColor.yellow },
]}
>
{subItem.label}
</Text>
</TouchableOpacity>
);
})}
</Animated.View>
</View>
);
}
// Menu tanpa submenu
return (
<TouchableOpacity
style={[styles.singleItem, isActive && styles.singleItemActive]}
onPress={() => {
setActiveLink(item.link || null);
onClose?.();
router.push(item.link as any);
}}
>
<Ionicons
name={item.icon}
size={16}
color={isActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.singleText,
{ color: isActive ? MainColor.yellow : MainColor.white },
]}
>
{item.label}
</Text>
</TouchableOpacity>
);
}
// Styles
const styles = StyleSheet.create({
container: {
marginBottom: 5,
},
parentItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 10,
// backgroundColor: AccentColor.darkblue,
borderRadius: 8,
marginBottom: 5,
justifyContent: "space-between",
},
parentText: {
flex: 1,
fontSize: 16,
fontWeight: "500",
marginLeft: 10,
color: MainColor.white,
},
singleItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 10,
// backgroundColor: AccentColor.darkblue,
borderRadius: 8,
marginBottom: 5,
},
singleItemActive: {
backgroundColor: AccentColor.blue,
},
singleText: {
fontSize: 16,
fontWeight: "500",
marginLeft: 10,
color: MainColor.white,
},
icon: {
width: 24,
textAlign: "center",
paddingRight: 10,
},
submenu: {
overflow: "hidden",
marginLeft: 30,
marginTop: 5,
},
subItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 6,
marginBottom: 4,
},
subItemActive: {
backgroundColor: AccentColor.blue,
},
subText: {
color: MainColor.white,
fontSize: 16,
fontWeight: "500",
},
});