[WEB-4050] feat: breadcrumbs revamp (#7188)

* chore: project feature enum added

* feat: revamp breadcrumb and add navigation dropdown component

* chore: custom search select component refactoring

* chore: breadcrumb stories added

* chore: switch label and breadcrumb link component refactor

* chore: project navigation helper function added

* chore: common breadcrumb component added

* chore: breadcrumb refactoring

* chore: code refactor

* chore: code refactor

* fix: build error

* fix: nprogress and button tooltip

* chore: code refactor

* chore: workspace view breadcrumb improvements

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: vamsikrishnamathala <matalav55@gmail.com>
This commit is contained in:
Anmol Singh Bhatia 2025-06-19 17:17:14 +05:30 committed by GitHub
parent 64fd0b2830
commit 2b7a17b484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1251 additions and 581 deletions

View file

@ -0,0 +1,233 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Home, Settings, Briefcase, GridIcon, Layers2, FileIcon } from "lucide-react";
import * as React from "react";
import { ContrastIcon, EpicIcon, LayersIcon } from "../icons";
import { Breadcrumbs } from "./breadcrumbs";
import { BreadcrumbNavigationDropdown } from "./navigation-dropdown";
const meta: Meta<typeof Breadcrumbs> = {
title: "UI/Breadcrumbs",
component: Breadcrumbs,
tags: ["autodocs"],
argTypes: {
isLoading: {
control: "boolean",
description: "Shows loading state of breadcrumbs",
},
onBack: {
action: "onBack",
description: "Callback function when back button is clicked",
},
},
};
type TBreadcrumbBlockProps = {
href?: string;
label?: string;
icon?: React.ReactNode;
disableTooltip?: boolean;
};
// TODO: remove this component and use web Link component
const BreadcrumbBlock: React.FC<TBreadcrumbBlockProps> = (props) => {
const { label, icon, disableTooltip = false } = props;
return (
<>
<Breadcrumbs.ItemWrapper label={label} disableTooltip={disableTooltip}>
{icon && <div className="flex size-4 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>}
{label && <div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>}
</Breadcrumbs.ItemWrapper>
</>
);
};
export default meta;
type Story = StoryObj<typeof Breadcrumbs>;
export const Default: Story = {
args: {
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
<Breadcrumbs.Item
key="current"
component={<BreadcrumbBlock href="/projects/current" label="Current Project" />}
/>,
],
},
};
export const WithLoading: Story = {
args: {
isLoading: true,
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
],
},
};
export const WithCustomComponent: Story = {
args: {
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item
key="custom"
component={
<div className="flex items-center gap-2">
<span className="size-4 rounded-full bg-blue-500" />
<span>Custom Component</span>
</div>
}
/>,
],
},
};
export const SingleItem: Story = {
args: {
children: [<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />],
},
};
export const WithNavigationDropdown: Story = {
args: {
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item
key="projects"
component={
<BreadcrumbNavigationDropdown
selectedItemKey="project-1"
navigationItems={[
{
key: "project-1",
title: "Project Alpha",
action: () => console.log("Project Alpha selected"),
},
{
key: "project-2",
title: "Project Beta",
action: () => console.log("Project Beta selected"),
},
{
key: "project-3",
title: "Project Gamma",
action: () => console.log("Project Gamma selected"),
},
]}
/>
}
showSeparator={false}
/>,
<Breadcrumbs.Item key="settings" component={<BreadcrumbBlock href="/settings" label="Settings" />} />,
],
},
};
export const WithNavigationDropdownAndIcons: Story = {
args: {
children: [
<Breadcrumbs.Item
key="home"
component={<BreadcrumbBlock href="/" label="Home" icon={<Home className="size-3.5" />} />}
/>,
<Breadcrumbs.Item
key="projects"
component={
<BreadcrumbNavigationDropdown
selectedItemKey="project-1"
navigationItems={[
{
key: "project-1",
title: "Project Alpha",
icon: Briefcase,
action: () => console.log("Project Alpha selected"),
},
{
key: "project-2",
title: "Project Beta",
icon: Briefcase,
// disabled: true,
action: () => console.log("Project Beta selected"),
},
{
key: "project-3",
title: "Project Gamma",
icon: Briefcase,
action: () => console.log("Project Gamma selected"),
},
]}
/>
}
showSeparator={false}
/>,
<Breadcrumbs.Item
key="features"
component={
<BreadcrumbNavigationDropdown
selectedItemKey="feature-1"
navigationItems={[
{
key: "feature-1",
title: "Epics",
icon: EpicIcon,
action: () => console.log("Feature Alpha selected"),
},
{
key: "feature-2",
title: "Work items",
icon: LayersIcon,
// disabled: true,
action: () => console.log("Feature Beta selected"),
},
{
key: "feature-3",
title: "Cycles",
icon: ContrastIcon,
action: () => console.log("Feature Gamma selected"),
},
{
key: "feature-3",
title: "Modules",
icon: GridIcon,
action: () => console.log("Feature Gamma selected"),
},
{
key: "feature-3",
title: "Views",
icon: Layers2,
action: () => console.log("Feature Gamma selected"),
},
{
key: "feature-3",
title: "Pages",
icon: FileIcon,
action: () => console.log("Feature Gamma selected"),
},
]}
/>
}
showSeparator={false}
/>,
<Breadcrumbs.Item
key="settings"
component={<BreadcrumbBlock href="/settings" label="Settings" icon={<Settings className="size-3.5" />} />}
isLast
/>,
],
},
};

