diff --git a/.changeset/thin-boats-sneeze.md b/.changeset/thin-boats-sneeze.md new file mode 100644 index 0000000000..c5e0de5a55 --- /dev/null +++ b/.changeset/thin-boats-sneeze.md @@ -0,0 +1,5 @@ +--- +'@commercetools-uikit/dropdown-menu': patch +--- + +improved positioning logic of DropdownMenu's floating panel diff --git a/packages/components/dropdowns/dropdown-menu/README.md b/packages/components/dropdowns/dropdown-menu/README.md index cc402f813f..aee9f84390 100644 --- a/packages/components/dropdowns/dropdown-menu/README.md +++ b/packages/components/dropdowns/dropdown-menu/README.md @@ -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 diff --git a/packages/components/dropdowns/dropdown-menu/src/dropdown-menu.spec.tsx b/packages/components/dropdowns/dropdown-menu/src/dropdown-menu.spec.tsx index 315b7130d4..d101744da7 100644 --- a/packages/components/dropdowns/dropdown-menu/src/dropdown-menu.spec.tsx +++ b/packages/components/dropdowns/dropdown-menu/src/dropdown-menu.spec.tsx @@ -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(); @@ -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(); @@ -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 diff --git a/packages/components/dropdowns/dropdown-menu/src/dropdown-menu.story.js b/packages/components/dropdowns/dropdown-menu/src/dropdown-menu.story.js index fabd9568bb..b7a3d8e839 100644 --- a/packages/components/dropdowns/dropdown-menu/src/dropdown-menu.story.js +++ b/packages/components/dropdowns/dropdown-menu/src/dropdown-menu.story.js @@ -72,6 +72,8 @@ storiesOf('Components|Dropdowns|DropdownMenu', module) onChange(event.target.value)} options={[ { value: 'is', label: 'is' }, @@ -87,6 +89,8 @@ storiesOf('Components|Dropdowns|DropdownMenu', module) onChange(event.target.value)} + menuPortalTarget={document.body} + menuPortalZIndex={5} options={[ { value: 'laval', label: 'Laval Montreal' }, { value: 'forest', label: 'Forest Ottawa' }, @@ -111,4 +115,72 @@ storiesOf('Components|Dropdowns|DropdownMenu', module) - )); + )) + .add('DropdownMenu - Reposition menu if necessary', () => { + const optionsCount = number('Options count', 5); + return ( +
+
+ {[ + { + 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) => ( +
+ } 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) => ( + {`Option ${index + 1}`} + ))} + +
+ ))} +
+
+ ); + }); diff --git a/packages/components/dropdowns/dropdown-menu/src/menu/dropdown-menu-menu.tsx b/packages/components/dropdowns/dropdown-menu/src/menu/dropdown-menu-menu.tsx index 3972c0a012..b271d7447f 100644 --- a/packages/components/dropdowns/dropdown-menu/src/menu/dropdown-menu-menu.tsx +++ b/packages/components/dropdowns/dropdown-menu/src/menu/dropdown-menu-menu.tsx @@ -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; @@ -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; `; } @@ -48,30 +48,130 @@ function DropdownBaseMenu(props: TDropdownBaseMenuProps) { const menuRef = useRef(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,