refactor: mentions

refactor-mentions
tangjinzhou 2021-07-07 21:00:05 +08:00
parent ab5f9c9bf8
commit 10d0a91c55
11 changed files with 307 additions and 199 deletions

View File

@ -11,11 +11,12 @@ function $$(className) {
} }
function triggerInput(wrapper, text = '') { function triggerInput(wrapper, text = '') {
const lastChar = text[text.length - 1];
wrapper.find('textarea').element.value = text; wrapper.find('textarea').element.value = text;
wrapper.find('textarea').element.selectionStart = text.length; wrapper.find('textarea').element.selectionStart = text.length;
wrapper.find('textarea').trigger('keydown'); wrapper.find('textarea').trigger('keydown');
wrapper.find('textarea').trigger('change'); wrapper.find('textarea').trigger('change');
wrapper.find('textarea').trigger('keyup'); wrapper.find('textarea').trigger('keyup', { key: lastChar });
} }
describe('Mentions', () => { describe('Mentions', () => {
@ -69,9 +70,9 @@ describe('Mentions', () => {
}, },
{ sync: false, attachTo: 'body' }, { sync: false, attachTo: 'body' },
); );
await sleep(500); await sleep(100);
triggerInput(wrapper, '@'); triggerInput(wrapper, '@');
await sleep(500); await sleep(100);
expect($$('.ant-mentions-dropdown-menu-item').length).toBeTruthy(); expect($$('.ant-mentions-dropdown-menu-item').length).toBeTruthy();
expect($$('.ant-spin')).toBeTruthy(); expect($$('.ant-spin')).toBeTruthy();
}); });

View File