View file

@ -1,13 +1,25 @@
import * as React from "react";
import { ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "../../helpers";
import { Tooltip } from "../tooltip";
type BreadcrumbsProps = {
className?: string;
children: React.ReactNode;
onBack?: () => void;
isLoading?: boolean;
};
const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) => {
export const BreadcrumbItemLoader = () => (
<div className="flex items-center gap-2 h-7 animate-pulse">
<div className="group h-full flex items-center gap-2 rounded px-2 py-1 text-sm font-medium">
<span className="h-full w-5 bg-custom-background-80 rounded" />
<span className="h-full w-16 bg-custom-background-80 rounded" />
</div>
</div>
);
const Breadcrumbs = ({ className, children, onBack, isLoading = false }: BreadcrumbsProps) => {
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
React.useEffect(() => {
@ -22,35 +34,31 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps)
const childrenArray = React.Children.toArray(children);
const BreadcrumbItemLoader = (
<div className="flex items-center gap-1 animate-pulse">
<span className="h-5 w-5 bg-custom-background-80 rounded" />
<span className="h-5 w-16 bg-custom-background-80 rounded" />
</div>
);
return (
<div className="flex items-center space-x-2 overflow-hidden">
<div className={cn("flex items-center overflow-hidden gap-0.5 flex-grow", className)}>
{!isSmallScreen && (
<>
{childrenArray.map((child, index) => (
<React.Fragment key={index}>
{index > 0 && !isSmallScreen && (
<div className="flex items-center gap-2.5">
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
</div>
)}
<div className={`flex items-center gap-2.5 ${isSmallScreen && index > 0 ? "hidden sm:flex" : "flex"}`}>
{isLoading ? BreadcrumbItemLoader : child}
</div>
</React.Fragment>
))}
{childrenArray.map((child, index) => {
if (isLoading) {
return (
<>
<BreadcrumbItemLoader />
</>
);
}
if (React.isValidElement<BreadcrumbItemProps>(child)) {
return React.cloneElement(child, {
isLast: index === childrenArray.length - 1,
});
}
return child;
})}
</>
)}
{isSmallScreen && childrenArray.length > 1 && (
<>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-2.5 p-1">
{onBack && (
<span onClick={onBack} className="text-custom-text-200">
...
@ -58,8 +66,16 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps)
)}
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
</div>
<div className="flex items-center gap-2.5">
{isLoading ? BreadcrumbItemLoader : childrenArray[childrenArray.length - 1]}
<div className="flex items-center gap-2.5 p-1">
{isLoading ? (
<BreadcrumbItemLoader />
) : React.isValidElement(childrenArray[childrenArray.length - 1]) ? (
React.cloneElement(childrenArray[childrenArray.length - 1] as React.ReactElement, {
isLast: true,
})
) : (
childrenArray[childrenArray.length - 1]
)}
</div>
</>
)}
@ -68,17 +84,107 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps)
);
};
type Props = {
type?: "text" | "component";
// breadcrumb item
type BreadcrumbItemProps = {
component?: React.ReactNode;
link?: JSX.Element;
showSeparator?: boolean;
isLast?: boolean;
};
const BreadcrumbItem: React.FC<Props> = (props) => {
const { type = "text", component, link } = props;
return <>{type !== "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = (props) => {
const { component, showSeparator = true, isLast = false } = props;
return (
<div className="flex items-center gap-0.5 h-6">
{component}
{showSeparator && !isLast && <BreadcrumbSeparator />}
</div>
);
};
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
// breadcrumb icon
type BreadcrumbIconProps = {
children: React.ReactNode;
className?: string;
};
export { Breadcrumbs, BreadcrumbItem };
const BreadcrumbIcon: React.FC<BreadcrumbIconProps> = (props) => {
const { children, className } = props;
return <div className={cn("flex size-4 items-center justify-start overflow-hidden", className)}>{children}</div>;
};
// breadcrumb label
type BreadcrumbLabelProps = {
children: React.ReactNode;
className?: string;
};
const BreadcrumbLabel: React.FC<BreadcrumbLabelProps> = (props) => {
const { children, className } = props;
return (
<div className={cn("relative line-clamp-1 block max-w-[150px] overflow-hidden truncate", className)}>
{children}
</div>
);
};
// breadcrumb separator
type BreadcrumbSeparatorProps = {
className?: string;
containerClassName?: string;
iconClassName?: string;
showDivider?: boolean;
};
const BreadcrumbSeparator: React.FC<BreadcrumbSeparatorProps> = (props) => {
const { className, containerClassName, iconClassName, showDivider = false } = props;
return (
<div className={cn("relative flex items-center justify-center h-full px-1.5 py-1", className)}>
{showDivider && <span className="absolute -left-0.5 top-0 h-full w-[1.8px] bg-custom-background-100" />}
<div
className={cn(
"flex items-center justify-center flex-shrink-0 rounded text-custom-text-400 transition-all",
containerClassName
)}
>
<ChevronRight className={cn("h-3.5 w-3.5 flex-shrink-0", iconClassName)} />
</div>
</div>
);
};
// breadcrumb wrapper
type BreadcrumbItemWrapperProps = {
label?: string;
disableTooltip?: boolean;
children: React.ReactNode;
className?: string;
type?: "link" | "text";
isLast?: boolean;
};
const BreadcrumbItemWrapper: React.FC<BreadcrumbItemWrapperProps> = (props) => {
const { label, disableTooltip = false, children, className, type = "link", isLast = false } = props;
return (
<Tooltip tooltipContent={label} position="bottom" disabled={!label || label === "" || disableTooltip}>
<div
className={cn(
"group h-full flex items-center gap-2 rounded px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-default",
{
"hover:text-custom-text-100 hover:bg-custom-background-90 cursor-pointer": type === "link" && !isLast,
},
className
)}
>
{children}
</div>
</Tooltip>
);
};
Breadcrumbs.Item = BreadcrumbItem;
Breadcrumbs.Icon = BreadcrumbIcon;
Breadcrumbs.Label = BreadcrumbLabel;
Breadcrumbs.Separator = BreadcrumbSeparator;
Breadcrumbs.ItemWrapper = BreadcrumbItemWrapper;
export { Breadcrumbs, BreadcrumbItem, BreadcrumbIcon, BreadcrumbLabel, BreadcrumbSeparator, BreadcrumbItemWrapper };

View file

@ -1,2 +1,3 @@
export * from "./breadcrumbs";
export * from "./navigation-dropdown";
export * from "./navigation-search-dropdown";

View file

@ -1,42 +1,54 @@
"use client";
import { CheckIcon } from "lucide-react";
import * as React from "react";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { cn } from "../../helpers";
// ui
import { CustomMenu, TContextMenuItem } from "../dropdowns";
// helpers
import { cn } from "../../helpers";
import { Tooltip } from "../tooltip";
import { Breadcrumbs } from "./breadcrumbs";
type TBreadcrumbNavigationDropdownProps = {
selectedItemKey: string;
navigationItems: TContextMenuItem[];
navigationDisabled?: boolean;
handleOnClick?: () => void;
isLast?: boolean;
};
export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdownProps) => {
const { selectedItemKey, navigationItems, navigationDisabled = false } = props;
const { selectedItemKey, navigationItems, navigationDisabled = false, handleOnClick, isLast = false } = props;
const [isOpen, setIsOpen] = React.useState(false);
// derived values
const selectedItem = navigationItems.find((item) => item.key === selectedItemKey);
const selectedItemIcon = selectedItem?.icon ? (
<selectedItem.icon className={cn("size-3.5", selectedItem.iconClassName)} />
<selectedItem.icon className={cn("size-4", selectedItem.iconClassName)} />
) : undefined;
// if no selected item, return null
if (!selectedItem) return null;
const NavigationButton = ({ className }: { className?: string }) => (
<li
className={cn(
"flex items-center justify-center cursor-default text-sm font-medium text-custom-text-200 group-hover:text-custom-text-100 outline-none",
className
)}
tabIndex={-1}
>
{selectedItemIcon && (
<div className="flex h-5 w-5 items-center justify-start overflow-hidden">{selectedItemIcon}</div>
)}
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{selectedItem.title}</div>
</li>
const NavigationButton = () => (
<Tooltip tooltipContent={selectedItem.title} position="bottom" disabled={isOpen}>
<button
onClick={(e) => {
if (!isLast) {
e.preventDefault();
e.stopPropagation();
handleOnClick?.();
}
}}
className={cn(
"group h-full flex items-center gap-2 px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-pointer rounded rounded-r-none",
{
"hover:bg-custom-background-80 hover:text-custom-text-100": !isLast,
}
)}
>
{selectedItemIcon && <Breadcrumbs.Icon>{selectedItemIcon}</Breadcrumbs.Icon>}
<Breadcrumbs.Label>{selectedItem.title}</Breadcrumbs.Label>
</button>
</Tooltip>
);
if (navigationDisabled) {
@ -46,13 +58,37 @@ export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdow
return (
<CustomMenu
customButton={
<div className="group flex items-center gap-1.5">
<NavigationButton className="cursor-pointer" />
<ChevronDownIcon className="size-4 text-custom-text-200 group-hover:text-custom-text-100" />
</div>
<>
<NavigationButton />
<Breadcrumbs.Separator
className={cn("rounded-r", {
"bg-custom-background-80": isOpen && !isLast,
"hover:bg-custom-background-80": !isLast,
})}
containerClassName="p-0"
iconClassName={cn("group-hover:rotate-90 hover:text-custom-text-100", {
"text-custom-text-100": isOpen,
"rotate-90": isOpen || isLast,
})}
showDivider={!isLast}
/>
</>
}
placement="bottom-start"
className="h-full rounded"
customButtonClassName={cn(
"group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded",
{
"bg-custom-background-90": isOpen,
}
)}
closeOnSelect
menuButtonOnClick={() => {
setIsOpen(!isOpen);
}}
onMenuClose={() => {
setIsOpen(false);
}}
>
{navigationItems.map((item) => {
if (item.shouldRender === false) return null;
@ -74,7 +110,7 @@ export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdow
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("size-3.5", item.iconClassName)} />}
{item.icon && <item.icon className={cn("size-4 flex-shrink-0", item.iconClassName)} />}
<div className="w-full">
<h5>{item.title}</h5>
{item.description && (

View file

@ -0,0 +1,96 @@
import * as React from "react";
import { useState } from "react";
import { ICustomSearchSelectOption } from "@plane/types";
import { cn } from "../../helpers";
import { CustomSearchSelect } from "../dropdowns";
import { Tooltip } from "../tooltip";
import { Breadcrumbs } from "./breadcrumbs";
type TBreadcrumbNavigationSearchDropdownProps = {
icon?: React.JSX.Element;
title?: string;
selectedItem: string;
navigationItems: ICustomSearchSelectOption[];
onChange?: (value: string) => void;
navigationDisabled?: boolean;
isLast?: boolean;
handleOnClick?: () => void;
disableRootHover?: boolean;
};
export const BreadcrumbNavigationSearchDropdown: React.FC<TBreadcrumbNavigationSearchDropdownProps> = (props) => {
const {
icon,
title,
selectedItem,
navigationItems,
onChange,
navigationDisabled = false,
isLast = false,
handleOnClick,
} = props;
// state
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
return (
<CustomSearchSelect
onOpen={() => {
setIsDropdownOpen(true);
}}
onClose={() => {
setIsDropdownOpen(false);
}}
options={navigationItems}
value={selectedItem}
onChange={(value: string) => {
if (value !== selectedItem) {
onChange?.(value);
}
}}
customButton={
<>
<Tooltip tooltipContent={title} position="bottom">
<button
onClick={(e) => {
if (!isLast) {
e.preventDefault();
e.stopPropagation();
handleOnClick?.();
}
}}
className={cn(
"group h-full flex items-center gap-2 px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-pointer rounded rounded-r-none",
{
"hover:bg-custom-background-80 hover:text-custom-text-100": !isLast,
}
)}
>
{icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
<Breadcrumbs.Label>{title}</Breadcrumbs.Label>
</button>
</Tooltip>
<Breadcrumbs.Separator
className={cn("rounded-r", {
"bg-custom-background-80": isDropdownOpen && !isLast,
"hover:bg-custom-background-80": !isLast,
})}
containerClassName="p-0"
iconClassName={cn("group-hover:rotate-90 hover:text-custom-text-100", {
"text-custom-text-100": isDropdownOpen,
"rotate-90": isDropdownOpen || isLast,
})}
showDivider={!isLast}
/>
</>
}
disabled={navigationDisabled}
className="h-full rounded"
customButtonClassName={cn(
"group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded",
{
"bg-custom-background-90": isDropdownOpen,
}
)}
/>
);
};

View file

@ -61,6 +61,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
if (onOpen) onOpen();
};
const closeDropdown = () => {
@ -95,11 +96,14 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex w-full items-center justify-between gap-1 text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
className={cn(
"flex w-full items-center justify-between gap-1 text-xs",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer hover:bg-custom-background-80": !disabled,
},
customButtonClassName
)}
onClick={toggleDropdown}
>
{customButton}

View file

@ -38,7 +38,10 @@ const Header = (props: HeaderProps) => {
const LeftItem = (props: HeaderProps) => (
<div
className={cn("flex flex-wrap items-center gap-2 overflow-ellipsis whitespace-nowrap max-w-[80%]", props.className)}
className={cn(
"flex flex-wrap items-center gap-2 overflow-ellipsis whitespace-nowrap max-w-[80%] flex-grow",
props.className
)}
>
{props.children}
</div>