-
Notifications
You must be signed in to change notification settings - Fork 155
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
Changes from 13 commits
9e15bd7
a06ec12
bab72fe
f63900c
a7faf94
4f6cc5d
a252143
885033b
2b37e3a
67dc6e0
2b5438d
8645d6a
5684856
260d59d
e72eb09
526ce72
9b0c667
86f554b
cae4b9c
a862df6
f066ed2
14d2dbc
b2ca3ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> | ||
); | ||
} |
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 |
---|---|---|
|
@@ -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']); | ||
target.style.removeProperty('maxWidth'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why maxWidth is removed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because when Why is There was a problem hiding this comment. Choose a reason for hiding this commentThe 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']); | ||
|
There was a problem hiding this comment.
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:
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?
There was a problem hiding this comment.
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 callonDropdownOpen
(or at least part of it) essentially on every render. Here are two things to consider:There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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?
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
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.