@ -1,4 +1,5 @@
import type { App, PropType, VNodeTypes, Plugin, ExtractPropTypes } from 'vue'; import type { App, PropType, Plugin, ExtractPropTypes } from 'vue';
import { watch } from 'vue';
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { defineComponent, nextTick } from 'vue'; import { defineComponent, nextTick } from 'vue';
import classNames from '../_util/classNames'; import classNames from '../_util/classNames';
@ -6,9 +7,8 @@ import omit from 'omit.js';
import PropTypes from '../_util/vue-types'; import PropTypes from '../_util/vue-types';
import VcMentions from '../vc-mentions'; import VcMentions from '../vc-mentions';
import { mentionsProps as baseMentionsProps } from '../vc-mentions/src/mentionsProps'; import { mentionsProps as baseMentionsProps } from '../vc-mentions/src/mentionsProps';
import Spin from '../spin';
import type { RenderEmptyHandler } from '../config-provider/renderEmpty';
import useConfigInject from '../_util/hooks/useConfigInject'; import useConfigInject from '../_util/hooks/useConfigInject';
import { flattenChildren, getOptionProps } from '../_util/props-util';
const { Option } = VcMentions; const { Option } = VcMentions;
@ -19,23 +19,26 @@ interface MentionsConfig {
export interface MentionsOptionProps { export interface MentionsOptionProps {
value: string; value: string;
disabled: boolean; disabled?: boolean;
children: VNodeTypes; label?: string | number | ((o: MentionsOptionProps) => any);
[key: string]: any; [key: string]: any;
} }
function loadingFilterOption() { interface MentionsEntity {
return true; prefix: string;
value: string;
} }
function getMentions(value = '', config: MentionsConfig) { export type MentionPlacement = 'top' | 'bottom';
const getMentions = (value = '', config: MentionsConfig): MentionsEntity[] => {
const { prefix = '@', split = ' ' } = config || {}; const { prefix = '@', split = ' ' } = config || {};
const prefixList = Array.isArray(prefix) ? prefix : [prefix]; const prefixList: string[] = Array.isArray(prefix) ? prefix : [prefix];
return value return value
.split(split) .split(split)
.map((str = '') => { .map((str = ''): MentionsEntity | null => {
let hitPrefix = null; let hitPrefix: string | null = null;
prefixList.some(prefixStr => { prefixList.some(prefixStr => {
const startStr = str.slice(0, prefixStr.length); const startStr = str.slice(0, prefixStr.length);
@ -49,13 +52,13 @@ function getMentions(value = '', config: MentionsConfig) {
if (hitPrefix !== null) { if (hitPrefix !== null) {
return { return {
prefix: hitPrefix, prefix: hitPrefix,
value: str.slice(hitPrefix.length), value: str.slice((hitPrefix as string).length),
}; };
} }
return null; return null;
}) })
.filter(entity => !!entity && !!entity.value); .filter((entity): entity is MentionsEntity => !!entity && !!entity.value);
} };
const mentionsProps = { const mentionsProps = {
...baseMentionsProps, ...baseMentionsProps,
@ -72,6 +75,8 @@ const mentionsProps = {
onChange: { onChange: {
type: Function as PropType<(text: string) => void>, type: Function as PropType<(text: string) => void>,
}, },
notFoundContent: PropTypes.any,
defaultValue: String,
}; };
export type MentionsProps = Partial<ExtractPropTypes<typeof mentionsProps>>; export type MentionsProps = Partial<ExtractPropTypes<typeof mentionsProps>>;
@ -81,12 +86,20 @@ const Mentions = defineComponent({
inheritAttrs: false, inheritAttrs: false,
props: mentionsProps, props: mentionsProps,
getMentions, getMentions,
emits: ['update:value', 'change', 'focus', 'blur', 'select'], Option,
emits: ['update:value', 'change', 'focus', 'blur', 'select', 'pressenter'],
slots: ['notFoundContent', 'option'],
setup(props, { slots, emit, attrs, expose }) { setup(props, { slots, emit, attrs, expose }) {
const { prefixCls, renderEmpty, direction } = useConfigInject('mentions', props); const { prefixCls, renderEmpty, direction } = useConfigInject('mentions', props);
const focused = ref(false); const focused = ref(false);
const vcMentions = ref(null); const vcMentions = ref(null);
const value = ref(props.value ?? props.defaultValue ?? '');
watch(
() => props.value,
val => {
value.value = val;
},
);
const handleFocus = (e: FocusEvent) => { const handleFocus = (e: FocusEvent) => {
focused.value = true; focused.value = true;
emit('focus', e); emit('focus', e);
@ -103,43 +116,34 @@ const Mentions = defineComponent({
}; };
const handleChange = (val: string) => { const handleChange = (val: string) => {
if (props.value === undefined) {
value.value = val;
}
emit('update:value', val); emit('update:value', val);
emit('change', val); emit('change', val);
}; };
const getNotFoundContent = (renderEmpty: RenderEmptyHandler) => { const getNotFoundContent = () => {
const notFoundContent = props.notFoundContent; const notFoundContent = props.notFoundContent;
if (notFoundContent !== undefined) { if (notFoundContent !== undefined) {
return notFoundContent; return notFoundContent;
} }
if (slots.notFoundContent) {
return renderEmpty('Select'); return slots.notFoundContent();
}
return renderEmpty.value('Select');
}; };
const getOptions = () => { const getOptions = () => {
const { loading } = props; return flattenChildren(slots.default?.() || []).map(item => {
return { ...getOptionProps(item), label: (item.children as any)?.default?.() };
if (loading) { });
return (
<Option value="ANTD_SEARCHING" disabled>
<Spin size="small" />
</Option>
);
}
return slots.default?.();
};
const getFilterOption = () => {
const { filterOption, loading } = props;
if (loading) {
return loadingFilterOption;
}
return filterOption;
}; };
const focus = () => { const focus = () => {
(vcMentions.value as HTMLTextAreaElement).focus(); (vcMentions.value as HTMLTextAreaElement).focus();
}; };
const blur = () => { const blur = () => {
(vcMentions.value as HTMLTextAreaElement).blur(); (vcMentions.value as HTMLTextAreaElement).blur();
}; };
@ -157,35 +161,40 @@ const Mentions = defineComponent({
}); });
return () => { return () => {
const { disabled, getPopupContainer, ...restProps } = props; const { disabled, getPopupContainer, rows = 1, ...restProps } = props;
const { class: className, ...otherAttrs } = attrs; const { class: className, ...otherAttrs } = attrs;
const otherProps = omit(restProps, ['loading', 'onUpdate:value', 'prefixCls']); const otherProps = omit(restProps, ['defaultValue', 'onUpdate:value', 'prefixCls']);
const mergedClassName = classNames(className, { const mergedClassName = classNames(className, {
[`${prefixCls.value}-disabled`]: disabled, [`${prefixCls.value}-disabled`]: disabled,
[`${prefixCls.value}-focused`]: focused.value, [`${prefixCls.value}-focused`]: focused.value,
[`${prefixCls}-rtl`]: direction.value === 'rtl', [`${prefixCls.value}-rtl`]: direction.value === 'rtl',
}); });
const mentionsProps = { const mentionsProps = {
prefixCls: prefixCls.value, prefixCls: prefixCls.value,
notFoundContent: getNotFoundContent(renderEmpty.value),
...otherProps, ...otherProps,
disabled, disabled,
direction: direction.value, direction: direction.value,
filterOption: getFilterOption(), filterOption: props.filterOption,
getPopupContainer, getPopupContainer,
children: getOptions(), options: props.options || getOptions(),
class: mergedClassName, class: mergedClassName,
rows: 1,
...otherAttrs, ...otherAttrs,
rows,
onChange: handleChange, onChange: handleChange,
onSelect: handleSelect, onSelect: handleSelect,
onFocus: handleFocus, onFocus: handleFocus,
onBlur: handleBlur, onBlur: handleBlur,
ref: vcMentions, ref: vcMentions,
value: value.value,
}; };
return <VcMentions {...mentionsProps} />; return (
<VcMentions
{...mentionsProps}
v-slots={{ notFoundContent: getNotFoundContent, option: slots.option }}
></VcMentions>
);
}; };
}, },
}); });
@ -198,11 +207,12 @@ export const MentionsOption = {
/* istanbul ignore next */ /* istanbul ignore next */
Mentions.install = function (app: App) { Mentions.install = function (app: App) {
app.component(Mentions.name, Mentions); app.component(Mentions.name, Mentions);
app.component(MentionsOption.name, MentionsOption); app.component('AMentionsOption', Option);
return app; return app;
}; };
export default Mentions as typeof Mentions & export default Mentions as typeof Mentions &
Plugin & { Plugin & {
getMentions: typeof getMentions;
readonly Option: typeof Option; readonly Option: typeof Option;
}; };

View File

@ -1,6 +1,8 @@
// base rc-mentions .6.2
import Mentions from './src/Mentions'; import Mentions from './src/Mentions';
import Option from './src/Option'; import Option from './src/Option';
Mentions.Option = Option; Mentions.Option = Option;
export { Option };
export default Mentions; export default Mentions;

View File

@ -1,26 +1,34 @@
import Menu, { Item as MenuItem } from '../../menu'; import Menu, { Item as MenuItem } from '../../menu';
import PropTypes from '../../_util/vue-types'; import type { PropType } from 'vue';
import { defineComponent, inject } from 'vue'; import { defineComponent, inject, ref } from 'vue';
import type { OptionProps } from './Option';
import MentionsContextKey from './MentionsContext';
import Spin from '../../spin';
function noop() {} function noop() {}
export default defineComponent({ export default defineComponent({
name: 'DropdownMenu', name: 'DropdownMenu',
props: { props: {
prefixCls: PropTypes.string, prefixCls: String,
options: PropTypes.any, options: {
type: Array as PropType<OptionProps[]>,
default: () => [],
},
}, },
setup(props) { slots: ['notFoundContent', 'option'],
setup(props, { slots }) {
const {
activeIndex,
setActiveIndex,
selectOption,
onFocus = noop,
onBlur = noop,
loading,
} = inject(MentionsContextKey, {
activeIndex: ref(),
loading: ref(false),
});
return () => { return () => {
const {
notFoundContent,
activeIndex,
setActiveIndex,
selectOption,
onFocus = noop,
onBlur = noop,
} = inject('mentionsContext');
const { prefixCls, options } = props; const { prefixCls, options } = props;
const activeOption = options[activeIndex.value] || {}; const activeOption = options[activeIndex.value] || {};
@ -35,9 +43,9 @@ export default defineComponent({
onBlur={onBlur} onBlur={onBlur}
onFocus={onFocus} onFocus={onFocus}
> >
{[ {!loading.value &&
...options.map((option, index) => { options.map((option, index) => {
const { value, disabled, children } = option; const { value, disabled, label = option.value } = option;
return ( return (
<MenuItem <MenuItem
key={value} key={value}
@ -46,16 +54,21 @@ export default defineComponent({
setActiveIndex(index); setActiveIndex(index);
}} }}
> >
{children} {slots.option?.(option) ??
(typeof label === 'function' ? label({ value, disabled }) : label)}
</MenuItem> </MenuItem>
); );
}), })}
!options.length && ( {!loading.value && options.length === 0 ? (
<MenuItem key="notFoundContent" disabled> <MenuItem key="notFoundContent" disabled>
{notFoundContent.value} {slots.notFoundContent?.()}
</MenuItem> </MenuItem>
), ) : null}
].filter(Boolean)} {loading.value && (
<MenuItem key="loading" disabled>
<Spin size="small" />
</MenuItem>
)}
</Menu> </Menu>
); );
}; };

View File

@ -1,7 +1,9 @@
import PropTypes from '../../_util/vue-types'; import PropTypes from '../../_util/vue-types';
import Trigger from '../../vc-trigger'; import Trigger from '../../vc-trigger';
import DropdownMenu from './DropdownMenu'; import DropdownMenu from './DropdownMenu';
import { defineComponent } from 'vue'; import type { PropType } from 'vue';
import { computed, defineComponent } from 'vue';
import type { OptionProps } from './Option';
const BUILT_IN_PLACEMENTS = { const BUILT_IN_PLACEMENTS = {
bottomRight: { bottomRight: {
@ -12,6 +14,14 @@ const BUILT_IN_PLACEMENTS = {
adjustY: 1, adjustY: 1,
}, },
}, },
bottomLeft: {
points: ['tr', 'bl'],
offset: [0, 4],
overflow: {
adjustX: 0,
adjustY: 1,
},
},
topRight: { topRight: {
points: ['bl', 'tr'], points: ['bl', 'tr'],
offset: [0, -4], offset: [0, -4],
@ -20,50 +30,72 @@ const BUILT_IN_PLACEMENTS = {
adjustY: 1, adjustY: 1,
}, },
}, },
topLeft: {
points: ['br', 'tl'],
offset: [0, -4],
overflow: {
adjustX: 0,
adjustY: 1,
},
},
}; };
export default defineComponent({ export default defineComponent({
name: 'KeywordTrigger', name: 'KeywordTrigger',
props: { props: {
loading: PropTypes.looseBool, loading: PropTypes.looseBool,
options: PropTypes.array, options: {
type: Array as PropType<OptionProps[]>,
default: () => [],
},
prefixCls: PropTypes.string, prefixCls: PropTypes.string,
placement: PropTypes.string, placement: PropTypes.string,
visible: PropTypes.looseBool, visible: PropTypes.looseBool,
transitionName: PropTypes.string, transitionName: PropTypes.string,
getPopupContainer: PropTypes.func, getPopupContainer: PropTypes.func,
direction: PropTypes.string,
}, },
methods: { slots: ['notFoundContent', 'option'],
getDropdownPrefix() { setup(props, { slots }) {
return `${this.$props.prefixCls}-dropdown`; const getDropdownPrefix = () => {
}, return `${props.prefixCls}-dropdown`;
getDropdownElement() { };
const { options } = this.$props; const getDropdownElement = () => {
return <DropdownMenu prefixCls={this.getDropdownPrefix()} options={options} />; const { options } = props;
}, return (
}, <DropdownMenu
prefixCls={getDropdownPrefix()}
options={options}
v-slots={{ notFoundContent: slots.notFoundContent, option: slots.option }}
/>
);
};
render() { const popupPlacement = computed(() => {
const { visible, placement, transitionName, getPopupContainer } = this.$props; const { placement, direction } = props;
let popupPlacement = 'topRight';
const { $slots } = this; if (direction === 'rtl') {
popupPlacement = placement === 'top' ? 'topLeft' : 'bottomLeft';
const children = $slots.default?.(); } else {
popupPlacement = placement === 'top' ? 'topRight' : 'bottomRight';
const popupElement = this.getDropdownElement(); }
return popupPlacement;
return ( });
<Trigger return () => {
prefixCls={this.getDropdownPrefix()} const { visible, transitionName, getPopupContainer } = props;
popupVisible={visible} return (
popup={popupElement} <Trigger
popupPlacement={placement === 'top' ? 'topRight' : 'bottomRight'} prefixCls={getDropdownPrefix()}
popupTransitionName={transitionName} popupVisible={visible}
builtinPlacements={BUILT_IN_PLACEMENTS} popup={getDropdownElement()}
getPopupContainer={getPopupContainer} popupPlacement={popupPlacement.value}
> popupTransitionName={transitionName}
{children} builtinPlacements={BUILT_IN_PLACEMENTS}
</Trigger> getPopupContainer={getPopupContainer}
); >
{slots.default?.()}
</Trigger>
);
};
}, },
}); });

View File

@ -1,9 +1,9 @@
import type { ExtractPropTypes } from 'vue'; import type { ExtractPropTypes } from 'vue';
import { toRef, watchEffect } from 'vue';
import { import {
defineComponent, defineComponent,
provide, provide,
withDirectives, withDirectives,
onMounted,
ref, ref,
reactive, reactive,
onUpdated, onUpdated,
@ -13,8 +13,7 @@ import {
import classNames from '../../_util/classNames'; import classNames from '../../_util/classNames';
import omit from 'omit.js'; import omit from 'omit.js';
import KeyCode from '../../_util/KeyCode'; import KeyCode from '../../_util/KeyCode';
import { getOptionProps, initDefaultProps } from '../../_util/props-util'; import { initDefaultProps } from '../../_util/props-util';
import warning from 'warning';
import { import {
getBeforeSelectionText, getBeforeSelectionText,
getLastMeasureIndex, getLastMeasureIndex,
@ -24,21 +23,25 @@ import {
import KeywordTrigger from './KeywordTrigger'; import KeywordTrigger from './KeywordTrigger';
import { vcMentionsProps, defaultProps } from './mentionsProps'; import { vcMentionsProps, defaultProps } from './mentionsProps';
import antInput from '../../_util/antInputDirective'; import antInput from '../../_util/antInputDirective';
import type { OptionProps } from './Option';
import MentionsContextKey from './MentionsContext';
export type MentionsProps = Partial<ExtractPropTypes<typeof vcMentionsProps>>; export type MentionsProps = Partial<ExtractPropTypes<typeof vcMentionsProps>>;
function noop() {} function noop() {}
const Mentions = { export default defineComponent({
name: 'Mentions', name: 'Mentions',
inheritAttrs: false, inheritAttrs: false,
props: initDefaultProps(vcMentionsProps, defaultProps), props: initDefaultProps(vcMentionsProps, defaultProps),
setup(props: MentionsProps, { emit, attrs, expose }) { slots: ['notFoundContent', 'option'],
emits: ['change', 'select', 'search', 'focus', 'blur', 'pressenter'],
setup(props: MentionsProps, { emit, attrs, expose, slots }) {
const measure = ref(null); const measure = ref(null);
const textarea = ref(null); const textarea = ref(null);
const focusId = ref(); const focusId = ref();
const state = reactive({ const state = reactive({
value: props.defaultValue || props.value || '', value: props.value || '',
measuring: false, measuring: false,
measureLocation: 0, measureLocation: 0,
measureText: null, measureText: null,
@ -47,8 +50,11 @@ const Mentions = {
isFocus: false, isFocus: false,
}); });
watchEffect(() => {
state.value = props.value;
});
const triggerChange = (val: string) => { const triggerChange = (val: string) => {
state.value = val;
emit('change', val); emit('change', val);
}; };
@ -57,6 +63,24 @@ const Mentions = {
triggerChange(value); triggerChange(value);
}; };
const startMeasure = (measureText: string, measurePrefix: string, measureLocation: number) => {
Object.assign(state, {
measuring: true,
measureText,
measurePrefix,
measureLocation,
activeIndex: 0,
});
};
const stopMeasure = (callback?: () => void) => {
Object.assign(state, {
measuring: false,
measureLocation: 0,
measureText: null,
});
callback?.();
};
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
const { which } = event; const { which } = event;
// Skip if not measuring // Skip if not measuring
@ -66,7 +90,7 @@ const Mentions = {
if (which === KeyCode.UP || which === KeyCode.DOWN) { if (which === KeyCode.UP || which === KeyCode.DOWN) {
// Control arrow function // Control arrow function
const optionLen = getOptions().length; const optionLen = options.value.length;
const offset = which === KeyCode.UP ? -1 : 1; const offset = which === KeyCode.UP ? -1 : 1;
const newActiveIndex = (state.activeIndex + offset + optionLen) % optionLen; const newActiveIndex = (state.activeIndex + offset + optionLen) % optionLen;
state.activeIndex = newActiveIndex; state.activeIndex = newActiveIndex;
@ -76,12 +100,11 @@ const Mentions = {
} else if (which === KeyCode.ENTER) { } else if (which === KeyCode.ENTER) {
// Measure hit // Measure hit
event.preventDefault(); event.preventDefault();
const options = getOptions(); if (!options.value.length) {
if (!options.length) {
stopMeasure(); stopMeasure();
return; return;
} }
const option = options[state.activeIndex]; const option = options.value[state.activeIndex];
selectOption(option); selectOption(option);
} }
}; };
@ -110,6 +133,7 @@ const Mentions = {
if (validateMeasure) { if (validateMeasure) {
if ( if (
key === measurePrefix || key === measurePrefix ||
key === 'Shift' ||
measuring || measuring ||
(measureText !== prevMeasureText && matchOption) (measureText !== prevMeasureText && matchOption)
) { ) {
@ -131,6 +155,12 @@ const Mentions = {
stopMeasure(); stopMeasure();
} }
}; };
const onPressEnter = event => {
if (!state.measuring) {
emit('pressenter', event);
}
};
const onInputFocus = (event: Event) => { const onInputFocus = (event: Event) => {
onFocus(event); onFocus(event);
}; };
@ -152,7 +182,7 @@ const Mentions = {
emit('blur', event); emit('blur', event);
}, 100); }, 100);
}; };
const selectOption = option => { const selectOption = (option: OptionProps) => {
const { split } = props; const { split } = props;
const { value: mentionValue = '' } = option; const { value: mentionValue = '' } = option;
const { text, selectionLocation } = replaceWithMeasure(state.value, { const { text, selectionLocation } = replaceWithMeasure(state.value, {
@ -170,38 +200,26 @@ const Mentions = {
emit('select', option, state.measurePrefix); emit('select', option, state.measurePrefix);
}; };
const setActiveIndex = activeIndex => { const setActiveIndex = (activeIndex: number) => {
state.activeIndex = activeIndex; state.activeIndex = activeIndex;
}; };
const getOptions = (measureText?: string) => { const getOptions = (measureText?: string) => {
const targetMeasureText = measureText || state.measureText || ''; const targetMeasureText = measureText || state.measureText || '';
const { filterOption, children = [] } = props; const { filterOption } = props;
const list = (Array.isArray(children) ? children : [children]) const list = props.options.filter((option: OptionProps) => {
.map(item => { /** Return all result if `filterOption` is false. */
return { ...getOptionProps(item), children: item.children.default?.() }; if (!!filterOption === false) {
}) return true;
.filter(option => { }
/** Return all result if `filterOption` is false. */ return filterOption(targetMeasureText, option);
if (!!filterOption === false) { });
return true;
}
return filterOption(targetMeasureText, option);
});
return list; return list;
}; };
const startMeasure = (measureText, measurePrefix, measureLocation) => { const options = computed(() => {
state.measuring = true; return getOptions();
state.measureText = measureText; });
state.measurePrefix = measurePrefix;
state.measureLocation = measureLocation;
state.activeIndex = 0;
};
const stopMeasure = (callback?: () => void) => {
state.measuring = false;
state.measureLocation = 0;
state.measureText = null;
callback?.();
};
const focus = () => { const focus = () => {
textarea.value.focus(); textarea.value.focus();
}; };
@ -209,19 +227,13 @@ const Mentions = {
textarea.value.blur(); textarea.value.blur();
}; };
expose({ blur, focus }); expose({ blur, focus });
onMounted(() => { provide(MentionsContextKey, {
warning(props.children, 'please children prop replace slots.default'); activeIndex: toRef(state, 'activeIndex'),
setActiveIndex,
provide('mentionsContext', { selectOption,
notFoundContent: computed(() => { onFocus,
return props.notFoundContent; onBlur,
}), loading: toRef(props, 'loading'),
activeIndex: computed(() => {
return state.activeIndex;
}),
setActiveIndex,
selectOption,
});
}); });
onUpdated(() => { onUpdated(() => {
nextTick(() => { nextTick(() => {
@ -232,28 +244,21 @@ const Mentions = {
}); });
return () => { return () => {
const { measureLocation, measurePrefix, measuring } = state; const { measureLocation, measurePrefix, measuring } = state;
const { const { prefixCls, placement, transitionName, getPopupContainer, direction, ...restProps } =
prefixCls, props;
placement,
transitionName,
notFoundContent,
getPopupContainer,
...restProps
} = props;
const { class: className, style, ...otherAttrs } = attrs; const { class: className, style, ...otherAttrs } = attrs;
const inputProps = omit(restProps, [ const inputProps = omit(restProps, [
'value', 'value',
'defaultValue',
'prefix', 'prefix',
'split', 'split',
'children',
'validateSearch', 'validateSearch',
'filterOption', 'filterOption',
'options',
'loading',
]); ]);
const options = measuring ? getOptions() : [];
const textareaProps = { const textareaProps = {
...inputProps, ...inputProps,
...otherAttrs, ...otherAttrs,
@ -265,8 +270,8 @@ const Mentions = {
onKeydown: onKeyDown, onKeydown: onKeyDown,
onKeyup: onKeyUp, onKeyup: onKeyUp,
onFocus: onInputFocus, onFocus: onInputFocus,
onPressenter: onPressEnter,
}; };
return ( return (
<div class={classNames(prefixCls, className)} style={style}> <div class={classNames(prefixCls, className)} style={style}>
{withDirectives(<textarea ref={textarea} {...textareaProps} />, [[antInput]])} {withDirectives(<textarea ref={textarea} {...textareaProps} />, [[antInput]])}
@ -277,9 +282,11 @@ const Mentions = {
prefixCls={prefixCls} prefixCls={prefixCls}
transitionName={transitionName} transitionName={transitionName}
placement={placement} placement={placement}
options={options} options={measuring ? options.value : []}
visible visible
direction={direction}
getPopupContainer={getPopupContainer} getPopupContainer={getPopupContainer}
v-slots={{ notFoundContent: slots.notFoundContent, option: slots.option }}
> >
<span>{measurePrefix}</span> <span>{measurePrefix}</span>
</KeywordTrigger> </KeywordTrigger>
@ -290,6 +297,4 @@ const Mentions = {
); );
}; };
}, },
}; });
export default defineComponent(Mentions);

View File

@ -0,0 +1,15 @@
import type { InjectionKey, Ref } from 'vue';
import type { OptionProps } from './Option';
export interface MentionsContext {
activeIndex: Ref<number>;
setActiveIndex?: (index: number) => void;
selectOption?: (option: OptionProps) => void;
onFocus?: EventListener;
onBlur?: EventListener;
loading?: Ref<boolean>;
}
const MentionsContextKey: InjectionKey<MentionsContext> = Symbol('MentionsContextKey');
export default MentionsContextKey;

View File

@ -1,19 +1,18 @@
import type { ExtractPropTypes } from 'vue'; import type { ExtractPropTypes } from 'vue';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import PropTypes from '../../_util/vue-types';
export const optionProps = { export const optionProps = {
value: PropTypes.string, value: String,
disabled: PropTypes.looseBool, disabled: Boolean,
children: PropTypes.any, label: [String, Number, Function],
}; };
export type OptionProps = ExtractPropTypes<typeof optionProps>; export type OptionProps = Partial<ExtractPropTypes<typeof optionProps>>;
export default defineComponent({ export default defineComponent({
name: 'Option', name: 'Option',
props: optionProps, props: optionProps,
render() { render(_props: any, { slots }: any) {
return null; return slots.default?.();
}, },
}); });

View File

@ -6,17 +6,17 @@ import {
validateSearch as defaultValidateSearch, validateSearch as defaultValidateSearch,
} from './util'; } from './util';
import { tuple } from '../../_util/type'; import { tuple } from '../../_util/type';
import type { OptionProps } from './Option';
export const PlaceMent = tuple('top', 'bottom'); export const PlaceMent = tuple('top', 'bottom');
export type Direction = 'ltr' | 'rtl';
export const mentionsProps = { export const mentionsProps = {
autofocus: PropTypes.looseBool, autofocus: PropTypes.looseBool,
prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
prefixCls: PropTypes.string, prefixCls: PropTypes.string,
value: PropTypes.string, value: PropTypes.string,
defaultValue: PropTypes.string,
disabled: PropTypes.looseBool, disabled: PropTypes.looseBool,
notFoundContent: PropTypes.VNodeChild,
split: PropTypes.string, split: PropTypes.string,
transitionName: PropTypes.string, transitionName: PropTypes.string,
placement: PropTypes.oneOf(PlaceMent), placement: PropTypes.oneOf(PlaceMent),
@ -27,16 +27,23 @@ export const mentionsProps = {
getPopupContainer: { getPopupContainer: {
type: Function as PropType<() => HTMLElement>, type: Function as PropType<() => HTMLElement>,
}, },
options: {
type: Array as PropType<OptionProps>,
default: () => undefined,
},
loading: PropTypes.looseBool,
rows: [Number, String],
direction: { type: String as PropType<Direction> },
}; };
export const vcMentionsProps = { export const vcMentionsProps = {
...mentionsProps, ...mentionsProps,
children: PropTypes.any,
}; };
export const defaultProps = { export const defaultProps = {
prefix: '@', prefix: '@',
split: ' ', split: ' ',
rows: 1,
validateSearch: defaultValidateSearch, validateSearch: defaultValidateSearch,
filterOption: defaultFilterOption, filterOption: defaultFilterOption,
}; };

View File

@ -1,12 +1,24 @@
import type { MentionsProps } from './Mentions'; import type { MentionsProps } from './Mentions';
import type { OptionProps } from './Option';
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
type OmitFunc = <T extends object, K extends [...(keyof T)[]]>(
obj: T,
...keys: K
) => { [K2 in Exclude<keyof T, K[number]>]: T[K2] };
export const omit: OmitFunc = (obj, ...keys) => {
const clone = {
...obj,
};
keys.forEach(key => {
delete clone[key];
});
return clone;
};
interface MeasureConfig {
measureLocation: number;
prefix: string;
targetText: string;
selectionStart: number;
split: string;
}
/** /**
* Cut input selection into 2 part and return text before selection start * Cut input selection into 2 part and return text before selection start
*/ */
@ -15,17 +27,17 @@ export function getBeforeSelectionText(input: HTMLTextAreaElement) {
return input.value.slice(0, selectionStart); return input.value.slice(0, selectionStart);
} }
function lower(char: string | undefined): string { interface MeasureIndex {
return (char || '').toLowerCase(); location: number;
prefix: string;
} }
/** /**
* Find the last match prefix index * Find the last match prefix index
*/ */
export function getLastMeasureIndex(text: string, prefix: string | string[] = '') { export function getLastMeasureIndex(text: string, prefix: string | string[] = ''): MeasureIndex {
const prefixList: string[] = Array.isArray(prefix) ? prefix : [prefix]; const prefixList: string[] = Array.isArray(prefix) ? prefix : [prefix];
return prefixList.reduce( return prefixList.reduce(
(lastMatch, prefixStr) => { (lastMatch: MeasureIndex, prefixStr): MeasureIndex => {
const lastIndex = text.lastIndexOf(prefixStr); const lastIndex = text.lastIndexOf(prefixStr);
if (lastIndex > lastMatch.location) { if (lastIndex > lastMatch.location) {
return { return {
@ -39,6 +51,18 @@ export function getLastMeasureIndex(text: string, prefix: string | string[] = ''
); );
} }
interface MeasureConfig {
measureLocation: number;
prefix: string;
targetText: string;
selectionStart: number;
split: string;
}
function lower(char: string | undefined): string {
return (char || '').toLowerCase();
}
function reduceText(text: string, targetText: string, split: string) { function reduceText(text: string, targetText: string, split: string) {
const firstChar = text[0]; const firstChar = text[0];
if (!firstChar || firstChar === split) { if (!firstChar || firstChar === split) {
@ -112,7 +136,7 @@ export function validateSearch(text: string, props: MentionsProps) {
return !split || text.indexOf(split) === -1; return !split || text.indexOf(split) === -1;
} }
export function filterOption(input = '', { value = '' } = {}) { export function filterOption(input: string, { value = '' }: OptionProps): boolean {
const lowerCase = input.toLowerCase(); const lowerCase = input.toLowerCase();
return value.toLowerCase().indexOf(lowerCase) !== -1; return value.toLowerCase().indexOf(lowerCase) !== -1;
} }

2
v2-doc

@ -1 +1 @@
Subproject commit b6ab0fec2cfa378bab8dfe6c8ef6b6a8664b970e Subproject commit 89612874e476dc788711cdaedfd037b9497e5e78