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 { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu, TextInput } 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 { SidebarVariant } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
// ---------------------------------------------------------------------------
// Props
@@ -32,9 +33,17 @@ interface MenuItemExpandedProps {
item: MenuItemType;
activeKeys: Set<string>;
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 isActive = activeKeys.has(item.key);
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 isOpened = isSearching || opened;
useEffect(() => {
if (expandVersion > 0) setOpened(true);
}, [expandVersion]);
useEffect(() => {
if (collapseVersion > 0) setOpened(false);
}, [collapseVersion]);
return (
<NavLink
component={hasChildren ? 'button' : (Link as any)}
@@ -69,7 +86,14 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys, isSe
>
{hasChildren &&
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>
);
@@ -195,6 +219,8 @@ export const SidebarMenu = memo(function SidebarMenu({
withToggle = false,
withMenuFilter = true,
}: SidebarMenuProps) {
const { t } = useTranslation();
const { pathname } = useLocation();
const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell();
@@ -205,6 +231,20 @@ export const SidebarMenu = memo(function SidebarMenu({
const inputRef = useRef<HTMLInputElement>(null);
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
const activeKeys = useMemo(() => {
const keys = new Set<string>();
@@ -291,6 +331,28 @@ export const SidebarMenu = memo(function SidebarMenu({
}
}, [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 ------------------------------------------------
if (isMini) {
return (
@@ -352,29 +414,69 @@ export const SidebarMenu = memo(function SidebarMenu({
borderBottom: '1px solid var(--mantine-color-default-border)',
}}
>
<TextInput
ref={inputRef}
placeholder="Filter menu..."
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
leftSection={<Search size={14} />}
variant="filled"
radius="md"
size="xs"
rightSection={
searchQuery ? (
<ActionIcon variant="subtle" color="gray" onClick={() => setSearchQuery('')} size="sm">
<X size={14} />
</ActionIcon>
) : null
}
/>
<Box style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<TextInput
ref={inputRef}
placeholder={`${t('common:searchMenu')}...`}
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
leftSection={<Search size={14} />}
variant="filled"
radius="md"
size="xs"
style={{ flex: 1 }}
rightSectionWidth={searchQuery ? 30 : 48}
rightSection={
searchQuery ? (
<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 p="lg" pt="md" style={{ flex: 1, overflowY: 'auto' }}>
{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>
@@ -32,6 +32,9 @@
"systemInformation": "System Information",
"systemInformationDesc": "View details about the system version and status.",
"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",
"systemInformationDesc": "Lihat detail tentang versi dan status sistem.",
"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"
}
}