diff --git a/components/_util/transition.tsx b/components/_util/transition.tsx index 0281d680eb..3fc0478629 100644 --- a/components/_util/transition.tsx +++ b/components/_util/transition.tsx @@ -1,4 +1,12 @@ -import { defineComponent, nextTick, Transition as T, TransitionGroup as TG } from 'vue'; +import { + BaseTransitionProps, + CSSProperties, + defineComponent, + nextTick, + Ref, + Transition as T, + TransitionGroup as TG, +} from 'vue'; import { findDOMNode } from './props-util'; export const getTransitionProps = (transitionName: string, opt: object = {}) => { @@ -80,6 +88,63 @@ if (process.env.NODE_ENV === 'test') { }); } -export { Transition, TransitionGroup }; +export declare type MotionEvent = (TransitionEvent | AnimationEvent) & { + deadline?: boolean; +}; + +export declare type MotionEventHandler = (element: Element, done?: () => void) => CSSProperties; + +export declare type MotionEndEventHandler = (element: Element, done?: () => void) => boolean | void; + +// ================== Collapse Motion ================== +const getCollapsedHeight: MotionEventHandler = () => ({ height: 0, opacity: 0 }); +const getRealHeight: MotionEventHandler = node => ({ + height: `${node.scrollHeight}px`, + opacity: 1, +}); +const getCurrentHeight: MotionEventHandler = (node: any) => ({ height: `${node.offsetHeight}px` }); +// const skipOpacityTransition: MotionEndEventHandler = (_, event) => +// (event as TransitionEvent).propertyName === 'height'; + +export interface CSSMotionProps extends Partial> { + name?: string; + css?: boolean; +} + +const collapseMotion = (style: Ref, className: Ref): CSSMotionProps => { + return { + name: 'ant-motion-collapse', + appear: true, + css: true, + onBeforeEnter: node => { + className.value = 'ant-motion-collapse'; + style.value = getCollapsedHeight(node); + }, + onEnter: node => { + nextTick(() => { + style.value = getRealHeight(node); + }); + }, + onAfterEnter: () => { + className.value = ''; + style.value = {}; + }, + onBeforeLeave: node => { + className.value = 'ant-motion-collapse'; + style.value = getCurrentHeight(node); + }, + onLeave: node => { + window.setTimeout(() => { + style.value = getCollapsedHeight(node); + }); + }, + onAfterLeave: () => { + className.value = ''; + style.value = {}; + }, + }; +}; + +export { Transition, TransitionGroup, collapseMotion }; export default Transition; diff --git a/components/menu/MenuItem.tsx b/components/menu/MenuItem.tsx deleted file mode 100644 index fa4ac904ed..0000000000 --- a/components/menu/MenuItem.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { defineComponent, inject } from 'vue'; -import { Item, itemProps } from '../vc-menu'; -import { getOptionProps, getSlot } from '../_util/props-util'; -import Tooltip, { TooltipProps } from '../tooltip'; -import { SiderContextProps } from '../layout/Sider'; -import { injectExtraPropsKey } from '../vc-menu/FunctionProvider'; -import PropTypes from '../_util/vue-types'; - -export default defineComponent({ - name: 'MenuItem', - inheritAttrs: false, - props: { - ...itemProps, - onClick: PropTypes.func, - }, - isMenuItem: true, - setup() { - return { - getInlineCollapsed: inject<() => boolean>('getInlineCollapsed', () => false), - layoutSiderContext: inject('layoutSiderContext', {}), - injectExtraProps: inject(injectExtraPropsKey, () => ({})), - }; - }, - methods: { - onKeyDown(e: HTMLElement) { - (this.$refs.menuItem as any).onKeyDown(e); - }, - }, - render() { - const props = getOptionProps(this); - const { level, title, rootPrefixCls } = { ...props, ...this.injectExtraProps } as any; - const { getInlineCollapsed, $attrs: attrs } = this; - const inlineCollapsed = getInlineCollapsed(); - let tooltipTitle = title; - const children = getSlot(this); - if (typeof title === 'undefined') { - tooltipTitle = level === 1 ? children : ''; - } else if (title === false) { - tooltipTitle = ''; - } - const tooltipProps: TooltipProps = { - title: tooltipTitle, - }; - 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, - title, - ...attrs, - ref: 'menuItem', - }; - const toolTipProps: TooltipProps = { - ...tooltipProps, - placement: 'right', - overlayClassName: `${rootPrefixCls}-inline-collapsed-tooltip`, - }; - const item = {children}; - return {item}; - }, -}); diff --git a/components/menu/SubMenu.tsx b/components/menu/SubMenu.tsx deleted file mode 100644 index 24edc9dbff..0000000000 --- a/components/menu/SubMenu.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { defineComponent, inject } from 'vue'; -import { SubMenu as VcSubMenu } from '../vc-menu'; -import classNames from '../_util/classNames'; -import { injectExtraPropsKey } from '../vc-menu/FunctionProvider'; - -export type MenuTheme = 'light' | 'dark'; - -export interface MenuContextProps { - inlineCollapsed?: boolean; - theme?: MenuTheme; -} - -export default defineComponent({ - name: 'ASubMenu', - isSubMenu: true, - inheritAttrs: false, - props: { ...VcSubMenu.props }, - setup() { - return { - menuPropsContext: inject('menuPropsContext', {}), - injectExtraProps: inject(injectExtraPropsKey, () => ({})), - }; - }, - methods: { - onKeyDown(e: Event) { - (this.$refs.subMenu as any).onKeyDown(e); - }, - }, - - render() { - const { $slots, $attrs } = this; - const { rootPrefixCls, popupClassName } = { ...this.$props, ...this.injectExtraProps } as any; - const { theme: antdMenuTheme } = this.menuPropsContext; - const props = { - ...this.$props, - popupClassName: classNames(`${rootPrefixCls}-${antdMenuTheme}`, popupClassName), - ref: 'subMenu', - ...$attrs, - } as any; - return ; - }, -}); diff --git a/components/menu/__tests__/__snapshots__/demo.test.js.snap b/components/menu/__tests__/__snapshots__/demo.test.js.snap deleted file mode 100644 index 4266989b95..0000000000 --- a/components/menu/__tests__/__snapshots__/demo.test.js.snap +++ /dev/null @@ -1,327 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders ./antdv-demo/docs/menu/demo/horizontal.md correctly 1`] = ` -
- -
-`; - -exports[`renders ./antdv-demo/docs/menu/demo/inline.md correctly 1`] = ` -
- -
-`; - -exports[`renders ./antdv-demo/docs/menu/demo/inline-collapsed.md correctly 1`] = ` -
- -
-`; - -exports[`renders ./antdv-demo/docs/menu/demo/sider-current.md correctly 1`] = ` -
- -
-`; - -exports[`renders ./antdv-demo/docs/menu/demo/switch-mode.md correctly 1`] = ` -
Change Mode Change Theme

- -
-`; - -exports[`renders ./antdv-demo/docs/menu/demo/template.md correctly 1`] = ` -
- -
-`; - -exports[`renders ./antdv-demo/docs/menu/demo/theme.md correctly 1`] = ` -


