[Dashboards] - Tabs reordering (#14798)

closes https://github.com/twentyhq/core-team-issues/issues/1544

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
nitin
2025-10-23 17:28:00 +00:00
committed by GitHub
co-authored by Lucas Bordeau Félix Malfait
parent f301f07e43
commit 06a195af41
26 changed files with 1614 additions and 342 deletions
@@ -1,14 +1,15 @@
import { PageLayoutContent } from '@/page-layout/components/PageLayoutContent';
import { PageLayoutLeftPanel } from '@/page-layout/components/PageLayoutLeftPanel';
import { PageLayoutTabHeader } from '@/page-layout/components/PageLayoutTabHeader';
import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList';
import { useCreatePageLayoutTab } from '@/page-layout/hooks/useCreatePageLayoutTab';
import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout';
import { useReorderPageLayoutTabs } from '@/page-layout/hooks/useReorderPageLayoutTabs';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { getTabsByDisplayMode } from '@/page-layout/utils/getTabsByDisplayMode';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { ShowPageContainer } from '@/ui/layout/page/components/ShowPageContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
@@ -34,7 +35,7 @@ const StyledShowPageRightContainer = styled.div`
overflow: auto;
`;
const StyledTabList = styled(TabList)`
const StyledTabList = styled(PageLayoutTabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
@@ -54,6 +55,7 @@ export const PageLayoutRendererContent = () => {
const activeTabId = useRecoilComponentValue(activeTabIdComponentState);
const { createPageLayoutTab } = useCreatePageLayoutTab(currentPageLayout?.id);
const { reorderTabs } = useReorderPageLayoutTabs(currentPageLayout?.id ?? '');
const handleAddTab = isPageLayoutInEditMode ? createPageLayoutTab : undefined;
@@ -69,6 +71,14 @@ export const PageLayoutRendererContent = () => {
isInRightDrawer,
});
const tabListInstanceId = getTabListInstanceIdFromPageLayoutId(
currentPageLayout.id,
);
const sortedTabs = [...tabsToRenderInTabList].sort(
(a, b) => a.position - b.position,
);
return (
<ShowPageContainer>
{isDefined(pinnedLeftTab) && (
@@ -78,16 +88,14 @@ export const PageLayoutRendererContent = () => {
<StyledShowPageRightContainer>
<StyledTabsAndDashboardContainer>
<StyledTabList
tabs={tabsToRenderInTabList}
tabs={sortedTabs}
behaveAsLinks={false}
componentInstanceId={getTabListInstanceIdFromPageLayoutId(
currentPageLayout.id,
)}
componentInstanceId={tabListInstanceId}
onAddTab={handleAddTab}
isReorderEnabled={isPageLayoutInEditMode}
onReorder={isPageLayoutInEditMode ? reorderTabs : undefined}
/>
<PageLayoutTabHeader />
<StyledScrollWrapper
componentInstanceId={`scroll-wrapper-page-layout-${currentPageLayout.id}`}
defaultEnableXScroll={false}
@@ -0,0 +1,312 @@
import styled from '@emotion/styled';
import {
DragDropContext,
type DropResult,
type OnDragEndResponder,
type OnDragStartResponder,
type OnDragUpdateResponder,
type ResponderProvided,
} from '@hello-pangea/dnd';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { IconPlus } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { isPageLayoutTabDraggingComponentState } from '@/page-layout/states/isPageLayoutTabDraggingComponentState';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { TabListFromUrlOptionalEffect } from '@/ui/layout/tab-list/components/TabListFromUrlOptionalEffect';
import { TabListHiddenMeasurements } from '@/ui/layout/tab-list/components/TabListHiddenMeasurements';
import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap';
import { useTabListMeasurements } from '@/ui/layout/tab-list/hooks/useTabListMeasurements';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { type TabListProps } from '@/ui/layout/tab-list/types/TabListProps';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { PageLayoutTabListReorderableOverflowDropdown } from '@/page-layout/components/PageLayoutTabListReorderableOverflowDropdown';
import { PageLayoutTabListStaticOverflowDropdown } from '@/page-layout/components/PageLayoutTabListStaticOverflowDropdown';
import { PageLayoutTabListVisibleTabs } from '@/page-layout/components/PageLayoutTabListVisibleTabs';
import { pageLayoutTabListCurrentDragDroppableIdComponentState } from '@/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
height: ${({ theme }) => theme.spacing(10)};
position: relative;
user-select: none;
width: 100%;
&::after {
background-color: ${({ theme }) => theme.border.color.light};
bottom: 0;
content: '';
height: 1px;
left: 0;
position: absolute;
right: 0;
}
`;
const StyledAddButton = styled.div`
display: flex;
align-items: center;
height: ${({ theme }) => theme.spacing(10)};
margin-left: ${TAB_LIST_GAP}px;
`;
type PageLayoutTabListProps = TabListProps & {
isReorderEnabled: boolean;
onAddTab?: () => void;
onReorder?: (result: DropResult, provided: ResponderProvided) => boolean;
};
export const PageLayoutTabList = ({
tabs,
loading,
behaveAsLinks = true,
isInRightDrawer,
className,
componentInstanceId,
onChangeTab,
onAddTab,
isReorderEnabled,
onReorder,
}: PageLayoutTabListProps) => {
const visibleTabs = useMemo(() => tabs.filter((tab) => !tab.hide), [tabs]);
const navigate = useNavigate();
const [activeTabId, setActiveTabId] = useRecoilComponentState(
activeTabIdComponentState,
componentInstanceId,
);
const activeTabExists = visibleTabs.some((tab) => tab.id === activeTabId);
const initialActiveTabId = activeTabExists ? activeTabId : visibleTabs[0]?.id;
const {
visibleTabCount,
hiddenTabs,
hiddenTabsCount,
hasHiddenTabs,
onTabWidthChange,
onContainerWidthChange,
onMoreButtonWidthChange,
onAddButtonWidthChange,
} = useTabListMeasurements({
visibleTabs,
hasAddButton: isDefined(onAddTab),
});
const dropdownId = `tab-overflow-${componentInstanceId}`;
const { closeDropdown } = useCloseDropdown();
const { openDropdown } = useOpenDropdown();
const { toggleClickOutside } = useClickOutsideListener(dropdownId);
const setIsTabDragging = useSetRecoilComponentState(
isPageLayoutTabDraggingComponentState,
componentInstanceId,
);
const isActiveTabHidden = useMemo(() => {
if (!hasHiddenTabs) return false;
return hiddenTabs.some((tab) => tab.id === activeTabId);
}, [hasHiddenTabs, hiddenTabs, activeTabId]);
useEffect(() => {
setActiveTabId(initialActiveTabId);
onChangeTab?.(initialActiveTabId || '');
}, [initialActiveTabId, setActiveTabId, onChangeTab]);
const selectTab = useCallback(
(tabId: string) => {
setActiveTabId(tabId);
onChangeTab?.(tabId);
},
[setActiveTabId, onChangeTab],
);
const selectTabFromDropdown = useCallback(
(tabId: string) => {
if (behaveAsLinks) {
navigate(`#${tabId}`);
onChangeTab?.(tabId);
return;
}
selectTab(tabId);
},
[behaveAsLinks, selectTab, navigate, onChangeTab],
);
const closeOverflowDropdown = useCallback(() => {
closeDropdown(dropdownId);
}, [closeDropdown, dropdownId]);
const setPageLayoutTabListCurrentDragDroppableId = useSetRecoilComponentState(
pageLayoutTabListCurrentDragDroppableIdComponentState,
);
const handleDragUpdate: OnDragUpdateResponder = (update) => {
setPageLayoutTabListCurrentDragDroppableId(update.destination?.droppableId);
};
const handleDragStart = useCallback<OnDragStartResponder>(() => {
setIsTabDragging(true);
toggleClickOutside(false);
}, [setIsTabDragging, toggleClickOutside]);
const handleDragEnd = useCallback<OnDragEndResponder>(
(result, provided) => {
const droppedInOverflow =
result.destination?.droppableId ===
PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS;
if (!droppedInOverflow) {
setIsTabDragging(false);
}
toggleClickOutside(true);
if (!onReorder) {
return;
}
const shouldOpenDropdown = onReorder(result, provided);
if (shouldOpenDropdown === true) {
openDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
}
},
[onReorder, setIsTabDragging, toggleClickOutside, openDropdown, dropdownId],
);
if (visibleTabs.length === 0) {
return null;
}
const canReorderTabs = isReorderEnabled && isDefined(onReorder);
const shouldRenderReorderableDropdown = hasHiddenTabs && canReorderTabs;
const shouldRenderStaticDropdown = hasHiddenTabs && !canReorderTabs;
return (
<TabListComponentInstanceContext.Provider
value={{ instanceId: componentInstanceId }}
>
<TabListFromUrlOptionalEffect
isInRightDrawer={!!isInRightDrawer}
tabListIds={tabs.map((tab) => tab.id)}
/>
{visibleTabs.length > 1 && (
<TabListHiddenMeasurements
visibleTabs={visibleTabs}
activeTabId={activeTabId}
loading={loading}
onTabWidthChange={onTabWidthChange}
onMoreButtonWidthChange={onMoreButtonWidthChange}
onAddButtonWidthChange={onAddTab ? onAddButtonWidthChange : undefined}
addButtonMeasurement={
onAddTab ? (
<StyledAddButton>
<IconButton Icon={IconPlus} size="small" variant="tertiary" />
</StyledAddButton>
) : undefined
}
/>
)}
<NodeDimension onDimensionChange={onContainerWidthChange}>
{isReorderEnabled && onReorder ? (
<DragDropContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragUpdate={handleDragUpdate}
>
<StyledContainer className={className}>
<PageLayoutTabListVisibleTabs
visibleTabs={visibleTabs}
visibleTabCount={visibleTabCount}
activeTabId={activeTabId}
behaveAsLinks={behaveAsLinks}
loading={loading}
onChangeTab={onChangeTab}
onSelectTab={selectTab}
canReorder={canReorderTabs}
/>
{shouldRenderReorderableDropdown && (
<PageLayoutTabListReorderableOverflowDropdown
dropdownId={dropdownId}
hiddenTabs={hiddenTabs}
hiddenTabsCount={hiddenTabsCount}
isActiveTabHidden={isActiveTabHidden}
activeTabId={activeTabId || ''}
loading={loading}
onSelect={selectTabFromDropdown}
visibleTabCount={visibleTabCount}
onClose={closeOverflowDropdown}
/>
)}
{onAddTab && (
<StyledAddButton>
<IconButton
Icon={IconPlus}
size="small"
variant="tertiary"
onClick={() => onAddTab()}
/>
</StyledAddButton>
)}
</StyledContainer>
</DragDropContext>
) : (
<StyledContainer className={className}>
<PageLayoutTabListVisibleTabs
visibleTabs={visibleTabs}
visibleTabCount={visibleTabCount}
activeTabId={activeTabId}
behaveAsLinks={behaveAsLinks}
loading={loading}
onChangeTab={onChangeTab}
onSelectTab={selectTab}
canReorder={canReorderTabs}
/>
{shouldRenderStaticDropdown && (
<PageLayoutTabListStaticOverflowDropdown
dropdownId={dropdownId}
hiddenTabs={hiddenTabs}
hiddenTabsCount={hiddenTabsCount}
isActiveTabHidden={isActiveTabHidden}
activeTabId={activeTabId || ''}
loading={loading}
onSelect={selectTabFromDropdown}
onClose={closeOverflowDropdown}
/>
)}
{onAddTab && (
<StyledAddButton>
<IconButton
Icon={IconPlus}
size="small"
variant="tertiary"
onClick={() => onAddTab()}
/>
</StyledAddButton>
)}
</StyledContainer>
)}
</NodeDimension>
</TabListComponentInstanceContext.Provider>
);
};
@@ -0,0 +1,5 @@
export const PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS = {
VISIBLE_TABS: 'page-layout-visible-tabs',
OVERFLOW_TABS: 'page-layout-overflow-tabs',
MORE_BUTTON: 'page-layout-more-button',
} as const;
@@ -0,0 +1,42 @@
import styled from '@emotion/styled';
import { Droppable } from '@hello-pangea/dnd';
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { TabMoreButton } from '@/ui/layout/tab-list/components/TabMoreButton';
type PageLayoutTabListDroppableMoreButtonProps = {
hiddenTabsCount: number;
isActiveTabHidden: boolean;
};
const StyledTabMoreButton = styled(TabMoreButton)<{ isDraggingOver: boolean }>`
${({ isDraggingOver, theme }) =>
isDraggingOver &&
`
background-color: ${theme.background.transparent.blue};
pointer-events: none;
`}
`;
export const PageLayoutTabListDroppableMoreButton = ({
hiddenTabsCount,
isActiveTabHidden,
}: PageLayoutTabListDroppableMoreButtonProps) => {
return (
<Droppable droppableId={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
<StyledTabMoreButton
hiddenTabsCount={hiddenTabsCount}
active={isActiveTabHidden}
isDraggingOver={snapshot.isDraggingOver}
/>
</div>
)}
</Droppable>
);
};
@@ -0,0 +1,192 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
Draggable,
type DraggableProvided,
type DraggableRubric,
type DraggableStateSnapshot,
Droppable,
} from '@hello-pangea/dnd';
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { PageLayoutTabListDroppableMoreButton } from '@/page-layout/components/PageLayoutTabListDroppableMoreButton';
import { PageLayoutTabRenderClone } from '@/page-layout/components/PageLayoutTabRenderClone';
import { isPageLayoutTabDraggingComponentState } from '@/page-layout/states/isPageLayoutTabDraggingComponentState';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { TabAvatar } from '@/ui/layout/tab-list/components/TabAvatar';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useContext } from 'react';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
const StyledOverflowDropdownListDraggableWrapper = styled.div`
display: flex;
cursor: grab;
&:active {
cursor: grabbing;
}
`;
type PageLayoutTabListReorderableOverflowDropdownProps = {
dropdownId: string;
hiddenTabs: SingleTabProps[];
hiddenTabsCount: number;
isActiveTabHidden: boolean;
activeTabId: string | null;
loading?: boolean;
onSelect: (tabId: string) => void;
visibleTabCount: number;
onClose: () => void;
};
export const PageLayoutTabListReorderableOverflowDropdown = ({
dropdownId,
hiddenTabs,
hiddenTabsCount,
isActiveTabHidden,
activeTabId,
loading,
onSelect,
visibleTabCount,
onClose,
}: PageLayoutTabListReorderableOverflowDropdownProps) => {
const theme = useTheme();
const context = useContext(TabListComponentInstanceContext);
const instanceId = context?.instanceId;
const isTabDragging = useRecoilComponentValue(
isPageLayoutTabDraggingComponentState,
instanceId,
);
const setIsTabDragging = useSetRecoilComponentState(
isPageLayoutTabDraggingComponentState,
instanceId,
);
const handleClose = () => {
if (!isTabDragging) {
onClose();
}
};
const handleTabSelect = (tabId: string) => {
setIsTabDragging(false);
onSelect(tabId);
handleClose();
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
onClickOutside={handleClose}
clickableComponent={
<PageLayoutTabListDroppableMoreButton
hiddenTabsCount={hiddenTabsCount}
isActiveTabHidden={isActiveTabHidden}
data-dropdown-id={dropdownId}
/>
}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<Droppable
droppableId={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}
renderClone={(
provided: DraggableProvided,
_snapshot: DraggableStateSnapshot,
rubric: DraggableRubric,
) => {
const overflowIndex = rubric.source.index - visibleTabCount;
const tab = hiddenTabs[overflowIndex];
return (
<PageLayoutTabRenderClone
provided={provided}
tab={tab}
activeTabId={activeTabId}
/>
);
}}
>
{(provided) => (
<DropdownMenuItemsContainer>
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{hiddenTabs.map((tab, index) => {
const globalIndex = visibleTabCount + index;
const disabled = tab.disabled ?? loading;
return (
<Draggable
key={tab.id}
draggableId={tab.id}
index={globalIndex}
isDragDisabled={disabled}
>
{(draggableProvided, draggableSnapshot) => (
<StyledOverflowDropdownListDraggableWrapper
ref={draggableProvided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.dragHandleProps}
style={{
...draggableProvided.draggableProps.style,
position: 'relative',
left: 'auto',
top: 'auto',
cursor: draggableSnapshot.isDragging
? 'grabbing'
: 'grab',
background: draggableSnapshot.isDragging
? theme.background.transparent.light
: 'none',
width: 50,
overflow: 'visible',
}}
>
<div
style={{
minWidth:
GenericDropdownContentWidth.Medium -
theme.spacingMultiplicator * 2,
}}
>
<MenuItemSelectAvatar
text={tab.title}
avatar={<TabAvatar tab={tab} />}
selected={tab.id === activeTabId}
onClick={
draggableSnapshot.isDragging
? undefined
: () => handleTabSelect(tab.id)
}
disabled={disabled}
/>
</div>
</StyledOverflowDropdownListDraggableWrapper>
)}
</Draggable>
);
})}
<div>{provided.placeholder}</div>
</div>
</DropdownMenuItemsContainer>
)}
</Droppable>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,51 @@
import { Draggable } from '@hello-pangea/dnd';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { StyledTabContainer, TabContent } from 'twenty-ui/input';
type PageLayoutTabListReorderableTabProps = {
tab: SingleTabProps;
index: number;
isActive: boolean;
disabled?: boolean;
onSelect: () => void;
};
export const PageLayoutTabListReorderableTab = ({
tab,
index,
isActive,
disabled,
onSelect,
}: PageLayoutTabListReorderableTabProps) => {
return (
<Draggable draggableId={tab.id} index={index} isDragDisabled={disabled}>
{(draggableProvided, draggableSnapshot) => (
<StyledTabContainer
ref={draggableProvided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.dragHandleProps}
onClick={draggableSnapshot.isDragging ? undefined : onSelect}
active={isActive}
disabled={disabled}
style={{
...draggableProvided.draggableProps.style,
cursor: draggableSnapshot.isDragging ? 'grabbing' : 'pointer',
}}
>
<TabContent
id={tab.id}
active={isActive}
disabled={disabled}
LeftIcon={tab.Icon}
title={tab.title}
logo={tab.logo}
pill={tab.pill}
/>
</StyledTabContainer>
)}
</Draggable>
);
};
@@ -0,0 +1,71 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { TabAvatar } from '@/ui/layout/tab-list/components/TabAvatar';
import { TabMoreButton } from '@/ui/layout/tab-list/components/TabMoreButton';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
type PageLayoutTabListStaticOverflowDropdownProps = {
dropdownId: string;
hiddenTabs: SingleTabProps[];
hiddenTabsCount: number;
isActiveTabHidden: boolean;
activeTabId: string | null;
loading?: boolean;
onSelect: (tabId: string) => void;
onClose: () => void;
};
export const PageLayoutTabListStaticOverflowDropdown = ({
dropdownId,
hiddenTabs,
hiddenTabsCount,
isActiveTabHidden,
activeTabId,
loading,
onSelect,
onClose,
}: PageLayoutTabListStaticOverflowDropdownProps) => {
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
onClickOutside={onClose}
clickableComponent={
<TabMoreButton
hiddenTabsCount={hiddenTabsCount}
active={isActiveTabHidden}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
{hiddenTabs.map((tab) => {
const disabled = tab.disabled ?? loading;
return (
<MenuItemSelectAvatar
key={tab.id}
text={tab.title}
avatar={<TabAvatar tab={tab} />}
selected={tab.id === activeTabId}
onClick={
disabled
? undefined
: () => {
onSelect(tab.id);
onClose();
}
}
disabled={disabled}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,119 @@
import styled from '@emotion/styled';
import {
type DraggableProvided,
type DraggableRubric,
type DraggableStateSnapshot,
Droppable,
} from '@hello-pangea/dnd';
import { TabButton } from 'twenty-ui/input';
import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { PageLayoutTabListReorderableTab } from '@/page-layout/components/PageLayoutTabListReorderableTab';
import { PageLayoutTabRenderClone } from '@/page-layout/components/PageLayoutTabRenderClone';
type PageLayoutTabListVisibleTabsProps = {
visibleTabs: SingleTabProps[];
visibleTabCount: number;
activeTabId: string | null;
behaveAsLinks: boolean;
loading?: boolean;
onChangeTab?: (tabId: string) => void;
onSelectTab: (tabId: string) => void;
canReorder: boolean;
};
const StyledTabContainer = styled.div`
display: flex;
position: relative;
overflow: hidden;
max-width: 100%;
> *:not(:last-child) {
margin-right: ${TAB_LIST_GAP}px;
}
// > div[data-rbd-placeholder-context-id] {
margin-right: ${TAB_LIST_GAP}px;
}
`;
export const PageLayoutTabListVisibleTabs = ({
visibleTabs,
visibleTabCount,
activeTabId,
behaveAsLinks,
loading,
onChangeTab,
onSelectTab,
canReorder,
}: PageLayoutTabListVisibleTabsProps) => {
if (canReorder) {
return (
<Droppable
droppableId={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS}
direction="horizontal"
renderClone={(
provided: DraggableProvided,
_snapshot: DraggableStateSnapshot,
rubric: DraggableRubric,
) => {
const tab = visibleTabs[rubric.source.index];
return (
<PageLayoutTabRenderClone
provided={provided}
tab={tab}
activeTabId={activeTabId}
/>
);
}}
>
{(provided) => (
<StyledTabContainer
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{visibleTabs.slice(0, visibleTabCount).map((tab, index) => (
<PageLayoutTabListReorderableTab
key={tab.id}
tab={tab}
index={index}
isActive={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
onSelect={() => onSelectTab(tab.id)}
/>
))}
{provided.placeholder}
</StyledTabContainer>
)}
</Droppable>
);
}
return (
<StyledTabContainer>
{visibleTabs.slice(0, visibleTabCount).map((tab) => (
<TabButton
key={tab.id}
id={tab.id}
title={tab.title}
LeftIcon={tab.Icon}
logo={tab.logo}
active={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
pill={tab.pill}
to={behaveAsLinks ? `#${tab.id}` : undefined}
onClick={
behaveAsLinks
? () => onChangeTab?.(tab.id)
: () => onSelectTab(tab.id)
}
/>
))}
</StyledTabContainer>
);
};
@@ -0,0 +1,101 @@
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { pageLayoutTabListCurrentDragDroppableIdComponentState } from '@/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { TabAvatar } from '@/ui/layout/tab-list/components/TabAvatar';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type DraggableProvided } from '@hello-pangea/dnd';
import { StyledTabContainer, TabContent } from 'twenty-ui/input';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
const StyledDraggableWrapper = styled.div`
display: flex;
cursor: grab;
&:active {
cursor: grabbing;
}
`;
export const PageLayoutTabRenderClone = ({
tab,
provided,
activeTabId,
}: {
tab: SingleTabProps;
provided: DraggableProvided;
activeTabId: string | null;
}) => {
const theme = useTheme();
const pageLayoutTabListCurrentDragDroppableId = useRecoilComponentValue(
pageLayoutTabListCurrentDragDroppableIdComponentState,
);
const isHoveringTabList =
pageLayoutTabListCurrentDragDroppableId ===
PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS;
if (!tab) return null;
if (isHoveringTabList) {
return (
<StyledDraggableWrapper
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.draggableProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style,
cursor: 'grabbing',
}}
>
<StyledTabContainer active={tab.id === activeTabId} disabled={false}>
<TabContent
id={tab.id}
active={false}
disabled={false}
LeftIcon={tab.Icon}
title={tab.title}
logo={tab.logo}
pill={tab.pill}
/>
</StyledTabContainer>
</StyledDraggableWrapper>
);
} else {
return (
<StyledDraggableWrapper
id={'clone-drag-wrapper'}
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.draggableProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style,
cursor: 'grabbing',
}}
>
<div
style={{
minWidth:
GenericDropdownContentWidth.Medium -
theme.spacingMultiplicator * 2,
}}
>
<MenuItemSelectAvatar
text={tab.title}
avatar={<TabAvatar tab={tab} />}
selected={tab.id === activeTabId}
onClick={undefined}
disabled
/>
</div>
</StyledDraggableWrapper>
);
}
};
@@ -0,0 +1,161 @@
import styled from '@emotion/styled';
import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd';
import type { Meta, StoryObj } from '@storybook/react';
import { useMemo, useState } from 'react';
import { RecoilRoot } from 'recoil';
import { IconPlus } from 'twenty-ui/display';
import { ComponentWithRouterDecorator } from 'twenty-ui/testing';
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList';
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
const StyledContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.strong};
padding: ${({ theme }) => theme.spacing(4)};
width: 720px;
`;
type PageLayoutTab = SingleTabProps & {
position: number;
};
const createInitialTabs = (): PageLayoutTab[] => [
{ id: 'overview', title: 'Overview', position: 0, Icon: IconPlus },
{ id: 'revenue', title: 'Revenue', position: 1 },
{ id: 'forecasts', title: 'Forecasts', position: 2 },
];
const PageLayoutTabListPlayground = ({
isReorderEnabled,
}: {
isReorderEnabled: boolean;
}) => {
const [tabs, setTabs] = useState<PageLayoutTab[]>(createInitialTabs());
const [nextIndex, setNextIndex] = useState(tabs.length);
const sortedTabs = useMemo(() => {
return [...tabs].sort((a, b) => a.position - b.position);
}, [tabs]);
const handleAddTab = () => {
setTabs((prev) => [
...prev,
{
id: `new-tab-${nextIndex}`,
title: `New Tab ${nextIndex}`,
position: nextIndex,
},
]);
setNextIndex((value) => value + 1);
};
const handleReorder = (
result: DropResult,
_provided: ResponderProvided,
): boolean => {
const { destination, source, draggableId } = result;
if (!destination) {
return false;
}
const isDroppedOnMoreButton =
destination.droppableId ===
PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON;
if (isDroppedOnMoreButton) {
setTabs((prev) => {
const maxPosition = Math.max(...prev.map((tab) => tab.position), 0);
return prev.map((tab) =>
tab.id === draggableId ? { ...tab, position: maxPosition + 1 } : tab,
);
});
return true;
}
setTabs((prev) => {
const sorted = [...prev].sort((a, b) => a.position - b.position);
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return prev;
}
const draggedTab = sorted.find((tab) => tab.id === draggableId);
if (!draggedTab) {
return prev;
}
const withoutDragged = sorted.filter((tab) => tab.id !== draggableId);
const movingBetweenDroppables =
destination.droppableId !== source.droppableId;
const destinationIndexAdjusted =
movingBetweenDroppables && destination.index > source.index
? destination.index - 1
: destination.index;
const newPosition = calculateNewPosition({
destinationIndex: destinationIndexAdjusted,
sourceIndex: source.index,
items: withoutDragged,
});
return prev.map((tab) =>
tab.id === draggableId ? { ...tab, position: newPosition } : tab,
);
});
return false;
};
return (
<StyledContainer>
<PageLayoutTabList
tabs={sortedTabs}
componentInstanceId="page-layout-tab-list-story"
behaveAsLinks={false}
loading={false}
onAddTab={isReorderEnabled ? handleAddTab : undefined}
isReorderEnabled={isReorderEnabled}
onReorder={isReorderEnabled ? handleReorder : undefined}
/>
</StyledContainer>
);
};
const meta: Meta<typeof PageLayoutTabListPlayground> = {
title: 'Modules/PageLayout/PageLayoutTabList',
component: PageLayoutTabListPlayground,
args: {
isReorderEnabled: true,
},
decorators: [
ComponentWithRouterDecorator,
(Story) => (
<RecoilRoot>
<PageLayoutComponentInstanceContext.Provider
value={{ instanceId: 'instance-id' }}
>
<Story />
</PageLayoutComponentInstanceContext.Provider>
</RecoilRoot>
),
],
};
export default meta;
type Story = StoryObj<typeof PageLayoutTabListPlayground>;
export const Default: Story = {
args: {
isReorderEnabled: true,
},
};
@@ -0,0 +1,96 @@
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout';
import { usePageLayoutDraftState } from '@/page-layout/hooks/usePageLayoutDraftState';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { type DropResult } from '@hello-pangea/dnd';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useReorderPageLayoutTabs = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const { currentPageLayout } = useCurrentPageLayout();
const { setPageLayoutDraft } = usePageLayoutDraftState(pageLayoutId);
const reorderTabs = useCallback(
(result: DropResult): boolean => {
const { source, destination, draggableId } = result;
if (!isDefined(destination) || !isDefined(currentPageLayout)) {
return false;
}
if (
source.droppableId === destination.droppableId &&
source.index === destination.index
) {
return false;
}
const sortedTabs = [...currentPageLayout.tabs].sort(
(a, b) => a.position - b.position,
);
const draggedTab = sortedTabs.find((tab) => tab.id === draggableId);
if (!isDefined(draggedTab)) {
return false;
}
if (
destination.droppableId ===
PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON
) {
const maxPosition =
sortedTabs.length > 0
? Math.max(...sortedTabs.map((tab) => tab.position))
: 0;
setPageLayoutDraft((prev) => ({
...prev,
tabs: prev.tabs.map((tab) =>
tab.id === draggableId
? { ...tab, position: maxPosition + 1 }
: tab,
),
}));
return true;
}
const tabsWithoutDragged = sortedTabs.filter(
(tab) => tab.id !== draggableId,
);
const movingBetweenDroppables =
source.droppableId !== destination.droppableId;
const destinationIndexAdjusted =
movingBetweenDroppables && destination.index > source.index
? destination.index - 1
: destination.index;
const newPosition = calculateNewPosition({
destinationIndex: destinationIndexAdjusted,
sourceIndex: source.index,
items: tabsWithoutDragged,
});
setPageLayoutDraft((prev) => ({
...prev,
tabs: prev.tabs.map((tab) =>
tab.id === draggableId ? { ...tab, position: newPosition } : tab,
),
}));
return false;
},
[currentPageLayout, setPageLayoutDraft],
);
return { reorderTabs };
};
@@ -0,0 +1,9 @@
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const isPageLayoutTabDraggingComponentState =
createComponentState<boolean>({
key: 'isPageLayoutTabDraggingComponentState',
defaultValue: false,
componentInstanceContext: TabListComponentInstanceContext,
});
@@ -0,0 +1,10 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutTabListCurrentDragDroppableIdComponentState =
createComponentState<string | undefined>({
key: 'pageLayoutTabListCurrentDragDroppableIdComponentState',
defaultValue: undefined,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -3,18 +3,16 @@ import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { type TabListProps } from '@/ui/layout/tab-list/types/TabListProps';
import { type TabWidthsById } from '@/ui/layout/tab-list/types/TabWidthsById';
import { calculateVisibleTabCount } from '@/ui/layout/tab-list/utils/calculateVisibleTabCount';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { TabListHiddenMeasurements } from '@/ui/layout/tab-list/components/TabListHiddenMeasurements';
import { useTabListMeasurements } from '@/ui/layout/tab-list/hooks/useTabListMeasurements';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import styled from '@emotion/styled';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { IconPlus } from 'twenty-ui/display';
import { IconButton, TabButton } from 'twenty-ui/input';
import { TabButton } from 'twenty-ui/input';
import { TabListDropdown } from './TabListDropdown';
import { TabListFromUrlOptionalEffect } from './TabListFromUrlOptionalEffect';
import { TabMoreButton } from './TabMoreButton';
const StyledContainer = styled.div`
box-sizing: border-box;
@@ -43,22 +41,6 @@ const StyledTabContainer = styled.div`
max-width: 100%;
`;
const StyledHiddenMeasurement = styled.div`
display: flex;
gap: ${TAB_LIST_GAP}px;
pointer-events: none;
position: absolute;
top: -9999px;
visibility: hidden;
`;
const StyledAddButton = styled.div`
display: flex;
align-items: center;
height: ${({ theme }) => theme.spacing(10)};
margin-left: ${TAB_LIST_GAP}px;
`;
export const TabList = ({
tabs,
loading,
@@ -67,7 +49,6 @@ export const TabList = ({
className,
componentInstanceId,
onChangeTab,
onAddTab,
}: TabListProps) => {
const visibleTabs = tabs.filter((tab) => !tab.hide);
const navigate = useNavigate();
@@ -77,42 +58,29 @@ export const TabList = ({
componentInstanceId,
);
const [tabWidthsById, setTabWidthsById] = useState<TabWidthsById>({});
const [containerWidth, setContainerWidth] = useState(0);
const [moreButtonWidth, setMoreButtonWidth] = useState(0);
const [addButtonWidth, setAddButtonWidth] = useState(0);
const activeTabExists = visibleTabs.some((tab) => tab.id === activeTabId);
const initialActiveTabId = activeTabExists ? activeTabId : visibleTabs[0]?.id;
const visibleTabCount = useMemo(() => {
return calculateVisibleTabCount({
visibleTabs,
tabWidthsById,
containerWidth,
moreButtonWidth,
addButtonWidth: onAddTab ? addButtonWidth : 0,
});
}, [
tabWidthsById,
containerWidth,
moreButtonWidth,
addButtonWidth,
const {
visibleTabCount,
hiddenTabs,
hiddenTabsCount,
hasHiddenTabs,
onTabWidthChange,
onContainerWidthChange,
onMoreButtonWidthChange,
} = useTabListMeasurements({
visibleTabs,
onAddTab,
]);
const hiddenTabsCount = visibleTabs.length - visibleTabCount;
const hasHiddenTabs = hiddenTabsCount > 0;
hasAddButton: false,
});
const dropdownId = `tab-overflow-${componentInstanceId}`;
const { closeDropdown } = useCloseDropdown();
const isActiveTabHidden = useMemo(() => {
if (!hasHiddenTabs) return false;
const hiddenTabs = visibleTabs.slice(visibleTabCount);
return hiddenTabs.some((tab) => tab.id === activeTabId);
}, [hasHiddenTabs, visibleTabs, visibleTabCount, activeTabId]);
}, [hasHiddenTabs, hiddenTabs, activeTabId]);
useEffect(() => {
setActiveTabId(initialActiveTabId);
@@ -139,48 +107,6 @@ export const TabList = ({
[behaveAsLinks, handleTabSelect, navigate, onChangeTab],
);
const handleTabWidthChange = useCallback(
(tabId: string) => (dimensions: { width: number; height: number }) => {
setTabWidthsById((prev) => {
if (prev[tabId] !== dimensions.width) {
return {
...prev,
[tabId]: dimensions.width,
};
}
return prev;
});
},
[],
);
const handleContainerWidthChange = useCallback(
(dimensions: { width: number; height: number }) => {
setContainerWidth((prev) => {
return prev !== dimensions.width ? dimensions.width : prev;
});
},
[],
);
const handleMoreButtonWidthChange = useCallback(
(dimensions: { width: number; height: number }) => {
setMoreButtonWidth((prev) => {
return prev !== dimensions.width ? dimensions.width : prev;
});
},
[],
);
const handleAddButtonWidthChange = useCallback(
(dimensions: { width: number; height: number }) => {
setAddButtonWidth((prev) => {
return prev !== dimensions.width ? dimensions.width : prev;
});
},
[],
);
if (visibleTabs.length === 0) {
return null;
}
@@ -196,40 +122,16 @@ export const TabList = ({
/>
{visibleTabs.length > 1 && (
<StyledHiddenMeasurement>
{visibleTabs.map((tab) => (
<NodeDimension
key={tab.id}
onDimensionChange={handleTabWidthChange(tab.id)}
>
<TabButton
id={tab.id}
title={tab.title}
LeftIcon={tab.Icon}
logo={tab.logo}
active={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
pill={tab.pill}
disableTestId={true}
/>
</NodeDimension>
))}
<NodeDimension onDimensionChange={handleMoreButtonWidthChange}>
<TabMoreButton hiddenTabsCount={1} active={false} />
</NodeDimension>
{onAddTab && (
<NodeDimension onDimensionChange={handleAddButtonWidthChange}>
<StyledAddButton>
<IconButton Icon={IconPlus} size="small" variant="tertiary" />
</StyledAddButton>
</NodeDimension>
)}
</StyledHiddenMeasurement>
<TabListHiddenMeasurements
visibleTabs={visibleTabs}
activeTabId={activeTabId}
loading={loading}
onTabWidthChange={onTabWidthChange}
onMoreButtonWidthChange={onMoreButtonWidthChange}
/>
)}
<NodeDimension onDimensionChange={handleContainerWidthChange}>
<NodeDimension onDimensionChange={onContainerWidthChange}>
<StyledContainer className={className}>
<StyledTabContainer>
{visibleTabs.slice(0, visibleTabCount).map((tab) => (
@@ -262,23 +164,12 @@ export const TabList = ({
hiddenTabsCount,
isActiveTabHidden,
}}
hiddenTabs={visibleTabs.slice(visibleTabCount)}
hiddenTabs={hiddenTabs}
activeTabId={activeTabId || ''}
onTabSelect={handleTabSelectFromDropdown}
loading={loading}
/>
)}
{onAddTab && (
<StyledAddButton>
<IconButton
Icon={IconPlus}
size="small"
variant="tertiary"
onClick={() => onAddTab()}
/>
</StyledAddButton>
)}
</StyledContainer>
</NodeDimension>
</>
@@ -0,0 +1,79 @@
import type { ReactNode } from 'react';
import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import styled from '@emotion/styled';
import { IconPlus } from 'twenty-ui/display';
import { TabButton } from 'twenty-ui/input';
import { type TabListDimensions } from '@/ui/layout/tab-list/types/TabListDimension';
import { TabMoreButton } from './TabMoreButton';
type TabListHiddenMeasurementsProps = {
visibleTabs: SingleTabProps[];
activeTabId: string | null;
loading?: boolean;
onTabWidthChange: (tabId: string) => (dimensions: TabListDimensions) => void;
onMoreButtonWidthChange: (dimensions: TabListDimensions) => void;
onAddButtonWidthChange?: (dimensions: TabListDimensions) => void;
addButtonMeasurement?: ReactNode;
};
const StyledHiddenMeasurement = styled.div`
display: flex;
gap: ${TAB_LIST_GAP}px;
pointer-events: none;
position: absolute;
top: -9999px;
visibility: hidden;
`;
export const TabListHiddenMeasurements = ({
visibleTabs,
activeTabId,
loading,
onTabWidthChange,
onMoreButtonWidthChange,
onAddButtonWidthChange,
addButtonMeasurement,
}: TabListHiddenMeasurementsProps) => {
return (
<StyledHiddenMeasurement>
{visibleTabs.map((tab) => (
<NodeDimension
key={tab.id}
onDimensionChange={onTabWidthChange(tab.id)}
>
<TabButton
id={tab.id}
title={tab.title}
LeftIcon={tab.Icon}
logo={tab.logo}
active={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
pill={tab.pill}
disableTestId={true}
/>
</NodeDimension>
))}
<NodeDimension onDimensionChange={onMoreButtonWidthChange}>
<TabMoreButton hiddenTabsCount={1} active={false} />
</NodeDimension>
{onAddButtonWidthChange && (
<NodeDimension onDimensionChange={onAddButtonWidthChange}>
{addButtonMeasurement ?? (
<TabButton
id="tab-add-button"
title="+"
LeftIcon={IconPlus}
disableTestId={true}
/>
)}
</NodeDimension>
)}
</StyledHiddenMeasurement>
);
};
@@ -10,9 +10,11 @@ const StyledTabMoreButton = styled(TabButton)`
export const TabMoreButton = ({
hiddenTabsCount,
active,
className,
}: {
hiddenTabsCount: number;
active: boolean;
className?: string;
}) => {
return (
<StyledTabMoreButton
@@ -20,6 +22,7 @@ export const TabMoreButton = ({
active={active}
title={`+${hiddenTabsCount} ${t`More`}`}
RightIcon={IconChevronDown}
className={className}
/>
);
};
@@ -1,6 +1,5 @@
import styled from '@emotion/styled';
import { type Meta, type StoryObj } from '@storybook/react';
import { useState } from 'react';
import {
IconCalendar,
IconCheckbox,
@@ -12,8 +11,6 @@ import {
} from 'twenty-ui/display';
import { ComponentWithRouterDecorator } from 'twenty-ui/testing';
import { TabList } from '../TabList';
import { type TabListProps } from '../../types/TabListProps';
import { type SingleTabProps } from '../../types/SingleTabProps';
const tabs = [
{ id: 'general', title: 'General', logo: 'https://picsum.photos/200' },
@@ -83,72 +80,3 @@ export const Default: Story = {
</StyledInteractiveContainer>
),
};
type TabListWithAddProps = Pick<
TabListProps,
'componentInstanceId' | 'loading' | 'isInRightDrawer' | 'className'
> & {
initialTabs: SingleTabProps[];
};
const TabListWithAdd = ({
componentInstanceId,
loading,
isInRightDrawer,
className,
initialTabs,
}: TabListWithAddProps) => {
const [currentTabs, setCurrentTabs] = useState<SingleTabProps[]>(initialTabs);
const [nextTabId, setNextTabId] = useState(initialTabs.length + 1);
const handleAddTab = () => {
const newTab: SingleTabProps = {
id: `new-tab-${nextTabId}`,
title: `New Tab ${nextTabId}`,
Icon: IconCheckbox,
};
setCurrentTabs([...currentTabs, newTab]);
setNextTabId(nextTabId + 1);
};
return (
<StyledInteractiveContainer>
<p>
<strong>Click the + button to add new tabs!</strong>
</p>
<TabList
tabs={currentTabs}
componentInstanceId={componentInstanceId}
loading={loading}
behaveAsLinks={false}
isInRightDrawer={isInRightDrawer}
className={className}
onAddTab={handleAddTab}
/>
</StyledInteractiveContainer>
);
};
export const WithAddTab: Story = {
args: {
componentInstanceId: 'tabs-with-add',
tabs: tabs.slice(0, 3),
},
render: (args) => (
<TabListWithAdd
componentInstanceId={args.componentInstanceId}
loading={args.loading}
isInRightDrawer={args.isInRightDrawer}
className={args.className}
initialTabs={args.tabs}
/>
),
parameters: {
docs: {
description: {
story:
'TabList with the ability to add new tabs dynamically using the onAddTab callback. Click the + button to add new tabs.',
},
},
},
};
@@ -0,0 +1,110 @@
import { useCallback, useMemo, useState } from 'react';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { type TabListDimensions } from '@/ui/layout/tab-list/types/TabListDimension';
import { type TabWidthsById } from '@/ui/layout/tab-list/types/TabWidthsById';
import { calculateVisibleTabCount } from '@/ui/layout/tab-list/utils/calculateVisibleTabCount';
type UseTabListMeasurementsOptions = {
visibleTabs: SingleTabProps[];
hasAddButton?: boolean;
};
type UseTabListMeasurementsResult = {
visibleTabCount: number;
hiddenTabs: SingleTabProps[];
hiddenTabsCount: number;
hasHiddenTabs: boolean;
onTabWidthChange: (tabId: string) => (dimensions: TabListDimensions) => void;
onContainerWidthChange: (dimensions: TabListDimensions) => void;
onMoreButtonWidthChange: (dimensions: TabListDimensions) => void;
onAddButtonWidthChange: (dimensions: TabListDimensions) => void;
};
export const useTabListMeasurements = ({
visibleTabs,
hasAddButton = false,
}: UseTabListMeasurementsOptions): UseTabListMeasurementsResult => {
const [tabWidthsById, setTabWidthsById] = useState<TabWidthsById>({});
const [containerWidth, setContainerWidth] = useState(0);
const [moreButtonWidth, setMoreButtonWidth] = useState(0);
const [addButtonWidth, setAddButtonWidth] = useState(0);
const visibleTabCount = useMemo(() => {
return calculateVisibleTabCount({
visibleTabs,
tabWidthsById,
containerWidth,
moreButtonWidth,
addButtonWidth: hasAddButton ? addButtonWidth : 0,
});
}, [
visibleTabs,
tabWidthsById,
containerWidth,
moreButtonWidth,
addButtonWidth,
hasAddButton,
]);
const hiddenTabs = useMemo(() => {
return visibleTabs.slice(visibleTabCount);
}, [visibleTabs, visibleTabCount]);
const hiddenTabsCount = hiddenTabs.length;
const hasHiddenTabs = hiddenTabsCount > 0;
const onTabWidthChange = useCallback(
(tabId: string) => (dimensions: TabListDimensions) => {
setTabWidthsById((prev) => {
if (prev[tabId] !== dimensions.width) {
return {
...prev,
[tabId]: dimensions.width,
};
}
return prev;
});
},
[],
);
const onContainerWidthChange = useCallback(
(dimensions: TabListDimensions) => {
setContainerWidth((prev) => {
return prev !== dimensions.width ? dimensions.width : prev;
});
},
[],
);
const onMoreButtonWidthChange = useCallback(
(dimensions: TabListDimensions) => {
setMoreButtonWidth((prev) => {
return prev !== dimensions.width ? dimensions.width : prev;
});
},
[],
);
const onAddButtonWidthChange = useCallback(
(dimensions: TabListDimensions) => {
setAddButtonWidth((prev) => {
return prev !== dimensions.width ? dimensions.width : prev;
});
},
[],
);
return {
visibleTabCount,
hiddenTabs,
hiddenTabsCount,
hasHiddenTabs,
onTabWidthChange,
onContainerWidthChange,
onMoreButtonWidthChange,
onAddButtonWidthChange,
};
};
@@ -0,0 +1,4 @@
export type TabListDimensions = {
width: number;
height: number;
};
@@ -8,5 +8,4 @@ export type TabListProps = {
isInRightDrawer?: boolean;
componentInstanceId: string;
onChangeTab?: (tabId: string) => void;
onAddTab?: () => void;
};
@@ -1,125 +0,0 @@
import isPropValid from '@emotion/is-prop-valid';
import styled from '@emotion/styled';
import { Pill } from '@ui/components/Pill/Pill';
import { Avatar, type IconComponent } from '@ui/display';
import { ThemeContext } from '@ui/theme';
import { type ReactElement, useContext } from 'react';
import { Link } from 'react-router-dom';
const StyledTabButton = styled('button', {
shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'active',
})<{
active?: boolean;
disabled?: boolean;
to?: string;
}>`
all: unset;
align-items: center;
color: ${({ theme, active, disabled }) =>
active
? theme.font.color.primary
: disabled
? theme.font.color.light
: theme.font.color.secondary};
cursor: pointer;
background-color: transparent;
border: none;
font-family: inherit;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
pointer-events: ${({ disabled }) => (disabled ? 'none' : '')};
text-decoration: none;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: ${({ theme, active }) =>
active ? theme.border.color.inverted : 'transparent'};
z-index: 1;
}
`;
const StyledTabHover = styled.span<{
contentSize?: 'sm' | 'md';
}>`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme, contentSize }) =>
contentSize === 'sm'
? `${theme.spacing(1)} ${theme.spacing(2)}`
: `${theme.spacing(2)} ${theme.spacing(2)}`};
font-weight: ${({ theme }) => theme.font.weight.medium};
width: 100%;
white-space: nowrap;
border-radius: ${({ theme }) => theme.border.radius.sm};
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
&:active {
background: ${({ theme }) => theme.background.quaternary};
}
`;
type TabButtonProps = {
id: string;
active?: boolean;
disabled?: boolean;
to?: string;
LeftIcon?: IconComponent;
className?: string;
title?: string;
onClick?: () => void;
logo?: string;
RightIcon?: IconComponent;
pill?: string | ReactElement;
contentSize?: 'sm' | 'md';
disableTestId?: boolean;
};
export const TabButton = ({
id,
active,
disabled,
to,
LeftIcon,
className,
title,
onClick,
logo,
RightIcon,
pill,
contentSize = 'sm',
disableTestId = false,
}: TabButtonProps) => {
const { theme } = useContext(ThemeContext);
const iconColor = active
? theme.font.color.primary
: disabled
? theme.font.color.extraLight
: theme.font.color.secondary;
return (
<StyledTabButton
data-testid={disableTestId ? undefined : `tab-${id}`}
active={active}
disabled={disabled}
as={to ? Link : 'button'}
to={to}
className={className}
onClick={onClick}
>
<StyledTabHover contentSize={contentSize}>
{LeftIcon && <LeftIcon color={iconColor} size={theme.icon.size.md} />}
{logo && <Avatar avatarUrl={logo} size="md" placeholder={title} />}
{title}
{RightIcon && <RightIcon color={iconColor} size={theme.icon.size.md} />}
{pill && (typeof pill === 'string' ? <Pill label={pill} /> : pill)}
</StyledTabHover>
</StyledTabButton>
);
};
@@ -0,0 +1,61 @@
import { type IconComponent } from '@ui/display';
import { StyledTabButton } from '@ui/input/button/components/TabButton/internals/components/StyledTabBase';
import { TabContent } from '@ui/input/button/components/TabButton/internals/components/TabContent';
import { type ReactElement } from 'react';
import { Link } from 'react-router-dom';
type TabButtonProps = {
id: string;
active?: boolean;
disabled?: boolean;
to?: string;
LeftIcon?: IconComponent;
className?: string;
title?: string;
onClick?: () => void;
logo?: string;
RightIcon?: IconComponent;
pill?: string | ReactElement;
contentSize?: 'sm' | 'md';
disableTestId?: boolean;
};
export const TabButton = ({
id,
active,
disabled,
to,
LeftIcon,
className,
title,
onClick,
logo,
RightIcon,
pill,
contentSize = 'sm',
disableTestId = false,
}: TabButtonProps) => {
return (
<StyledTabButton
data-testid={disableTestId ? undefined : `tab-${id}`}
active={active}
disabled={disabled}
as={to ? Link : 'button'}
to={to}
className={className}
onClick={onClick}
>
<TabContent
id={id}
active={active}
disabled={disabled}
LeftIcon={LeftIcon}
title={title}
logo={logo}
RightIcon={RightIcon}
pill={pill}
contentSize={contentSize}
/>
</StyledTabButton>
);
};
@@ -0,0 +1,93 @@
import isPropValid from '@emotion/is-prop-valid';
import styled from '@emotion/styled';
export const StyledTabButton = styled('button', {
shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'active',
})<{
active?: boolean;
disabled?: boolean;
to?: string;
}>`
all: unset;
align-items: center;
color: ${({ theme, active, disabled }) =>
active
? theme.font.color.primary
: disabled
? theme.font.color.light
: theme.font.color.secondary};
cursor: pointer;
background-color: transparent;
border: none;
font-family: inherit;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
pointer-events: ${({ disabled }) => (disabled ? 'none' : '')};
text-decoration: none;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: ${({ theme, active }) =>
active ? theme.border.color.inverted : 'transparent'};
z-index: 1;
}
`;
export const StyledTabContainer = styled.div<{
active?: boolean;
disabled?: boolean;
}>`
align-items: center;
color: ${({ theme, active, disabled }) =>
active
? theme.font.color.primary
: disabled
? theme.font.color.light
: theme.font.color.secondary};
cursor: pointer;
background-color: transparent;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
text-decoration: none;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: ${({ theme, active }) =>
active ? theme.border.color.inverted : 'transparent'};
z-index: 1;
}
`;
export const StyledTabHover = styled.span<{
contentSize?: 'sm' | 'md';
}>`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme, contentSize }) =>
contentSize === 'sm'
? `${theme.spacing(1)} ${theme.spacing(2)}`
: `${theme.spacing(2)} ${theme.spacing(2)}`};
font-weight: ${({ theme }) => theme.font.weight.medium};
width: 100%;
white-space: nowrap;
border-radius: ${({ theme }) => theme.border.radius.sm};
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
&:active {
background: ${({ theme }) => theme.background.quaternary};
}
`;
@@ -0,0 +1,45 @@
import { Pill } from '@ui/components/Pill/Pill';
import { Avatar, type IconComponent } from '@ui/display';
import { ThemeContext } from '@ui/theme';
import { type ReactElement, useContext } from 'react';
import { StyledTabHover } from './StyledTabBase';
export type TabContentProps = {
id: string;
active?: boolean;
disabled?: boolean;
LeftIcon?: IconComponent;
title?: string;
logo?: string;
RightIcon?: IconComponent;
pill?: string | ReactElement;
contentSize?: 'sm' | 'md';
};
export const TabContent = ({
active,
disabled,
LeftIcon,
title,
logo,
RightIcon,
pill,
contentSize = 'sm',
}: TabContentProps) => {
const { theme } = useContext(ThemeContext);
const iconColor = active
? theme.font.color.primary
: disabled
? theme.font.color.extraLight
: theme.font.color.secondary;
return (
<StyledTabHover contentSize={contentSize}>
{LeftIcon && <LeftIcon color={iconColor} size={theme.icon.size.md} />}
{logo && <Avatar avatarUrl={logo} size="md" placeholder={title} />}
{title}
{RightIcon && <RightIcon color={iconColor} size={theme.icon.size.md} />}
{pill && (typeof pill === 'string' ? <Pill label={pill} /> : pill)}
</StyledTabHover>
);
};
@@ -8,13 +8,13 @@ import {
IconSettings,
IconUser,
} from '@ui/display';
import { TabButton } from '@ui/input/button/components/TabButton/TabButton';
import {
CatalogDecorator,
type CatalogStory,
ComponentWithRouterDecorator,
RecoilRootDecorator,
} from '@ui/testing';
import { TabButton } from '../TabButton';
// Mimic the TabList container styling for proper positioning
const StyledTabContainer = styled.div`
+8 -1
View File
@@ -67,7 +67,14 @@ export { LightIconButtonGroup } from './button/components/LightIconButtonGroup';
export type { MainButtonVariant } from './button/components/MainButton';
export { MainButton } from './button/components/MainButton';
export { RoundedIconButton } from './button/components/RoundedIconButton';
export { TabButton } from './button/components/TabButton';
export {
StyledTabButton,
StyledTabContainer,
StyledTabHover,
} from './button/components/TabButton/internals/components/StyledTabBase';
export type { TabContentProps } from './button/components/TabButton/internals/components/TabContent';
export { TabContent } from './button/components/TabButton/internals/components/TabContent';
export { TabButton } from './button/components/TabButton/TabButton';
export { CodeEditor } from './code-editor/components/CodeEditor';
export type { CoreEditorHeaderProps } from './code-editor/components/CodeEditorHeader';
export { CoreEditorHeader } from './code-editor/components/CodeEditorHeader';