Refactor mentions (#4341)

* refactor(mentions): use compositionAPI (#4313)

* refactor: mentions

* refactor: mentions

Co-authored-by: ajuner <106791576@qq.com>
pull/4499/head
tangjinzhou 2021-07-07 23:12:03 +08:00 committed by GitHub
parent 8198cab549
commit f7b39e2d92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 720 additions and 622 deletions

View File

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

View File

@ -1,15 +1,14 @@
import type { App, PropType, VNodeTypes, Plugin, ExtractPropTypes } from 'vue';
import { defineComponent, inject, nextTick } from 'vue';
import type { App, PropType, Plugin, ExtractPropTypes } from 'vue';
import { watch } from 'vue';
import { ref, onMounted } from 'vue';
import { defineComponent, nextTick } from 'vue';
import classNames from '../_util/classNames';
import omit from 'omit.js';
import PropTypes from '../_util/vue-types';
import VcMentions from '../vc-mentions';
import { mentionsProps as baseMentionsProps } from '../vc-mentions/src/mentionsProps';
import Spin from '../spin';
import BaseMixin from '../_util/BaseMixin';
import { defaultConfigProvider } from '../config-provider';
import { getOptionProps, getComponent, getSlot } from '../_util/props-util';
import type { RenderEmptyHandler } from '../config-provider/renderEmpty';
import useConfigInject from '../_util/hooks/useConfigInject';
import { flattenChildren, getOptionProps } from '../_util/props-util';
const { Option } = VcMentions;
@ -20,23 +19,26 @@ interface MentionsConfig {
export interface MentionsOptionProps {
value: string;
disabled: boolean;
children: VNodeTypes;
disabled?: boolean;
label?: string | number | ((o: MentionsOptionProps) => any);
[key: string]: any;
}
function loadingFilterOption() {
return true;
interface MentionsEntity {
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 prefixList = Array.isArray(prefix) ? prefix : [prefix];
const prefixList: string[] = Array.isArray(prefix) ? prefix : [prefix];
return value
.split(split)
.map((str = '') => {
let hitPrefix = null;
.map((str = ''): MentionsEntity | null => {
let hitPrefix: string | null = null;
prefixList.some(prefixStr => {
const startStr = str.slice(0, prefixStr.length);
@ -50,13 +52,13 @@ function getMentions(value = '', config: MentionsConfig) {
if (hitPrefix !== null) {
return {
prefix: hitPrefix,
value: str.slice(hitPrefix.length),
value: str.slice((hitPrefix as string).length),
};
}
return null;
})
.filter(entity => !!entity && !!entity.value);
}
.filter((entity): entity is MentionsEntity => !!entity && !!entity.value);
};
const mentionsProps = {
...baseMentionsProps,
@ -73,145 +75,144 @@ const mentionsProps = {
onChange: {
type: Function as PropType<(text: string) => void>,
},
notFoundContent: PropTypes.any,
defaultValue: String,
};
export type MentionsProps = Partial<ExtractPropTypes<typeof mentionsProps>>;
const Mentions = defineComponent({
name: 'AMentions',
mixins: [BaseMixin],
inheritAttrs: false,
Option: { ...Option, name: 'AMentionsOption' },
getMentions,
props: mentionsProps,
emits: ['update:value', 'change', 'focus', 'blur', 'select'],
setup() {
return {
configProvider: inject('configProvider', defaultConfigProvider),
getMentions,
Option,
emits: ['update:value', 'change', 'focus', 'blur', 'select', 'pressenter'],
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() {
return {
focused: false,
const handleBlur = (e: FocusEvent) => {
focused.value = false;
emit('blur', e);
};
},
mounted() {
nextTick(() => {
if (process.env.NODE_ENV === 'test') {
if (this.autofocus) {
this.focus();
}
const handleSelect = (...args: [MentionsOptionProps, string]) => {
emit('select', ...args);
focused.value = true;
};
const handleChange = (val: string) => {
if (props.value === undefined) {
value.value = val;
}
});
},
methods: {
handleFocus(e: FocusEvent) {
this.$emit('focus', e);
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');
emit('update:value', val);
emit('change', val);
};
const getNotFoundContent = () => {
const notFoundContent = props.notFoundContent;
if (notFoundContent !== undefined) {
return notFoundContent;
}
return renderEmpty('Select');
},
getOptions() {
const { loading } = this.$props;
const children = getSlot(this);
if (loading) {
return (
<Option value="ANTD_SEARCHING" disabled>
<Spin size="small" />
</Option>
);
if (slots.notFoundContent) {
return slots.notFoundContent();
}
return children;
},
getFilterOption() {
const { filterOption, loading } = this.$props;
if (loading) {
return loadingFilterOption;
}
return filterOption;
},
focus() {
(this.$refs.vcMentions as HTMLTextAreaElement).focus();
},
blur() {
(this.$refs.vcMentions as HTMLTextAreaElement).blur();
},
},
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, {
[`${prefixCls}-disabled`]: disabled,
[`${prefixCls}-focused`]: focused,
});
const mentionsProps = {
prefixCls,
notFoundContent: this.getNotFoundContent(renderEmpty),
...otherProps,
disabled,
filterOption: this.getFilterOption(),
getPopupContainer,
children: this.getOptions(),
class: mergedClassName,
rows: 1,
...otherAttrs,
onChange: this.handleChange,
onSelect: this.handleSelect,
onFocus: this.handleFocus,
onBlur: this.handleBlur,
ref: 'vcMentions',
return renderEmpty.value('Select');
};
return <VcMentions {...mentionsProps} />;
const getOptions = () => {
return flattenChildren(slots.default?.() || []).map(item => {
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 () => {
const { disabled, getPopupContainer, rows = 1, ...restProps } = props;
const { class: className, ...otherAttrs } = attrs;
const otherProps = omit(restProps, ['defaultValue', 'onUpdate:value', 'prefixCls']);
const mergedClassName = classNames(className, {
[`${prefixCls.value}-disabled`]: disabled,
[`${prefixCls.value}-focused`]: focused.value,
[`${prefixCls.value}-rtl`]: direction.value === 'rtl',
});
const mentionsProps = {
prefixCls: prefixCls.value,
...otherProps,
disabled,
direction: direction.value,
filterOption: props.filterOption,
getPopupContainer,
options: props.options || getOptions(),
class: mergedClassName,
...otherAttrs,
rows,
onChange: handleChange,
onSelect: handleSelect,
onFocus: handleFocus,
onBlur: handleBlur,
ref: vcMentions,
value: value.value,
};
return (
<VcMentions
{...mentionsProps}
v-slots={{ notFoundContent: getNotFoundContent, option: slots.option }}
></VcMentions>
);
};
},
});
export const MentionsOption = {
...Option,
name: 'AMentionsOption',
};
/* istanbul ignore next */
Mentions.install = function (app: App) {
app.component(Mentions.name, Mentions);
app.component(Mentions.Option.name, Mentions.Option);
app.component(MentionsOption.name, MentionsOption);
return app;
};
export const MentionsOption = Mentions.Option;
export default Mentions as typeof Mentions &
Plugin & {
getMentions: typeof getMentions;
readonly Option: typeof Option;
};

View File

@ -64,10 +64,6 @@
background-color: transparent;
}
.placeholder();
&:read-only {
cursor: default;
}
}
&-measure {
@ -123,7 +119,7 @@
overflow: hidden;
color: @text-color;
font-weight: normal;
line-height: 22px;
line-height: @line-height-base;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
@ -159,9 +155,11 @@
}
&-active {
background-color: @item-active-bg;
background-color: @item-hover-bg;
}
}
}
}
}
@import './rtl';

View File

@ -0,0 +1,10 @@
@import '../../style/themes/index';
@import '../../style/mixins/index';
@mention-prefix-cls: ~'@{ant-prefix}-mentions';
.@{mention-prefix-cls} {
&-rtl {
direction: rtl;
}
}

View File

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

View File

@ -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>
);
},
};

View File

@ -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>
);
};
},
});

View File

@ -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>
);
},
};