- -
-`; - -exports[`renders ./antdv-demo/docs/menu/demo/vertical.md correctly 1`] = ` -
- -
-`; diff --git a/components/menu/index.tsx b/components/menu/index.tsx index 842ace832f..120eed0742 100644 --- a/components/menu/index.tsx +++ b/components/menu/index.tsx @@ -1,322 +1,22 @@ -import { defineComponent, inject, provide, toRef, App, ExtractPropTypes, Plugin } from 'vue'; -import omit from 'omit.js'; -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, getOptionProps } from '../_util/props-util'; -import BaseMixin from '../_util/BaseMixin'; -import commonPropsType from '../vc-menu/commonPropsType'; -import { defaultConfigProvider } from '../config-provider'; -import { SiderContextProps } from '../layout/Sider'; -import { tuple } from '../_util/type'; -// import raf from '../_util/raf'; - -export const MenuMode = PropTypes.oneOf([ - 'vertical', - 'vertical-left', - 'vertical-right', - 'horizontal', - 'inline', -]); - -export const menuProps = { - ...commonPropsType, - theme: PropTypes.oneOf(tuple('light', 'dark')).def('light'), - mode: MenuMode.def('vertical'), - selectable: PropTypes.looseBool, - selectedKeys: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), - defaultSelectedKeys: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), - openKeys: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), - defaultOpenKeys: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), - openAnimation: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - openTransitionName: PropTypes.string, - prefixCls: PropTypes.string, - multiple: PropTypes.looseBool, - inlineIndent: PropTypes.number.def(24), - inlineCollapsed: PropTypes.looseBool, - isRootMenu: PropTypes.looseBool.def(true), - focusable: PropTypes.looseBool.def(false), - onOpenChange: PropTypes.func, - onSelect: PropTypes.func, - onDeselect: PropTypes.func, - onClick: PropTypes.func, - onMouseenter: PropTypes.func, - onSelectChange: PropTypes.func, -}; - -export type MenuProps = Partial>; - -const Menu = defineComponent({ - name: 'AMenu', - mixins: [BaseMixin], - inheritAttrs: false, - props: menuProps, - Divider: { ...Divider, name: 'AMenuDivider' }, - Item: { ...Item, name: 'AMenuItem' }, - SubMenu: { ...SubMenu, name: 'ASubMenu' }, - ItemGroup: { ...ItemGroup, name: 'AMenuItemGroup' }, - emits: [ - 'update:selectedKeys', - 'update:openKeys', - 'mouseenter', - 'openChange', - 'click', - 'selectChange', - 'select', - 'deselect', - ], - setup() { - const layoutSiderContext = inject('layoutSiderContext', {}); - const layoutSiderCollapsed = toRef(layoutSiderContext, 'sCollapsed'); - return { - configProvider: inject('configProvider', defaultConfigProvider), - layoutSiderContext, - layoutSiderCollapsed, - propsUpdating: false, - switchingModeFromInline: false, - leaveAnimationExecutedWhenInlineCollapsed: false, - inlineOpenKeys: [], - }; - }, - data() { - const props: MenuProps = getOptionProps(this); - warning( - !('inlineCollapsed' in props && props.mode !== 'inline'), - 'Menu', - "`inlineCollapsed` should only be used when Menu's `mode` is inline.", - ); - let sOpenKeys: (number | string)[]; - - if ('openKeys' in props) { - sOpenKeys = props.openKeys; - } else if ('defaultOpenKeys' in props) { - sOpenKeys = props.defaultOpenKeys; - } - return { - sOpenKeys, - }; - }, - // beforeUnmount() { - // raf.cancel(this.mountRafId); - // }, - watch: { - mode(val, oldVal) { - if (oldVal === 'inline' && val !== 'inline') { - this.switchingModeFromInline = true; - } - }, - openKeys(val) { - this.setState({ sOpenKeys: val }); - }, - inlineCollapsed(val) { - this.collapsedChange(val); - }, - layoutSiderCollapsed(val) { - this.collapsedChange(val); - }, - }, - created() { - provide('getInlineCollapsed', this.getInlineCollapsed); - provide('menuPropsContext', this.$props); - }, - updated() { - this.propsUpdating = false; - }, - methods: { - collapsedChange(val: unknown) { - if (this.propsUpdating) { - return; - } - this.propsUpdating = true; - if (!hasProp(this, 'openKeys')) { - if (val) { - this.switchingModeFromInline = true; - this.inlineOpenKeys = this.sOpenKeys; - this.setState({ sOpenKeys: [] }); - } else { - this.setState({ sOpenKeys: this.inlineOpenKeys }); - this.inlineOpenKeys = []; - } - } else if (val) { - // 缩起时,openKeys置为空的动画会闪动,react可以通过是否传递openKeys避免闪动,vue不是很方便动态传递openKeys - this.switchingModeFromInline = true; - } - }, - restoreModeVerticalFromInline() { - if (this.switchingModeFromInline) { - this.switchingModeFromInline = false; - this.$forceUpdate(); - } - }, - // Restore vertical mode when menu is collapsed responsively when mounted - // https://github.com/ant-design/ant-design/issues/13104 - // TODO: not a perfect solution, looking a new way to avoid setting switchingModeFromInline in this situation - handleMouseEnter(e: Event) { - this.restoreModeVerticalFromInline(); - this.$emit('mouseenter', e); - }, - handleTransitionEnd(e: TransitionEvent) { - // 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 as SVGAnimationElement | HTMLElement; - // 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' && classNameValue.indexOf('anticon') >= 0; - - if (widthCollapsed || iconScaled) { - this.restoreModeVerticalFromInline(); - } - }, - handleClick(e: Event) { - this.handleOpenChange([]); - this.$emit('click', e); - }, - handleSelect(info) { - this.$emit('update:selectedKeys', info.selectedKeys); - this.$emit('select', info); - this.$emit('selectChange', info.selectedKeys); - }, - handleDeselect(info) { - this.$emit('update:selectedKeys', info.selectedKeys); - this.$emit('deselect', info); - this.$emit('selectChange', info.selectedKeys); - }, - handleOpenChange(openKeys: (number | string)[]) { - this.setOpenKeys(openKeys); - this.$emit('update:openKeys', openKeys); - this.$emit('openChange', openKeys); - }, - setOpenKeys(openKeys: (number | string)[]) { - if (!hasProp(this, 'openKeys')) { - this.setState({ sOpenKeys: openKeys }); - } - }, - getRealMenuMode() { - const inlineCollapsed = this.getInlineCollapsed(); - if (this.switchingModeFromInline && inlineCollapsed) { - return 'inline'; - } - const { mode } = this.$props; - return inlineCollapsed ? 'vertical' : mode; - }, - getInlineCollapsed() { - const { inlineCollapsed } = this.$props; - if (this.layoutSiderContext.sCollapsed !== undefined) { - return this.layoutSiderContext.sCollapsed; - } - return inlineCollapsed; - }, - getMenuOpenAnimation(menuMode: string) { - const { openAnimation, openTransitionName } = this.$props; - let menuOpenAnimation = openAnimation || openTransitionName; - if (openAnimation === undefined && openTransitionName === undefined) { - if (menuMode === 'horizontal') { - menuOpenAnimation = 'slide-up'; - } else if (menuMode === 'inline') { - menuOpenAnimation = animation; - } else { - // When mode switch from inline - // submenu should hide without animation - if (this.switchingModeFromInline) { - menuOpenAnimation = ''; - this.switchingModeFromInline = false; - } else { - menuOpenAnimation = 'zoom-big'; - } - } - } - return menuOpenAnimation; - }, - }, - render() { - const { layoutSiderContext } = this; - const { collapsedWidth } = layoutSiderContext; - const { getPopupContainer: getContextPopupContainer } = this.configProvider; - const props = getOptionProps(this); - const { prefixCls: customizePrefixCls, theme, getPopupContainer } = props; - const getPrefixCls = this.configProvider.getPrefixCls; - const prefixCls = getPrefixCls('menu', customizePrefixCls); - const menuMode = this.getRealMenuMode(); - const menuOpenAnimation = this.getMenuOpenAnimation(menuMode); - const { class: className, ...otherAttrs } = this.$attrs; - const menuClassName = { - [className as string]: className, - [`${prefixCls}-${theme}`]: true, - [`${prefixCls}-inline-collapsed`]: this.getInlineCollapsed(), - }; - - const menuProps = { - ...omit(props, [ - 'inlineCollapsed', - 'onUpdate:selectedKeys', - 'onUpdate:openKeys', - 'onSelectChange', - ]), - getPopupContainer: getPopupContainer || getContextPopupContainer, - openKeys: this.sOpenKeys, - mode: menuMode, - prefixCls, - ...otherAttrs, - onSelect: this.handleSelect, - onDeselect: this.handleDeselect, - onOpenChange: this.handleOpenChange, - onMouseenter: this.handleMouseEnter, - onTransitionend: this.handleTransitionEnd, - // children: getSlot(this), - }; - if (!hasProp(this, 'selectedKeys')) { - delete menuProps.selectedKeys; - } - - if (menuMode !== 'inline') { - // closing vertical popup submenu after click it - menuProps.onClick = this.handleClick; - menuProps.openTransitionName = menuOpenAnimation; - } else { - menuProps.onClick = (e: Event) => { - this.$emit('click', e); - }; - menuProps.openAnimation = menuOpenAnimation; - } - - // https://github.com/ant-design/ant-design/issues/8587 - const hideMenu = - this.getInlineCollapsed() && - (collapsedWidth === 0 || collapsedWidth === '0' || collapsedWidth === '0px'); - if (hideMenu) { - menuProps.openKeys = []; - } - - return ; - }, -}); - +import Menu from './src/Menu'; +import MenuItem from './src/MenuItem'; +import SubMenu from './src/SubMenu'; +import ItemGroup from './src/ItemGroup'; +import Divider from './src/Divider'; +import { App } from 'vue'; /* istanbul ignore next */ Menu.install = function(app: App) { app.component(Menu.name, Menu); - app.component(Menu.Item.name, Menu.Item); - app.component(Menu.SubMenu.name, Menu.SubMenu); - app.component(Menu.Divider.name, Menu.Divider); - app.component(Menu.ItemGroup.name, Menu.ItemGroup); + app.component(MenuItem.name, MenuItem); + app.component(SubMenu.name, SubMenu); + app.component(Divider.name, Divider); + app.component(ItemGroup.name, ItemGroup); return app; }; export default Menu as typeof Menu & Plugin & { - readonly Item: typeof Item; + readonly Item: typeof MenuItem; readonly SubMenu: typeof SubMenu; readonly Divider: typeof Divider; readonly ItemGroup: typeof ItemGroup; diff --git a/components/menu/src/Divider.tsx b/components/menu/src/Divider.tsx new file mode 100644 index 0000000000..8e9d99ad5a --- /dev/null +++ b/components/menu/src/Divider.tsx @@ -0,0 +1,13 @@ +import { defineComponent } from 'vue'; + +export default defineComponent({ + name: 'Divider', + props: { + prefixCls: String, + }, + setup(props) { + return () => { + return
  • ; + }; + }, +}); diff --git a/components/menu/src/InlineSubMenuList.tsx b/components/menu/src/InlineSubMenuList.tsx new file mode 100644 index 0000000000..4b42a6008c --- /dev/null +++ b/components/menu/src/InlineSubMenuList.tsx @@ -0,0 +1,66 @@ +import { computed, defineComponent, ref, watch } from '@vue/runtime-core'; +import Transition from '../../_util/transition'; +import { useInjectMenu, MenuContextProvider } from './hooks/useMenuContext'; +import SubMenuList from './SubMenuList'; + +export default defineComponent({ + name: 'InlineSubMenuList', + inheritAttrs: false, + props: { + id: String, + open: Boolean, + keyPath: Array, + }, + setup(props, { slots }) { + const fixedMode = computed(() => 'inline'); + const { motion, mode, defaultMotions } = useInjectMenu(); + const sameModeRef = computed(() => mode.value === fixedMode.value); + const destroy = ref(!sameModeRef.value); + + const mergedOpen = computed(() => (sameModeRef.value ? props.open : false)); + + // ================================= Effect ================================= + // Reset destroy state when mode change back + watch( + mode, + () => { + if (sameModeRef.value) { + destroy.value = false; + } + }, + { flush: 'post' }, + ); + const style = ref({}); + const className = ref(''); + const mergedMotion = computed(() => { + const m = + motion.value || defaultMotions.value?.[fixedMode.value] || defaultMotions.value?.other; + const res = typeof m === 'function' ? m(style, className) : m; + return { ...res, appear: props.keyPath.length <= 1 }; + }); + return () => { + if (destroy.value) { + return null; + } + return ( + + + + {slots.default?.()} + + + + ); + }; + }, +}); diff --git a/components/menu/src/ItemGroup.tsx b/components/menu/src/ItemGroup.tsx new file mode 100644 index 0000000000..993f0cb4de --- /dev/null +++ b/components/menu/src/ItemGroup.tsx @@ -0,0 +1,30 @@ +import { getPropsSlot } from '../../_util/props-util'; +import { computed, defineComponent } from 'vue'; +import PropTypes from '../../_util/vue-types'; +import { useInjectMenu } from './hooks/useMenuContext'; + +export default defineComponent({ + name: 'AMenuItemGroup', + props: { + title: PropTypes.VNodeChild, + }, + inheritAttrs: false, + slots: ['title'], + setup(props, { slots, attrs }) { + const { prefixCls } = useInjectMenu(); + const groupPrefixCls = computed(() => `${prefixCls.value}-item-group`); + return () => { + return ( +
  • e.stopPropagation()} class={groupPrefixCls.value}> +
    + {getPropsSlot(slots, props, 'title')} +
    +
      {slots.default?.()}
    +
  • + ); + }; + }, +}); diff --git a/components/menu/src/Menu.tsx b/components/menu/src/Menu.tsx new file mode 100644 index 0000000000..6582e57d23 --- /dev/null +++ b/components/menu/src/Menu.tsx @@ -0,0 +1,328 @@ +import { Key } from '../../_util/type'; +import { + computed, + defineComponent, + ExtractPropTypes, + ref, + PropType, + inject, + watchEffect, + watch, + reactive, + onMounted, + toRaw, + unref, +} from 'vue'; +import shallowEqual from '../../_util/shallowequal'; +import useProvideMenu, { StoreMenuInfo, useProvideFirstLevel } from './hooks/useMenuContext'; +import useConfigInject from '../../_util/hooks/useConfigInject'; +import { + MenuTheme, + MenuMode, + BuiltinPlacements, + TriggerSubMenuAction, + MenuInfo, + SelectInfo, +} from './interface'; +import devWarning from '../../vc-util/devWarning'; +import { collapseMotion, CSSMotionProps } from '../../_util/transition'; +import uniq from 'lodash-es/uniq'; + +export const menuProps = { + prefixCls: String, + disabled: Boolean, + inlineCollapsed: Boolean, + overflowDisabled: Boolean, + openKeys: Array, + selectedKeys: Array, + selectable: { type: Boolean, default: true }, + multiple: { type: Boolean, default: false }, + + motion: Object as PropType, + + theme: { type: String as PropType, default: 'light' }, + mode: { type: String as PropType, default: 'vertical' }, + + inlineIndent: { type: Number, default: 24 }, + subMenuOpenDelay: { type: Number, default: 0.1 }, + subMenuCloseDelay: { type: Number, default: 0.1 }, + + builtinPlacements: { type: Object as PropType }, + + triggerSubMenuAction: { type: String as PropType, default: 'hover' }, + + getPopupContainer: Function as PropType<(node: HTMLElement) => HTMLElement>, +}; + +export type MenuProps = Partial>; + +export default defineComponent({ + name: 'AMenu', + props: menuProps, + emits: ['update:openKeys', 'openChange', 'select', 'deselect', 'update:selectedKeys', 'click'], + setup(props, { slots, emit }) { + const { prefixCls, direction } = useConfigInject('menu', props); + const store = reactive>({}); + const siderCollapsed = inject( + 'layoutSiderCollapsed', + computed(() => undefined), + ); + const inlineCollapsed = computed(() => { + if (siderCollapsed.value !== undefined) { + return siderCollapsed.value; + } + return props.inlineCollapsed; + }); + + const isMounted = ref(false); + onMounted(() => { + isMounted.value = true; + }); + watchEffect(() => { + devWarning( + !(props.inlineCollapsed === true && props.mode !== 'inline'), + 'Menu', + '`inlineCollapsed` should only be used when `mode` is inline.', + ); + + devWarning( + !(siderCollapsed.value !== undefined && props.inlineCollapsed === true), + 'Menu', + '`inlineCollapsed` not control Menu under Sider. Should set `collapsed` on Sider instead.', + ); + }); + + const activeKeys = ref([]); + const mergedSelectedKeys = ref([]); + + watch( + () => props.selectedKeys, + (selectedKeys = mergedSelectedKeys.value) => { + mergedSelectedKeys.value = selectedKeys; + }, + { immediate: true }, + ); + + const selectedSubMenuEventKeys = ref([]); + + watch( + [store, mergedSelectedKeys], + () => { + let subMenuParentEventKeys = []; + (Object.values(toRaw(store)) as any).forEach((menuInfo: StoreMenuInfo) => { + if (mergedSelectedKeys.value.includes(menuInfo.key)) { + subMenuParentEventKeys.push(...unref(menuInfo.parentEventKeys)); + } + }); + + subMenuParentEventKeys = uniq(subMenuParentEventKeys); + if (!shallowEqual(selectedSubMenuEventKeys.value, subMenuParentEventKeys)) { + selectedSubMenuEventKeys.value = subMenuParentEventKeys; + } + }, + { immediate: true }, + ); + + // >>>>> Trigger select + const triggerSelection = (info: MenuInfo) => { + if (!props.selectable) { + return; + } + // Insert or Remove + const { key: targetKey } = info; + const exist = mergedSelectedKeys.value.includes(targetKey); + let newSelectedKeys: Key[]; + + if (exist && props.multiple) { + newSelectedKeys = mergedSelectedKeys.value.filter(key => key !== targetKey); + } else if (props.multiple) { + newSelectedKeys = [...mergedSelectedKeys.value, targetKey]; + } else { + newSelectedKeys = [targetKey]; + } + + // Trigger event + const selectInfo: SelectInfo = { + ...info, + selectedKeys: newSelectedKeys, + }; + if (!('selectedKeys' in props)) { + mergedSelectedKeys.value = newSelectedKeys; + } + if (!shallowEqual(newSelectedKeys, mergedSelectedKeys.value)) { + emit('update:selectedKeys', newSelectedKeys); + if (exist && props.multiple) { + emit('deselect', selectInfo); + } else { + emit('select', selectInfo); + } + } + }; + + const mergedOpenKeys = ref([]); + + watch( + () => props.openKeys, + (openKeys = mergedOpenKeys.value) => { + if (!shallowEqual(mergedOpenKeys.value, openKeys)) { + mergedOpenKeys.value = openKeys; + } + }, + { immediate: true }, + ); + + const changeActiveKeys = (keys: Key[]) => { + activeKeys.value = keys; + }; + const disabled = computed(() => !!props.disabled); + const isRtl = computed(() => direction.value === 'rtl'); + const mergedMode = ref('vertical'); + const mergedInlineCollapsed = ref(false); + + watchEffect(() => { + if (props.mode === 'inline' && inlineCollapsed.value) { + mergedMode.value = 'vertical'; + mergedInlineCollapsed.value = inlineCollapsed.value; + } else { + mergedMode.value = props.mode; + mergedInlineCollapsed.value = false; + } + }); + + const isInlineMode = computed(() => mergedMode.value === 'inline'); + + // >>>>> Cache & Reset open keys when inlineCollapsed changed + const inlineCacheOpenKeys = ref(mergedOpenKeys.value); + + const mountRef = ref(false); + + // Cache + watch( + mergedOpenKeys, + () => { + if (isInlineMode.value) { + inlineCacheOpenKeys.value = mergedOpenKeys.value; + } + }, + { immediate: true }, + ); + + // Restore + watch( + isInlineMode, + () => { + if (!mountRef.value) { + mountRef.value = true; + return; + } + + if (isInlineMode.value) { + mergedOpenKeys.value = inlineCacheOpenKeys.value; + } else { + const empty = []; + mergedOpenKeys.value = empty; + // Trigger open event in case its in control + emit('update:openKeys', empty); + emit('openChange', empty); + } + }, + { immediate: true }, + ); + + const className = computed(() => { + return { + [`${prefixCls.value}`]: true, + [`${prefixCls.value}-root`]: true, + [`${prefixCls.value}-${mergedMode.value}`]: true, + [`${prefixCls.value}-inline-collapsed`]: mergedInlineCollapsed.value, + [`${prefixCls.value}-rtl`]: isRtl.value, + [`${prefixCls.value}-${props.theme}`]: true, + }; + }); + + const defaultMotions = { + horizontal: { name: `ant-slide-up` }, + inline: collapseMotion, + other: { name: `ant-zoom-big` }, + }; + + useProvideFirstLevel(true); + + const getChildrenKeys = (eventKeys: string[] = []): Key[] => { + const keys = []; + eventKeys.forEach(eventKey => { + const { key, childrenEventKeys } = store[eventKey]; + keys.push(key, ...getChildrenKeys(childrenEventKeys)); + }); + return keys; + }; + + // ========================= Open ========================= + /** + * Click for item. SubMenu do not have selection status + */ + const onInternalClick = (info: MenuInfo) => { + emit('click', info); + triggerSelection(info); + }; + + const onInternalOpenChange = (eventKey: Key, open: boolean) => { + const { key, childrenEventKeys } = store[eventKey]; + let newOpenKeys = mergedOpenKeys.value.filter(k => k !== key); + + if (open) { + newOpenKeys.push(key); + } else if (mergedMode.value !== 'inline') { + // We need find all related popup to close + const subPathKeys = getChildrenKeys(childrenEventKeys); + newOpenKeys = newOpenKeys.filter(k => !subPathKeys.includes(k)); + } + + if (!shallowEqual(mergedOpenKeys, newOpenKeys)) { + mergedOpenKeys.value = newOpenKeys; + emit('update:openKeys', newOpenKeys); + emit('openChange', newOpenKeys); + } + }; + + const registerMenuInfo = (key: string, info: StoreMenuInfo) => { + store[key] = info as any; + }; + const unRegisterMenuInfo = (key: string) => { + delete store[key]; + }; + + useProvideMenu({ + store, + prefixCls, + activeKeys, + openKeys: mergedOpenKeys, + selectedKeys: mergedSelectedKeys, + changeActiveKeys, + disabled, + rtl: isRtl, + mode: mergedMode, + inlineIndent: computed(() => props.inlineIndent), + subMenuCloseDelay: computed(() => props.subMenuCloseDelay), + subMenuOpenDelay: computed(() => props.subMenuOpenDelay), + builtinPlacements: computed(() => props.builtinPlacements), + triggerSubMenuAction: computed(() => props.triggerSubMenuAction), + getPopupContainer: computed(() => props.getPopupContainer), + inlineCollapsed: mergedInlineCollapsed, + antdMenuTheme: computed(() => props.theme), + siderCollapsed, + defaultMotions: computed(() => (isMounted.value ? defaultMotions : null)), + motion: computed(() => (isMounted.value ? props.motion : null)), + overflowDisabled: computed(() => props.overflowDisabled), + onOpenChange: onInternalOpenChange, + onItemClick: onInternalClick, + registerMenuInfo, + unRegisterMenuInfo, + selectedSubMenuEventKeys, + isRootMenu: true, + }); + return () => { + return
      {slots.default?.()}
    ; + }; + }, +}); diff --git a/components/menu/src/MenuItem.tsx b/components/menu/src/MenuItem.tsx new file mode 100644 index 0000000000..0221194a73 --- /dev/null +++ b/components/menu/src/MenuItem.tsx @@ -0,0 +1,196 @@ +import { flattenChildren, getPropsSlot, isValidElement } from '../../_util/props-util'; +import PropTypes from '../../_util/vue-types'; +import { computed, defineComponent, getCurrentInstance, onBeforeUnmount, ref, watch } from 'vue'; +import { useInjectKeyPath } from './hooks/useKeyPath'; +import { useInjectFirstLevel, useInjectMenu } from './hooks/useMenuContext'; +import { cloneElement } from '../../_util/vnode'; +import Tooltip from '../../tooltip'; +import { MenuInfo } from './interface'; + +let indexGuid = 0; + +export default defineComponent({ + name: 'AMenuItem', + props: { + role: String, + disabled: Boolean, + danger: Boolean, + title: { type: [String, Boolean], default: undefined }, + icon: PropTypes.VNodeChild, + }, + emits: ['mouseenter', 'mouseleave', 'click'], + slots: ['icon'], + inheritAttrs: false, + setup(props, { slots, emit, attrs }) { + const instance = getCurrentInstance(); + const key = instance.vnode.key; + const eventKey = `menu_item_${++indexGuid}_$$_${key}`; + const { parentEventKeys } = useInjectKeyPath(); + const { + prefixCls, + activeKeys, + disabled, + changeActiveKeys, + rtl, + inlineCollapsed, + siderCollapsed, + onItemClick, + selectedKeys, + store, + registerMenuInfo, + unRegisterMenuInfo, + } = useInjectMenu(); + const firstLevel = useInjectFirstLevel(); + const isActive = ref(false); + const keyPath = computed(() => { + return [...parentEventKeys.value.map(eK => store[eK].key), key]; + }); + + const keysPath = computed(() => [...parentEventKeys.value, eventKey]); + const menuInfo = { + eventKey, + key, + parentEventKeys, + isLeaf: true, + }; + + registerMenuInfo(eventKey, menuInfo); + + onBeforeUnmount(() => { + unRegisterMenuInfo(eventKey); + }); + + watch( + activeKeys, + () => { + isActive.value = !!activeKeys.value.find(val => val === key); + }, + { immediate: true }, + ); + const mergedDisabled = computed(() => disabled.value || props.disabled); + const selected = computed(() => selectedKeys.value.includes(key)); + const classNames = computed(() => { + const itemCls = `${prefixCls.value}-item`; + return { + [`${itemCls}`]: true, + [`${itemCls}-danger`]: props.danger, + [`${itemCls}-active`]: isActive.value, + [`${itemCls}-selected`]: selected.value, + [`${itemCls}-disabled`]: mergedDisabled.value, + }; + }); + + const getEventInfo = (e: MouseEvent): MenuInfo => { + return { + key: key, + eventKey: eventKey, + keyPath: keyPath.value, + eventKeyPath: [...parentEventKeys.value, eventKey], + domEvent: e, + }; + }; + + // ============================ Events ============================ + const onInternalClick = (e: MouseEvent) => { + if (mergedDisabled.value) { + return; + } + + const info = getEventInfo(e); + emit('click', e); + onItemClick(info); + }; + + const onMouseEnter = (event: MouseEvent) => { + if (!mergedDisabled.value) { + changeActiveKeys(keysPath.value); + console.log('item mouseenter', keysPath.value); + emit('mouseenter', event); + } + }; + const onMouseLeave = (event: MouseEvent) => { + if (!mergedDisabled.value) { + changeActiveKeys([]); + emit('mouseleave', event); + } + }; + + const renderItemChildren = (icon: any, children: any) => { + // inline-collapsed.md demo 依赖 span 来隐藏文字,有 icon 属性,则内部包裹一个 span + // ref: https://github.com/ant-design/ant-design/pull/23456 + if (!icon || (isValidElement(children) && children.type === 'span')) { + if (children && inlineCollapsed.value && firstLevel && typeof children === 'string') { + return ( +
    {children.charAt(0)}
    + ); + } + return children; + } + return {children}; + }; + + return () => { + const { title } = props; + const children = flattenChildren(slots.default?.()); + const childrenLength = children.length; + let tooltipTitle: any = title; + if (typeof title === 'undefined') { + tooltipTitle = firstLevel ? children : ''; + } else if (title === false) { + tooltipTitle = ''; + } + const tooltipProps: any = { + title: tooltipTitle, + }; + + if (!siderCollapsed.value && !inlineCollapsed.value) { + 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; + } + + // ============================ Render ============================ + const optionRoleProps = {}; + + if (props.role === 'option') { + optionRoleProps['aria-selected'] = selected.value; + } + + const icon = getPropsSlot(slots, props, 'icon'); + return ( + +
  • + {cloneElement(icon, { + class: `${prefixCls.value}-item-icon`, + })} + {renderItemChildren(icon, children)} +
  • +
    + ); + }; + }, +}); diff --git a/components/menu/src/PopupTrigger.tsx b/components/menu/src/PopupTrigger.tsx new file mode 100644 index 0000000000..d1f3266712 --- /dev/null +++ b/components/menu/src/PopupTrigger.tsx @@ -0,0 +1,103 @@ +import Trigger from '../../vc-trigger'; +import { computed, defineComponent, onBeforeUnmount, PropType, ref, watch } from 'vue'; +import { MenuMode } from './interface'; +import { useInjectMenu } from './hooks/useMenuContext'; +import { placements, placementsRtl } from './placements'; +import raf from '../../_util/raf'; +import classNames from '../../_util/classNames'; + +const popupPlacementMap = { + horizontal: 'bottomLeft', + vertical: 'rightTop', + 'vertical-left': 'rightTop', + 'vertical-right': 'leftTop', +}; +export default defineComponent({ + name: 'PopupTrigger', + props: { + prefixCls: String, + mode: String as PropType, + visible: Boolean, + // popup: React.ReactNode; + popupClassName: String, + popupOffset: Array as PropType, + disabled: Boolean, + onVisibleChange: Function as PropType<(visible: boolean) => void>, + }, + slots: ['popup'], + emits: ['visibleChange'], + inheritAttrs: false, + setup(props, { slots, emit }) { + const innerVisible = ref(false); + const { + getPopupContainer, + rtl, + subMenuOpenDelay, + subMenuCloseDelay, + builtinPlacements, + triggerSubMenuAction, + isRootMenu, + } = useInjectMenu(); + + const placement = computed(() => + rtl + ? { ...placementsRtl, ...builtinPlacements.value } + : { ...placements, ...builtinPlacements.value }, + ); + + const popupPlacement = computed(() => popupPlacementMap[props.mode]); + + const visibleRef = ref(); + watch( + () => props.visible, + visible => { + raf.cancel(visibleRef.value); + visibleRef.value = raf(() => { + innerVisible.value = visible; + }); + }, + { immediate: true }, + ); + onBeforeUnmount(() => { + raf.cancel(visibleRef.value); + }); + + const onVisibleChange = (visible: boolean) => { + emit('visibleChange', visible); + }; + return () => { + const { prefixCls, popupClassName, mode, popupOffset, disabled } = props; + return ( + triggerNode.parentNode + } + builtinPlacements={placement.value} + popupPlacement={popupPlacement.value} + popupVisible={innerVisible.value} + popupAlign={popupOffset && { offset: popupOffset }} + action={disabled ? [] : [triggerSubMenuAction.value]} + mouseEnterDelay={subMenuOpenDelay.value} + mouseLeaveDelay={subMenuCloseDelay.value} + onPopupVisibleChange={onVisibleChange} + // forceRender={forceSubMenuRender} + v-slots={{ + popup: () => { + return slots.popup?.({ visible: innerVisible.value }); + }, + default: slots.default, + }} + > + ); + }; + }, +}); diff --git a/components/menu/src/SubMenu.tsx b/components/menu/src/SubMenu.tsx new file mode 100644 index 0000000000..4f3a03bcbc --- /dev/null +++ b/components/menu/src/SubMenu.tsx @@ -0,0 +1,294 @@ +import PropTypes from '../../_util/vue-types'; +import { + computed, + defineComponent, + getCurrentInstance, + ref, + watch, + PropType, + onBeforeUnmount, +} from 'vue'; +import useProvideKeyPath, { useInjectKeyPath } from './hooks/useKeyPath'; +import { useInjectMenu, useProvideFirstLevel, MenuContextProvider } from './hooks/useMenuContext'; +import { getPropsSlot, isValidElement } from '../../_util/props-util'; +import classNames from '../../_util/classNames'; +import useDirectionStyle from './hooks/useDirectionStyle'; +import PopupTrigger from './PopupTrigger'; +import SubMenuList from './SubMenuList'; +import InlineSubMenuList from './InlineSubMenuList'; +import Transition, { getTransitionProps } from '../../_util/transition'; + +let indexGuid = 0; +export default defineComponent({ + name: 'ASubMenu', + props: { + icon: PropTypes.VNodeChild, + title: PropTypes.VNodeChild, + disabled: Boolean, + level: Number, + popupClassName: String, + popupOffset: Array as PropType, + internalPopupClose: Boolean, + }, + slots: ['icon', 'title'], + emits: ['titleClick', 'mouseenter', 'mouseleave'], + inheritAttrs: false, + setup(props, { slots, attrs, emit }) { + useProvideFirstLevel(false); + + const instance = getCurrentInstance(); + const key = instance.vnode.key; + + const eventKey = `sub_menu_${++indexGuid}_$$_${key}`; + const { parentEventKeys, parentInfo } = useInjectKeyPath(); + const keysPath = computed(() => [...parentEventKeys.value, eventKey]); + + const childrenEventKeys = ref([]); + const menuInfo = { + eventKey, + key, + parentEventKeys, + childrenEventKeys, + }; + parentInfo.childrenEventKeys?.value.push(eventKey); + onBeforeUnmount(() => { + if (parentInfo.childrenEventKeys) { + parentInfo.childrenEventKeys.value = parentInfo.childrenEventKeys?.value.filter( + k => k != eventKey, + ); + } + }); + + useProvideKeyPath(eventKey, menuInfo); + + const { + prefixCls, + activeKeys, + disabled: contextDisabled, + changeActiveKeys, + mode, + inlineCollapsed, + antdMenuTheme, + openKeys, + overflowDisabled, + onOpenChange, + registerMenuInfo, + unRegisterMenuInfo, + selectedSubMenuEventKeys, + motion, + defaultMotions, + } = useInjectMenu(); + + registerMenuInfo(eventKey, menuInfo); + + onBeforeUnmount(() => { + unRegisterMenuInfo(eventKey); + }); + + const subMenuPrefixCls = computed(() => `${prefixCls.value}-submenu`); + const mergedDisabled = computed(() => contextDisabled.value || props.disabled); + const elementRef = ref(); + const popupRef = ref(); + + // // ================================ Icon ================================ + // const mergedItemIcon = itemIcon || contextItemIcon; + // const mergedExpandIcon = expandIcon || contextExpandIcon; + + // ================================ Open ================================ + const originOpen = computed(() => openKeys.value.includes(key)); + const open = computed(() => !overflowDisabled.value && originOpen.value); + + // =============================== Select =============================== + const childrenSelected = computed(() => { + return selectedSubMenuEventKeys.value.includes(eventKey); + }); + + const isActive = ref(false); + watch( + activeKeys, + () => { + isActive.value = !!activeKeys.value.find(val => val === key); + }, + { immediate: true }, + ); + + // =============================== Events =============================== + // >>>> Title click + const onInternalTitleClick = (e: Event) => { + // Skip if disabled + if (mergedDisabled.value) { + return; + } + emit('titleClick', e, key); + + // Trigger open by click when mode is `inline` + if (mode.value === 'inline') { + onOpenChange(eventKey, !originOpen.value); + } + }; + + const onMouseEnter = (event: MouseEvent) => { + if (!mergedDisabled.value) { + changeActiveKeys(keysPath.value); + emit('mouseenter', event); + } + }; + const onMouseLeave = (event: MouseEvent) => { + if (!mergedDisabled.value) { + changeActiveKeys([]); + emit('mouseleave', event); + } + }; + + // ========================== DirectionStyle ========================== + const directionStyle = useDirectionStyle(computed(() => keysPath.value.length)); + + // >>>>> Visible change + const onPopupVisibleChange = (newVisible: boolean) => { + if (mode.value !== 'inline') { + onOpenChange(eventKey, newVisible); + } + }; + + /** + * Used for accessibility. Helper will focus element without key board. + * We should manually trigger an active + */ + const onInternalFocus = () => { + changeActiveKeys(keysPath.value); + }; + + // =============================== Render =============================== + const popupId = eventKey && `${eventKey}-popup`; + + const popupClassName = computed(() => + classNames( + prefixCls.value, + `${prefixCls.value}-${antdMenuTheme.value}`, + props.popupClassName, + ), + ); + const renderTitle = (title: any, icon: any) => { + if (!icon) { + return inlineCollapsed.value && props.level === 1 && title && typeof title === 'string' ? ( +
    {title.charAt(0)}
    + ) : ( + title + ); + } + // inline-collapsed.md demo 依赖 span 来隐藏文字,有 icon 属性,则内部包裹一个 span + // ref: https://github.com/ant-design/ant-design/pull/23456 + const titleIsSpan = isValidElement(title) && title.type === 'span'; + return ( + <> + {icon} + {titleIsSpan ? title : {title}} + + ); + }; + + // Cache mode if it change to `inline` which do not have popup motion + const triggerModeRef = computed(() => { + return mode.value !== 'inline' && keysPath.value.length > 1 ? 'vertical' : mode.value; + }); + + const renderMode = computed(() => (mode.value === 'horizontal' ? 'vertical' : mode.value)); + + const style = ref({}); + const className = ref(''); + const mergedMotion = computed(() => { + const m = motion.value || defaultMotions.value?.[mode.value] || defaultMotions.value?.other; + const res = typeof m === 'function' ? m(style, className) : m; + return res ? getTransitionProps(res.name) : undefined; + }); + + return () => { + const icon = getPropsSlot(slots, props, 'icon'); + const title = renderTitle(getPropsSlot(slots, props, 'title'), icon); + const subMenuPrefixClsValue = subMenuPrefixCls.value; + let titleNode = ( +
    + {title} + + {/* Only non-horizontal mode shows the icon */} + {mode.value !== 'horizontal' && slots.expandIcon ? ( + slots.expandIcon({ ...props, isOpen: open.value }) + ) : ( + + )} +
    + ); + + if (!overflowDisabled.value) { + const triggerMode = triggerModeRef.value; + titleNode = ( + ( + + + + {slots.default?.()} + + + + ), + }} + > + {titleNode} + + ); + } + return ( + +
  • + {titleNode} + + {/* Inline mode */} + {!overflowDisabled.value && ( + + {slots.default?.()} + + )} +
  • +
    + ); + }; + }, +}); diff --git a/components/menu/src/SubMenuList.tsx b/components/menu/src/SubMenuList.tsx new file mode 100644 index 0000000000..a5d9040091 --- /dev/null +++ b/components/menu/src/SubMenuList.tsx @@ -0,0 +1,23 @@ +import classNames from '../../_util/classNames'; +import { FunctionalComponent, provide } from 'vue'; +import { useInjectMenu } from './hooks/useMenuContext'; +const InternalSubMenuList: FunctionalComponent = (_props, { slots, attrs }) => { + const { prefixCls, mode } = useInjectMenu(); + return ( +
      + {slots.default?.()} +
    + ); +}; + +InternalSubMenuList.displayName = 'SubMenuList'; + +export default InternalSubMenuList; diff --git a/components/menu/src/hooks/useDirectionStyle.ts b/components/menu/src/hooks/useDirectionStyle.ts new file mode 100644 index 0000000000..9721fc16d0 --- /dev/null +++ b/components/menu/src/hooks/useDirectionStyle.ts @@ -0,0 +1,14 @@ +import { computed, ComputedRef, CSSProperties } from 'vue'; +import { useInjectMenu } from './useMenuContext'; + +export default function useDirectionStyle(level: ComputedRef): ComputedRef { + const { mode, rtl, inlineIndent } = useInjectMenu(); + + return computed(() => + mode.value !== 'inline' + ? null + : rtl.value + ? { paddingRight: level.value * inlineIndent.value } + : { paddingLeft: level.value * inlineIndent.value }, + ); +} diff --git a/components/menu/src/hooks/useKeyPath.ts b/components/menu/src/hooks/useKeyPath.ts new file mode 100644 index 0000000000..35f30a1ec8 --- /dev/null +++ b/components/menu/src/hooks/useKeyPath.ts @@ -0,0 +1,26 @@ +import { Key } from '../../../_util/type'; +import { computed, ComputedRef, inject, InjectionKey, provide } from 'vue'; +import { StoreMenuInfo } from './useMenuContext'; + +const KeyPathContext: InjectionKey<{ + parentEventKeys: ComputedRef; + parentInfo: StoreMenuInfo; +}> = Symbol('KeyPathContext'); + +const useInjectKeyPath = () => { + return inject(KeyPathContext, { + parentEventKeys: computed(() => []), + parentInfo: {} as StoreMenuInfo, + }); +}; + +const useProvideKeyPath = (eventKey: string, menuInfo: StoreMenuInfo) => { + const { parentEventKeys } = useInjectKeyPath(); + const keys = computed(() => [...parentEventKeys.value, eventKey]); + provide(KeyPathContext, { parentEventKeys: keys, parentInfo: menuInfo }); + return keys; +}; + +export { useProvideKeyPath, useInjectKeyPath, KeyPathContext }; + +export default useProvideKeyPath; diff --git a/components/menu/src/hooks/useMenuContext.ts b/components/menu/src/hooks/useMenuContext.ts new file mode 100644 index 0000000000..7ee25502c1 --- /dev/null +++ b/components/menu/src/hooks/useMenuContext.ts @@ -0,0 +1,136 @@ +import { Key } from '../../../_util/type'; +import { + ComputedRef, + CSSProperties, + defineComponent, + inject, + InjectionKey, + provide, + Ref, + UnwrapRef, +} from 'vue'; +import { + BuiltinPlacements, + MenuClickEventHandler, + MenuMode, + MenuTheme, + TriggerSubMenuAction, +} from '../interface'; +import { CSSMotionProps } from '../../../_util/transition'; + +export interface StoreMenuInfo { + eventKey: string; + key: Key; + parentEventKeys: ComputedRef; + childrenEventKeys?: Ref; + isLeaf?: boolean; +} +export interface MenuContextProps { + isRootMenu: boolean; + + store: UnwrapRef>; + registerMenuInfo: (key: string, info: StoreMenuInfo) => void; + unRegisterMenuInfo: (key: string) => void; + prefixCls: ComputedRef; + openKeys: Ref; + selectedKeys: Ref; + + selectedSubMenuEventKeys: Ref; + rtl?: ComputedRef; + + locked?: Ref; + + inlineCollapsed: Ref; + antdMenuTheme?: ComputedRef; + + siderCollapsed?: ComputedRef; + + // // Mode + mode: Ref; + + // // Disabled + disabled?: ComputedRef; + // // Used for overflow only. Prevent hidden node trigger open + overflowDisabled?: ComputedRef; + + // // Active + activeKeys: Ref; + changeActiveKeys: (keys: Key[]) => void; + // onActive: (key: string) => void; + // onInactive: (key: string) => void; + + // // Selection + // selectedKeys: string[]; + + // // Level + inlineIndent: ComputedRef; + + // // Motion + motion?: ComputedRef; + defaultMotions?: ComputedRef, className: Ref) => CSSMotionProps); + } + > | null>; + + // // Popup + subMenuOpenDelay: ComputedRef; + subMenuCloseDelay: ComputedRef; + // forceSubMenuRender?: boolean; + builtinPlacements?: ComputedRef; + triggerSubMenuAction?: ComputedRef; + + // // Icon + // itemIcon?: RenderIconType; + // expandIcon?: RenderIconType; + + // // Function + onItemClick: MenuClickEventHandler; + onOpenChange: (key: Key, open: boolean) => void; + getPopupContainer: ComputedRef<(node: HTMLElement) => HTMLElement>; +} + +const MenuContextKey: InjectionKey = Symbol('menuContextKey'); + +const useProvideMenu = (props: MenuContextProps) => { + provide(MenuContextKey, props); +}; + +const useInjectMenu = () => { + return inject(MenuContextKey); +}; + +const MenuFirstLevelContextKey: InjectionKey = Symbol('menuFirstLevelContextKey'); +const useProvideFirstLevel = (firstLevel: Boolean) => { + provide(MenuFirstLevelContextKey, firstLevel); +}; + +const useInjectFirstLevel = () => { + return inject(MenuFirstLevelContextKey, true); +}; + +const MenuContextProvider = defineComponent({ + name: 'MenuContextProvider', + inheritAttrs: false, + props: { + props: Object, + }, + setup(props, { slots }) { + useProvideMenu({ ...useInjectMenu(), ...props.props }); + return () => slots.default?.(); + }, +}); + +export { + useProvideMenu, + MenuContextKey, + useInjectMenu, + MenuFirstLevelContextKey, + useProvideFirstLevel, + useInjectFirstLevel, + MenuContextProvider, +}; + +export default useProvideMenu; diff --git a/components/menu/src/interface.ts b/components/menu/src/interface.ts new file mode 100644 index 0000000000..14a153c183 --- /dev/null +++ b/components/menu/src/interface.ts @@ -0,0 +1,45 @@ +import { Key } from '../../_util/type'; + +export type MenuTheme = 'light' | 'dark'; + +// ========================== Basic ========================== +export type MenuMode = 'horizontal' | 'vertical' | 'inline'; + +export type BuiltinPlacements = Record; + +export type TriggerSubMenuAction = 'click' | 'hover'; + +export interface RenderIconInfo { + isSelected?: boolean; + isOpen?: boolean; + isSubMenu?: boolean; + disabled?: boolean; +} + +export type RenderIconType = (props: RenderIconInfo) => any; + +export interface MenuInfo { + key: Key; + eventKey: string; + keyPath?: Key[]; + eventKeyPath: Key[]; + domEvent: MouseEvent | KeyboardEvent; +} + +export interface MenuTitleInfo { + key: Key; + domEvent: MouseEvent | KeyboardEvent; +} + +// ========================== Hover ========================== +export type MenuHoverEventHandler = (info: { key: Key; domEvent: MouseEvent }) => void; + +// ======================== Selection ======================== +export interface SelectInfo extends MenuInfo { + selectedKeys: Key[]; +} + +export type SelectEventHandler = (info: SelectInfo) => void; + +// ========================== Click ========================== +export type MenuClickEventHandler = (info: MenuInfo) => void; diff --git a/components/menu/src/placements.ts b/components/menu/src/placements.ts new file mode 100644 index 0000000000..7b45acf921 --- /dev/null +++ b/components/menu/src/placements.ts @@ -0,0 +1,52 @@ +const autoAdjustOverflow = { + adjustX: 1, + adjustY: 1, +}; + +export const placements = { + topLeft: { + points: ['bl', 'tl'], + overflow: autoAdjustOverflow, + offset: [0, -7], + }, + bottomLeft: { + points: ['tl', 'bl'], + overflow: autoAdjustOverflow, + offset: [0, 7], + }, + leftTop: { + points: ['tr', 'tl'], + overflow: autoAdjustOverflow, + offset: [-4, 0], + }, + rightTop: { + points: ['tl', 'tr'], + overflow: autoAdjustOverflow, + offset: [4, 0], + }, +}; + +export const placementsRtl = { + topLeft: { + points: ['bl', 'tl'], + overflow: autoAdjustOverflow, + offset: [0, -7], + }, + bottomLeft: { + points: ['tl', 'bl'], + overflow: autoAdjustOverflow, + offset: [0, 7], + }, + rightTop: { + points: ['tr', 'tl'], + overflow: autoAdjustOverflow, + offset: [-4, 0], + }, + leftTop: { + points: ['tl', 'tr'], + overflow: autoAdjustOverflow, + offset: [4, 0], + }, +}; + +export default placements; diff --git a/components/menu/style/dark.less b/components/menu/style/dark.less index 03d837a6aa..1ad2abf991 100644 --- a/components/menu/style/dark.less +++ b/components/menu/style/dark.less @@ -1,7 +1,8 @@ .@{menu-prefix-cls} { // dark theme - &-dark, - &-dark &-sub { + &&-dark, + &-dark &-sub, + &&-dark &-sub { color: @menu-dark-color; background: @menu-dark-bg; .@{menu-prefix-cls}-submenu-title .@{menu-prefix-cls}-submenu-arrow { @@ -19,8 +20,7 @@ } &-dark &-inline&-sub { - background: @menu-dark-submenu-bg; - box-shadow: 0 2px 8px fade(@black, 45%) inset; + background: @menu-dark-inline-submenu-bg; } &-dark&-horizontal { @@ -31,17 +31,23 @@ &-dark&-horizontal > &-submenu { top: 0; margin-top: 0; + padding: @menu-item-padding; border-color: @menu-dark-bg; border-bottom: 0; } + &-dark&-horizontal > &-item:hover { + background-color: @menu-dark-item-active-bg; + } + &-dark&-horizontal > &-item > a::before { bottom: 0; } &-dark &-item, &-dark &-item-group-title, - &-dark &-item > a { + &-dark &-item > a, + &-dark &-item > span > a { color: @menu-dark-color; } @@ -77,7 +83,8 @@ &-dark &-submenu-title:hover { color: @menu-dark-highlight-color; background-color: transparent; - > a { + > a, + > span > a { color: @menu-dark-highlight-color; } > .@{menu-prefix-cls}-submenu-title, @@ -95,6 +102,10 @@ background-color: @menu-dark-item-hover-bg; } + &-dark&-dark:not(&-horizontal) &-item-selected { + background-color: @menu-dark-item-active-bg; + } + &-dark &-item-selected { color: @menu-dark-highlight-color; border-right: 0; @@ -102,14 +113,19 @@ border-right: 0; } > a, - > a:hover { + > span > a, + > a:hover, + > span > a:hover { color: @menu-dark-highlight-color; } + + .@{menu-prefix-cls}-item-icon, .@{iconfont-css-prefix} { color: @menu-dark-selected-item-icon-color; - } - .@{iconfont-css-prefix} + span { - color: @menu-dark-selected-item-text-color; + + + span { + color: @menu-dark-selected-item-text-color; + } } } @@ -122,7 +138,8 @@ &-dark &-item-disabled, &-dark &-submenu-disabled { &, - > a { + > a, + > span > a { color: @disabled-color-dark !important; opacity: 0.8; } diff --git a/components/menu/style/index.less b/components/menu/style/index.less index 53ac605993..b14e86259b 100644 --- a/components/menu/style/index.less +++ b/components/menu/style/index.less @@ -1,7 +1,15 @@ @import '../../style/themes/index'; @import '../../style/mixins/index'; +@import './status'; @menu-prefix-cls: ~'@{ant-prefix}-menu'; +@menu-animation-duration-normal: 0.15s; + +.accessibility-focus() { + box-shadow: 0 0 0 2px fade(@primary-color, 20%); +} + +// TODO: Should remove icon style compatible in v5 // default theme .@{menu-prefix-cls} { @@ -10,14 +18,21 @@ margin-bottom: 0; padding-left: 0; // Override default ul/ol color: @menu-item-color; + font-size: @menu-item-font-size; line-height: 0; // Fix display inline-block gap + text-align: left; list-style: none; background: @menu-bg; outline: none; box-shadow: @box-shadow-base; - transition: background 0.3s, width 0.3s cubic-bezier(0.2, 0, 0, 1) 0s; + transition: background @animation-duration-slow, + width @animation-duration-slow cubic-bezier(0.2, 0, 0, 1) 0s; .clearfix(); + &&-root:focus-visible { + .accessibility-focus(); + } + ul, ol { margin: 0; @@ -25,22 +40,29 @@ list-style: none; } - &-hidden { + &-hidden, + &-submenu-hidden { display: none; } &-item-group-title { + height: @menu-item-group-height; padding: 8px 16px; color: @menu-item-group-title-color; - font-size: @font-size-base; - line-height: @line-height-base; - transition: all 0.3s; + font-size: @menu-item-group-title-font-size; + line-height: @menu-item-group-height; + transition: all @animation-duration-slow; } + &-horizontal &-submenu { + transition: border-color @animation-duration-slow @ease-in-out, + background @animation-duration-slow @ease-in-out; + } &-submenu, &-submenu-inline { - transition: border-color 0.3s @ease-in-out, background 0.3s @ease-in-out, - padding 0.15s @ease-in-out; + transition: border-color @animation-duration-slow @ease-in-out, + background @animation-duration-slow @ease-in-out, + padding @menu-animation-duration-normal @ease-in-out; } &-submenu-selected { @@ -54,11 +76,11 @@ &-submenu &-sub { cursor: initial; - transition: background 0.3s @ease-in-out, padding 0.3s @ease-in-out; + transition: background @animation-duration-slow @ease-in-out, + padding @animation-duration-slow @ease-in-out; } - &-item > a { - display: block; + &-item a { color: @menu-item-color; &:hover { color: @menu-highlight-color; @@ -75,7 +97,7 @@ } // https://github.com/ant-design/ant-design/issues/19809 - &-item > .@{ant-prefix}-badge > a { + &-item > .@{ant-prefix}-badge a { color: @menu-item-color; &:hover { color: @menu-highlight-color; @@ -110,8 +132,8 @@ &-item-selected { color: @menu-highlight-color; - > a, - > a:hover { + a, + a:hover { color: @menu-highlight-color; } } @@ -125,6 +147,7 @@ &-vertical-left { border-right: @border-width-base @border-style-base @border-color-split; } + &-vertical-right { border-left: @border-width-base @border-style-base @border-color-split; } @@ -133,9 +156,17 @@ &-vertical-left&-sub, &-vertical-right&-sub { min-width: 160px; + max-height: calc(100vh - 100px); padding: 0; + overflow: hidden; border-right: 0; - transform-origin: 0 0; + + // https://github.com/ant-design/ant-design/issues/22244 + // https://github.com/ant-design/ant-design/issues/26812 + &:not([class*='-active']) { + overflow-x: hidden; + overflow-y: auto; + } .@{menu-prefix-cls}-item { left: 0; @@ -155,26 +186,48 @@ min-width: 114px; // in case of submenu width is too big: https://codesandbox.io/s/qvpwm6mk66 } + &-horizontal &-item, + &-horizontal &-submenu-title { + transition: border-color @animation-duration-slow, background @animation-duration-slow; + } + &-item, &-submenu-title { position: relative; display: block; margin: 0; - padding: 0 20px; + padding: @menu-item-padding; white-space: nowrap; cursor: pointer; - transition: color 0.3s @ease-in-out, border-color 0.3s @ease-in-out, - background 0.3s @ease-in-out, padding 0.15s @ease-in-out; + transition: border-color @animation-duration-slow, background @animation-duration-slow, + padding @animation-duration-slow @ease-in-out; + + .@{menu-prefix-cls}-item-icon, .@{iconfont-css-prefix} { min-width: 14px; - margin-right: 10px; font-size: @menu-icon-size; - transition: font-size 0.15s @ease-out, margin 0.3s @ease-in-out; + transition: font-size @menu-animation-duration-normal @ease-out, + margin @animation-duration-slow @ease-in-out, color @animation-duration-slow; + span { + margin-left: @menu-icon-margin-right; opacity: 1; - transition: opacity 0.3s @ease-in-out, width 0.3s @ease-in-out; + // transition: opacity @animation-duration-slow @ease-in-out, + // width @animation-duration-slow @ease-in-out, color @animation-duration-slow; + transition: opacity @animation-duration-slow @ease-in-out, margin @animation-duration-slow, + color @animation-duration-slow; } } + + &.@{menu-prefix-cls}-item-only-child { + > .@{iconfont-css-prefix}, + > .@{menu-prefix-cls}-item-icon { + margin-right: 0; + } + } + + &:focus-visible { + .accessibility-focus(); + } } & > &-item-divider { @@ -190,94 +243,105 @@ &-popup { position: absolute; z-index: @zindex-dropdown; - // background: @menu-popup-bg; + background: transparent; border-radius: @border-radius-base; + box-shadow: none; + transform-origin: 0 0; - .submenu-title-wrapper { - padding-right: 20px; - } - + // https://github.com/ant-design/ant-design/issues/13955 &::before { position: absolute; top: -7px; right: 0; bottom: 0; left: 0; + z-index: -1; + width: 100%; + height: 100%; opacity: 0.0001; content: ' '; } } + // https://github.com/ant-design/ant-design/issues/13955 + &-placement-rightTop::before { + top: 0; + left: -7px; + } + > .@{menu-prefix-cls} { background-color: @menu-bg; border-radius: @border-radius-base; &-submenu-title::after { - transition: transform 0.3s @ease-in-out; + transition: transform @animation-duration-slow @ease-in-out; } } - &-vertical, - &-vertical-left, - &-vertical-right, - &-inline { - > .@{menu-prefix-cls}-submenu-title .@{menu-prefix-cls}-submenu-arrow { + &-popup > .@{menu-prefix-cls} { + background-color: @menu-popup-bg; + } + + &-expand-icon, + &-arrow { + position: absolute; + top: 50%; + right: 16px; + width: 10px; + color: @menu-item-color; + transform: translateY(-50%); + transition: transform @animation-duration-slow @ease-in-out; + } + + &-arrow { + // → + &::before, + &::after { position: absolute; - top: 50%; - right: 16px; - width: 10px; - transition: transform 0.3s @ease-in-out; - &::before, - &::after { - position: absolute; - width: 6px; - height: 1.5px; - // background + background-image to makes before & after cross have same color. - // Since `linear-gradient` not work on IE9, we should hack it. - // ref: https://github.com/ant-design/ant-design/issues/15910 - background: @menu-bg; - background: ~'@{menu-item-color} \9'; - background-image: linear-gradient(to right, @menu-item-color, @menu-item-color); - background-image: ~'none \9'; - border-radius: 2px; - transition: background 0.3s @ease-in-out, transform 0.3s @ease-in-out, - top 0.3s @ease-in-out; - content: ''; - } - &::before { - transform: rotate(45deg) translateY(-2px); - } - &::after { - transform: rotate(-45deg) translateY(2px); - } + width: 6px; + height: 1.5px; + background-color: currentColor; + border-radius: 2px; + transition: background @animation-duration-slow @ease-in-out, + transform @animation-duration-slow @ease-in-out, top @animation-duration-slow @ease-in-out, + color @animation-duration-slow @ease-in-out; + content: ''; } - > .@{menu-prefix-cls}-submenu-title:hover .@{menu-prefix-cls}-submenu-arrow { - &::after, - &::before { - background: linear-gradient(to right, @menu-highlight-color, @menu-highlight-color); - } + &::before { + transform: rotate(45deg) translateY(-2.5px); + } + &::after { + transform: rotate(-45deg) translateY(2.5px); } } - &-inline > .@{menu-prefix-cls}-submenu-title .@{menu-prefix-cls}-submenu-arrow { + &:hover > &-title > &-expand-icon, + &:hover > &-title > &-arrow { + color: @menu-highlight-color; + } + + .@{menu-prefix-cls}-inline-collapsed &-arrow, + &-inline &-arrow { + // ↓ &::before { - transform: rotate(-45deg) translateX(2px); + transform: rotate(-45deg) translateX(2.5px); } &::after { - transform: rotate(45deg) translateX(-2px); + transform: rotate(45deg) translateX(-2.5px); } } - &-open { - &.@{menu-prefix-cls}-submenu-inline - > .@{menu-prefix-cls}-submenu-title - .@{menu-prefix-cls}-submenu-arrow { - transform: translateY(-2px); - &::after { - transform: rotate(-45deg) translateX(-2px); - } - &::before { - transform: rotate(45deg) translateX(2px); - } + &-horizontal &-arrow { + display: none; + } + + &-open&-inline > &-title > &-arrow { + // ↑ + transform: translateY(-2px); + &::after { + transform: rotate(-45deg) translateX(-2.5px); + } + &::before { + transform: rotate(45deg) translateX(2.5px); } } } @@ -286,18 +350,34 @@ &-vertical-left &-submenu-selected, &-vertical-right &-submenu-selected { color: @menu-highlight-color; - > a { - color: @menu-highlight-color; - } } &-horizontal { - line-height: 46px; - white-space: nowrap; + line-height: @menu-horizontal-line-height; border: 0; border-bottom: @border-width-base @border-style-base @border-color-split; box-shadow: none; + &:not(.@{menu-prefix-cls}-dark) { + > .@{menu-prefix-cls}-item, + > .@{menu-prefix-cls}-submenu { + margin: @menu-item-padding; + margin-top: -1px; + margin-bottom: 0; + padding: @menu-item-padding; + padding-right: 0; + padding-left: 0; + + &:hover, + &-active, + &-open, + &-selected { + color: @menu-highlight-color; + border-bottom: 2px solid @menu-highlight-color; + } + } + } + > .@{menu-prefix-cls}-item, > .@{menu-prefix-cls}-submenu { position: relative; @@ -305,19 +385,14 @@ display: inline-block; vertical-align: bottom; border-bottom: 2px solid transparent; + } - &:hover, - &-active, - &-open, - &-selected { - color: @menu-highlight-color; - border-bottom: 2px solid @menu-highlight-color; - } + > .@{menu-prefix-cls}-submenu > .@{menu-prefix-cls}-submenu-title { + padding: 0; } > .@{menu-prefix-cls}-item { - > a { - display: block; + a { color: @menu-item-color; &:hover { color: @menu-highlight-color; @@ -326,7 +401,7 @@ bottom: -2px; } } - &-selected > a { + &-selected a { color: @menu-highlight-color; } } @@ -353,7 +428,8 @@ border-right: @menu-item-active-border-width solid @menu-highlight-color; transform: scaleY(0.0001); opacity: 0; - transition: transform 0.15s @ease-out, opacity 0.15s @ease-out; + transition: transform @menu-animation-duration-normal @ease-out, + opacity @menu-animation-duration-normal @ease-out; content: ''; } } @@ -365,7 +441,6 @@ margin-bottom: @menu-item-vertical-margin; padding: 0 16px; overflow: hidden; - font-size: @menu-item-font-size; line-height: @menu-item-height; text-overflow: ellipsis; } @@ -386,6 +461,13 @@ } } + &-vertical { + .@{menu-prefix-cls}-item-group-list .@{menu-prefix-cls}-submenu-title, + .@{menu-prefix-cls}-submenu-title { + padding-right: 34px; + } + } + &-inline { width: 100%; .@{menu-prefix-cls}-selected, @@ -393,7 +475,8 @@ &::after { transform: scaleY(1); opacity: 1; - transition: transform 0.15s @ease-in-out, opacity 0.15s @ease-in-out; + transition: transform @menu-animation-duration-normal @ease-in-out, + opacity @menu-animation-duration-normal @ease-in-out; } } @@ -402,13 +485,37 @@ width: ~'calc(100% + 1px)'; } + .@{menu-prefix-cls}-item-group-list .@{menu-prefix-cls}-submenu-title, .@{menu-prefix-cls}-submenu-title { padding-right: 34px; } + + // Motion enhance for first level + &.@{menu-prefix-cls}-root { + .@{menu-prefix-cls}-item, + .@{menu-prefix-cls}-submenu-title { + display: flex; + align-items: center; + transition: border-color @animation-duration-slow, background @animation-duration-slow, + padding 0.1s @ease-out; + + > .@{menu-prefix-cls}-title-content { + flex: auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + > * { + flex: none; + } + } + } } - &-inline-collapsed { + &&-inline-collapsed { width: @menu-collapsed-width; + > .@{menu-prefix-cls}-item, > .@{menu-prefix-cls}-item-group > .@{menu-prefix-cls}-item-group-list @@ -419,24 +526,34 @@ > .@{menu-prefix-cls}-submenu-title, > .@{menu-prefix-cls}-submenu > .@{menu-prefix-cls}-submenu-title { left: 0; - padding: 0 ((@menu-collapsed-width - @menu-icon-size-lg) / 2) !important; + padding: 0 ~'calc(50% - @{menu-icon-size-lg} / 2)'; text-overflow: clip; + .@{menu-prefix-cls}-submenu-arrow { display: none; } + + .@{menu-prefix-cls}-item-icon, .@{iconfont-css-prefix} { margin: 0; font-size: @menu-icon-size-lg; line-height: @menu-item-height; + span { display: inline-block; - max-width: 0; opacity: 0; } } } + + .@{menu-prefix-cls}-item-icon, + .@{iconfont-css-prefix} { + display: inline-block; + } + &-tooltip { pointer-events: none; + + .@{menu-prefix-cls}-item-icon, .@{iconfont-css-prefix} { display: none; } @@ -470,8 +587,19 @@ box-shadow: none; } + &-root&-inline-collapsed { + .@{menu-prefix-cls}-item, + .@{menu-prefix-cls}-submenu .@{menu-prefix-cls}-submenu-title { + > .@{menu-prefix-cls}-inline-collapsed-noicon { + font-size: @menu-icon-size-lg; + text-align: center; + } + } + } + &-sub&-inline { padding: 0; + background: @menu-inline-submenu-bg; border: 0; border-radius: 0; box-shadow: none; @@ -495,7 +623,7 @@ background: none; border-color: transparent !important; cursor: not-allowed; - > a { + a { color: @disabled-color !important; pointer-events: none; } @@ -512,4 +640,12 @@ } } +// Integration with header element so menu items have the same height +.@{ant-prefix}-layout-header { + .@{menu-prefix-cls} { + line-height: inherit; + } +} + @import './dark'; +@import './rtl'; diff --git a/components/menu/style/index.ts b/components/menu/style/index.tsx similarity index 100% rename from components/menu/style/index.ts rename to components/menu/style/index.tsx diff --git a/components/menu/style/rtl.less b/components/menu/style/rtl.less new file mode 100644 index 0000000000..a7edba5bbf --- /dev/null +++ b/components/menu/style/rtl.less @@ -0,0 +1,164 @@ +@import '../../style/themes/index'; +@import '../../style/mixins/index'; + +@menu-prefix-cls: ~'@{ant-prefix}-menu'; + +.@{menu-prefix-cls} { + &&-rtl { + direction: rtl; + text-align: right; + } + + &-item-group-title { + .@{menu-prefix-cls}-rtl & { + text-align: right; + } + } + + &-inline, + &-vertical { + .@{menu-prefix-cls}-rtl& { + border-right: none; + border-left: @border-width-base @border-style-base @border-color-split; + } + } + + &-dark&-inline, + &-dark&-vertical { + .@{menu-prefix-cls}-rtl& { + border-left: none; + } + } + + &-vertical&-sub, + &-vertical-left&-sub, + &-vertical-right&-sub { + > .@{menu-prefix-cls}-item, + > .@{menu-prefix-cls}-submenu { + .@{menu-prefix-cls}-rtl& { + transform-origin: top right; + } + } + } + + &-item, + &-submenu-title { + .@{menu-prefix-cls}-item-icon, + .@{iconfont-css-prefix} { + .@{menu-prefix-cls}-rtl & { + margin-right: auto; + margin-left: @menu-icon-margin-right; + } + } + + &.@{menu-prefix-cls}-item-only-child { + > .@{menu-prefix-cls}-item-icon, + > .@{iconfont-css-prefix} { + .@{menu-prefix-cls}-rtl & { + margin-left: 0; + } + } + } + } + + &-submenu { + &-rtl.@{menu-prefix-cls}-submenu-popup { + transform-origin: 100% 0; + } + + &-vertical, + &-vertical-left, + &-vertical-right, + &-inline { + > .@{menu-prefix-cls}-submenu-title .@{menu-prefix-cls}-submenu-arrow { + .@{menu-prefix-cls}-rtl & { + right: auto; + left: 16px; + } + } + } + + &-vertical, + &-vertical-left, + &-vertical-right { + > .@{menu-prefix-cls}-submenu-title .@{menu-prefix-cls}-submenu-arrow { + &::before { + .@{menu-prefix-cls}-rtl & { + transform: rotate(-45deg) translateY(-2px); + } + } + &::after { + .@{menu-prefix-cls}-rtl & { + transform: rotate(45deg) translateY(2px); + } + } + } + } + } + + &-vertical, + &-vertical-left, + &-vertical-right, + &-inline { + .@{menu-prefix-cls}-item { + &::after { + .@{menu-prefix-cls}-rtl& { + right: auto; + left: 0; + } + } + } + + .@{menu-prefix-cls}-item, + .@{menu-prefix-cls}-submenu-title { + .@{menu-prefix-cls}-rtl& { + text-align: right; + } + } + } + + &-inline { + .@{menu-prefix-cls}-submenu-title { + .@{menu-prefix-cls}-rtl& { + padding-right: 0; + padding-left: 34px; + } + } + } + + &-vertical { + .@{menu-prefix-cls}-submenu-title { + .@{menu-prefix-cls}-rtl& { + padding-right: 16px; + padding-left: 34px; + } + } + } + + &-inline-collapsed&-vertical { + .@{menu-prefix-cls}-submenu-title { + .@{menu-prefix-cls}-rtl& { + padding: 0 ~'calc(50% - @{menu-icon-size-lg} / 2)'; + } + } + } + + &-item-group-list { + .@{menu-prefix-cls}-item, + .@{menu-prefix-cls}-submenu-title { + .@{menu-prefix-cls}-rtl & { + padding: 0 28px 0 16px; + } + } + } + + &-sub&-inline { + border: 0; + & .@{menu-prefix-cls}-item-group-title { + .@{menu-prefix-cls}-rtl& { + padding-right: 32px; + padding-left: 0; + } + } + } +} diff --git a/components/menu/style/status.less b/components/menu/style/status.less new file mode 100644 index 0000000000..5e5d66c057 --- /dev/null +++ b/components/menu/style/status.less @@ -0,0 +1,47 @@ +@import './index'; + +.@{menu-prefix-cls} { + // Danger + &-item-danger&-item { + color: @menu-highlight-danger-color; + + &:hover, + &-active { + color: @menu-highlight-danger-color; + } + + &:active { + background: @menu-item-active-danger-bg; + } + + &-selected { + color: @menu-highlight-danger-color; + > a, + > a:hover { + color: @menu-highlight-danger-color; + } + } + + .@{menu-prefix-cls}:not(.@{menu-prefix-cls}-horizontal) &-selected { + background-color: @menu-item-active-danger-bg; + } + + .@{menu-prefix-cls}-inline &::after { + border-right-color: @menu-highlight-danger-color; + } + } + + // ==================== Dark ==================== + &-dark &-item-danger&-item { + &, + &:hover, + & > a { + color: @menu-dark-danger-color; + } + } + + &-dark&-dark:not(&-horizontal) &-item-danger&-item-selected { + color: @menu-dark-highlight-color; + background-color: @menu-dark-item-active-danger-bg; + } +} diff --git a/components/style/core/global.less b/components/style/core/global.less index 1b3e8d2849..d4f53103fb 100644 --- a/components/style/core/global.less +++ b/components/style/core/global.less @@ -239,7 +239,6 @@ a { &[disabled] { color: @disabled-color; cursor: not-allowed; - pointer-events: none; } } diff --git a/components/style/core/motion.less b/components/style/core/motion.less index cce881b779..730c693687 100644 --- a/components/style/core/motion.less +++ b/components/style/core/motion.less @@ -3,7 +3,6 @@ @import 'motion/move'; @import 'motion/other'; @import 'motion/slide'; -@import 'motion/swing'; @import 'motion/zoom'; // For common/openAnimation diff --git a/components/style/core/motion/fade.less b/components/style/core/motion/fade.less index fd9d621cba..c703b5973a 100644 --- a/components/style/core/motion/fade.less +++ b/components/style/core/motion/fade.less @@ -1,11 +1,12 @@ .fade-motion(@className, @keyframeName) { - .make-motion(@className, @keyframeName); - .@{className}-enter, - .@{className}-appear { + @name: ~'@{ant-prefix}-@{className}'; + .make-motion(@name, @keyframeName); + .@{name}-enter, + .@{name}-appear { opacity: 0; animation-timing-function: linear; } - .@{className}-leave { + .@{name}-leave { animation-timing-function: linear; } } diff --git a/components/style/core/motion/move.less b/components/style/core/motion/move.less index a11b911b3c..e7972d77af 100644 --- a/components/style/core/motion/move.less +++ b/components/style/core/motion/move.less @@ -1,11 +1,12 @@ .move-motion(@className, @keyframeName) { - .make-motion(@className, @keyframeName); - .@{className}-enter, - .@{className}-appear { + @name: ~'@{ant-prefix}-@{className}'; + .make-motion(@name, @keyframeName); + .@{name}-enter, + .@{name}-appear { opacity: 0; animation-timing-function: @ease-out-circ; } - .@{className}-leave { + .@{name}-leave { animation-timing-function: @ease-in-circ; } } diff --git a/components/style/core/motion/other.less b/components/style/core/motion/other.less index 80887427a6..d1a25494e7 100644 --- a/components/style/core/motion/other.less +++ b/components/style/core/motion/other.less @@ -4,17 +4,23 @@ } } -[ant-click-animating='true'], -[ant-click-animating-without-extra-node='true'] { +@click-animating-true: ~"[@{ant-prefix}-click-animating='true']"; +@click-animating-with-extra-node-true: ~"[@{ant-prefix}-click-animating-without-extra-node='true']"; + +@{click-animating-true}, +@{click-animating-with-extra-node-true} { position: relative; } html { --antd-wave-shadow-color: @primary-color; + --scroll-bar: 0; } -[ant-click-animating-without-extra-node='true']::after, -.ant-click-animating-node { +@click-animating-with-extra-node-true-after: ~'@{click-animating-with-extra-node-true}::after'; + +@{click-animating-with-extra-node-true-after}, +.@{ant-prefix}-click-animating-node { position: absolute; top: 0; right: 0; diff --git a/components/style/core/motion/slide.less b/components/style/core/motion/slide.less index b5032c799d..f838c6e4ac 100644 --- a/components/style/core/motion/slide.less +++ b/components/style/core/motion/slide.less @@ -1,11 +1,12 @@ .slide-motion(@className, @keyframeName) { - .make-motion(@className, @keyframeName); - .@{className}-enter, - .@{className}-appear { + @name: ~'@{ant-prefix}-@{className}'; + .make-motion(@name, @keyframeName); + .@{name}-enter, + .@{name}-appear { opacity: 0; animation-timing-function: @ease-out-quint; } - .@{className}-leave { + .@{name}-leave { animation-timing-function: @ease-in-quint; } } diff --git a/components/style/core/motion/swing.less b/components/style/core/motion/swing.less deleted file mode 100644 index 138a942d47..0000000000 --- a/components/style/core/motion/swing.less +++ /dev/null @@ -1,34 +0,0 @@ -.swing-motion(@className, @keyframeName) { - .@{className}-enter, - .@{className}-appear { - .motion-common(); - - animation-play-state: paused; - } - .@{className}-enter.@{className}-enter-active, - .@{className}-appear.@{className}-appear-active { - animation-name: ~'@{keyframeName}In'; - animation-play-state: running; - } -} - -.swing-motion(swing, antSwing); - -@keyframes antSwingIn { - 0%, - 100% { - transform: translateX(0); - } - 20% { - transform: translateX(-10px); - } - 40% { - transform: translateX(10px); - } - 60% { - transform: translateX(-5px); - } - 80% { - transform: translateX(5px); - } -} diff --git a/components/style/core/motion/zoom.less b/components/style/core/motion/zoom.less index f633274291..8c2c57acac 100644 --- a/components/style/core/motion/zoom.less +++ b/components/style/core/motion/zoom.less @@ -1,15 +1,17 @@ .zoom-motion(@className, @keyframeName, @duration: @animation-duration-base) { - .make-motion(@className, @keyframeName, @duration); - .@{className}-enter, - .@{className}-appear { + @name: ~'@{ant-prefix}-@{className}'; + .make-motion(@name, @keyframeName, @duration); + .@{name}-enter, + .@{name}-appear { transform: scale(0); // need this by yiminghe opacity: 0; animation-timing-function: @ease-out-circ; + &-prepare { transform: none; } } - .@{className}-leave { + .@{name}-leave { animation-timing-function: @ease-in-out-circ; } } @@ -54,7 +56,7 @@ opacity: 0; } 5% { - transform: scale(0.2); + transform: scale(0.8); opacity: 0; } 100% { diff --git a/components/style/themes/default.less b/components/style/themes/default.less index 666c69872b..dcb8fa8cb5 100644 --- a/components/style/themes/default.less +++ b/components/style/themes/default.less @@ -490,28 +490,37 @@ // --- @menu-inline-toplevel-item-height: 40px; @menu-item-height: 40px; +@menu-item-group-height: @line-height-base; @menu-collapsed-width: 80px; @menu-bg: @component-background; @menu-popup-bg: @component-background; @menu-item-color: @text-color; +@menu-inline-submenu-bg: @background-color-light; @menu-highlight-color: @primary-color; -@menu-item-active-bg: @item-active-bg; +@menu-highlight-danger-color: @error-color; +@menu-item-active-bg: @primary-1; +@menu-item-active-danger-bg: @red-1; @menu-item-active-border-width: 3px; @menu-item-group-title-color: @text-color-secondary; -@menu-icon-size: @font-size-base; -@menu-icon-size-lg: @font-size-lg; - @menu-item-vertical-margin: 4px; @menu-item-font-size: @font-size-base; @menu-item-boundary-margin: 8px; +@menu-item-padding: 0 20px; +@menu-horizontal-line-height: 46px; +@menu-icon-margin-right: 10px; +@menu-icon-size: @menu-item-font-size; +@menu-icon-size-lg: @font-size-lg; +@menu-item-group-title-font-size: @menu-item-font-size; // dark theme @menu-dark-color: @text-color-secondary-dark; +@menu-dark-danger-color: @error-color; @menu-dark-bg: @layout-header-background; @menu-dark-arrow-color: #fff; -@menu-dark-submenu-bg: #000c17; +@menu-dark-inline-submenu-bg: #000c17; @menu-dark-highlight-color: #fff; @menu-dark-item-active-bg: @primary-color; +@menu-dark-item-active-danger-bg: @error-color; @menu-dark-selected-item-icon-color: @white; @menu-dark-selected-item-text-color: @white; @menu-dark-item-hover-bg: transparent; diff --git a/components/vc-overflow/Item.tsx b/components/vc-overflow/Item.tsx new file mode 100644 index 0000000000..e8045ccb8f --- /dev/null +++ b/components/vc-overflow/Item.tsx @@ -0,0 +1,105 @@ +import { + computed, + CSSProperties, + defineComponent, + HTMLAttributes, + onUnmounted, + PropType, + ref, +} from 'vue'; +import ResizeObserver from '../vc-resize-observer'; +import classNames from '../_util/classNames'; +import { Key, VueNode } from '../_util/type'; +import PropTypes from '../_util/vue-types'; + +export default defineComponent({ + name: 'InternalItem', + props: { + prefixCls: String, + item: PropTypes.any, + renderItem: Function as PropType<(item: any) => VueNode>, + responsive: Boolean, + itemKey: [String, Number], + registerSize: Function as PropType<(key: Key, width: number | null) => void>, + display: Boolean, + order: Number, + component: PropTypes.any, + invalidate: Boolean, + }, + setup(props, { slots, expose }) { + const mergedHidden = computed(() => props.responsive && !props.display); + const itemNodeRef = ref(); + + expose({ itemNodeRef }); + + // ================================ Effect ================================ + function internalRegisterSize(width: number | null) { + props.registerSize(props.itemKey!, width); + } + + onUnmounted(() => { + internalRegisterSize(null); + }); + + return () => { + const { + prefixCls, + invalidate, + item, + renderItem, + responsive, + registerSize, + itemKey, + display, + order, + component: Component = 'div', + ...restProps + } = props; + const children = slots.default?.(); + // ================================ Render ================================ + const childNode = renderItem && item !== undefined ? renderItem(item) : children; + + let overflowStyle: CSSProperties | undefined; + if (!invalidate) { + overflowStyle = { + opacity: mergedHidden.value ? 0 : 1, + height: mergedHidden.value ? 0 : undefined, + overflowY: mergedHidden.value ? 'hidden' : undefined, + order: responsive ? order : undefined, + pointerEvents: mergedHidden.value ? 'none' : undefined, + }; + } + + const overflowProps: HTMLAttributes = {}; + if (mergedHidden.value) { + overflowProps['aria-hidden'] = true; + } + + let itemNode = ( + + {childNode} + + ); + + if (responsive) { + itemNode = ( + { + internalRegisterSize(offsetWidth); + }} + > + {itemNode} + + ); + } + + return itemNode; + }; + }, +}); diff --git a/components/vc-overflow/index.ts b/components/vc-overflow/index.ts new file mode 100644 index 0000000000..0d37249788 --- /dev/null +++ b/components/vc-overflow/index.ts @@ -0,0 +1,5 @@ +// import Overflow, { OverflowProps } from './Overflow'; + +// export { OverflowProps }; + +// export default Overflow; diff --git a/components/vc-trigger/Trigger.jsx b/components/vc-trigger/Trigger.jsx index d97f417aa4..1fcfbedd21 100644 --- a/components/vc-trigger/Trigger.jsx +++ b/components/vc-trigger/Trigger.jsx @@ -47,7 +47,7 @@ export default defineComponent({ showAction: PropTypes.any.def([]), hideAction: PropTypes.any.def([]), getPopupClassNameFromAlign: PropTypes.any.def(returnEmptyString), - // onPopupVisibleChange: PropTypes.func.def(noop), + onPopupVisibleChange: PropTypes.func.def(noop), afterPopupVisibleChange: PropTypes.func.def(noop), popup: PropTypes.any, popupStyle: PropTypes.object.def(() => ({})), @@ -443,7 +443,7 @@ export default defineComponent({ }, setPopupVisible(sPopupVisible, event) { - const { alignPoint, sPopupVisible: prevPopupVisible, $attrs } = this; + const { alignPoint, sPopupVisible: prevPopupVisible, onPopupVisibleChange } = this; this.clearDelayTimer(); if (prevPopupVisible !== sPopupVisible) { if (!hasProp(this, 'popupVisible')) { @@ -452,7 +452,7 @@ export default defineComponent({ prevPopupVisible, }); } - $attrs.onPopupVisibleChange && $attrs.onPopupVisibleChange(sPopupVisible); + onPopupVisibleChange && onPopupVisibleChange(sPopupVisible); } // Always record the point position since mouseEnterDelay will delay the show if (alignPoint && event) { diff --git a/components/vc-util/devWarning.ts b/components/vc-util/devWarning.ts new file mode 100644 index 0000000000..17f72748b5 --- /dev/null +++ b/components/vc-util/devWarning.ts @@ -0,0 +1,7 @@ +import devWarning, { resetWarned } from './warning'; + +export { resetWarned }; + +export default (valid: boolean, component: string, message: string): void => { + devWarning(valid, `[ant-design-vue: ${component}] ${message}`); +}; diff --git a/examples/App.vue b/examples/App.vue index e15daac464..81ea7e0f4a 100644 --- a/examples/App.vue +++ b/examples/App.vue @@ -1,39 +1,107 @@ - diff --git a/v2-doc b/v2-doc index eacad021cf..d197053285 160000 --- a/v2-doc +++ b/v2-doc @@ -1 +1 @@ -Subproject commit eacad021cf9e4d7d18fe4c4b9a38cbd7e3378d49 +Subproject commit d197053285b81e77718621c0b5b94cb3b21831a2