[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:
@@ -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>
|
||||
</>
|
||||
|
||||
+79
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
-72
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user