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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user