diff --git a/pages/dropdown-list-placement.test.page.tsx b/pages/dropdown-list-placement.test.page.tsx
new file mode 100644
index 0000000000..eedccfe050
--- /dev/null
+++ b/pages/dropdown-list-placement.test.page.tsx
@@ -0,0 +1,70 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import React, { useState } from 'react';
+import { range } from 'lodash';
+
+import { Autosuggest, Multiselect, Select } from '~components';
+
+import { SimplePage } from './app/templates';
+
+const options = range(0, 100).map(index => ({ value: (index + 1).toString() }));
+
+export default function Page() {
+ const [showError, setShowError] = useState(true);
+ const [autosuggestValue, setAutosuggestValue] = useState('');
+
+ const statusType = showError ? ('error' as const) : ('finished' as const);
+ const errorText = showError ? 'Error' : undefined;
+ const onLoadItems = ({ detail }: { detail: { samePage: boolean } }) => {
+ if (detail.samePage) {
+ setShowError(false);
+ }
+ };
+ const asyncProps = { statusType, errorText, onLoadItems };
+
+ return (
+
+
+
+
+
setAutosuggestValue(event.detail.value)}
+ ariaLabel="autosuggest"
+ placeholder="autosuggest"
+ {...asyncProps}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/autosuggest/internal.tsx b/src/autosuggest/internal.tsx
index 60bbf09475..5c32f15be8 100644
--- a/src/autosuggest/internal.tsx
+++ b/src/autosuggest/internal.tsx
@@ -198,6 +198,9 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
const shouldRenderDropdownContent =
autosuggestItemsState.items.length !== 0 || !!dropdownStatus.content || (!hideEnteredTextOption && !!value);
+ const hasItems = useRef(autosuggestItemsState.items.length > 0);
+ hasItems.current = hasItems.current || autosuggestItemsState.items.length > 0;
+
return (
) : null
}
+ // Forces dropdown position recalculation when new options are loaded
+ dropdownContentKey={hasItems.current.toString()}
loopFocus={dropdownStatus.hasRecoveryButton}
onCloseDropdown={handleCloseDropdown}
onDelayedInput={handleDelayedInput}
diff --git a/src/internal/components/dropdown/__integ__/dropdown-list-placement.test.ts b/src/internal/components/dropdown/__integ__/dropdown-list-placement.test.ts
new file mode 100644
index 0000000000..828dbbf825
--- /dev/null
+++ b/src/internal/components/dropdown/__integ__/dropdown-list-placement.test.ts
@@ -0,0 +1,99 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
+import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
+
+import createWrapper from '../../../../../lib/components/test-utils/selectors';
+
+const autosuggest = createWrapper().findAutosuggest();
+const select = createWrapper().findSelect();
+const multiselect = createWrapper().findMultiselect();
+
+function setupTest(testFn: (page: BasePageObject) => Promise) {
+ return useBrowser(async browser => {
+ await browser.url('#/light/dropdown-list-placement.test');
+ const page = new BasePageObject(browser);
+ await page.waitForVisible(autosuggest.toSelector());
+ await testFn(page);
+ });
+}
+
+async function isBelow(page: BasePageObject, inputSelector: string, dropdownSelector: string) {
+ const inputBox = await page.getBoundingBox(inputSelector);
+ const dropdownBox = await page.getBoundingBox(dropdownSelector);
+ return dropdownBox.top >= inputBox.bottom;
+}
+
+async function isAbove(page: BasePageObject, inputSelector: string, dropdownSelector: string) {
+ return !(await isBelow(page, inputSelector, dropdownSelector));
+}
+
+test(
+ 'changes autosuggest dropdown position',
+ setupTest(async page => {
+ const inputSelector = autosuggest.findNativeInput().toSelector();
+ const dropdownSelector = autosuggest.findDropdown().findOpenDropdown().toSelector();
+ const optionsSelector = autosuggest.findDropdown().findOptions().toSelector();
+ const recoveryButtonSelector = autosuggest.findErrorRecoveryButton().toSelector();
+
+ // Open dropdown
+ await page.click(inputSelector);
+ await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
+ await expect(isBelow(page, inputSelector, dropdownSelector)).resolves.toBe(true);
+ // Click retry to load more options (should update dropdown position)
+ await page.click(recoveryButtonSelector);
+ await expect(page.getElementsCount(optionsSelector)).resolves.toBe(100);
+ await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
+ // Enter search text to reduce options (should not update dropdown position)
+ await page.keys('x');
+ await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
+ await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
+ })
+);
+
+test(
+ 'changes select dropdown position',
+ setupTest(async page => {
+ const inputSelector = select.findTrigger().toSelector();
+ const dropdownSelector = select.findDropdown().findOpenDropdown().toSelector();
+ const optionsSelector = select.findDropdown().findOptions().toSelector();
+ const recoveryButtonSelector = select.findErrorRecoveryButton().toSelector();
+
+ // Open dropdown
+ await page.click(inputSelector);
+ await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
+ await expect(isBelow(page, inputSelector, dropdownSelector)).resolves.toBe(true);
+ // Click retry to load more options (should update dropdown position)
+ await page.click(recoveryButtonSelector);
+ await expect(page.getElementsCount(optionsSelector)).resolves.toBe(100);
+ await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
+ // Enter search text to reduce options (should not update dropdown position)
+ await page.keys('x');
+ await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
+ await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
+ })
+);
+
+test(
+ 'changes multiselect dropdown position',
+ setupTest(async page => {
+ const inputSelector = multiselect.findTrigger().toSelector();
+ const dropdownSelector = multiselect.findDropdown().findOpenDropdown().toSelector();
+ const optionsSelector = multiselect.findDropdown().findOptions().toSelector();
+ const recoveryButtonSelector = multiselect.findErrorRecoveryButton().toSelector();
+
+ // Open dropdown
+ await page.click(inputSelector);
+ await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
+ await expect(isBelow(page, inputSelector, dropdownSelector)).resolves.toBe(true);
+ // Click retry to load more options (should update dropdown position)
+ await page.click(recoveryButtonSelector);
+ await expect(page.getElementsCount(optionsSelector)).resolves.toBe(100);
+ await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
+ // Enter search text to reduce options (should not update dropdown position)
+ await page.keys('x');
+ await expect(page.getElementsCount(optionsSelector)).resolves.toBe(0);
+ await expect(isAbove(page, inputSelector, dropdownSelector)).resolves.toBe(true);
+ })
+);
diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx
index 20554912d5..1c33ed9253 100644
--- a/src/multiselect/internal.tsx
+++ b/src/multiselect/internal.tsx
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import React, { useState } from 'react';
+import React, { useRef, useState } from 'react';
import clsx from 'clsx';
import { useUniqueId } from '@cloudscape-design/component-toolkit/internal';
@@ -149,6 +149,9 @@ const InternalMultiselect = React.forwardRef(
const dropdownProps = multiselectProps.getDropdownProps();
const hasFilteredOptions = multiselectProps.filteredOptions.length > 0;
+ const hasOptions = useRef(options.length > 0);
+ hasOptions.current = hasOptions.current || options.length > 0;
+
return (
0);
+ hasOptions.current = hasOptions.current || options.length > 0;
+
return (