Skip to content

Commit b6f4384

Browse files
committed
fix: Fixes dropdown list placement with async options
1 parent 8c37834 commit b6f4384

File tree

5 files changed

+185
-1
lines changed

5 files changed

+185
-1
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useState } from 'react';
5+
import { range } from 'lodash';
6+
7+
import { Autosuggest, Multiselect, Select } from '~components';
8+
9+
import { SimplePage } from './app/templates';
10+
11+
const options = range(0, 100).map(index => ({ value: (index + 1).toString() }));
12+
13+
export default function Page() {
14+
const [showError, setShowError] = useState(true);
15+
const [autosuggestValue, setAutosuggestValue] = useState('');
16+
17+
const statusType = showError ? ('error' as const) : ('finished' as const);
18+
const errorText = showError ? 'Error' : undefined;
19+
const onLoadItems = ({ detail }: { detail: { samePage: boolean } }) => {
20+
if (detail.samePage) {
21+
setShowError(false);
22+
}
23+
};
24+
const asyncProps = { statusType, errorText, onLoadItems };
25+
26+
return (
27+
<SimplePage
28+
title="Dropdown list placement test page"
29+
subtitle="Imitate async loading of dropdown list options that should cause dropdown position to change"
30+
i18n={{}}
31+
>
32+
<div style={{ position: 'absolute', insetBlockEnd: 150, insetInlineStart: 16, insetInlineEnd: 16 }}>
33+
<div style={{ display: 'flex', gap: 16 }}>
34+
<div style={{ flex: 1 }}>
35+
<Autosuggest
36+
value={autosuggestValue}
37+
options={showError ? [] : options}
38+
onChange={event => setAutosuggestValue(event.detail.value)}
39+
ariaLabel="autosuggest"
40+
placeholder="autosuggest"
41+
{...asyncProps}
42+
/>
43+
</div>
44+
45+
<div style={{ flex: 1 }}>
46+
<Select
47+
options={showError ? [] : options}
48+
selectedOption={null}
49+
filteringType="auto"
50+
ariaLabel="select"
51+
placeholder="select"
52+
{...asyncProps}
53+
/>
54+
</div>
55+
56+
<div style={{ flex: 1 }}>
57+
<Multiselect
58+
options={showError ? [] : options}
59+
selectedOptions={[]}
60+
filteringType="auto"
61+
ariaLabel="multiselect"
62+
placeholder="multiselect"
63+
{...asyncProps}
64+
/>
65+
</div>
66+
</div>
67+
</div>
68+
</SimplePage>
69+
);
70+
}

src/autosuggest/internal.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
198198
const shouldRenderDropdownContent =
199199
autosuggestItemsState.items.length !== 0 || !!dropdownStatus.content || (!hideEnteredTextOption && !!value);
200200

