Created a breadcrumb for left nav menu sub items (#6762)

Closes https://github.com/twentyhq/twenty/issues/6484

<img width="270" alt="image"
src="https://github.com/user-attachments/assets/3cfd7a5a-5239-4998-87f7-a9b45e3b5229">
This commit is contained in:
Lucas Bordeau
2024-08-30 15:10:18 +02:00
committed by GitHub
parent 09ac8e3274
commit 26eba76fb5
16 changed files with 450 additions and 165 deletions

View File

@ -4,7 +4,7 @@ import { IconSearch, IconSettings } from 'twenty-ui';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { Favorites } from '@/favorites/components/Favorites'; import { Favorites } from '@/favorites/components/Favorites';
import { ObjectMetadataNavItems } from '@/object-metadata/components/ObjectMetadataNavItems'; import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
@ -41,8 +41,8 @@ export const MainNavigationDrawerItems = () => {
<Favorites /> <Favorites />
<ObjectMetadataNavItems isRemote={false} /> <NavigationDrawerSectionForObjectMetadataItems isRemote={false} />
<ObjectMetadataNavItems isRemote={true} /> <NavigationDrawerSectionForObjectMetadataItems isRemote={true} />
</> </>
); );
}; };

View File

@ -1,11 +1,10 @@
import { AnimatePresence, motion } from 'framer-motion';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined, useIcons } from 'twenty-ui'; import { isDefined, useIcons } from 'twenty-ui';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { useLastVisitedView } from '@/navigation/hooks/useLastVisitedView'; import { useLastVisitedView } from '@/navigation/hooks/useLastVisitedView';
import { ObjectMetadataNavItemsSkeletonLoader } from '@/object-metadata/components/ObjectMetadataNavItemsSkeletonLoader'; import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
@ -15,9 +14,9 @@ import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/compo
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem'; import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection'; import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { getNavigationSubItemState } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemState';
import { View } from '@/views/types/View'; import { View } from '@/views/types/View';
import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews'; import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews';
import { Theme, useTheme } from '@emotion/react';
const ORDERED_STANDARD_OBJECTS = [ const ORDERED_STANDARD_OBJECTS = [
'person', 'person',
@ -27,20 +26,11 @@ const ORDERED_STANDARD_OBJECTS = [
'note', 'note',
]; ];
const navItemsAnimationVariants = (theme: Theme) => ({ export const NavigationDrawerSectionForObjectMetadataItems = ({
hidden: { isRemote,
height: 0, }: {
opacity: 0, isRemote: boolean;
marginTop: 0, }) => {
},
visible: {
height: 'auto',
opacity: 1,
marginTop: theme.spacing(1),
},
});
export const ObjectMetadataNavItems = ({ isRemote }: { isRemote: boolean }) => {
const currentUser = useRecoilValue(currentUserState); const currentUser = useRecoilValue(currentUserState);
const { toggleNavigationSection, isNavigationSectionOpenState } = const { toggleNavigationSection, isNavigationSectionOpenState } =
@ -57,13 +47,13 @@ export const ObjectMetadataNavItems = ({ isRemote }: { isRemote: boolean }) => {
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews); const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const loading = useIsPrefetchLoading(); const loading = useIsPrefetchLoading();
const theme = useTheme();
const { getLastVisitedViewIdFromObjectMetadataItemId } = useLastVisitedView(); const { getLastVisitedViewIdFromObjectMetadataItemId } = useLastVisitedView();
if (loading && isDefined(currentUser)) { if (loading && isDefined(currentUser)) {
return <ObjectMetadataNavItemsSkeletonLoader />; return <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
} }
// TODO: refactor this by splitting into separate components
return ( return (
filteredActiveObjectMetadataItems.length > 0 && ( filteredActiveObjectMetadataItems.length > 0 && (
<NavigationDrawerSection> <NavigationDrawerSection>
@ -121,6 +111,17 @@ export const ObjectMetadataNavItems = ({ isRemote }: { isRemote: boolean }) => {
currentPath === `/objects/${objectMetadataItem.namePlural}` && currentPath === `/objects/${objectMetadataItem.namePlural}` &&
objectMetadataViews.length > 1; objectMetadataViews.length > 1;
const sortedObjectMetadataViews = [...objectMetadataViews].sort(
(viewA, viewB) =>
viewA.key === 'INDEX' ? -1 : viewA.position - viewB.position,
);
const selectedSubItemIndex = sortedObjectMetadataViews.findIndex(
(view) => viewId === view.id,
);
const subItemArrayLength = sortedObjectMetadataViews.length;
return ( return (
<div key={objectMetadataItem.id}> <div key={objectMetadataItem.id}>
<NavigationDrawerItem <NavigationDrawerItem
@ -132,33 +133,21 @@ export const ObjectMetadataNavItems = ({ isRemote }: { isRemote: boolean }) => {
currentPath === `/objects/${objectMetadataItem.namePlural}` currentPath === `/objects/${objectMetadataItem.namePlural}`
} }
/> />
<AnimatePresence> {shouldSubItemsBeDisplayed &&
{shouldSubItemsBeDisplayed && ( sortedObjectMetadataViews.map((view, index) => (
<motion.div <NavigationDrawerSubItem
initial="hidden" label={view.name}
animate="visible" to={`/objects/${objectMetadataItem.namePlural}?view=${view.id}`}
exit="hidden" active={viewId === view.id}
variants={navItemsAnimationVariants(theme)} subItemState={getNavigationSubItemState({
transition={{ duration: 0.3, ease: 'easeInOut' }} index,
> arrayLength: subItemArrayLength,
{objectMetadataViews selectedIndex: selectedSubItemIndex,
.sort((viewA, viewB) => })}
viewA.key === 'INDEX' Icon={getIcon(view.icon)}
? -1 key={view.id}
: viewA.position - viewB.position, />
) ))}
.map((view) => (
<NavigationDrawerSubItem
label={view.name}
to={`/objects/${objectMetadataItem.namePlural}?view=${view.id}`}
active={viewId === view.id}
Icon={getIcon(view.icon)}
key={view.id}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
})} })}

View File

@ -0,0 +1,29 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledSkeletonColumn = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
height: 76px;
padding-left: ${({ theme }) => theme.spacing(1)};
`;
export const NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader: React.FC =
() => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.light}
borderRadius={4}
>
<StyledSkeletonColumn>
<Skeleton width={196} height={16} />
<Skeleton width={196} height={16} />
<Skeleton width={196} height={16} />
</StyledSkeletonColumn>
</SkeletonTheme>
);
};

View File

@ -1,28 +0,0 @@
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
const StyledSkeletonColumn = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
height: 76px;
padding-left: ${({ theme }) => theme.spacing(1)};
`;
export const ObjectMetadataNavItemsSkeletonLoader: React.FC = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.light}
borderRadius={4}
>
<StyledSkeletonColumn>
<Skeleton width={196} height={16} />
<Skeleton width={196} height={16} />
<Skeleton width={196} height={16} />
</StyledSkeletonColumn>
</SkeletonTheme>
);
};

View File

@ -9,12 +9,12 @@ import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadat
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
import { PrefetchLoadedDecorator } from '~/testing/decorators/PrefetchLoadedDecorator'; import { PrefetchLoadedDecorator } from '~/testing/decorators/PrefetchLoadedDecorator';
import { ObjectMetadataNavItems } from '../ObjectMetadataNavItems';
const meta: Meta<typeof ObjectMetadataNavItems> = { const meta: Meta<typeof NavigationDrawerSectionForObjectMetadataItems> = {
title: 'Modules/ObjectMetadata/ObjectMetadataNavItems', title: 'Modules/ObjectMetadata/NavigationDrawerSectionForObjectMetadataItems',
component: ObjectMetadataNavItems, component: NavigationDrawerSectionForObjectMetadataItems,
decorators: [ decorators: [
IconsProviderDecorator, IconsProviderDecorator,
ObjectMetadataItemsDecorator, ObjectMetadataItemsDecorator,
@ -29,7 +29,7 @@ const meta: Meta<typeof ObjectMetadataNavItems> = {
}; };
export default meta; export default meta;
type Story = StoryObj<typeof ObjectMetadataNavItems>; type Story = StoryObj<typeof NavigationDrawerSectionForObjectMetadataItems>;
export const Default: Story = { export const Default: Story = {
play: async () => { play: async () => {

View File

@ -63,7 +63,7 @@ export const useHandleToggleTrashColumnFilter = ({
upsertCombinedViewFilter(newFilter); upsertCombinedViewFilter(newFilter);
}, [ }, [
columnDefinitions, columnDefinitions,
objectMetadataItem.fields, objectMetadataItem,
objectNameSingular, objectNameSingular,
upsertCombinedViewFilter, upsertCombinedViewFilter,
]); ]);

View File

@ -6,32 +6,38 @@ import {
NavigationDrawerItem, NavigationDrawerItem,
NavigationDrawerItemProps, NavigationDrawerItemProps,
} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState';
type SettingsNavigationDrawerItemProps = Pick< type SettingsNavigationDrawerItemProps = Pick<
NavigationDrawerItemProps, NavigationDrawerItemProps,
'Icon' | 'label' | 'level' | 'soon' 'Icon' | 'label' | 'indentationLevel' | 'soon'
> & { > & {
matchSubPages?: boolean; matchSubPages?: boolean;
path: SettingsPath; path: SettingsPath;
subItemState?: NavigationDrawerSubItemState;
}; };
export const SettingsNavigationDrawerItem = ({ export const SettingsNavigationDrawerItem = ({
Icon, Icon,
label, label,
level, indentationLevel,
matchSubPages = false, matchSubPages = false,
path, path,
soon, soon,
subItemState,
}: SettingsNavigationDrawerItemProps) => { }: SettingsNavigationDrawerItemProps) => {
const href = getSettingsPagePath(path); const href = getSettingsPagePath(path);
const pathName = useResolvedPath(href).pathname;
const isActive = !!useMatch({ const isActive = !!useMatch({
path: useResolvedPath(href).pathname, path: pathName,
end: !matchSubPages, end: !matchSubPages,
}); });
return ( return (
<NavigationDrawerItem <NavigationDrawerItem
level={level} indentationLevel={indentationLevel}
subItemState={subItemState}
label={label} label={label}
to={href} to={href}
Icon={Icon} Icon={Icon}

View File

@ -5,6 +5,7 @@ import {
IconCalendarEvent, IconCalendarEvent,
IconCode, IconCode,
IconColorSwatch, IconColorSwatch,
IconComponent,
IconCurrencyDollar, IconCurrencyDollar,
IconDoorEnter, IconDoorEnter,
IconFunction, IconFunction,
@ -19,12 +20,26 @@ import {
import { useAuth } from '@/auth/hooks/useAuth'; import { useAuth } from '@/auth/hooks/useAuth';
import { billingState } from '@/client-config/states/billingState'; import { billingState } from '@/client-config/states/billingState';
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem'; import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import {
NavigationDrawerItem,
NavigationDrawerItemIndentationLevel,
} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup'; import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { getNavigationSubItemState } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { matchPath, resolvePath, useLocation } from 'react-router-dom';
type SettingsNavigationItem = {
label: string;
path: SettingsPath;
Icon: IconComponent;
matchSubPages?: boolean;
indentationLevel?: NavigationDrawerItemIndentationLevel;
};
export const SettingsNavigationDrawerItems = () => { export const SettingsNavigationDrawerItems = () => {
const { signOut } = useAuth(); const { signOut } = useAuth();
@ -38,6 +53,39 @@ export const SettingsNavigationDrawerItems = () => {
const isBillingPageEnabled = const isBillingPageEnabled =
billing?.isBillingEnabled && !isFreeAccessEnabled; billing?.isBillingEnabled && !isFreeAccessEnabled;
// TODO: Refactor this part to only have arrays of navigation items
const currentPathName = useLocation().pathname;
const accountSubSettings: SettingsNavigationItem[] = [
{
label: 'Emails',
path: SettingsPath.AccountsEmails,
Icon: IconMail,
matchSubPages: true,
indentationLevel: 2,
},
{
label: 'Calendars',
path: SettingsPath.AccountsCalendars,
Icon: IconCalendarEvent,
matchSubPages: true,
indentationLevel: 2,
},
];
const selectedIndex = accountSubSettings.findIndex((accountSubSetting) => {
const href = getSettingsPagePath(accountSubSetting.path);
const pathName = resolvePath(href).pathname;
return matchPath(
{
path: pathName,
end: !accountSubSetting.matchSubPages,
},
currentPathName,
);
});
return ( return (
<> <>
<NavigationDrawerSection> <NavigationDrawerSection>
@ -58,23 +106,21 @@ export const SettingsNavigationDrawerItems = () => {
path={SettingsPath.Accounts} path={SettingsPath.Accounts}
Icon={IconAt} Icon={IconAt}
/> />
<SettingsNavigationDrawerItem {accountSubSettings.map((navigationItem, index) => (
level={2} <SettingsNavigationDrawerItem
label="Emails" label={navigationItem.label}
path={SettingsPath.AccountsEmails} path={navigationItem.path}
Icon={IconMail} Icon={navigationItem.Icon}
matchSubPages indentationLevel={navigationItem.indentationLevel}
/> subItemState={getNavigationSubItemState({
<SettingsNavigationDrawerItem arrayLength: accountSubSettings.length,
level={2} index,
label="Calendars" selectedIndex,
path={SettingsPath.AccountsCalendars} })}
Icon={IconCalendarEvent} />
matchSubPages ))}
/>
</NavigationDrawerItemGroup> </NavigationDrawerItemGroup>
</NavigationDrawerSection> </NavigationDrawerSection>
<NavigationDrawerSection> <NavigationDrawerSection>
<NavigationDrawerSectionTitle label="Workspace" /> <NavigationDrawerSectionTitle label="Workspace" />
<SettingsNavigationDrawerItem <SettingsNavigationDrawerItem
@ -125,7 +171,6 @@ export const SettingsNavigationDrawerItems = () => {
/> />
)} )}
</NavigationDrawerSection> </NavigationDrawerSection>
<NavigationDrawerSection> <NavigationDrawerSection>
<NavigationDrawerSectionTitle label="Other" /> <NavigationDrawerSectionTitle label="Other" />
<SettingsNavigationDrawerItem <SettingsNavigationDrawerItem

View File

@ -1,3 +1,5 @@
import { NavigationDrawerItemBreadcrumb } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemBreadcrumb';
import { NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState';
import { isNavigationDrawerOpenState } from '@/ui/navigation/states/isNavigationDrawerOpenState'; import { isNavigationDrawerOpenState } from '@/ui/navigation/states/isNavigationDrawerOpenState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import isPropValid from '@emotion/is-prop-valid'; import isPropValid from '@emotion/is-prop-valid';
@ -9,10 +11,15 @@ import { useSetRecoilState } from 'recoil';
import { IconComponent, MOBILE_VIEWPORT, Pill } from 'twenty-ui'; import { IconComponent, MOBILE_VIEWPORT, Pill } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
const DEFAULT_INDENTATION_LEVEL = 1;
export type NavigationDrawerItemIndentationLevel = 1 | 2;
export type NavigationDrawerItemProps = { export type NavigationDrawerItemProps = {
className?: string; className?: string;
label: string; label: string;
level?: 1 | 2; indentationLevel?: NavigationDrawerItemIndentationLevel;
subItemState?: NavigationDrawerSubItemState;
to?: string; to?: string;
onClick?: () => void; onClick?: () => void;
Icon: IconComponent; Icon: IconComponent;
@ -23,13 +30,10 @@ export type NavigationDrawerItemProps = {
keyboard?: string[]; keyboard?: string[];
}; };
type StyledItemProps = { type StyledItemProps = Pick<
active?: boolean; NavigationDrawerItemProps,
danger?: boolean; 'active' | 'danger' | 'indentationLevel' | 'soon' | 'to'
level: 1 | 2; >;
soon?: boolean;
to?: string;
};
const StyledItem = styled('div', { const StyledItem = styled('div', {
shouldForwardProp: (prop) => shouldForwardProp: (prop) =>
@ -59,13 +63,17 @@ const StyledItem = styled('div', {
font-family: 'Inter'; font-family: 'Inter';
font-size: ${({ theme }) => theme.font.size.md}; font-size: ${({ theme }) => theme.font.size.md};
gap: ${({ theme }) => theme.spacing(2)}; gap: ${({ theme }) => theme.spacing(2)};
margin-left: ${({ level, theme }) => theme.spacing((level - 1) * 4)};
padding-bottom: ${({ theme }) => theme.spacing(1)}; padding-bottom: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(1)}; padding-left: ${({ theme }) => theme.spacing(1)};
padding-right: ${({ theme }) => theme.spacing(1)}; padding-right: ${({ theme }) => theme.spacing(1)};
padding-top: ${({ theme }) => theme.spacing(1)}; padding-top: ${({ theme }) => theme.spacing(1)};
pointer-events: ${(props) => (props.soon ? 'none' : 'auto')};
margin-top: ${({ indentationLevel }) =>
indentationLevel === 2 ? '2px' : '0'};
pointer-events: ${(props) => (props.soon ? 'none' : 'auto')};
width: 100%;
:hover { :hover {
background: ${({ theme }) => theme.background.transparent.light}; background: ${({ theme }) => theme.background.transparent.light};
color: ${(props) => color: ${(props) =>
@ -116,10 +124,16 @@ const StyledKeyBoardShortcut = styled.div`
visibility: hidden; visibility: hidden;
`; `;
const StyledNavigationDrawerItemContainer = styled.div`
display: flex;
flex-grow: 1;
width: 100%;
`;
export const NavigationDrawerItem = ({ export const NavigationDrawerItem = ({
className, className,
label, label,
level = 1, indentationLevel = DEFAULT_INDENTATION_LEVEL,
Icon, Icon,
to, to,
onClick, onClick,
@ -128,6 +142,7 @@ export const NavigationDrawerItem = ({
soon, soon,
count, count,
keyboard, keyboard,
subItemState,
}: NavigationDrawerItemProps) => { }: NavigationDrawerItemProps) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -136,6 +151,8 @@ export const NavigationDrawerItem = ({
isNavigationDrawerOpenState, isNavigationDrawerOpenState,
); );
const showBreadcrumb = indentationLevel === 2;
const handleItemClick = () => { const handleItemClick = () => {
if (isMobile) { if (isMobile) {
setIsNavigationDrawerOpen(false); setIsNavigationDrawerOpen(false);
@ -152,26 +169,33 @@ export const NavigationDrawerItem = ({
}; };
return ( return (
<StyledItem <StyledNavigationDrawerItemContainer>
className={className} <StyledItem
level={level} className={className}
onClick={handleItemClick} onClick={handleItemClick}
active={active} active={active}
aria-selected={active} aria-selected={active}
danger={danger} danger={danger}
soon={soon} soon={soon}
as={to ? Link : 'div'} as={to ? Link : 'div'}
to={to ? to : undefined} to={to ? to : undefined}
> indentationLevel={indentationLevel}
{Icon && <Icon size={theme.icon.size.md} stroke={theme.icon.stroke.md} />} >
<StyledItemLabel>{label}</StyledItemLabel> {showBreadcrumb && (
{soon && <Pill label="Soon" />} <NavigationDrawerItemBreadcrumb state={subItemState} />
{!!count && <StyledItemCount>{count}</StyledItemCount>} )}
{keyboard && ( {Icon && (
<StyledKeyBoardShortcut className="keyboard-shortcuts"> <Icon size={theme.icon.size.md} stroke={theme.icon.stroke.md} />
{keyboard} )}
</StyledKeyBoardShortcut> <StyledItemLabel>{label}</StyledItemLabel>
)} {soon && <Pill label="Soon" />}
</StyledItem> {!!count && <StyledItemCount>{count}</StyledItemCount>}
{keyboard && (
<StyledKeyBoardShortcut className="keyboard-shortcuts">
{keyboard}
</StyledKeyBoardShortcut>
)}
</StyledItem>
</StyledNavigationDrawerItemContainer>
); );
}; };

View File

@ -0,0 +1,79 @@
import { NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState';
import styled from '@emotion/styled';
export type NavigationDrawerItemBreadcrumbProps = {
state?: NavigationDrawerSubItemState;
};
const StyledNavigationDrawerItemBreadcrumbContainer = styled.div`
margin-left: 7.5px;
height: 28px;
width: 9px;
`;
const StyledGapVerticalLine = styled.div<{ darker: boolean }>`
background: ${({ theme, darker }) =>
darker ? theme.font.color.tertiary : theme.border.color.strong};
position: relative;
top: -2px;
height: 2px;
width: 1px;
`;
const StyledSecondaryFullVerticalBar = styled.div<{ darker: boolean }>`
background: ${({ theme, darker }) =>
darker ? theme.font.color.tertiary : theme.border.color.strong};
position: relative;
top: -17px;
height: 28px;
width: 1px;
`;
const StyledRoundedProtrusion = styled.div<{ darker: boolean }>`
position: relative;
top: -2px;
border-bottom-left-radius: 4px;
border: 1px solid
${({ theme, darker }) =>
darker ? theme.font.color.tertiary : theme.border.color.strong};
${({ darker }) => (darker ? 'z-index: 1;' : '')}
border-top: none;
border-right: none;
height: 14px;
width: 8px;
`;
export const NavigationDrawerItemBreadcrumb = ({
state,
}: NavigationDrawerItemBreadcrumbProps) => {
const showVerticalBar =
state !== 'last-not-selected' && state !== 'last-selected';
const verticalBarShouldBeDarker = state === 'intermediate-before-selected';
const protrusionShouldBeDarker =
state === 'intermediate-selected' || state === 'last-selected';
const gapShouldBeDarker =
state === 'intermediate-before-selected' ||
state === 'intermediate-selected' ||
state === 'last-selected';
return (
<StyledNavigationDrawerItemBreadcrumbContainer>
<StyledGapVerticalLine darker={gapShouldBeDarker} />
<StyledRoundedProtrusion darker={protrusionShouldBeDarker} />
{showVerticalBar && (
<StyledSecondaryFullVerticalBar darker={verticalBarShouldBeDarker} />
)}
</StyledNavigationDrawerItemBreadcrumbContainer>
);
};

View File

@ -3,7 +3,6 @@ import styled from '@emotion/styled';
const StyledGroup = styled.div` const StyledGroup = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: ${({ theme }) => theme.spacing(0.5)};
`; `;
export { StyledGroup as NavigationDrawerItemGroup }; export { StyledGroup as NavigationDrawerItemGroup };

View File

@ -2,21 +2,12 @@ import {
NavigationDrawerItem, NavigationDrawerItem,
NavigationDrawerItemProps, NavigationDrawerItemProps,
} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import styled from '@emotion/styled';
const StyledItem = styled.div`
&:not(:last-child) {
margin-bottom: ${({ theme }) => theme.spacing(0.5)};
}
margin-left: ${({ theme }) => theme.spacing(4)};
`;
type NavigationDrawerSubItemProps = NavigationDrawerItemProps; type NavigationDrawerSubItemProps = NavigationDrawerItemProps;
export const NavigationDrawerSubItem = ({ export const NavigationDrawerSubItem = ({
className, className,
label, label,
level = 1,
Icon, Icon,
to, to,
onClick, onClick,
@ -25,22 +16,22 @@ export const NavigationDrawerSubItem = ({
soon, soon,
count, count,
keyboard, keyboard,
subItemState,
}: NavigationDrawerSubItemProps) => { }: NavigationDrawerSubItemProps) => {
return ( return (
<StyledItem> <NavigationDrawerItem
<NavigationDrawerItem className={className}
className={className} label={label}
label={label} indentationLevel={2}
level={level} subItemState={subItemState}
Icon={Icon} Icon={Icon}
to={to} to={to}
onClick={onClick} onClick={onClick}
active={active} active={active}
danger={danger} danger={danger}
soon={soon} soon={soon}
count={count} count={count}
keyboard={keyboard} keyboard={keyboard}
/> />
</StyledItem>
); );
}; };

View File

@ -23,6 +23,7 @@ import { GithubVersionLink } from '@/ui/navigation/link/components/GithubVersion
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { NavigationDrawer } from '../NavigationDrawer'; import { NavigationDrawer } from '../NavigationDrawer';
import { NavigationDrawerItem } from '../NavigationDrawerItem'; import { NavigationDrawerItem } from '../NavigationDrawerItem';
import { NavigationDrawerItemGroup } from '../NavigationDrawerItemGroup'; import { NavigationDrawerItemGroup } from '../NavigationDrawerItemGroup';
@ -108,17 +109,17 @@ export const Submenu: Story = {
to={getSettingsPagePath(SettingsPath.Accounts)} to={getSettingsPagePath(SettingsPath.Accounts)}
Icon={IconAt} Icon={IconAt}
/> />
<NavigationDrawerItem <NavigationDrawerSubItem
level={2}
label="Emails" label="Emails"
to={getSettingsPagePath(SettingsPath.AccountsEmails)} to={getSettingsPagePath(SettingsPath.AccountsEmails)}
Icon={IconMail} Icon={IconMail}
subItemState="intermediate-before-selected"
/> />
<NavigationDrawerItem <NavigationDrawerSubItem
level={2}
label="Calendar" label="Calendar"
to={getSettingsPagePath(SettingsPath.AccountsCalendars)} to={getSettingsPagePath(SettingsPath.AccountsCalendars)}
Icon={IconCalendarEvent} Icon={IconCalendarEvent}
subItemState="last-selected"
/> />
</NavigationDrawerItemGroup> </NavigationDrawerItemGroup>
</NavigationDrawerSection> </NavigationDrawerSection>

View File

@ -37,6 +37,115 @@ export const Default: Story = {
], ],
}; };
export const Breadcrumb: Story = {
decorators: [
(Story) => (
<StyledContainer>
<h1>Breadcrumb</h1>
<Story
args={{
indentationLevel: 1,
label: 'Search',
Icon: IconSearch,
}}
/>
<Story
args={{
indentationLevel: 2,
subItemState: 'intermediate-before-selected',
label: 'First not selected',
Icon: IconSearch,
}}
/>
<Story
args={{
indentationLevel: 2,
subItemState: 'intermediate-before-selected',
label: 'Before selected',
Icon: IconSearch,
}}
/>
<Story
args={{
indentationLevel: 2,
subItemState: 'intermediate-selected',
label: 'Selected',
Icon: IconSearch,
}}
/>
<Story
args={{
indentationLevel: 2,
subItemState: 'intermediate-after-selected',
label: 'After selected',
Icon: IconSearch,
}}
/>
<Story
args={{
indentationLevel: 2,
subItemState: 'last-not-selected',
label: 'Last not selected',
Icon: IconSearch,
}}
/>
</StyledContainer>
),
ComponentWithRouterDecorator,
],
};
export const BreadcrumbCatalog: CatalogStory<
Story,
typeof NavigationDrawerItem
> = {
decorators: [
(Story) => (
<StyledContainer>
<Story />
</StyledContainer>
),
CatalogDecorator,
MemoryRouterDecorator,
],
args: {
indentationLevel: 2,
},
parameters: {
pseudo: { hover: ['.hover'] },
catalog: {
dimensions: [
{
name: 'subItemState',
values: [
'Intermediate before selected',
'Intermediate selected',
'Intermediate after selected',
'Last not selected',
'Last selected',
],
props: (state: string) => {
switch (state) {
case 'Intermediate before selected':
return { subItemState: 'intermediate-before-selected' };
case 'Intermediate selected':
return { subItemState: 'intermediate-selected' };
case 'Intermediate after selected':
return { subItemState: 'intermediate-after-selected' };
case 'Last not selected':
return { subItemState: 'last-not-selected' };
case 'Last selected':
return { subItemState: 'last-selected' };
default:
throw new Error(`Unknown state: ${state}`);
}
},
},
],
},
},
};
export const Catalog: CatalogStory<Story, typeof NavigationDrawerItem> = { export const Catalog: CatalogStory<Story, typeof NavigationDrawerItem> = {
decorators: [ decorators: [
(Story) => ( (Story) => (

View File

@ -0,0 +1,6 @@
export type NavigationDrawerSubItemState =
| 'intermediate-before-selected'
| 'intermediate-selected'
| 'intermediate-after-selected'
| 'last-selected'
| 'last-not-selected';

View File

@ -0,0 +1,35 @@
import { NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState';
export const getNavigationSubItemState = ({
index,
arrayLength,
selectedIndex,
}: {
index: number;
arrayLength: number;
selectedIndex: number;
}): NavigationDrawerSubItemState => {
const thereIsOnlyOneItem = arrayLength === 1;
const itsTheLastItem = index === arrayLength - 1;
const itsTheSelectedItem = index === selectedIndex;
const itsBeforeTheSelectedItem = index < selectedIndex;
if (thereIsOnlyOneItem || itsTheLastItem) {
if (itsTheSelectedItem) {
return 'last-selected';
} else {
return 'last-not-selected';
}
} else {
if (itsTheSelectedItem) {
return 'intermediate-selected';
} else if (itsBeforeTheSelectedItem) {
return 'intermediate-before-selected';
} else {
return 'intermediate-after-selected';
}
}
};