Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Constrain dropdown width to viewport #1555

Merged
merged 23 commits into from
Sep 21, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion pages/dropdown/min-width.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default function DropdownScenario() {
const [open, setOpen] = useState(false);
return (
<article>
<h1>Dropdown`s midWidth property test</h1>
<h1>Dropdown`s minWidth property test</h1>
<ul>
<li>
Dropdown should have the width equal to <code>minWidth</code>, if the trigger is larger than it
Expand Down
187 changes: 187 additions & 0 deletions pages/dropdown/width.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useContext, useState } from 'react';
import Select, { SelectProps } from '~components/select';
import Multiselect, { MultiselectProps } from '~components/multiselect';
import Autosuggest from '~components/autosuggest';
import AppContext, { AppContextType } from '../app/app-context';
import SpaceBetween from '~components/space-between';
import ColumnLayout from '~components/column-layout';

type DemoContext = React.Context<
AppContextType<{
component: string;
triggerWidth: string;
virtualScroll: boolean;
expandToViewport: boolean;
containerWidth: string;
}>
>;

const shortOptionText = 'Short text';
const longOptionText =
'A very very very very very very very very very very very very very very very very very very very very very very very very very very long text';

const componentOptions: ReadonlyArray<SelectProps.Option> = [
{
value: 'autosuggest',
label: 'Autosuggest',
},
{
value: 'multiselect',
label: 'Multiselect',
},
{
value: 'select',
label: 'Select',
},
{
value: 'property-filter',
label: 'Property filter',
},
];

function SettingsForm() {
const { urlParams, setUrlParams } = useContext(AppContext as DemoContext);

return (
<ColumnLayout columns={4}>
<label>
Component{' '}
<select
value={componentOptions.find(option => option.value === urlParams.component)?.value || ''}
onChange={event => setUrlParams({ component: event.target.value })}
>
<option value="" disabled={true}>
Select a component
</option>
{componentOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Trigger width{' '}
<input
placeholder={'example: 300px'}
value={urlParams.triggerWidth || ''}
onChange={event => setUrlParams({ triggerWidth: event.target.value })}
/>
</label>
<label>
Container width{' '}
<input
placeholder={'example: 500px'}
value={urlParams.containerWidth || ''}
onChange={event => setUrlParams({ containerWidth: event.target.value })}
/>
</label>
<label>
<input
type="checkbox"
checked={urlParams.expandToViewport}
onChange={event => setUrlParams({ expandToViewport: event.target.checked })}
/>{' '}
expandToViewport
</label>
{['autosuggest', 'multiselect', 'property-filter', 'select'].includes(urlParams.component) && (
<label>
<input
type="checkbox"
checked={urlParams.virtualScroll}
onChange={event => setUrlParams({ virtualScroll: event.target.checked })}
/>{' '}
virtualScroll
</label>
)}
</ColumnLayout>
);
}

function CustomAutosuggest({ expandToViewport, virtualScroll }: { expandToViewport: boolean; virtualScroll: boolean }) {
const [value, setValue] = useState('');
return (
<Autosuggest
value={value}
onChange={e => setValue(e.detail.value)}
options={[
{ value: longOptionText, tags: ['tag1', 'tag2'], filteringTags: ['bla', 'opt'], description: 'description1' },
{ value: shortOptionText, labelTag: 'This is a label tag' },
]}
ariaLabel="simple autosuggest"
virtualScroll={virtualScroll}
expandToViewport={expandToViewport}
/>
);
}

function CustomMultiSelect({ expandToViewport, virtualScroll }: { expandToViewport: boolean; virtualScroll: boolean }) {
const [selectedOptions, setSelectedOptions] = useState<ReadonlyArray<MultiselectProps.Option>>([]);
return (
<Multiselect
expandToViewport={expandToViewport}
virtualScroll={virtualScroll}
options={[
{ value: 'first', label: longOptionText },
{
value: 'second',
label: shortOptionText,
},
]}
selectedOptions={selectedOptions}
onChange={({ detail }) => setSelectedOptions(detail.selectedOptions)}
/>
);
}

function CustomSelect({ expandToViewport, virtualScroll }: { expandToViewport: boolean; virtualScroll: boolean }) {
const [selectedOption, setSelectedOption] = useState<SelectProps['selectedOption']>(null);
return (
<Select
selectedOption={selectedOption}
options={[
{
value: longOptionText,
tags: ['tag1', 'tag2'],
filteringTags: ['bla', 'opt'],
description: 'description1',
},
{ value: shortOptionText },
]}
onChange={event => setSelectedOption(event.detail.selectedOption)}
ariaLabel={'simple select'}
filteringType={'auto'}
virtualScroll={virtualScroll}
expandToViewport={expandToViewport}
/>
);
}

export default function () {
const { urlParams } = useContext(AppContext as DemoContext);

const { component, triggerWidth, virtualScroll, expandToViewport, containerWidth } = urlParams;
return (
<article>
<h1>Dropdown width</h1>
<SpaceBetween size="l">
<SettingsForm />
<div style={{ width: containerWidth, height: '300px', overflow: 'hidden', border: `1px solid blue` }}>
<div style={{ width: triggerWidth }}>
{component === 'autosuggest' && (
<CustomAutosuggest virtualScroll={virtualScroll} expandToViewport={expandToViewport} />
)}
{component === 'multiselect' && (
<CustomMultiSelect expandToViewport={expandToViewport} virtualScroll={virtualScroll} />
)}
{component === 'select' && (
<CustomSelect expandToViewport={expandToViewport} virtualScroll={virtualScroll} />
)}
</div>
</div>
</SpaceBetween>
</article>
);
}
107 changes: 107 additions & 0 deletions src/internal/components/dropdown/__integ__/width.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import createWrapper from '../../../../../lib/components/test-utils/selectors';

type ComponentId = 'autosuggest' | 'multiselect' | 'select';

function getWrapperAndTrigger(componentId: ComponentId) {
const wrapper = createWrapper();
let componentWrapper;
switch (componentId) {
case 'autosuggest':
componentWrapper = wrapper.findAutosuggest();
return {
wrapper: componentWrapper,
trigger: componentWrapper.findNativeInput(),
};
case 'multiselect':
componentWrapper = wrapper.findMultiselect();
return {
wrapper: componentWrapper,
trigger: componentWrapper.findTrigger(),
};
case 'select':
componentWrapper = wrapper.findSelect();
return {
wrapper: componentWrapper,
trigger: componentWrapper.findTrigger(),
};
}
}

function setupTest(
{
pageWidth,
componentId,
triggerWidth,
expandToViewport,
virtualScroll,
}: {
pageWidth: number;
componentId: ComponentId;
triggerWidth: number;
expandToViewport: boolean;
virtualScroll: boolean;
},
testFn: (page: BasePageObject) => Promise<void>
) {
return useBrowser({ width: pageWidth, height: 1000 }, async browser => {
await browser.url(
`#/light/dropdown/width?component=${componentId}&expandToViewport=${expandToViewport}&width=${triggerWidth}px&virtualScroll=${virtualScroll}`
);
const page = new BasePageObject(browser);
await page.waitForVisible(getWrapperAndTrigger(componentId).wrapper.toSelector());
await testFn(page);
});
}

async function openDropdown({
componentId,
page,
expandToViewport,
}: {
componentId: ComponentId;
page: BasePageObject;
expandToViewport: boolean;
}) {
const { wrapper, trigger } = getWrapperAndTrigger(componentId);
await page.click(trigger.toSelector());
const openDropdownSelector = wrapper.findDropdown({ expandToViewport }).findOpenDropdown().toSelector();
await expect(openDropdownSelector).toBeTruthy();
return page.getBoundingBox(openDropdownSelector);
}

describe('Dropdown width', () => {
const triggerWidth = 200;
describe('stretches beyond the trigger width if there is enough space', () => {
const pageWidth = 500;
describe.each([false, true])('expandToViewport: %s', expandToViewport => {
test.each(['autosuggest', 'multiselect', 'select'])('with %s', componentId =>
setupTest(
{ componentId: componentId as ComponentId, triggerWidth, pageWidth, expandToViewport, virtualScroll: false },
async page => {
const dropdownBox = await openDropdown({ componentId: componentId as ComponentId, page, expandToViewport });
expect(dropdownBox.width).toBeGreaterThan(triggerWidth);
expect(dropdownBox.left + dropdownBox.width).toBeLessThanOrEqual(pageWidth);
}
)()
);
});
});
describe('does not overflow the viewport', () => {
const pageWidth = 350;
describe.each([false, true])('expandToViewport: %s', expandToViewport => {
test.each(['autosuggest', 'multiselect', 'select'])('with %s', componentId =>
setupTest(
{ componentId: componentId as ComponentId, triggerWidth, pageWidth, expandToViewport, virtualScroll: false },
async page => {
const dropdownBox = await openDropdown({ componentId: componentId as ComponentId, page, expandToViewport });
expect(dropdownBox.left + dropdownBox.width).toBeLessThanOrEqual(pageWidth);
}
)()
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
height: '605px',
left: 'auto',
width: '100px',
overflows: false,
};

function getSizedElement(width: number, height: number, top = 0, left = 0) {
Expand Down Expand Up @@ -53,6 +54,7 @@
getDropdownPosition({ triggerElement: trigger, dropdownElement: dropdown, overflowParents: [windowSize] })
).toEqual({
...defaults,
overflows: true,
dropLeft: true,
width: '550px',
});
Expand Down Expand Up @@ -125,7 +127,7 @@
getDropdownPosition({ triggerElement: trigger, dropdownElement: dropdown, overflowParents: [windowSize] })
).toEqual({
...defaults,
dropLeft: true, // TODO: this value is incorrect, should be fixed, see AWSUI-16369 for details

Check warning on line 130 in src/internal/components/dropdown/__tests__/dropdown-fit-handler.test.ts

View workflow job for this annotation

GitHub Actions / build / build

Unexpected 'todo' comment: 'TODO: this value is incorrect, should be...'
});
});

Expand Down
7 changes: 7 additions & 0 deletions src/internal/components/dropdown/dropdown-fit-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface DropdownPosition {
dropUp: boolean;
dropLeft: boolean;
left: string;
overflows: boolean;
}
export interface InteriorDropdownPosition extends DropdownPosition {
bottom: string;
Expand Down Expand Up @@ -165,6 +166,7 @@ export const getDropdownPosition = ({
let dropLeft: boolean;
let left: number | null = null;
let width = idealWidth;
let overflows = false;

//1. Can it be positioned with ideal width to the right?
if (idealWidth <= availableSpace.right) {
Expand All @@ -176,6 +178,7 @@ export const getDropdownPosition = ({
} else {
dropLeft = availableSpace.left > availableSpace.right;
width = Math.max(availableSpace.left, availableSpace.right, minWidth);
overflows = true;
}

if (preferCenter) {
Expand All @@ -202,6 +205,7 @@ export const getDropdownPosition = ({
left: left === null ? 'auto' : `${left}px`,
height: `${croppedHeight}px`,
width: `${width}px`,
overflows,
};
};

Expand All @@ -216,6 +220,7 @@ export const getInteriorDropdownPosition = (
const { top: parentDropdownTop, height: parentDropdownHeight } = getClosestParentDimensions(trigger);

let dropLeft;
let overflows = false;

let width = dropdown.getBoundingClientRect().width;
const top = triggerTop - parentDropdownTop;
Expand All @@ -226,6 +231,7 @@ export const getInteriorDropdownPosition = (
} else {
dropLeft = availableSpace.left > availableSpace.right;
width = Math.max(availableSpace.left, availableSpace.right);
overflows = true;
}

const left = dropLeft ? 0 - width : triggerWidth;
Expand All @@ -244,6 +250,7 @@ export const getInteriorDropdownPosition = (
top: `${top}px`,
bottom: `${bottom}px`,
left: `${left}px`,
overflows,
};
};

Expand Down
8 changes: 8 additions & 0 deletions src/internal/components/dropdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ const Dropdown = ({
} else {
target.style.width = position.width;
}

// Prevent the dropdown width from stretching beyond the trigger width
// if that is going to cause the dropdown to be cropped because of overflow
if (position.overflows) {
target.classList.remove(styles['stretch-beyond-trigger-width']);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is worrying is that the class is added and removed in different places. There is a chance that with another render of the dropdown the class is going to be re-added but not removed:

function DropdownConsumer () {
  const [counter, setCounter] = useState(0);
  useEffect(() => setInterval(() => setCounter(prev => prev + 1), 1000), []);
  return <Dropdown ... ><LargeContent /></Dropdown>
}

Also, what if the content changes? E.g. initially the dropdown renders a loading state and then the content appears. Does overflow calculation re-apply?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, there is an issue here because this useLayoutEffect hook has some dependencies so that it is called essentially when the dropdown changes from closed to open, but not if its content is changes while it's open. I think we will have to call onDropdownOpen (or at least part of it) essentially on every render. Here are two things to consider:

  • Performance: the function call takes 2ms in average (on my machine), so I think this should not be a concern
  • Behavior: should the dropdown placement change if its content changes? Since this hook only runs when opening, the current behavior is not to reposition the dropdown in any case while opened. I think this current behavior is good when select options are added or removed (which can happen with Autoselect) to prevent moving content that is already present in the page, but when changing the status from loading to content loaded, I think it would actually make sense to recalculate the position.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the dropdown placement should stay the same but the concern is it might not to. The class that fixes the issue is only removed in the effect so the next render can bring it back and as result break the positioning. This can probably be fixed by applying the class from within the effect as well, wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code that (potentially) removes the class (and the maxWidth) is in a useLayoutEffect hook, so it will run after the component renders but before the browser paints the screen. I just moved this code to its own useLayoutEffect hook, because as said, we will have to check this on every render.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an advantage in using the layout effect with an empty dependencies array over computing the props in the render cycle, like:

const canOverflow = ...;
const maxWidth = canOverflow ? defaultMaxWidth : undefined;
const overflowClasses = canOverflow ? [styles['stretch-beyond-trigger-width']] : [];

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, we need to know what the rendered dimensions are going to be, so we need to render the component first...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking on a slightly different solution then to not introduce the potential performance degradation. What if when the overflow is banned it is reflected in a new state so that the next renders will not override it?

useLayoutEffect(() => {
  if (!canOverflow) {
    setBanOveflow(true);
  }
}, [...]);

return <div style={{ maxWidth: !banOverflow ? defaultMaxWidth : undefined }} className={clsx(..., !banOverflow && styles['stretch-beyond-trigger-width'])} />

Alternatively, the state can be replaced with e.g. a new class name (like styles['ban-overflow']) to remove the effects in CSS. This class name can be added in the layout effect but then we need to ensure it persists over the next renders which can be achieved by also storing this info in a ref.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't the browser still do one paint with the layout that we don't want? Since this would "fix" the next render, but not the current one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this one. When the state is changed from the layout effect it probably won't render the previous one. If yes, the alternative approach can be used or the styles and classes can be also removed imperatively.

Copy link
Member

@pan-kot pan-kot Sep 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you can also try is:

useLayoutEffect(() => {
  // ...
  banOverflowRef.current = !canOverflow;
  if (!canOverflow) {
    dropdownRef.classList.add(styles['ban-overflow']);
  } else {
    dropdownRef.classList.remove(styles['ban-overflow']);
  }
  // ...
}, [...]);

return <div ... className={clsx(..., banOverflowRef.current && styles['ban-overflow'])} />;

The above code ensures the styles['ban-overflow'] is always present when we want to ban the overflow. In CSS you can use that class selector to negate the max-width and 'stretch-beyond-trigger-width' effects.

target.style.removeProperty('maxWidth');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why maxWidth is removed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because when stretchBeyondTriggerWidth is true, then both the stretch-beyong-trigger-width class and the maxWidth are applied (see line 106 here) and line 116 here respectively). Here we need to revert both changes.

Why is maxWidth applied separately from the class in the first place? I assume basically because its value comes from JavaScript, here. I guess this could be refactored, e.g, by setting a CSS custom property that could be used in a ruleset under the class selector. This way adding and removing the class would be enough, and this part in particular might be a bit more readable but at the same time this would add a bit more complexity overall, for just removing one line, so not sure it's worth it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think eventually we need to refactor that entire component but that's not in scope. What I would recommend is to add a code comment to explain why the max-width is removed.

}

// Using styles for main dropdown to adjust its position as preferred alternative
if (position.dropUp && !interior) {
target.classList.add(styles['dropdown-drop-up']);
Expand Down
Loading