[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:
co-authored by
Lucas Bordeau
Félix Malfait
parent
f301f07e43
commit
06a195af41
+16
-8
@@ -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>
|
||||
);
|
||||
};
|
||||
+5
@@ -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;
|
||||
+42
@@ -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>
|
||||
);
|
||||
};
|
||||
+192
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+51
@@ -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>
|
||||
);
|
||||
};
|
||||
+71
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+119
@@ -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>
|
||||
);
|
||||
};
|
||||
+101
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
+161
@@ -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 };
|
||||
};
|
||||
+9
@@ -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,
|
||||
});
|
||||
+10
@@ -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>
|
||||
</>
|
||||
|
||||
+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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+93
@@ -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};
|
||||
}
|
||||
`;
|
||||
+45
@@ -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`
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user