Files
calendar/apps/web/modules/shell/navigation/NavigationItem.tsx
T
Bailey PumfleetGitHubunknown <>Pedro CastroDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
7c66f33de2 chore: UX Fixes (#26643)
* Copy changes

* Move search bar inline with new button

* Get rid of no more results message

* Change hidden badge to (hidden)

* Remove Cal.ai badge from sidebar

* Add dropdown to create button when there is multiple options

* Fix delete dialog

* Saved filters updates

* More string fixes

* Switch members table to use names

* Fix member spacing

* Fix routing form identifier field

* Fix routing forms stuff

* Only show SMS hint on SMS options

* Make workflow delete button minimal

* Fix padding on workflow steps

* Remove min width on workflow title

* Fix delete workflow PR

* Fix org profile buttons

* Fix org profile screen partially scrolled down

* Improve logos & banner uploads

* Personal profile fixes

* Fix settings general view stuff

* Sentence case consistency

* Fix stuff I broke

* Fix fab

* Fix hidden translation string

* Fix text fields

* Make button small for solo users too

* fix: update E2E tests to match sentence case labels in routing forms

* fix: update tests to match sentence case label changes

- insights.e2e.ts: chart titles (14 strings)
- event-types.e2e.ts: Organizer phone number location
- EditLocationDialog.test.tsx: phone number labels

* fix: address Cubic AI review feedback (confidence 9+)

- Replace hardcoded text-gray-500 with text-muted in TextField.tsx hint section
- Replace text locator with data-testid in E2E test for location select

Co-Authored-By: unknown <>

* fix: update E2E tests for sentence case label changes

- Use data-testid selectors for location options (more reliable than text)
- Update field identifiers in routing-forms tests to match new labels
- Fix Long text selector in manage-booking-questions test

* fix: replace text locator with data-testid in manage-booking-questions E2E test

Replace fragile text="Long text" locator with resilient
page.getByTestId("select-option-textarea") selector per E2E best practices.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* fix: use .last() for multiple location select items

---------

Co-authored-by: Pedro Castro <pedro@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-13 00:56:08 -05:00

394 lines
14 KiB
TypeScript

import Link from "next/link";
import { usePathname } from "next/navigation";
import posthog from "posthog-js";
import React, { Fragment, useState, useEffect } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { sessionStorage } from "@calcom/lib/webstorage";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
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(() => {
const stored = sessionStorage.getItem(`nav-expansion-${itemName}`);
if (stored !== null) {
setIsExpanded(JSON.parse(stored));
}
}, [itemName]);
const setPersistedExpansion = (expanded: boolean) => {
setIsExpanded(expanded);
sessionStorage.setItem(
`nav-expansion-${itemName}`,
JSON.stringify(expanded)
);
};
return [isExpanded, setPersistedExpansion] as const;
};
const trackNavigationClick = (itemName: string, parentItemName?: string) => {
posthog.capture("navigation_item_clicked", {
item_name: itemName,
parent_name: parentItemName,
});
};
export type NavigationItemType = {
name: string;
href: string;
isLoading?: boolean;
onClick?: React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>;
target?: HTMLAnchorElement["target"];
badge?: React.ReactNode;
icon?: IconName;
child?: NavigationItemType[];
pro?: true;
onlyMobile?: boolean;
onlyDesktop?: boolean;
moreOnMobile?: boolean;
isCurrent?: ({
item,
isChild,
pathname,
}: {
item: Pick<NavigationItemType, "href">;
isChild?: boolean;
pathname: string | null;
}) => boolean;
};
const defaultIsCurrent: NavigationItemType["isCurrent"] = ({
isChild,
item,
pathname,
}) => {
return isChild
? item.href === pathname
: item.href
? pathname?.startsWith(item.href) ?? false
: false;
};
export const NavigationItem: React.FC<{
index?: number;
item: NavigationItemType;
isChild?: boolean;
}> = (props) => {
const { item, isChild } = props;
const { t, isLocaleReady } = useLocale();
const pathname = usePathname();
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);
const isTablet = useMediaQuery("(max-width: 1024px)");
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
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>
{isParentNavigationItem ? (
<Tooltip
side="right"
open={isTooltipOpen}
content={
hasChildren ? (
<div className="stack-y-1 pointer-events-auto flex flex-col p-1">
<span className="text-subtle px-2 text-xs font-semibold uppercase tracking-wide">
{t(item.name)}
</span>
<div className="flex flex-col gap-1">
{item.child?.map((childItem) => {
const childIsCurrent =
typeof childItem.isCurrent === "function"
? childItem.isCurrent({
isChild: true,
item: childItem,
pathname,
})
: defaultIsCurrent({
isChild: true,
item: childItem,
pathname,
});
return (
<Link
key={childItem.name}
href={childItem.href}
aria-current={childIsCurrent ? "page" : undefined}
onClick={() => {
setIsTooltipOpen(false);
trackNavigationClick(childItem.name, item.name);
}}
className={classNames(
"group relative block rounded-md px-3 py-1 text-sm font-medium",
childIsCurrent
? "bg-emphasis text-white"
: "hover:bg-emphasis text-mute hover:text-emphasis"
)}
>
{t(childItem.name)}
</Link>
);
})}
</div>
</div>
) : (
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);
if (isTablet && hasChildren) {
setIsTooltipOpen(!isTooltipOpen);
}
}}
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-[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 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"
data-testid={`${item.name}-test`}
>
{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}
onClick={() => trackNavigationClick(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-[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-1 hover:mt-1 [&[aria-current='page']]:mt-1"
}`
: "[&[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 shrink-0 aria-[aria-current='page']:text-inherit rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2",
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 &&
shouldShowChildren &&
item.child.map((item, index) => (
<NavigationItem index={index} key={item.name} item={item} isChild />
))}
</Fragment>
);
};
export const MobileNavigationItem: React.FC<{
item: NavigationItemType;
isChild?: boolean;
}> = (props) => {
const { item, isChild } = props;
const pathname = usePathname();
const { t, isLocaleReady } = useLocale();
const isCurrent: NavigationItemType["isCurrent"] =
item.isCurrent || defaultIsCurrent;
const current = isCurrent({ isChild: !!isChild, item, pathname });
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(
props.item
);
if (!shouldDisplayNavigationItem) return null;
return (
<Link
key={item.name}
href={item.href}
target={item.target}
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted bg-transparent! relative my-2 min-w-0 flex-1 overflow-hidden rounded-md p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
aria-current={current ? "page" : undefined}
>
{item.badge && <div className="absolute right-1 top-1">{item.badge}</div>}
{item.icon && (
<Icon
name={item.icon}
className="[&[aria-current='page']]:text-emphasis mx-auto mb-1 block h-5 w-5 shrink-0 text-center text-inherit"
aria-hidden="true"
aria-current={current ? "page" : undefined}
/>
)}
{isLocaleReady ? (
<span className="block truncate">{t(item.name)}</span>
) : (
<SkeletonText />
)}
</Link>
);
};
export const MobileNavigationMoreItem: React.FC<{
item: NavigationItemType;
isChild?: boolean;
}> = (props) => {
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}>
{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 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-cal-muted flex items-center p-4 pl-12 transition"
>
<span className="text-default font-medium">
{isLocaleReady ? t(childItem.name) : <SkeletonText />}
</span>
</Link>
</li>
))}
</ul>
)}
</>
) : (
<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 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>
);
};