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 DropdownMenu component issues #2804

Merged
merged 4 commits into from
May 2, 2024
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
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);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is to allow having long lists in the dropdown menu so the height and scrollability can be tested.

Copy link
Contributor

Choose a reason for hiding this comment

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

💯

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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We want to block the scroll of the nearest ancestor with scrolling enabled, if any.

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>();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to extract a bit of logic from the component itself.


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
Loading