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`:
+ *
+ *
`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
+ */
+ 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 }) => (
+
+
+
+
+ ));
+
+ 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);