Skip to content

Commit

Permalink
fix(dropdown-menu): improve dropdown menu floating panel placement
Browse files Browse the repository at this point in the history
improved the positioning logic of DropdownMenu's floating panel
---------

Co-authored-by: Carlos Cortizas <[email protected]>
  • Loading branch information
misama-ct and CarlosCortizasCT authored Jun 25, 2024
1 parent 061fe2a commit 6b4d378
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 26 deletions.
5 changes: 5 additions & 0 deletions .changeset/thin-boats-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@commercetools-uikit/dropdown-menu': patch
---

improved positioning logic of DropdownMenu's floating panel
5 changes: 4 additions & 1 deletion packages/components/dropdowns/dropdown-menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ It allows to use any component as the element used to trigger the floating panel

The panel can be customized to render whatever is needed. However, as a common use case would be to render a list of elements and select one of them, this component provides some helpers to easily implement such use case.

Something to bear in mind is that, when the panel is open, the document scroll is blocked.
Something to bear in mind:

- when the panel is open, the document scroll is blocked
- if there is limited screen estate, the `menuPosition` may be adjusted to ensure the menu is displayed properly

## Installation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { act, screen, render } from '../../../../../test/test-utils';
import DropdownMenu from './dropdown-menu';

describe('DropdownMenu', () => {
beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

it('should render list menu', async () => {
const firstOptionOnClick = jest.fn();
const secondOptionOnClick = jest.fn();
Expand All @@ -27,6 +35,7 @@ describe('DropdownMenu', () => {

// Open the dropdown
screen.getByLabelText('Trigger').click();
await jest.runAllTimersAsync();
expect(await screen.findByText('Option 1')).toBeVisible();
expect(screen.getByText('Option 2')).toBeVisible();

Expand Down Expand Up @@ -56,6 +65,7 @@ describe('DropdownMenu', () => {

// Open the dropdown
screen.getByLabelText('Trigger').click();
await jest.runAllTimersAsync();
expect(await screen.findByText('Content')).toBeVisible();

// Clicking outside the dropdown should close it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ storiesOf('Components|Dropdowns|DropdownMenu', module)
<SelectInput
appearance="quiet"
value={value}
menuPortalTarget={document.body}
menuPortalZIndex={5}
onChange={(event) => onChange(event.target.value)}
options={[
{ value: 'is', label: 'is' },
Expand All @@ -87,6 +89,8 @@ storiesOf('Components|Dropdowns|DropdownMenu', module)
<SelectInput
value={value}
onChange={(event) => onChange(event.target.value)}
menuPortalTarget={document.body}
menuPortalZIndex={5}
options={[
{ value: 'laval', label: 'Laval Montreal' },
{ value: 'forest', label: 'Forest Ottawa' },
Expand All @@ -111,4 +115,72 @@ storiesOf('Components|Dropdowns|DropdownMenu', module)
</SpacingsStack>
</DropdownMenu>
</Section>
));
))
.add('DropdownMenu - Reposition menu if necessary', () => {
const optionsCount = number('Options count', 5);
return (
<Section align="center">
<div>
{[
{
id: 1,
position: 'absolute',
top: 24,
left: 24,
},
{
id: 2,
position: 'absolute',
top: 24,
right: 24,
},
{
id: 3,
position: 'absolute',
bottom: 24,
right: 24,
},
{
id: 4,
position: 'absolute',
bottom: 24,
left: 24,
},
{
id: 5,
position: 'absolute',
top: '50%',
left: '50%',
},
].map((css) => (
<div key={css.id} style={{ ...css }}>
<DropdownMenu
triggerElement={
<IconButton icon={<ColumnsIcon />} label="list" />
}
menuHorizontalConstraint={select(
'menu horizontalConstraint',
Constraints.getAcceptedMaxPropValues(),
6
)}
menuPosition={select(
'Menu position',
['left', 'right'],
'left'
)}
menuMaxHeight={number('menuMaxHeight', 0)}
menuType="list"
>
{new Array(optionsCount).fill().map((_, index) => (
<DropdownMenu.ListMenuItem
key={index}
onClick={action('onClick')}
>{`Option ${index + 1}`}</DropdownMenu.ListMenuItem>
))}
</DropdownMenu>
</div>
))}
</div>
</Section>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import SpacingsStack from '@commercetools-uikit/spacings-stack';
// but also for calculating the default max height of the dropdown menu so we make sure it fits
// within the viewport.
const boxShadowBottomSize = '5px';
const marginTop = designTokens.spacing20;
const outerMargin = designTokens.spacing20;

export function getDropdownMenuBaseStyles(params: {
isOpen: boolean;
Expand All @@ -26,12 +26,12 @@ export function getDropdownMenuBaseStyles(params: {
border-radius: ${designTokens.borderRadius4};
box-shadow: 0 2px ${boxShadowBottomSize} 0px rgba(0, 0, 0, 0.15);
display: ${params.isOpen ? 'block' : 'none'};
margin-top: ${marginTop};
max-width: ${Constraints.getMaxPropTokenValue(params.horizontalConstraint)};
overflow-y: auto;
position: fixed;
visibility: hidden;
width: ${params.horizontalConstraint === 'auto' ? 'auto' : '100%'};
z-index: 5;
z-index: 1;
`;
}

Expand All @@ -48,30 +48,130 @@ function DropdownBaseMenu(props: TDropdownBaseMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
// Update the position of the menu when it is open
if (props.isOpen && props.triggerElementRef.current && menuRef.current) {
const triggerElementCoordinates =
props.triggerElementRef.current.getBoundingClientRect();

menuRef.current.style.top = `${
triggerElementCoordinates.top + triggerElementCoordinates.height
}px`;
if (props.menuPosition === 'left') {
menuRef.current.style.left = `${triggerElementCoordinates.left}px`;
menuRef.current.style.removeProperty('right');
if (!props.isOpen || !props.triggerElementRef.current || !menuRef.current) {
return;
}

const menuMaxHeight = props.menuMaxHeight;
const menuEl = menuRef.current;
const menuTriggerEl = props.triggerElementRef.current;

const menuDOMRect = menuEl.getBoundingClientRect();
const triggerDOMRect = menuTriggerEl.getBoundingClientRect();

// By default, the menu is not exceeding the viewport, this can change though
let menuIsExceedingViewport = false;

if (menuDOMRect.width >= document.body.scrollWidth) {
// If the menu width is greater than the body width, we need to set the width of the menu to the body width
// to prevent the menu from overflowing, this happens usually when the horizontalConstraint is set to 'auto'
menuEl.style.width = `calc(${document.body.scrollWidth}px - 2 * ${outerMargin})`;
menuIsExceedingViewport = true;
}

// The preferred/ideal height of the menu (which might change later if there is
// not enough screen estate)
let desiredMenuHeight = menuMaxHeight || menuDOMRect.height;
const menuWidth = menuDOMRect.width;

const availableSpaceTop = triggerDOMRect.top;
const availableSpaceBottom = window.innerHeight - triggerDOMRect.bottom;

// Prefer rendering below the trigger element if there is enough space
// to display the whole menu, otherwise render wherever there is more space
const menuYPosition =
availableSpaceBottom >= desiredMenuHeight
? 'below'
: availableSpaceBottom > availableSpaceTop
? 'below'
: 'above';

let menuXPosition: 'left' | 'right';

if (props.menuPosition === 'left') {
const distanceToRightEdge = window.innerWidth - triggerDOMRect.left;
menuXPosition = distanceToRightEdge >= menuWidth ? 'left' : 'right';
}

if (props.menuPosition === 'right') {
const distanceToLeftEdge = triggerDOMRect.left + triggerDOMRect.width;
menuXPosition = distanceToLeftEdge >= menuWidth ? 'right' : 'left';
}
// Since scrolling will be disabled by a hook, possibly on the body-element,
// the available viewport-width might change, thus, the positioning of the
// menu would be affected, hence we need to get the scroll-width before the
// menu renders
const scrollWidthBefore = document.body.scrollWidth;

// Using setTimeout allows us to get the correct dimensions & positions
// of the trigger- & menu-element first before doing the calculations for
// positioning the menu correctly
setTimeout(() => {
// If there is a scrollWidthDiff, it means that the width of the
// viewports has changed due to removed scrollbars, and we need to
// adjust the position of the menu to be still properly aligned with
// the trigger
const scrollWidthDiff =
(document.body.scrollWidth - scrollWidthBefore) * 0.5;

if (menuIsExceedingViewport) {
menuEl.style.left = `calc( ${outerMargin} + ${scrollWidthDiff}px)`;
} else if (menuXPosition === 'left') {
menuEl.style.left = `${triggerDOMRect.left + scrollWidthDiff}px`;
menuEl.style.removeProperty('right');
} else {
menuRef.current.style.right = `${
window.innerWidth - triggerElementCoordinates.right
menuEl.style.right = `${
window.innerWidth - triggerDOMRect.right - scrollWidthDiff
}px`;
menuRef.current.style.removeProperty('left');
menuEl.style.removeProperty('left');
}
menuRef.current.style.maxHeight = props.menuMaxHeight
? `${props.menuMaxHeight}px`
: `calc(${
window.innerHeight -
(triggerElementCoordinates.top + triggerElementCoordinates.height)
}px - ${marginTop} - ${boxShadowBottomSize})`;
}

if (menuYPosition === 'below') {
menuEl.style.top = `calc(${
triggerDOMRect.top + triggerDOMRect.height
}px + ${outerMargin})`;
} else {
// Need to re-request getBoundingClientRect() because the menu height
// might have changed when the dropdown is in 'auto' mode;
let desiredMenuHeight =
menuMaxHeight || menuEl.getBoundingClientRect().height;

menuEl.style.top = `calc(${
triggerDOMRect.top - desiredMenuHeight
}px - ${outerMargin})`;
}

if (menuMaxHeight) {
// Apply the manual max-width
menuEl.style.maxHeight = menuMaxHeight + 'px';
} else {
// Make sure max-height does not exceed the available top- or bottom-space
menuEl.style.maxHeight =
menuYPosition === 'below'
? `calc(${
window.innerHeight - triggerDOMRect.bottom
}px - ${outerMargin} - ${boxShadowBottomSize})`
: `calc(${triggerDOMRect.top}px - ${outerMargin} - ${boxShadowBottomSize})`;
}

// All positioning operations done, make menu visible again
menuEl.style.visibility = 'visible';
}, 0);

return () => {
[
'top',
'left',
'right',
'bottom',
'width',
'height',
'maxHeight',
'visibility',
].forEach((prop) => {
menuEl.style.removeProperty(prop);
});
};
}, [
props.isOpen,
props.menuPosition,
Expand Down

0 comments on commit 6b4d378

Please sign in to comment.