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:
@@ -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)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Box style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder="Filter menu..."
|
placeholder={`${t('common:searchMenu')}...`}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
|
||||||
leftSection={<Search size={14} />}
|
leftSection={<Search size={14} />}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
rightSectionWidth={searchQuery ? 30 : 48}
|
||||||
rightSection={
|
rightSection={
|
||||||
searchQuery ? (
|
searchQuery ? (
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => setSearchQuery('')} size="sm">
|
<ActionIcon variant="subtle" color="gray" onClick={() => setSearchQuery('')} size="sm">
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
) : null
|
) : (
|
||||||
|
<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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user