refactor(mentions): use compositionAPI (#4313)
parent
d5952c060f
commit
ab5f9c9bf8
|
@ -1,15 +1,14 @@
|
|||
import type { App, PropType, VNodeTypes, Plugin, ExtractPropTypes } from 'vue';
|
||||
import { defineComponent, inject, nextTick } 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';
|
||||
|
||||
const { Option } = VcMentions;
|
||||
|
||||
|
@ -79,65 +78,46 @@ export type MentionsProps = Partial<ExtractPropTypes<typeof mentionsProps>>;
|
|||
|
||||
const Mentions = defineComponent({
|
||||
name: 'AMentions',
|
||||
mixins: [BaseMixin],
|
||||
inheritAttrs: false,
|
||||
Option: { ...Option, name: 'AMentionsOption' },
|
||||
getMentions,
|
||||
props: mentionsProps,
|
||||
getMentions,
|
||||
emits: ['update:value', 'change', 'focus', 'blur', 'select'],
|
||||
setup() {
|
||||
return {
|
||||
configProvider: inject('configProvider', defaultConfigProvider),
|
||||
setup(props, { slots, emit, attrs, expose }) {
|
||||
const { prefixCls, renderEmpty, direction } = useConfigInject('mentions', props);
|
||||
const focused = ref(false);
|
||||
const vcMentions = ref(null);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
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');
|
||||
|
||||
const handleSelect = (...args: [MentionsOptionProps, string]) => {
|
||||
emit('select', ...args);
|
||||
focused.value = true;
|
||||
};
|
||||
|
||||
const handleChange = (val: string) => {
|
||||
emit('update:value', val);
|
||||
emit('change', val);
|
||||
};
|
||||
|
||||
const getNotFoundContent = (renderEmpty: RenderEmptyHandler) => {
|
||||
const notFoundContent = props.notFoundContent;
|
||||
if (notFoundContent !== undefined) {
|
||||
return notFoundContent;
|
||||
}
|
||||
|
||||
return renderEmpty('Select');
|
||||
},
|
||||
getOptions() {
|
||||
const { loading } = this.$props;
|
||||
const children = getSlot(this);
|
||||
};
|
||||
|
||||
const getOptions = () => {
|
||||
const { loading } = props;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
@ -146,71 +126,82 @@ const Mentions = defineComponent({
|
|||
</Option>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
},
|
||||
getFilterOption() {
|
||||
const { filterOption, loading } = this.$props;
|
||||
return slots.default?.();
|
||||
};
|
||||
|
||||
const getFilterOption = () => {
|
||||
const { filterOption, loading } = 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 <VcMentions {...mentionsProps} />;
|
||||
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, ...restProps } = props;
|
||||
const { class: className, ...otherAttrs } = attrs;
|
||||
const otherProps = omit(restProps, ['loading', 'onUpdate:value', 'prefixCls']);
|
||||
|
||||
const mergedClassName = classNames(className, {
|
||||
[`${prefixCls.value}-disabled`]: disabled,
|
||||
[`${prefixCls.value}-focused`]: focused.value,
|
||||
[`${prefixCls}-rtl`]: direction.value === 'rtl',
|
||||
});
|
||||
|
||||
const mentionsProps = {
|
||||
prefixCls: prefixCls.value,
|
||||
notFoundContent: getNotFoundContent(renderEmpty.value),
|
||||
...otherProps,
|
||||
disabled,
|
||||
direction: direction.value,
|
||||
filterOption: getFilterOption(),
|
||||
getPopupContainer,
|
||||
children: getOptions(),
|
||||
class: mergedClassName,
|
||||
rows: 1,
|
||||
...otherAttrs,
|
||||
onChange: handleChange,
|
||||
onSelect: handleSelect,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
ref: vcMentions,
|
||||
};
|
||||
return <VcMentions {...mentionsProps} />;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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 & {
|
||||
readonly Option: typeof Option;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,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,63 @@
|
|||
import Menu, { Item as MenuItem } from '../../menu';
|
||||
import PropTypes from '../../_util/vue-types';
|
||||
import { defineComponent, inject } from 'vue';
|
||||
|
||||
function noop() {}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DropdownMenu',
|
||||
props: {
|
||||
prefixCls: PropTypes.string,
|
||||
options: PropTypes.any,
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
const {
|
||||
notFoundContent,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
selectOption,
|
||||
onFocus = noop,
|
||||
onBlur = noop,
|
||||
} = inject('mentionsContext');
|
||||
|
||||
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}
|
||||
>
|
||||
{[
|
||||
...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.value}
|
||||
</MenuItem>
|
||||
),
|
||||
].filter(Boolean)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
|
@ -1,8 +1,7 @@
|
|||
import PropTypes from '../../_util/vue-types';
|
||||
import Trigger from '../../vc-trigger';
|
||||
import DropdownMenu from './DropdownMenu';
|
||||
import { OptionProps } from './Option';
|
||||
import { PlaceMent } from './placement';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
const BUILT_IN_PLACEMENTS = {
|
||||
bottomRight: {
|
||||
|
@ -23,13 +22,13 @@ const BUILT_IN_PLACEMENTS = {
|
|||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: 'KeywordTrigger',
|
||||
props: {
|
||||
loading: PropTypes.looseBool,
|
||||
options: PropTypes.arrayOf(OptionProps),
|
||||
options: PropTypes.array,
|
||||
prefixCls: PropTypes.string,
|
||||
placement: PropTypes.oneOf(PlaceMent),
|
||||
placement: PropTypes.string,
|
||||
visible: PropTypes.looseBool,
|
||||
transitionName: PropTypes.string,
|
||||
getPopupContainer: PropTypes.func,
|
||||
|
@ -67,4 +66,4 @@ export 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,295 @@
|
|||
import type { ExtractPropTypes } from 'vue';
|
||||
import {
|
||||
defineComponent,
|
||||
provide,
|
||||
withDirectives,
|
||||
onMounted,
|
||||
ref,
|
||||
reactive,
|
||||
onUpdated,
|
||||
nextTick,
|
||||
computed,
|
||||
} from 'vue';
|
||||
import classNames from '../../_util/classNames';
|
||||
import omit from 'omit.js';
|
||||
import KeyCode from '../../_util/KeyCode';
|
||||
import { 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';
|
||||
|
||||
export type MentionsProps = Partial<ExtractPropTypes<typeof vcMentionsProps>>;
|
||||
|
||||
function noop() {}
|
||||
|
||||
const Mentions = {
|
||||
name: 'Mentions',
|
||||
inheritAttrs: false,
|
||||
props: initDefaultProps(vcMentionsProps, defaultProps),
|
||||
setup(props: MentionsProps, { emit, attrs, expose }) {
|
||||
const measure = ref(null);
|
||||
const textarea = ref(null);
|
||||
const focusId = ref();
|
||||
const state = reactive({
|
||||
value: props.defaultValue || props.value || '',
|
||||
measuring: false,
|
||||
measureLocation: 0,
|
||||
measureText: null,
|
||||
measurePrefix: '',
|
||||
activeIndex: 0,
|
||||
isFocus: false,
|
||||
});
|
||||
|
||||
const triggerChange = (val: string) => {
|
||||
state.value = val;
|
||||
emit('change', val);
|
||||
};
|
||||
|
||||
const onChange = ({ target: { value, composing }, isComposing }) => {
|
||||
if (isComposing || composing) return;
|
||||
triggerChange(value);
|
||||
};
|
||||
|
||||
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 = getOptions().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();
|
||||
const options = getOptions();
|
||||
if (!options.length) {
|
||||
stopMeasure();
|
||||
return;
|
||||
}
|
||||
const option = options[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 ||
|
||||
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 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 => {
|
||||
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 => {
|
||||
state.activeIndex = activeIndex;
|
||||
};
|
||||
const getOptions = (measureText?: string) => {
|
||||
const targetMeasureText = measureText || state.measureText || '';
|
||||
const { filterOption, children = [] } = 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;
|
||||
};
|
||||
const startMeasure = (measureText, measurePrefix, measureLocation) => {
|
||||
state.measuring = true;
|
||||
state.measureText = measureText;
|
||||
state.measurePrefix = measurePrefix;
|
||||
state.measureLocation = measureLocation;
|
||||
state.activeIndex = 0;
|
||||
};
|
||||
const stopMeasure = (callback?: () => void) => {
|
||||
state.measuring = false;
|
||||
state.measureLocation = 0;
|
||||
state.measureText = null;
|
||||
callback?.();
|
||||
};
|
||||
const focus = () => {
|
||||
textarea.value.focus();
|
||||
};
|
||||
const blur = () => {
|
||||
textarea.value.blur();
|
||||
};
|
||||
expose({ blur, focus });
|
||||
onMounted(() => {
|
||||
warning(props.children, 'please children prop replace slots.default');
|
||||
|
||||
provide('mentionsContext', {
|
||||
notFoundContent: computed(() => {
|
||||
return props.notFoundContent;
|
||||
}),
|
||||
activeIndex: computed(() => {
|
||||
return state.activeIndex;
|
||||
}),
|
||||
setActiveIndex,
|
||||
selectOption,
|
||||
});
|
||||
});
|
||||
onUpdated(() => {
|
||||
nextTick(() => {
|
||||
if (state.measuring) {
|
||||
measure.value.scrollTop = textarea.value.scrollTop;
|
||||
}
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
const { measureLocation, measurePrefix, measuring } = state;
|
||||
const {
|
||||
prefixCls,
|
||||
placement,
|
||||
transitionName,
|
||||
notFoundContent,
|
||||
getPopupContainer,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const { class: className, style, ...otherAttrs } = attrs;
|
||||
|
||||
const inputProps = omit(restProps, [
|
||||
'value',
|
||||
'defaultValue',
|
||||
'prefix',
|
||||
'split',
|
||||
'children',
|
||||
'validateSearch',
|
||||
'filterOption',
|
||||
]);
|
||||
|
||||
const options = measuring ? getOptions() : [];
|
||||
const textareaProps = {
|
||||
...inputProps,
|
||||
...otherAttrs,
|
||||
onChange: noop,
|
||||
onSelect: noop,
|
||||
value: state.value,
|
||||
onInput: onChange,
|
||||
onBlur: onInputBlur,
|
||||
onKeydown: onKeyDown,
|
||||
onKeyup: onKeyUp,
|
||||
onFocus: onInputFocus,
|
||||
};
|
||||
|
||||
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={options}
|
||||
visible
|
||||
getPopupContainer={getPopupContainer}
|
||||
>
|
||||
<span>{measurePrefix}</span>
|
||||
</KeywordTrigger>
|
||||
{state.value.slice(measureLocation + measurePrefix.length)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default defineComponent(Mentions);
|
|
@ -1,15 +1,18 @@
|
|||
import type { ExtractPropTypes } from 'vue';
|
||||
import { defineComponent } from 'vue';
|
||||
import PropTypes from '../../_util/vue-types';
|
||||
|
||||
export const OptionProps = {
|
||||
export const optionProps = {
|
||||
value: PropTypes.string,
|
||||
disabled: PropTypes.looseBool,
|
||||
children: PropTypes.any,
|
||||
};
|
||||
|
||||
export type OptionProps = ExtractPropTypes<typeof optionProps>;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Option',
|
||||
props: OptionProps,
|
||||
props: optionProps,
|
||||
render() {
|
||||
return null;
|
||||
},
|
|
@ -5,11 +5,13 @@ import {
|
|||
filterOption as defaultFilterOption,
|
||||
validateSearch as defaultValidateSearch,
|
||||
} from './util';
|
||||
import { PlaceMent } from './placement';
|
||||
import { tuple } from '../../_util/type';
|
||||
|
||||
export const PlaceMent = tuple('top', 'bottom');
|
||||
|
||||
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,
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
import { tuple } from '../../_util/type';
|
||||
|
||||
export const PlaceMent = tuple('top', 'bottom');
|
|
@ -1,20 +1,29 @@
|
|||
import type { MentionsProps } from './Mentions';
|
||||
|
||||
interface MeasureConfig {
|
||||
measureLocation: number;
|
||||
prefix: string;
|
||||
targetText: string;
|
||||
selectionStart: number;
|
||||
split: string;
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
function lower(char: string | undefined): string {
|
||||
return (char || '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] = '') {
|
||||
const prefixList: string[] = Array.isArray(prefix) ? prefix : [prefix];
|
||||
return prefixList.reduce(
|
||||
(lastMatch, prefixStr) => {
|
||||
const lastIndex = text.lastIndexOf(prefixStr);
|
||||
|
@ -30,7 +39,7 @@ export function getLastMeasureIndex(text, prefix = '') {
|
|||
);
|
||||
}
|
||||
|
||||
function reduceText(text, targetText, split) {
|
||||
function reduceText(text: string, targetText: string, split: string) {
|
||||
const firstChar = text[0];
|
||||
if (!firstChar || firstChar === split) {
|
||||
return text;
|
||||
|
@ -57,7 +66,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 +96,7 @@ export function replaceWithMeasure(text, measureConfig) {
|
|||
};
|
||||
}
|
||||
|
||||
export function setInputSelection(input, location) {
|
||||
export function setInputSelection(input: HTMLTextAreaElement, location: number) {
|
||||
input.setSelectionRange(location, location);
|
||||
|
||||
/**
|
||||
|
@ -98,7 +107,7 @@ 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;
|
||||
}
|
Loading…
Reference in New Issue