diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f45bbd2..84510003d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,24 @@ ### Added +- `Menu`: It is now scrollable when the available space is less than the height ([@farazatarodi](https://https://github.com/farazatarodi) in [#2618](https://github.com/teamleadercrm/ui/pull/2618)) + ### Changed -- `Container`: remove 72px on xl viewport ([@farazatarodi](https://https://github.com/farazatarodi) in [#2615](https://github.com/teamleadercrm/ui/pull/2615)) +- `Container`: Removed 72px padding on xl viewport ([@farazatarodi](https://https://github.com/farazatarodi) in [#2618](https://github.com/teamleadercrm/ui/pull/2618)) +- [BREAKING] `Menu`: State management now should happen in the parent component ([@farazatarodi](https://https://github.com/farazatarodi) in [#2618](https://github.com/teamleadercrm/ui/pull/2618)). +- `Menu`: Shadow and border now use the values from the design system ([@farazatarodi](https://https://github.com/farazatarodi) in [#2618](https://github.com/teamleadercrm/ui/pull/2618)). ### Deprecated ### Removed +- [BREAKING] `Menu`: The `onShow` property is removed as the state management is now moved to the parent component ([@farazatarodi](https://https://github.com/farazatarodi) in [#2618](https://github.com/teamleadercrm/ui/pull/2618)). + ### Fixed +- [BREAKING] `Menu`: It now requires an anchor element for positioning when it is not static. Previously it was based on the parent element, which caused layout bugs in `flex` elements ([@farazatarodi](https://https://github.com/farazatarodi) in [#2618](https://github.com/teamleadercrm/ui/pull/2618)). + ### Dependency updates ## [20.1.0] - 2023-03-17 diff --git a/src/components/menu/IconMenu.tsx b/src/components/menu/IconMenu.tsx index ca31505e5..475f52589 100644 --- a/src/components/menu/IconMenu.tsx +++ b/src/components/menu/IconMenu.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, ReactNode, SyntheticEvent, MouseEvent, useState } from 'react'; +import React, { ReactElement, ReactNode, SyntheticEvent, MouseEvent, useState, useEffect, useRef } from 'react'; import cx from 'classnames'; import { IconMoreMediumOutline } from '@teamleader/ui-icons'; import IconButton from '../iconButton'; @@ -35,6 +35,9 @@ const IconMenu: GenericComponent = ({ ...others }) => { const [active, setActive] = useState(false); + + const buttonRef = useRef(null); + const buttonIcon = icon || ; const boxProps = pickBoxProps(others); @@ -48,17 +51,25 @@ const IconMenu: GenericComponent = ({ onHide && onHide(); }; + useEffect(() => { + if (active) { + onShow && onShow(); + } else { + onHide && onHide(); + } + }, [active, onHide, onShow]); + return ( - + {children} diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index 748feb72e..e9ae2e719 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -1,4 +1,3 @@ -import uiUtilities from '@teamleader/ui-utilities'; import cx from 'classnames'; import React, { ReactElement, @@ -11,10 +10,9 @@ import React, { useState, } from 'react'; -import Box, { pickBoxProps } from '../box'; +import Box from '../box'; import { BoxProps } from '../box/Box'; import MarketingMenuItem from '../marketingMenuItem'; -import { events } from '../utils'; import isComponentOfType from '../utils/is-component-of-type'; import { getViewport } from '../utils/utils'; import MenuItem from './MenuItem'; @@ -29,149 +27,155 @@ const POSITION: Record extends Omit { /** If true, the menu will be active. */ active?: boolean; + /** Callback to hide the menu. */ + onHide?: () => void; /** The content to display inside the menu. */ children?: ReactNode; /** A class name for the wrapper to give custom styles. */ className?: string; - /** Callback function that is fired when the menu hides. */ - onHide?: () => void; /** Callback function that is fired when a menu item is clicked. */ onSelect?: (selected: S) => void; - /** Callback function that is fired when the menu shows. */ - onShow?: () => void; /** If true, a border is rendered around the menu. */ outline?: boolean; /** The position in which the menu is rendered. */ - position?: 'auto' | 'static' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + position?: Position; /** If true, the menu will highlight the selected value. */ selectable?: boolean; /** The value of the menu item that will be highlighted. */ selected?: S; + /** The anchor element */ + anchorElement?: HTMLElement | null; } const Menu = ({ active = false, + onHide, children, className, - onHide, onSelect, - onShow, outline = true, position = 'static', selectable = true, selected, + anchorElement, ...others }: MenuProps): ReactElement | null => { - const [stateWidth, setStateWidth] = useState(0); - const [stateHeight, setStateHeight] = useState(0); - const [statePosition, setPosition] = useState(position); + const [positionState, setPositionState] = useState(position); + const [calculatedPosition, setCalculatedPosition] = useState({}); + const [maxHeight, setMaxHeight] = useState(); + const menuRef = useRef(null); - const menuNode = useRef(null); - const menuWrapper = useRef(null); + const localActive = active || positionState === POSITION.STATIC; - const boxProps = pickBoxProps(others); const classNames = cx( theme['menu'], - theme[position], { - [theme['active']]: active, + [theme['static']]: positionState === POSITION.STATIC, + [theme['outline']]: outline, + [theme['shadow']]: positionState !== POSITION.STATIC, }, className, ); - const outlineClassNames = cx(theme['outline'], { - [uiUtilities['box-shadow-200']]: position !== POSITION.STATIC, - }); - - const handleDocumentClick = (event: Event) => { - if (active && !events.targetIsDescendant(event, menuWrapper.current)) { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - hide(); - } - }; - const handleSelect = (item: ReactElement, event: SyntheticEvent) => { - const { value, onClick } = item.props; + const handleDocumentClick = useCallback( + (event: Event) => { + const clickedNode = event.target as HTMLElement; + const menuNode = menuRef.current; - if (onSelect) { - onSelect(value); - } - - if (onClick) { - event.persist(); - onClick(event); - } - - // eslint-disable-next-line @typescript-eslint/no-use-before-define - hide(); - }; + if (menuNode && menuNode !== clickedNode && anchorElement !== clickedNode && !menuNode.contains(clickedNode)) { + onHide && onHide(); + } + }, + [anchorElement, onHide], + ); - const addEvents = () => { - window.setTimeout( - () => - events.addEventsToDocument({ - click: handleDocumentClick, - touchstart: handleDocumentClick, - }), - 0, - ); - }; + const handleSelect = useCallback( + (item: ReactElement, event: SyntheticEvent) => { + const { value, onClick } = item.props; - const removeEvents = () => { - events.removeEventsFromDocument({ - click: handleDocumentClick, - touchstart: handleDocumentClick, - }); - }; + if (onSelect) { + onSelect(value); + } - const calculatePosition = () => { - const parentNode = menuWrapper?.current?.parentNode as HTMLElement; + if (onClick) { + event.persist(); + onClick(event); + } - if (!parentNode) { - return; - } + if (position !== POSITION.STATIC) { + onHide && onHide(); + } + }, + [onHide, onSelect, position], + ); - const { top, left, height, width } = parentNode.getBoundingClientRect(); + const calculateAutoPosition = (anchorElement: HTMLElement) => { + const { top, left, height, width } = anchorElement.getBoundingClientRect(); const { height: vh, width: vw } = getViewport(); const toTop = top < vh / 2 - height / 2; const toLeft = left < vw / 2 - width / 2; - return `${toTop ? 'top' : 'bottom'}${toLeft ? 'Left' : 'Right'}`; + return `${toTop ? 'top' : 'bottom'}-${toLeft ? 'left' : 'right'}` as Position; }; - const getRootStyle = () => { - if (statePosition !== POSITION.STATIC) { - return { width: stateWidth, height: stateHeight }; - } - }; + const calculatePosition = useCallback(() => { + if (anchorElement && menuRef.current) { + const { height } = anchorElement.getBoundingClientRect(); + const { height: menuHeight } = menuRef.current.getBoundingClientRect(); - const getActiveMenuStyle = () => { - return { clip: `rect(0 ${stateWidth}px ${stateHeight}px 0)` }; - }; + if (positionState === POSITION.TOP_LEFT) { + return { + top: height + 3, + left: 0, + }; + } + + if (positionState === POSITION.TOP_RIGHT) { + return { + top: height + 3, + right: 0, + }; + } - const getMenuStyleByPosition = () => { - switch (statePosition) { - case POSITION.TOP_RIGHT: - return { clip: `rect(0 ${stateWidth}px 0 ${stateWidth}px)` }; - case POSITION.BOTTOM_RIGHT: - return { clip: `rect(${stateHeight}px ${stateWidth}px ${stateHeight}px ${stateWidth}px)` }; - case POSITION.BOTTOM_LEFT: - return { clip: `rect(${stateHeight}px 0 ${stateHeight}px 0)` }; - case POSITION.TOP_LEFT: - return { clip: 'rect(0 0 0 0)' }; - default: - return {}; + if (positionState === POSITION.BOTTOM_LEFT) { + return { + top: -1 * (menuHeight + 3), + left: 0, + }; + } + + if (positionState === POSITION.BOTTOM_RIGHT) { + return { + top: -1 * (menuHeight + 3), + right: 0, + }; + } } - }; + return {}; + }, [anchorElement, positionState]); - const getMenuStyle = () => { - return active ? getActiveMenuStyle() : getMenuStyleByPosition(); - }; + const calculateMaxHeight = useCallback(() => { + if (anchorElement) { + const { top, height } = anchorElement.getBoundingClientRect(); + const { height: viewportHeight } = getViewport(); + + if (positionState === POSITION.TOP_LEFT || positionState === POSITION.TOP_RIGHT) { + return viewportHeight - top - height - 24; + } - const getItems = useCallback(() => { + if (positionState === POSITION.BOTTOM_LEFT || positionState === POSITION.BOTTOM_RIGHT) { + return top - 24; + } + } + }, [anchorElement, positionState]); + + const renderItems = useCallback(() => { return React.Children.map(children, (item: ReactNode) => { if (!item) { return item; @@ -188,56 +192,45 @@ const Menu = ({ return React.cloneElement(item); } }); - }, [children]); + }, [children, handleSelect, selectable, selected]); - const show = () => { - onShow && onShow(); - addEvents(); - }; + useEffect(() => { + if (position !== POSITION.STATIC && active) { + document.documentElement.addEventListener('click', handleDocumentClick); - const hide = () => { - onHide && onHide(); - removeEvents(); - }; + return () => { + document.documentElement.removeEventListener('click', handleDocumentClick); + }; + } + }, [active, handleDocumentClick, position]); useLayoutEffect(() => { - const { width, height } = menuNode.current?.getBoundingClientRect() || {}; - - setStateWidth(width); - setStateHeight(height); - }, [menuNode.current?.getBoundingClientRect()]); - - useEffect(() => { - active ? show() : hide(); + if (position === POSITION.AUTO && anchorElement && active) { + setPositionState(calculateAutoPosition(anchorElement)); + } else { + setPositionState(position); + } + }, [active, anchorElement, position]); - return () => { - active && removeEvents(); - }; - }, [active]); + useLayoutEffect(() => { + if (positionState !== POSITION.STATIC && positionState !== POSITION.AUTO) { + setMaxHeight(calculateMaxHeight()); + } + }, [calculateMaxHeight, positionState]); - useEffect(() => { - if (position === POSITION.AUTO) { - setPosition(calculatePosition()); + useLayoutEffect(() => { + if (positionState !== POSITION.STATIC) { + setCalculatedPosition(calculatePosition()); } - }, [position]); - - return ( - - {outline && ( -
- )} -
    - {/* An invisible element so the first element doesn't look like its selected or focused */} - - - - {getItems()} + }, [calculatePosition, positionState, maxHeight]); + + return localActive ? ( + +
      + {renderItems()}
    - ); + ) : null; }; export default Menu; diff --git a/src/components/menu/__tests__/__snapshots__/IconMenu.spec.tsx.snap b/src/components/menu/__tests__/__snapshots__/IconMenu.spec.tsx.snap index 9d72219ed..8028dab48 100644 --- a/src/components/menu/__tests__/__snapshots__/IconMenu.spec.tsx.snap +++ b/src/components/menu/__tests__/__snapshots__/IconMenu.spec.tsx.snap @@ -32,70 +32,6 @@ exports[`Component - IconMenu renders 1`] = ` - `; diff --git a/src/components/menu/__tests__/__snapshots__/Menu.spec.tsx.snap b/src/components/menu/__tests__/__snapshots__/Menu.spec.tsx.snap index fcd4e3f5d..f0b6656ba 100644 --- a/src/components/menu/__tests__/__snapshots__/Menu.spec.tsx.snap +++ b/src/components/menu/__tests__/__snapshots__/Menu.spec.tsx.snap @@ -3,24 +3,12 @@ exports[`Component - Menu renders 1`] = `