View File

@ -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>
);
};
},
});

View File

@ -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);

View File

@ -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>
);
};
},
});

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,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;
},
});

View File

@ -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?.();
},
});

View File

@ -5,16 +5,18 @@ import {
filterOption as defaultFilterOption,
validateSearch as defaultValidateSearch,
} 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 = {
autofocus: PropTypes.looseBool,
prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
prefixCls: PropTypes.string,
value: PropTypes.string,
defaultValue: PropTypes.string,
disabled: PropTypes.looseBool,
notFoundContent: PropTypes.VNodeChild,
split: PropTypes.string,
transitionName: PropTypes.string,
placement: PropTypes.oneOf(PlaceMent),
@ -25,16 +27,23 @@ export const mentionsProps = {
getPopupContainer: {
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 = {
...mentionsProps,
children: PropTypes.any,
};
export const defaultProps = {
prefix: '@',
split: ' ',
rows: 1,
validateSearch: defaultValidateSearch,
filterOption: defaultFilterOption,
};

View File

@ -1,3 +0,0 @@
import { tuple } from '../../_util/type';
export const PlaceMent = tuple('top', 'bottom');

View File

@ -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
*/
export function getBeforeSelectionText(input) {
export function getBeforeSelectionText(input: HTMLTextAreaElement) {
const { selectionStart } = input;
return input.value.slice(0, selectionStart);
}
function lower(char) {
return (char || '').toLowerCase();
interface MeasureIndex {
location: number;
prefix: string;
}
/**
* Find the last match prefix index
*/
export function getLastMeasureIndex(text, prefix = '') {
const prefixList = Array.isArray(prefix) ? prefix : [prefix];
export function getLastMeasureIndex(text: string, prefix: string | string[] = ''): MeasureIndex {
const prefixList: string[] = Array.isArray(prefix) ? prefix : [prefix];
return prefixList.reduce(
(lastMatch, prefixStr) => {
(lastMatch: MeasureIndex, prefixStr): MeasureIndex => {
const lastIndex = text.lastIndexOf(prefixStr);
if (lastIndex > lastMatch.location) {
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];
if (!firstChar || firstChar === split) {
return text;
@ -57,7 +90,7 @@ function reduceText(text, targetText, split) {
* targetText: light
* => little @light test
*/
export function replaceWithMeasure(text, measureConfig) {
export function replaceWithMeasure(text: string, measureConfig: MeasureConfig) {
const { measureLocation, prefix, targetText, selectionStart, split } = measureConfig;
// 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);
/**
@ -98,12 +131,12 @@ export function setInputSelection(input, location) {
input.focus();
}
export function validateSearch(text = '', props = {}) {
export function validateSearch(text: string, props: MentionsProps) {
const { split } = props;
return !split || text.indexOf(split) === -1;
}
export function filterOption(input = '', { value = '' } = {}) {
export function filterOption(input: string, { value = '' }: OptionProps): boolean {
const lowerCase = input.toLowerCase();
return value.toLowerCase().indexOf(lowerCase) !== -1;
}