feat(sidebar): add expand/collapse functionality and search menu with keyboard shortcuts

feat(i18n): add translations for expand/collapse menu and search menu
This commit is contained in:
Firman Ramdhani
2026-07-06 13:27:28 +07:00
parent 3b89200c2b
commit 34d1dff0f6
3 changed files with 132 additions and 24 deletions
@@ -1,10 +1,11 @@
import { memo, useCallback, useMemo, useState, useRef } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu, TextInput } from '@repo/ui/components'; import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu, TextInput } from '@repo/ui/components';
import { useCoreAppShell } from '@repo/ui/components'; import { useCoreAppShell } from '@repo/ui/components';
import { ChevronsLeft, ChevronsRight, Search, X } from 'lucide-react'; import { ChevronsLeft, ChevronsRight, PanelTopClose, PanelTopOpen, Search, X } from 'lucide-react';
import type { MenuItemType } from '../types/menu.types'; import type { MenuItemType } from '../types/menu.types';
import type { SidebarVariant } from '@repo/ui/components'; import type { SidebarVariant } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Props // Props
@@ -32,9 +33,17 @@ interface MenuItemExpandedProps {
item: MenuItemType; item: MenuItemType;
activeKeys: Set<string>; activeKeys: Set<string>;
isSearching?: boolean; isSearching?: boolean;
expandVersion?: number;
collapseVersion?: number;
} }
const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys, isSearching }: MenuItemExpandedProps) { const MenuItemExpanded = memo(function MenuItemExpanded({
item,
activeKeys,
isSearching,
expandVersion = 0,
collapseVersion = 0,
}: MenuItemExpandedProps) {
const Icon = item.icon; const Icon = item.icon;
const isActive = activeKeys.has(item.key); const isActive = activeKeys.has(item.key);
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
@@ -45,6 +54,14 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys, isSe
const [opened, setOpened] = useState(isActive); const [opened, setOpened] = useState(isActive);
const isOpened = isSearching || opened; const isOpened = isSearching || opened;
useEffect(() => {
if (expandVersion > 0) setOpened(true);
}, [expandVersion]);
useEffect(() => {
if (collapseVersion > 0) setOpened(false);
}, [collapseVersion]);
return ( return (
<NavLink <NavLink
component={hasChildren ? 'button' : (Link as any)} component={hasChildren ? 'button' : (Link as any)}
@@ -69,7 +86,14 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys, isSe
> >
{hasChildren && {hasChildren &&
item.children!.map((child) => ( item.children!.map((child) => (
<MenuItemExpanded key={child.key} item={child} activeKeys={activeKeys} isSearching={isSearching} /> <MenuItemExpanded
key={child.key}
item={child}
activeKeys={activeKeys}
isSearching={isSearching}
expandVersion={expandVersion}
collapseVersion={collapseVersion}
/>
))} ))}
</NavLink> </NavLink>
); );
@@ -195,6 +219,8 @@ export const SidebarMenu = memo(function SidebarMenu({
withToggle = false, withToggle = false,
withMenuFilter = true, withMenuFilter = true,
}: SidebarMenuProps) { }: SidebarMenuProps) {
const { t } = useTranslation();
const { pathname } = useLocation(); const { pathname } = useLocation();
const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell(); const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell();
@@ -205,6 +231,20 @@ export const SidebarMenu = memo(function SidebarMenu({
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const isSearching = searchQuery.trim().length > 0; const isSearching = searchQuery.trim().length > 0;
const [expandVersion, setExpandVersion] = useState(0);
const [collapseVersion, setCollapseVersion] = useState(0);
const [isAllExpanded, setIsAllExpanded] = useState(false);
const handleToggleExpandAll = useCallback(() => {
if (isAllExpanded) {
setCollapseVersion((v) => v + 1);
setIsAllExpanded(false);
} else {
setExpandVersion((v) => v + 1);
setIsAllExpanded(true);
}
}, [isAllExpanded]);
// Recursive active state calculation // Recursive active state calculation
const activeKeys = useMemo(() => { const activeKeys = useMemo(() => {
const keys = new Set<string>(); const keys = new Set<string>();
@@ -291,6 +331,28 @@ export const SidebarMenu = memo(function SidebarMenu({
} }
}, [isMini, setSidebarVariant]); }, [isMini, setSidebarVariant]);
const [isMac, setIsMac] = useState(true);
useEffect(() => {
setIsMac(typeof window !== 'undefined' && navigator.userAgent.includes('Mac'));
}, []);
const shortcutText = isMac ? '⌘K' : 'Ctrl+K';
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (isMini) {
handleExpandAndSearch();
} else {
inputRef.current?.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isMini, handleExpandAndSearch]);
// -- Mini (Collapsed) Mode ------------------------------------------------ // -- Mini (Collapsed) Mode ------------------------------------------------
if (isMini) { if (isMini) {
return ( return (
@@ -352,29 +414,69 @@ export const SidebarMenu = memo(function SidebarMenu({
borderBottom: '1px solid var(--mantine-color-default-border)', borderBottom: '1px solid var(--mantine-color-default-border)',
}} }}
> >
<TextInput <Box style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
ref={inputRef} <TextInput
placeholder="Filter menu..." ref={inputRef}
value={searchQuery} placeholder={`${t('common:searchMenu')}...`}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)} value={searchQuery}
leftSection={<Search size={14} />} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
variant="filled" leftSection={<Search size={14} />}
radius="md" variant="filled"
size="xs" radius="md"
rightSection={ size="xs"
searchQuery ? ( style={{ flex: 1 }}
<ActionIcon variant="subtle" color="gray" onClick={() => setSearchQuery('')} size="sm"> rightSectionWidth={searchQuery ? 30 : 48}
<X size={14} /> rightSection={
</ActionIcon> searchQuery ? (
) : null <ActionIcon variant="subtle" color="gray" onClick={() => setSearchQuery('')} size="sm">
} <X size={14} />
/> </ActionIcon>
) : (
<span
style={{
fontSize: '10px',
fontWeight: 600,
pointerEvents: 'none',
padding: '2px 4px',
color: 'var(--mantine-color-dimmed)',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-sm)',
}}
>
{shortcutText}
</span>
)
}
/>
<Tooltip
label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
position="bottom"
withArrow
>
<ActionIcon
variant="light"
color="gray"
onClick={handleToggleExpandAll}
size={30}
aria-label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
>
{isAllExpanded ? <PanelTopOpen size={16} /> : <PanelTopClose size={16} />}
</ActionIcon>
</Tooltip>
</Box>
</Box> </Box>
)} )}
<Box p="lg" pt="md" style={{ flex: 1, overflowY: 'auto' }}> <Box p="lg" pt="md" style={{ flex: 1, overflowY: 'auto' }}>
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<MenuItemExpanded key={item.key} item={item} activeKeys={activeKeys} isSearching={isSearching} /> <MenuItemExpanded
key={item.key}
item={item}
activeKeys={activeKeys}
isSearching={isSearching}
expandVersion={expandVersion}
collapseVersion={collapseVersion}
/>
))} ))}
</Box> </Box>
@@ -32,6 +32,9 @@
"systemInformation": "System Information", "systemInformation": "System Information",
"systemInformationDesc": "View details about the system version and status.", "systemInformationDesc": "View details about the system version and status.",
"systemNotifications": "All Notifications", "systemNotifications": "All Notifications",
"systemNotificationsDesc": "View and manage all system notifications." "systemNotificationsDesc": "View and manage all system notifications.",
"expandAll": "Expand all menu",
"collapseAll": "Collapse all menu",
"searchMenu": "Search menu"
} }
} }
@@ -32,6 +32,9 @@
"systemInformation": "Informasi Sistem", "systemInformation": "Informasi Sistem",
"systemInformationDesc": "Lihat detail tentang versi dan status sistem.", "systemInformationDesc": "Lihat detail tentang versi dan status sistem.",
"systemNotifications": "Semua Notifikasi", "systemNotifications": "Semua Notifikasi",
"systemNotificationsDesc": "Lihat dan kelola semua notifikasi sistem." "systemNotificationsDesc": "Lihat dan kelola semua notifikasi sistem.",
"expandAll": "Buka semua menu",
"collapseAll": "Tutup semua menu",
"searchMenu": "Cari menu"
} }
} }