Refactor mentions (#4341)
* refactor(mentions): use compositionAPI (#4313) * refactor: mentions * refactor: mentions Co-authored-by: ajuner <106791576@qq.com>pull/4499/head
parent
8198cab549
commit
f7b39e2d92
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
import type { App, PropType, VNodeTypes, Plugin, ExtractPropTypes } from 'vue';
|
import type { App, PropType, Plugin, ExtractPropTypes } from 'vue';
|
||||||
import { defineComponent, inject, nextTick } from 'vue';
|
import { watch } from 'vue';
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { defineComponent, nextTick } from 'vue';
|
||||||
import classNames from '../_util/classNames';
|
import classNames from '../_util/classNames';
|
||||||
import omit from 'omit.js';
|
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 useConfigInject from '../_util/hooks/useConfigInject';
|
||||||
import BaseMixin from '../_util/BaseMixin';
|
import { flattenChildren, getOptionProps } from '../_util/props-util';
|
||||||
import { defaultConfigProvider } from '../config-provider';
|
|
||||||
import { getOptionProps, getComponent, getSlot } from '../_util/props-util';
|
|
||||||
import type { RenderEmptyHandler } from '../config-provider/renderEmpty';
|
|
||||||
|
|
||||||
const { Option } = VcMentions;
|
const { Option } = VcMentions;
|
||||||
|
|
||||||
|
@ -20,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);
|
||||||
|
@ -50,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,
|
||||||
|
@ -73,145 +75,144 @@ 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>>;
|
||||||
|
|
||||||
const Mentions = defineComponent({
|
const Mentions = defineComponent({
|
||||||
name: 'AMentions',
|
name: 'AMentions',
|
||||||
mixins: [BaseMixin],
|
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
Option: { ...Option, name: 'AMentionsOption' },
|
|
||||||
getMentions,
|
|
||||||
props: mentionsProps,
|
props: mentionsProps,
|
||||||
emits: ['update:value', 'change', 'focus', 'blur', 'select'],
|
getMentions,
|
||||||
setup() {
|
Option,
|
||||||
return {
|
emits: ['update:value', 'change', 'focus', 'blur', 'select', 'pressenter'],
|
||||||
configProvider: inject('configProvider', defaultConfigProvider),
|
slots: ['notFoundContent', 'option'],
|
||||||
|
setup(props, { slots, emit, attrs, expose }) {
|
||||||
|
const { prefixCls, renderEmpty, direction } = useConfigInject('mentions', props);
|
||||||
|
const focused = ref(false);
|
||||||
|
const vcMentions = ref(null);
|
||||||
|
const value = ref(props.value ?? props.defaultValue ?? '');
|
||||||
|
watch(
|
||||||
|
() => props.value,
|
||||||
|
val => {
|
||||||
|
value.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const handleFocus = (e: FocusEvent) => {
|
||||||
|
focused.value = true;
|
||||||
|
emit('focus', e);
|
||||||
};
|
};
|
||||||
},
|
|
||||||
data() {
|
const handleBlur = (e: FocusEvent) => {
|
||||||
return {
|
focused.value = false;
|
||||||
focused: false,
|
emit('blur', e);
|
||||||
};
|
};
|
||||||
},
|
|
||||||
mounted() {
|
const handleSelect = (...args: [MentionsOptionProps, string]) => {
|
||||||
nextTick(() => {
|
emit('select', ...args);
|
||||||
if (process.env.NODE_ENV === 'test') {
|
focused.value = true;
|
||||||
if (this.autofocus) {
|
};
|
||||||
this.focus();
|
|
||||||
|
const handleChange = (val: string) => {
|
||||||
|
if (props.value === undefined) {
|
||||||
|
value.value = val;
|
||||||
}
|
}
|
||||||
}
|
emit('update:value', val);
|
||||||
});
|
emit('change', val);
|
||||||
},
|
};
|
||||||
methods: {
|
|
||||||
handleFocus(e: FocusEvent) {
|
const getNotFoundContent = () => {
|
||||||
this.$emit('focus', e);
|
const notFoundContent = props.notFoundContent;
|
||||||
this.setState({
|
|
||||||
focused: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
handleBlur(e: FocusEvent) {
|
|
||||||
this.$emit('blur', e);
|
|
||||||
this.setState({
|
|
||||||
focused: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
handleSelect(...args: [MentionsOptionProps, string]) {
|
|
||||||
this.$emit('select', ...args);
|
|
||||||
this.setState({
|
|
||||||
focused: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
handleChange(val: string) {
|
|
||||||
this.$emit('update:value', val);
|
|
||||||
this.$emit('change', val);
|
|
||||||
},
|
|
||||||
getNotFoundContent(renderEmpty: RenderEmptyHandler) {
|
|
||||||
const notFoundContent = getComponent(this, 'notFoundContent');
|
|
||||||
if (notFoundContent !== undefined) {
|
if (notFoundContent !== undefined) {
|
||||||
return notFoundContent;
|
return notFoundContent;
|
||||||
}
|
}
|
||||||
|
if (slots.notFoundContent) {
|
||||||
return renderEmpty('Select');
|
return slots.notFoundContent();
|
||||||
},
|
|
||||||
getOptions() {
|
|
||||||
const { loading } = this.$props;
|
|
||||||
const children = getSlot(this);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Option value="ANTD_SEARCHING" disabled>
|
|
||||||
<Spin size="small" />
|
|
||||||
</Option>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return children;
|
return renderEmpty.value('Select');
|
||||||
},
|
};
|
||||||
getFilterOption() {
|
|
||||||
const { filterOption, loading } = this.$props;
|
const getOptions = () => {
|
||||||
if (loading) {
|
return flattenChildren(slots.default?.() || []).map(item => {
|
||||||
return loadingFilterOption;
|
return { ...getOptionProps(item), label: (item.children as any)?.default?.() };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
(vcMentions.value as HTMLTextAreaElement).focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const blur = () => {
|
||||||
|
(vcMentions.value as HTMLTextAreaElement).blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
expose({ focus, blur });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
if (props.autofocus) {
|
||||||
|
focus();
|
||||||
}
|
}
|
||||||
return filterOption;
|
}
|
||||||
},
|
});
|
||||||
focus() {
|
});
|
||||||
(this.$refs.vcMentions as HTMLTextAreaElement).focus();
|
|
||||||
},
|
return () => {
|
||||||
blur() {
|
const { disabled, getPopupContainer, rows = 1, ...restProps } = props;
|
||||||
(this.$refs.vcMentions as HTMLTextAreaElement).blur();
|
const { class: className, ...otherAttrs } = attrs;
|
||||||
},
|
const otherProps = omit(restProps, ['defaultValue', 'onUpdate:value', 'prefixCls']);
|
||||||
},
|
|
||||||
render() {
|
|
||||||
const { focused } = this.$data;
|
|
||||||
const { getPrefixCls, renderEmpty } = this.configProvider;
|
|
||||||
const {
|
|
||||||
prefixCls: customizePrefixCls,
|
|
||||||
disabled,
|
|
||||||
getPopupContainer,
|
|
||||||
...restProps
|
|
||||||
} = getOptionProps(this) as any;
|
|
||||||
const { class: className, ...otherAttrs } = this.$attrs;
|
|
||||||
const prefixCls = getPrefixCls('mentions', customizePrefixCls);
|
|
||||||
const otherProps = omit(restProps, ['loading', 'onUpdate:value']);
|
|
||||||
|
|
||||||
const mergedClassName = classNames(className, {
|
const mergedClassName = classNames(className, {
|
||||||
[`${prefixCls}-disabled`]: disabled,
|
[`${prefixCls.value}-disabled`]: disabled,
|
||||||
[`${prefixCls}-focused`]: focused,
|
[`${prefixCls.value}-focused`]: focused.value,
|
||||||
|
[`${prefixCls.value}-rtl`]: direction.value === 'rtl',
|
||||||
});
|
});
|
||||||
|
|
||||||
const mentionsProps = {
|
const mentionsProps = {
|
||||||
prefixCls,
|
prefixCls: prefixCls.value,
|
||||||
notFoundContent: this.getNotFoundContent(renderEmpty),
|
|
||||||
...otherProps,
|
...otherProps,
|
||||||
disabled,
|
disabled,
|
||||||
filterOption: this.getFilterOption(),
|
direction: direction.value,
|
||||||
|
filterOption: props.filterOption,
|
||||||
getPopupContainer,
|
getPopupContainer,
|
||||||
children: this.getOptions(),
|
options: props.options || getOptions(),
|
||||||
class: mergedClassName,
|
class: mergedClassName,
|
||||||
rows: 1,
|
|
||||||
...otherAttrs,
|
...otherAttrs,
|
||||||
onChange: this.handleChange,
|
rows,
|
||||||
onSelect: this.handleSelect,
|
onChange: handleChange,
|
||||||
onFocus: this.handleFocus,
|
onSelect: handleSelect,
|
||||||
onBlur: this.handleBlur,
|
onFocus: handleFocus,
|
||||||
ref: 'vcMentions',
|
onBlur: handleBlur,
|
||||||
|
ref: vcMentions,
|
||||||
|
value: value.value,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<VcMentions
|
||||||
|
{...mentionsProps}
|
||||||
|
v-slots={{ notFoundContent: getNotFoundContent, option: slots.option }}
|
||||||
|
></VcMentions>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return <VcMentions {...mentionsProps} />;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const MentionsOption = {
|
||||||
|
...Option,
|
||||||
|
name: 'AMentionsOption',
|
||||||
|
};
|
||||||
|
|
||||||
/* 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(Mentions.Option.name, Mentions.Option);
|
app.component(MentionsOption.name, MentionsOption);
|
||||||
return app;
|
return app;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MentionsOption = Mentions.Option;
|
|
||||||
|
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -64,10 +64,6 @@
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
.placeholder();
|
.placeholder();
|
||||||
|
|
||||||
&:read-only {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-measure {
|
&-measure {
|
||||||
|
@ -123,7 +119,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
line-height: 22px;
|
line-height: @line-height-base;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -159,9 +155,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&-active {
|
&-active {
|
||||||
background-color: @item-active-bg;
|
background-color: @item-hover-bg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@import './rtl';
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
@import '../../style/themes/index';
|
||||||
|
@import '../../style/mixins/index';
|
||||||
|
|
||||||
|
@mention-prefix-cls: ~'@{ant-prefix}-mentions';
|
||||||
|
|
||||||
|
.@{mention-prefix-cls} {
|
||||||
|
&-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -1,65 +0,0 @@
|
||||||
import Menu, { Item as MenuItem } from '../../menu';
|
|
||||||
import PropTypes from '../../_util/vue-types';
|
|
||||||
import { OptionProps } from './Option';
|
|
||||||
import { inject } from 'vue';
|
|
||||||
|
|
||||||
function noop() {}
|
|
||||||
export default {
|
|
||||||
name: 'DropdownMenu',
|
|
||||||
props: {
|
|
||||||
prefixCls: PropTypes.string,
|
|
||||||
options: PropTypes.arrayOf(OptionProps),
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
mentionsContext: inject('mentionsContext'),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
notFoundContent,
|
|
||||||
activeIndex,
|
|
||||||
setActiveIndex,
|
|
||||||
selectOption,
|
|
||||||
onFocus = noop,
|
|
||||||
onBlur = noop,
|
|
||||||
} = this.mentionsContext;
|
|
||||||
const { prefixCls, options } = this.$props;
|
|
||||||
const activeOption = options[activeIndex] || {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu
|
|
||||||
prefixCls={`${prefixCls}-menu`}
|
|
||||||
activeKey={activeOption.value}
|
|
||||||
onSelect={({ key }) => {
|
|
||||||
const option = options.find(({ value }) => value === key);
|
|
||||||
selectOption(option);
|
|
||||||
}}
|
|
||||||
onBlur={onBlur}
|
|
||||||
onFocus={onFocus}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
...options.map((option, index) => {
|
|
||||||
const { value, disabled, children } = option;
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
key={value}
|
|
||||||
disabled={disabled}
|
|
||||||
onMouseenter={() => {
|
|
||||||
setActiveIndex(index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
!options.length && (
|
|
||||||
<MenuItem key="notFoundContent" disabled>
|
|
||||||
{notFoundContent}
|
|
||||||
</MenuItem>
|
|
||||||
),
|
|
||||||
].filter(Boolean)}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
import Menu, { Item as MenuItem } from '../../menu';
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
import { defineComponent, inject, ref } from 'vue';
|
||||||
|
import type { OptionProps } from './Option';
|
||||||
|
import MentionsContextKey from './MentionsContext';
|
||||||
|
import Spin from '../../spin';
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'DropdownMenu',
|
||||||
|
props: {
|
||||||
|
prefixCls: String,
|
||||||
|
options: {
|
||||||
|
type: Array as PropType<OptionProps[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slots: ['notFoundContent', 'option'],
|
||||||
|
setup(props, { slots }) {
|
||||||
|
const {
|
||||||
|
activeIndex,
|
||||||
|
setActiveIndex,
|
||||||
|
selectOption,
|
||||||
|
onFocus = noop,
|
||||||
|
onBlur = noop,
|
||||||
|
loading,
|
||||||
|
} = inject(MentionsContextKey, {
|
||||||
|
activeIndex: ref(),
|
||||||
|
loading: ref(false),
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
const { prefixCls, options } = props;
|
||||||
|
const activeOption = options[activeIndex.value] || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
prefixCls={`${prefixCls}-menu`}
|
||||||
|
activeKey={activeOption.value}
|
||||||
|
onSelect={({ key }) => {
|
||||||
|
const option = options.find(({ value }) => value === key);
|
||||||
|
selectOption(option);
|
||||||
|
}}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onFocus={onFocus}
|
||||||
|
>
|
||||||
|
{!loading.value &&
|
||||||
|
options.map((option, index) => {
|
||||||
|
const { value, disabled, label = option.value } = option;
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseenter={() => {
|
||||||
|
setActiveIndex(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slots.option?.(option) ??
|
||||||
|
(typeof label === 'function' ? label({ value, disabled }) : label)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!loading.value && options.length === 0 ? (
|
||||||
|
<MenuItem key="notFoundContent" disabled>
|
||||||
|
{slots.notFoundContent?.()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
{loading.value && (
|
||||||
|
<MenuItem key="loading" disabled>
|
||||||
|
<Spin size="small" />
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
|
@ -1,70 +0,0 @@
|
||||||
import PropTypes from '../../_util/vue-types';
|
|
||||||
import Trigger from '../../vc-trigger';
|
|
||||||
import DropdownMenu from './DropdownMenu';
|
|
||||||
import { OptionProps } from './Option';
|
|
||||||
import { PlaceMent } from './placement';
|
|
||||||
|
|
||||||
const BUILT_IN_PLACEMENTS = {
|
|
||||||
bottomRight: {
|
|
||||||
points: ['tl', 'br'],
|
|
||||||
offset: [0, 4],
|
|
||||||
overflow: {
|
|
||||||
adjustX: 0,
|
|
||||||
adjustY: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
topRight: {
|
|
||||||
points: ['bl', 'tr'],
|
|
||||||
offset: [0, -4],
|
|
||||||
overflow: {
|
|
||||||
adjustX: 0,
|
|
||||||
adjustY: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'KeywordTrigger',
|
|
||||||
props: {
|
|
||||||
loading: PropTypes.looseBool,
|
|
||||||
options: PropTypes.arrayOf(OptionProps),
|
|
||||||
prefixCls: PropTypes.string,
|
|
||||||
placement: PropTypes.oneOf(PlaceMent),
|
|
||||||
visible: PropTypes.looseBool,
|
|
||||||
transitionName: PropTypes.string,
|
|
||||||
getPopupContainer: PropTypes.func,
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getDropdownPrefix() {
|
|
||||||
return `${this.$props.prefixCls}-dropdown`;
|
|
||||||
},
|
|
||||||
getDropdownElement() {
|
|
||||||
const { options } = this.$props;
|
|
||||||
return <DropdownMenu prefixCls={this.getDropdownPrefix()} options={options} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { visible, placement, transitionName, getPopupContainer } = this.$props;
|
|
||||||
|
|
||||||
const { $slots } = this;
|
|
||||||
|
|
||||||
const children = $slots.default?.();
|
|
||||||
|
|
||||||
const popupElement = this.getDropdownElement();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Trigger
|
|
||||||
prefixCls={this.getDropdownPrefix()}
|
|
||||||
popupVisible={visible}
|
|
||||||
popup={popupElement}
|
|
||||||
popupPlacement={placement === 'top' ? 'topRight' : 'bottomRight'}
|
|
||||||
popupTransitionName={transitionName}
|
|
||||||
builtinPlacements={BUILT_IN_PLACEMENTS}
|
|
||||||
getPopupContainer={getPopupContainer}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Trigger>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
import PropTypes from '../../_util/vue-types';
|
||||||
|
import Trigger from '../../vc-trigger';
|
||||||
|
import DropdownMenu from './DropdownMenu';
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
import { computed, defineComponent } from 'vue';
|
||||||
|
import type { OptionProps } from './Option';
|
||||||
|
|
||||||
|
const BUILT_IN_PLACEMENTS = {
|
||||||
|
bottomRight: {
|
||||||
|
points: ['tl', 'br'],
|
||||||
|
offset: [0, 4],
|
||||||
|
overflow: {
|
||||||
|
adjustX: 0,
|
||||||
|
adjustY: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bottomLeft: {
|
||||||
|
points: ['tr', 'bl'],
|
||||||
|
offset: [0, 4],
|
||||||
|
overflow: {
|
||||||
|
adjustX: 0,
|
||||||
|
adjustY: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
topRight: {
|
||||||
|
points: ['bl', 'tr'],
|
||||||
|
offset: [0, -4],
|
||||||
|
overflow: {
|
||||||
|
adjustX: 0,
|
||||||
|
adjustY: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
topLeft: {
|
||||||
|
points: ['br', 'tl'],
|
||||||
|
offset: [0, -4],
|
||||||
|
overflow: {
|
||||||
|
adjustX: 0,
|
||||||
|
adjustY: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'KeywordTrigger',
|
||||||
|
props: {
|
||||||
|
loading: PropTypes.looseBool,
|
||||||
|
options: {
|
||||||
|
type: Array as PropType<OptionProps[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
prefixCls: PropTypes.string,
|
||||||
|
placement: PropTypes.string,
|
||||||
|
visible: PropTypes.looseBool,
|
||||||
|
transitionName: PropTypes.string,
|
||||||
|
getPopupContainer: PropTypes.func,
|
||||||
|
direction: PropTypes.string,
|
||||||
|
},
|
||||||
|
slots: ['notFoundContent', 'option'],
|
||||||
|
setup(props, { slots }) {
|
||||||
|
const getDropdownPrefix = () => {
|
||||||
|
return `${props.prefixCls}-dropdown`;
|
||||||
|
};
|
||||||
|
const getDropdownElement = () => {
|
||||||
|
const { options } = props;
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
prefixCls={getDropdownPrefix()}
|
||||||
|
options={options}
|
||||||
|
v-slots={{ notFoundContent: slots.notFoundContent, option: slots.option }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const popupPlacement = computed(() => {
|
||||||
|
const { placement, direction } = props;
|
||||||
|
let popupPlacement = 'topRight';
|
||||||
|
if (direction === 'rtl') {
|
||||||
|
popupPlacement = placement === 'top' ? 'topLeft' : 'bottomLeft';
|
||||||
|
} else {
|
||||||
|
popupPlacement = placement === 'top' ? 'topRight' : 'bottomRight';
|
||||||
|
}
|
||||||
|
return popupPlacement;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
const { visible, transitionName, getPopupContainer } = props;
|
||||||
|
return (
|
||||||
|
<Trigger
|
||||||
|
prefixCls={getDropdownPrefix()}
|
||||||
|
popupVisible={visible}
|
||||||
|
popup={getDropdownElement()}
|
||||||
|
popupPlacement={popupPlacement.value}
|
||||||
|
popupTransitionName={transitionName}
|
||||||
|
builtinPlacements={BUILT_IN_PLACEMENTS}
|
||||||
|
getPopupContainer={getPopupContainer}
|
||||||
|
>
|
||||||
|
{slots.default?.()}
|
||||||
|
</Trigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
|
@ -1,312 +0,0 @@
|
||||||
import { defineComponent, provide, withDirectives } from 'vue';
|
|
||||||
import classNames from '../../_util/classNames';
|
|
||||||
import omit from 'omit.js';
|
|
||||||
import KeyCode from '../../_util/KeyCode';
|
|
||||||
import BaseMixin from '../../_util/BaseMixin';
|
|
||||||
import { hasProp, getOptionProps, initDefaultProps } from '../../_util/props-util';
|
|
||||||
import warning from 'warning';
|
|
||||||
import {
|
|
||||||
getBeforeSelectionText,
|
|
||||||
getLastMeasureIndex,
|
|
||||||
replaceWithMeasure,
|
|
||||||
setInputSelection,
|
|
||||||
} from './util';
|
|
||||||
import KeywordTrigger from './KeywordTrigger';
|
|
||||||
import { vcMentionsProps, defaultProps } from './mentionsProps';
|
|
||||||
import antInput from '../../_util/antInputDirective';
|
|
||||||
|
|
||||||
function noop() {}
|
|
||||||
|
|
||||||
const Mentions = {
|
|
||||||
name: 'Mentions',
|
|
||||||
mixins: [BaseMixin],
|
|
||||||
inheritAttrs: false,
|
|
||||||
props: initDefaultProps(vcMentionsProps, defaultProps),
|
|
||||||
created() {
|
|
||||||
this.mentionsContext = provide('mentionsContext', this);
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
const { value = '', defaultValue = '' } = this.$props;
|
|
||||||
warning(this.$props.children, 'please children prop replace slots.default');
|
|
||||||
return {
|
|
||||||
_value: !hasProp(this, 'value') ? defaultValue : value,
|
|
||||||
measuring: false,
|
|
||||||
measureLocation: 0,
|
|
||||||
measureText: null,
|
|
||||||
measurePrefix: '',
|
|
||||||
activeIndex: 0,
|
|
||||||
isFocus: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
value(val) {
|
|
||||||
this.$data._value = val;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
updated() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const { measuring } = this.$data;
|
|
||||||
|
|
||||||
// Sync measure div top with textarea for rc-trigger usage
|
|
||||||
if (measuring) {
|
|
||||||
this.$refs.measure.scrollTop = this.$refs.textarea.scrollTop;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
triggerChange(value) {
|
|
||||||
const props = getOptionProps(this);
|
|
||||||
if (!('value' in props)) {
|
|
||||||
this.setState({ _value: value });
|
|
||||||
} else {
|
|
||||||
this.$forceUpdate();
|
|
||||||
}
|
|
||||||
this.__emit('change', value);
|
|
||||||
},
|
|
||||||
onChange({ target: { value, composing }, isComposing }) {
|
|
||||||
if (isComposing || composing) return;
|
|
||||||
this.triggerChange(value);
|
|
||||||
},
|
|
||||||
onKeyDown(event) {
|
|
||||||
const { which } = event;
|
|
||||||
const { activeIndex, measuring } = this.$data;
|
|
||||||
// Skip if not measuring
|
|
||||||
if (!measuring) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (which === KeyCode.UP || which === KeyCode.DOWN) {
|
|
||||||
// Control arrow function
|
|
||||||
const optionLen = this.getOptions().length;
|
|
||||||
const offset = which === KeyCode.UP ? -1 : 1;
|
|
||||||
const newActiveIndex = (activeIndex + offset + optionLen) % optionLen;
|
|
||||||
this.setState({
|
|
||||||
activeIndex: newActiveIndex,
|
|
||||||
});
|
|
||||||
event.preventDefault();
|
|
||||||
} else if (which === KeyCode.ESC) {
|
|
||||||
this.stopMeasure();
|
|
||||||
} else if (which === KeyCode.ENTER) {
|
|
||||||
// Measure hit
|
|
||||||
event.preventDefault();
|
|
||||||
const options = this.getOptions();
|
|
||||||
if (!options.length) {
|
|
||||||
this.stopMeasure();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const option = options[activeIndex];
|
|
||||||
this.selectOption(option);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* When to start measure:
|
|
||||||
* 1. When user press `prefix`
|
|
||||||
* 2. When measureText !== prevMeasureText
|
|
||||||
* - If measure hit
|
|
||||||
* - If measuring
|
|
||||||
*
|
|
||||||
* When to stop measure:
|
|
||||||
* 1. Selection is out of range
|
|
||||||
* 2. Contains `space`
|
|
||||||
* 3. ESC or select one
|
|
||||||
*/
|
|
||||||
onKeyUp(event) {
|
|
||||||
const { key, which } = event;
|
|
||||||
const { measureText: prevMeasureText, measuring } = this.$data;
|
|
||||||
const { prefix = '', validateSearch } = this.$props;
|
|
||||||
const target = event.target;
|
|
||||||
const selectionStartText = getBeforeSelectionText(target);
|
|
||||||
const { location: measureIndex, prefix: measurePrefix } = getLastMeasureIndex(
|
|
||||||
selectionStartText,
|
|
||||||
prefix,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Skip if match the white key list
|
|
||||||
if ([KeyCode.ESC, KeyCode.UP, KeyCode.DOWN, KeyCode.ENTER].indexOf(which) !== -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (measureIndex !== -1) {
|
|
||||||
const measureText = selectionStartText.slice(measureIndex + measurePrefix.length);
|
|
||||||
const validateMeasure = validateSearch(measureText, this.$props);
|
|
||||||
const matchOption = !!this.getOptions(measureText).length;
|
|
||||||
|
|
||||||
if (validateMeasure) {
|
|
||||||
if (
|
|
||||||
key === measurePrefix ||
|
|
||||||
measuring ||
|
|
||||||
(measureText !== prevMeasureText && matchOption)
|
|
||||||
) {
|
|
||||||
this.startMeasure(measureText, measurePrefix, measureIndex);
|
|
||||||
}
|
|
||||||
} else if (measuring) {
|
|
||||||
// Stop if measureText is invalidate
|
|
||||||
this.stopMeasure();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We will trigger `onSearch` to developer since they may use for async update.
|
|
||||||
* If met `space` means user finished searching.
|
|
||||||
*/
|
|
||||||
if (validateMeasure) {
|
|
||||||
this.__emit('search', measureText, measurePrefix);
|
|
||||||
}
|
|
||||||
} else if (measuring) {
|
|
||||||
this.stopMeasure();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onInputFocus(event) {
|
|
||||||
this.onFocus(event);
|
|
||||||
},
|
|
||||||
onInputBlur(event) {
|
|
||||||
this.onBlur(event);
|
|
||||||
},
|
|
||||||
onDropdownFocus() {
|
|
||||||
this.onFocus();
|
|
||||||
},
|
|
||||||
onDropdownBlur() {
|
|
||||||
this.onBlur();
|
|
||||||
},
|
|
||||||
onFocus(event) {
|
|
||||||
window.clearTimeout(this.focusId);
|
|
||||||
const { isFocus } = this.$data;
|
|
||||||
if (!isFocus && event) {
|
|
||||||
this.__emit('focus', event);
|
|
||||||
}
|
|
||||||
this.setState({ isFocus: true });
|
|
||||||
},
|
|
||||||
onBlur(event) {
|
|
||||||
this.focusId = window.setTimeout(() => {
|
|
||||||
this.setState({ isFocus: false });
|
|
||||||
this.stopMeasure();
|
|
||||||
this.__emit('blur', event);
|
|
||||||
}, 100);
|
|
||||||
},
|
|
||||||
selectOption(option) {
|
|
||||||
const { _value: value, measureLocation, measurePrefix } = this.$data;
|
|
||||||
const { split } = this.$props;
|
|
||||||
const { value: mentionValue = '' } = option;
|
|
||||||
const { text, selectionLocation } = replaceWithMeasure(value, {
|
|
||||||
measureLocation,
|
|
||||||
targetText: mentionValue,
|
|
||||||
prefix: measurePrefix,
|
|
||||||
selectionStart: this.$refs.textarea.selectionStart,
|
|
||||||
split,
|
|
||||||
});
|
|
||||||
this.triggerChange(text);
|
|
||||||
this.stopMeasure(() => {
|
|
||||||
// We need restore the selection position
|
|
||||||
setInputSelection(this.$refs.textarea, selectionLocation);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.__emit('select', option, measurePrefix);
|
|
||||||
},
|
|
||||||
setActiveIndex(activeIndex) {
|
|
||||||
this.setState({
|
|
||||||
activeIndex,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getOptions(measureText) {
|
|
||||||
const targetMeasureText = measureText || this.$data.measureText || '';
|
|
||||||
const { filterOption, children = [] } = this.$props;
|
|
||||||
const list = (Array.isArray(children) ? children : [children])
|
|
||||||
.map(item => {
|
|
||||||
return { ...getOptionProps(item), children: item.children.default?.() };
|
|
||||||
})
|
|
||||||
.filter(option => {
|
|
||||||
/** Return all result if `filterOption` is false. */
|
|
||||||
if (filterOption === false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return filterOption(targetMeasureText, option);
|
|
||||||
});
|
|
||||||
return list;
|
|
||||||
},
|
|
||||||
startMeasure(measureText, measurePrefix, measureLocation) {
|
|
||||||
this.setState({
|
|
||||||
measuring: true,
|
|
||||||
measureText,
|
|
||||||
measurePrefix,
|
|
||||||
measureLocation,
|
|
||||||
activeIndex: 0,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
stopMeasure(callback) {
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
measuring: false,
|
|
||||||
measureLocation: 0,
|
|
||||||
measureText: null,
|
|
||||||
},
|
|
||||||
callback,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
focus() {
|
|
||||||
this.$refs.textarea.focus();
|
|
||||||
},
|
|
||||||
blur() {
|
|
||||||
this.$refs.textarea.blur();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { _value: value, measureLocation, measurePrefix, measuring } = this.$data;
|
|
||||||
const {
|
|
||||||
prefixCls,
|
|
||||||
placement,
|
|
||||||
transitionName,
|
|
||||||
notFoundContent,
|
|
||||||
getPopupContainer,
|
|
||||||
...restProps
|
|
||||||
} = getOptionProps(this);
|
|
||||||
|
|
||||||
const { class: className, style, ...otherAttrs } = this.$attrs;
|
|
||||||
|
|
||||||
const inputProps = omit(restProps, [
|
|
||||||
'value',
|
|
||||||
'defaultValue',
|
|
||||||
'prefix',
|
|
||||||
'split',
|
|
||||||
'children',
|
|
||||||
'validateSearch',
|
|
||||||
'filterOption',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const options = measuring ? this.getOptions() : [];
|
|
||||||
const textareaProps = {
|
|
||||||
...inputProps,
|
|
||||||
...otherAttrs,
|
|
||||||
onChange: noop,
|
|
||||||
onSelect: noop,
|
|
||||||
value,
|
|
||||||
onInput: this.onChange,
|
|
||||||
onBlur: this.onInputBlur,
|
|
||||||
onKeydown: this.onKeyDown,
|
|
||||||
onKeyup: this.onKeyUp,
|
|
||||||
onFocus: this.onInputFocus,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div class={classNames(prefixCls, className)} style={style}>
|
|
||||||
{withDirectives(<textarea ref="textarea" {...textareaProps} />, [[antInput]])}
|
|
||||||
{measuring && (
|
|
||||||
<div ref="measure" class={`${prefixCls}-measure`}>
|
|
||||||
{value.slice(0, measureLocation)}
|
|
||||||
<KeywordTrigger
|
|
||||||
prefixCls={prefixCls}
|
|
||||||
transitionName={transitionName}
|
|
||||||
placement={placement}
|
|
||||||
options={options}
|
|
||||||
visible
|
|
||||||
getPopupContainer={getPopupContainer}
|
|
||||||
>
|
|
||||||
<span>{measurePrefix}</span>
|
|
||||||
</KeywordTrigger>
|
|
||||||
{value.slice(measureLocation + measurePrefix.length)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineComponent(Mentions);
|
|
|
@ -0,0 +1,300 @@
|
||||||
|
import type { ExtractPropTypes } from 'vue';
|
||||||
|
import { toRef, watchEffect } from 'vue';
|
||||||
|
import {
|
||||||
|
defineComponent,
|
||||||
|
provide,
|
||||||
|
withDirectives,
|
||||||
|
ref,
|
||||||
|
reactive,
|
||||||
|
onUpdated,
|
||||||
|
nextTick,
|
||||||
|
computed,
|
||||||
|
} from 'vue';
|
||||||
|
import classNames from '../../_util/classNames';
|
||||||
|
import omit from 'omit.js';
|
||||||
|
import KeyCode from '../../_util/KeyCode';
|
||||||
|
import { initDefaultProps } from '../../_util/props-util';
|
||||||
|
import {
|
||||||
|
getBeforeSelectionText,
|
||||||
|
getLastMeasureIndex,
|
||||||
|
replaceWithMeasure,
|
||||||
|
setInputSelection,
|
||||||
|
} from './util';
|
||||||
|
import KeywordTrigger from './KeywordTrigger';
|
||||||
|
import { vcMentionsProps, defaultProps } from './mentionsProps';
|
||||||
|
import antInput from '../../_util/antInputDirective';
|
||||||
|
import type { OptionProps } from './Option';
|
||||||
|
import MentionsContextKey from './MentionsContext';
|
||||||
|
|
||||||
|
export type MentionsProps = Partial<ExtractPropTypes<typeof vcMentionsProps>>;
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'Mentions',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: initDefaultProps(vcMentionsProps, defaultProps),
|
||||||
|
slots: ['notFoundContent', 'option'],
|
||||||
|
emits: ['change', 'select', 'search', 'focus', 'blur', 'pressenter'],
|
||||||
|
setup(props: MentionsProps, { emit, attrs, expose, slots }) {
|
||||||
|
const measure = ref(null);
|
||||||
|
const textarea = ref(null);
|
||||||
|
const focusId = ref();
|
||||||
|
const state = reactive({
|
||||||
|
value: props.value || '',
|
||||||
|
measuring: false,
|
||||||
|
measureLocation: 0,
|
||||||
|
measureText: null,
|
||||||
|
measurePrefix: '',
|
||||||
|
activeIndex: 0,
|
||||||
|
isFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
state.value = props.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerChange = (val: string) => {
|
||||||
|
emit('change', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChange = ({ target: { value, composing }, isComposing }) => {
|
||||||
|
if (isComposing || composing) return;
|
||||||
|
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 { which } = event;
|
||||||
|
// Skip if not measuring
|
||||||
|
if (!state.measuring) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (which === KeyCode.UP || which === KeyCode.DOWN) {
|
||||||
|
// Control arrow function
|
||||||
|
const optionLen = options.value.length;
|
||||||
|
const offset = which === KeyCode.UP ? -1 : 1;
|
||||||
|
const newActiveIndex = (state.activeIndex + offset + optionLen) % optionLen;
|
||||||
|
state.activeIndex = newActiveIndex;
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (which === KeyCode.ESC) {
|
||||||
|
stopMeasure();
|
||||||
|
} else if (which === KeyCode.ENTER) {
|
||||||
|
// Measure hit
|
||||||
|
event.preventDefault();
|
||||||
|
if (!options.value.length) {
|
||||||
|
stopMeasure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const option = options.value[state.activeIndex];
|
||||||
|
selectOption(option);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
const { key, which } = event;
|
||||||
|
const { measureText: prevMeasureText, measuring } = state;
|
||||||
|
const { prefix, validateSearch } = props;
|
||||||
|
const target = event.target as HTMLTextAreaElement;
|
||||||
|
const selectionStartText = getBeforeSelectionText(target);
|
||||||
|
const { location: measureIndex, prefix: measurePrefix } = getLastMeasureIndex(
|
||||||
|
selectionStartText,
|
||||||
|
prefix,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skip if match the white key list
|
||||||
|
if ([KeyCode.ESC, KeyCode.UP, KeyCode.DOWN, KeyCode.ENTER].indexOf(which) !== -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (measureIndex !== -1) {
|
||||||
|
const measureText = selectionStartText.slice(measureIndex + measurePrefix.length);
|
||||||
|
const validateMeasure = validateSearch(measureText, props);
|
||||||
|
const matchOption = !!getOptions(measureText).length;
|
||||||
|
|
||||||
|
if (validateMeasure) {
|
||||||
|
if (
|
||||||
|
key === measurePrefix ||
|
||||||
|
key === 'Shift' ||
|
||||||
|
measuring ||
|
||||||
|
(measureText !== prevMeasureText && matchOption)
|
||||||
|
) {
|
||||||
|
startMeasure(measureText, measurePrefix, measureIndex);
|
||||||
|
}
|
||||||
|
} else if (measuring) {
|
||||||
|
// Stop if measureText is invalidate
|
||||||
|
stopMeasure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We will trigger `onSearch` to developer since they may use for async update.
|
||||||
|
* If met `space` means user finished searching.
|
||||||
|
*/
|
||||||
|
if (validateMeasure) {
|
||||||
|
emit('search', measureText, measurePrefix);
|
||||||
|
}
|
||||||
|
} else if (measuring) {
|
||||||
|
stopMeasure();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPressEnter = event => {
|
||||||
|
if (!state.measuring) {
|
||||||
|
emit('pressenter', event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInputFocus = (event: Event) => {
|
||||||
|
onFocus(event);
|
||||||
|
};
|
||||||
|
const onInputBlur = (event: Event) => {
|
||||||
|
onBlur(event);
|
||||||
|
};
|
||||||
|
const onFocus = (event: Event) => {
|
||||||
|
window.clearTimeout(focusId.value);
|
||||||
|
const { isFocus } = state;
|
||||||
|
if (!isFocus && event) {
|
||||||
|
emit('focus', event);
|
||||||
|
}
|
||||||
|
state.isFocus = true;
|
||||||
|
};
|
||||||
|
const onBlur = (event: Event) => {
|
||||||
|
focusId.value = window.setTimeout(() => {
|
||||||
|
state.isFocus = false;
|
||||||
|
stopMeasure();
|
||||||
|
emit('blur', event);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
const selectOption = (option: OptionProps) => {
|
||||||
|
const { split } = props;
|
||||||
|
const { value: mentionValue = '' } = option;
|
||||||
|
const { text, selectionLocation } = replaceWithMeasure(state.value, {
|
||||||
|
measureLocation: state.measureLocation,
|
||||||
|
targetText: mentionValue,
|
||||||
|
prefix: state.measurePrefix,
|
||||||
|
selectionStart: textarea.value.selectionStart,
|
||||||
|
split,
|
||||||
|
});
|
||||||
|
triggerChange(text);
|
||||||
|
stopMeasure(() => {
|
||||||
|
// We need restore the selection position
|
||||||
|
setInputSelection(textarea.value, selectionLocation);
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('select', option, state.measurePrefix);
|
||||||
|
};
|
||||||
|
const setActiveIndex = (activeIndex: number) => {
|
||||||
|
state.activeIndex = activeIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptions = (measureText?: string) => {
|
||||||
|
const targetMeasureText = measureText || state.measureText || '';
|
||||||
|
const { filterOption } = props;
|
||||||
|
const list = props.options.filter((option: OptionProps) => {
|
||||||
|
/** Return all result if `filterOption` is false. */
|
||||||
|
if (!!filterOption === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return filterOption(targetMeasureText, option);
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
const options = computed(() => {
|
||||||
|
return getOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
textarea.value.focus();
|
||||||
|
};
|
||||||
|
const blur = () => {
|
||||||
|
textarea.value.blur();
|
||||||
|
};
|
||||||
|
expose({ blur, focus });
|
||||||
|
provide(MentionsContextKey, {
|
||||||
|
activeIndex: toRef(state, 'activeIndex'),
|
||||||
|
setActiveIndex,
|
||||||
|
selectOption,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
loading: toRef(props, 'loading'),
|
||||||
|
});
|
||||||
|
onUpdated(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (state.measuring) {
|
||||||
|
measure.value.scrollTop = textarea.value.scrollTop;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
const { measureLocation, measurePrefix, measuring } = state;
|
||||||
|
const { prefixCls, placement, transitionName, getPopupContainer, direction, ...restProps } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
const { class: className, style, ...otherAttrs } = attrs;
|
||||||
|
|
||||||
|
const inputProps = omit(restProps, [
|
||||||
|
'value',
|
||||||
|
'prefix',
|
||||||
|
'split',
|
||||||
|
'validateSearch',
|
||||||
|
'filterOption',
|
||||||
|
'options',
|
||||||
|
'loading',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const textareaProps = {
|
||||||
|
...inputProps,
|
||||||
|
...otherAttrs,
|
||||||
|
onChange: noop,
|
||||||
|
onSelect: noop,
|
||||||
|
value: state.value,
|
||||||
|
onInput: onChange,
|
||||||
|
onBlur: onInputBlur,
|
||||||
|
onKeydown: onKeyDown,
|
||||||
|
onKeyup: onKeyUp,
|
||||||
|
onFocus: onInputFocus,
|
||||||
|
onPressenter: onPressEnter,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div class={classNames(prefixCls, className)} style={style}>
|
||||||
|
{withDirectives(<textarea ref={textarea} {...textareaProps} />, [[antInput]])}
|
||||||
|
{measuring && (
|
||||||
|
<div ref={measure} class={`${prefixCls}-measure`}>
|
||||||
|
{state.value.slice(0, measureLocation)}
|
||||||
|
<KeywordTrigger
|
||||||
|
prefixCls={prefixCls}
|
||||||
|
transitionName={transitionName}
|
||||||
|
placement={placement}
|
||||||
|
options={measuring ? options.value : []}
|
||||||
|
visible
|
||||||
|
direction={direction}
|
||||||
|
getPopupContainer={getPopupContainer}
|
||||||
|
v-slots={{ notFoundContent: slots.notFoundContent, option: slots.option }}
|
||||||
|
>
|
||||||
|
<span>{measurePrefix}</span>
|
||||||
|
</KeywordTrigger>
|
||||||
|
{state.value.slice(measureLocation + measurePrefix.length)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
|
@ -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;
|
|
@ -1,16 +0,0 @@
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import PropTypes from '../../_util/vue-types';
|
|
||||||
|
|
||||||
export const OptionProps = {
|
|
||||||
value: PropTypes.string,
|
|
||||||
disabled: PropTypes.looseBool,
|
|
||||||
children: PropTypes.any,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'Option',
|
|
||||||
props: OptionProps,
|
|
||||||
render() {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { ExtractPropTypes } from 'vue';
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
export const optionProps = {
|
||||||
|
value: String,
|
||||||
|
disabled: Boolean,
|
||||||
|
label: [String, Number, Function],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OptionProps = Partial<ExtractPropTypes<typeof optionProps>>;
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'Option',
|
||||||
|
props: optionProps,
|
||||||
|
render(_props: any, { slots }: any) {
|
||||||
|
return slots.default?.();
|
||||||
|
},
|
||||||
|
});
|
|
@ -5,16 +5,18 @@ import {
|
||||||
filterOption as defaultFilterOption,
|
filterOption as defaultFilterOption,
|
||||||
validateSearch as defaultValidateSearch,
|
validateSearch as defaultValidateSearch,
|
||||||
} from './util';
|
} from './util';
|
||||||
import { PlaceMent } from './placement';
|
import { tuple } from '../../_util/type';
|
||||||
|
import type { OptionProps } from './Option';
|
||||||
|
|
||||||
|
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.array]),
|
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),
|
||||||
|
@ -25,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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
import { tuple } from '../../_util/type';
|
|
||||||
|
|
||||||
export const PlaceMent = tuple('top', 'bottom');
|
|
|
@ -1,22 +1,43 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cut input selection into 2 part and return text before selection start
|
* Cut input selection into 2 part and return text before selection start
|
||||||
*/
|
*/
|
||||||
export function getBeforeSelectionText(input) {
|
export function getBeforeSelectionText(input: HTMLTextAreaElement) {
|
||||||
const { selectionStart } = input;
|
const { selectionStart } = input;
|
||||||
return input.value.slice(0, selectionStart);
|
return input.value.slice(0, selectionStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lower(char) {
|
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, prefix = '') {
|
export function getLastMeasureIndex(text: string, prefix: string | string[] = ''): MeasureIndex {
|
||||||
const prefixList = 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 {
|
||||||
|
@ -30,7 +51,19 @@ export function getLastMeasureIndex(text, prefix = '') {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reduceText(text, targetText, split) {
|
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) {
|
||||||
const firstChar = text[0];
|
const firstChar = text[0];
|
||||||
if (!firstChar || firstChar === split) {
|
if (!firstChar || firstChar === split) {
|
||||||
return text;
|
return text;
|
||||||
|
@ -57,7 +90,7 @@ function reduceText(text, targetText, split) {
|
||||||
* targetText: light
|
* targetText: light
|
||||||
* => little @light test
|
* => little @light test
|
||||||
*/
|
*/
|
||||||
export function replaceWithMeasure(text, measureConfig) {
|
export function replaceWithMeasure(text: string, measureConfig: MeasureConfig) {
|
||||||
const { measureLocation, prefix, targetText, selectionStart, split } = measureConfig;
|
const { measureLocation, prefix, targetText, selectionStart, split } = measureConfig;
|
||||||
|
|
||||||
// Before text will append one space if have other text
|
// Before text will append one space if have other text
|
||||||
|
@ -87,7 +120,7 @@ export function replaceWithMeasure(text, measureConfig) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setInputSelection(input, location) {
|
export function setInputSelection(input: HTMLTextAreaElement, location: number) {
|
||||||
input.setSelectionRange(location, location);
|
input.setSelectionRange(location, location);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -98,12 +131,12 @@ export function setInputSelection(input, location) {
|
||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateSearch(text = '', props = {}) {
|
export function validateSearch(text: string, props: MentionsProps) {
|
||||||
const { split } = props;
|
const { split } = props;
|
||||||
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;
|
||||||
}
|
}
|
Loading…
Reference in New Issue