material-ui
Version:
Material Design UI components built with React
601 lines (498 loc) • 16.6 kB
JSX
import React from 'react';
import ReactDOM from 'react-dom';
import update from 'react-addons-update';
import Controllable from '../mixins/controllable';
import StylePropable from '../mixins/style-propable';
import ClickAwayable from '../mixins/click-awayable';
import AutoPrefix from '../styles/auto-prefix';
import Transitions from '../styles/transitions';
import KeyCode from '../utils/key-code';
import PropTypes from '../utils/prop-types';
import List from '../lists/list';
import Paper from '../paper';
import DefaultRawTheme from '../styles/raw-themes/light-raw-theme';
import ThemeManager from '../styles/theme-manager';
const Menu = React.createClass({
propTypes: {
/**
* If true, the menu will apply transitions when added it
* gets added to the DOM. In order for transitions to
* work, wrap the menu inside a ReactTransitionGroup.
*/
animated: React.PropTypes.bool,
/**
* If true, the width will automatically be
* set according to the items inside the menu
* using the proper keyline increment.
*/
autoWidth: React.PropTypes.bool,
/**
* Children for the Menu. Usually MenuItems.
*/
children: React.PropTypes.node,
/**
* Indicates if the menu should render with compact desktop styles.
*/
desktop: React.PropTypes.bool,
/**
* True if this item should be focused by the keyboard initially.
*/
initiallyKeyboardFocused: React.PropTypes.bool,
/**
* The style object to use to override underlying list style.
*/
listStyle: React.PropTypes.object,
/**
* The maxHeight of the menu in pixels. If
* specified, the menu will scroll if larger than the maxHeight.
*/
maxHeight: React.PropTypes.number,
/**
* If true, the value can an array and allow the menu to be a multi-select.
*/
multiple: React.PropTypes.bool,
/**
* Fired when a menu item is touchTapped and the menu item
* value is not equal to the current menu value.
*/
onChange: React.PropTypes.func,
/**
* Fired when an Esc key is keyed down.
*/
onEscKeyDown: React.PropTypes.func,
/**
* Fired when a menu item is touchTapped.
*/
onItemTouchTap: React.PropTypes.func,
/**
* Fired when a key is pressed.
*/
onKeyDown: React.PropTypes.func,
/**
* This is the placement of the menu relative to the IconButton.
*/
openDirection: PropTypes.corners,
/**
* Style for the selected Menu Item.
*/
selectedMenuItemStyle: React.PropTypes.object,
/**
* Override the inline-styles of the root element.
*/
style: React.PropTypes.object,
/**
* The value of the selected menu item. If passed in,
* this will make the menu a controlled component.
* This component also supports valueLink.
*/
value: React.PropTypes.any,
/**
* ValueLink for this component when controlled.
*/
valueLink: React.PropTypes.object,
/**
* Sets the width of the menu. If not specified, the menu
* width will be dictated by its children. The rendered
* width will always be a keyline increment
* (64px for desktop, 56px otherwise).
*/
width: PropTypes.stringOrNumber,
/**
* Sets the width of the menu. If not specified,
* the menu width will be dictated by its children.
* The rendered width will always be a keyline increment
* (64px for desktop, 56px otherwise).
*/
zDepth: PropTypes.zDepth,
},
contextTypes: {
muiTheme: React.PropTypes.object,
},
//for passing default theme context to children
childContextTypes: {
muiTheme: React.PropTypes.object,
},
mixins: [
StylePropable,
Controllable,
ClickAwayable,
],
getDefaultProps() {
return {
animated: false,
autoWidth: true,
desktop: false,
initiallyKeyboardFocused: false,
maxHeight: null,
multiple: false,
onEscKeyDown: () => {},
onItemTouchTap: () => {},
onKeyDown: () => {},
openDirection: 'bottom-left',
zDepth: 1,
};
},
getInitialState() {
const filteredChildren = this._getFilteredChildren(this.props.children);
let selectedIndex = this._getSelectedIndex(this.props, filteredChildren);
return {
focusIndex: selectedIndex >= 0 ? selectedIndex : 0,
isKeyboardFocused: this.props.initiallyKeyboardFocused,
keyWidth: this.props.desktop ? 64 : 56,
muiTheme: this.context.muiTheme ? this.context.muiTheme : ThemeManager.getMuiTheme(DefaultRawTheme),
};
},
getChildContext() {
return {
muiTheme: this.state.muiTheme,
};
},
componentDidMount() {
if (this.props.autoWidth) this._setWidth();
if (!this.props.animated) this._animateOpen();
this._setScollPosition();
},
componentWillReceiveProps(nextProps, nextContext) {
const filteredChildren = this._getFilteredChildren(nextProps.children);
let selectedIndex = this._getSelectedIndex(nextProps, filteredChildren);
let newMuiTheme = nextContext.muiTheme ? nextContext.muiTheme : this.state.muiTheme;
this.setState({
focusIndex: selectedIndex >= 0 ? selectedIndex : 0,
keyWidth: nextProps.desktop ? 64 : 56,
muiTheme: newMuiTheme,
});
},
componentDidUpdate() {
if (this.props.autoWidth) this._setWidth();
},
componentDidEnter() {
this._animateOpen();
},
componentWillLeave(callback) {
let rootStyle = ReactDOM.findDOMNode(this).style;
rootStyle.transition = Transitions.easeOut('250ms', ['opacity', 'transform']);
rootStyle.transform = 'translate3d(0,-8px,0)';
rootStyle.opacity = 0;
rootStyle = AutoPrefix.all(rootStyle);
setTimeout(() => {
if (this.isMounted()) callback();
}, 250);
},
componentClickAway(e) {
if (e.defaultPrevented)
return;
this._setFocusIndex(-1, false);
},
setKeyboardFocused(keyboardFocused) {
this.setState({
isKeyboardFocused: keyboardFocused,
});
},
_getFilteredChildren(children) {
const filteredChildren = [];
React.Children.forEach(children, child => {
if (child) {
filteredChildren.push(child);
}
});
return filteredChildren;
},
_animateOpen() {
let rootStyle = ReactDOM.findDOMNode(this).style;
let scrollContainerStyle = ReactDOM.findDOMNode(this.refs.scrollContainer).style;
let menuContainers = ReactDOM.findDOMNode(this.refs.list).childNodes;
AutoPrefix.set(rootStyle, 'transform', 'scaleX(1)');
AutoPrefix.set(scrollContainerStyle, 'transform', 'scaleY(1)');
scrollContainerStyle.opacity = 1;
for (let i = 0; i < menuContainers.length; ++i) {
menuContainers[i].style.opacity = 1;
}
},
_cloneMenuItem(child, childIndex, styles) {
let {
desktop,
selectedMenuItemStyle,
} = this.props;
let selected = this._isChildSelected(child, this.props);
let selectedChildrenStyles = {};
if (selected) {
selectedChildrenStyles = this.mergeStyles(styles.selectedMenuItem, selectedMenuItemStyle);
}
let mergedChildrenStyles = this.mergeStyles(
child.props.style || {},
selectedChildrenStyles
);
let isFocused = childIndex === this.state.focusIndex;
let focusState = 'none';
if (isFocused) {
focusState = this.state.isKeyboardFocused ?
'keyboard-focused' : 'focused';
}
return React.cloneElement(child, {
desktop: desktop,
focusState: focusState,
onTouchTap: (e) => {
this._handleMenuItemTouchTap(e, child);
if (child.props.onTouchTap) child.props.onTouchTap(e);
},
ref: isFocused ? 'focusedMenuItem' : null,
style: mergedChildrenStyles,
});
},
_decrementKeyboardFocusIndex() {
let index = this.state.focusIndex;
index--;
if (index < 0) index = 0;
this._setFocusIndex(index, true);
},
_getCascadeChildrenCount(filteredChildren) {
let {
desktop,
maxHeight,
} = this.props;
let count = 1;
let currentHeight = desktop ? 16 : 8;
let menuItemHeight = desktop ? 32 : 48;
//MaxHeight isn't set - cascade all of the children
if (!maxHeight) return filteredChildren.length;
//Count all the children that will fit inside the
//max menu height
filteredChildren.forEach(child => {
if (currentHeight < maxHeight) {
let childIsADivider = child.type && child.type.displayName === 'Divider';
currentHeight += childIsADivider ? 16 : menuItemHeight;
count++;
}
});
return count;
},
_getMenuItemCount(filteredChildren) {
let menuItemCount = 0;
filteredChildren.forEach(child => {
let childIsADivider = child.type && child.type.displayName === 'Divider';
let childIsDisabled = child.props.disabled;
if (!childIsADivider && !childIsDisabled) menuItemCount++;
});
return menuItemCount;
},
_getSelectedIndex(props, filteredChildren) {
let selectedIndex = -1;
let menuItemIndex = 0;
filteredChildren.forEach(child => {
let childIsADivider = child.type && child.type.displayName === 'Divider';
if (this._isChildSelected(child, props)) selectedIndex = menuItemIndex;
if (!childIsADivider) menuItemIndex++;
});
return selectedIndex;
},
_handleKeyDown(e) {
const filteredChildren = this._getFilteredChildren(this.props.children);
switch (e.keyCode) {
case KeyCode.DOWN:
e.preventDefault();
this._incrementKeyboardFocusIndex(filteredChildren);
break;
case KeyCode.ESC:
this.props.onEscKeyDown(e);
break;
case KeyCode.TAB:
e.preventDefault();
if (e.shiftKey) {
this._decrementKeyboardFocusIndex();
}
else {
this._incrementKeyboardFocusIndex(filteredChildren);
}
break;
case KeyCode.UP:
e.preventDefault();
this._decrementKeyboardFocusIndex();
break;
}
this.props.onKeyDown(e);
},
_handleMenuItemTouchTap(e, item) {
let children = this.props.children;
let multiple = this.props.multiple;
let valueLink = this.getValueLink(this.props);
let menuValue = valueLink.value;
let itemValue = item.props.value;
let focusIndex = React.isValidElement(children) ? 0 : children.indexOf(item);
this._setFocusIndex(focusIndex, false);
if (multiple) {
let index = menuValue.indexOf(itemValue);
let newMenuValue = index === -1 ?
update(menuValue, {$push: [itemValue]}) :
update(menuValue, {$splice: [[index, 1]]});
valueLink.requestChange(e, newMenuValue);
}
else if (!multiple && itemValue !== menuValue) {
valueLink.requestChange(e, itemValue);
}
this.props.onItemTouchTap(e, item);
},
_incrementKeyboardFocusIndex(filteredChildren) {
let index = this.state.focusIndex;
let maxIndex = this._getMenuItemCount(filteredChildren) - 1;
index++;
if (index > maxIndex) index = maxIndex;
this._setFocusIndex(index, true);
},
_isChildSelected(child, props) {
let multiple = props.multiple;
let menuValue = this.getValueLink(props).value;
let childValue = child.props.value;
return (multiple && menuValue.length && menuValue.indexOf(childValue) !== -1) ||
(!multiple && menuValue && menuValue === childValue);
},
_setFocusIndex(newIndex, isKeyboardFocused) {
this.setState({
focusIndex: newIndex,
isKeyboardFocused: isKeyboardFocused,
});
},
_setScollPosition() {
let desktop = this.props.desktop;
let focusedMenuItem = this.refs.focusedMenuItem;
let menuItemHeight = desktop ? 32 : 48;
if (focusedMenuItem) {
let selectedOffSet = ReactDOM.findDOMNode(focusedMenuItem).offsetTop;
//Make the focused item be the 2nd item in the list the
//user sees
let scrollTop = selectedOffSet - menuItemHeight;
if (scrollTop < menuItemHeight) scrollTop = 0;
ReactDOM.findDOMNode(this.refs.scrollContainer).scrollTop = scrollTop;
}
},
_setWidth() {
let el = ReactDOM.findDOMNode(this);
let listEl = ReactDOM.findDOMNode(this.refs.list);
let elWidth = el.offsetWidth;
let keyWidth = this.state.keyWidth;
let minWidth = keyWidth * 1.5;
let keyIncrements = elWidth / keyWidth;
let newWidth;
keyIncrements = keyIncrements <= 1.5 ? 1.5 : Math.ceil(keyIncrements);
newWidth = keyIncrements * keyWidth;
if (newWidth < minWidth) newWidth = minWidth;
el.style.width = newWidth + 'px';
listEl.style.width = newWidth + 'px';
},
render() {
let {
animated,
autoWidth,
children,
desktop,
initiallyKeyboardFocused,
listStyle,
maxHeight,
multiple,
openDirection,
selectedMenuItemStyle,
style,
value,
valueLink,
width,
zDepth,
...other,
} = this.props;
let openDown = openDirection.split('-')[0] === 'bottom';
let openLeft = openDirection.split('-')[1] === 'left';
const muiTheme = this.state.muiTheme;
const rawTheme = muiTheme.rawTheme;
let styles = {
root: {
//Nested div bacause the List scales x faster than
//it scales y
transition: animated ? Transitions.easeOut('250ms', 'transform') : null,
zIndex: muiTheme.zIndex.menu,
top: openDown ? 0 : null,
bottom: !openDown ? 0 : null,
left: !openLeft ? 0 : null,
right: openLeft ? 0 : null,
transform: 'scaleX(0)',
transformOrigin: openLeft ? 'right' : 'left',
},
divider: {
marginTop: 7,
marginBottom: 8,
},
list: {
display: 'table-cell',
paddingBottom: desktop ? 16 : 8,
paddingTop: desktop ? 16 : 8,
userSelect: 'none',
width: width,
},
menuItemContainer: {
transition: animated ? Transitions.easeOut(null, 'opacity') : null,
opacity: 0,
},
paper: {
transition: animated ? Transitions.easeOut('500ms', ['transform', 'opacity']) : null,
transform: 'scaleY(0)',
transformOrigin: openDown ? 'top' : 'bottom',
opacity: 0,
maxHeight: maxHeight,
overflowY: maxHeight ? 'auto' : null,
},
selectedMenuItem: {
color: rawTheme.palette.accent1Color,
},
};
let mergedRootStyles = this.prepareStyles(styles.root, style);
let mergedListStyles = this.mergeStyles(styles.list, listStyle);
const filteredChildren = this._getFilteredChildren(children);
//Cascade children opacity
let cumulativeDelay = openDown ? 175 : 325;
let cascadeChildrenCount = this._getCascadeChildrenCount(filteredChildren);
let cumulativeDelayIncrement = Math.ceil(150 / cascadeChildrenCount);
let menuItemIndex = 0;
let newChildren = React.Children.map(filteredChildren, child => {
let childIsADivider = child.type && child.type.displayName === 'Divider';
let childIsDisabled = child.props.disabled;
let childrenContainerStyles = {};
if (animated) {
let focusIndex = this.state.focusIndex;
let transitionDelay = 0;
//Only cascade the visible menu items
if ((menuItemIndex >= focusIndex - 1) &&
(menuItemIndex <= focusIndex + cascadeChildrenCount - 1)) {
cumulativeDelay = openDown ?
cumulativeDelay + cumulativeDelayIncrement :
cumulativeDelay - cumulativeDelayIncrement;
transitionDelay = cumulativeDelay;
}
childrenContainerStyles = this.prepareStyles(styles.menuItemContainer, {
transitionDelay: transitionDelay + 'ms',
});
}
let clonedChild = childIsADivider ? React.cloneElement(child, {style: styles.divider}) :
childIsDisabled ? React.cloneElement(child, {desktop: desktop}) :
this._cloneMenuItem(child, menuItemIndex, styles);
if (!childIsADivider && !childIsDisabled) menuItemIndex++;
return animated ? (
<div style={childrenContainerStyles}>{clonedChild}</div>
) : clonedChild;
});
return (
<div
onKeyDown={this._handleKeyDown}
style={mergedRootStyles}>
<Paper
ref="scrollContainer"
style={styles.paper}
zDepth={zDepth}>
<List
{...other}
ref="list"
style={mergedListStyles}>
{newChildren}
</List>
</Paper>
</div>
);
},
});
export default Menu;