diff --git a/components/mentions/__tests__/index.test.js b/components/mentions/__tests__/index.test.js index 47adfe1fc..5637526b7 100644 --- a/components/mentions/__tests__/index.test.js +++ b/components/mentions/__tests__/index.test.js @@ -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(); }); diff --git a/components/mentions/index.tsx b/components/mentions/index.tsx index 6f9bfed3d..e74311ce9 100644 --- a/components/mentions/index.tsx +++ b/components/mentions/index.tsx @@ -1,4 +1,5 @@ -import type { App, PropType, VNodeTypes, Plugin, ExtractPropTypes } from 'vue'; +import type { App, PropType, Plugin, ExtractPropTypes } from 'vue'; +import { watch } from 'vue'; import { ref, onMounted } from 'vue'; import { defineComponent, nextTick } from 'vue'; import classNames from '../_util/classNames'; @@ -6,9 +7,8 @@ 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 type { RenderEmptyHandler } from '../config-provider/renderEmpty'; import useConfigInject from '../_util/hooks/useConfigInject'; +import { flattenChildren, getOptionProps } from '../_util/props-util'; const { Option } = VcMentions; @@ -19,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); @@ -49,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, @@ -72,6 +75,8 @@ const mentionsProps = { onChange: { type: Function as PropType<(text: string) => void>, }, + notFoundContent: PropTypes.any, + defaultValue: String, }; export type MentionsProps = Partial>; @@ -81,12 +86,20 @@ const Mentions = defineComponent({ inheritAttrs: false, props: mentionsProps, getMentions, - emits: ['update:value', 'change', 'focus', 'blur', 'select'], + Option, + emits: ['update:value', 'change', 'focus', 'blur', 'select', 'pressenter'], + slots: ['notFoundContent', 'option'], setup(props, { slots, emit, attrs, expose }) { 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); @@ -103,43 +116,34 @@ const Mentions = defineComponent({ }; const handleChange = (val: string) => { + if (props.value === undefined) { + value.value = val; + } emit('update:value', val); emit('change', val); }; - const getNotFoundContent = (renderEmpty: RenderEmptyHandler) => { + const getNotFoundContent = () => { const notFoundContent = props.notFoundContent; if (notFoundContent !== undefined) { return notFoundContent; } - - return renderEmpty('Select'); + if (slots.notFoundContent) { + return slots.notFoundContent(); + } + return renderEmpty.value('Select'); }; const getOptions = () => { - const { loading } = props; - - if (loading) { - return ( - - ); - } - return slots.default?.(); - }; - - const getFilterOption = () => { - const { filterOption, loading } = props; - if (loading) { - return loadingFilterOption; - } - return filterOption; + 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(); }; @@ -157,35 +161,40 @@ const Mentions = defineComponent({ }); return () => { - const { disabled, getPopupContainer, ...restProps } = props; + const { disabled, getPopupContainer, rows = 1, ...restProps } = props; const { class: className, ...otherAttrs } = attrs; - const otherProps = omit(restProps, ['loading', 'onUpdate:value', 'prefixCls']); + const otherProps = omit(restProps, ['defaultValue', 'onUpdate:value', 'prefixCls']); const mergedClassName = classNames(className, { [`${prefixCls.value}-disabled`]: disabled, [`${prefixCls.value}-focused`]: focused.value, - [`${prefixCls}-rtl`]: direction.value === 'rtl', + [`${prefixCls.value}-rtl`]: direction.value === 'rtl', }); const mentionsProps = { prefixCls: prefixCls.value, - notFoundContent: getNotFoundContent(renderEmpty.value), ...otherProps, disabled, direction: direction.value, - filterOption: getFilterOption(), + filterOption: props.filterOption, getPopupContainer, - children: getOptions(), + options: props.options || getOptions(), class: mergedClassName, - rows: 1, ...otherAttrs, + rows, onChange: handleChange, onSelect: handleSelect, onFocus: handleFocus, onBlur: handleBlur, ref: vcMentions, + value: value.value, }; - return ; + return ( + + ); }; }, }); @@ -198,11 +207,12 @@ export const MentionsOption = { /* istanbul ignore next */ Mentions.install = function (app: App) { app.component(Mentions.name, Mentions); - app.component(MentionsOption.name, MentionsOption); + app.component('AMentionsOption', Option); return app; }; export default Mentions as typeof Mentions & Plugin & { + getMentions: typeof getMentions; readonly Option: typeof Option; }; diff --git a/components/vc-mentions/index.ts b/components/vc-mentions/index.ts index 14b917af8..828b8bcb6 100644 --- a/components/vc-mentions/index.ts +++ b/components/vc-mentions/index.ts @@ -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; diff --git a/components/vc-mentions/src/DropdownMenu.tsx b/components/vc-mentions/src/DropdownMenu.tsx index 453b9dc22..454403f0a 100644 --- a/components/vc-mentions/src/DropdownMenu.tsx +++ b/components/vc-mentions/src/DropdownMenu.tsx @@ -1,26 +1,34 @@ import Menu, { Item as MenuItem } from '../../menu'; -import PropTypes from '../../_util/vue-types'; -import { defineComponent, inject } from 'vue'; +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: PropTypes.string, - options: PropTypes.any, + prefixCls: String, + options: { + type: Array as PropType, + default: () => [], + }, }, - setup(props) { + slots: ['notFoundContent', 'option'], + setup(props, { slots }) { + const { + activeIndex, + setActiveIndex, + selectOption, + onFocus = noop, + onBlur = noop, + loading, + } = inject(MentionsContextKey, { + activeIndex: ref(), + loading: ref(false), + }); return () => { - const { - notFoundContent, - activeIndex, - setActiveIndex, - selectOption, - onFocus = noop, - onBlur = noop, - } = inject('mentionsContext'); - const { prefixCls, options } = props; const activeOption = options[activeIndex.value] || {}; @@ -35,9 +43,9 @@ export default defineComponent({ onBlur={onBlur} onFocus={onFocus} > - {[ - ...options.map((option, index) => { - const { value, disabled, children } = option; + {!loading.value && + options.map((option, index) => { + const { value, disabled, label = option.value } = option; return ( - {children} + {slots.option?.(option) ?? + (typeof label === 'function' ? label({ value, disabled }) : label)} ); - }), - !options.length && ( - - {notFoundContent.value} - - ), - ].filter(Boolean)} + })} + {!loading.value && options.length === 0 ? ( + + {slots.notFoundContent?.()} + + ) : null} + {loading.value && ( + + + + )} ); }; diff --git a/components/vc-mentions/src/KeywordTrigger.tsx b/components/vc-mentions/src/KeywordTrigger.tsx index 7da20e4e8..3bb9078be 100644 --- a/components/vc-mentions/src/KeywordTrigger.tsx +++ b/components/vc-mentions/src/KeywordTrigger.tsx @@ -1,7 +1,9 @@ import PropTypes from '../../_util/vue-types'; import Trigger from '../../vc-trigger'; import DropdownMenu from './DropdownMenu'; -import { defineComponent } from 'vue'; +import type { PropType } from 'vue'; +import { computed, defineComponent } from 'vue'; +import type { OptionProps } from './Option'; const BUILT_IN_PLACEMENTS = { bottomRight: { @@ -12,6 +14,14 @@ const BUILT_IN_PLACEMENTS = { adjustY: 1, }, }, + bottomLeft: { + points: ['tr', 'bl'], + offset: [0, 4], + overflow: { + adjustX: 0, + adjustY: 1, + }, + }, topRight: { points: ['bl', 'tr'], offset: [0, -4], @@ -20,50 +30,72 @@ const BUILT_IN_PLACEMENTS = { adjustY: 1, }, }, + topLeft: { + points: ['br', 'tl'], + offset: [0, -4], + overflow: { + adjustX: 0, + adjustY: 1, + }, + }, }; export default defineComponent({ name: 'KeywordTrigger', props: { loading: PropTypes.looseBool, - options: PropTypes.array, + options: { + type: Array as PropType, + default: () => [], + }, prefixCls: PropTypes.string, placement: PropTypes.string, visible: PropTypes.looseBool, transitionName: PropTypes.string, getPopupContainer: PropTypes.func, + direction: PropTypes.string, }, - methods: { - getDropdownPrefix() { - return `${this.$props.prefixCls}-dropdown`; - }, - getDropdownElement() { - const { options } = this.$props; - return ; - }, - }, + slots: ['notFoundContent', 'option'], + setup(props, { slots }) { + const getDropdownPrefix = () => { + return `${props.prefixCls}-dropdown`; + }; + const getDropdownElement = () => { + const { options } = props; + return ( + + ); + }; - render() { - const { visible, placement, transitionName, getPopupContainer } = this.$props; - - const { $slots } = this; - - const children = $slots.default?.(); - - const popupElement = this.getDropdownElement(); - - return ( - - {children} - - ); + 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 ( + + {slots.default?.()} + + ); + }; }, }); diff --git a/components/vc-mentions/src/Mentions.tsx b/components/vc-mentions/src/Mentions.tsx index 58ca259e7..10e78387b 100644 --- a/components/vc-mentions/src/Mentions.tsx +++ b/components/vc-mentions/src/Mentions.tsx @@ -1,9 +1,9 @@ import type { ExtractPropTypes } from 'vue'; +import { toRef, watchEffect } from 'vue'; import { defineComponent, provide, withDirectives, - onMounted, ref, reactive, onUpdated, @@ -13,8 +13,7 @@ import { 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 { initDefaultProps } from '../../_util/props-util'; import { getBeforeSelectionText, getLastMeasureIndex, @@ -24,21 +23,25 @@ import { 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>; function noop() {} -const Mentions = { +export default defineComponent({ name: 'Mentions', inheritAttrs: false, props: initDefaultProps(vcMentionsProps, defaultProps), - setup(props: MentionsProps, { emit, attrs, expose }) { + slots: ['notFoundContent', 'option'], + emits: ['change', 'select', 'search', 'focus', 'blur', 'pressenter'], + setup(props: MentionsProps, { emit, attrs, expose, slots }) { const measure = ref(null); const textarea = ref(null); const focusId = ref(); const state = reactive({ - value: props.defaultValue || props.value || '', + value: props.value || '', measuring: false, measureLocation: 0, measureText: null, @@ -47,8 +50,11 @@ const Mentions = { isFocus: false, }); + watchEffect(() => { + state.value = props.value; + }); + const triggerChange = (val: string) => { - state.value = val; emit('change', val); }; @@ -57,6 +63,24 @@ const Mentions = { 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 @@ -66,7 +90,7 @@ const Mentions = { if (which === KeyCode.UP || which === KeyCode.DOWN) { // Control arrow function - const optionLen = getOptions().length; + const optionLen = options.value.length; const offset = which === KeyCode.UP ? -1 : 1; const newActiveIndex = (state.activeIndex + offset + optionLen) % optionLen; state.activeIndex = newActiveIndex; @@ -76,12 +100,11 @@ const Mentions = { } else if (which === KeyCode.ENTER) { // Measure hit event.preventDefault(); - const options = getOptions(); - if (!options.length) { + if (!options.value.length) { stopMeasure(); return; } - const option = options[state.activeIndex]; + const option = options.value[state.activeIndex]; selectOption(option); } }; @@ -110,6 +133,7 @@ const Mentions = { if (validateMeasure) { if ( key === measurePrefix || + key === 'Shift' || measuring || (measureText !== prevMeasureText && matchOption) ) { @@ -131,6 +155,12 @@ const Mentions = { stopMeasure(); } }; + const onPressEnter = event => { + if (!state.measuring) { + emit('pressenter', event); + } + }; + const onInputFocus = (event: Event) => { onFocus(event); }; @@ -152,7 +182,7 @@ const Mentions = { emit('blur', event); }, 100); }; - const selectOption = option => { + const selectOption = (option: OptionProps) => { const { split } = props; const { value: mentionValue = '' } = option; const { text, selectionLocation } = replaceWithMeasure(state.value, { @@ -170,38 +200,26 @@ const Mentions = { emit('select', option, state.measurePrefix); }; - const setActiveIndex = activeIndex => { + const setActiveIndex = (activeIndex: number) => { 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); - }); + 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 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 options = computed(() => { + return getOptions(); + }); + const focus = () => { textarea.value.focus(); }; @@ -209,19 +227,13 @@ const Mentions = { 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, - }); + provide(MentionsContextKey, { + activeIndex: toRef(state, 'activeIndex'), + setActiveIndex, + selectOption, + onFocus, + onBlur, + loading: toRef(props, 'loading'), }); onUpdated(() => { nextTick(() => { @@ -232,28 +244,21 @@ const Mentions = { }); return () => { const { measureLocation, measurePrefix, measuring } = state; - const { - prefixCls, - placement, - transitionName, - notFoundContent, - getPopupContainer, - ...restProps - } = props; + const { prefixCls, placement, transitionName, getPopupContainer, direction, ...restProps } = + props; const { class: className, style, ...otherAttrs } = attrs; const inputProps = omit(restProps, [ 'value', - 'defaultValue', 'prefix', 'split', - 'children', 'validateSearch', 'filterOption', + 'options', + 'loading', ]); - const options = measuring ? getOptions() : []; const textareaProps = { ...inputProps, ...otherAttrs, @@ -265,8 +270,8 @@ const Mentions = { onKeydown: onKeyDown, onKeyup: onKeyUp, onFocus: onInputFocus, + onPressenter: onPressEnter, }; - return (
{withDirectives(