[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 22:58:00 +05:30
committed by GitHub
parent f301f07e43
commit 06a195af41
26 changed files with 1614 additions and 342 deletions
@@ -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;
};