From 35a8918ea95731b6ef94dd90beab78db58b867e6 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 8 Jul 2025 11:39:38 +0200 Subject: [PATCH] feat: add expandable submenu for sidebar (#22268) * feat: add dropdown submenu for Insights navigation - Add dropdown functionality for desktop Insights menu to show submenu instead of immediate navigation - Add expandable submenu for mobile Insights in More navigation - Users can now select specific insights destination (Bookings, Routing, Router Position) - Maintains existing navigation patterns and accessibility Co-Authored-By: eunjae@cal.com * fix: use expansion state instead of dropdown for insights navigation - Remove dropdown components and use existing child rendering pattern - Add state management for expansion tracking - Change items with children to render as buttons instead of links - Preserve existing behavior when currently on page Co-Authored-By: eunjae@cal.com * feat: keep parent expanded and hide chevron when sub-item is active - Add hasActiveChild logic to detect when any child item is currently active - Keep parent menu expanded when any sub-item is selected - Hide chevron icon when any sub-item is active - Maintain existing expansion behavior when no sub-item is active Co-Authored-By: eunjae@cal.com * feat: add localStorage persistence for navigation expansion state - Replace useState with usePersistedExpansionState hook in both NavigationItem and MobileNavigationMoreItem - Expansion state now persists across page navigations using localStorage - Uses nav-expansion-{itemName} keys following existing localStorage patterns Co-Authored-By: eunjae@cal.com * chore: update yarn.lock from dev server startup Co-Authored-By: eunjae@cal.com * feat: change navigation expansion persistence from localStorage to sessionStorage - Expansion state now persists within browser session but resets when tab closes - Maintains same error handling and key naming patterns Co-Authored-By: eunjae@cal.com * Revert "chore: update yarn.lock from dev server startup" This reverts commit 832f417da96f79979942c99400d2a7b26f889616. * clean up * add aria attributes --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../shell/navigation/NavigationItem.tsx | 217 +++++++++++++----- 1 file changed, 164 insertions(+), 53 deletions(-) diff --git a/packages/features/shell/navigation/NavigationItem.tsx b/packages/features/shell/navigation/NavigationItem.tsx index 535a0f060f..a66e1e790a 100644 --- a/packages/features/shell/navigation/NavigationItem.tsx +++ b/packages/features/shell/navigation/NavigationItem.tsx @@ -1,16 +1,38 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import React, { Fragment } from "react"; +import React, { Fragment, useState, useEffect } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { SkeletonText } from "@calcom/ui/components/skeleton"; import classNames from "@calcom/ui/classNames"; import { Icon } from "@calcom/ui/components/icon"; import type { IconName } from "@calcom/ui/components/icon"; +import { SkeletonText } from "@calcom/ui/components/skeleton"; import { Tooltip } from "@calcom/ui/components/tooltip"; import { useShouldDisplayNavigationItem } from "./useShouldDisplayNavigationItem"; +const usePersistedExpansionState = (itemName: string) => { + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + try { + const stored = sessionStorage.getItem(`nav-expansion-${itemName}`); + if (stored !== null) { + setIsExpanded(JSON.parse(stored)); + } + } catch (_error) {} + }, [itemName]); + + const setPersistedExpansion = (expanded: boolean) => { + setIsExpanded(expanded); + try { + sessionStorage.setItem(`nav-expansion-${itemName}`, JSON.stringify(expanded)); + } catch (_error) {} + }; + + return [isExpanded, setPersistedExpansion] as const; +}; + export type NavigationItemType = { name: string; href: string; @@ -50,55 +72,106 @@ export const NavigationItem: React.FC<{ const isCurrent: NavigationItemType["isCurrent"] = item.isCurrent || defaultIsCurrent; const current = isCurrent({ isChild: !!isChild, item, pathname }); const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item); + const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name); if (!shouldDisplayNavigationItem) return null; + const hasChildren = item.child && item.child.length > 0; + const hasActiveChild = + hasChildren && item.child?.some((child) => isCurrent({ isChild: true, item: child, pathname })); + const shouldShowChildren = isExpanded || hasActiveChild || isCurrent({ pathname, isChild, item }); + const shouldShowChevron = hasChildren && !hasActiveChild; + const isParentNavigationItem = hasChildren && !isChild; + return ( - - - {item.icon && ( - + {isParentNavigationItem ? ( + + + + ) : ( + + + {item.icon && ( + + )} {item.child && - isCurrent({ pathname, isChild, item }) && + shouldShowChildren && item.child.map((item, index) => )} ); @@ -144,20 +217,58 @@ export const MobileNavigationMoreItem: React.FC<{ const { item } = props; const { t, isLocaleReady } = useLocale(); const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item); + const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name); if (!shouldDisplayNavigationItem) return null; + const hasChildren = item.child && item.child.length > 0; + return (
  • - - - {item.icon && ( - - - + + ) : ( + + + {item.icon && ( + + + + )}
  • ); };