Skip to content

Commit

Permalink
Fix DropdownMenu component issues (#2804)
Browse files Browse the repository at this point in the history
* fix(dropdown-menu): fix max height management

* fix(dropdown-menu): fix scrolling issues

* feat(dropdown-menu): introducing the new menuMaxHeight property

* chore: changeset added
  • Loading branch information
CarlosCortizasCT authored May 2, 2024
1 parent 6c73d85 commit 4013cd3
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 41 deletions.
7 changes: 7 additions & 0 deletions .changeset/hot-swans-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@commercetools-uikit/dropdown-menu': minor
---

We've fixed two issues we had regarding floating menu position when scrolling and its height.

We're also introducing a new property (`menuMaxHeight`) which allows consumers to limit the floating panel maximum height.
1 change: 1 addition & 0 deletions packages/components/dropdowns/dropdown-menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const CustomDropdownExample = () => {
| Props | Type | Required | Default | Description |
| -------------------------- | ----------------------------------------------------- | :------: | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `menuPosition` | `union`<br/>Possible values:<br/>`'left' , 'right'` | | `'left'` | The position of the menu relative to the trigger element. |
| `menuMaxHeight` | `number` | | | The maximum height for the menu in pixels.&#xA;By default, the max height will be the available space between the trigger element and the bottom of the viewport. |
| `triggerElement` | `ReactElement` || | The element that triggers the dropdown. |
| `menuType` | `union`<br/>Possible values:<br/>`'default' , 'list'` | | `'default'` | The type of the menu.&#xA;The 'default' type just renders a dropdown container but the 'list' type is intended to be used with the DropdownMenu.ListMenuItem component. |
| `menuHorizontalConstraint` | `TMaxProp` | | `'auto'` | The horizontal constraint of the menu. |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Value } from 'react-value';
import { storiesOf } from '@storybook/react';
import { withKnobs, select } from '@storybook/addon-knobs/react';
import { withKnobs, select, number } from '@storybook/addon-knobs/react';
import { action } from '@storybook/addon-actions';
import CheckboxInput from '@commercetools-uikit/checkbox-input';
import Constraints from '@commercetools-uikit/constraints';
Expand All @@ -24,30 +24,31 @@ storiesOf('Components|Dropdowns|DropdownMenu', module)
sidebar: Readme,
},
})
.add('DropdownMenu - List menu content', () => (
<Section align="center">
<DropdownMenu
triggerElement={<IconButton icon={<ColumnsIcon />} label="list" />}
menuHorizontalConstraint={select(
'menu horizontalConstraint',
Constraints.getAcceptedMaxPropValues(),
6
)}
menuPosition={select('Menu position', ['left', 'right'], 'left')}
menuType="list"
>
<DropdownMenu.ListMenuItem onClick={action('onClick')}>
Option 1
</DropdownMenu.ListMenuItem>
<DropdownMenu.ListMenuItem onClick={action('onClick')} isDisabled>
Option 2
</DropdownMenu.ListMenuItem>
<DropdownMenu.ListMenuItem onClick={action('onClick')}>
Option 3
</DropdownMenu.ListMenuItem>
</DropdownMenu>
</Section>
))
.add('DropdownMenu - List menu content', () => {
const optionsCount = number('Options count', 5);
return (
<Section align="center">
<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>
</Section>
);
})
.add('DropdownMenu - Complex menu content', () => (
<Section align="center">
<DropdownMenu
Expand Down
69 changes: 56 additions & 13 deletions packages/components/dropdowns/dropdown-menu/src/dropdown-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import React, {
import {
useCallback,
useEffect,
useMemo,
useRef,
type ReactElement,
type ReactNode,
type RefObject,
} from 'react';
import { useToggleState } from '@commercetools-uikit/hooks';
import { type TMaxProp } from '@commercetools-uikit/constraints';
Expand All @@ -21,6 +23,11 @@ export type TDropdownMenuProps = {
* The position of the menu relative to the trigger element.
*/
menuPosition?: 'left' | 'right';
/**
* The maximum height for the menu in pixels.
* By default, the max height will be the available space between the trigger element and the bottom of the viewport.
*/
menuMaxHeight?: number;
/**
* The element that triggers the dropdown.
*/
Expand All @@ -40,6 +47,48 @@ export type TDropdownMenuProps = {
children: ReactNode;
};

function getScrollableParent(element: HTMLElement | null): HTMLElement | null {
if (!element) {
return null;
}
const overflowY = window.getComputedStyle(element).overflowY;
const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden';
if (isScrollable && element.scrollHeight >= element.clientHeight) {
return element;
}

return getScrollableParent(element.parentElement);
}

function useScrollBlock(isOpen: boolean, triggerRef: RefObject<HTMLElement>) {
const scrollableParentRef = useRef<HTMLElement | null>();

useEffect(() => {
if (!scrollableParentRef.current) {
scrollableParentRef.current = getScrollableParent(triggerRef.current);
}

const { current: scrollableParent } = scrollableParentRef;
if (scrollableParent && isOpen) {
scrollableParent.setAttribute(
'data-prev-scroll',
scrollableParent.style.overflowY
);
scrollableParent.style.overflowY = 'hidden';
}
return () => {
// The cleanup effect runs after the component is unmounted but also everytime
// the dependency array changes. We need to manage both to manage opening/closing
// the dropdown but also to manage the the dropdown is opened and the component
// is unmounted. For instance, when navigating to another page client-side.
if (scrollableParent && isOpen) {
const prevScroll = scrollableParent.getAttribute('data-prev-scroll');
scrollableParent.style.overflowY = prevScroll || '';
}
};
}, [isOpen, scrollableParentRef, triggerRef]);
}

const defaultProps: Pick<
TDropdownMenuProps,
'menuPosition' | 'menuType' | 'menuHorizontalConstraint'
Expand All @@ -56,7 +105,7 @@ const Container = styled.div`

function DropdownMenu(props: TDropdownMenuProps) {
const [isOpen, toggle] = useToggleState(false);
const triggerRef = React.useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);

// We use the context so children can toggle the dropdown
const context = useMemo(
Expand Down Expand Up @@ -91,17 +140,10 @@ function DropdownMenu(props: TDropdownMenuProps) {
window.removeEventListener('click', handleGlobalClick);
};
}, [handleGlobalClick]);
// Block scrolling when the dropdown is open
useEffect(() => {
if (isOpen) {
window.document.body.style.overflow = 'hidden';
} else {
window.document.body.style.overflow = 'initial';
}
return () => {
window.document.body.style.overflow = 'initial';
};
}, [isOpen]);

// Block scrolling when the dropdown is open.
// We do this to avoid requiring dropdown rerendering while the user scrolls.
useScrollBlock(isOpen, triggerRef);

return (
<DropdownMenuContext.Provider value={context}>
Expand All @@ -114,6 +156,7 @@ function DropdownMenu(props: TDropdownMenuProps) {
horizontalConstraint={props.menuHorizontalConstraint!}
isOpen={isOpen}
menuPosition={props.menuPosition!}
menuMaxHeight={props.menuMaxHeight}
triggerElementRef={triggerRef}
>
{props.children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { designTokens } from '@commercetools-uikit/design-system';
import Constraints, { type TMaxProp } from '@commercetools-uikit/constraints';
import SpacingsStack from '@commercetools-uikit/spacings-stack';

// We declare this style properties here because we need them both for initial component styling
// 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;

export function getDropdownMenuBaseStyles(params: {
isOpen: boolean;
horizontalConstraint: TMaxProp;
Expand All @@ -18,10 +24,11 @@ export function getDropdownMenuBaseStyles(params: {
background-color: ${designTokens.colorSurface};
border: 1px solid ${designTokens.colorSurface};
border-radius: ${designTokens.borderRadius4};
box-shadow: 0 2px 5px 0px rgba(0, 0, 0, 0.15);
box-shadow: 0 2px ${boxShadowBottomSize} 0px rgba(0, 0, 0, 0.15);
display: ${params.isOpen ? 'block' : 'none'};
margin-top: ${designTokens.spacing20};
margin-top: ${marginTop};
max-width: ${Constraints.getMaxPropTokenValue(params.horizontalConstraint)};
overflow-y: auto;
position: fixed;
width: ${params.horizontalConstraint === 'auto' ? 'auto' : '100%'};
z-index: 5;
Expand All @@ -34,6 +41,7 @@ type TDropdownBaseMenuProps = {
horizontalConstraint: TMaxProp;
isOpen: boolean;
menuPosition: 'left' | 'right';
menuMaxHeight?: number;
triggerElementRef: RefObject<HTMLElement>;
};
function DropdownBaseMenu(props: TDropdownBaseMenuProps) {
Expand All @@ -57,8 +65,19 @@ function DropdownBaseMenu(props: TDropdownBaseMenuProps) {
triggerElementCoordinates.width -
menuElementCoordinates.width
}px`;
menuRef.current.style.maxHeight = props.menuMaxHeight
? `${props.menuMaxHeight}px`
: `calc(${
window.innerHeight -
(triggerElementCoordinates.top + triggerElementCoordinates.height)
}px - ${marginTop} - ${boxShadowBottomSize})`;
}
}, [props.isOpen, props.menuPosition, props.triggerElementRef]);
}, [
props.isOpen,
props.menuPosition,
props.triggerElementRef,
props.menuMaxHeight,
]);

