Skip to content

Commit

Permalink
Merge pull request #1518 from cozy/popper
Browse files Browse the repository at this point in the history
fix: Use newer version of popper in action menu
  • Loading branch information
y-lohse authored Aug 7, 2020
2 parents a41fc34 + 90df646 commit ffb3566
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 280 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
},
"dependencies": {
"@babel/runtime": "^7.3.4",
"@popperjs/core": "^2.4.4",
"body-scroll-lock": "^2.5.8",
"classnames": "^2.2.5",
"date-fns": "^1.28.5",
Expand All @@ -143,6 +144,7 @@
"react-hot-loader": "^4.3.11",
"react-markdown": "^4.0.8",
"react-pdf": "^4.0.5",
"react-popper": "^2.2.3",
"react-select": "2.2.0",
"react-swipeable-views": "0.13.3"
},
Expand Down
110 changes: 4 additions & 106 deletions react/ActionMenu/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ const hideMenu = () => setState({ menuDisplayed: false });
</div>
```

### Placement
### Placement on desktop

The `placement` and `anchorElRef` prop can be used to control the placement of the menu on desktop. `anchorElRef` should be a ref to a DOM element and not a react component.
You can pass a reference to a custom DOM element through the `anchorElRef` prop to attach the menu to that element.
We use [popper.js](https://popper.js.org/docs/v2/) under the hood. You can use the `popperOptions` prop to pass options to the popper.js instance. This lets you control things like placement relative to the anchor, positioning strategies and more — refer to the popper doc for all the details.

```
import ActionMenu, { ActionMenuItem } from 'cozy-ui/transpiled/react/ActionMenu';
Expand All @@ -114,112 +115,9 @@ const anchorRef = React.createRef();
{state.menuDisplayed &&
<ActionMenu
anchorElRef={anchorRef}
placement="bottom-end"
buttonRef={testRef}
popperOptions={{ placement: 'bottom-end'}}
onClose={hideMenu}>
<ActionMenuItem left={<Icon icon='file' />}>Item 1</ActionMenuItem>
</ActionMenu>}
</div>
```

### ActionMenu inside custom fixed elements

The ActionMenu component is rendered at the root of the DOM tree using a [Portal](https://reactjs.org/docs/portals.html) and a fixed z-index. Since Modals for example have a higher z-index than ActionMenus, an ActionMenu _inside_ a Modal would be displayed behind it, instead of over it. To fix this problem, the Popper instance
is given a reference to the Modal DOM node for its `container` option via a special
React context.

Any z-indexed element that will display action menus should use this technique to
provide a ref to a node inside this stacking context (CSS context, not React
context).

```
import { useRef, useState, useCallback } from 'react'
import PopperContainerContext from '../PopperContainerContext'
import ActionMenu, { ActionMenuItem, ActionMenuHeader } from './index';
import Icon from '../Icon';
const MyActionMenu = ({ anchorRef, buttonRef, onClose }) => {
return <ActionMenu
anchorElRef={anchorRef}
placement="bottom-end"
buttonRef={buttonRef}
onClose={onClose}>
<ActionMenuItem onClick={onClose} left={<Icon icon='file' />}>Item 1</ActionMenuItem>
</ActionMenu>
}
const MyFixedElement = () => {
const ref = useRef()
const [shown, setShown] = useState(isTesting())
const buttonRef = useRef()
const show = useCallback(() => setShown(true), [setShown])
const hide = useCallback(() => setShown(false), [setShown])
return <PopperContainerContext.Provider value={ref}>
<div style={{ position: 'fixed', height: 100, width: 100, bottom: 0, top: 0, margin: 'auto', left: 0, right: 0, zIndex: 1 }}>
<div ref={ref} />
<button onClick={show} ref={buttonRef}>Open action menu</button>
{shown ? <MyActionMenu onClose={hide} buttonRef={buttonRef}/> : null}
</div>
</PopperContainerContext.Provider>
};
initialState = { shown: isTesting() };
<>
<button onClick={() => setState({ shown: !state.shown })}>
Toggle custom fixed element
</button>
{ state.shown && <MyFixedElement /> }
</>
```

### ActionMenu within modals

The above technique is used in Modals, nothing is required from the developer
for ActionMenus inside modals to work correctly.

```
import { useState, useRef } from 'react'
import ActionMenu, { ActionMenuItem, ActionMenuHeader } from './index';
import Icon from '../Icon';
import Modal, { ModalDescription } from '../Modal';
import Filename from '../Filename';
initialState = { modalDisplayed: isTesting() };
const showModal = () => setState({ modalDisplayed: true });
const hideModal = () => setState({ modalDisplayed: false });
const ExampleModalContent = () => {
const [menuDisplayed, setMenuDisplayed] = useState(isTesting())
const showMenu = () => setMenuDisplayed(true);
const hideMenu = () => setMenuDisplayed(false);
return (
<ModalDescription>
<button onClick={showMenu}>Show action menu</button>
{menuDisplayed &&
<ActionMenu
onClose={hideMenu}>
<ActionMenuHeader>
<Filename icon="file" filename="my_awesome_paper" extension=".pdf" />
</ActionMenuHeader>
<ActionMenuItem onClick={() => alert('click')}left={<Icon icon='file' />}>Item 1</ActionMenuItem>
</ActionMenu>}
</ModalDescription>
)
}
<div>
<button onClick={showModal}>Show modal</button>
{state.modalDisplayed &&
<Modal dismissAction={hideModal}>
<ExampleModalContent />
</Modal>
}
</div>
```

### preventOverflow

Set `preventOverflow` to `true` to keep the ActionMenu visible on desktop, even if `anchorElRef` is outside the viewport.
79 changes: 51 additions & 28 deletions react/ActionMenu/index.jsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,95 @@
import React, { useCallback, useContext } from 'react'
import React from 'react'
import PropTypes from 'prop-types'
import cx from 'classnames'
import ClickAwayListener from '@material-ui/core/ClickAwayListener'
import styles from './styles.styl'
import { Media, Bd, Img } from '../Media'
import BottomDrawer from '../BottomDrawer'
import withBreakpoints from '../helpers/withBreakpoints'
import Popper from '@material-ui/core/Popper'
import { getCssVariableValue } from '../utils/color'
import PopperContainerContext from '../PopperContainerContext'
import Radio from '../Radio'
import { spacingProp } from '../Stack'
import { usePopper } from 'react-popper'
import createDepreciationLogger from '../helpers/createDepreciationLogger'

const ActionMenuWrapper = ({
inline,
onClose,
anchorElRef,
containerElRef,
popperOptions,
placement,
preventOverflow,
children
}) => {
const getElementFromRefCallback = ref =>
useCallback(() => {
return ref ? ref.current : undefined
}, [ref])

const popperContainerRef = useContext(PopperContainerContext)
const getAnchorElement = getElementFromRefCallback(anchorElRef)

const containerRef = popperContainerRef || containerElRef
const getContainerElement = getElementFromRefCallback(containerRef)
const normalOverflowModifiers = {
preventOverflow: { enabled: false },
hide: { enabled: false }
const [popperElement, setPopperElement] = React.useState(null)
const referenceElement = anchorElRef ? anchorElRef.current : null

const normalOverflowModifiers = [
{
name: 'preventOverflow',
enabled: false
},
{
name: 'hide',
enabled: false
}
]
const options = popperOptions || {
placement,
modifiers: preventOverflow ? undefined : normalOverflowModifiers
}

const { styles, attributes } = usePopper(
referenceElement,
popperElement,
options
)

return inline ? (
<Popper
anchorEl={getAnchorElement}
container={getContainerElement}
modifiers={preventOverflow ? null : normalOverflowModifiers}
open
placement={placement}
<div
ref={setPopperElement}
style={{
...styles.popper,
zIndex: getCssVariableValue('zIndex-popover')
}}
{...attributes.popper}
>
<ClickAwayListener onClickAway={onClose}>{children}</ClickAwayListener>
</Popper>
</div>
) : (
<BottomDrawer onClose={onClose}>{children}</BottomDrawer>
)
}

const logDepecratedPlacement = createDepreciationLogger()
const logDepecratedOverflow = createDepreciationLogger()
const logDepecratedContainer = createDepreciationLogger()

const ActionMenu = ({
children,
autoclose,
className,
onClose,
placement,
preventOverflow,
popperOptions,
anchorElRef,
containerElRef,
breakpoints: { isMobile }
}) => {
if (placement)
logDepecratedPlacement(
'<ActionMenu placement /> is deprecated, use <ActionMenu popperOptions={{ placement }} /> instead'
)
if (preventOverflow)
logDepecratedOverflow(
'<ActionMenu preventOverflow /> is deprecated, use <ActionMenu popperOptions={{ modifiers }} /> instead'
)
if (containerElRef)
logDepecratedContainer(
'<ActionMenu containerElRef /> is not needed anymore, it can be removed.'
)

const shouldDisplayInline = !isMobile
const containerRef = React.createRef()
return (
Expand All @@ -77,9 +102,9 @@ const ActionMenu = ({
onClose={onClose}
inline={shouldDisplayInline}
anchorElRef={anchorElRef || containerRef}
containerElRef={containerElRef}
placement={placement}
preventOverflow={preventOverflow}
popperOptions={popperOptions}
>
<div
className={cx(styles['c-actionmenu'], {
Expand Down Expand Up @@ -120,9 +145,7 @@ ActionMenu.propTypes = {
/** Will keep the menu visible when scrolling */
preventOverflow: PropTypes.bool,
/** The reference element for the menu placement and overflow prevention. */
anchorElRef: PropTypes.object,
/** ActionMenu will be rendered inside the elemnt of this ref. Useful when rendering inside Modals for example. */
containerElRef: PropTypes.object
anchorElRef: PropTypes.object
}

ActionMenu.defaultProps = {
Expand Down
Loading

0 comments on commit ffb3566

Please sign in to comment.