fix(front): recompute ExpandableList visible chips on resize (#21139)

## 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 <charles@twenty.com>
This commit is contained in:
Dilan Melvin T
2026-06-19 17:08:39 +05:30
committed by GitHub
parent 26db3f5735
commit 1bd7be36e0
3 changed files with 153 additions and 18 deletions
@@ -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 && (
<ExpandableListResizeEffect
containerRef={containerRef}
onContainerWidthChange={resetFirstHiddenChildIndex}
/>
)}
<StyledChildrenContainer ref={setChildrenContainerElement}>
{children.slice(0, firstHiddenChildIndex).map((child, index) => (
{visibleChildren.map((child, index) => (
<StyledChildContainer
key={index}
ref={(childElement) => {
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}
</StyledChildContainer>
@@ -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<HTMLElement | null>;
onContainerWidthChange: () => void;
};
export const ExpandableListResizeEffect = ({
containerRef,
onContainerWidthChange,
}: ExpandableListResizeEffectProps) => {
useEffect(() => {
const containerElement = containerRef.current;
if (!isDefined(containerElement)) {
return;
}
let previousWidth = containerElement.clientWidth;
let settleTimeoutId: ReturnType<typeof setTimeout> | 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;
};
@@ -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) => (
<Tag
key={index}
text={`Option ${index + 1}`}
color={MAIN_COLOR_NAMES[index]}
/>
));
const countRenderedOptions = (canvas: ReturnType<typeof within>) =>
canvas.queryAllByText(/^Option \d+$/).length;
export const RecomputesVisibleChipsOnResize: Story = {
render: () => (
<div
data-resizable-cell
style={{
resize: 'horizontal',
overflow: 'hidden',
width: COLLAPSED_WIDTH_PX,
minWidth: 56,
maxWidth: '100%',
}}
>
<ExpandableList isChipCountDisplayed>{optionTags}</ExpandableList>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const resizableCell = canvasElement.querySelector<HTMLElement>(
'[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: () => (
<div style={{ width: '100%', overflow: 'hidden' }}>
<ExpandableList isChipCountDisplayed={false}>{optionTags}</ExpandableList>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(countRenderedOptions(canvas)).toBe(OPTIONS_COUNT);
});
},
};