diff --git a/docs/src/modules/components/withRoot.js b/docs/src/modules/components/withRoot.js index 8620a3a22f9f81..a5c7122f691d00 100644 --- a/docs/src/modules/components/withRoot.js +++ b/docs/src/modules/components/withRoot.js @@ -92,6 +92,9 @@ const pages = [ { pathname: '/utils/transitions', }, + { + pathname: '/utils/popup-state', + }, { pathname: '/utils/popover', }, diff --git a/docs/src/pages/utils/popup-state/MenuPopupState.js b/docs/src/pages/utils/popup-state/MenuPopupState.js new file mode 100644 index 00000000000000..8b85e6d562b40b --- /dev/null +++ b/docs/src/pages/utils/popup-state/MenuPopupState.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import Button from '@material-ui/core/Button'; +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import PopupState from '@material-ui/core/PopupState'; + +const MenuPopupState = () => ( + + {({ close, bindTrigger, bindPopup }) => ( + + + + Cake + Death + + + )} + +); + +export default MenuPopupState; diff --git a/docs/src/pages/utils/popup-state/PopoverPopupState.js b/docs/src/pages/utils/popup-state/PopoverPopupState.js new file mode 100644 index 00000000000000..69a3b7b39bac56 --- /dev/null +++ b/docs/src/pages/utils/popup-state/PopoverPopupState.js @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import Popover from '@material-ui/core/Popover'; +import PopupState from '@material-ui/core/PopupState'; + +const styles = theme => ({ + typography: { + margin: theme.spacing.unit * 2, + }, +}); + +const PopoverPopupState = ({ classes }) => ( + + {({ bindTrigger, bindPopup }) => ( +
+ + + The content of the Popover. + +
+ )} +
+); + +PopoverPopupState.propTypes = { + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styles)(PopoverPopupState); diff --git a/docs/src/pages/utils/popup-state/PopperPopupState.js b/docs/src/pages/utils/popup-state/PopperPopupState.js new file mode 100644 index 00000000000000..4bfef0baa3bfc4 --- /dev/null +++ b/docs/src/pages/utils/popup-state/PopperPopupState.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import Popper from '@material-ui/core/Popper'; +import PopupState from '@material-ui/core/PopupState'; +import Fade from '@material-ui/core/Fade'; +import Paper from '@material-ui/core/Paper'; + +const styles = theme => ({ + typography: { + padding: theme.spacing.unit * 2, + }, +}); + +const PopperPopupState = ({ classes }) => ( + + {({ bindToggle, bindPopup }) => ( +
+ + + {({ TransitionProps }) => ( + + + The content of the Popper. + + + )} + +
+ )} +
+); + +PopperPopupState.propTypes = { + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styles)(PopperPopupState); diff --git a/docs/src/pages/utils/popup-state/popup-state.md b/docs/src/pages/utils/popup-state/popup-state.md new file mode 100644 index 00000000000000..49edb1beb089e8 --- /dev/null +++ b/docs/src/pages/utils/popup-state/popup-state.md @@ -0,0 +1,23 @@ +--- +title: PopupState React component +components: PopupState, Menu, Popover, Popper +--- + +# PopupState + +

PopupState takes care of the boilerplate for common Menu, Popover and Popper use cases.

+ +It keeps track of the local state for a single popup and passes props to inject into the +popup component and a trigger component (like a `Button`). + +## Menu + +{{"demo": "pages/utils/popup-state/MenuPopupState.js"}} + +## Popover + +{{"demo": "pages/utils/popup-state/PopoverPopupState.js"}} + +## Popper + +{{"demo": "pages/utils/popup-state/PopperPopupState.js"}} diff --git a/packages/material-ui/src/PopupState/PopupState.js b/packages/material-ui/src/PopupState/PopupState.js new file mode 100644 index 00000000000000..3dbbca11164169 --- /dev/null +++ b/packages/material-ui/src/PopupState/PopupState.js @@ -0,0 +1,114 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import warning from 'warning'; + +export default class PopupState extends React.Component { + state = { anchorEl: null }; + + handleToggle = eventOrAnchorEl => { + if (this.state.anchorEl) this.handleClose(); + else this.handleOpen(eventOrAnchorEl); + }; + + handleOpen = eventOrAnchorEl => { + warning(eventOrAnchorEl || eventOrAnchorEl.target, 'eventOrAnchorEl should be defined'); + this.setState({ + anchorEl: + eventOrAnchorEl && eventOrAnchorEl.target ? eventOrAnchorEl.target : eventOrAnchorEl, + }); + }; + + handleClose = () => this.setState({ anchorEl: null }); + + handleSetOpen = (open, eventOrAnchorEl) => { + if (open) this.handleOpen(eventOrAnchorEl); + else this.handleClose(); + }; + + render() { + const { children, popupId, variant } = this.props; + const { anchorEl } = this.state; + + const isOpen = Boolean(anchorEl); + + const bindPopup = { + id: popupId, + anchorEl, + open: isOpen, + }; + if (variant !== 'popper') bindPopup.onClose = this.handleClose; + + return children({ + open: this.handleOpen, + close: this.handleClose, + toggle: this.handleToggle, + setOpen: this.handleSetOpen, + isOpen, + bindTrigger: { + 'aria-owns': isOpen ? popupId : null, + 'aria-haspopup': true, + onClick: this.handleOpen, + }, + bindToggle: { + 'aria-owns': isOpen ? popupId : null, + 'aria-haspopup': true, + onClick: this.handleToggle, + }, + bindPopup, + }); + } +} + +PopupState.propTypes = { + /** + * The render function. + * + * @param {object} props the properties injected by `PopupState`: + * + * + * @returns {React.Node} the content to display + */ + children: PropTypes.func.isRequired, + /** + * The `id` property to use for the popup. Will be passed to the render + * function as `bindPopup.id`, and also used for the `aria-owns` property + * passed to the trigger component via `bindTrigger`. + */ + popupId: PropTypes.string, + /** + * The type of popup you are controlling. + */ + variant: PropTypes.oneOf(['menu', 'popover', 'popper']).isRequired, +}; diff --git a/packages/material-ui/src/PopupState/PopupState.test.js b/packages/material-ui/src/PopupState/PopupState.test.js new file mode 100644 index 00000000000000..d30db178ec11d8 --- /dev/null +++ b/packages/material-ui/src/PopupState/PopupState.test.js @@ -0,0 +1,199 @@ +import * as React from 'react'; +import { spy } from 'sinon'; +import { assert } from 'chai'; +import { createMount } from '../test-utils'; +import Button from '../Button'; +import Popper from '../Popper'; +import Menu from '../Menu'; +import MenuItem from '../MenuItem'; +import PopupState from './PopupState'; + +describe('', () => { + let mount; + + before(() => { + mount = createMount(); + }); + + after(() => { + mount.cleanUp(); + }); + + describe('variant="menu"', () => { + let buttonRef; + let button; + let menu; + + const render = spy(({ close, bindTrigger, bindPopup }) => ( + + + + Test + + + )); + + beforeEach(() => render.resetHistory()); + + it('passes correct props to bindTrigger/bindPopup', () => { + const wrapper = mount( + + {render} + , + ); + button = wrapper.find(Button); + menu = wrapper.find(Menu); + assert.strictEqual(render.args[0][0].isOpen, false); + assert.strictEqual(button.prop('aria-owns'), null); + assert.strictEqual(button.prop('aria-haspopup'), true); + assert.strictEqual(button.prop('onClick'), render.args[0][0].open); + assert.strictEqual(menu.prop('id'), 'menu'); + assert.strictEqual(menu.prop('anchorEl'), null); + assert.strictEqual(menu.prop('open'), false); + assert.strictEqual(menu.prop('onClose'), render.args[0][0].close); + + button.simulate('click'); + wrapper.update(); + button = wrapper.find(Button); + menu = wrapper.find(Menu); + assert.strictEqual(render.args[1][0].isOpen, true); + assert.strictEqual(button.prop('aria-owns'), 'menu'); + assert.strictEqual(button.prop('aria-haspopup'), true); + assert.strictEqual(button.prop('onClick'), render.args[1][0].open); + assert.strictEqual(menu.prop('id'), 'menu'); + assert.strictEqual(menu.prop('anchorEl'), buttonRef); + assert.strictEqual(menu.prop('open'), true); + assert.strictEqual(menu.prop('onClose'), render.args[1][0].close); + + wrapper.find(MenuItem).simulate('click'); + wrapper.update(); + button = wrapper.find(Button); + menu = wrapper.find(Menu); + assert.strictEqual(render.args[2][0].isOpen, false); + assert.strictEqual(button.prop('aria-owns'), null); + assert.strictEqual(button.prop('aria-haspopup'), true); + assert.strictEqual(button.prop('onClick'), render.args[2][0].open); + assert.strictEqual(menu.prop('id'), 'menu'); + assert.strictEqual(menu.prop('anchorEl'), null); + assert.strictEqual(menu.prop('open'), false); + assert.strictEqual(menu.prop('onClose'), render.args[2][0].close); + }); + it('open/close works', () => { + const wrapper = mount( + + {render} + , + ); + + render.args[0][0].open(buttonRef); + wrapper.update(); + assert.strictEqual(render.args[1][0].isOpen, true); + + render.args[1][0].close(); + wrapper.update(); + assert.strictEqual(render.args[2][0].isOpen, false); + }); + it('toggle works', () => { + const wrapper = mount( + + {render} + , + ); + + render.args[0][0].toggle(buttonRef); + wrapper.update(); + assert.strictEqual(render.args[1][0].isOpen, true); + + render.args[1][0].toggle(buttonRef); + wrapper.update(); + assert.strictEqual(render.args[2][0].isOpen, false); + }); + it('setOpen works', () => { + const wrapper = mount( + + {render} + , + ); + + render.args[0][0].setOpen(true, buttonRef); + wrapper.update(); + assert.strictEqual(render.args[1][0].isOpen, true); + + render.args[1][0].setOpen(false); + wrapper.update(); + assert.strictEqual(render.args[2][0].isOpen, false); + }); + }); + describe('variant="popper"', () => { + let buttonRef; + let button; + let popper; + + const render = spy(({ bindToggle, bindPopup }) => ( + + + The popper content + + )); + + beforeEach(() => render.resetHistory()); + + it('passes correct props to bindToggle/bindPopup', () => { + const wrapper = mount( + + {render} + , + ); + button = wrapper.find(Button); + popper = wrapper.find(Popper); + assert.strictEqual(render.args[0][0].isOpen, false); + assert.strictEqual(button.prop('aria-owns'), null); + assert.strictEqual(button.prop('aria-haspopup'), true); + assert.strictEqual(button.prop('onClick'), render.args[0][0].toggle); + assert.strictEqual(popper.prop('id'), 'popper'); + assert.strictEqual(popper.prop('anchorEl'), null); + assert.strictEqual(popper.prop('open'), false); + assert.strictEqual(popper.prop('onClose'), undefined); + + button.simulate('click'); + wrapper.update(); + button = wrapper.find(Button); + popper = wrapper.find(Popper); + assert.strictEqual(render.args[1][0].isOpen, true); + assert.strictEqual(button.prop('aria-owns'), 'popper'); + assert.strictEqual(button.prop('aria-haspopup'), true); + assert.strictEqual(button.prop('onClick'), render.args[1][0].toggle); + assert.strictEqual(popper.prop('id'), 'popper'); + assert.strictEqual(popper.prop('anchorEl'), buttonRef); + assert.strictEqual(popper.prop('open'), true); + assert.strictEqual(popper.prop('onClose'), undefined); + + button.simulate('click'); + wrapper.update(); + button = wrapper.find(Button); + popper = wrapper.find(Popper); + assert.strictEqual(render.args[2][0].isOpen, false); + assert.strictEqual(button.prop('aria-owns'), null); + assert.strictEqual(button.prop('aria-haspopup'), true); + assert.strictEqual(button.prop('onClick'), render.args[2][0].toggle); + assert.strictEqual(popper.prop('id'), 'popper'); + assert.strictEqual(popper.prop('anchorEl'), null); + assert.strictEqual(popper.prop('open'), false); + assert.strictEqual(popper.prop('onClose'), undefined); + }); + }); +}); diff --git a/packages/material-ui/src/PopupState/index.js b/packages/material-ui/src/PopupState/index.js new file mode 100644 index 00000000000000..93e0919aecab8b --- /dev/null +++ b/packages/material-ui/src/PopupState/index.js @@ -0,0 +1 @@ +export { default } from './PopupState'; diff --git a/pages/api/menu.md b/pages/api/menu.md index ec799e64a74b35..bc76d17fa6650d 100644 --- a/pages/api/menu.md +++ b/pages/api/menu.md @@ -59,4 +59,5 @@ You can take advantage of this behavior to [target nested components](/guides/ap ## Demos - [Menus](/demos/menus) +- [Popup State](/utils/popup-state) diff --git a/pages/api/popover.md b/pages/api/popover.md index 5c52886d0266d8..e62513d66763d2 100644 --- a/pages/api/popover.md +++ b/pages/api/popover.md @@ -68,4 +68,5 @@ You can take advantage of this behavior to [target nested components](/guides/ap ## Demos - [Popover](/utils/popover) +- [Popup State](/utils/popup-state) diff --git a/pages/api/popper.md b/pages/api/popper.md index a0f50c2b344672..00b19c4e98f33e 100644 --- a/pages/api/popper.md +++ b/pages/api/popper.md @@ -33,4 +33,5 @@ Any other properties supplied will be spread to the root element (native element - [Autocomplete](/demos/autocomplete) - [Menus](/demos/menus) - [Popper](/utils/popper) +- [Popup State](/utils/popup-state) diff --git a/pages/api/popup-state.js b/pages/api/popup-state.js new file mode 100644 index 00000000000000..ecd4a503a45b09 --- /dev/null +++ b/pages/api/popup-state.js @@ -0,0 +1,10 @@ +import React from 'react'; +import withRoot from 'docs/src/modules/components/withRoot'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import markdown from './popup-state.md'; + +function Page() { + return ; +} + +export default withRoot(Page); diff --git a/pages/api/popup-state.md b/pages/api/popup-state.md new file mode 100644 index 00000000000000..d6e2d67806cd2f --- /dev/null +++ b/pages/api/popup-state.md @@ -0,0 +1,27 @@ +--- +filename: /packages/material-ui/src/PopupState/PopupState.js +title: PopupState API +--- + + + +# PopupState + +

The API documentation of the PopupState React component.

+ + + +## Props + +| Name | Type | Default | Description | +|:-----|:-----|:--------|:------------| +| children * | func |   | The render function.

**Signature:**
`function(props: object) => React.Node`
*props:* the properties injected by `PopupState`:
  • `open(eventOrAnchorEl)`: opens the popup
  • `close()`: closes the popup
  • `toggle(eventOrAnchorEl)`: opens the popup if it is closed, or closes the popup if it is open.
  • `setOpen(open, [eventOrAnchorEl])`: sets whether the popup is open. `eventOrAnchorEl` is required if `open` is truthy.
  • `isOpen`: `true`/`false` if the popup is open/closed
  • `bindTrigger`: properties to pass to a trigger component
    • `aria-owns`: the `popupId` you passed to `PopupState` (when the popup is open)
    • `aria-haspopup`: true
    • `onClick(event)`: opens the popup
  • `bindToggle`: properties to pass to a toggle component
    • `aria-owns`: the `popupId` you passed to `PopupState` (when the popup is open)
    • `aria-haspopup`: true
    • `onClick(event)`: toggles the popup
  • `bindPopup`: properties to pass to the popup component
    • `id`: the `popupId` you passed to `PopupState`
    • `anchorEl`: the trigger element (when the popup is open)
    • `open`: `true`/`false` if the popup is open/closed
    • `onClose()`: closes the popup (`"menu"` and `"popover"` variants only)

*returns* (React.Node): the content to display | +| popupId | string |   | The `id` property to use for the popup. Will be passed to the render function as `bindPopup.id`, and also used for the `aria-owns` property passed to the trigger component via `bindTrigger`. | +| variant * | enum: 'menu' |
 'popover' |
 'popper'
|   | The type of popup you are controlling. | + +Any other properties supplied will be spread to the root element (native element). + +## Demos + +- [Popup State](/utils/popup-state) + diff --git a/pages/utils/popup-state.js b/pages/utils/popup-state.js new file mode 100644 index 00000000000000..88dc7039198f40 --- /dev/null +++ b/pages/utils/popup-state.js @@ -0,0 +1,37 @@ +import React from 'react'; +import withRoot from 'docs/src/modules/components/withRoot'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import markdown from 'docs/src/pages/utils/popup-state/popup-state.md'; + +function Page() { + return ( + + ); +} + +export default withRoot(Page);