Skip to content

Commit 12a6d7f

Browse files
骆沛kagol
authored andcommitted
feat: 完成Mention组件所有功能及文档和demo说明
1 parent e03472e commit 12a6d7f

File tree

9 files changed

+593
-10
lines changed

9 files changed

+593
-10
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"devDependencies": {
1919
"@commitlint/cli": "^11.0.0",
2020
"@ls-lint/ls-lint": "^1.10.0",
21+
"@types/lodash": "^4.14.182",
2122
"all-contributors-cli": "^6.20.0",
2223
"esbuild-register": "^2.6.0",
2324
"eslint": "^7.28.0",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { App } from 'vue';
2+
import Mention from './src/mention';
3+
4+
export * from './src/mention-types';
5+
6+
export { Mention };
7+
8+
export default {
9+
title: 'Mention 提及',
10+
category: '数据录入',
11+
status: '100%',
12+
install(app: App): void {
13+
app.component(Mention.name, Mention);
14+
},
15+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ExtractPropTypes, PropType } from 'vue';
2+
3+
export interface IMentionSuggestionItem {
4+
value: string;
5+
id: string | number;
6+
}
7+
8+
9+
export const mentionProps = {
10+
position: {
11+
type: String as PropType<'top' | 'bottom'>,
12+
default: 'bottom'
13+
},
14+
suggestions: {
15+
type: Array as PropType<IMentionSuggestionItem[]>,
16+
required: true
17+
},
18+
notFoundContent: {
19+
type: String,
20+
default: 'No suggestion matched'
21+
},
22+
loading: {
23+
type: Boolean as PropType<boolean>,
24+
default: false
25+
},
26+
dmValueParse: {
27+
type: Object as PropType<IMentionSuggestionItem>,
28+
default: { value: 'value', id: 'id' }
29+
},
30+
trigger: {
31+
type: Array as PropType<string[]>,
32+
default: ['@']
33+
}
34+
};
35+
36+
37+
export type MentionProps = ExtractPropTypes<typeof mentionProps>;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
@import '../../styles-var/devui-var.scss';
2+
3+
.#{$devui-prefix}-mention {
4+
position: relative;
5+
6+
&__suggestions {
7+
position: absolute;
8+
left: 24px;
9+
z-index: $devui-z-index-framework;
10+
background: #ffffff;
11+
min-width: 120px;
12+
max-height: 250px;
13+
overflow-x: hidden;
14+
overflow-y: auto;
15+
box-shadow: var(--devui-shadow-length-base, 0 1px 4px 0) var(--devui-light-shadow, rgba(37, 43, 58, 0.1));
16+
17+
&-item {
18+
display: flex;
19+
align-items: center;
20+
padding: 6px 14px;
21+
cursor: pointer;
22+
23+
&:hover {
24+
background: #eeeeee;
25+
}
26+
27+
&-active {
28+
background: #f2f5fa;
29+
}
30+
}
31+
32+
&-loading {
33+
height: 40px;
34+
width: 40px;
35+
display: flex;
36+
justify-content: center;
37+
align-items: center;
38+
top: 10px;
39+
padding: 6px 10px;
40+
cursor: pointer;
41+
}
42+
}
43+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { defineComponent, ref, onMounted, watch, onUnmounted, nextTick, computed, getCurrentInstance } from 'vue';
2+
import { IMentionSuggestionItem, mentionProps, type MentionProps } from './mention-types';
3+
import DTextarea from '../../textarea/src/textarea';
4+
import DIcon from '../../icon/src/icon';
5+
import './mention.scss';
6+
import { useNamespace } from '../../shared/hooks/use-namespace';
7+
import { debounce } from 'lodash';
8+
9+
export default defineComponent({
10+
name: 'DMention',
11+
components: {
12+
DTextarea,
13+
DIcon,
14+
},
15+
props: mentionProps,
16+
emits: ['select', 'change'],
17+
setup(props: MentionProps, { slots, emit }) {
18+
const ns = useNamespace('mention');
19+
const textContext = ref<string>('');
20+
const showSuggestions = ref<boolean>(false);
21+
const currentIndex = ref<number>(0);
22+
const suggestionsTop = ref<number>();
23+
const suggestions = ref<IMentionSuggestionItem[]>([]);
24+
const filteredSuggestions = ref<IMentionSuggestionItem[]>([]);
25+
const suggestionsDom = ref<HTMLDivElement>();
26+
const loading = computed(() => props.loading);
27+
const instance = getCurrentInstance();
28+
29+
const handleUpdate = debounce((val: string) => {
30+
if (props.trigger.includes(val[0])) {
31+
showSuggestions.value = true;
32+
if (props.position === 'top') {
33+
nextTick(() => {
34+
const height = window.getComputedStyle(suggestionsDom.value as Element, null).height;
35+
suggestionsTop.value = -Number(height.replace('px', ''));
36+
});
37+
}
38+
filteredSuggestions.value = (suggestions.value as IMentionSuggestionItem[]).filter((item: IMentionSuggestionItem) =>
39+
String(item[props.dmValueParse.value as keyof IMentionSuggestionItem])
40+
.toLocaleLowerCase()
41+
.includes(val.slice(1).toLocaleLowerCase())
42+
);
43+
} else {
44+
showSuggestions.value = false;
45+
}
46+
emit('change', val.slice(1));
47+
}, 300);
48+
49+
const handleBlur = () => {
50+
setTimeout(() => {
51+
showSuggestions.value = false;
52+
}, 100);
53+
};
54+
55+
const handleFocus = () => {
56+
if (props.trigger.includes(textContext.value)) {
57+
showSuggestions.value = true;
58+
}
59+
};
60+
61+
const clickItem = (item: IMentionSuggestionItem, e: MouseEvent) => {
62+
emit('select', item);
63+
e.stopPropagation();
64+
e.preventDefault();
65+
showSuggestions.value = false;
66+
textContext.value += item[props.dmValueParse.value as keyof IMentionSuggestionItem];
67+
};
68+
69+
const arrowKeyDown = (e: KeyboardEvent) => {
70+
if (showSuggestions.value && filteredSuggestions.value.length) {
71+
if (e.key === 'ArrowDown') {
72+
currentIndex.value++;
73+
if (currentIndex.value === filteredSuggestions.value.length) {
74+
currentIndex.value = 0;
75+
}
76+
}
77+
if (e.key === 'ArrowUp') {
78+
currentIndex.value--;
79+
if (currentIndex.value === -1) {
80+
currentIndex.value = filteredSuggestions.value.length - 1;
81+
}
82+
}
83+
const itemDom = instance?.proxy?.$refs[`devui-suggestions-item-${currentIndex.value}`];
84+
const itemOffsetTop = (itemDom as HTMLElement)?.offsetTop;
85+
const clientHeight = (suggestionsDom.value as HTMLElement)?.clientHeight;
86+
const itemTotal = Math.ceil(clientHeight / (itemDom as HTMLElement).clientHeight);
87+
if ((e.key === 'ArrowDown' && currentIndex.value >= itemTotal) || e.key === 'ArrowUp') {
88+
suggestionsDom.value?.scrollTo({
89+
top: itemOffsetTop,
90+
});
91+
}
92+
}
93+
};
94+
95+
const enterKeyDown = (e: KeyboardEvent) => {
96+
if (showSuggestions.value && filteredSuggestions.value.length) {
97+
if (e.key === 'Enter') {
98+
e.stopPropagation();
99+
e.preventDefault();
100+
showSuggestions.value = false;
101+
textContext.value += filteredSuggestions.value[currentIndex.value][props.dmValueParse.value as keyof IMentionSuggestionItem];
102+
emit('select', filteredSuggestions.value[currentIndex.value]);
103+
}
104+
}
105+
};
106+
107+
watch(
108+
() => props.suggestions,
109+
(val) => {
110+
suggestions.value = val as IMentionSuggestionItem[];
111+
filteredSuggestions.value = val as IMentionSuggestionItem[];
112+
},
113+
{ immediate: true, deep: true }
114+
);
115+
116+
onMounted(() => {
117+
window.addEventListener('keydown', arrowKeyDown);
118+
window.addEventListener('keydown', enterKeyDown);
119+
});
120+
121+
onUnmounted(() => {
122+
window.removeEventListener('keydown', arrowKeyDown);
123+
window.removeEventListener('keydown', enterKeyDown);
124+
});
125+
126+
return () => {
127+
return (
128+
<div class={ns.b()}>
129+
<d-textarea v-model={textContext.value} onUpdate={handleUpdate} onBlur={handleBlur} onFocus={handleFocus}></d-textarea>
130+
{showSuggestions.value ? (
131+
loading.value ? (
132+
<div class={[`${ns.e('suggestions')} ${ns.e('suggestions-loading')}`]}>加载中... </div>
133+
) : (
134+
<div
135+
class={ns.e('suggestions')}
136+
ref={suggestionsDom}
137+
style={{
138+
marginTop: props.position === 'top' ? '0px' : '-16px',
139+
top: suggestionsTop.value ? suggestionsTop.value + 'px' : 'inherit',
140+
}}>
141+
{filteredSuggestions.value.length > 0 ? (
142+
filteredSuggestions.value?.map((item, index) => {
143+
return (
144+
<div
145+
ref={`devui-suggestions-item-${index}`}
146+
class={`${ns.e('suggestions-item')}
147+
${currentIndex.value === index ? `${ns.e('suggestions-item-active')}` : ''}`}
148+
key={item.id}
149+
onClick={(e) => clickItem(item, e)}>
150+
{slots.template ? slots.template({ item }) : item.value}
151+
</div>
152+
);
153+
})
154+
) : (
155+
<div>{props.notFoundContent}</div>
156+
)}
157+
</div>
158+
)
159+
) : null}
160+
</div>
161+
);
162+
};
163+
},
164+
});

packages/devui-vue/devui/textarea/src/composables/use-textarea-event.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function useTextareaEvent(isFocus: Ref<boolean>, props: TextareaProps, ct
1919

2020
const onInput = (e: Event) => {
2121
ctx.emit('update:modelValue', (e.target as HTMLInputElement).value);
22+
ctx.emit('update', (e.target as HTMLInputElement).value);
2223
};
2324

2425
const onChange = (e: Event) => {

packages/devui-vue/devui/textarea/src/textarea.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default defineComponent({
1111
name: 'DTextarea',
1212
inheritAttrs: false,
1313
props: textareaProps,
14-
emits: ['update:modelValue', 'focus', 'blur', 'change', 'keydown'],
14+
emits: ['update:modelValue', 'update', 'focus', 'blur', 'change', 'keydown'],
1515
setup(props: TextareaProps, ctx: SetupContext) {
1616
const { modelValue } = toRefs(props);
1717
const formItemContext = inject(FORM_ITEM_TOKEN, undefined) as FormItemContext;

0 commit comments

Comments
 (0)