201+
const maxItemsLength = useRef(autosuggestItemsState.items.length);
202+
maxItemsLength.current = Math.max(maxItemsLength.current, autosuggestItemsState.items.length);
203+
201204
return (
202205
<AutosuggestInput
203206
{...restProps}
@@ -256,6 +259,8 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
256259
/>
257260
) : null
258261
}
262+
// Forces dropdown position recalculation when new options are loaded
263+
dropdownContentKey={maxItemsLength.current.toString()}
259264
loopFocus={dropdownStatus.hasRecoveryButton}
260265
onCloseDropdown={handleCloseDropdown}
261266
onDelayedInput={handleDelayedInput}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
5+
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
6+
7+
import createWrapper from '../../../../../lib/components/test-utils/selectors';
8+
9+
const autosuggest = createWrapper().findAutosuggest();
10+
const select = createWrapper().findSelect();
11+
const multiselect = createWrapper().findMultiselect();
12+
13+
function setupTest(testFn: (page: BasePageObject) => Promise<void>) {
14+
return useBrowser(async browser => {
15+
await browser.url('#/light/dropdown-list-placement.test');
16+
const page = new BasePageObject(browser);
17+
await page.waitForVisible(autosuggest.toSelector());
18+
await testFn(page);
19+
});
20+
}
21+
22+
async function isBelow(page: BasePageObject, inputSelector: string, dropdownSelector: string) {
23+
const inputBox = await page.getBoundingBox(inputSelector);
24+
const dropdownBox = await page.getBoundingBox(dropdownSelector);
25+
return dropdownBox.top >= inputBox.bottom;
26+
}
27+
28+
async function isAbove(page: BasePageObject, inputSelector: string, dropdownSelector: string) {
29+
return !(await isBelow(page, inputSelector, dropdownSelector));
30+
}
31+
32+
test(
33+
'changes autosuggest dropdown position',
34+
setupTest(async page => {
35+
const inputSelector = autosuggest.findNativeInput().toSelector();
36+
const dropdownSelector = autosuggest.findDropdown().findOpenDropdown().toSelector();
37+
const optionsSelector = autosuggest.findDropdown().findOptions().toSelector();
38+
const recoveryButtonSelector = autosuggest.findErrorRecoveryButton().toSelector();
39+
40+
// Open dropdown
41+
await page.click(inputSelector);
42+
await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
43+
await expect(isBelow(page, inputSelector, dropdownSelector)).resolves.toBe(true);
44+
// Click retry to load more options (should update dropdown position)
45+
await page.click(recoveryButtonSelector);
46+
await expect(page.getElementsCount(optionsSelector)).resolves.toBe(100);
47+
await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
48+
// Enter search text to reduce options (should not update dropdown position)
49+
await page.keys('x');
50+
await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
51+
await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
52+
})
53+
);
54+
55+
test(
56+
'changes select dropdown position',
57+
setupTest(async page => {
58+
const inputSelector = select.findTrigger().toSelector();
59+
const dropdownSelector = select.findDropdown().findOpenDropdown().toSelector();
60+
const optionsSelector = select.findDropdown().findOptions().toSelector();
61+
const recoveryButtonSelector = select.findErrorRecoveryButton().toSelector();
62+
63+
// Open dropdown
64+
await page.click(inputSelector);
65+
await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
66+
await expect(isBelow(page, inputSelector, dropdownSelector)).resolves.toBe(true);
67+
// Click retry to load more options (should update dropdown position)
68+
await page.click(recoveryButtonSelector);
69+
await expect(page.getElementsCount(optionsSelector)).resolves.toBe(100);
70+
await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
71+
// Enter search text to reduce options (should not update dropdown position)
72+
await page.keys('x');
73+
await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
74+
await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
75+
})
76+
);
77+
78+
test(
79+
'changes multiselect dropdown position',
80+
setupTest(async page => {
81+
const inputSelector = multiselect.findTrigger().toSelector();
82+
const dropdownSelector = multiselect.findDropdown().findOpenDropdown().toSelector();
83+
const optionsSelector = multiselect.findDropdown().findOptions().toSelector();
84+
const recoveryButtonSelector = multiselect.findErrorRecoveryButton().toSelector();
85+
86+
// Open dropdown
87+
await page.click(inputSelector);
88+
await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
89+
await expect(isBelow(page, inputSelector, dropdownSelector)).resolves.toBe(true);
90+
// Click retry to load more options (should update dropdown position)
91+
await page.click(recoveryButtonSelector);
92+
await expect(page.getElementsCount(optionsSelector)).resolves.toBe(100);
93+
await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
94+
// Enter search text to reduce options (should not update dropdown position)
95+
await page.keys('x');
96+
await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
97+
await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
98+
})
99+
);

src/multiselect/internal.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import React, { useState } from 'react';
3+
import React, { useRef, useState } from 'react';
44
import clsx from 'clsx';
55

66
import { useUniqueId } from '@cloudscape-design/component-toolkit/internal';
@@ -149,6 +149,9 @@ const InternalMultiselect = React.forwardRef(
149149
const dropdownProps = multiselectProps.getDropdownProps();
150150
const hasFilteredOptions = multiselectProps.filteredOptions.length > 0;
151151

152+
const maxOptionsLength = useRef(options.length);
153+
maxOptionsLength.current = Math.max(maxOptionsLength.current, options.length);
154+
152155
return (
153156
<div
154157
{...baseProps}
@@ -172,6 +175,8 @@ const InternalMultiselect = React.forwardRef(
172175
}
173176
expandToViewport={expandToViewport}
174177
stretchBeyondTriggerWidth={true}
178+
// Forces dropdown position recalculation when new options are loaded
179+
contentKey={maxOptionsLength.current.toString()}
175180
>
176181
<ListComponent
177182
listBottom={

src/select/internal.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ const InternalSelect = React.forwardRef(
235235

236236
const dropdownProps = getDropdownProps();
237237

238+
const maxOptionsLength = useRef(options.length);
239+
maxOptionsLength.current = Math.max(maxOptionsLength.current, options.length);
240+
238241
return (
239242
<div
240243
{...baseProps}
@@ -260,6 +263,8 @@ const InternalSelect = React.forwardRef(
260263
) : null
261264
}
262265
expandToViewport={expandToViewport}
266+
// Forces dropdown position recalculation when new options are loaded
267+
contentKey={maxOptionsLength.current.toString()}
263268
>
264269
<ListComponent
265270
listBottom={

0 commit comments

Comments
 (0)