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:
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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