material-ui
Version:
Material Design UI components built with React
474 lines (391 loc) • 13.1 kB
JSX
const React = require('react');
const ReactDOM = require('react-dom');
const update = require('react-addons-update');
const Controllable = require('../mixins/controllable');
const StylePropable = require('../mixins/style-propable');
const AutoPrefix = require('../styles/auto-prefix');
const Transitions = require('../styles/transitions');
const KeyCode = require('../utils/key-code');
const PropTypes = require('../utils/prop-types');
const List = require('../lists/list');
const Paper = require('../paper');
const DefaultRawTheme = require('../styles/raw-themes/light-raw-theme');
const ThemeManager = require('../styles/theme-manager');
const Menu = React.createClass({
mixins: [StylePropable, Controllable],
contextTypes: {
muiTheme: React.PropTypes.object,
},
propTypes: {
animated: React.PropTypes.bool,
autoWidth: React.PropTypes.bool,
desktop: React.PropTypes.bool,
initiallyKeyboardFocused: React.PropTypes.bool,
listStyle: React.PropTypes.object,
maxHeight: React.PropTypes.number,
multiple: React.PropTypes.bool,
onEscKeyDown: React.PropTypes.func,
onItemTouchTap: React.PropTypes.func,
onKeyDown: React.PropTypes.func,
openDirection: PropTypes.corners,
selectedMenuItemStyle: React.PropTypes.object,
width: PropTypes.stringOrNumber,
zDepth: PropTypes.zDepth,
},
getDefaultProps() {
return {
animated: false,
autoWidth: true,
maxHeight: null,
onEscKeyDown: () => {},
onItemTouchTap: () => {},
onKeyDown: () => {},
openDirection: 'bottom-left',
zDepth: 1,
};
},
//for passing default theme context to children
childContextTypes: {
muiTheme: React.PropTypes.object,
},
getChildContext () {
return {
muiTheme: this.state.muiTheme,
};
},
getInitialState() {
let selectedIndex = this._getSelectedIndex(this.props);
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),
};
},
componentDidEnter() {
this._animateOpen();
},
componentDidMount() {
if (this.props.autoWidth) this._setWidth();
if (!this.props.animated) this._animateOpen();
this._setScollPosition();
},
componentDidUpdate() {
if (this.props.autoWidth) this._setWidth();
},
componentWillLeave(callback) {
let rootStyle = ReactDOM.findDOMNode(this).style;
AutoPrefix.set(rootStyle, 'transition', Transitions.easeOut('250ms', ['opacity', 'transform']));
AutoPrefix.set(rootStyle, 'transform', 'translate3d(0,-8px,0)');
rootStyle.opacity = 0;
setTimeout(() => {
if (this.isMounted()) callback();
}, 250);
},
componentWillReceiveProps(nextProps, nextContext) {
let selectedIndex = this._getSelectedIndex(nextProps);
let newMuiTheme = nextContext.muiTheme ? nextContext.muiTheme : this.state.muiTheme;
this.setState({
focusIndex: selectedIndex >= 0 ? selectedIndex : 0,
keyWidth: nextProps.desktop ? 64 : 56,
muiTheme: newMuiTheme,
});
},
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';
let styles = {
root: {
//Nested div bacause the List scales x faster than
//it scales y
transition: animated ? Transitions.easeOut('250ms', 'transform') : null,
position: 'absolute',
zIndex: 10,
top: openDown ? 0 : null,
bottom: !openDown ? 0 : null,
left: !openLeft ? 0 : null,
right: openLeft ? 0 : null,
transform: 'scaleX(0)',
transformOrigin: openLeft ? 'right' : 'left',
},
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 ? 'scroll' : null,
},
selectedMenuItem: {
color: this.state.muiTheme.rawTheme.palette.accent1Color,
},
};
let mergedRootStyles = this.prepareStyles(styles.root, style);
let mergedListStyles = this.mergeStyles(styles.list, listStyle);
//Cascade children opacity
let cumulativeDelay = openDown ? 175 : 325;
let cascadeChildrenCount = this._getCascadeChildrenCount();
let cumulativeDelayIncrement = Math.ceil(150/cascadeChildrenCount);
let menuItemIndex = 0;
let newChildren = React.Children.map(children, (child) => {
let childIsADivider = child.type.displayName === 'MenuDivider';
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 ? child :
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>
);
},
setKeyboardFocused(keyboardFocused) {
this.setState({
isKeyboardFocused: keyboardFocused,
});
},
_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() {
let {
children,
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 React.Children.count(children);
//Count all the children that will fit inside the
//max menu height
React.Children.forEach(children, (child) => {
if (currentHeight < maxHeight) {
let childIsADivider = child.type.displayName === 'MenuDivider';
currentHeight += childIsADivider ? 16 : menuItemHeight;
count++;
}
});
return count;
},
_getMenuItemCount() {
let menuItemCount = 0;
React.Children.forEach(this.props.children, (child) => {
let childIsADivider = child.type.displayName === 'MenuDivider';
let childIsDisabled = child.props.disabled;
if (!childIsADivider && !childIsDisabled) menuItemCount++;
});
return menuItemCount;
},
_getSelectedIndex(props) {
let {
children,
} = props;
let selectedIndex = -1;
let menuItemIndex = 0;
React.Children.forEach(children, (child) => {
let childIsADivider = child.type.displayName === 'MenuDivider';
if (this._isChildSelected(child, props)) selectedIndex = menuItemIndex;
if (!childIsADivider) menuItemIndex++;
});
return selectedIndex;
},
_handleKeyDown(e) {
switch (e.keyCode) {
case KeyCode.DOWN:
e.preventDefault();
this._incrementKeyboardFocusIndex();
break;
case KeyCode.ESC:
this.props.onEscKeyDown(e);
break;
case KeyCode.TAB:
e.preventDefault();
if (e.shiftKey) {
this._decrementKeyboardFocusIndex();
}
else {
this._incrementKeyboardFocusIndex();
}
break;
case KeyCode.UP:
e.preventDefault();
this._decrementKeyboardFocusIndex();
break;
}
this.props.onKeyDown(e);
},
_handleMenuItemTouchTap(e, item) {
let multiple = this.props.multiple;
let valueLink = this.getValueLink(this.props);
let menuValue = valueLink.value;
let itemValue = item.props.value;
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() {
let index = this.state.focusIndex;
let maxIndex = this._getMenuItemCount() - 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';
},
});
module.exports = Menu;