Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions pages/dropdown-list-placement.test.page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SimplePage
title="Dropdown list placement test page"
subtitle="Imitate async loading of dropdown list options that should cause dropdown position to change"
i18n={{}}
>
<div style={{ position: 'absolute', insetBlockEnd: 150, insetInlineStart: 16, insetInlineEnd: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 1 }}>
<Autosuggest
value={autosuggestValue}
options={showError ? [] : options}
onChange={event => setAutosuggestValue(event.detail.value)}
ariaLabel="autosuggest"
placeholder="autosuggest"
{...asyncProps}
/>
</div>

<div style={{ flex: 1 }}>
<Select
options={showError ? [] : options}
selectedOption={null}
filteringType="auto"
ariaLabel="select"
placeholder="select"
{...asyncProps}
/>
</div>

<div style={{ flex: 1 }}>
<Multiselect
options={showError ? [] : options}
selectedOptions={[]}
filteringType="auto"
ariaLabel="multiselect"
placeholder="multiselect"
{...asyncProps}
/>
</div>
</div>
</div>
</SimplePage>
);
}
5 changes: 5 additions & 0 deletions src/autosuggest/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AutosuggestInput
{...restProps}
Expand Down Expand Up @@ -256,6 +259,8 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
/>
) : null
}
// Forces dropdown position recalculation when new options are loaded
dropdownContentKey={hasItems.current.toString()}
loopFocus={dropdownStatus.hasRecoveryButton}
onCloseDropdown={handleCloseDropdown}
onDelayedInput={handleDelayedInput}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>) {
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);
})
);
7 changes: 6 additions & 1 deletion src/multiselect/internal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<div
{...baseProps}
Expand All @@ -172,6 +175,8 @@ const InternalMultiselect = React.forwardRef(
}
expandToViewport={expandToViewport}
stretchBeyondTriggerWidth={true}
// Forces dropdown position recalculation when new options are loaded
contentKey={hasOptions.current.toString()}
>
<ListComponent
listBottom={
Expand Down
5 changes: 5 additions & 0 deletions src/select/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ const InternalSelect = React.forwardRef(

const dropdownProps = getDropdownProps();

const hasOptions = useRef(options.length > 0);
hasOptions.current = hasOptions.current || options.length > 0;

return (
<div
{...baseProps}
Expand All @@ -260,6 +263,8 @@ const InternalSelect = React.forwardRef(
) : null
}
expandToViewport={expandToViewport}
// Forces dropdown position recalculation when new options are loaded
contentKey={hasOptions.current.toString()}
>
<ListComponent
listBottom={
Expand Down
Loading