diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx index 426dc5d8228..8ef38145ccb 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx @@ -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 ( {isDefined(pinnedLeftTab) && ( @@ -78,16 +88,14 @@ export const PageLayoutRendererContent = () => { - - 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(() => { + setIsTabDragging(true); + toggleClickOutside(false); + }, [setIsTabDragging, toggleClickOutside]); + + const handleDragEnd = useCallback( + (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 ( + + tab.id)} + /> + + {visibleTabs.length > 1 && ( + + + + ) : undefined + } + /> + )} + + + {isReorderEnabled && onReorder ? ( + + + + + {shouldRenderReorderableDropdown && ( + + )} + + {onAddTab && ( + + onAddTab()} + /> + + )} + + + ) : ( + + + {shouldRenderStaticDropdown && ( + + )} + {onAddTab && ( + + onAddTab()} + /> + + )} + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableIds.ts b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableIds.ts new file mode 100644 index 00000000000..fc3dac41b1f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableIds.ts @@ -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; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx new file mode 100644 index 00000000000..c3dda048116 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx @@ -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 ( + + {(provided, snapshot) => ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx new file mode 100644 index 00000000000..c21b913d37f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx @@ -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 ( + + } + dropdownComponents={ + + { + const overflowIndex = rubric.source.index - visibleTabCount; + const tab = hiddenTabs[overflowIndex]; + + return ( + + ); + }} + > + {(provided) => ( + +
+ {hiddenTabs.map((tab, index) => { + const globalIndex = visibleTabCount + index; + const disabled = tab.disabled ?? loading; + + return ( + + {(draggableProvided, draggableSnapshot) => ( + +
+ } + selected={tab.id === activeTabId} + onClick={ + draggableSnapshot.isDragging + ? undefined + : () => handleTabSelect(tab.id) + } + disabled={disabled} + /> +
+
+ )} +
+ ); + })} +
{provided.placeholder}
+
+
+ )} +
+
+ } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx new file mode 100644 index 00000000000..2aa59006814 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx @@ -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 ( + + {(draggableProvided, draggableSnapshot) => ( + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListStaticOverflowDropdown.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListStaticOverflowDropdown.tsx new file mode 100644 index 00000000000..2342801c6df --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListStaticOverflowDropdown.tsx @@ -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 ( + + } + dropdownComponents={ + + + {hiddenTabs.map((tab) => { + const disabled = tab.disabled ?? loading; + + return ( + } + selected={tab.id === activeTabId} + onClick={ + disabled + ? undefined + : () => { + onSelect(tab.id); + onClose(); + } + } + disabled={disabled} + /> + ); + })} + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx new file mode 100644 index 00000000000..06befa5ecbd --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx @@ -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 ( + { + const tab = visibleTabs[rubric.source.index]; + + return ( + + ); + }} + > + {(provided) => ( + + {visibleTabs.slice(0, visibleTabCount).map((tab, index) => ( + onSelectTab(tab.id)} + /> + ))} + {provided.placeholder} + + )} + + ); + } + + return ( + + {visibleTabs.slice(0, visibleTabCount).map((tab) => ( + onChangeTab?.(tab.id) + : () => onSelectTab(tab.id) + } + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx new file mode 100644 index 00000000000..bbe28031344 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx @@ -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 ( + + + + + + ); + } else { + return ( + +
+ } + selected={tab.id === activeTabId} + onClick={undefined} + disabled + /> +
+
+ ); + } +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx new file mode 100644 index 00000000000..c5902b040e3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx @@ -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(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 ( + + + + ); +}; + +const meta: Meta = { + title: 'Modules/PageLayout/PageLayoutTabList', + component: PageLayoutTabListPlayground, + args: { + isReorderEnabled: true, + }, + decorators: [ + ComponentWithRouterDecorator, + (Story) => ( + + + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isReorderEnabled: true, + }, +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts b/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts new file mode 100644 index 00000000000..09c308ca320 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/states/isPageLayoutTabDraggingComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/isPageLayoutTabDraggingComponentState.ts new file mode 100644 index 00000000000..d598f6a35cd --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/isPageLayoutTabDraggingComponentState.ts @@ -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({ + key: 'isPageLayoutTabDraggingComponentState', + defaultValue: false, + componentInstanceContext: TabListComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts new file mode 100644 index 00000000000..d2d2d6af132 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts @@ -0,0 +1,10 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext'; + +export const pageLayoutTabListCurrentDragDroppableIdComponentState = + createComponentState({ + key: 'pageLayoutTabListCurrentDragDroppableIdComponentState', + defaultValue: undefined, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabList.tsx b/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabList.tsx index 4a2e23a43ec..ca2d4aee128 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabList.tsx @@ -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({}); - 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 && ( - - {visibleTabs.map((tab) => ( - - - - ))} - - - - - - {onAddTab && ( - - - - - - )} - + )} - + {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 && ( - - onAddTab()} - /> - - )} diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabListHiddenMeasurements.tsx b/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabListHiddenMeasurements.tsx new file mode 100644 index 00000000000..4023b5d136a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabListHiddenMeasurements.tsx @@ -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 ( + + {visibleTabs.map((tab) => ( + + + + ))} + + + + + + {onAddButtonWidthChange && ( + + {addButtonMeasurement ?? ( + + )} + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabMoreButton.tsx b/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabMoreButton.tsx index 65a3bf6c944..fbeef1587a6 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabMoreButton.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabMoreButton.tsx @@ -10,9 +10,11 @@ const StyledTabMoreButton = styled(TabButton)` export const TabMoreButton = ({ hiddenTabsCount, active, + className, }: { hiddenTabsCount: number; active: boolean; + className?: string; }) => { return ( ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/components/__stories__/Tablist.stories.tsx b/packages/twenty-front/src/modules/ui/layout/tab-list/components/__stories__/Tablist.stories.tsx index bd6c3766ceb..7bc98465905 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/components/__stories__/Tablist.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/components/__stories__/Tablist.stories.tsx @@ -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 = { ), }; - -type TabListWithAddProps = Pick< - TabListProps, - 'componentInstanceId' | 'loading' | 'isInRightDrawer' | 'className' -> & { - initialTabs: SingleTabProps[]; -}; - -const TabListWithAdd = ({ - componentInstanceId, - loading, - isInRightDrawer, - className, - initialTabs, -}: TabListWithAddProps) => { - const [currentTabs, setCurrentTabs] = useState(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 ( - -

- Click the + button to add new tabs! -

- -
- ); -}; - -export const WithAddTab: Story = { - args: { - componentInstanceId: 'tabs-with-add', - tabs: tabs.slice(0, 3), - }, - render: (args) => ( - - ), - parameters: { - docs: { - description: { - story: - 'TabList with the ability to add new tabs dynamically using the onAddTab callback. Click the + button to add new tabs.', - }, - }, - }, -}; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/hooks/useTabListMeasurements.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/hooks/useTabListMeasurements.ts new file mode 100644 index 00000000000..d76136830cd --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/hooks/useTabListMeasurements.ts @@ -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({}); + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListDimension.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListDimension.ts new file mode 100644 index 00000000000..320f5c483a3 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListDimension.ts @@ -0,0 +1,4 @@ +export type TabListDimensions = { + width: number; + height: number; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListProps.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListProps.ts index 6aed210f117..50672054ae8 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListProps.ts +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListProps.ts @@ -8,5 +8,4 @@ export type TabListProps = { isInRightDrawer?: boolean; componentInstanceId: string; onChangeTab?: (tabId: string) => void; - onAddTab?: () => void; }; diff --git a/packages/twenty-ui/src/input/button/components/TabButton.tsx b/packages/twenty-ui/src/input/button/components/TabButton.tsx deleted file mode 100644 index b2d81a08fbc..00000000000 --- a/packages/twenty-ui/src/input/button/components/TabButton.tsx +++ /dev/null @@ -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 ( - - - {LeftIcon && } - {logo && } - {title} - {RightIcon && } - {pill && (typeof pill === 'string' ? : pill)} - - - ); -}; diff --git a/packages/twenty-ui/src/input/button/components/TabButton/TabButton.tsx b/packages/twenty-ui/src/input/button/components/TabButton/TabButton.tsx new file mode 100644 index 00000000000..9c2b3d4a5f6 --- /dev/null +++ b/packages/twenty-ui/src/input/button/components/TabButton/TabButton.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/twenty-ui/src/input/button/components/TabButton/internals/components/StyledTabBase.tsx b/packages/twenty-ui/src/input/button/components/TabButton/internals/components/StyledTabBase.tsx new file mode 100644 index 00000000000..27911d932c2 --- /dev/null +++ b/packages/twenty-ui/src/input/button/components/TabButton/internals/components/StyledTabBase.tsx @@ -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}; + } +`; diff --git a/packages/twenty-ui/src/input/button/components/TabButton/internals/components/TabContent.tsx b/packages/twenty-ui/src/input/button/components/TabButton/internals/components/TabContent.tsx new file mode 100644 index 00000000000..febb8900924 --- /dev/null +++ b/packages/twenty-ui/src/input/button/components/TabButton/internals/components/TabContent.tsx @@ -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 ( + + {LeftIcon && } + {logo && } + {title} + {RightIcon && } + {pill && (typeof pill === 'string' ? : pill)} + + ); +}; diff --git a/packages/twenty-ui/src/input/button/components/__stories__/TabButton.stories.tsx b/packages/twenty-ui/src/input/button/components/__stories__/TabButton.stories.tsx index cc726200639..a2ab2dee817 100644 --- a/packages/twenty-ui/src/input/button/components/__stories__/TabButton.stories.tsx +++ b/packages/twenty-ui/src/input/button/components/__stories__/TabButton.stories.tsx @@ -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` diff --git a/packages/twenty-ui/src/input/index.ts b/packages/twenty-ui/src/input/index.ts index 64d916e26bc..92f39b1e583 100644 --- a/packages/twenty-ui/src/input/index.ts +++ b/packages/twenty-ui/src/input/index.ts @@ -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';