diff --git a/components/_util/transition.tsx b/components/_util/transition.tsx index 0281d680e..275d1cef7 100644 --- a/components/_util/transition.tsx +++ b/components/_util/transition.tsx @@ -1,4 +1,11 @@ -import { defineComponent, nextTick, Transition as T, TransitionGroup as TG } from 'vue'; +import { + BaseTransitionProps, + CSSProperties, + defineComponent, + nextTick, + Transition as T, + TransitionGroup as TG, +} from 'vue'; import { findDOMNode } from './props-util'; export const getTransitionProps = (transitionName: string, opt: object = {}) => { @@ -80,6 +87,37 @@ if (process.env.NODE_ENV === 'test') { }); } -export { Transition, TransitionGroup }; +export declare type MotionEvent = (TransitionEvent | AnimationEvent) & { + deadline?: boolean; +}; + +export declare type MotionEventHandler = ( + element: HTMLElement, + done?: () => void, +) => CSSProperties | void; + +export declare type MotionEndEventHandler = ( + element: HTMLElement, + done?: () => void, +) => boolean | void; + +// ================== Collapse Motion ================== +const getCollapsedHeight: MotionEventHandler = () => ({ height: 0, opacity: 0 }); +const getRealHeight: MotionEventHandler = node => ({ height: node.scrollHeight, opacity: 1 }); +const getCurrentHeight: MotionEventHandler = node => ({ height: node.offsetHeight }); +// const skipOpacityTransition: MotionEndEventHandler = (_, event) => +// (event as TransitionEvent).propertyName === 'height'; + +const collapseMotion: BaseTransitionProps = { + // motionName: 'ant-motion-collapse', + appear: true, + // onAppearStart: getCollapsedHeight, + onBeforeEnter: getCollapsedHeight, + onEnter: getRealHeight, + onBeforeLeave: getCurrentHeight, + onLeave: getCollapsedHeight, +}; + +export { Transition, TransitionGroup, collapseMotion }; export default Transition; diff --git a/components/menu/src/InlineSubMenuList.tsx b/components/menu/src/InlineSubMenuList.tsx new file mode 100644 index 000000000..5bf53b5a9 --- /dev/null +++ b/components/menu/src/InlineSubMenuList.tsx @@ -0,0 +1,50 @@ +import { computed, defineComponent, ref, watch } from '@vue/runtime-core'; +import { useInjectMenu, MenuContextProvider } from './hooks/useMenuContext'; +import { MenuMode } from './interface'; +import SubMenuList from './SubMenuList'; +export default defineComponent({ + name: 'InlineSubMenuList', + inheritAttrs: false, + props: { + id: String, + open: Boolean, + keyPath: Array, + }, + setup(props, { slots }) { + const fixedMode: MenuMode = 'inline'; + const { prefixCls, forceSubMenuRender, motion, mode } = useInjectMenu(); + const sameModeRef = computed(() => mode.value === fixedMode); + const destroy = ref(!sameModeRef.value); + + // ================================= Effect ================================= + // Reset destroy state when mode change back + watch( + mode, + () => { + if (sameModeRef.value) { + destroy.value = false; + } + }, + { flush: 'post' }, + ); + let transitionProps = computed(() => { + return { appear: props.keyPath.length > 1, css: false }; + }); + + return () => { + if (destroy.value) { + return null; + } + return ( + + {slots.default?.()} + + ); + }; + }, +}); diff --git a/components/menu/src/ItemGroup.tsx b/components/menu/src/ItemGroup.tsx index b44604b47..993f0cb4d 100644 --- a/components/menu/src/ItemGroup.tsx +++ b/components/menu/src/ItemGroup.tsx @@ -8,13 +8,14 @@ export default defineComponent({ props: { title: PropTypes.VNodeChild, }, + inheritAttrs: false, slots: ['title'], - setup(props, { slots }) { + setup(props, { slots, attrs }) { const { prefixCls } = useInjectMenu(); const groupPrefixCls = computed(() => `${prefixCls.value}-item-group`); return () => { return ( -
  • e.stopPropagation()} class={groupPrefixCls.value}> +
  • e.stopPropagation()} class={groupPrefixCls.value}>
    , 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>; @@ -18,6 +40,33 @@ export default defineComponent({ props: menuProps, setup(props, { slots }) { const { prefixCls, direction } = useConfigInject('menu', props); + + const siderCollapsed = inject( + 'layoutSiderCollapsed', + computed(() => undefined), + ); + const inlineCollapsed = computed(() => { + const { inlineCollapsed } = props; + if (siderCollapsed.value !== undefined) { + return siderCollapsed.value; + } + return inlineCollapsed; + }); + + watchEffect(() => { + devWarning( + !('inlineCollapsed' in props && props.mode !== 'inline'), + 'Menu', + '`inlineCollapsed` should only be used when `mode` is inline.', + ); + + devWarning( + !(siderCollapsed.value !== undefined && 'inlineCollapsed' in props), + 'Menu', + '`inlineCollapsed` not control Menu under Sider. Should set `collapsed` on Sider instead.', + ); + }); + const activeKeys = ref([]); const openKeys = ref([]); const selectedKeys = ref([]); @@ -25,10 +74,18 @@ export default defineComponent({ activeKeys.value = keys; }; const disabled = computed(() => !!props.disabled); - useProvideMenu({ prefixCls, activeKeys, openKeys, selectedKeys, changeActiveKeys, disabled }); const isRtl = computed(() => direction.value === 'rtl'); - const mergedMode = ref('vertical'); + const mergedMode = ref('vertical'); const mergedInlineCollapsed = ref(false); + watchEffect(() => { + if (props.mode === 'inline' && inlineCollapsed.value) { + mergedMode.value = 'vertical'; + mergedInlineCollapsed.value = inlineCollapsed.value; + } + mergedMode.value = props.mode; + mergedInlineCollapsed.value = false; + }); + const className = computed(() => { return { [`${prefixCls.value}`]: true, @@ -39,6 +96,35 @@ export default defineComponent({ [`${prefixCls.value}-${props.theme}`]: true, }; }); + + const defaultMotions = { + horizontal: { motionName: `ant-slide-up` }, + inline: collapseMotion, + other: { motionName: `ant-zoom-big` }, + }; + + useProvideFirstLevel(true); + + useProvideMenu({ + prefixCls, + activeKeys, + openKeys, + selectedKeys, + 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, + }); return () => { return
      {slots.default?.()}
    ; }; diff --git a/components/menu/src/MenuItem.tsx b/components/menu/src/MenuItem.tsx index 415b32b1d..3c1bf23c1 100644 --- a/components/menu/src/MenuItem.tsx +++ b/components/menu/src/MenuItem.tsx @@ -1,6 +1,10 @@ +import { flattenChildren, getPropsSlot, isValidElement } from '../../_util/props-util'; +import PropTypes from '../../_util/vue-types'; import { computed, defineComponent, getCurrentInstance, ref, watch } from 'vue'; import { useInjectKeyPath } from './hooks/useKeyPath'; -import { useInjectMenu } from './hooks/useMenuContext'; +import { useInjectFirstLevel, useInjectMenu } from './hooks/useMenuContext'; +import { cloneElement } from '../../_util/vnode'; +import Tooltip from '../../tooltip'; let indexGuid = 0; @@ -9,15 +13,29 @@ export default defineComponent({ props: { role: String, disabled: Boolean, + danger: Boolean, + title: { type: [String, Boolean] }, + icon: PropTypes.VNodeChild, }, emits: ['mouseenter', 'mouseleave'], - setup(props, { slots, emit }) { + slots: ['icon'], + inheritAttrs: false, + setup(props, { slots, emit, attrs }) { const instance = getCurrentInstance(); const key = instance.vnode.key; const uniKey = `menu_item_${++indexGuid}`; const parentKeys = useInjectKeyPath(); console.log(parentKeys.value); - const { prefixCls, activeKeys, disabled, changeActiveKeys } = useInjectMenu(); + const { + prefixCls, + activeKeys, + disabled, + changeActiveKeys, + rtl, + inlineCollapsed, + siderCollapsed, + } = useInjectMenu(); + const firstLevel = useInjectFirstLevel(); const isActive = ref(false); watch( activeKeys, @@ -32,6 +50,7 @@ export default defineComponent({ const itemCls = `${prefixCls.value}-item`; return { [`${itemCls}`]: true, + [`${itemCls}-danger`]: props.danger, [`${itemCls}-active`]: isActive.value, [`${itemCls}-selected`]: selected.value, [`${itemCls}-disabled`]: mergedDisabled.value, @@ -50,26 +69,80 @@ export default defineComponent({ } }; + 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 ( -
  • - {slots.default?.()} -
  • +
  • + {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 000000000..ac291d5c1 --- /dev/null +++ b/components/menu/src/PopupTrigger.tsx @@ -0,0 +1,96 @@ +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, + } = 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 ( + + ); + }; + }, +}); diff --git a/components/menu/src/SubMenu.tsx b/components/menu/src/SubMenu.tsx index 51e186a94..09ac845f2 100644 --- a/components/menu/src/SubMenu.tsx +++ b/components/menu/src/SubMenu.tsx @@ -1,12 +1,74 @@ -import { defineComponent } from 'vue'; -import useProvideKeyPath from './hooks/useKeyPath'; +import PropTypes from '../../_util/vue-types'; +import { computed, defineComponent } from 'vue'; +import useProvideKeyPath, { useInjectKeyPath } from './hooks/useKeyPath'; +import { useInjectMenu, useProvideFirstLevel } from './hooks/useMenuContext'; +import { getPropsSlot, isValidElement } from 'ant-design-vue/es/_util/props-util'; +import classNames from 'ant-design-vue/es/_util/classNames'; export default defineComponent({ name: 'ASubMenu', - setup(props, { slots }) { + props: { + icon: PropTypes.VNodeChild, + title: PropTypes.VNodeChild, + disabled: Boolean, + level: Number, + popupClassName: String, + popupOffset: [Number, Number], + }, + slots: ['icon', 'title'], + inheritAttrs: false, + setup(props, { slots, attrs }) { useProvideKeyPath(); + useProvideFirstLevel(false); + const keyPath = useInjectKeyPath(); + const { + prefixCls, + activeKeys, + disabled, + changeActiveKeys, + rtl, + mode, + inlineCollapsed, + antdMenuTheme, + } = useInjectMenu(); + + const popupClassName = computed(() => + classNames(prefixCls, `${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}} + + ); + }; + + const className = computed(() => + classNames( + prefixCls.value, + `${prefixCls.value}-sub`, + `${prefixCls.value}-${mode.value === 'inline' ? 'inline' : 'vertical'}`, + ), + ); return () => { - return
      {slots.default?.()}
    ; + const icon = getPropsSlot(slots, props, 'icon'); + const title = renderTitle(getPropsSlot(slots, props, 'title'), icon); + return ( +
      + {slots.default?.()} +
    + ); }; }, }); diff --git a/components/menu/src/SubMenuList.tsx b/components/menu/src/SubMenuList.tsx new file mode 100644 index 000000000..d98343d6f --- /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 000000000..9721fc16d --- /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/useMenuContext.ts b/components/menu/src/hooks/useMenuContext.ts index 76c959631..060d70e59 100644 --- a/components/menu/src/hooks/useMenuContext.ts +++ b/components/menu/src/hooks/useMenuContext.ts @@ -1,22 +1,22 @@ import { Key } from '../../../_util/type'; -import { ComputedRef, inject, InjectionKey, provide, Ref } from 'vue'; - -// import { -// BuiltinPlacements, -// MenuClickEventHandler, -// MenuMode, -// RenderIconType, -// TriggerSubMenuAction, -// } from '../interface'; +import { ComputedRef, FunctionalComponent, inject, InjectionKey, provide, Ref } from 'vue'; +import { BuiltinPlacements, MenuMode, MenuTheme, TriggerSubMenuAction } from '../interface'; export interface MenuContextProps { prefixCls: ComputedRef; openKeys: Ref; selectedKeys: Ref; - // rtl?: boolean; + rtl?: ComputedRef; + + locked?: Ref; + + inlineCollapsed: Ref; + antdMenuTheme?: ComputedRef; + + siderCollapsed?: ComputedRef; // // Mode - // mode: MenuMode; + mode: Ref; // // Disabled disabled?: ComputedRef; @@ -33,18 +33,18 @@ export interface MenuContextProps { // selectedKeys: string[]; // // Level - // inlineIndent: number; + inlineIndent: ComputedRef; // // Motion - // // motion?: CSSMotionProps; - // // defaultMotions?: Partial<{ [key in MenuMode | 'other']: CSSMotionProps }>; + motion?: any; + defaultMotions?: Partial<{ [key in MenuMode | 'other']: any }>; // // Popup - // subMenuOpenDelay: number; - // subMenuCloseDelay: number; + subMenuOpenDelay: ComputedRef; + subMenuCloseDelay: ComputedRef; // forceSubMenuRender?: boolean; - // builtinPlacements?: BuiltinPlacements; - // triggerSubMenuAction?: TriggerSubMenuAction; + builtinPlacements?: ComputedRef; + triggerSubMenuAction?: ComputedRef; // // Icon // itemIcon?: RenderIconType; @@ -53,7 +53,7 @@ export interface MenuContextProps { // // Function // onItemClick: MenuClickEventHandler; // onOpenChange: (key: string, open: boolean) => void; - // getPopupContainer: (node: HTMLElement) => HTMLElement; + getPopupContainer: ComputedRef<(node: HTMLElement) => HTMLElement>; } const MenuContextKey: InjectionKey = Symbol('menuContextKey'); @@ -66,6 +66,34 @@ const useInjectMenu = () => { return inject(MenuContextKey); }; -export { useProvideMenu, MenuContextKey, useInjectMenu }; +const MenuFirstLevelContextKey: InjectionKey = Symbol('menuFirstLevelContextKey'); +const useProvideFirstLevel = (firstLevel: Boolean) => { + provide(MenuFirstLevelContextKey, firstLevel); +}; + +const useInjectFirstLevel = () => { + return inject(MenuFirstLevelContextKey, true); +}; + +const MenuContextProvider: FunctionalComponent<{ props: Record }> = ( + props, + { slots }, +) => { + useProvideMenu({ ...useInjectMenu(), ...props }); + return slots.default?.(); +}; +MenuContextProvider.props = { props: Object }; +MenuContextProvider.inheritAttrs = false; +MenuContextProvider.displayName = 'MenuContextProvider'; + +export { + useProvideMenu, + MenuContextKey, + useInjectMenu, + MenuFirstLevelContextKey, + useProvideFirstLevel, + useInjectFirstLevel, + MenuContextProvider, +}; export default useProvideMenu; diff --git a/components/vc-trigger/Trigger.jsx b/components/vc-trigger/Trigger.jsx index d97f417aa..1fcfbedd2 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 000000000..17f72748b --- /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 3caf71cb9..cd9ce1dfc 100644 --- a/examples/App.vue +++ b/examples/App.vue @@ -15,14 +15,11 @@ Navigation One - - - Option 1 - Option 2 - + + + Option 1 + + Option 2 Option 3 Option 4