Skip to content

Commit f7e42c2

Browse files
jaymantricursoragent
authored andcommitted
[origin] Fix long-list dropdown scrolling (#28492)
## Reason [DES-58](https://lightspark.atlassian.net/browse/DES-58) exposed that long PhoneInput country menus could be clipped because popup chrome and list scrolling were owned by the same element. This moves scroll ownership to the list for the ungrouped popup components that need bounded long-list behavior, while keeping the popup responsible for border, radius, shadow, and overflow clipping. ## Overview - Keep PhoneInput, Combobox, and Autocomplete popup chrome clipped while their listbox content owns max-height, padding, overscroll behavior, and vertical scrolling. - Add long-list stories and component tests for the affected components so the scroll boundary stays explicit. - Intentionally leave Select unchanged because grouped Select content still relies on popup-level scrolling. ## Storybook preview - Components/PhoneInput: LongCountryList: https://dev.dev.sparkinfra.net/app/origin-storybook-pr-28492/?path=/story/components-phoneinput--long-country-list - Components/Combobox: LongList: https://dev.dev.sparkinfra.net/app/origin-storybook-pr-28492/?path=/story/components-combobox--long-list - Components/Autocomplete: LongList: https://dev.dev.sparkinfra.net/app/origin-storybook-pr-28492/?path=/story/components-autocomplete--long-list ## Test Plan - yarn workspace @lightsparkdev/origin lint - yarn workspace @lightsparkdev/origin types - yarn workspace @lightsparkdev/origin test:ct src/components/Autocomplete/Autocomplete.test.tsx src/components/Combobox/Combobox.test.tsx src/components/PhoneInput/PhoneInput.test.tsx [DES-58]: https://lightspark.atlassian.net/browse/DES-58?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Cursor <cursoragent@cursor.com> GitOrigin-RevId: f64eb0f37b312e55c51114f9f5638085870d2d05
1 parent 74d4fc8 commit f7e42c2

12 files changed

Lines changed: 484 additions & 9 deletions

packages/origin/src/components/Autocomplete/Autocomplete.module.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
.popup {
5050
box-sizing: border-box;
5151
width: var(--anchor-width);
52-
max-height: min(23rem, var(--available-height));
5352
max-width: var(--available-width);
5453
overflow: hidden;
5554
background: var(--surface-primary);
@@ -95,6 +94,7 @@
9594
display: flex;
9695
gap: var(--spacing-2xs);
9796
align-items: center;
97+
flex-shrink: 0;
9898
height: 36px;
9999
padding: var(--spacing-xs);
100100
@include smooth-corners(var(--corner-radius-xs));

packages/origin/src/components/Autocomplete/Autocomplete.stories.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const fruits: Fruit[] = [
2121
{ value: "honeydew", label: "Honeydew" },
2222
];
2323

24+
const longFruits: Fruit[] = Array.from({ length: 40 }, (_, index) => ({
25+
value: `fruit-${index + 1}`,
26+
label: `Fruit ${index + 1}`,
27+
}));
28+
2429
const meta: Meta<typeof Autocomplete.Root> = {
2530
title: "Components/Autocomplete",
2631
component: Autocomplete.Root,
@@ -62,6 +67,33 @@ export const Basic: Story = {
6267
),
6368
};
6469

70+
export const LongList: Story = {
71+
render: () => (
72+
<div style={{ width: 300 }}>
73+
<Autocomplete.Root
74+
items={longFruits}
75+
itemToStringValue={(item: Fruit) => item.label}
76+
>
77+
<Autocomplete.Input placeholder="Search fruits..." />
78+
<Autocomplete.Portal>
79+
<Autocomplete.Positioner>
80+
<Autocomplete.Popup>
81+
<Autocomplete.Empty>No results found.</Autocomplete.Empty>
82+
<Autocomplete.List>
83+
{(item: Fruit) => (
84+
<Autocomplete.Item key={item.value} value={item}>
85+
{item.label}
86+
</Autocomplete.Item>
87+
)}
88+
</Autocomplete.List>
89+
</Autocomplete.Popup>
90+
</Autocomplete.Positioner>
91+
</Autocomplete.Portal>
92+
</Autocomplete.Root>
93+
</div>
94+
),
95+
};
96+
6597
export const WithLeadingIcons: Story = {
6698
render: () => (
6799
<div style={{ width: 300 }}>

packages/origin/src/components/Autocomplete/Autocomplete.test-stories.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const fruits: Fruit[] = [
1919
{ value: "elderberry", label: "Elderberry" },
2020
];
2121

22+
const longFruits: Fruit[] = Array.from({ length: 40 }, (_, index) => ({
23+
value: `fruit-${index + 1}`,
24+
label: `Fruit ${index + 1}`,
25+
}));
26+
2227
const groupedItems = [
2328
{
2429
label: "Fruits",
@@ -61,6 +66,34 @@ export function BasicAutocomplete() {
6166
);
6267
}
6368

69+
/**
70+
* Autocomplete with enough items to require list scrolling.
71+
*/
72+
export function LongListAutocomplete() {
73+
return (
74+
<Autocomplete.Root
75+
items={longFruits}
76+
itemToStringValue={(item: Fruit) => item.label}
77+
>
78+
<Autocomplete.Input placeholder="Search fruits..." />
79+
<Autocomplete.Portal>
80+
<Autocomplete.Positioner>
81+
<Autocomplete.Popup data-testid="autocomplete-long-list-popup">
82+
<Autocomplete.Empty>No results found.</Autocomplete.Empty>
83+
<Autocomplete.List data-testid="autocomplete-long-list">
84+
{(item: Fruit) => (
85+
<Autocomplete.Item key={item.value} value={item}>
86+
{item.label}
87+
</Autocomplete.Item>
88+
)}
89+
</Autocomplete.List>
90+
</Autocomplete.Popup>
91+
</Autocomplete.Positioner>
92+
</Autocomplete.Portal>
93+
</Autocomplete.Root>
94+
);
95+
}
96+
6497
/**
6598
* Autocomplete with leading icons
6699
*/

packages/origin/src/components/Autocomplete/Autocomplete.test.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { test, expect } from "@playwright/experimental-ct-react";
22
import {
33
BasicAutocomplete,
4+
LongListAutocomplete,
45
WithLeadingIcon,
56
WithDisabledItems,
67
DisabledAutocomplete,
@@ -32,6 +33,94 @@ test.describe("Autocomplete", () => {
3233
await expect(page.getByRole("listbox")).toBeVisible();
3334
});
3435

36+
test("keeps long-list scrolling on the list", async ({ mount, page }) => {
37+
const component = await mount(<LongListAutocomplete />);
38+
const input = component.getByPlaceholder("Search fruits...");
39+
40+
await input.focus();
41+
await input.press("ArrowDown");
42+
43+
const popup = page.getByTestId("autocomplete-long-list-popup");
44+
const listbox = page.getByTestId("autocomplete-long-list");
45+
await expect(listbox).toBeVisible();
46+
47+
const state = await listbox.evaluate((list) => {
48+
const popup = document.querySelector(
49+
'[data-testid="autocomplete-long-list-popup"]',
50+
);
51+
const firstItem = list.querySelector('[role="option"]');
52+
53+
if (!(popup instanceof HTMLElement)) {
54+
throw new Error("Autocomplete long-list popup is missing");
55+
}
56+
57+
if (!(firstItem instanceof HTMLElement)) {
58+
throw new Error("Autocomplete list is missing option rows");
59+
}
60+
61+
const listStyles = window.getComputedStyle(list);
62+
const popupStyles = window.getComputedStyle(popup);
63+
const itemStyles = window.getComputedStyle(firstItem);
64+
popup.scrollTop = popup.scrollHeight;
65+
list.scrollTop = list.scrollHeight;
66+
67+
return {
68+
itemFlexShrink: itemStyles.flexShrink,
69+
itemHeight: itemStyles.height,
70+
itemRenderedHeight: firstItem.getBoundingClientRect().height,
71+
popupMaxHeight: popupStyles.maxHeight,
72+
popupOverflowY: popupStyles.overflowY,
73+
popupHasScrollableOverflow: popup.scrollHeight > popup.clientHeight,
74+
popupCanScroll: popup.scrollTop > 0,
75+
listMaxHeight: listStyles.maxHeight,
76+
listOverflowY: listStyles.overflowY,
77+
listOverscrollBehaviorY: listStyles.overscrollBehaviorY,
78+
listScrollPaddingBlockEnd: listStyles.scrollPaddingBlockEnd,
79+
listScrollPaddingBlockStart: listStyles.scrollPaddingBlockStart,
80+
listHasScrollableOverflow: list.scrollHeight > list.clientHeight,
81+
listCanScroll: list.scrollTop > 0,
82+
};
83+
});
84+
85+
expect(state.itemFlexShrink).toBe("0");
86+
expect(state.itemHeight).toBe("36px");
87+
expect(state.itemRenderedHeight).toBeGreaterThanOrEqual(34);
88+
expect(state.popupMaxHeight).toBe("none");
89+
expect(state.popupOverflowY).toBe("hidden");
90+
expect(state.popupHasScrollableOverflow).toBe(false);
91+
expect(state.popupCanScroll).toBe(false);
92+
expect(state.listMaxHeight).not.toBe("none");
93+
expect(state.listOverflowY).toBe("auto");
94+
expect(state.listOverscrollBehaviorY).toBe("contain");
95+
expect(
96+
Number.parseFloat(state.listScrollPaddingBlockStart),
97+
).toBeGreaterThan(0);
98+
expect(
99+
Number.parseFloat(state.listScrollPaddingBlockEnd),
100+
).toBeGreaterThan(0);
101+
expect(state.listHasScrollableOverflow).toBe(true);
102+
expect(state.listCanScroll).toBe(true);
103+
104+
await expect(popup).toBeVisible();
105+
await expect(
106+
page.getByRole("option", { name: "Fruit 40" }),
107+
).toBeVisible();
108+
});
109+
110+
test("filters long-list object items by label", async ({ mount, page }) => {
111+
const component = await mount(<LongListAutocomplete />);
112+
const input = component.getByPlaceholder("Search fruits...");
113+
114+
await input.fill("40");
115+
116+
await expect(
117+
page.getByRole("option", { name: "Fruit 40" }),
118+
).toBeVisible();
119+
await expect(
120+
page.getByRole("option", { name: "Fruit 1" }),
121+
).not.toBeVisible();
122+
});
123+
35124
test("filters items as user types", async ({ mount, page }) => {
36125
const component = await mount(<BasicAutocomplete />);
37126
const input = component.getByPlaceholder("Search fruits...");

packages/origin/src/components/Combobox/Combobox.module.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,6 @@
170170
.popup {
171171
box-sizing: border-box;
172172
width: var(--anchor-width);
173-
max-height: min(23rem, var(--available-height));
174173
max-width: var(--available-width);
175174
overflow: hidden;
176175
background: var(--surface-primary);
@@ -216,6 +215,7 @@
216215
display: flex;
217216
gap: var(--spacing-2xs);
218217
align-items: center;
218+
flex-shrink: 0;
219219
height: 36px;
220220
padding: var(--spacing-xs);
221221
@include smooth-corners(var(--corner-radius-xs));

packages/origin/src/components/Combobox/Combobox.stories.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const fruits = [
2727
"Lemon",
2828
];
2929

30+
const longFruits = Array.from(
31+
{ length: 40 },
32+
(_, index) => `Fruit ${index + 1}`,
33+
);
34+
3035
export const Default: Story = {
3136
args: {
3237
disabled: false,
@@ -58,6 +63,34 @@ export const Default: Story = {
5863
),
5964
};
6065

66+
export const LongList: Story = {
67+
render: () => (
68+
<Combobox.Root items={longFruits}>
69+
<Combobox.InputWrapper>
70+
<Combobox.Input placeholder="Select a fruit..." />
71+
<Combobox.ActionButtons>
72+
<Combobox.Trigger aria-label="Open popup" />
73+
</Combobox.ActionButtons>
74+
</Combobox.InputWrapper>
75+
<Combobox.Portal>
76+
<Combobox.Positioner sideOffset={4}>
77+
<Combobox.Popup>
78+
<Combobox.Empty />
79+
<Combobox.List>
80+
{(item: string) => (
81+
<Combobox.Item key={item} value={item}>
82+
<Combobox.ItemIndicator />
83+
<Combobox.ItemText>{item}</Combobox.ItemText>
84+
</Combobox.Item>
85+
)}
86+
</Combobox.List>
87+
</Combobox.Popup>
88+
</Combobox.Positioner>
89+
</Combobox.Portal>
90+
</Combobox.Root>
91+
),
92+
};
93+
6194
export const WithClear: Story = {
6295
render: () => (
6396
<Combobox.Root items={fruits} defaultValue="Apple">

packages/origin/src/components/Combobox/Combobox.test-stories.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ const fruits = [
1212
"Grape",
1313
];
1414

15+
const longFruits = Array.from(
16+
{ length: 40 },
17+
(_, index) => `Fruit ${index + 1}`,
18+
);
19+
1520
/** InputWrapper conformance - forwards props, ref, className */
1621
export function ConformanceInputWrapper(
1722
props: React.HTMLAttributes<HTMLDivElement>,
@@ -99,6 +104,32 @@ export const TestCombobox = () => (
99104
</Combobox.Root>
100105
);
101106

107+
export const TestComboboxLongList = () => (
108+
<Combobox.Root items={longFruits}>
109+
<Combobox.InputWrapper>
110+
<Combobox.Input placeholder="Select a fruit..." />
111+
<Combobox.ActionButtons>
112+
<Combobox.Trigger aria-label="Open popup" />
113+
</Combobox.ActionButtons>
114+
</Combobox.InputWrapper>
115+
<Combobox.Portal>
116+
<Combobox.Positioner sideOffset={4}>
117+
<Combobox.Popup data-testid="combobox-long-list-popup">
118+
<Combobox.Empty />
119+
<Combobox.List data-testid="combobox-long-list">
120+
{(item: string) => (
121+
<Combobox.Item key={item} value={item}>
122+
<Combobox.ItemIndicator />
123+
<Combobox.ItemText>{item}</Combobox.ItemText>
124+
</Combobox.Item>
125+
)}
126+
</Combobox.List>
127+
</Combobox.Popup>
128+
</Combobox.Positioner>
129+
</Combobox.Portal>
130+
</Combobox.Root>
131+
);
132+
102133
export const TestComboboxMultiple = () => (
103134
<Combobox.Root items={fruits} multiple>
104135
<Combobox.InputWrapper>

0 commit comments

Comments
 (0)