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:
@ -4,7 +4,7 @@ import { IconSearch, IconSettings } from 'twenty-ui';
|
||||
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
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 { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
|
||||
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
|
||||
@ -41,8 +41,8 @@ export const MainNavigationDrawerItems = () => {
|
||||
|
||||
<Favorites />
|
||||
|
||||
<ObjectMetadataNavItems isRemote={false} />
|
||||
<ObjectMetadataNavItems isRemote={true} />
|
||||
<NavigationDrawerSectionForObjectMetadataItems isRemote={false} />
|
||||
<NavigationDrawerSectionForObjectMetadataItems isRemote={true} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined, useIcons } from 'twenty-ui';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
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 { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
|
||||
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 { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
|
||||
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 { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews';
|
||||
import { Theme, useTheme } from '@emotion/react';
|
||||
|
||||
const ORDERED_STANDARD_OBJECTS = [
|
||||
'person',
|
||||
@ -27,20 +26,11 @@ const ORDERED_STANDARD_OBJECTS = [
|
||||
'note',
|
||||
];
|
||||
|
||||
const navItemsAnimationVariants = (theme: Theme) => ({
|
||||
hidden: {
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
marginTop: 0,
|
||||
},
|
||||
visible: {
|
||||
height: 'auto',
|
||||
opacity: 1,
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
});
|
||||
|
||||
export const ObjectMetadataNavItems = ({ isRemote }: { isRemote: boolean }) => {
|
||||
export const NavigationDrawerSectionForObjectMetadataItems = ({
|
||||
isRemote,
|
||||
}: {
|
||||
isRemote: boolean;
|
||||
}) => {
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
|
||||
const { toggleNavigationSection, isNavigationSectionOpenState } =
|
||||
@ -57,13 +47,13 @@ export const ObjectMetadataNavItems = ({ isRemote }: { isRemote: boolean }) => {
|
||||
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
|
||||
const loading = useIsPrefetchLoading();
|
||||
|
||||
const theme = useTheme();
|
||||
const { getLastVisitedViewIdFromObjectMetadataItemId } = useLastVisitedView();
|
||||
|
||||
if (loading && isDefined(currentUser)) {
|
||||
return <ObjectMetadataNavItemsSkeletonLoader />;
|
||||
return <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
|
||||
}
|
||||
|
||||
// TODO: refactor this by splitting into separate components
|
||||
return (
|
||||
filteredActiveObjectMetadataItems.length > 0 && (
|
||||
<NavigationDrawerSection>
|
||||
@ -121,6 +111,17 @@ export const ObjectMetadataNavItems = ({ isRemote }: { isRemote: boolean }) => {
|
||||
currentPath === `/objects/${objectMetadataItem.namePlural}` &&
|
||||
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 (
|
||||
<div key={objectMetadataItem.id}>
|
||||
<NavigationDrawerItem
|
||||
@ -132,33 +133,21 @@ export const ObjectMetadataNavItems = ({ isRemote }: { isRemote: boolean }) => {
|
||||
currentPath === `/objects/${objectMetadataItem.namePlural}`
|
||||
}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{shouldSubItemsBeDisplayed && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
variants={navItemsAnimationVariants(theme)}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
{objectMetadataViews
|
||||
.sort((viewA, viewB) =>
|
||||
viewA.key === 'INDEX'
|
||||
? -1
|
||||
: viewA.position - viewB.position,
|
||||
)
|
||||
.map((view) => (
|
||||
{shouldSubItemsBeDisplayed &&
|
||||
sortedObjectMetadataViews.map((view, index) => (
|
||||
<NavigationDrawerSubItem
|
||||
label={view.name}
|
||||
to={`/objects/${objectMetadataItem.namePlural}?view=${view.id}`}
|
||||
active={viewId === view.id}
|
||||
subItemState={getNavigationSubItemState({
|
||||
index,
|
||||
arrayLength: subItemArrayLength,
|
||||
selectedIndex: selectedSubItemIndex,
|
||||
})}
|
||||
Icon={getIcon(view.icon)}
|
||||
key={view.id}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -9,12 +9,12 @@ import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadat
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
|
||||
import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
|
||||
import { PrefetchLoadedDecorator } from '~/testing/decorators/PrefetchLoadedDecorator';
|
||||
import { ObjectMetadataNavItems } from '../ObjectMetadataNavItems';
|
||||
|
||||
const meta: Meta<typeof ObjectMetadataNavItems> = {
|
||||
title: 'Modules/ObjectMetadata/ObjectMetadataNavItems',
|
||||
component: ObjectMetadataNavItems,
|
||||
const meta: Meta<typeof NavigationDrawerSectionForObjectMetadataItems> = {
|
||||
title: 'Modules/ObjectMetadata/NavigationDrawerSectionForObjectMetadataItems',
|
||||
component: NavigationDrawerSectionForObjectMetadataItems,
|
||||
decorators: [
|
||||
IconsProviderDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
@ -29,7 +29,7 @@ const meta: Meta<typeof ObjectMetadataNavItems> = {
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ObjectMetadataNavItems>;
|
||||
type Story = StoryObj<typeof NavigationDrawerSectionForObjectMetadataItems>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async () => {
|
||||
|
||||
@ -63,7 +63,7 @@ export const useHandleToggleTrashColumnFilter = ({
|
||||
upsertCombinedViewFilter(newFilter);
|
||||
}, [
|
||||
columnDefinitions,
|
||||
objectMetadataItem.fields,
|
||||
objectMetadataItem,
|
||||
objectNameSingular,
|
||||
upsertCombinedViewFilter,
|
||||
]);
|
||||
|
||||
@ -6,32 +6,38 @@ import {
|
||||
NavigationDrawerItem,
|
||||
NavigationDrawerItemProps,
|
||||
} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
|
||||
import { NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState';
|
||||
|
||||
type SettingsNavigationDrawerItemProps = Pick<
|
||||
NavigationDrawerItemProps,
|
||||
'Icon' | 'label' | 'level' | 'soon'
|
||||
'Icon' | 'label' | 'indentationLevel' | 'soon'
|
||||
> & {
|
||||
matchSubPages?: boolean;
|
||||
path: SettingsPath;
|
||||
subItemState?: NavigationDrawerSubItemState;
|
||||
};
|
||||
|
||||
export const SettingsNavigationDrawerItem = ({
|
||||
Icon,
|
||||
label,
|
||||
level,
|
||||
indentationLevel,
|
||||
matchSubPages = false,
|
||||
path,
|
||||
soon,
|
||||
subItemState,
|
||||
}: SettingsNavigationDrawerItemProps) => {
|
||||
const href = getSettingsPagePath(path);
|
||||
const pathName = useResolvedPath(href).pathname;
|
||||
|
||||
const isActive = !!useMatch({
|
||||
path: useResolvedPath(href).pathname,
|
||||
path: pathName,
|
||||
end: !matchSubPages,
|
||||
});
|
||||
|
||||
return (
|
||||
<NavigationDrawerItem
|
||||
level={level}
|
||||
indentationLevel={indentationLevel}
|
||||
subItemState={subItemState}
|
||||
label={label}
|
||||
to={href}
|
||||
Icon={Icon}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
IconCalendarEvent,
|
||||
IconCode,
|
||||
IconColorSwatch,
|
||||
IconComponent,
|
||||
IconCurrencyDollar,
|
||||
IconDoorEnter,
|
||||
IconFunction,
|
||||
@ -19,12 +20,26 @@ import {
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
|
||||
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
||||
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 { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
|
||||
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 { matchPath, resolvePath, useLocation } from 'react-router-dom';
|
||||
|
||||
type SettingsNavigationItem = {
|
||||
label: string;
|
||||
path: SettingsPath;
|
||||
Icon: IconComponent;
|
||||
matchSubPages?: boolean;
|
||||
indentationLevel?: NavigationDrawerItemIndentationLevel;
|
||||
};
|
||||
|
||||
export const SettingsNavigationDrawerItems = () => {
|
||||
const { signOut } = useAuth();
|
||||
@ -38,6 +53,39 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
const isBillingPageEnabled =
|
||||
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 (
|
||||
<>
|
||||
<NavigationDrawerSection>
|
||||
@ -58,23 +106,21 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
path={SettingsPath.Accounts}
|
||||
Icon={IconAt}
|
||||
/>
|
||||
{accountSubSettings.map((navigationItem, index) => (
|
||||
<SettingsNavigationDrawerItem
|
||||
level={2}
|
||||
label="Emails"
|
||||
path={SettingsPath.AccountsEmails}
|
||||
Icon={IconMail}
|
||||
matchSubPages
|
||||
/>
|
||||
<SettingsNavigationDrawerItem
|
||||
level={2}
|
||||
label="Calendars"
|
||||
path={SettingsPath.AccountsCalendars}
|
||||
Icon={IconCalendarEvent}
|
||||
matchSubPages
|
||||
label={navigationItem.label}
|
||||
path={navigationItem.path}
|
||||
Icon={navigationItem.Icon}
|
||||
indentationLevel={navigationItem.indentationLevel}
|
||||
subItemState={getNavigationSubItemState({
|
||||
arrayLength: accountSubSettings.length,
|
||||
index,
|
||||
selectedIndex,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</NavigationDrawerItemGroup>
|
||||
</NavigationDrawerSection>
|
||||
|
||||
<NavigationDrawerSection>
|
||||
<NavigationDrawerSectionTitle label="Workspace" />
|
||||
<SettingsNavigationDrawerItem
|
||||
@ -125,7 +171,6 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
/>
|
||||
)}
|
||||
</NavigationDrawerSection>
|
||||
|
||||
<NavigationDrawerSection>
|
||||
<NavigationDrawerSectionTitle label="Other" />
|
||||
<SettingsNavigationDrawerItem
|
||||
|
||||
@ -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 { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import isPropValid from '@emotion/is-prop-valid';
|
||||
@ -9,10 +11,15 @@ import { useSetRecoilState } from 'recoil';
|
||||
import { IconComponent, MOBILE_VIEWPORT, Pill } from 'twenty-ui';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const DEFAULT_INDENTATION_LEVEL = 1;
|
||||
|
||||
export type NavigationDrawerItemIndentationLevel = 1 | 2;
|
||||
|
||||
export type NavigationDrawerItemProps = {
|
||||
className?: string;
|
||||
label: string;
|
||||
level?: 1 | 2;
|
||||
indentationLevel?: NavigationDrawerItemIndentationLevel;
|
||||
subItemState?: NavigationDrawerSubItemState;
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
Icon: IconComponent;
|
||||
@ -23,13 +30,10 @@ export type NavigationDrawerItemProps = {
|
||||
keyboard?: string[];
|
||||
};
|
||||
|
||||
type StyledItemProps = {
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
level: 1 | 2;
|
||||
soon?: boolean;
|
||||
to?: string;
|
||||
};
|
||||
type StyledItemProps = Pick<
|
||||
NavigationDrawerItemProps,
|
||||
'active' | 'danger' | 'indentationLevel' | 'soon' | 'to'
|
||||
>;
|
||||
|
||||
const StyledItem = styled('div', {
|
||||
shouldForwardProp: (prop) =>
|
||||
@ -59,13 +63,17 @@ const StyledItem = styled('div', {
|
||||
font-family: 'Inter';
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
margin-left: ${({ level, theme }) => theme.spacing((level - 1) * 4)};
|
||||
|
||||
padding-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
padding-left: ${({ theme }) => theme.spacing(1)};
|
||||
padding-right: ${({ 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 {
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
color: ${(props) =>
|
||||
@ -116,10 +124,16 @@ const StyledKeyBoardShortcut = styled.div`
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
const StyledNavigationDrawerItemContainer = styled.div`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const NavigationDrawerItem = ({
|
||||
className,
|
||||
label,
|
||||
level = 1,
|
||||
indentationLevel = DEFAULT_INDENTATION_LEVEL,
|
||||
Icon,
|
||||
to,
|
||||
onClick,
|
||||
@ -128,6 +142,7 @@ export const NavigationDrawerItem = ({
|
||||
soon,
|
||||
count,
|
||||
keyboard,
|
||||
subItemState,
|
||||
}: NavigationDrawerItemProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
@ -136,6 +151,8 @@ export const NavigationDrawerItem = ({
|
||||
isNavigationDrawerOpenState,
|
||||
);
|
||||
|
||||
const showBreadcrumb = indentationLevel === 2;
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (isMobile) {
|
||||
setIsNavigationDrawerOpen(false);
|
||||
@ -152,9 +169,9 @@ export const NavigationDrawerItem = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledNavigationDrawerItemContainer>
|
||||
<StyledItem
|
||||
className={className}
|
||||
level={level}
|
||||
onClick={handleItemClick}
|
||||
active={active}
|
||||
aria-selected={active}
|
||||
@ -162,8 +179,14 @@ export const NavigationDrawerItem = ({
|
||||
soon={soon}
|
||||
as={to ? Link : 'div'}
|
||||
to={to ? to : undefined}
|
||||
indentationLevel={indentationLevel}
|
||||
>
|
||||
{Icon && <Icon size={theme.icon.size.md} stroke={theme.icon.stroke.md} />}
|
||||
{showBreadcrumb && (
|
||||
<NavigationDrawerItemBreadcrumb state={subItemState} />
|
||||
)}
|
||||
{Icon && (
|
||||
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.md} />
|
||||
)}
|
||||
<StyledItemLabel>{label}</StyledItemLabel>
|
||||
{soon && <Pill label="Soon" />}
|
||||
{!!count && <StyledItemCount>{count}</StyledItemCount>}
|
||||
@ -173,5 +196,6 @@ export const NavigationDrawerItem = ({
|
||||
</StyledKeyBoardShortcut>
|
||||
)}
|
||||
</StyledItem>
|
||||
</StyledNavigationDrawerItemContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -3,7 +3,6 @@ import styled from '@emotion/styled';
|
||||
const StyledGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(0.5)};
|
||||
`;
|
||||
|
||||
export { StyledGroup as NavigationDrawerItemGroup };
|
||||
|
||||
@ -2,21 +2,12 @@ import {
|
||||
NavigationDrawerItem,
|
||||
NavigationDrawerItemProps,
|
||||
} 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;
|
||||
|
||||
export const NavigationDrawerSubItem = ({
|
||||
className,
|
||||
label,
|
||||
level = 1,
|
||||
Icon,
|
||||
to,
|
||||
onClick,
|
||||
@ -25,13 +16,14 @@ export const NavigationDrawerSubItem = ({
|
||||
soon,
|
||||
count,
|
||||
keyboard,
|
||||
subItemState,
|
||||
}: NavigationDrawerSubItemProps) => {
|
||||
return (
|
||||
<StyledItem>
|
||||
<NavigationDrawerItem
|
||||
className={className}
|
||||
label={label}
|
||||
level={level}
|
||||
indentationLevel={2}
|
||||
subItemState={subItemState}
|
||||
Icon={Icon}
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
@ -41,6 +33,5 @@ export const NavigationDrawerSubItem = ({
|
||||
count={count}
|
||||
keyboard={keyboard}
|
||||
/>
|
||||
</StyledItem>
|
||||
);
|
||||
};
|
||||
|
||||
@ -23,6 +23,7 @@ import { GithubVersionLink } from '@/ui/navigation/link/components/GithubVersion
|
||||
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
|
||||
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
|
||||
import { NavigationDrawer } from '../NavigationDrawer';
|
||||
import { NavigationDrawerItem } from '../NavigationDrawerItem';
|
||||
import { NavigationDrawerItemGroup } from '../NavigationDrawerItemGroup';
|
||||
@ -108,17 +109,17 @@ export const Submenu: Story = {
|
||||
to={getSettingsPagePath(SettingsPath.Accounts)}
|
||||
Icon={IconAt}
|
||||
/>
|
||||
<NavigationDrawerItem
|
||||
level={2}
|
||||
<NavigationDrawerSubItem
|
||||
label="Emails"
|
||||
to={getSettingsPagePath(SettingsPath.AccountsEmails)}
|
||||
Icon={IconMail}
|
||||
subItemState="intermediate-before-selected"
|
||||
/>
|
||||
<NavigationDrawerItem
|
||||
level={2}
|
||||
<NavigationDrawerSubItem
|
||||
label="Calendar"
|
||||
to={getSettingsPagePath(SettingsPath.AccountsCalendars)}
|
||||
Icon={IconCalendarEvent}
|
||||
subItemState="last-selected"
|
||||
/>
|
||||
</NavigationDrawerItemGroup>
|
||||
</NavigationDrawerSection>
|
||||
|
||||
@ -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> = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
export type NavigationDrawerSubItemState =
|
||||
| 'intermediate-before-selected'
|
||||
| 'intermediate-selected'
|
||||
| 'intermediate-after-selected'
|
||||
| 'last-selected'
|
||||
| 'last-not-selected';
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user