From f6e76093ef1b7c0ea090a0da923429a7b4f67ff2 Mon Sep 17 00:00:00 2001 From: Chris McVittie Date: Mon, 2 Nov 2015 20:50:31 +0000 Subject: [PATCH] popover component --- docs/src/app/app-routes.jsx | 2 + docs/src/app/components/pages/components.jsx | 1 + .../components/pages/components/popover.jsx | 196 ++++++++++ .../app/components/raw-code/popover-code.txt | 90 +++++ src/index.js | 1 + src/popover/popover.jsx | 337 ++++++++++++++++++ src/utils/prop-types.js | 12 + 7 files changed, 639 insertions(+) create mode 100644 docs/src/app/components/pages/components/popover.jsx create mode 100644 docs/src/app/components/raw-code/popover-code.txt create mode 100644 src/popover/popover.jsx diff --git a/docs/src/app/app-routes.jsx b/docs/src/app/app-routes.jsx index 7996d2d3f32d5b..240314979b4705 100644 --- a/docs/src/app/app-routes.jsx +++ b/docs/src/app/app-routes.jsx @@ -36,6 +36,7 @@ const LeftNav = require('./components/pages/components/left-nav'); const Lists = require('./components/pages/components/lists'); const Menus = require('./components/pages/components/menus'); const Paper = require('./components/pages/components/paper'); +const Popover = require('./components/pages/components/popover'); const Progress = require('./components/pages/components/progress'); const RefreshIndicator = require('./components/pages/components/refresh-indicator'); const Sliders = require('./components/pages/components/sliders'); @@ -92,6 +93,7 @@ const AppRoutes = ( + diff --git a/docs/src/app/components/pages/components.jsx b/docs/src/app/components/pages/components.jsx index bc9787804cfff6..2b1175489e00be 100644 --- a/docs/src/app/components/pages/components.jsx +++ b/docs/src/app/components/pages/components.jsx @@ -21,6 +21,7 @@ export default class Components extends React.Component { { route: '/components/lists', text: 'Lists'}, { route: '/components/menus', text: 'Menus'}, { route: '/components/paper', text: 'Paper'}, + { route: '/components/popover', text: 'Popover'}, { route: '/components/progress', text: 'Progress'}, { route: '/components/refresh-indicator', text: 'Refresh Indicator'}, { route: '/components/sliders', text: 'Sliders'}, diff --git a/docs/src/app/components/pages/components/popover.jsx b/docs/src/app/components/pages/components/popover.jsx new file mode 100644 index 00000000000000..2c605adb60e0eb --- /dev/null +++ b/docs/src/app/components/pages/components/popover.jsx @@ -0,0 +1,196 @@ +let React = require('react'); +let { Popover, RadioButton, RaisedButton, SelectField, TextField } = require('material-ui'); +let ComponentDoc = require('../../component-doc'); +let Code = require('popover-code'); +let CodeExample = require('../../code-example/code-example'); + + +let PopoverPage = React.createClass({ + getInitialState() { + return { + selectValue:'1', + textValue:'here is a value', + activePopover:'none', + anchorOrigin:{horizontal:'left', vertical:'bottom'}, + targetOrigin:{horizontal:'left', vertical:'top'}, + } + }, + + render() { + + let componentInfo = [ + { + name: 'Props', + infoArray: [ + { + name: 'anchorOrigin', + type: 'origin object', + header: 'optional', + desc: + 'This is the point on the anchor where the popover targetOrigin will stick to.\n' + + 'Options:\n'+ + 'vertical: [top, middle, bottom]\n' + + 'horizontal: [left, center, right]\n', + }, + { + name: 'targetOrigin', + type: 'origin object', + header: 'optional', + desc: + 'This is the point on the popover which will stick to the anchors origin.' + + 'Options:'+ + 'vertical: [top, middle, bottom]' + + 'horizontal: [left, center, right]', + }, + { + name: 'animated', + type: 'bool', + header: 'default: false', + desc: 'If true, the popover will apply transitions when added it gets added to the DOM.', + }, + { + name: 'autoCloseWhenOffScreen', + type: 'bool', + header: 'default: true', + desc: 'If true, the popover will hide when the anchor scrolls off the screen', + }, + { + name: 'canAutoPosition', + type: 'bool', + header: 'default: true', + desc: 'If true, the popover (potentially) ignores targetOrigin and anchorOrigin to make itself fit on screen,' + + 'which is useful for mobile devices.', + }, + { + name: 'open', + type: 'bool', + header: 'default: false', + desc: 'Controls the visibility of the popover.', + }, + { + name: 'onRequestClose', + type: 'func', + header: 'default: no-op', + desc: 'This is a callback that fires when the popover thinks it should close. (e.g. click-away or scroll off-screen)', + }, + { + name: 'zDepth', + type: 'number (0-5)', + header: 'default: 1', + desc: 'This number represents the zDepth of the paper shadow.', + }, + ], + }, + ]; + + let menuItems = [ + { payload: '1', text: 'Never' }, + { payload: '2', text: 'Every Night' }, + { payload: '3', text: 'Weeknights' }, + { payload: '4', text: 'Weekends' }, + { payload: '5', text: 'Weekly' }, + ]; + + return ( + + + +
+
+ + Note that in this version, the select field causes nasty scrolling, + an upcoming PR will fix, which updates SelectField to use the popover for itself! +
+

Position Options

+

Use the settings below to toggle the positioning of the popovers above

+ Current Settings +
+
+            anchorOrigin: {JSON.stringify(this.state.anchorOrigin)}
+            
+ targetOrigin: {JSON.stringify(this.state.targetOrigin)} +
+

Anchor Origin

+
+ Vertical + + + +
+
+ Horizontal + + + +
+
+
+ +

Target Origin

+
+ Vertical + + + +
+
+ Horizontal + + + +
+ + +
+

Here is an arbitrary popover

+

Hi - here is some content

+ +
+
+
+
+ ); + }, + + show(key, e) { + this.setState({ + activePopover:key, + anchorEl:e.currentTarget, + }); + }, + + closePopover(key) { + if (this.state.activePopover !== key) + return + this.setState({ + activePopover:'none', + }); + }, + + setAnchor(positionElement, position, e) { + let {anchorOrigin} = this.state; + anchorOrigin[positionElement] = position; + + this.setState({ + anchorOrigin:anchorOrigin, + }); + }, + + setTarget(positionElement, position, e) { + let {targetOrigin} = this.state; + targetOrigin[positionElement] = position; + + this.setState({ + targetOrigin:targetOrigin, + }); + }, + +}); + +module.exports = PopoverPage; diff --git a/docs/src/app/components/raw-code/popover-code.txt b/docs/src/app/components/raw-code/popover-code.txt new file mode 100644 index 00000000000000..0480a819eb282e --- /dev/null +++ b/docs/src/app/components/raw-code/popover-code.txt @@ -0,0 +1,90 @@ + +
+
+ +Note that in this version, the select field causes nasty scrolling, +an upcoming PR will fix, which updates SelectField to use the popover for itself! +
+

Position Options

+

Use the settings below to toggle the positioning of the popovers above

+Current Settings +
+
+  anchorOrigin: {JSON.stringify(this.state.anchorOrigin)}
+  
+ targetOrigin: {JSON.stringify(this.state.targetOrigin)} +
+

Anchor Origin

+
+ Vertical + + + +
+
+ Horizontal + + + +
+
+
+ +

Target Origin

+
+ Vertical + + + +
+
+ Horizontal + + + +
+ + +
+

Here is an arbitrary popover

+

Hi - here is some content

+ +
+
+ +show(key, e) { + this.setState({ + activePopover:key, + anchorEl:e.currentTarget, + }); +}, + +closePopover(key) { + if (this.state.activePopover !== key) + return + this.setState({ + activePopover:'none', + }); +}, + +setAnchor(positionElement, position, e) { + let {anchorOrigin} = this.state; + anchorOrigin[positionElement] = position; + + this.setState({ + anchorOrigin:anchorOrigin, + }); +}, + +setTarget(positionElement, position, e) { + let {targetOrigin} = this.state; + targetOrigin[positionElement] = position; + + this.setState({ + targetOrigin:targetOrigin, + }); +}, diff --git a/src/index.js b/src/index.js index 8eac4857d27557..3af30050944d5e 100644 --- a/src/index.js +++ b/src/index.js @@ -37,6 +37,7 @@ module.exports = { Mixins: require('./mixins/'), Overlay: require('./overlay'), Paper: require('./paper'), + Popover:require('./popover/popover'), RadioButton: require('./radio-button'), RadioButtonGroup: require('./radio-button-group'), RaisedButton: require('./raised-button'), diff --git a/src/popover/popover.jsx b/src/popover/popover.jsx new file mode 100644 index 00000000000000..10adda55c517ac --- /dev/null +++ b/src/popover/popover.jsx @@ -0,0 +1,337 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import WindowListenable from '../mixins/window-listenable'; +import RenderToLayer from '../render-to-layer'; +import StylePropable from '../mixins/style-propable'; +import Extend from '../utils/extend'; +import CssEvent from '../utils/css-event'; +import Dom from '../utils/dom'; +import PropTypes from '../utils/prop-types'; +import Transitions from '../styles/transitions'; +import Paper from '../paper'; +import throttle from 'lodash.throttle'; +import AutoPrefix from '../styles/auto-prefix'; +import ContextPure from '../mixins/context-pure'; + +const Popover = React.createClass({ + mixins: [ + ContextPure, + StylePropable, + WindowListenable, + ], + + propTypes: { + anchorEl: React.PropTypes.object, + anchorOrigin: PropTypes.origin, + animated: React.PropTypes.bool, + autoCloseWhenOffScreen: React.PropTypes.bool, + canAutoPosition: React.PropTypes.bool, + children: React.PropTypes.object, + className: React.PropTypes.string, + open: React.PropTypes.bool, + onRequestClose: React.PropTypes.func, + style: React.PropTypes.object, + targetOrigin: PropTypes.origin, + zDepth: PropTypes.zDepth, + }, + + getDefaultProps() { + return { + anchorOrigin: { + vertical:'bottom', + horizontal:'left', + }, + animated:true, + autoCloseWhenOffScreen:true, + canAutoPosition:true, + onRequestClose: () => {}, + open:false, + style: {}, + targetOrigin: { + vertical:'top', + horizontal:'left', + }, + zDepth: 1, + }; + }, + + getInitialState() { + this.setPlacementThrottled = throttle(this.setPlacement, 100); + return { + open: false, + }; + }, + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + windowListeners: { + resize: 'setPlacementThrottled', + scroll: 'setPlacementThrottled', + }, + + componentWillReceiveProps(nextProps) { + if (nextProps.open !== this.state.open) { + if (nextProps.open) + this._showInternal(nextProps.anchorEl); + else + this._hideInternal(); + } + }, + + componentDidUpdate() { + this.setPlacement(); + }, + + render() { + return ; + }, + + renderLayer() { + let { + animated, + targetOrigin, + className, + zDepth, + } = this.props; + + const anchorEl = this.props.anchorEl || this.anchorEl; + let anchor = this.getAnchorPosition(anchorEl); + let horizontal = targetOrigin.horizontal.replace("middle", "vertical"); + + let wrapperStyle = { + position: 'fixed', + top: anchor.top, + left: anchor.left, + zIndex: 20, + opacity:1, + overflow:'auto', + maxHeight:'100%', + transform:'scale(0,0)', + transformOrigin: `${horizontal} ${targetOrigin.vertical}`, + transition: animated ? Transitions.easeOut('500ms', ['transform', 'opacity']) : null, + }; + wrapperStyle = this.mergeAndPrefix(wrapperStyle, this.props.style); + + let horizontalAnimation = { + maxHeight:'100%', + overflowY:'auto', + transform:'scaleX(0)', + opacity:1, + transition: animated ? Transitions.easeOut('250ms', ['transform', 'opacity']): null, + transformOrigin: `${horizontal} ${targetOrigin.vertical}`, + }; + + let verticalAnimation = { + opacity:1, + transform:'scaleY(0)', + transformOrigin: `${horizontal} ${targetOrigin.vertical}`, + transition: animated ? Transitions.easeOut('500ms', ['transform', 'opacity']) : null, + } + + return ( + +
+
+
+ {this.props.children} +
+
+
+
+ ); + }, + + requestClose() { + if (this.props.onRequestClose) + this.props.onRequestClose(); + }, + + componentClickAway(e) { + if (e.defaultPrevented) { + return; + } + this._hideInternal(); + }, + + _resizeAutoPosition() { + this.setPlacement(); + }, + + _showInternal(anchorEl) { + this.anchorEl = anchorEl || this.props.anchorEl; + this.setState({open: true}); + const popOverShowEvent = new CustomEvent('popOverOnShow', {detail: this}); + document.dispatchEvent(popOverShowEvent); + + }, + + _hideInternal() { + if (!this.state.open) { + return; + } + this.setState({ + open: false, + }, () => { + this._animateClose(); + const popOverHideEvent = new CustomEvent('popOverOnHide'); + document.dispatchEvent(popOverHideEvent); + }); + }, + + _animateClose() { + if (!this.refs.layer.getLayer()){ + return; + } + let el = this.refs.layer.getLayer().children[0]; + this._animate(el, false); + }, + + _animateOpen(el) { + this._animate(el, true); + }, + + _animate(el) { + let value = '0'; + const inner = el.children[0]; + const innerInner = inner.children[0]; + const innerInnerInner = innerInner.children[0]; + const rootStyle = inner.style; + const innerStyle = innerInner.style; + + if (this.state.open) { + value = '1'; + } + else { + CssEvent.onTransitionEnd(inner, () => { + if (!this.state.open) + this.requestClose(); + }); + } + + AutoPrefix.set(el.style, 'transform', `scale(${value},${value})`); + AutoPrefix.set(innerInner.style, 'transform', `scaleX(${value})`); + AutoPrefix.set(innerInnerInner.style, 'transform', `scaleY(${value})`); + AutoPrefix.set(rootStyle, 'opacity', value); + AutoPrefix.set(innerStyle, 'opacity', value); + AutoPrefix.set(innerInnerInner, 'opacity', value); + AutoPrefix.set(el.style, 'opacity', value); + }, + + getAnchorPosition(el) { + if (!el) + el = ReactDOM.findDOMNode(this); + + const rect = el.getBoundingClientRect(); + const a = { + top: rect.top, + left: rect.left, + width: el.offsetWidth, + height: el.offsetHeight, + }; + + a.right = a.left + a.width; + a.bottom = a.top + a.height; + a.middle = a.left + a.width / 2; + a.center = a.top + a.height / 2; + return a; + }, + + getTargetPosition(targetEl) { + return { + top:0, + center: targetEl.offsetHeight / 2, + bottom: targetEl.offsetHeight, + left:0, + middle:targetEl.offsetWidth / 2, + right:targetEl.offsetWidth, + } + }, + + setPlacement(el) { + if (!this.state.open) + return; + + const anchorEl = this.props.anchorEl || this.anchorEl; + + if (!this.refs.layer.getLayer()) + return; + + const targetEl = this.refs.layer.getLayer().children[0]; + if (!targetEl) { + return {}; + } + + let {targetOrigin, anchorOrigin} = this.props; + + let anchor = this.getAnchorPosition(anchorEl); + let target = this.getTargetPosition(targetEl); + + let targetPosition = { + top: anchor[anchorOrigin.vertical] - target[targetOrigin.vertical], + left: anchor[anchorOrigin.horizontal] - target[targetOrigin.horizontal], + } + + if (this.props.autoCloseWhenOffScreen) + this.autoCloseWhenOffScreen(anchor); + + if (this.props.canAutoPosition) { + target = this.getTargetPosition(targetEl); // update as height may have changed + targetPosition = this.applyAutoPositionIfNeeded(anchor, target, targetOrigin, anchorOrigin, targetPosition); + } + + + targetEl.style.top = targetPosition.top + 'px'; + targetEl.style.left = targetPosition.left + 'px'; + this._animateOpen(targetEl) + }, + + autoCloseWhenOffScreen(anchorPosition) { + if (!this.props.autoCloseWhenOffScreen) + return; + if (anchorPosition.top < 0 + || anchorPosition.top > window.innerHeight + || anchorPosition.left < 0 + || anchorPosition.left > window.innerWith + ) + this._hideInternal(); + }, + + applyAutoPositionIfNeeded(anchor, target, targetOrigin, anchorOrigin, targetPosition) { + if (targetPosition.top + target.bottom > window.innerHeight) { + let positions = ["top", "center", "bottom"] + .filter((position) => position !== targetOrigin.vertical); + + let newTop = anchor[anchorOrigin.vertical] - target[positions[0]]; + if (newTop + target.bottom <= window.innerHeight) + targetPosition.top = Math.max(0, newTop); + else { + newTop = anchor[anchorOrigin.vertical] - target[positions[1]]; + if (newTop + target.bottom <= window.innerHeight) + targetPosition.top = Math.max(0, newTop); + } + } + if (targetPosition.left + target.right > window.innerWidth) { + let positions = ["left", "middle", "right"] + .filter((position) => position !== targetOrigin.horizontal); + + let newLeft = anchor[anchorOrigin.horizontal] - target[positions[0]]; + if (newLeft + target.right <= window.innerWidth) + targetPosition.left = Math.max(0, newLeft); + else { + newLeft = anchor[anchorOrigin.horizontal] - target[positions[1]]; + if (newLeft + target.right <= window.innerWidth) + targetPosition.left = Math.max(0, newLeft); + } + } + return targetPosition; + }, + +}); + +export default Popover; diff --git a/src/utils/prop-types.js b/src/utils/prop-types.js index 7703e1819fced5..72a849049e47a4 100644 --- a/src/utils/prop-types.js +++ b/src/utils/prop-types.js @@ -1,5 +1,8 @@ let React = require('react'); +const horizontal = React.PropTypes.oneOf(['left', 'middle', 'right']); +const vertical = React.PropTypes.oneOf(['top', 'center', 'bottom']); + module.exports = { corners: React.PropTypes.oneOf([ @@ -9,6 +12,15 @@ module.exports = { 'top-right', ]), + horizontal:horizontal, + + vertical:vertical, + + origin: React.PropTypes.shape({ + horizontal: horizontal, + vertical:vertical, + }), + cornersAndCenter: React.PropTypes.oneOf([ 'bottom-center', 'bottom-left',