diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableList.tsx b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableList.tsx index fa1586fc50..925078e712 100644 --- a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableList.tsx @@ -7,12 +7,13 @@ import { useState, } from 'react'; +import { ExpandableListResizeEffect } from '@/ui/layout/expandable-list/components/ExpandableListResizeEffect'; import { ExpandedListDropdown } from '@/ui/layout/expandable-list/components/ExpandedListDropdown'; import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement'; import { isDefined } from 'twenty-shared/utils'; import { ChipSize } from 'twenty-ui/data-display'; -import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces'; import { AnimatedContainer } from 'twenty-ui/layout'; +import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces'; import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledContainer = styled.div` @@ -94,6 +95,10 @@ export const ExpandableList = ({ const hiddenChildrenCount = children.length - firstHiddenChildIndex; const canDisplayChipCount = isChipCountDisplayed && hiddenChildrenCount > 0; + const visibleChildren = isChipCountDisplayed + ? children.slice(0, firstHiddenChildIndex) + : children; + const handleChipCountClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsListExpanded(true); @@ -103,9 +108,6 @@ export const ExpandableList = ({ setFirstHiddenChildIndex(children.length); }, [children.length]); - // Recompute first hidden child when: - // - isChipCountDisplayed changes - // - children length changes useEffect(() => { resetFirstHiddenChildIndex(); }, [isChipCountDisplayed, children.length, resetFirstHiddenChildIndex]); @@ -137,22 +139,31 @@ export const ExpandableList = ({ : () => setIsChipCountDisplayedInternal(false) } > + {isChipCountDisplayed && ( + + )} - {children.slice(0, firstHiddenChildIndex).map((child, index) => ( + {visibleChildren.map((child, index) => ( { - if ( - // First element is always displayed. - index > 0 && - isFirstOverflowingChildElement({ - containerElement: childrenContainerElement, - childElement, - }) - ) { - setFirstHiddenChildIndex(index); - } - }} + ref={ + isChipCountDisplayed + ? (childElement) => { + if ( + index > 0 && + isFirstOverflowingChildElement({ + containerElement: childrenContainerElement, + childElement, + }) + ) { + setFirstHiddenChildIndex(index); + } + } + : undefined + } > {child} diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableListResizeEffect.tsx b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableListResizeEffect.tsx new file mode 100644 index 0000000000..8d1cce8f36 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableListResizeEffect.tsx @@ -0,0 +1,57 @@ +import { type RefObject, useEffect } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +const RESIZE_THRESHOLD_PX = 1; + +const RESIZE_SETTLE_DELAY_MS = 100; + +type ExpandableListResizeEffectProps = { + containerRef: RefObject; + onContainerWidthChange: () => void; +}; + +export const ExpandableListResizeEffect = ({ + containerRef, + onContainerWidthChange, +}: ExpandableListResizeEffectProps) => { + useEffect(() => { + const containerElement = containerRef.current; + + if (!isDefined(containerElement)) { + return; + } + + let previousWidth = containerElement.clientWidth; + let settleTimeoutId: ReturnType | undefined; + + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + + if (!isDefined(entry)) { + return; + } + + const newWidth = entry.contentRect.width; + + if (Math.abs(newWidth - previousWidth) <= RESIZE_THRESHOLD_PX) { + return; + } + + previousWidth = newWidth; + clearTimeout(settleTimeoutId); + settleTimeoutId = setTimeout( + onContainerWidthChange, + RESIZE_SETTLE_DELAY_MS, + ); + }); + + resizeObserver.observe(containerElement); + + return () => { + clearTimeout(settleTimeoutId); + resizeObserver.disconnect(); + }; + }, [containerRef, onContainerWidthChange]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/__stories__/ExpandableList.stories.tsx b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/__stories__/ExpandableList.stories.tsx index 495656523b..007f82ccaa 100644 --- a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/__stories__/ExpandableList.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/__stories__/ExpandableList.stories.tsx @@ -1,7 +1,8 @@ import { type Meta, type StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; +import { isDefined } from 'twenty-shared/utils'; import { Tag } from 'twenty-ui/data-display'; import { ComponentDecorator } from 'twenty-ui/testing'; import { MAIN_COLOR_NAMES } from 'twenty-ui/theme'; @@ -56,3 +57,69 @@ export const WithExpandedList: Story = { expect(await bodyCanvas.findByText('Option 7')).toBeDefined(); }, }; + +const OPTIONS_COUNT = 7; +const COLLAPSED_WIDTH_PX = 96; + +const optionTags = Array.from({ length: OPTIONS_COUNT }, (_, index) => ( + +)); + +const countRenderedOptions = (canvas: ReturnType) => + canvas.queryAllByText(/^Option \d+$/).length; + +export const RecomputesVisibleChipsOnResize: Story = { + render: () => ( +
+ {optionTags} +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const resizableCell = canvasElement.querySelector( + '[data-resizable-cell]', + ); + + await waitFor(() => { + expect(countRenderedOptions(canvas)).toBeLessThan(OPTIONS_COUNT); + }); + + const collapsedCount = countRenderedOptions(canvas); + + if (isDefined(resizableCell)) { + resizableCell.style.width = '100%'; + } + + await waitFor(() => { + expect(countRenderedOptions(canvas)).toBeGreaterThan(collapsedCount); + }); + }, +}; + +export const ShowsAllChipsWhenCountHidden: Story = { + render: () => ( +
+ {optionTags} +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(() => { + expect(countRenderedOptions(canvas)).toBe(OPTIONS_COUNT); + }); + }, +};