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 { 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user