Commit f645680a3b9a1f75395329970551d9e5d6bd845b
1 parent
c8021ef3
feat: right-click menu supports multiple levels
Showing
15 changed files
with
138 additions
and
52 deletions
CHANGELOG.zh_CN.md
... | ... | @@ -3,6 +3,7 @@ |
3 | 3 | ### ✨ Features |
4 | 4 | |
5 | 5 | - 全局 loading 添加文本 |
6 | +- 右键菜单支持多级 | |
6 | 7 | |
7 | 8 | ### 🎫 Chores |
8 | 9 | |
... | ... | @@ -13,7 +14,7 @@ |
13 | 14 | - Layout 界面布局样式调整 |
14 | 15 | - 优化表格渲染性能 |
15 | 16 | - 表单折叠搜索添图标添加动画 |
16 | -- routeModule 可以忽略 layou 配置不写。方便配置一级菜单 | |
17 | +- routeModule 可以忽略 layout 配置不写。方便配置一级菜单 | |
17 | 18 | |
18 | 19 | ### 🐛 Bug Fixes |
19 | 20 | ... | ... |
src/components/ContextMenu/src/index.less
1 | 1 | @import (reference) '../../../design/index.less'; |
2 | 2 | |
3 | +.item-style() { | |
4 | + li { | |
5 | + display: inline-block; | |
6 | + width: 100%; | |
7 | + height: 46px !important; | |
8 | + margin: 0 !important; | |
9 | + line-height: 46px; | |
10 | + | |
11 | + span { | |
12 | + line-height: 46px; | |
13 | + } | |
14 | + | |
15 | + > div { | |
16 | + margin: 0 !important; | |
17 | + } | |
18 | + | |
19 | + &:hover { | |
20 | + color: @text-color-base; | |
21 | + background: #eee; | |
22 | + } | |
23 | + } | |
24 | +} | |
25 | + | |
3 | 26 | .context-menu { |
4 | 27 | position: fixed; |
5 | 28 | top: 0; |
... | ... | @@ -18,32 +41,17 @@ |
18 | 41 | background-clip: padding-box; |
19 | 42 | user-select: none; |
20 | 43 | |
21 | - &.hidden { | |
22 | - display: none !important; | |
23 | - } | |
44 | + .item-style(); | |
24 | 45 | |
25 | - &__item { | |
26 | - a { | |
27 | - display: inline-block; | |
28 | - width: 100%; | |
29 | - padding: 10px 14px; | |
46 | + .ant-divider { | |
47 | + margin: 0 0; | |
48 | + } | |
30 | 49 | |
31 | - &:hover { | |
32 | - color: @text-color-base; | |
33 | - background: #eee; | |
34 | - } | |
50 | + &__popup { | |
51 | + .ant-divider { | |
52 | + margin: 0 0; | |
35 | 53 | } |
36 | 54 | |
37 | - &.disabled { | |
38 | - a { | |
39 | - color: @disabled-color; | |
40 | - cursor: not-allowed; | |
41 | - | |
42 | - &:hover { | |
43 | - color: @disabled-color; | |
44 | - background: unset; | |
45 | - } | |
46 | - } | |
47 | - } | |
55 | + .item-style(); | |
48 | 56 | } |
49 | 57 | } | ... | ... |
src/components/ContextMenu/src/index.tsx
... | ... | @@ -8,9 +8,13 @@ import { |
8 | 8 | unref, |
9 | 9 | onUnmounted, |
10 | 10 | } from 'vue'; |
11 | + | |
11 | 12 | import { props } from './props'; |
12 | 13 | import Icon from '/@/components/Icon'; |
14 | +import { Menu, Divider } from 'ant-design-vue'; | |
15 | + | |
13 | 16 | import type { ContextMenuItem } from './types'; |
17 | + | |
14 | 18 | import './index.less'; |
15 | 19 | const prefixCls = 'context-menu'; |
16 | 20 | export default defineComponent({ |
... | ... | @@ -43,12 +47,13 @@ export default defineComponent({ |
43 | 47 | top: (body.clientHeight < y + menuHeight ? y - menuHeight : y) + 'px', |
44 | 48 | }; |
45 | 49 | }); |
50 | + | |
46 | 51 | function handleAction(item: ContextMenuItem, e: MouseEvent) { |
52 | + state.show = false; | |
47 | 53 | const { handler, disabled } = item; |
48 | 54 | if (disabled) { |
49 | 55 | return; |
50 | 56 | } |
51 | - state.show = false; | |
52 | 57 | if (e) { |
53 | 58 | e.stopPropagation(); |
54 | 59 | e.preventDefault(); |
... | ... | @@ -61,31 +66,47 @@ export default defineComponent({ |
61 | 66 | |
62 | 67 | const { showIcon } = props; |
63 | 68 | return ( |
64 | - <span style="display: inline-block; width: 100%;"> | |
69 | + <span style="display: inline-block; width: 100%;" onClick={handleAction.bind(null, item)}> | |
65 | 70 | {showIcon && icon && <Icon class="mr-2" icon={icon} />} |
66 | 71 | <span>{label}</span> |
67 | 72 | </span> |
68 | 73 | ); |
69 | 74 | } |
70 | 75 | function renderMenuItem(items: ContextMenuItem[]) { |
71 | - return items.map((item) => { | |
72 | - const { disabled, label } = item; | |
76 | + return items.map((item, index) => { | |
77 | + const { disabled, label, children, divider = false } = item; | |
73 | 78 | |
74 | - return ( | |
75 | - <li class={`${prefixCls}__item ${disabled ? 'disabled' : ''}`} key={label}> | |
76 | - <a onClick={handleAction.bind(null, item)} style="color:#333;"> | |
77 | - {renderContent(item)} | |
78 | - </a> | |
79 | - </li> | |
79 | + const DividerComp = divider ? <Divider key={`d-${index}`} /> : null; | |
80 | + if (!children || children.length === 0) { | |
81 | + return [ | |
82 | + <Menu.Item disabled={disabled} class={`${prefixCls}__item`} key={label}> | |
83 | + {() => [renderContent(item)]} | |
84 | + </Menu.Item>, | |
85 | + DividerComp, | |
86 | + ]; | |
87 | + } | |
88 | + return !state.show ? null : ( | |
89 | + <Menu.SubMenu key={label} disabled={disabled} popupClassName={`${prefixCls}__popup `}> | |
90 | + {{ | |
91 | + title: () => renderContent(item), | |
92 | + default: () => [renderMenuItem(children)], | |
93 | + }} | |
94 | + </Menu.SubMenu> | |
80 | 95 | ); |
81 | 96 | }); |
82 | 97 | } |
83 | 98 | return () => { |
84 | 99 | const { items } = props; |
85 | - return ( | |
86 | - <ul class={[prefixCls, !state.show && 'hidden']} ref={wrapRef} style={unref(getStyle)}> | |
87 | - {renderMenuItem(items)} | |
88 | - </ul> | |
100 | + return !state.show ? null : ( | |
101 | + <Menu | |
102 | + inlineIndent={12} | |
103 | + mode="vertical" | |
104 | + class={[prefixCls]} | |
105 | + ref={wrapRef} | |
106 | + style={unref(getStyle)} | |
107 | + > | |
108 | + {() => renderMenuItem(items)} | |
109 | + </Menu> | |
89 | 110 | ); |
90 | 111 | }; |
91 | 112 | }, | ... | ... |
src/components/Description/src/index.tsx
... | ... | @@ -23,6 +23,7 @@ export default defineComponent({ |
23 | 23 | ...unref(propsRef), |
24 | 24 | }; |
25 | 25 | }); |
26 | + | |
26 | 27 | const getProps = computed(() => { |
27 | 28 | const opt = { |
28 | 29 | ...props, |
... | ... | @@ -31,12 +32,14 @@ export default defineComponent({ |
31 | 32 | }; |
32 | 33 | return opt; |
33 | 34 | }); |
35 | + | |
34 | 36 | /** |
35 | 37 | * @description: 是否使用标题 |
36 | 38 | */ |
37 | 39 | const useWrapper = computed(() => { |
38 | 40 | return !!unref(getMergeProps).title; |
39 | 41 | }); |
42 | + | |
40 | 43 | /** |
41 | 44 | * @description: 获取配置Collapse |
42 | 45 | */ |
... | ... | @@ -49,6 +52,7 @@ export default defineComponent({ |
49 | 52 | }; |
50 | 53 | } |
51 | 54 | ); |
55 | + | |
52 | 56 | /** |
53 | 57 | * @description:设置desc |
54 | 58 | */ |
... | ... | @@ -57,9 +61,11 @@ export default defineComponent({ |
57 | 61 | const mergeProps = deepMerge(unref(propsRef) || {}, descProps); |
58 | 62 | propsRef.value = cloneDeep(mergeProps); |
59 | 63 | } |
64 | + | |
60 | 65 | const methods: DescInstance = { |
61 | 66 | setDescProps, |
62 | 67 | }; |
68 | + | |
63 | 69 | emit('register', methods); |
64 | 70 | |
65 | 71 | // 防止换行 |
... | ... | @@ -95,6 +101,7 @@ export default defineComponent({ |
95 | 101 | |
96 | 102 | const width = contentMinWidth; |
97 | 103 | return ( |
104 | + // @ts-ignore | |
98 | 105 | <Descriptions.Item label={renderLabel(item)} key={field} span={span}> |
99 | 106 | {() => |
100 | 107 | contentMinWidth ? ( |
... | ... | @@ -113,13 +120,15 @@ export default defineComponent({ |
113 | 120 | ); |
114 | 121 | }); |
115 | 122 | } |
123 | + | |
116 | 124 | const renderDesc = () => { |
117 | 125 | return ( |
118 | - <Descriptions class={`${prefixCls}`} {...{ ...attrs, ...unref(getProps) }}> | |
126 | + <Descriptions class={`${prefixCls}`} {...{ ...attrs, ...(unref(getProps) as any) }}> | |
119 | 127 | {() => renderItem()} |
120 | 128 | </Descriptions> |
121 | 129 | ); |
122 | 130 | }; |
131 | + | |
123 | 132 | const renderContainer = () => { |
124 | 133 | const content = props.useCollapse ? renderDesc() : <div>{renderDesc()}</div>; |
125 | 134 | // 减少dom层级 | ... | ... |
src/components/Description/src/useDescription.ts
... | ... | @@ -10,7 +10,7 @@ export function useDescription(props?: Partial<DescOptions>): UseDescReturnType |
10 | 10 | const descRef = ref<DescInstance | null>(null); |
11 | 11 | const loadedRef = ref(false); |
12 | 12 | |
13 | - function getDescription(instance: DescInstance) { | |
13 | + function register(instance: DescInstance) { | |
14 | 14 | if (unref(loadedRef) && isProdMode()) { |
15 | 15 | return; |
16 | 16 | } |
... | ... | @@ -18,10 +18,11 @@ export function useDescription(props?: Partial<DescOptions>): UseDescReturnType |
18 | 18 | props && instance.setDescProps(props); |
19 | 19 | loadedRef.value = true; |
20 | 20 | } |
21 | + | |
21 | 22 | const methods: DescInstance = { |
22 | 23 | setDescProps: (descProps: Partial<DescOptions>): void => { |
23 | 24 | unref(descRef)!.setDescProps(descProps); |
24 | 25 | }, |
25 | 26 | }; |
26 | - return [getDescription, methods]; | |
27 | + return [register, methods]; | |
27 | 28 | } | ... | ... |
src/components/Icon/index.tsx
... | ... | @@ -32,6 +32,7 @@ export default defineComponent({ |
32 | 32 | const { icon, prefix } = props; |
33 | 33 | return `${prefix ? prefix + ':' : ''}${icon}`; |
34 | 34 | }); |
35 | + | |
35 | 36 | const update = async () => { |
36 | 37 | const el = unref(elRef); |
37 | 38 | if (el) { |
... | ... | @@ -67,6 +68,7 @@ export default defineComponent({ |
67 | 68 | }); |
68 | 69 | |
69 | 70 | watch(() => props.icon, update, { flush: 'post' }); |
71 | + | |
70 | 72 | onMounted(update); |
71 | 73 | |
72 | 74 | return () => ( | ... | ... |
src/components/Menu/src/BasicMenu.tsx
... | ... | @@ -55,6 +55,7 @@ export default defineComponent({ |
55 | 55 | } |
56 | 56 | return menuState.openKeys; |
57 | 57 | }); |
58 | + | |
58 | 59 | // menu外层样式 |
59 | 60 | const getMenuWrapStyle = computed((): any => { |
60 | 61 | const { showLogo, search } = props; |
... | ... | @@ -130,6 +131,7 @@ export default defineComponent({ |
130 | 131 | menuState.selectedKeys = [path]; |
131 | 132 | emit('menuClick', menu); |
132 | 133 | } |
134 | + | |
133 | 135 | function handleMenuChange() { |
134 | 136 | const { flatItems } = props; |
135 | 137 | if (!unref(flatItems) || flatItems.length === 0) { | ... | ... |
src/components/Menu/src/useSearchInput.ts
... | ... | @@ -48,9 +48,11 @@ export function useSearchInput({ |
48 | 48 | openKeys = es6Unique(openKeys); |
49 | 49 | menuState.openKeys = openKeys; |
50 | 50 | } |
51 | + | |
51 | 52 | // 搜索框点击 |
52 | 53 | function handleInputClick(e: any): void { |
53 | 54 | emit('clickSearchInput', e); |
54 | 55 | } |
56 | + | |
55 | 57 | return { handleInputChange, handleInputClick }; |
56 | 58 | } | ... | ... |
src/components/Preview/src/index.tsx
src/hooks/web/useWatermark.ts
... | ... | @@ -3,6 +3,7 @@ import { getCurrentInstance, onBeforeUnmount, ref, Ref, unref } from 'vue'; |
3 | 3 | const domSymbol = Symbol('watermark-dom'); |
4 | 4 | |
5 | 5 | export function useWatermark(appendEl: Ref<HTMLElement | null> = ref(document.body)) { |
6 | + let func: Fn = () => {}; | |
6 | 7 | const id = domSymbol.toString(); |
7 | 8 | const clear = () => { |
8 | 9 | const domId = document.getElementById(id); |
... | ... | @@ -10,6 +11,7 @@ export function useWatermark(appendEl: Ref<HTMLElement | null> = ref(document.bo |
10 | 11 | const el = unref(appendEl); |
11 | 12 | el && el.removeChild(domId); |
12 | 13 | } |
14 | + window.addEventListener('resize', func); | |
13 | 15 | }; |
14 | 16 | const createWatermark = (str: string) => { |
15 | 17 | clear(); |
... | ... | @@ -45,7 +47,7 @@ export function useWatermark(appendEl: Ref<HTMLElement | null> = ref(document.bo |
45 | 47 | |
46 | 48 | function setWatermark(str: string) { |
47 | 49 | createWatermark(str); |
48 | - const func = () => { | |
50 | + func = () => { | |
49 | 51 | createWatermark(str); |
50 | 52 | }; |
51 | 53 | window.addEventListener('resize', func); |
... | ... | @@ -53,7 +55,6 @@ export function useWatermark(appendEl: Ref<HTMLElement | null> = ref(document.bo |
53 | 55 | if (instance) { |
54 | 56 | onBeforeUnmount(() => { |
55 | 57 | clear(); |
56 | - window.addEventListener('resize', func); | |
57 | 58 | }); |
58 | 59 | } |
59 | 60 | } | ... | ... |
src/router/routes/modules/demo/feat.ts
... | ... | @@ -18,7 +18,7 @@ export default { |
18 | 18 | { |
19 | 19 | path: '/icon', |
20 | 20 | name: 'IconDemo', |
21 | - component: () => import('/@/views/demo/comp/icon/index.vue'), | |
21 | + component: () => import('/@/views/demo/feat/icon/index.vue'), | |
22 | 22 | meta: { |
23 | 23 | title: '图标', |
24 | 24 | }, |
... | ... | @@ -43,7 +43,7 @@ export default { |
43 | 43 | { |
44 | 44 | path: '/click-out-side', |
45 | 45 | name: 'ClickOutSideDemo', |
46 | - component: () => import('/@/views/demo/comp/click-out-side/index.vue'), | |
46 | + component: () => import('/@/views/demo/feat/click-out-side/index.vue'), | |
47 | 47 | meta: { |
48 | 48 | title: 'ClickOutSide组件', |
49 | 49 | }, | ... | ... |
src/views/demo/comp/button/index.vue
src/views/demo/comp/click-out-side/index.vue renamed to src/views/demo/feat/click-out-side/index.vue
1 | 1 | <template> |
2 | - <div class="px-10"> | |
3 | - <Alert message="点内外部触发事件" show-icon class="mt-4"></Alert> | |
2 | + <div class="p-10"> | |
3 | + <Alert message="点内外部触发事件" show-icon></Alert> | |
4 | 4 | <ClickOutSide @clickOutside="handleClickOutside" class="flex justify-center mt-10"> |
5 | 5 | <div @click="innerClick" class="demo-box"> |
6 | 6 | {{ text }} | ... | ... |
src/views/demo/feat/context-menu/index.vue
... | ... | @@ -3,6 +3,10 @@ |
3 | 3 | <CollapseContainer title="Simple"> |
4 | 4 | <a-button type="primary" @contextmenu="handleContext">Right Click on me</a-button> |
5 | 5 | </CollapseContainer> |
6 | + | |
7 | + <CollapseContainer title="Multiple" class="mt-4"> | |
8 | + <a-button type="primary" @contextmenu="handleMultipleContext">Right Click on me</a-button> | |
9 | + </CollapseContainer> | |
6 | 10 | </div> |
7 | 11 | </template> |
8 | 12 | <script lang="ts"> |
... | ... | @@ -36,7 +40,44 @@ |
36 | 40 | ], |
37 | 41 | }); |
38 | 42 | } |
39 | - return { handleContext }; | |
43 | + | |
44 | + function handleMultipleContext(e: MouseEvent) { | |
45 | + createContextMenu({ | |
46 | + event: e, | |
47 | + items: [ | |
48 | + { | |
49 | + label: 'New', | |
50 | + icon: 'ant-design:plus-outlined', | |
51 | + | |
52 | + children: [ | |
53 | + { | |
54 | + label: 'New1-1', | |
55 | + icon: 'ant-design:plus-outlined', | |
56 | + divider: true, | |
57 | + children: [ | |
58 | + { | |
59 | + label: 'New1-1-1', | |
60 | + handler: () => { | |
61 | + createMessage.success('click new'); | |
62 | + }, | |
63 | + }, | |
64 | + { | |
65 | + label: 'New1-2-1', | |
66 | + disabled: true, | |
67 | + }, | |
68 | + ], | |
69 | + }, | |
70 | + { | |
71 | + label: 'New1-2', | |
72 | + icon: 'ant-design:plus-outlined', | |
73 | + }, | |
74 | + ], | |
75 | + }, | |
76 | + ], | |
77 | + }); | |
78 | + } | |
79 | + | |
80 | + return { handleContext, handleMultipleContext }; | |
40 | 81 | }, |
41 | 82 | }); |
42 | 83 | </script> | ... | ... |
src/views/demo/comp/icon/index.vue renamed to src/views/demo/feat/icon/index.vue
... | ... | @@ -12,7 +12,7 @@ |
12 | 12 | </div> |
13 | 13 | </CollapseContainer> |
14 | 14 | |
15 | - <CollapseContainer title="IconIfy 组件使用" class="mt-5"> | |
15 | + <CollapseContainer title="IconIfy 组件使用" class="my-5"> | |
16 | 16 | <div class="flex justify-around flex-wrap"> |
17 | 17 | <Icon icon="fa-solid:address-book" :size="30" /> |
18 | 18 | <Icon icon="mdi-light:bank" :size="30" /> |
... | ... | @@ -23,7 +23,6 @@ |
23 | 23 | |
24 | 24 | <Alert |
25 | 25 | show-icon |
26 | - class="mt-5" | |
27 | 26 | message="推荐使用Iconify组件" |
28 | 27 | description="Icon组件基本包含所有的图标,在下面网址内你可以查询到你想要的任何图标。并且打包只会打包所用到的图标。唯一不足的可能就是需要连接外网进行使用。" |
29 | 28 | /> | ... | ... |