1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

feat(sidebar): update menu structure [EE-5666] (#10418)

This commit is contained in:
Ali 2023-10-09 19:23:12 +01:00 committed by GitHub
parent b468070945
commit a0dbabcc5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 519 additions and 146 deletions

View file

@ -1,52 +1,71 @@
import { ReactNode } from 'react';
import { Icon } from 'lucide-react';
import { Icon as IconTest } from 'lucide-react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { Icon } from '@@/Icon';
import { useSidebarState } from '../useSidebarState';
import { Wrapper } from './Wrapper';
import { Menu } from './Menu';
import { Head } from './Head';
import { getPathsForChildren } from './utils';
import { SidebarTooltip } from './SidebarTooltip';
import { useSidebarSrefActive } from './useSidebarSrefActive';
interface Props extends AutomationTestingProps {
icon?: Icon;
icon?: IconTest;
to: string;
params?: object;
label: string;
children?: ReactNode;
openOnPaths?: string[];
isSubMenu?: boolean;
ignorePaths?: string[];
includePaths?: string[];
}
export function SidebarItem({
children,
icon,
to,
params,
label,
openOnPaths = [],
isSubMenu = false,
ignorePaths = [],
includePaths = [],
'data-cy': dataCy,
}: Props) {
const childrenPath = getPathsForChildren(children);
const head = (
<Head
icon={icon}
to={to}
params={params}
label={label}
ignorePaths={childrenPath}
data-cy={dataCy}
/>
);
const { isOpen } = useSidebarState();
const anchorProps = useSidebarSrefActive(to, undefined, params, undefined, {
ignorePaths,
includePaths,
});
return (
<Wrapper label={label} className="sidebar">
{children ? (
<Menu head={head} openOnPaths={[...openOnPaths, ...childrenPath]}>
{children}
</Menu>
) : (
head
)}
const anchor = (
<Wrapper label={label}>
<a
href={anchorProps.href}
onClick={anchorProps.onClick}
className={clsx(
anchorProps.className,
'text-inherit no-underline hover:text-inherit hover:no-underline focus:text-inherit focus:no-underline',
'flex h-8 w-full flex-1 items-center space-x-4 rounded-md text-sm',
'transition-colors duration-200 hover:bg-blue-5/20 be:hover:bg-gray-5/20 th-dark:hover:bg-gray-true-5/20',
{
// submenu items are always expanded (in a tooltip or in the sidebar)
'w-full justify-start px-3': isOpen || isSubMenu,
'w-8 justify-center': !isOpen && !isSubMenu,
}
)}
data-cy={dataCy}
>
{!!icon && <Icon icon={icon} className={clsx('flex [&>svg]:w-4')} />}
{(isOpen || isSubMenu) && <span>{label}</span>}
</a>
</Wrapper>
);
if (isOpen || isSubMenu) return anchor;
return (
<SidebarTooltip content={<span className="text-sm">{label}</span>}>
<span className="w-full">{anchor}</span>
</SidebarTooltip>
);
}

View file

@ -0,0 +1,135 @@
import clsx from 'clsx';
import { ChevronDown } from 'lucide-react';
import { PropsWithChildren, useState } from 'react';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import { useSidebarState } from '../useSidebarState';
import { Wrapper } from './Wrapper';
import { PathOptions, useSidebarSrefActive } from './useSidebarSrefActive';
import { SidebarTooltip } from './SidebarTooltip';
type Props = {
label: string;
icon: React.ReactNode;
to: string;
'data-cy': string;
pathOptions?: PathOptions;
params?: object;
};
export function SidebarParent({
children,
icon,
label: title,
to,
params,
pathOptions,
'data-cy': dataCy,
}: PropsWithChildren<Props>) {
const anchorProps = useSidebarSrefActive(
to,
undefined,
params,
{},
pathOptions
);
const hasActiveChild = !!anchorProps.className;
const { isOpen: isSidebarOpen } = useSidebarState();
const [isExpanded, setIsExpanded] = useState(hasActiveChild);
const parentItem = (
<Wrapper className="flex flex-col">
<div
className={clsx(
'w-full h-8 items-center ease-in-out transition-colors flex duration-200 hover:bg-blue-5/20 be:hover:bg-gray-5/20 th-dark:hover:bg-gray-true-5/20 rounded-md',
isSidebarOpen && 'pl-3',
// only highlight the parent when the sidebar is closed/contracted and a child item is selected
(!isSidebarOpen || !isExpanded) && anchorProps.className
)}
data-cy={dataCy}
>
<button
type="button"
className="flex-1 h-full cursor-pointer flex items-center border-none bg-transparent"
onClick={() => setIsExpanded(true)}
>
<Link
to={to}
params={params}
className={clsx(
'w-full h-full font-medium items-center flex list-none border-none text-gray-5 hover:text-gray-5 hover:no-underline focus:text-gray-5 focus:no-underline',
{
'justify-start': isSidebarOpen,
'justify-center': !isSidebarOpen,
}
)}
>
<Icon icon={icon} />
{isSidebarOpen && <span className="ml-4">{title}</span>}
</Link>
</button>
{isSidebarOpen && (
<button
type="button"
className="flex-none border-none bg-transparent flex items-center group p-0 px-3 h-8"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center group-hover:bg-blue-5 be:group-hover:bg-gray-5 group-hover:th-dark:bg-gray-true-7 group-hover:bg-opacity-10 be:group-hover:bg-opacity-10 rounded-full p-[3px] transition ease-in-out">
<Icon
icon={ChevronDown}
size="md"
className={clsx('transition ease-in-out', {
'rotate-180': isExpanded,
'rotate-0': !isExpanded,
})}
/>
</div>
</button>
)}
</div>
</Wrapper>
);
const childList = (
<ul
// pl-11 must be important because it needs to avoid the padding from '.root ul' in sidebar.module.css
className={clsx('text-white !pl-11', {
hidden: !isExpanded,
block: isExpanded,
})}
>
{children}
</ul>
);
if (isSidebarOpen)
return (
<>
{parentItem}
{childList}
</>
);
return (
<SidebarTooltip
content={
<ul>
<li className="flex items-center space-x-2 text-sm mb-1">
<span>{title}</span>
</li>
<div className="bg-blue-8/50 be:bg-gray-8 th-dark:bg-gray-true-8 rounded">
{children}
</div>
</ul>
}
>
<span>{parentItem}</span>
</SidebarTooltip>
);
}

View file

@ -0,0 +1,24 @@
import Tippy, { TippyProps } from '@tippyjs/react';
type Props = {
content: React.ReactNode;
children?: TippyProps['children'];
};
export function SidebarTooltip({ children, content }: Props) {
return (
<Tippy
className="sidebar !rounded-md bg-blue-9 p-3 !opacity-100 be:bg-gray-9 th-dark:bg-gray-true-9"
content={content}
delay={[0, 0]}
duration={[0, 0]}
zIndex={1000}
placement="right"
arrow
allowHTML
interactive
>
{children}
</Tippy>
);
}

View file

@ -0,0 +1,51 @@
import {
TransitionOptions,
useCurrentStateAndParams,
useSrefActive,
} from '@uirouter/react';
export type PathOptions = {
ignorePaths?: string[];
includePaths?: string[];
};
/**
* Extends useSrefActive by ignoring or including paths and updating the classNames field returned when a child route is active.
* @param to The route to match
* @param activeClassName The active class names to return
* @param params The route params
* @param options The transition options
* @param pathOptions The paths to ignore/include
*/
export function useSidebarSrefActive(
to: string,
// default values are the classes used in the sidebar for an active item
activeClassName: string = 'bg-blue-5/25 be:bg-gray-5/25 th-dark:bg-gray-true-5/25',
params: Partial<Record<string, string>> = {},
options: TransitionOptions = {},
pathOptions: PathOptions = {
ignorePaths: [],
includePaths: [],
}
) {
const { state: { name: stateName = '' } = {} } = useCurrentStateAndParams();
const anchorProps = useSrefActive(to, params || {}, activeClassName, options);
// overwrite the className to '' if the the current route is in ignorePaths
const isIgnorePathInRoute = pathOptions.ignorePaths?.some((path) =>
stateName.includes(path)
);
if (isIgnorePathInRoute) {
return { ...anchorProps, className: '' };
}
// overwrite the className to activeClassName if the the current route is in includePaths
const isIncludePathInRoute = pathOptions.includePaths?.some((path) =>
stateName.includes(path)
);
if (isIncludePathInRoute) {
return { ...anchorProps, className: activeClassName };
}
return anchorProps;
}

View file

@ -9,10 +9,6 @@ function isReactElement(element: ReactNode): element is ReactElement {
);
}
export function getPathsForChildren(children: ReactNode): string[] {
return Children.map(children, (child) => getPaths(child, []))?.flat() || [];
}
export function getPaths(element: ReactNode, paths: string[]): string[] {
if (!isReactElement(element)) {
return paths;