From 1bd7be36e0d4356ba1cf4f478f384e4430a35377 Mon Sep 17 00:00:00 2001 From: Dilan Melvin T Date: Fri, 19 Jun 2026 17:08:39 +0530 Subject: [PATCH] fix(front): recompute ExpandableList visible chips on resize (#21139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Relation field cells in the record table render their chips through `ExpandableList`, which measured how many chips fit only once (during the children ref pass) and cached the cutoff. It recomputed on item-count and hover changes, but never when the cell's available width changed — so a cell measured while narrow stayed stuck on that count even after the column grew wider. This is the "only ~3 items shown even when the cell is larger" bug. This PR adds a `ResizeObserver` on the outer container that resets the first hidden child index whenever the available width changes, so the list reveals as many chips as fit (and re-trims when narrowed). The outer container is observed because its width tracks the available width independently of how many chips are currently rendered, which avoids a measure → trim → shrink → re-measure feedback loop. The observer is cleaned up on unmount. ## Test plan - [x] Added a Storybook interaction test (`RecomputesVisibleChipsOnResize`) that renders the list in a narrow container, widens it, and asserts more chips become visible. - [x] Verified the test fails without the fix and passes with it. - [x] `oxfmt` and `oxlint` pass on the changed files. - Manual: open a record table with a to-many relation field that has several linked records, widen the column, and confirm more chips appear. Fixes #12039 --------- Co-authored-by: Charles Bochet --- .../components/ExpandableList.tsx | 45 +++++++----- .../components/ExpandableListResizeEffect.tsx | 57 +++++++++++++++ .../__stories__/ExpandableList.stories.tsx | 69 ++++++++++++++++++- 3 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableListResizeEffect.tsx 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); + }); + }, +};