diff --git a/apps/web/src/apps/main/layouts/components/sidebar.store.test.ts b/apps/web/src/apps/main/layouts/components/sidebar.store.test.ts
new file mode 100644
index 0000000..46bbdb5
--- /dev/null
+++ b/apps/web/src/apps/main/layouts/components/sidebar.store.test.ts
@@ -0,0 +1,48 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../../../core/storage/local', () => ({
+ appStorage: {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ },
+ AppStorageKey: {
+ SIDEBAR_OPEN_MENUS: 'sidebar_open_menus',
+ },
+}));
+
+import { appStorage } from '../../../../core/storage/local';
+import { useSidebarStore } from './sidebar.store';
+
+const allParentKeys = ['sales', 'sales-data', 'sales-activities'];
+
+describe('useSidebarStore', () => {
+ beforeEach(() => {
+ useSidebarStore.setState({
+ openedKeys: new Set(),
+ isInitialized: false,
+ isAllExpanded: false,
+ searchQuery: '',
+ });
+ vi.mocked(appStorage.getItem).mockReset();
+ vi.mocked(appStorage.setItem).mockReset();
+ });
+
+ it('restores saved open keys so nested sales branches can render together', async () => {
+ vi.mocked(appStorage.getItem).mockResolvedValue(['sales', 'sales-data']);
+
+ await useSidebarStore.getState().initializeStorage(['sales'], allParentKeys);
+
+ expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
+ expect(useSidebarStore.getState().isInitialized).toBe(true);
+ expect(useSidebarStore.getState().isAllExpanded).toBe(false);
+ });
+
+ it('opens the active path on first visit when nothing is saved', async () => {
+ vi.mocked(appStorage.getItem).mockResolvedValue(null);
+
+ await useSidebarStore.getState().initializeStorage(['sales', 'sales-data'], allParentKeys);
+
+ expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
+ expect(appStorage.setItem).toHaveBeenCalledWith('sidebar_open_menus', ['sales', 'sales-data']);
+ });
+});
diff --git a/apps/web/src/apps/main/layouts/components/sidebar.tsx b/apps/web/src/apps/main/layouts/components/sidebar.tsx
index 9dcecb8..88b8407 100644
--- a/apps/web/src/apps/main/layouts/components/sidebar.tsx
+++ b/apps/web/src/apps/main/layouts/components/sidebar.tsx
@@ -22,6 +22,7 @@ import { useTranslation } from '@repo/core-i18n';
import { shortcutsData } from '@repo/ui/constants';
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
import { useSidebarStore } from './sidebar.store';
+import { getAllParentKeys, shouldShowMenuChildren } from './sidebar.utils';
import { useDebouncedValue } from '@repo/ui/hooks';
// ---------------------------------------------------------------------------
@@ -74,7 +75,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
const isOpened = useSidebarStore((state) => state.openedKeys.has(item.key));
const toggleMenu = useSidebarStore((state) => state.toggleMenu);
- const effectivelyOpened = isSearching || isOpened;
+ const effectivelyOpened = shouldShowMenuChildren(isOpened, isSearching);
const handleChange = useCallback(
(newOpened: boolean) => {
@@ -84,39 +85,46 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
);
return (
- }
- active={isExactActive}
- opened={effectivelyOpened}
- onChange={handleChange}
- variant="light"
- styles={{
- root: {
- borderRadius: 'var(--mantine-radius-md)',
- color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
- },
- label: {
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
- fontWeight: 500,
- },
- }}
- >
- {hasChildren &&
- item.children!.map((child) => (
-
- ))}
-
+ <>
+ }
+ active={isExactActive}
+ opened={effectivelyOpened}
+ onChange={handleChange}
+ variant="light"
+ styles={{
+ root: {
+ borderRadius: 'var(--mantine-radius-md)',
+ color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
+ },
+ label: {
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ fontWeight: 500,
+ },
+ }}
+ >
+ {/* Keep the chevron without nesting real items in Collapse (nested height clips siblings on refresh). */}
+ {hasChildren ? <>> : undefined}
+
+ {hasChildren && effectivelyOpened ? (
+
+ {item.children!.map((child) => (
+
+ ))}
+
+ ) : null}
+ >
);
});
@@ -236,17 +244,6 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot =
// SidebarMenu Component
// ---------------------------------------------------------------------------
-const getAllParentKeys = (items: MenuItemType[]): string[] => {
- let keys: string[] = [];
- for (const item of items) {
- if (item.children && item.children.length > 0) {
- keys.push(item.key);
- keys = keys.concat(getAllParentKeys(item.children));
- }
- }
- return keys;
-};
-
export const SidebarMenu = memo(function SidebarMenu({
items,
variantOverride,
diff --git a/apps/web/src/apps/main/layouts/components/sidebar.utils.test.ts b/apps/web/src/apps/main/layouts/components/sidebar.utils.test.ts
new file mode 100644
index 0000000..44e5a5a
--- /dev/null
+++ b/apps/web/src/apps/main/layouts/components/sidebar.utils.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { MENU_ITEMS } from '../data/menu.data';
+import { getAllParentKeys, getVisibleMenuKeys, shouldShowMenuChildren } from './sidebar.utils';
+
+describe('shouldShowMenuChildren', () => {
+ it('shows children when the branch is open or the user is searching', () => {
+ expect(shouldShowMenuChildren(true, false)).toBe(true);
+ expect(shouldShowMenuChildren(false, true)).toBe(true);
+ expect(shouldShowMenuChildren(false, false)).toBe(false);
+ });
+});
+
+describe('getAllParentKeys', () => {
+ it('includes nested groups under sales, logistics, and settings', () => {
+ expect(getAllParentKeys(MENU_ITEMS)).toEqual(
+ expect.arrayContaining([
+ 'sales',
+ 'sales-data',
+ 'sales-activities',
+ 'logistics',
+ 'logistics-data',
+ 'settings',
+ 'settings-data',
+ ]),
+ );
+ });
+});
+
+describe('getVisibleMenuKeys', () => {
+ it('keeps all siblings of an expanded nested branch visible after restore', () => {
+ const openedKeys = new Set(['sales', 'sales-data']);
+
+ const keys = getVisibleMenuKeys(MENU_ITEMS, openedKeys);
+
+ expect(keys).toContain('sales-employees');
+ expect(keys).toContain('sales-cycles');
+ expect(keys).toContain('sales-activities');
+ expect(keys).toContain('sales-reports');
+ expect(keys).not.toContain('sales-requests');
+ });
+
+ it('reveals every nested item while searching', () => {
+ const keys = getVisibleMenuKeys(MENU_ITEMS, new Set(), true);
+
+ expect(keys).toContain('sales-requests');
+ expect(keys).toContain('logistics-packing-slips');
+ expect(keys).toContain('system-users');
+ });
+});
diff --git a/apps/web/src/apps/main/layouts/components/sidebar.utils.ts b/apps/web/src/apps/main/layouts/components/sidebar.utils.ts
new file mode 100644
index 0000000..448849d
--- /dev/null
+++ b/apps/web/src/apps/main/layouts/components/sidebar.utils.ts
@@ -0,0 +1,42 @@
+import type { MenuItemType } from '../types/menu.types';
+
+export function getAllParentKeys(items: MenuItemType[]): string[] {
+ const keys: string[] = [];
+
+ for (const item of items) {
+ if (item.children && item.children.length > 0) {
+ keys.push(item.key);
+ keys.push(...getAllParentKeys(item.children));
+ }
+ }
+
+ return keys;
+}
+
+export function shouldShowMenuChildren(isOpened: boolean, isSearching: boolean): boolean {
+ return isSearching || isOpened;
+}
+
+/**
+ * Keys that must stay in the accessible tree when a branch is open.
+ * Nested Collapse height bugs clip these siblings after refresh; this list is the contract.
+ */
+export function getVisibleMenuKeys(
+ items: MenuItemType[],
+ openedKeys: Set,
+ isSearching = false,
+): string[] {
+ const keys: string[] = [];
+
+ const walk = (nodes: MenuItemType[]) => {
+ for (const node of nodes) {
+ keys.push(node.key);
+ if (node.children?.length && shouldShowMenuChildren(openedKeys.has(node.key), isSearching)) {
+ walk(node.children);
+ }
+ }
+ };
+
+ walk(items);
+ return keys;
+}