UNPKG

15.8 kBJavaScriptView Raw
1import React from 'react';
2import ReactDOM from 'react-dom';
3import PropTypes from 'prop-types';
4import Trigger from 'rc-trigger';
5import { KeyCode } from 'tinper-bee-core';
6import classNames from 'classnames';
7import { connect } from 'mini-store';
8import SubPopupMenu from './SubPopupMenu';
9import placements from './placements';
10import Animate from 'rc-animate';
11import {
12 noop,
13 loopMenuItemRecursively,
14 getMenuIdFromSubMenuEventKey,
15 menuAllProps,
16} from './util';
17
18let guid = 0;
19
20const popupPlacementMap = {
21 horizontal: 'bottomLeft',
22 vertical: 'rightTop',
23 'vertical-left': 'rightTop',
24 'vertical-right': 'leftTop',
25};
26
27const updateDefaultActiveFirst = (store, eventKey, defaultActiveFirst) => {
28 const menuId = getMenuIdFromSubMenuEventKey(eventKey);
29 const state = store.getState();
30 store.setState({
31 defaultActiveFirst: {
32 ...state.defaultActiveFirst,
33 [menuId]: defaultActiveFirst,
34 },
35 });
36};
37
38export class SubMenu extends React.Component {
39 static propTypes = {
40 parentMenu: PropTypes.object,
41 title: PropTypes.node,
42 children: PropTypes.any,
43 selectedKeys: PropTypes.array,
44 openKeys: PropTypes.array,
45 onClick: PropTypes.func,
46 onOpenChange: PropTypes.func,
47 rootPrefixCls: PropTypes.string,
48 eventKey: PropTypes.string,
49 multiple: PropTypes.bool,
50 active: PropTypes.bool, // TODO: remove
51 onItemHover: PropTypes.func,
52 onSelect: PropTypes.func,
53 triggerSubMenuAction: PropTypes.string,
54 onDeselect: PropTypes.func,
55 onDestroy: PropTypes.func,
56 onMouseEnter: PropTypes.func,
57 onMouseLeave: PropTypes.func,
58 onTitleMouseEnter: PropTypes.func,
59 onTitleMouseLeave: PropTypes.func,
60 onTitleClick: PropTypes.func,
61 popupOffset: PropTypes.array,
62 isOpen: PropTypes.bool,
63 store: PropTypes.object,
64 mode: PropTypes.oneOf(['horizontal', 'vertical', 'vertical-left', 'vertical-right', 'inline']),
65 manualRef: PropTypes.func,
66 itemIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
67 expandIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
68 };
69
70 static defaultProps = {
71 onMouseEnter: noop,
72 onMouseLeave: noop,
73 onTitleMouseEnter: noop,
74 onTitleMouseLeave: noop,
75 onTitleClick: noop,
76 manualRef: noop,
77 mode: 'vertical',
78 title: '',
79 };
80
81 constructor(props) {
82 super(props);
83 const store = props.store;
84 const eventKey = props.eventKey;
85 const defaultActiveFirst = store.getState().defaultActiveFirst;
86
87 this.isRootMenu = false;
88
89 let value = false;
90
91 if (defaultActiveFirst) {
92 value = defaultActiveFirst[eventKey];
93 }
94
95 updateDefaultActiveFirst(store, eventKey, value);
96 }
97
98 componentDidMount() {
99 this.componentDidUpdate();
100 }
101
102 componentDidUpdate() {
103 const { mode, parentMenu, manualRef } = this.props;
104
105 // invoke customized ref to expose component to mixin
106 if (manualRef) {
107 manualRef(this);
108 }
109
110 if (mode !== 'horizontal' || !parentMenu.isRootMenu || !this.props.isOpen) {
111 return;
112 }
113
114 this.minWidthTimeout = setTimeout(() => this.adjustWidth(), 0);
115 }
116
117 componentWillUnmount() {
118 const { onDestroy, eventKey } = this.props;
119 if (onDestroy) {
120 onDestroy(eventKey);
121 }
122
123 /* istanbul ignore if */
124 if (this.minWidthTimeout) {
125 clearTimeout(this.minWidthTimeout);
126 }
127
128 /* istanbul ignore if */
129 if (this.mouseenterTimeout) {
130 clearTimeout(this.mouseenterTimeout);
131 }
132 }
133
134 onDestroy = (key) => {
135 this.props.onDestroy(key);
136 };
137
138 onKeyDown = (e) => {
139 const keyCode = e.keyCode;
140 const menu = this.menuInstance;
141 const {
142 isOpen,
143 store,
144 } = this.props;
145 if(this.props.store.getState().keyboard){//是否启用键盘操作
146 if (keyCode === KeyCode.ENTER) {
147 // this.onTitleClick(e);
148 menu&&menu.onKeyDown&&menu.onKeyDown(e);
149 updateDefaultActiveFirst(store, this.props.eventKey, true);
150 return true;
151 }
152
153 if (keyCode === KeyCode.RIGHT) {
154 if (isOpen) {
155 menu.onKeyDown(e);
156 } else {
157 this.triggerOpenChange(true);
158 // need to update current menu's defaultActiveFirst value
159 updateDefaultActiveFirst(store, this.props.eventKey, true);
160 }
161 return true;
162 }
163 if (keyCode === KeyCode.LEFT) {
164 let handled;
165 if (isOpen) {
166 handled = menu.onKeyDown(e);
167 } else {
168 return undefined;
169 }
170 if (!handled) {
171 this.triggerOpenChange(false);
172 handled = true;
173 }
174 return handled;
175 }
176 if (isOpen && (keyCode === KeyCode.UP || keyCode === KeyCode.DOWN)) {
177 return menu.onKeyDown(e);
178 }
179 }
180 };
181
182 onOpenChange = (e) => {
183 this.props.onOpenChange(e);
184 };
185
186 onPopupVisibleChange = (visible) => {
187 this.triggerOpenChange(visible, visible ? 'mouseenter' : 'mouseleave');
188 };
189
190 onMouseEnter = (e) => {
191 const { eventKey: key, onMouseEnter, store } = this.props;
192 updateDefaultActiveFirst(store, this.props.eventKey, false);
193 onMouseEnter({
194 key,
195 domEvent: e,
196 });
197 };
198
199 onMouseLeave = (e) => {
200 const {
201 parentMenu,
202 eventKey,
203 onMouseLeave,
204 } = this.props;
205 parentMenu.subMenuInstance = this;
206 onMouseLeave({
207 key: eventKey,
208 domEvent: e,
209 });
210 };
211
212 onTitleMouseEnter = (domEvent) => {
213 const { eventKey: key, onItemHover, onTitleMouseEnter } = this.props;
214 onItemHover({
215 key,
216 hover: true,
217 });
218 onTitleMouseEnter({
219 key,
220 domEvent,
221 });
222 };
223
224 onTitleMouseLeave = (e) => {
225 const { parentMenu, eventKey, onItemHover, onTitleMouseLeave } = this.props;
226 parentMenu.subMenuInstance = this;
227 onItemHover({
228 key: eventKey,
229 hover: false,
230 });
231 onTitleMouseLeave({
232 key: eventKey,
233 domEvent: e,
234 });
235 };
236
237 onTitleClick = (e) => {
238 const { props } = this;
239 props.onTitleClick({
240 key: props.eventKey,
241 domEvent: e,
242 });
243 if (props.triggerSubMenuAction === 'hover') {
244 return;
245 }
246 this.triggerOpenChange(!props.isOpen, 'click');
247 updateDefaultActiveFirst(props.store, this.props.eventKey, false);
248 };
249
250 onSubMenuClick = (info) => {
251 // in the case of overflowed submenu
252 // onClick is not copied over
253 if (typeof this.props.onClick === 'function') {
254 this.props.onClick(this.addKeyPath(info));
255 }
256 };
257
258 onSelect = (info) => {
259 this.props.onSelect(info);
260 };
261
262 onDeselect = (info) => {
263 this.props.onDeselect(info);
264 };
265
266 getPrefixCls = () => {
267 return `${this.props.rootPrefixCls}-submenu`;
268 };
269
270 getActiveClassName = () => {
271 return `${this.getPrefixCls()}-active`;
272 };
273
274 getDisabledClassName = () => {
275 return `${this.getPrefixCls()}-disabled`;
276 };
277
278 getSelectedClassName = () => {
279 return `${this.getPrefixCls()}-selected`;
280 };
281
282 getOpenClassName = () => {
283 return `${this.props.rootPrefixCls}-submenu-open`;
284 };
285
286 saveMenuInstance = (c) => {
287 // children menu instance
288 this.menuInstance = c;
289 };
290
291 addKeyPath = (info) => {
292 return {
293 ...info,
294 keyPath: (info.keyPath || []).concat(this.props.eventKey),
295 };
296 };
297
298 triggerOpenChange = (open, type) => {
299 const key = this.props.eventKey;
300 const openChange = () => {
301 this.onOpenChange({
302 key,
303 item: this,
304 trigger: type,
305 open,
306 });
307 };
308 if (type === 'mouseenter') {
309 // make sure mouseenter happen after other menu item's mouseleave
310 this.mouseenterTimeout = setTimeout(() => {
311 openChange();
312 }, 0);
313 } else {
314 openChange();
315 }
316 }
317
318 isChildrenSelected = () => {
319 const ret = { find: false };
320 loopMenuItemRecursively(this.props.children, this.props.selectedKeys, ret);
321 return ret.find;
322 }
323
324 isOpen = () => {
325 return this.props.openKeys.indexOf(this.props.eventKey) !== -1;
326 }
327
328 adjustWidth = () => {
329 /* istanbul ignore if */
330 if (!this.subMenuTitle || !this.menuInstance) {
331 return;
332 }
333 const popupMenu = ReactDOM.findDOMNode(this.menuInstance);
334 if (popupMenu.offsetWidth >= this.subMenuTitle.offsetWidth) {
335 return;
336 }
337
338 /* istanbul ignore next */
339 popupMenu.style.minWidth = `${this.subMenuTitle.offsetWidth}px`;
340 };
341
342 saveSubMenuTitle = (subMenuTitle) => {
343 this.subMenuTitle = subMenuTitle;
344 }
345
346 renderChildren(children) {
347 const props = this.props;
348 const baseProps = {
349 mode: props.mode === 'horizontal' ? 'vertical' : props.mode,
350 visible: this.props.isOpen,
351 level: props.level + 1,
352 inlineIndent: props.inlineIndent,
353 focusable: false,
354 onClick: this.onSubMenuClick,
355 onSelect: this.onSelect,
356 onDeselect: this.onDeselect,
357 onDestroy: this.onDestroy,
358 selectedKeys: props.selectedKeys,
359 eventKey: `${props.eventKey}-menu-`,
360 openKeys: props.openKeys,
361 openTransitionName: props.openTransitionName,
362 openAnimation: props.openAnimation,
363 onOpenChange: this.onOpenChange,
364 subMenuOpenDelay: props.subMenuOpenDelay,
365 parentMenu: this,
366 subMenuCloseDelay: props.subMenuCloseDelay,
367 forceSubMenuRender: props.forceSubMenuRender,
368 triggerSubMenuAction: props.triggerSubMenuAction,
369 builtinPlacements: props.builtinPlacements,
370 defaultActiveFirst: props.store.getState()
371 .defaultActiveFirst[getMenuIdFromSubMenuEventKey(props.eventKey)],
372 multiple: props.multiple,
373 prefixCls: props.rootPrefixCls,
374 id: this._menuId,
375 manualRef: this.saveMenuInstance,
376 itemIcon: props.itemIcon,
377 expandIcon: props.expandIcon,
378 };
379
380 const haveRendered = this.haveRendered;
381 this.haveRendered = true;
382
383 this.haveOpened = this.haveOpened || baseProps.visible || baseProps.forceSubMenuRender;
384 // never rendered not planning to, don't render
385 if (!this.haveOpened) {
386 return <div />;
387 }
388
389 // don't show transition on first rendering (no animation for opened menu)
390 // show appear transition if it's not visible (not sure why)
391 // show appear transition if it's not inline mode
392 const transitionAppear = haveRendered || !baseProps.visible || !baseProps.mode === 'inline';
393
394 baseProps.className = ` ${baseProps.prefixCls}-sub`;
395 const animProps = {};
396
397 if (baseProps.openTransitionName) {
398 animProps.transitionName = baseProps.openTransitionName;
399 } else if (typeof baseProps.openAnimation === 'object') {
400 animProps.animation = { ...baseProps.openAnimation };
401 if (!transitionAppear) {
402 delete animProps.animation.appear;
403 }
404 }
405
406 return (
407 <Animate
408 {...animProps}
409 showProp="visible"
410 component=""
411 transitionAppear={transitionAppear}
412 >
413 <SubPopupMenu {...baseProps} id={this._menuId}>{children}</SubPopupMenu>
414 </Animate>
415 );
416 }
417
418 render() {
419
420 const props = { ...this.props };
421 const isOpen = props.isOpen;
422 const prefixCls = this.getPrefixCls();
423 const isInlineMode = props.mode === 'inline';
424 const className = classNames(prefixCls, `${prefixCls}-${props.mode}`, {
425 [props.className]: !!props.className,
426 [this.getOpenClassName()]: isOpen,
427 [this.getActiveClassName()]: props.active || (isOpen && !isInlineMode),
428 [this.getDisabledClassName()]: props.disabled,
429 [this.getSelectedClassName()]: this.isChildrenSelected(),
430 });
431
432 if (!this._menuId) {
433 if (props.eventKey) {
434 this._menuId = `${props.eventKey}$Menu`;
435 } else {
436 this._menuId = `$__$${++guid}$Menu`;
437 }
438 }
439
440 let mouseEvents = {};
441 let titleClickEvents = {};
442 let titleMouseEvents = {};
443 if (!props.disabled) {
444 mouseEvents = {
445 onMouseLeave: this.onMouseLeave,
446 onMouseEnter: this.onMouseEnter,
447 };
448
449 // only works in title, not outer li
450 titleClickEvents = {
451 onClick: this.onTitleClick,
452 };
453 titleMouseEvents = {
454 onMouseEnter: this.onTitleMouseEnter,
455 onMouseLeave: this.onTitleMouseLeave,
456 };
457 }
458
459 const style = {};
460 if (isInlineMode) {
461 style.paddingLeft = props.inlineIndent * props.level;
462 }
463
464 let ariaOwns = {};
465 // only set aria-owns when menu is open
466 // otherwise it would be an invalid aria-owns value
467 // since corresponding node cannot be found
468 if (this.props.isOpen) {
469 ariaOwns = {
470 'aria-owns': this._menuId,
471 };
472 }
473
474 // expand custom icon should NOT be displayed in menu with horizontal mode.
475 let icon = null;
476 if (props.mode !== 'horizontal') {
477 icon = this.props.expandIcon; // ReactNode
478 if (typeof this.props.expandIcon === 'function') {
479 icon = React.createElement(
480 this.props.expandIcon,
481 { ...this.props }
482 );
483 }
484 }
485
486 const title = (
487 <div
488 ref={this.saveSubMenuTitle}
489 style={style}
490 className={`${prefixCls}-title`}
491 {...titleMouseEvents}
492 {...titleClickEvents}
493 aria-expanded={isOpen}
494 {...ariaOwns}
495 aria-haspopup="true"
496 title={typeof props.title === 'string' ? props.title : undefined}
497 >
498 {props.title}
499 {icon || <i className={`${prefixCls}-arrow`} />}
500 </div>
501 );
502 const children = this.renderChildren(props.children);
503
504 const getPopupContainer = triggerNode => triggerNode.parentNode;
505 const popupPlacement = popupPlacementMap[props.mode];
506 const popupAlign = props.popupOffset ? { offset: props.popupOffset } : {};
507 const popupClassName = props.mode === 'inline' ? '' : props.popupClassName;
508 const {
509 disabled,
510 triggerSubMenuAction,
511 subMenuOpenDelay,
512 forceSubMenuRender,
513 subMenuCloseDelay,
514 builtinPlacements,
515 } = props;
516 menuAllProps.forEach(key => delete props[key]);
517 // Set onClick to null, to ignore propagated onClick event
518 delete props.onClick;
519 return (
520 <li
521 {...props}
522 {...mouseEvents}
523 className={className}
524 role="menuitem"
525 >
526 {isInlineMode && title}
527 {isInlineMode && children}
528 {!isInlineMode && (
529 <Trigger
530 prefixCls={prefixCls}
531 popupClassName={`${prefixCls}-popup ${popupClassName}`}
532 getPopupContainer={getPopupContainer}
533 builtinPlacements={Object.assign({}, placements, builtinPlacements)}
534 popupPlacement={popupPlacement}
535 popupVisible={isOpen}
536 popupAlign={popupAlign}
537 popup={children}
538 action={disabled ? [] : [triggerSubMenuAction]}
539 mouseEnterDelay={subMenuOpenDelay}
540 mouseLeaveDelay={subMenuCloseDelay}
541 onPopupVisibleChange={this.onPopupVisibleChange}
542 forceRender={forceSubMenuRender}
543 >
544 {title}
545 </Trigger>
546 )}
547 </li>
548 );
549 }
550}
551
552const connected = connect(({ openKeys, activeKey, selectedKeys }, { eventKey, subMenuKey }) => ({
553 isOpen: openKeys.indexOf(eventKey) > -1,
554 active: activeKey[subMenuKey] === eventKey,
555 selectedKeys,
556}))(SubMenu);
557
558connected.isSubMenu = true;
559
560export default connected;