From 9884091894083a8c5e5d98b0bdd0e697b06adc3a Mon Sep 17 00:00:00 2001 From: Chris McVittie Date: Fri, 16 Oct 2015 09:20:40 +0100 Subject: [PATCH] Menu/Popover/IconMenu/Dialog Portal updates --- docs/src/app/app-routes.jsx | 2 + docs/src/app/components/pages/components.jsx | 1 + .../components/pages/components/dialog.jsx | 3 + .../pages/components/icon-menus.jsx | 196 +++++++++- .../app/components/pages/components/menus.jsx | 13 +- .../components/pages/components/popover.jsx | 157 ++++++++ .../app/components/raw-code/popover-code.txt | 16 + package.json | 4 + src/date-picker/date-picker-dialog.jsx | 23 +- src/dialog.jsx | 209 ++++++----- src/drop-down-menu.jsx | 97 ++--- src/index.js | 1 + src/left-nav.jsx | 3 +- src/lists/list-item.jsx | 2 +- src/menu/menu.jsx | 2 +- src/menus/icon-menu.jsx | 95 ++--- src/menus/menu-item.jsx | 56 +++ src/menus/menu.jsx | 10 +- src/mixins/render-to-layer.js | 119 ++++++ src/overlay.jsx | 23 +- src/popover/popover.jsx | 346 ++++++++++++++++++ src/select-field.jsx | 24 +- src/table/table-body.jsx | 25 +- src/time-picker/clock-hours.jsx | 9 +- src/time-picker/clock.jsx | 4 +- src/time-picker/time-picker-dialog.jsx | 49 +-- src/time-picker/time-picker.jsx | 21 +- src/utils/prop-types.js | 18 + 28 files changed, 1252 insertions(+), 276 deletions(-) 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/mixins/render-to-layer.js create mode 100644 src/popover/popover.jsx diff --git a/docs/src/app/app-routes.jsx b/docs/src/app/app-routes.jsx index fe37b92430b23b..183fdc9e6e7602 100644 --- a/docs/src/app/app-routes.jsx +++ b/docs/src/app/app-routes.jsx @@ -36,6 +36,7 @@ const Lists = require('./components/pages/components/lists'); const Menus = require('./components/pages/components/menus'); const Paper = require('./components/pages/components/paper'); const Progress = require('./components/pages/components/progress'); +const Popover = require('./components/pages/components/popover'); const RefreshIndicator = require('./components/pages/components/refresh-indicator'); const Sliders = require('./components/pages/components/sliders'); const Snackbar = require('./components/pages/components/snackbar'); @@ -90,6 +91,7 @@ const AppRoutes = ( + diff --git a/docs/src/app/components/pages/components.jsx b/docs/src/app/components/pages/components.jsx index d7069c034cdf97..c538094572209e 100644 --- a/docs/src/app/components/pages/components.jsx +++ b/docs/src/app/components/pages/components.jsx @@ -22,6 +22,7 @@ export default class Components extends React.Component { { route: '/components/paper', text: 'Paper'}, { route: '/components/progress', text: 'Progress'}, { route: '/components/refresh-indicator', text: 'Refresh Indicator'}, + { route: '/components/popover', text: 'Popover'}, { route: '/components/sliders', text: 'Sliders'}, { route: '/components/switches', text: 'Switches'}, { route: '/components/snackbar', text: 'Snackbar'}, diff --git a/docs/src/app/components/pages/components/dialog.jsx b/docs/src/app/components/pages/components/dialog.jsx index 2ef62cc95b6f15..d834bf2d4b5a31 100644 --- a/docs/src/app/components/pages/components/dialog.jsx +++ b/docs/src/app/components/pages/components/dialog.jsx @@ -175,6 +175,7 @@ export default class DialogPage extends React.Component { title="Dialog With Standard Actions" actions={standardActions} actionFocus="submit" + open={false} modal={this.state.modal}> The actions in this window are created from the json that's passed in. @@ -183,6 +184,7 @@ export default class DialogPage extends React.Component { ref="customDialog" title="Dialog With Custom Actions" actions={customActions} + open={false} modal={this.state.modal}> The actions in this window were passed in as an array of react objects. @@ -197,6 +199,7 @@ export default class DialogPage extends React.Component { title="Dialog With Scrollable Content" actions={scrollableCustomActions} modal={this.state.modal} + open={false} autoDetectWindowHeight={true} autoScrollBodyContent={true}>
diff --git a/docs/src/app/components/pages/components/icon-menus.jsx b/docs/src/app/components/pages/components/icon-menus.jsx index 2225cabcf4533c..05ea44ed2b8878 100644 --- a/docs/src/app/components/pages/components/icon-menus.jsx +++ b/docs/src/app/components/pages/components/icon-menus.jsx @@ -151,7 +151,10 @@ export default class IconMenus extends React.Component {

Menu with various open directions

- + @@ -161,7 +164,8 @@ export default class IconMenus extends React.Component { + anchorOrigin={{horizontal:'left', vertical:'top'}} + targetOrigin={{horizontal:'left', vertical:'top'}}> @@ -171,7 +175,8 @@ export default class IconMenus extends React.Component { + anchorOrigin={{horizontal:'right', vertical:'bottom'}} + targetOrigin={{horizontal:'right', vertical:'bottom'}}> @@ -181,7 +186,9 @@ export default class IconMenus extends React.Component { + anchorOrigin={{horizontal:'left', vertical:'bottom'}} + targetOrigin={{horizontal:'left', vertical:'bottom'}} + > @@ -195,7 +202,6 @@ export default class IconMenus extends React.Component { @@ -219,7 +225,6 @@ export default class IconMenus extends React.Component { iconButtonElement={filterButtonElement} multiple={true} onChange={this._handleIconMenuMultiChange} - openDirection="bottom-right" value={this.state.iconMenuMultiValue}> @@ -233,8 +238,7 @@ export default class IconMenus extends React.Component {

Menu Item variations

+ iconButtonElement={iconButtonElement}> @@ -245,8 +249,7 @@ export default class IconMenus extends React.Component { + iconButtonElement={iconButtonElement}> } /> } /> } /> @@ -263,7 +266,6 @@ export default class IconMenus extends React.Component { @@ -321,7 +323,6 @@ export default class IconMenus extends React.Component { @@ -379,7 +380,8 @@ export default class IconMenus extends React.Component { @@ -437,7 +439,173 @@ export default class IconMenus extends React.Component { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/app/components/pages/components/menus.jsx b/docs/src/app/components/pages/components/menus.jsx index d8c51fec34f838..c27dad9e3e6e25 100644 --- a/docs/src/app/components/pages/components/menus.jsx +++ b/docs/src/app/components/pages/components/menus.jsx @@ -292,7 +292,18 @@ export default class MenusPage extends React.Component { - } /> + } menuItems={[ + } menuItems={[ + , + , + , + , + ]}/>, + , + , + , + ]}> + 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..61d378039e41df --- /dev/null +++ b/docs/src/app/components/pages/components/popover.jsx @@ -0,0 +1,157 @@ +let React = require('react'); +let { Popover, 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', + } + }, + + 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. In order for transitions ' + + 'to work, wrap the popover inside a ReactTransitionGroup.', + }, + { + 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) ignore targetOrigin and anchorOrigin to make itself fit on screen,' + + 'which is useful for mobile devices.', + }, + { + name: 'childContextTypes', + type: 'object', + header: 'default: true', + desc: 'React 0.13 hack to allow contexts to be passed through to the popover. This is necessary because it' + + 'renders outside the owner in the DOM.', + }, + { + name: 'zDepth', + type: 'number (0-5)', + header: 'default: 1', + desc: 'This number represents the zDepth of the paper shadow.', + }, + ], + }, + { + name: 'Methods', + infoArray: [ + { + name: 'show', + header: 'Popover.show(anchor)', + desc: 'Show the popover adjacent or over the anchor.', + }, + { + name: 'hide', + header: 'Popover.hide()', + desc: 'Hide the popover.', + }, + { + name: 'toggle', + header: 'Popover.toggle(anchor)', + desc: 'Show or hide the popover adjacent or over the anchor.', + }, + ], + }, + ]; + + let menuItems = [ + { payload: '1', text: 'Never' }, + { payload: '2', text: 'Every Night' }, + { payload: '3', text: 'Weeknights' }, + { payload: '4', text: 'Weekends' }, + { payload: '5', text: 'Weekly' }, + ]; + + return ( + + + + Click on me to show a popover + +
+ + + Click on me to show a with a nested popover (SelectField) + + + +
+

Here is an arbitrary popover

+

Hi - here is some content

+ +
+
+ +
+

Here is an arbitrary popover

+

Hi - here is some content

+ + +
+
+
+
+ ); + }, + + show(ref, e) { + this.refs[ref].show(e.currentTarget); + }, + + onChangeSelect(e) { + this.setState({selectValue:e.target.value}); + }, + onChangeText(e) { + this.setState({textValue:e.target.value}); + }, + + +}); + +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..192966e079c901 --- /dev/null +++ b/docs/src/app/components/raw-code/popover-code.txt @@ -0,0 +1,16 @@ + + Click on me to show a popover + + + +
+

Here is an arbitrary popover

+

Hi - here is some content

+
+
+ +show(e) { + this.refs.pop.show(e.currentTarget) +} \ No newline at end of file diff --git a/package.json b/package.json index 49bc6507ab39a8..e0a5088b3410d4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "url": "https://github.com/callemall/material-ui/issues" }, "homepage": "http://material-ui.com/", + "dependencies": { + "lodash.throttle": "~3.0.4", + "lodash.debounce": "~3.1.1" + }, "peerDependencies": { "react": "^0.14.0", "react-dom": "^0.14.0", diff --git a/src/date-picker/date-picker-dialog.jsx b/src/date-picker/date-picker-dialog.jsx index 6800a1eee12615..1b49f8b4e634e3 100644 --- a/src/date-picker/date-picker-dialog.jsx +++ b/src/date-picker/date-picker-dialog.jsx @@ -50,6 +50,7 @@ const DatePickerDialog = React.createClass({ onClickAway: React.PropTypes.func, onDismiss: React.PropTypes.func, onShow: React.PropTypes.func, + open:React.PropTypes.bool, shouldDisableDate: React.PropTypes.func, showYearSelector: React.PropTypes.bool, }, @@ -69,6 +70,7 @@ const DatePickerDialog = React.createClass({ return { DateTimeFormat: DateTime.DateTimeFormat, locale: 'en-US', + open:false, wordings: { ok: 'OK', cancel: 'Cancel', @@ -83,6 +85,7 @@ const DatePickerDialog = React.createClass({ getInitialState() { return { isCalendarActive: false, + open:this.props.open, muiTheme: this.context.muiTheme ? this.context.muiTheme : ThemeManager.getMuiTheme(DefaultRawTheme), }; }, @@ -159,6 +162,7 @@ const DatePickerDialog = React.createClass({ onDismiss={this._handleDialogDismiss} onShow={this._handleDialogShow} onClickAway={this._handleDialogClickAway} + open={this.state.open} repositionOnUpdate={false}> { - this.setState({ - isCalendarActive: false, - }); + this.setState({ + isCalendarActive: false, + open:false, }); - if (this.props.onDismiss) this.props.onDismiss(); }, _handleDialogClickAway() { - CssEvent.onTransitionEnd(ReactDOM.findDOMNode(this.refs.dialog), () => { - this.setState({ - isCalendarActive: false, - }); + this.setState({ + isCalendarActive: false, + open:false, }); if (this.props.onClickAway) this.props.onClickAway(); diff --git a/src/dialog.jsx b/src/dialog.jsx index 77ee8650c49c27..d1f3e40a5a5297 100644 --- a/src/dialog.jsx +++ b/src/dialog.jsx @@ -5,12 +5,12 @@ const CssEvent = require('./utils/css-event'); const KeyCode = require('./utils/key-code'); const Transitions = require('./styles/transitions'); const StylePropable = require('./mixins/style-propable'); +const RenderToLayer = require('./mixins/render-to-layer'); const FlatButton = require('./flat-button'); const Overlay = require('./overlay'); const Paper = require('./paper'); const DefaultRawTheme = require('./styles/raw-themes/light-raw-theme'); const ThemeManager = require('./styles/theme-manager'); - const ReactTransitionGroup = require('react-addons-transition-group'); const TransitionItem = React.createClass({ @@ -45,7 +45,7 @@ const TransitionItem = React.createClass({ this.setState({muiTheme: newMuiTheme}); }, - componentWillEnter(callback) { + componentDidAppear(callback) { let spacing = this.state.muiTheme.rawTheme.spacing; this.setState({ @@ -110,7 +110,6 @@ let Dialog = React.createClass({ contentClassName: React.PropTypes.string, contentStyle: React.PropTypes.object, modal: React.PropTypes.bool, - openImmediately: React.PropTypes.bool, onClickAway: React.PropTypes.func, onDismiss: React.PropTypes.func, onShow: React.PropTypes.func, @@ -129,13 +128,14 @@ let Dialog = React.createClass({ autoScrollBodyContent: false, actions: [], modal: false, + open:true, repositionOnUpdate: true, }; }, getInitialState() { return { - open: this.props.openImmediately || false, + open: this.props.open, muiTheme: this.context.muiTheme ? this.context.muiTheme : ThemeManager.getMuiTheme(DefaultRawTheme), }; }, @@ -149,10 +149,6 @@ let Dialog = React.createClass({ componentDidMount() { this._positionDialog(); - if (this.props.openImmediately) { - this.refs.dialogOverlay.preventScrolling(); - this._onShow(); - } }, componentDidUpdate() { @@ -168,10 +164,8 @@ let Dialog = React.createClass({ WebkitTapHighlightColor: 'rgba(0,0,0,0)', zIndex: 10, top: 0, - left: -10000, width: '100%', height: '100%', - transition: Transitions.easeOut('0ms', 'left', '450ms'), }; let content = { @@ -201,14 +195,6 @@ let Dialog = React.createClass({ fontWeight: '400', }; - - if (this.state.open) { - main = this.mergeStyles(main, { - left: 0, - transition: Transitions.easeOut('0ms', 'left', '0ms'), - }); - } - return { main: this.mergeStyles(main, this.props.style), content: this.mergeStyles(content, this.props.contentStyle), @@ -231,12 +217,11 @@ let Dialog = React.createClass({

{this.props.title}

: this.props.title; } - + let {open} = this.props; return (
- - {this.state.open && - + {open && {this.props.children}
- {actions} }
); }, - isOpen() { - return this.state.open; - }, - - dismiss() { - CssEvent.onTransitionEnd(ReactDOM.findDOMNode(this), () => { - this.refs.dialogOverlay.allowScrolling(); - }); - - this.setState({ open: false }); - this._onDismiss(); - }, - - show() { - this.refs.dialogOverlay.preventScrolling(); - this.setState({ open: true }, this._onShow); - }, - _getAction(actionJSON, key) { let styles = {marginRight: 8}; let props = { @@ -290,7 +255,7 @@ let Dialog = React.createClass({ actionJSON.onTouchTap.call(undefined); } if (!(actionJSON.onClick || actionJSON.onTouchTap)) { - this.dismiss(); + this.props.onRequestClose(); } }, label: actionJSON.text, @@ -330,6 +295,9 @@ let Dialog = React.createClass({ if (!React.isValidElement(currentAction)) { currentAction = this._getAction(currentAction, i); } + else { + currentAction = React.cloneElement(currentAction, {key:i}); + } actionObjects.push(currentAction); } @@ -345,46 +313,37 @@ let Dialog = React.createClass({ }, _positionDialog() { - if (this.state.open) { - let clientHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; - let container = ReactDOM.findDOMNode(this); - let dialogWindow = ReactDOM.findDOMNode(this.refs.dialogWindow); - let dialogContent = ReactDOM.findDOMNode(this.refs.dialogContent); - let minPaddingTop = 16; - - //Reset the height in case the window was resized. - dialogWindow.style.height = ''; - dialogContent.style.height = ''; - - let dialogWindowHeight = dialogWindow.offsetHeight; - let paddingTop = ((clientHeight - dialogWindowHeight) / 2) - 64; - if (paddingTop < minPaddingTop) paddingTop = minPaddingTop; - - //Vertically center the dialog window, but make sure it doesn't - //transition to that position. - if (this.props.repositionOnUpdate || !container.style.paddingTop) { - container.style.paddingTop = paddingTop + 'px'; - } + let clientHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; + let container = ReactDOM.findDOMNode(this); + let dialogWindow = ReactDOM.findDOMNode(this.refs.dialogWindow); + let dialogContent = ReactDOM.findDOMNode(this.refs.dialogContent); + let minPaddingTop = 16; + + //Reset the height in case the window was resized. + dialogWindow.style.height = ''; + dialogContent.style.height = ''; + + let dialogWindowHeight = dialogWindow.offsetHeight; + let paddingTop = ((clientHeight - dialogWindowHeight) / 2) - 64; + if (paddingTop < minPaddingTop) paddingTop = minPaddingTop; + + //Vertically center the dialog window, but make sure it doesn't + //transition to that position. + if (this.props.repositionOnUpdate || !container.style.paddingTop) { + container.style.paddingTop = paddingTop + 'px'; + } - // Force a height if the dialog is taller than clientHeight - if (this.props.autoDetectWindowHeight || this.props.autoScrollBodyContent) { - let styles = this.getStyles(); - let maxDialogContentHeight = clientHeight - 2 * (styles.body.padding + 64); + // Force a height if the dialog is taller than clientHeight + if (this.props.autoDetectWindowHeight || this.props.autoScrollBodyContent) { + let styles = this.getStyles(); + let maxDialogContentHeight = clientHeight - 2 * (styles.body.padding + 64); - if (this.props.title) maxDialogContentHeight -= dialogContent.previousSibling.offsetHeight; - if (this.props.actions.length) maxDialogContentHeight -= dialogContent.nextSibling.offsetHeight; + if (this.props.title) maxDialogContentHeight -= dialogContent.previousSibling.offsetHeight; + if (this.props.actions.length) maxDialogContentHeight -= dialogContent.nextSibling.offsetHeight; - dialogContent.style.maxHeight = maxDialogContentHeight + 'px'; - } + dialogContent.style.maxHeight = maxDialogContentHeight + 'px'; } - }, - - _onShow() { - if (this.props.onShow) this.props.onShow(); - }, - _onDismiss() { - if (this.props.onDismiss) this.props.onDismiss(); }, _handleOverlayTouchTap(e) { @@ -392,17 +351,103 @@ let Dialog = React.createClass({ e.stopPropagation(); } else { - this.dismiss(); + this.props.onRequestClose(); if (this.props.onClickAway) this.props.onClickAway(); } }, _handleWindowKeyUp(e) { if (e.keyCode === KeyCode.ESC && !this.props.modal) { - this.dismiss(); + this.props.onRequestClose(); + } + }, + +}); + + +const DialogRenderToLayer = React.createClass({ + mixins:[RenderToLayer], + + propTypes: { + actions: React.PropTypes.array, + autoDetectWindowHeight: React.PropTypes.bool, + autoScrollBodyContent: React.PropTypes.bool, + bodyStyle: React.PropTypes.object, + childContextTypes: React.PropTypes.object, + contentClassName: React.PropTypes.string, + contentStyle: React.PropTypes.object, + modal: React.PropTypes.bool, + onClickAway: React.PropTypes.func, + onDismiss: React.PropTypes.func, + onShow: React.PropTypes.func, + openByProps: React.PropTypes.bool, + repositionOnUpdate: React.PropTypes.bool, + title: React.PropTypes.node, + }, + + getInitialState() { + return { + open:this.props.open, + }; + }, + + getDefaultProps() { + return { + open:true, + openByProps:true, + childContextTypes: { + muiTheme:React.PropTypes.object, + }, } }, + componentWillReceiveProps(nextProps) { + if (!this.props.openByProps) { + return; + } + if (nextProps.open !== this.state.open) { + if (nextProps.open) + this.show(); + else + this.dismiss(); + } + }, + + render() { + return null; + }, + + renderLayer() { + const open = this.state.open || this.props.open; + const wrapperStyle = {position:'fixed', top:0, left:0, zIndex:20}; + return ( +
+ +
+ ); + }, + + show() { + this.setState({open:true}); + this._onShow(); + }, + + dismiss() { + this.setState({open:false}); + }, + + _onShow() { + if (this.props.onShow) this.props.onShow(); + }, + + layerWillUnmount() { + if (this.props.onDismiss) this.props.onDismiss(); + }, + + isOpen() { + return this.state.open || this.props.open; + }, + }); -module.exports = Dialog; +module.exports = DialogRenderToLayer; diff --git a/src/drop-down-menu.jsx b/src/drop-down-menu.jsx index 610b02a2fe2d94..2b2db2c7f347d7 100644 --- a/src/drop-down-menu.jsx +++ b/src/drop-down-menu.jsx @@ -5,10 +5,12 @@ const Transitions = require('./styles/transitions'); const KeyCode = require('./utils/key-code'); const DropDownArrow = require('./svg-icons/navigation/arrow-drop-down'); const Paper = require('./paper'); -const Menu = require('./menu/menu'); +const Menu = require('./menus/menu'); +const MenuItem = require('./menus/menu-item'); const ClearFix = require('./clearfix'); const DefaultRawTheme = require('./styles/raw-themes/light-raw-theme'); const ThemeManager = require('./styles/theme-manager'); +const Popover = require('./popover/popover'); const DropDownMenu = React.createClass({ @@ -102,8 +104,10 @@ const DropDownMenu = React.createClass({ }, control: { cursor: disabled ? 'not-allowed' : 'pointer', - position: 'static', + position: 'relative', height: '100%', + width:'100%', + }, controlBg: { transition: Transitions.easeOut(), @@ -121,18 +125,19 @@ const DropDownMenu = React.createClass({ label: { transition: Transitions.easeOut(), lineHeight: spacing.desktopToolbarHeight + 'px', - position: 'absolute', + position: 'relative', paddingLeft: spacing.desktopGutter, - top: 0, + paddingRight: spacing.desktopGutter * 2, opacity: 1, + width:'auto', color: disabled ? this.state.muiTheme.rawTheme.palette.disabledColor : this.state.muiTheme.rawTheme.palette.textColor, }, underline: { borderTop: 'solid 1px ' + accentColor, - margin: '-1px ' + spacing.desktopGutter + 'px', + margin: '-9px ' + spacing.desktopGutter + 'px', }, menu: { - zIndex: zIndex + 1, + position:'relative', }, menuItem: { paddingRight: spacing.iconSize + @@ -143,20 +148,12 @@ const DropDownMenu = React.createClass({ whiteSpace: 'nowrap', }, rootWhenOpen: { - opacity: 1, + opacity:1, }, labelWhenOpen: { opacity: 0, top: spacing.desktopToolbarHeight / 2, }, - overlay: { - height: '100%', - width: '100%', - position: 'fixed', - top: 0, - left: 0, - zIndex: zIndex, - }, }; return styles; @@ -213,10 +210,12 @@ const DropDownMenu = React.createClass({ displayValue = selectedItem[displayMember]; } - let menuItems = this.props.menuItems.map((item) => { - item.text = item[displayMember]; - item.payload = item[valueMember]; - return item; + let menuItems = this.props.menuItems.map((item, idx) => { + return }); return ( @@ -232,37 +231,39 @@ const DropDownMenu = React.createClass({ this.state.open && styles.rootWhenOpen, style)} > - - -
+
+
{displayValue}
- - - - {this.state.open &&
} +
+ + + {menuItems} + +
); }, _setWidth() { let el = ReactDOM.findDOMNode(this); - let menuItemsDom = ReactDOM.findDOMNode(this.refs.menuItems); if (!this.props.style || !this.props.style.hasOwnProperty('width')) { el.style.width = 'auto'; - el.style.width = menuItemsDom.offsetWidth + 'px'; } }, @@ -278,7 +279,10 @@ const DropDownMenu = React.createClass({ _onControlClick() { if (!this.props.disabled) { - this.setState({ open: !this.state.open }); + this.setState({ + open: !this.state.open, + anchorEl: ReactDOM.findDOMNode(this), + }); } }, @@ -314,7 +318,7 @@ const DropDownMenu = React.createClass({ e.preventDefault(); }, - _onMenuItemClick(e, key, payload) { + _onMenuItemClick(key, payload, e) { if (this.props.onChange && this.state.selectedIndex !== key) { let selectedItem = this.props.menuItems[key]; if (selectedItem) { @@ -327,17 +331,22 @@ const DropDownMenu = React.createClass({ else { this.props.onChange(e, key, payload); } + this._onMenuRequestClose(); } this.setState({ selectedIndex: key, value: e.target.value, open: false, + anchorEl:null, }); }, _onMenuRequestClose() { - this.setState({open:false}); + this.setState({ + open:false, + anchorEl:null, + }); }, _selectPreviousItem() { @@ -348,12 +357,6 @@ const DropDownMenu = React.createClass({ this.setState({selectedIndex: Math.min(this.state.selectedIndex + 1, this.props.menuItems.length - 1)}); }, - _handleOverlayTouchTap() { - this.setState({ - open: false, - }); - }, - _isControlled() { return this.props.hasOwnProperty('value') || this.props.hasOwnProperty('valueLink'); diff --git a/src/index.js b/src/index.js index 31edeb6eae7fdb..bee3d60bfc87a9 100644 --- a/src/index.js +++ b/src/index.js @@ -36,6 +36,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/left-nav.jsx b/src/left-nav.jsx index 4178fc29841c2c..fbc63ddad0ed3b 100644 --- a/src/left-nav.jsx +++ b/src/left-nav.jsx @@ -11,7 +11,8 @@ const Transitions = require('./styles/transitions'); const WindowListenable = require('./mixins/window-listenable'); const Overlay = require('./overlay'); const Paper = require('./paper'); -const Menu = require('./menu/menu'); +const Menu = require('./menus/menu'); +const MenuItem = require('./menus/menu-item'); const DefaultRawTheme = require('./styles/raw-themes/light-raw-theme'); const ThemeManager = require('./styles/theme-manager'); diff --git a/src/lists/list-item.jsx b/src/lists/list-item.jsx index 589d9bc29072f3..53dd2b57b2663f 100644 --- a/src/lists/list-item.jsx +++ b/src/lists/list-item.jsx @@ -339,7 +339,7 @@ const ListItem = React.createClass({ const nestedList = nestedItems.length ? ( - {nestedItems} + {React.Children.toArray(nestedItems)} ) : undefined; diff --git a/src/menu/menu.jsx b/src/menu/menu.jsx index 9c27d8f42c925d..bdc39584134934 100644 --- a/src/menu/menu.jsx +++ b/src/menu/menu.jsx @@ -498,7 +498,7 @@ const Menu = React.createClass({ CssEvent.onTransitionEnd(el, () => { //Make sure the menu is open before setting the overflow. //This is to accout for fast clicks - if (this.props.visible) container.style.overflow = 'visible'; + if (this.props.visible) container.style.overflow = 'auto'; el.style.transition = null; el.focus(); }); diff --git a/src/menus/icon-menu.jsx b/src/menus/icon-menu.jsx index f1a0fdcdc6fca7..e2949b5c6937f5 100644 --- a/src/menus/icon-menu.jsx +++ b/src/menus/icon-menu.jsx @@ -1,42 +1,43 @@ const React = require('react'); const ReactDOM = require('react-dom'); const ReactTransitionGroup = require('react-addons-transition-group'); -const ClickAwayable = require('../mixins/click-awayable'); const StylePropable = require('../mixins/style-propable'); const Events = require('../utils/events'); const PropTypes = require('../utils/prop-types'); const Menu = require('../menus/menu'); const DefaultRawTheme = require('../styles/raw-themes/light-raw-theme'); const ThemeManager = require('../styles/theme-manager'); +const Popover = require('../popover/popover'); const IconMenu = React.createClass({ - mixins: [StylePropable, ClickAwayable], + mixins: [StylePropable], contextTypes: { muiTheme: React.PropTypes.object, }, propTypes: { + anchorOrigin: PropTypes.origin, closeOnItemTouchTap: React.PropTypes.bool, iconButtonElement: React.PropTypes.element.isRequired, iconStyle: React.PropTypes.object, - openDirection: PropTypes.corners, + menuOverlapsIcon: React.PropTypes.bool, + menuStyle: React.PropTypes.object, onItemTouchTap: React.PropTypes.func, onKeyboardFocus: React.PropTypes.func, onMouseDown: React.PropTypes.func, - onMouseLeave: React.PropTypes.func, onMouseEnter: React.PropTypes.func, + onMouseLeave: React.PropTypes.func, onMouseUp: React.PropTypes.func, onTouchTap: React.PropTypes.func, - menuStyle: React.PropTypes.object, + targetOrigin: PropTypes.origin, touchTapCloseDelay: React.PropTypes.number, }, getDefaultProps() { return { closeOnItemTouchTap: true, - openDirection: 'bottom-left', onItemTouchTap: () => {}, onKeyboardFocus: () => {}, onMouseDown: () => {}, @@ -44,6 +45,15 @@ const IconMenu = React.createClass({ onMouseEnter: () => {}, onMouseUp: () => {}, onTouchTap: () => {}, + anchorOrigin: { + vertical:'top', + horizontal:'left', + }, + targetOrigin: { + vertical:'top', + horizontal:'left', + }, + menuOverlapsIcon:true, touchTapCloseDelay: 200, }; }, @@ -79,17 +89,13 @@ const IconMenu = React.createClass({ if (this._timeout) clearTimeout(this._timeout); }, - componentClickAway() { - this.close(); - }, - render() { let { + anchorOrigin, className, closeOnItemTouchTap, iconButtonElement, iconStyle, - openDirection, onItemTouchTap, onKeyboardFocus, onMouseDown, @@ -97,14 +103,14 @@ const IconMenu = React.createClass({ onMouseEnter, onMouseUp, onTouchTap, + menuOverlapsIcon, menuStyle, style, + targetOrigin, ...other, } = this.props; - let open = this.state.open; - let openDown = openDirection.split('-')[0] === 'bottom'; - let openLeft = openDirection.split('-')[1] === 'left'; + let {open} = this.state; let styles = { root: { @@ -113,10 +119,7 @@ const IconMenu = React.createClass({ }, menu: { - top: openDown ? 12 : null, - bottom: !openDown ? 12 : null, - left: !openLeft ? 12 : null, - right: openLeft ? 12 : null, + position:'relative', }, }; @@ -127,24 +130,23 @@ const IconMenu = React.createClass({ onKeyboardFocus: this.props.onKeyboardFocus, iconStyle: this.mergeStyles(iconStyle, iconButtonElement.props.iconStyle), onTouchTap: (e) => { - this.open(Events.isKeyboard(e)); + this.open(Events.isKeyboard(e), e); if (iconButtonElement.props.onTouchTap) iconButtonElement.props.onTouchTap(e); }, ref: this.state.iconButtonRef, }); - let menu = open ? ( + let menu = {this.props.children} - - ) : null; + ; return (
{iconButton} - {menu} + + {menu} +
); }, @@ -166,32 +177,32 @@ const IconMenu = React.createClass({ }, close(isKeyboard) { - if (this.state.open) { - this.setState({open: false}, () => { - //Set focus on the icon button when the menu close - if (isKeyboard) { - let iconButton = this.refs[this.state.iconButtonRef]; - ReactDOM.findDOMNode(iconButton).focus(); - iconButton.setKeyboardFocus(); - } - }); + if (!this.state.open) { + return; } + this.setState({open: false}, () => { + //Set focus on the icon button when the menu close + if (isKeyboard) { + let iconButton = this.refs[this.state.iconButtonRef]; + ReactDOM.findDOMNode(iconButton).focus(); + iconButton.setKeyboardFocus(); + } + }); }, - open(menuInitiallyKeyboardFocused) { - if (!this.state.open) { - this.setState({ - open: true, - menuInitiallyKeyboardFocused: menuInitiallyKeyboardFocused, - }); - } + open(menuInitiallyKeyboardFocused, e) { + this.setState({ + open: true, + menuInitiallyKeyboardFocused: menuInitiallyKeyboardFocused, + anchorEl: e.currentTarget, + }); }, _handleItemTouchTap(e, child) { - if (this.props.closeOnItemTouchTap) { let isKeyboard = Events.isKeyboard(e); + this._timeout = setTimeout(() => { this.close(isKeyboard); }, this.props.touchTapCloseDelay); diff --git a/src/menus/menu-item.jsx b/src/menus/menu-item.jsx index c505c734621d74..a2a373d1e385e2 100644 --- a/src/menus/menu-item.jsx +++ b/src/menus/menu-item.jsx @@ -1,11 +1,14 @@ const React = require('react'); +const ReactDOM = require('react-dom'); const PureRenderMixin = require('react-addons-pure-render-mixin'); const StylePropable = require('../mixins/style-propable'); const Colors = require('../styles/colors'); +const Popover = require('../popover/popover'); const CheckIcon = require('../svg-icons/navigation/check'); const ListItem = require('../lists/list-item'); const DefaultRawTheme = require('../styles/raw-themes/light-raw-theme'); const ThemeManager = require('../styles/theme-manager'); +const Menu = require('./menu'); const MenuItem = React.createClass({ @@ -46,6 +49,7 @@ const MenuItem = React.createClass({ getInitialState () { return { muiTheme: this.context.muiTheme ? this.context.muiTheme : ThemeManager.getMuiTheme(DefaultRawTheme), + open:false, }; }, @@ -80,6 +84,7 @@ const MenuItem = React.createClass({ innerDivStyle, insetChildren, leftIcon, + menuItems, rightIcon, secondaryText, style, @@ -87,6 +92,7 @@ const MenuItem = React.createClass({ ...other, } = this.props; + const disabledColor = this.state.muiTheme.rawTheme.palette.disabledColor; const textColor = this.state.muiTheme.rawTheme.palette.textColor; const leftIndent = desktop ? 64 : 72; @@ -154,6 +160,21 @@ const MenuItem = React.createClass({ React.cloneElement(secondaryText, {style: mergedSecondaryTextStyles}) :
{secondaryText}
; } + let childMenuPopover; + if (menuItems) { + childMenuPopover = ( + + + {React.Children.map(menuItems, this._cloneMenuItem)} + + + ); + other.onTouchTap = this._onClick; + } return ( {children} {secondaryTextElement} + {childMenuPopover} ); }, @@ -174,6 +196,40 @@ const MenuItem = React.createClass({ _applyFocusState() { this.refs.listItem.applyFocusState(this.props.focusState); }, + + _cloneMenuItem(item) { + let props = { + onTouchTap: (e) => + { + this._onRequestClose(); + if (item.props.onClick) { + item.props.onClick(e); + } + if (this.props.onClick) { + this.props.onClick(e); + } + }, + onRequestClose: this._onRequestClose, + }; + return React.cloneElement(item, props); + }, + + _onClick(e) { + this.setState({ + open:true, + anchorEl:ReactDOM.findDOMNode(this), + }); + if (this.props.onClick) { + this.props.onClick(e); + } + }, + + _onRequestClose() { + this.setState({ + open:false, + anchorEl:null, + }); + }, }); module.exports = MenuItem; diff --git a/src/menus/menu.jsx b/src/menus/menu.jsx index bc5fd33d61a6f3..49136bd792cc2b 100644 --- a/src/menus/menu.jsx +++ b/src/menus/menu.jsx @@ -45,7 +45,7 @@ const Menu = React.createClass({ onEscKeyDown: () => {}, onItemTouchTap: () => {}, onKeyDown: () => {}, - openDirection: 'bottom-left', + openDirection: 'top-left', zDepth: 1, }; }, @@ -185,7 +185,7 @@ const Menu = React.createClass({ let menuItemIndex = 0; let newChildren = React.Children.map(children, (child) => { - let childIsADivider = child.type.displayName === 'MenuDivider'; + let childIsADivider = child.type && child.type.displayName === 'MenuDivider'; let childIsDisabled = child.props.disabled; let childrenContainerStyles = {}; @@ -322,7 +322,7 @@ const Menu = React.createClass({ //max menu height React.Children.forEach(children, (child) => { if (currentHeight < maxHeight) { - let childIsADivider = child.type.displayName === 'MenuDivider'; + let childIsADivider = child.type && child.type.displayName === 'MenuDivider'; currentHeight += childIsADivider ? 16 : menuItemHeight; count++; @@ -336,7 +336,7 @@ const Menu = React.createClass({ _getMenuItemCount() { let menuItemCount = 0; React.Children.forEach(this.props.children, (child) => { - let childIsADivider = child.type.displayName === 'MenuDivider'; + let childIsADivider = child.type && child.type.displayName === 'MenuDivider'; let childIsDisabled = child.props.disabled; if (!childIsADivider && !childIsDisabled) menuItemCount++; }); @@ -351,7 +351,7 @@ const Menu = React.createClass({ let menuItemIndex = 0; React.Children.forEach(children, (child) => { - let childIsADivider = child.type.displayName === 'MenuDivider'; + let childIsADivider = child.type && child.type.displayName === 'MenuDivider'; if (this._isChildSelected(child, props)) selectedIndex = menuItemIndex; if (!childIsADivider) menuItemIndex++; diff --git a/src/mixins/render-to-layer.js b/src/mixins/render-to-layer.js new file mode 100644 index 00000000000000..e7bc74a5315107 --- /dev/null +++ b/src/mixins/render-to-layer.js @@ -0,0 +1,119 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import Events from '../utils/events'; +import Dom from '../utils/dom'; +import debounce from 'lodash.debounce'; + +// heavily inspired by https://github.com/Khan/react-components/blob/master/js/layered-component-mixin.jsx +const LayerMixin = { + + componentDidMount() { + this._renderLayer(); + }, + + componentDidUpdate() { + this._renderLayer(); + }, + + componentWillUnmount() { + this._unbindClickAway(); + if (this._layer) { + this._unrenderLayer(); + } + }, + + _checkClickAway(e) { + if (!this.canClickAway) { + return; + } + const el = this._layer; + if (e.target !== el && + !Dom.isDescendant(el, e.target) && + document.documentElement.contains(e.target)) { + if (this.componentClickAway) { + this.componentClickAway(e); + } + } + }, + + _preventClickAway(e) { + if (e.detail === this) { + return; + } + this.canClickAway = false; + }, + + _allowClickAway() { + this.canClickAway = true; + }, + + _renderLayer: function() { + if (this.state.open || this.props.open) { + if (!this._layer) { + this._layer = document.createElement('div'); + document.body.appendChild(this._layer); + } + this._bindClickAway(); + if (this.reactUnmount) { + this.reactUnmount.cancel(); + } + } else if (this._layer) { + this._unbindClickAway(); + this._unrenderLayer(); + } else { + return; + } + + // By calling this method in componentDidMount() and + // componentDidUpdate(), you're effectively creating a "wormhole" that + // funnels React's hierarchical updates through to a DOM node on an + // entirely different part of the page. + + const layerElement = this.renderLayer(); + // Renders can return null, but React.render() doesn't like being asked + // to render null. If we get null back from renderLayer(), just render + // a noscript element, like React does when an element's render returns + // null. + if (layerElement === null) { + this.layerElement = ReactDOM.unstable_renderSubtreeIntoContainer (this,
@@ -144,16 +151,20 @@ const TimePicker = React.createClass({ openDialog() { this.setState({ dialogTime: this.getTime(), + dialogOpen:true, }); - - this.refs.dialogWindow.show(); }, _handleDialogAccept(t) { + this.setState({dialogOpen:false}); this.setTime(t); if (this.props.onChange) this.props.onChange(null, t); }, + _handleDialogClose() { + this.setState({dialogOpen:false}); + }, + _handleInputFocus(e) { e.target.blur(); if (this.props.onFocus) this.props.onFocus(e); @@ -161,9 +172,7 @@ const TimePicker = React.createClass({ _handleInputTouchTap(e) { e.preventDefault(); - this.openDialog(); - if (this.props.onTouchTap) this.props.onTouchTap(e); }, }); diff --git a/src/utils/prop-types.js b/src/utils/prop-types.js index 7703e1819fced5..ec64704613b474 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([ @@ -8,6 +11,21 @@ module.exports = { 'top-left', 'top-right', ]), + popOverPositions: React.PropTypes.oneOf([ + 'bottom-left', + 'bottom-right', + 'top-left', + 'top-right', + 'above', + 'below', + ]), + horizontal:horizontal, + vertical:vertical, + + origin: React.PropTypes.shape({ + horizontal: horizontal, + vertical:vertical, + }), cornersAndCenter: React.PropTypes.oneOf([ 'bottom-center',