return (
<div
Expand All @@ -75,6 +94,7 @@ export type TDropdownContentMenuProps = {
children: ReactNode;
horizontalConstraint: TMaxProp;
menuPosition: 'left' | 'right';
menuMaxHeight?: number;
isOpen: boolean;
triggerElementRef: RefObject<HTMLElement>;
};
Expand All @@ -87,6 +107,7 @@ export const DropdownContentMenu = (props: TDropdownContentMenuProps) => {
horizontalConstraint={props.horizontalConstraint}
isOpen={props.isOpen}
menuPosition={props.menuPosition}
menuMaxHeight={props.menuMaxHeight}
triggerElementRef={props.triggerElementRef}
>
{props.children}
Expand All @@ -98,6 +119,7 @@ export type TDropdownListMenuProps = {
children: ReactNode;
horizontalConstraint: TMaxProp;
menuPosition: 'left' | 'right';
menuMaxHeight?: number;
isOpen: boolean;
triggerElementRef: RefObject<HTMLElement>;
};
Expand All @@ -107,6 +129,7 @@ export const DropdownListMenu = (props: TDropdownListMenuProps) => {
horizontalConstraint={props.horizontalConstraint}
isOpen={props.isOpen}
menuPosition={props.menuPosition}
menuMaxHeight={props.menuMaxHeight}
triggerElementRef={props.triggerElementRef}
>
<SpacingsStack scale="xs">{props.children}</SpacingsStack>
Expand Down

0 comments on commit 4013cd3

Please sign in to comment.