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

@ -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,26 +169,33 @@ export const NavigationDrawerItem = ({
};
return (
<StyledItem
className={className}
level={level}
onClick={handleItemClick}
active={active}
aria-selected={active}
danger={danger}
soon={soon}
as={to ? Link : 'div'}
to={to ? to : undefined}
>
{Icon && <Icon size={theme.icon.size.md} stroke={theme.icon.stroke.md} />}
<StyledItemLabel>{label}</StyledItemLabel>
{soon && <Pill label="Soon" />}
{!!count && <StyledItemCount>{count}</StyledItemCount>}
{keyboard && (
<StyledKeyBoardShortcut className="keyboard-shortcuts">
{keyboard}
</StyledKeyBoardShortcut>
)}
</StyledItem>
<StyledNavigationDrawerItemContainer>
<StyledItem
className={className}
onClick={handleItemClick}
active={active}
aria-selected={active}
danger={danger}
soon={soon}
as={to ? Link : 'div'}
to={to ? to : undefined}
indentationLevel={indentationLevel}
>
{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>}
{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`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
export { StyledGroup as NavigationDrawerItemGroup };

View File

@ -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,22 +16,22 @@ export const NavigationDrawerSubItem = ({
soon,
count,
keyboard,
subItemState,
}: NavigationDrawerSubItemProps) => {
return (
<StyledItem>
<NavigationDrawerItem
className={className}
label={label}
level={level}
Icon={Icon}
to={to}
onClick={onClick}
active={active}
danger={danger}
soon={soon}
count={count}
keyboard={keyboard}
/>
</StyledItem>
<NavigationDrawerItem
className={className}
label={label}
indentationLevel={2}
subItemState={subItemState}
Icon={Icon}
to={to}
onClick={onClick}
active={active}
danger={danger}
soon={soon}
count={count}
keyboard={keyboard}
/>
);
};

View File

@ -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>

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> = {
decorators: [
(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';
}
}
};