mirror of https://github.com/portainer/portainer
feat(sidebar): update menu structure [EE-5666] (#10418)
parent
b468070945
commit
a0dbabcc5f
@ -1,5 +0,0 @@
|
||||
.root {
|
||||
display: block;
|
||||
margin: -5px auto -8px auto;
|
||||
padding-bottom: 5px;
|
||||
}
|
@ -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}
|
||||
const { isOpen } = useSidebarState();
|
||||
const anchorProps = useSidebarSrefActive(to, undefined, params, undefined, {
|
||||
ignorePaths,
|
||||
includePaths,
|
||||
});
|
||||
|
||||
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 (
|
||||
<Wrapper label={label} className="sidebar">
|
||||
{children ? (
|
||||
<Menu head={head} openOnPaths={[...openOnPaths, ...childrenPath]}>
|
||||
{children}
|
||||
</Menu>
|
||||
) : (
|
||||
head
|
||||
)}
|
||||
</Wrapper>
|
||||
<SidebarTooltip content={<span className="text-sm">{label}</span>}>
|
||||
<span className="w-full">{anchor}</span>
|
||||
</SidebarTooltip>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
Loading…
Reference in new issue