1 | import React from 'react';
|
2 | import ReactDOM from 'react-dom';
|
3 | import PropTypes from 'prop-types';
|
4 | import Trigger from 'rc-trigger';
|
5 | import { KeyCode } from 'tinper-bee-core';
|
6 | import classNames from 'classnames';
|
7 | import { connect } from 'mini-store';
|
8 | import SubPopupMenu from './SubPopupMenu';
|
9 | import placements from './placements';
|
10 | import Animate from 'rc-animate';
|
11 | import {
|
12 | noop,
|
13 | loopMenuItemRecursively,
|
14 | getMenuIdFromSubMenuEventKey,
|
15 | menuAllProps,
|
16 | } from './util';
|
17 |
|
18 | let guid = 0;
|
19 |
|
20 | const popupPlacementMap = {
|
21 | horizontal: 'bottomLeft',
|
22 | vertical: 'rightTop',
|
23 | 'vertical-left': 'rightTop',
|
24 | 'vertical-right': 'leftTop',
|
25 | };
|
26 |
|
27 | const 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 |
|
38 | export 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,
|
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 |
|
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 |
|
124 | if (this.minWidthTimeout) {
|
125 | clearTimeout(this.minWidthTimeout);
|
126 | }
|
127 |
|
128 |
|
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 |
|
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 |
|
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 |
|
252 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
552 | const connected = connect(({ openKeys, activeKey, selectedKeys }, { eventKey, subMenuKey }) => ({
|
553 | isOpen: openKeys.indexOf(eventKey) > -1,
|
554 | active: activeKey[subMenuKey] === eventKey,
|
555 | selectedKeys,
|
556 | }))(SubMenu);
|
557 |
|
558 | connected.isSubMenu = true;
|
559 |
|
560 | export default connected;
|