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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* chore: update yarn.lock from dev server startup

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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>
This commit is contained in:
Eunjae Lee
2025-07-08 09:39:38 +00:00
committed by GitHub
co-authored by Devin AI
parent b39eab72e7
commit 35a8918ea9
@@ -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 (
<Fragment>
<Tooltip side="right" content={t(item.name)} className="lg:hidden">
<Link
data-test-id={item.name}
href={item.href}
aria-label={t(item.name)}
target={item.target}
className={classNames(
"todesktop:py-[7px] text-default group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
item.child ? `[&[aria-current='page']]:!bg-transparent` : `[&[aria-current='page']]:bg-emphasis`,
isChild
? `[&[aria-current='page']]:text-emphasis [&[aria-current='page']]:bg-emphasis hidden h-8 pl-16 lg:flex lg:pl-11 ${
props.index === 0 ? "mt-0" : "mt-px"
}`
: "[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
isLocaleReady
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
: ""
)}
aria-current={current ? "page" : undefined}>
{item.icon && (
<Icon
name={item.isLoading ? "rotate-cw" : item.icon}
className={classNames(
"todesktop:!text-blue-500 mr-2 h-4 w-4 flex-shrink-0 rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2 [&[aria-current='page']]:text-inherit",
item.isLoading && "animate-spin"
)}
aria-hidden="true"
aria-current={current ? "page" : undefined}
/>
)}
{isLocaleReady ? (
<span
className="hidden w-full justify-between truncate text-ellipsis lg:flex"
data-testid={`${item.name}-test`}>
{t(item.name)}
{item.badge && item.badge}
</span>
) : (
<SkeletonText className="h-[20px] w-full" />
)}
</Link>
</Tooltip>
{isParentNavigationItem ? (
<Tooltip side="right" content={t(item.name)} className="lg:hidden">
<button
data-test-id={item.name}
aria-label={t(item.name)}
aria-expanded={isExpanded}
aria-current={current ? "page" : undefined}
onClick={() => setIsExpanded(!isExpanded)}
className={classNames(
"todesktop:py-[7px] text-default group flex w-full items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
"[&[aria-current='page']]:!bg-transparent",
"[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
isLocaleReady
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
: ""
)}>
{item.icon && (
<Icon
name={item.isLoading ? "rotate-cw" : item.icon}
className={classNames(
"todesktop:!text-blue-500 mr-2 h-4 w-4 flex-shrink-0 rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2",
item.isLoading && "animate-spin"
)}
aria-hidden="true"
/>
)}
{isLocaleReady ? (
<span className="hidden w-full justify-between truncate text-ellipsis lg:flex">
{t(item.name)}
{item.badge && item.badge}
</span>
) : (
<SkeletonText className="h-[20px] w-full" />
)}
{shouldShowChevron && (
<Icon name={isExpanded ? "chevron-up" : "chevron-down"} className="ml-auto h-4 w-4" />
)}
</button>
</Tooltip>
) : (
<Tooltip side="right" content={t(item.name)} className="lg:hidden">
<Link
data-test-id={item.name}
href={item.href}
aria-label={t(item.name)}
target={item.target}
className={classNames(
"todesktop:py-[7px] text-default group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
item.child
? `[&[aria-current='page']]:!bg-transparent`
: `[&[aria-current='page']]:bg-emphasis`,
isChild
? `[&[aria-current='page']]:text-emphasis [&[aria-current='page']]:bg-emphasis hidden h-8 pl-16 lg:flex lg:pl-11 ${
props.index === 0 ? "mt-0" : "mt-px"
}`
: "[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
isLocaleReady
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
: ""
)}
aria-current={current ? "page" : undefined}>
{item.icon && (
<Icon
name={item.isLoading ? "rotate-cw" : item.icon}
className={classNames(
"todesktop:!text-blue-500 mr-2 h-4 w-4 flex-shrink-0 rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2 [&[aria-current='page']]:text-inherit",
item.isLoading && "animate-spin"
)}
aria-hidden="true"
aria-current={current ? "page" : undefined}
/>
)}
{isLocaleReady ? (
<span
className="hidden w-full justify-between truncate text-ellipsis lg:flex"
data-testid={`${item.name}-test`}>
{t(item.name)}
{item.badge && item.badge}
</span>
) : (
<SkeletonText className="h-[20px] w-full" />
)}
</Link>
</Tooltip>
)}
{item.child &&
isCurrent({ pathname, isChild, item }) &&
shouldShowChildren &&
item.child.map((item, index) => <NavigationItem index={index} key={item.name} item={item} isChild />)}
</Fragment>
);
@@ -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 (
<li className="border-subtle border-b last:border-b-0" key={item.name}>
<Link href={item.href} className="hover:bg-subtle flex items-center justify-between p-5 transition">
<span className="text-default flex items-center font-semibold ">
{item.icon && (
<Icon name={item.icon} className="h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3" aria-hidden="true" />
{hasChildren ? (
<>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="hover:bg-subtle flex w-full items-center justify-between p-5 text-left transition">
<span className="text-default flex items-center font-semibold">
{item.icon && (
<Icon
name={item.icon}
className="h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
aria-hidden="true"
/>
)}
{isLocaleReady ? t(item.name) : <SkeletonText />}
</span>
<Icon name={isExpanded ? "chevron-up" : "chevron-down"} className="text-subtle h-5 w-5" />
</button>
{isExpanded && item.child && (
<ul className="bg-subtle">
{item.child.map((childItem) => (
<li key={childItem.name} className="border-subtle border-t">
<Link
href={childItem.href}
className="hover:bg-muted flex items-center p-4 pl-12 transition">
<span className="text-default font-medium">
{isLocaleReady ? t(childItem.name) : <SkeletonText />}
</span>
</Link>
</li>
))}
</ul>
)}
{isLocaleReady ? t(item.name) : <SkeletonText />}
</span>
<Icon name="arrow-right" className="text-subtle h-5 w-5" />
</Link>
</>
) : (
<Link href={item.href} className="hover:bg-subtle flex items-center justify-between p-5 transition">
<span className="text-default flex items-center font-semibold ">
{item.icon && (
<Icon name={item.icon} className="h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3" aria-hidden="true" />
)}
{isLocaleReady ? t(item.name) : <SkeletonText />}
</span>
<Icon name="arrow-right" className="text-subtle h-5 w-5" />
</Link>
)}
</li>
);
};