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:
+28
-17
@@ -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>
|
||||
|
||||
+57
@@ -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;
|
||||
};
|
||||
+68
-1
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user