diff --git a/build/config.js b/build/config.js index 65041891a..fa5c3a283 100644 --- a/build/config.js +++ b/build/config.js @@ -1,5 +1,5 @@ module.exports = { dev: { - componentName: 'list', // dev components + componentName: 'menu', // dev components }, }; diff --git a/components/menu/MenuItem.jsx b/components/menu/MenuItem.jsx index bf06b726c..c9196d999 100644 --- a/components/menu/MenuItem.jsx +++ b/components/menu/MenuItem.jsx @@ -8,8 +8,9 @@ export default { props: itemProps, inject: { getInlineCollapsed: { default: () => noop }, + layoutSiderContext: { default: () => ({}) }, }, - isMenuItem: 1, + isMenuItem: true, methods: { onKeyDown(e) { this.$refs.menuItem.onKeyDown(e); @@ -20,22 +21,28 @@ export default { const { level, title, rootPrefixCls } = props; const { getInlineCollapsed, $slots, $attrs: attrs } = this; const inlineCollapsed = getInlineCollapsed(); - let titleNode; - if (inlineCollapsed) { - titleNode = title || (level === 1 ? $slots.default : ''); + const tooltipProps = { + title: title || (level === 1 ? $slots.default : ''), + }; + const siderCollapsed = this.layoutSiderContext.sCollapsed; + if (!siderCollapsed && !inlineCollapsed) { + tooltipProps.title = null; + // Reset `visible` to fix control mode tooltip display not correct + // ref: https://github.com/ant-design/ant-design/issues/16742 + tooltipProps.visible = false; } const itemProps = { props: { ...props, - title: inlineCollapsed ? null : title, + title, }, attrs, on: getListeners(this), }; const toolTipProps = { props: { - title: titleNode, + ...tooltipProps, placement: 'right', overlayClassName: `${rootPrefixCls}-inline-collapsed-tooltip`, }, diff --git a/components/menu/SubMenu.jsx b/components/menu/SubMenu.jsx new file mode 100644 index 000000000..302084bee --- /dev/null +++ b/components/menu/SubMenu.jsx @@ -0,0 +1,42 @@ +import { SubMenu as VcSubMenu } from '../vc-menu'; +import { getListeners } from '../_util/props-util'; +import classNames from 'classnames'; + +export default { + name: 'ASubMenu', + isSubMenu: true, + props: { ...VcSubMenu.props }, + inject: { + menuPropsContext: { default: () => ({}) }, + }, + methods: { + onKeyDown(e) { + this.$refs.subMenu.onKeyDown(e); + }, + }, + + render() { + const { $slots, $scopedSlots } = this; + const { rootPrefixCls, popupClassName } = this.$props; + const { theme: antdMenuTheme } = this.menuPropsContext; + const props = { + props: { + ...this.$props, + popupClassName: classNames(`${rootPrefixCls}-${antdMenuTheme}`, popupClassName), + }, + ref: 'subMenu', + on: getListeners(this), + scopedSlots: $scopedSlots, + }; + const slotsKey = Object.keys($slots); + return ( + + {slotsKey.length + ? slotsKey.map(name => { + return ; + }) + : null} + + ); + }, +}; diff --git a/components/menu/__tests__/index.test.js b/components/menu/__tests__/index.test.js index 2556e38d8..6b26056d4 100644 --- a/components/menu/__tests__/index.test.js +++ b/components/menu/__tests__/index.test.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; import { asyncExpect } from '@/tests/utils'; import Menu from '..'; import Icon from '../../icon'; +import mountTest from '../../../tests/shared/mountTest'; jest.mock('mutationobserver-shim', () => { global.MutationObserver = function MutationObserver() { @@ -15,6 +16,17 @@ function $$(className) { return document.body.querySelectorAll(className); } describe('Menu', () => { + mountTest({ + render() { + return ( + + + + + + ); + }, + }); beforeEach(() => { document.body.innerHTML = ''; // jest.useFakeTimers() diff --git a/components/menu/demo/index.vue b/components/menu/demo/index.vue index 9543d02e8..a07ad072f 100644 --- a/components/menu/demo/index.vue +++ b/components/menu/demo/index.vue @@ -16,9 +16,13 @@ const md = { 导航菜单是一个网站的灵魂,用户依赖导航在各个页面中进行跳转。一般分为顶部导航和侧边导航,顶部导航提供全局性的类目和功能,侧边导航提供多级结构来收纳和排列网站架构。 ## 代码演示`, us: `# Menu - Menu list of Navigation. + A versatile menu for navigation. + ## When To Use -Navigation menu is important for a website, it helps users jump from one site section to another quickly. Mostly, it includes top navigation and side navigation. Top navigation provides all the category and functions of the website. Side navigation provides the Multi-level structure of the website. + +Navigation is an important part of any website, as a good navigation setup allows users to move around the site quickly and efficiently. Ant Design offers top and side navigation options. Top navigation provides all the categories and functions of the website. Side navigation provides the multi-level structure of the website. + +More layouts with navigation: [Layout](/components/layout). ## Examples`, }; export default { diff --git a/components/menu/index.en-US.md b/components/menu/index.en-US.md index 0a8c3bbdd..bfcd6bfa0 100644 --- a/components/menu/index.en-US.md +++ b/components/menu/index.en-US.md @@ -50,11 +50,12 @@ ### Menu.SubMenu -| Param | Description | Type | Default value | -| -------- | ----------------------------------- | ------------ | ------------- | -| disabled | whether sub menu is disabled or not | boolean | false | -| key | unique id of the sub menu | string | | -| title | title of the sub menu | string\|slot | | +| Param | Description | Type | Default value | Version | +| -------------- | ----------------------------------- | ------------ | ------------- | ------- | +| popupClassName | Sub-menu class name | string | | 1.5.0 | +| disabled | whether sub menu is disabled or not | boolean | false | | +| key | Unique ID of the sub menu | string | | | +| title | title of the sub menu | string\|slot | | | The children of Menu.SubMenu must be `MenuItem` or `SubMenu`. @@ -68,7 +69,7 @@ The children of Menu.SubMenu must be `MenuItem` or `SubMenu`. | Param | Description | Type | Default value | | -------- | ------------------ | ------------ | ------------- | -| children | sub menu items | MenuItem\[] | | +| children | sub-menu items | MenuItem\[] | | | title | title of the group | string\|slot | | The children of Menu.ItemGroup must be `MenuItem`. diff --git a/components/menu/index.jsx b/components/menu/index.jsx index ec5c9ccb3..d19077b30 100644 --- a/components/menu/index.jsx +++ b/components/menu/index.jsx @@ -1,14 +1,16 @@ import omit from 'omit.js'; -import VcMenu, { Divider, ItemGroup, SubMenu } from '../vc-menu'; +import VcMenu, { Divider, ItemGroup } from '../vc-menu'; +import SubMenu from './SubMenu'; import PropTypes from '../_util/vue-types'; import animation from '../_util/openAnimation'; import warning from '../_util/warning'; import Item from './MenuItem'; -import { hasProp, getListeners } from '../_util/props-util'; +import { hasProp, getListeners, getOptionProps } from '../_util/props-util'; import BaseMixin from '../_util/BaseMixin'; import commonPropsType from '../vc-menu/commonPropsType'; import { ConfigConsumerProps } from '../config-provider'; import Base from '../base'; +// import raf from '../_util/raf'; export const MenuMode = PropTypes.oneOf([ 'vertical', @@ -47,6 +49,7 @@ const Menu = { provide() { return { getInlineCollapsed: this.getInlineCollapsed, + menuPropsContext: this.$props, }; }, mixins: [BaseMixin], @@ -58,12 +61,12 @@ const Menu = { prop: 'selectedKeys', event: 'selectChange', }, - created() { - this.preProps = { ...this.$props }; - }, updated() { this.propsUpdating = false; }, + // beforeDestroy() { + // raf.cancel(this.mountRafId); + // }, watch: { mode(val, oldVal) { if (oldVal === 'inline' && val !== 'inline') { @@ -81,9 +84,10 @@ const Menu = { }, }, data() { - const props = this.$props; + const props = getOptionProps(this); warning( - !(hasProp(this, 'inlineCollapsed') && props.mode !== 'inline'), + !('inlineCollapsed' in props && props.mode !== 'inline'), + 'Menu', "`inlineCollapsed` should only be used when Menu's `mode` is inline.", ); this.switchingModeFromInline = false; @@ -91,9 +95,9 @@ const Menu = { this.inlineOpenKeys = []; let sOpenKeys; - if (hasProp(this, 'openKeys')) { + if ('openKeys' in props) { sOpenKeys = props.openKeys; - } else if (hasProp(this, 'defaultOpenKeys')) { + } else if ('defaultOpenKeys' in props) { sOpenKeys = props.defaultOpenKeys; } return { @@ -137,10 +141,20 @@ const Menu = { // when inlineCollapsed menu width animation finished // https://github.com/ant-design/ant-design/issues/12864 const widthCollapsed = e.propertyName === 'width' && e.target === e.currentTarget; + + // Fix SVGElement e.target.className.indexOf is not a function + // https://github.com/ant-design/ant-design/issues/15699 + const { className } = e.target; + // SVGAnimatedString.animVal should be identical to SVGAnimatedString.baseVal, unless during an animation. + const classNameValue = + Object.prototype.toString.call(className) === '[object SVGAnimatedString]' + ? className.animVal + : className; + // Fix for , the width transition won't trigger when menu is collapsed // https://github.com/ant-design/ant-design-pro/issues/2783 - const iconScaled = - e.propertyName === 'font-size' && e.target.className.indexOf('anticon') >= 0; + const iconScaled = e.propertyName === 'font-size' && classNameValue.indexOf('anticon') >= 0; + if (widthCollapsed || iconScaled) { this.restoreModeVerticalFromInline(); } @@ -254,11 +268,11 @@ const Menu = { } // https://github.com/ant-design/ant-design/issues/8587 - if ( + const hideMenu = this.getInlineCollapsed() && - (collapsedWidth === 0 || collapsedWidth === '0' || collapsedWidth === '0px') - ) { - return null; + (collapsedWidth === 0 || collapsedWidth === '0' || collapsedWidth === '0px'); + if (hideMenu) { + menuProps.props.openKeys = []; } return ( diff --git a/components/menu/index.zh-CN.md b/components/menu/index.zh-CN.md index 404dfee24..e52d5aa1d 100644 --- a/components/menu/index.zh-CN.md +++ b/components/menu/index.zh-CN.md @@ -49,11 +49,12 @@ ### Menu.SubMenu -| 参数 | 说明 | 类型 | 默认值 | -| -------- | ---------- | ------------ | ------ | -| disabled | 是否禁用 | boolean | false | -| key | 唯一标志 | string | | -| title | 子菜单项值 | string\|slot | | +| 参数 | 说明 | 类型 | 默认值 | 版本 | +| -------------- | ---------- | ------------ | ------ | ----- | +| popupClassName | 子菜单样式 | string | | 1.5.0 | +| disabled | 是否禁用 | boolean | false | | +| key | 唯一标志 | string | | | +| title | 子菜单项值 | string\|slot | | | Menu.SubMenu 的子元素必须是 `MenuItem` 或者 `SubMenu`. diff --git a/components/vc-menu/DOMWrap.jsx b/components/vc-menu/DOMWrap.jsx index 1db65ee66..54965afae 100644 --- a/components/vc-menu/DOMWrap.jsx +++ b/components/vc-menu/DOMWrap.jsx @@ -85,7 +85,7 @@ const DOMWrap = { this.resizeObserver.disconnect(); } if (this.mutationObserver) { - this.resizeObserver.disconnect(); + this.mutationObserver.disconnect(); } }, methods: { @@ -98,9 +98,9 @@ const DOMWrap = { } // filter out all overflowed indicator placeholder - return [].slice.call(ul.children).filter(node => { - return node.className.split(' ').indexOf(`${prefixCls}-overflowed-submenu`) < 0; - }); + return [].slice + .call(ul.children) + .filter(node => node.className.split(' ').indexOf(`${prefixCls}-overflowed-submenu`) < 0); }, getOverflowedSubMenuItem(keyPrefix, overflowedItems, renderPlaceholder) { @@ -111,10 +111,11 @@ const DOMWrap = { // put all the overflowed item inside a submenu // with a title of overflow indicator ('...') const copy = this.$slots.default[0]; - const { title, eventKey, ...rest } = getPropsData(copy); // eslint-disable-line no-unused-vars + const { title, ...rest } = getPropsData(copy); // eslint-disable-line no-unused-vars let style = {}; let key = `${keyPrefix}-overflowed-indicator`; + let eventKey = `${keyPrefix}-overflowed-indicator`; if (overflowedItems.length === 0 && renderPlaceholder !== true) { style = { @@ -127,6 +128,7 @@ const DOMWrap = { position: 'absolute', }; key = `${key}-placeholder`; + eventKey = `${eventKey}-placeholder`; } const popupClassName = theme ? `${prefixCls}-${theme}` : ''; @@ -141,7 +143,7 @@ const DOMWrap = { title: overflowedIndicator, popupClassName, ...props, - eventKey: `${keyPrefix}-overflowed-indicator`, + eventKey, disabled: false, }, class: `${prefixCls}-overflowed-submenu`, @@ -226,7 +228,7 @@ const DOMWrap = { this.menuItemSizes.forEach(liWidth => { currentSumWidth += liWidth; if (currentSumWidth + this.overflowedIndicatorWidth <= width) { - lastVisibleIndex++; + lastVisibleIndex += 1; } }); } @@ -251,7 +253,7 @@ const DOMWrap = { { style: { display: 'none' }, props: { eventKey: `${eventKey}-hidden` }, - class: { ...getClass(childNode), [MENUITEM_OVERFLOWED_CLASSNAME]: true }, + class: MENUITEM_OVERFLOWED_CLASSNAME, }, ); } diff --git a/components/vc-menu/MenuItem.jsx b/components/vc-menu/MenuItem.jsx index 301469ae0..1b04533aa 100644 --- a/components/vc-menu/MenuItem.jsx +++ b/components/vc-menu/MenuItem.jsx @@ -40,16 +40,22 @@ const MenuItem = { mixins: [BaseMixin], isMenuItem: true, created() { + this.prevActive = this.active; // invoke customized ref to expose component to mixin this.callRef(); }, updated() { this.$nextTick(() => { - if (this.active) { + const { active, parentMenu, eventKey } = this.$props; + if (!this.prevActive && active && (!parentMenu || !parentMenu[`scrolled-${eventKey}`])) { scrollIntoView(this.$el, this.parentMenu.$el, { onlyScrollIfNeeded: true, }); + parentMenu[`scrolled-${eventKey}`] = true; + } else if (parentMenu && parentMenu[`scrolled-${eventKey}`]) { + delete parentMenu[`scrolled-${eventKey}`]; } + this.prevActive = active; }); this.callRef(); }, diff --git a/components/vc-menu/MenuItemGroup.jsx b/components/vc-menu/MenuItemGroup.jsx index 13ff0ac1e..d8525dbd3 100644 --- a/components/vc-menu/MenuItemGroup.jsx +++ b/components/vc-menu/MenuItemGroup.jsx @@ -1,5 +1,6 @@ import PropTypes from '../_util/vue-types'; import { getComponentFromProp, getListeners } from '../_util/props-util'; + // import { menuAllProps } from './util' const MenuItemGroup = { diff --git a/components/vc-menu/SubMenu.jsx b/components/vc-menu/SubMenu.jsx index 8dc115eab..e211e8506 100644 --- a/components/vc-menu/SubMenu.jsx +++ b/components/vc-menu/SubMenu.jsx @@ -10,6 +10,7 @@ import { getComponentFromProp, filterEmpty, getListeners } from '../_util/props- import { requestAnimationTimeout, cancelAnimationTimeout } from '../_util/requestAnimationTimeout'; import { noop, loopMenuItemRecursively, getMenuIdFromSubMenuEventKey } from './util'; import getTransitionProps from '../_util/getTransitionProps'; +import { MenuItem } from './MenuItem'; let guid = 0; @@ -171,6 +172,7 @@ const SubMenu = { if (isOpen && (keyCode === KeyCode.UP || keyCode === KeyCode.DOWN)) { return menu.onKeyDown(e); } + return undefined; }, onPopupVisibleChange(visible) { @@ -368,7 +370,7 @@ const SubMenu = { deselect, openChange, }, - id: this._menuId, + id: this.internalMenuId, }; const baseProps = subPopupMenuProps.props; const haveRendered = this.haveRendered; @@ -429,11 +431,11 @@ const SubMenu = { [this.getSelectedClassName()]: this.isChildrenSelected(), }; - if (!this._menuId) { + if (!this.internalMenuId) { if (props.eventKey) { - this._menuId = `${props.eventKey}$Menu`; + this.internalMenuId = `${props.eventKey}$Menu`; } else { - this._menuId = `$__$${++guid}$Menu`; + this.internalMenuId = `$__$${++guid}$Menu`; } } @@ -466,7 +468,7 @@ const SubMenu = { // since corresponding node cannot be found if (isOpen) { ariaOwns = { - 'aria-owns': this._menuId, + 'aria-owns': this.internalMenuId, }; } const titleProps = { diff --git a/components/vc-menu/SubPopupMenu.jsx b/components/vc-menu/SubPopupMenu.jsx index 76fbbbea2..8aa8d10d4 100644 --- a/components/vc-menu/SubPopupMenu.jsx +++ b/components/vc-menu/SubPopupMenu.jsx @@ -193,6 +193,7 @@ const SubPopupMenu = { return 1; } + return undefined; }, onItemHover(e) { diff --git a/components/vc-menu/index.js b/components/vc-menu/index.js index 16af52416..827f24ff7 100644 --- a/components/vc-menu/index.js +++ b/components/vc-menu/index.js @@ -1,4 +1,4 @@ -// based on rc-menu 7.4.21 +// based on rc-menu 7.5.5 import Menu from './Menu'; import SubMenu from './SubMenu'; import MenuItem, { menuItemProps } from './MenuItem'; diff --git a/components/vc-menu/util.js b/components/vc-menu/util.js index 50469aa4b..c48a4c690 100644 --- a/components/vc-menu/util.js +++ b/components/vc-menu/util.js @@ -1,4 +1,4 @@ -const isMobile = require('ismobilejs'); +import isMobile from './utils/isMobile'; export function noop() {} diff --git a/components/vc-menu/utils/isMobile.js b/components/vc-menu/utils/isMobile.js new file mode 100644 index 000000000..96927fd3b --- /dev/null +++ b/components/vc-menu/utils/isMobile.js @@ -0,0 +1,110 @@ +// MIT License from https://github.com/kaimallea/isMobile + +const applePhone = /iPhone/i; +const appleIpod = /iPod/i; +const appleTablet = /iPad/i; +const androidPhone = /\bAndroid(?:.+)Mobile\b/i; // Match 'Android' AND 'Mobile' +const androidTablet = /Android/i; +const amazonPhone = /\bAndroid(?:.+)SD4930UR\b/i; +const amazonTablet = /\bAndroid(?:.+)(?:KF[A-Z]{2,4})\b/i; +const windowsPhone = /Windows Phone/i; +const windowsTablet = /\bWindows(?:.+)ARM\b/i; // Match 'Windows' AND 'ARM' +const otherBlackberry = /BlackBerry/i; +const otherBlackberry10 = /BB10/i; +const otherOpera = /Opera Mini/i; +const otherChrome = /\b(CriOS|Chrome)(?:.+)Mobile/i; +const otherFirefox = /Mobile(?:.+)Firefox\b/i; // Match 'Mobile' AND 'Firefox' + +function match(regex, userAgent) { + return regex.test(userAgent); +} + +function isMobile(userAgent) { + let ua = userAgent || (typeof navigator !== 'undefined' ? navigator.userAgent : ''); + + // Facebook mobile app's integrated browser adds a bunch of strings that + // match everything. Strip it out if it exists. + let tmp = ua.split('[FBAN'); + if (typeof tmp[1] !== 'undefined') { + [ua] = tmp; + } + + // Twitter mobile app's integrated browser on iPad adds a "Twitter for + // iPhone" string. Same probably happens on other tablet platforms. + // This will confuse detection so strip it out if it exists. + tmp = ua.split('Twitter'); + if (typeof tmp[1] !== 'undefined') { + [ua] = tmp; + } + + const result = { + apple: { + phone: match(applePhone, ua) && !match(windowsPhone, ua), + ipod: match(appleIpod, ua), + tablet: !match(applePhone, ua) && match(appleTablet, ua) && !match(windowsPhone, ua), + device: + (match(applePhone, ua) || match(appleIpod, ua) || match(appleTablet, ua)) && + !match(windowsPhone, ua), + }, + amazon: { + phone: match(amazonPhone, ua), + tablet: !match(amazonPhone, ua) && match(amazonTablet, ua), + device: match(amazonPhone, ua) || match(amazonTablet, ua), + }, + android: { + phone: + (!match(windowsPhone, ua) && match(amazonPhone, ua)) || + (!match(windowsPhone, ua) && match(androidPhone, ua)), + tablet: + !match(windowsPhone, ua) && + !match(amazonPhone, ua) && + !match(androidPhone, ua) && + (match(amazonTablet, ua) || match(androidTablet, ua)), + device: + (!match(windowsPhone, ua) && + (match(amazonPhone, ua) || + match(amazonTablet, ua) || + match(androidPhone, ua) || + match(androidTablet, ua))) || + match(/\bokhttp\b/i, ua), + }, + windows: { + phone: match(windowsPhone, ua), + tablet: match(windowsTablet, ua), + device: match(windowsPhone, ua) || match(windowsTablet, ua), + }, + other: { + blackberry: match(otherBlackberry, ua), + blackberry10: match(otherBlackberry10, ua), + opera: match(otherOpera, ua), + firefox: match(otherFirefox, ua), + chrome: match(otherChrome, ua), + device: + match(otherBlackberry, ua) || + match(otherBlackberry10, ua) || + match(otherOpera, ua) || + match(otherFirefox, ua) || + match(otherChrome, ua), + }, + + // Additional + any: null, + phone: null, + tablet: null, + }; + result.any = + result.apple.device || result.android.device || result.windows.device || result.other.device; + + // excludes 'other' devices and ipods, targeting touchscreen phones + result.phone = result.apple.phone || result.android.phone || result.windows.phone; + result.tablet = result.apple.tablet || result.android.tablet || result.windows.tablet; + + return result; +} + +const defaultResult = { + ...isMobile(), + isMobile, +}; + +export default defaultResult; diff --git a/types/menu/sub-menu.d.ts b/types/menu/sub-menu.d.ts index 1494381da..328f6e7ff 100644 --- a/types/menu/sub-menu.d.ts +++ b/types/menu/sub-menu.d.ts @@ -24,4 +24,6 @@ export declare class SubMenu extends AntdComponent { * @type string | slot */ title: any; + + popupClassName: string; }