389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import classNames from 'classnames';
|
|
import type { ComponentProps, ReactNode } from 'react';
|
|
import { useLayoutEffect } from 'react';
|
|
import { useContext } from 'react';
|
|
import { useRef } from 'react';
|
|
import { VegaLogo } from '../vega-logo';
|
|
import * as NavigationMenu from '@radix-ui/react-navigation-menu';
|
|
import { VegaIcon, VegaIconNames } from '../icon';
|
|
import { Drawer } from '../drawer';
|
|
import { NavLink } from 'react-router-dom';
|
|
import type {
|
|
NavigationElementProps,
|
|
NavigationProps,
|
|
} from './navigation-utils';
|
|
import { setSizeVariantClasses } from './navigation-utils';
|
|
import { NavigationBreakpoint, NavigationContext } from './navigation-utils';
|
|
import {
|
|
NavigationDrawerTrigger,
|
|
NavigationDrawerContext,
|
|
useNavigationDrawer,
|
|
NavigationDrawerContent,
|
|
} from './navigation-drawer';
|
|
|
|
const Logo = ({
|
|
appName,
|
|
homeLink,
|
|
}: Pick<NavigationProps, 'theme' | 'appName' | 'homeLink'>) => {
|
|
const { theme } = useContext(NavigationContext);
|
|
return (
|
|
<div className="flex h-full gap-4 items-center">
|
|
{homeLink ? (
|
|
<NavLink to={homeLink}>
|
|
<VegaLogo className="h-4 group-[.nav-size-small]:h-3" />
|
|
</NavLink>
|
|
) : (
|
|
<VegaLogo className="h-4 group-[.nav-size-small]:h-3" />
|
|
)}
|
|
|
|
{appName && (
|
|
<span
|
|
data-testid="nav-app-name"
|
|
className={classNames(
|
|
'group-[.nav-size-small]:text-sm',
|
|
'font-alpha calt lowercase text-xl tracking-[1px] whitespace-nowrap leading-1',
|
|
'border-l pl-4',
|
|
{
|
|
'border-l-vega-light-200 dark:border-l-vega-dark-200':
|
|
theme === 'system',
|
|
'border-l-vega-light-200': theme === 'light',
|
|
'border-l-vega-dark-200': theme === 'dark' || theme === 'yellow',
|
|
}
|
|
)}
|
|
>
|
|
{appName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const determineIfHidden = ({ hide, hideInDrawer }: NavigationElementProps) => [
|
|
{
|
|
'[.nav-size-full_.navbar_&]:hidden':
|
|
Array.isArray(hide) && hide?.includes(NavigationBreakpoint.Full),
|
|
'[.nav-size-narrow_.navbar_&]:hidden':
|
|
Array.isArray(hide) && hide?.includes(NavigationBreakpoint.Narrow),
|
|
'[.nav-size-small_.navbar_&]:hidden':
|
|
Array.isArray(hide) && hide?.includes(NavigationBreakpoint.Small),
|
|
'[.drawer-content_&]:hidden': hideInDrawer,
|
|
},
|
|
];
|
|
|
|
const Spacer = () => <div className="w-full" aria-hidden="true"></div>;
|
|
|
|
export const NavigationItem = ({
|
|
children,
|
|
className,
|
|
hide,
|
|
hideInDrawer,
|
|
...props
|
|
}: ComponentProps<typeof NavigationMenu.Item> & NavigationElementProps) => {
|
|
const insideDrawer = useContext(NavigationDrawerContext);
|
|
return (
|
|
<NavigationMenu.Item
|
|
className={classNames(
|
|
!insideDrawer && ['h-12 [.navigation-content_&]:h-8 flex items-center'],
|
|
determineIfHidden({ hide, hideInDrawer }),
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</NavigationMenu.Item>
|
|
);
|
|
};
|
|
export const NavigationList = ({
|
|
children,
|
|
className,
|
|
hide,
|
|
hideInDrawer,
|
|
...props
|
|
}: ComponentProps<typeof NavigationMenu.List> & NavigationElementProps) => {
|
|
const insideDrawer = useContext(NavigationDrawerContext);
|
|
if (!insideDrawer && hide === true) {
|
|
return null;
|
|
}
|
|
return (
|
|
<NavigationMenu.List
|
|
className={classNames(
|
|
'flex gap-4 items-center',
|
|
'[.navigation-content_&]:flex-col [.navigation-content_&]:items-start',
|
|
'[.drawer-content_&]:flex-col [.drawer-content_&]:items-start [.drawer-content_&]:gap-6 [.drawer-content_&]:mt-2',
|
|
'[.drawer-content_.navigation-content_&]:mt-6',
|
|
determineIfHidden({ hide, hideInDrawer }),
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</NavigationMenu.List>
|
|
);
|
|
};
|
|
export const NavigationTrigger = ({
|
|
children,
|
|
className,
|
|
isActive = false,
|
|
hide,
|
|
hideInDrawer,
|
|
...props
|
|
}: ComponentProps<typeof NavigationMenu.Trigger> & {
|
|
isActive?: boolean;
|
|
} & NavigationElementProps) => {
|
|
const { theme } = useContext(NavigationContext);
|
|
const insideDrawer = useContext(NavigationDrawerContext);
|
|
return (
|
|
<NavigationMenu.Trigger
|
|
className={classNames(
|
|
'h-12 [.drawer-content_&]:h-min flex items-center relative gap-2',
|
|
{
|
|
'text-black dark:text-white': isActive && theme === 'system',
|
|
'text-black': isActive && theme === 'light',
|
|
'text-white': isActive && theme === 'dark',
|
|
'text-black dark:[.drawer-content_&]:text-white':
|
|
isActive && theme === 'yellow',
|
|
},
|
|
determineIfHidden({ hide, hideInDrawer }),
|
|
className
|
|
)}
|
|
onPointerMove={(e) => e.preventDefault()} // disables hover
|
|
onPointerLeave={(e) => e.preventDefault()} // disables hover
|
|
disabled={insideDrawer}
|
|
{...props}
|
|
>
|
|
<span>{children}</span>
|
|
<span className="flex items-center group-data-open/drawer:hidden">
|
|
<VegaIcon name={VegaIconNames.ARROW_DOWN} />
|
|
</span>
|
|
<div
|
|
aria-hidden="true"
|
|
className={classNames(
|
|
'absolute bottom-0 left-0 w-full h-[2px] [.navigation-content_&]:hidden [.drawer-content_&]:hidden',
|
|
{ hidden: !isActive },
|
|
{
|
|
'bg-vega-yellow-550 dark:bg-vega-yellow-500': theme === 'system',
|
|
'bg-vega-yellow-550': theme === 'light',
|
|
'bg-vega-yellow-500': theme === 'dark',
|
|
'bg-black': theme === 'yellow',
|
|
}
|
|
)}
|
|
></div>
|
|
</NavigationMenu.Trigger>
|
|
);
|
|
};
|
|
export const NavigationContent = ({
|
|
children,
|
|
className,
|
|
...props
|
|
}: ComponentProps<typeof NavigationMenu.Content>) => {
|
|
const { theme } = useContext(NavigationContext);
|
|
const insideDrawer = useContext(NavigationDrawerContext);
|
|
|
|
const content = (
|
|
<NavigationMenu.Content
|
|
onPointerLeave={(e) => e.preventDefault()} // disables hover
|
|
asChild
|
|
{...props}
|
|
>
|
|
<div
|
|
className={classNames(
|
|
'navigation-content',
|
|
'absolute z-20 top-12 w-max',
|
|
'p-6 mt-1 min-w-[290px]',
|
|
'text-vega-light-300 dark:text-vega-dark-300',
|
|
'border rounded border-vega-light-200 dark:border-vega-dark-200',
|
|
'text-vega-light-300 dark:text-vega-light-300',
|
|
{
|
|
'bg-vega-light-100 dark:bg-vega-dark-100':
|
|
theme === 'system' || theme === 'yellow',
|
|
'bg-vega-light-100': theme === 'light',
|
|
'bg-vega-dark-100': theme === 'dark',
|
|
}
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
</NavigationMenu.Content>
|
|
);
|
|
|
|
const list = (
|
|
<div className={classNames('navigation-content', 'border-none pl-4')}>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
return insideDrawer ? list : content;
|
|
};
|
|
|
|
export const NavigationLink = ({
|
|
children,
|
|
to,
|
|
...props
|
|
}: ComponentProps<typeof NavLink>) => {
|
|
const { theme } = useContext(NavigationContext);
|
|
const setDrawerOpen = useNavigationDrawer((state) => state.setDrawerOpen);
|
|
return (
|
|
<NavigationMenu.Link
|
|
asChild
|
|
onClick={() => {
|
|
setDrawerOpen(false);
|
|
}}
|
|
>
|
|
<NavLink
|
|
to={to}
|
|
className={classNames(
|
|
'h-12 [.navigation-content_&]:h-min [.drawer-content_&]:h-min flex items-center relative'
|
|
)}
|
|
{...props}
|
|
>
|
|
{({ isActive }) => (
|
|
<>
|
|
<span
|
|
className={classNames({
|
|
'text-black dark:text-white': isActive && theme === 'system',
|
|
'text-black': isActive && theme === 'light',
|
|
'text-white': isActive && theme === 'dark',
|
|
'text-black dark:[.navigation-content_&]:text-white dark:[.drawer-content_&]:text-white':
|
|
isActive && theme === 'yellow',
|
|
})}
|
|
>
|
|
{children as ReactNode}
|
|
</span>
|
|
<div
|
|
aria-hidden="true"
|
|
className={classNames(
|
|
'absolute bottom-0 left-0 w-full h-[2px] [.navigation-content_&]:hidden [.drawer-content_&]:hidden',
|
|
{ hidden: !isActive },
|
|
{
|
|
'bg-vega-yellow-550 dark:bg-vega-yellow-500':
|
|
theme === 'system',
|
|
'bg-vega-yellow-550': theme === 'light',
|
|
'bg-vega-yellow-500': theme === 'dark',
|
|
'bg-black': theme === 'yellow',
|
|
}
|
|
)}
|
|
></div>
|
|
</>
|
|
)}
|
|
</NavLink>
|
|
</NavigationMenu.Link>
|
|
);
|
|
};
|
|
|
|
export const Navigation = ({
|
|
appName,
|
|
homeLink = '/',
|
|
children,
|
|
actions,
|
|
theme = 'system',
|
|
breakpoints = [478, 1000],
|
|
onResize,
|
|
}: NavigationProps) => {
|
|
const navigationRef = useRef<HTMLElement>(null);
|
|
const actionsRef = useRef<HTMLDivElement>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!navigationRef.current) return;
|
|
const target = navigationRef.current;
|
|
const currentWidth = Math.min(
|
|
target.getBoundingClientRect().width,
|
|
window.innerWidth
|
|
);
|
|
setSizeVariantClasses(breakpoints, currentWidth, target);
|
|
onResize?.(currentWidth, target);
|
|
|
|
const handler = () => {
|
|
const currentWidth = Math.min(
|
|
target.getBoundingClientRect().width,
|
|
window.innerWidth
|
|
);
|
|
setSizeVariantClasses(breakpoints, currentWidth, target);
|
|
onResize?.(currentWidth, target);
|
|
};
|
|
window.addEventListener('resize', handler);
|
|
return () => {
|
|
window.removeEventListener('resize', handler);
|
|
};
|
|
}, [breakpoints, onResize]);
|
|
|
|
const { drawerOpen, setDrawerOpen } = useNavigationDrawer((state) => ({
|
|
drawerOpen: state.drawerOpen,
|
|
setDrawerOpen: state.setDrawerOpen,
|
|
}));
|
|
|
|
return (
|
|
<NavigationContext.Provider value={{ theme }}>
|
|
<NavigationMenu.Root
|
|
ref={navigationRef}
|
|
id="navigation"
|
|
className={classNames(
|
|
'h-12',
|
|
'group flex gap-4 items-center',
|
|
'border-b px-3 relative',
|
|
// text
|
|
{
|
|
'text-black dark:text-white': theme === 'system',
|
|
'text-black': theme === 'light' || theme === 'yellow',
|
|
'text-white': theme === 'dark',
|
|
},
|
|
// border
|
|
{
|
|
'border-b-vega-light-200 dark:border-b-vega-dark-200':
|
|
theme === 'system',
|
|
'border-b-vega-light-200': theme === 'light',
|
|
'border-b-vega-dark-200': theme === 'dark',
|
|
'border-b-black': theme === 'yellow',
|
|
},
|
|
// background
|
|
{
|
|
'bg-white dark:bg-black': theme === 'system',
|
|
'bg-white': theme === 'light',
|
|
'bg-black': theme === 'dark',
|
|
'bg-vega-yellow-500': theme === 'yellow',
|
|
}
|
|
)}
|
|
data-testid="navigation"
|
|
>
|
|
<Logo appName={appName} theme={theme} homeLink={homeLink} />
|
|
<div
|
|
className={classNames(
|
|
'navbar',
|
|
'flex gap-4 h-12 items-center font-alpha text-lg',
|
|
{
|
|
'text-vega-light-300 dark:text-vega-dark-300': theme === 'system',
|
|
'text-vega-light-300': theme === 'light',
|
|
'text-vega-dark-300': theme === 'dark',
|
|
'text-vega-dark-200': theme === 'yellow',
|
|
}
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
<Spacer />
|
|
{(actions || children) && (
|
|
<div ref={actionsRef} className="flex gap-2 items-center">
|
|
{actions}
|
|
<Drawer
|
|
open={drawerOpen}
|
|
onChange={(isOpen) => setDrawerOpen(isOpen)}
|
|
trigger={<NavigationDrawerTrigger theme={theme} />}
|
|
container={actionsRef.current}
|
|
>
|
|
<NavigationDrawerContent
|
|
theme={theme}
|
|
style={{
|
|
paddingTop: `${
|
|
navigationRef?.current?.getBoundingClientRect().bottom || 0
|
|
}px`,
|
|
}}
|
|
>
|
|
{children}
|
|
</NavigationDrawerContent>
|
|
</Drawer>
|
|
</div>
|
|
)}
|
|
</NavigationMenu.Root>
|
|
</NavigationContext.Provider>
|
|
);
|
|
};
|