From 2c41a11a951f02d13918afc0773359a84a6680bd Mon Sep 17 00:00:00 2001 From: Amour1688 Date: Sat, 15 Feb 2020 15:39:50 +0800 Subject: [PATCH 01/13] feat: mentions style --- components/style/themes/default.less | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/components/style/themes/default.less b/components/style/themes/default.less index 8292fc0811..b8ff5fd087 100644 --- a/components/style/themes/default.less +++ b/components/style/themes/default.less @@ -333,6 +333,11 @@ @input-disabled-bg: @disabled-bg; @input-outline-offset: 0 0; +// Mentions +// --- +@mentions-dropdown-bg: @component-background; +@mentions-dropdown-menu-item-hover-bg: @mentions-dropdown-bg; + // Select // --- @select-border-color: @border-color-base; From b45b0a58b6f4ed9e72fa57a20ef592ccea806ffc Mon Sep 17 00:00:00 2001 From: Amour1688 Date: Sat, 15 Feb 2020 15:40:25 +0800 Subject: [PATCH 02/13] feat: theme default --- components/style/themes/default.less | 1 + 1 file changed, 1 insertion(+) diff --git a/components/style/themes/default.less b/components/style/themes/default.less index b8ff5fd087..1a1511b44a 100644 --- a/components/style/themes/default.less +++ b/components/style/themes/default.less @@ -1,6 +1,7 @@ /* stylelint-disable at-rule-empty-line-before,at-rule-name-space-after,at-rule-no-unknown */ @import '../color/colors'; +@theme: default; // The prefix to use on all css classes from ant. @ant-prefix: ant; From ac067a1dfe1c9bf864ca90c473969451e3fe4364 Mon Sep 17 00:00:00 2001 From: Amour1688 Date: Sat, 15 Feb 2020 15:41:10 +0800 Subject: [PATCH 03/13] feat: add mentions component --- components/index.js | 4 + components/mentions/demo/async.md | 71 ++++ components/mentions/demo/basic.md | 35 ++ components/mentions/demo/form.md | 68 ++++ components/mentions/demo/index.vue | 42 +++ components/mentions/demo/placement.md | 21 ++ components/mentions/demo/prefix.md | 48 +++ components/mentions/demo/readonly.md | 41 +++ components/mentions/index.jsx | 171 +++++++++ components/mentions/style/index.js | 6 + components/mentions/style/index.less | 167 +++++++++ components/style.js | 1 + components/vc-mentions/index.js | 6 + components/vc-mentions/src/DropdownMenu.jsx | 63 ++++ components/vc-mentions/src/KeywordTrigger.jsx | 70 ++++ components/vc-mentions/src/Mentions.jsx | 332 ++++++++++++++++++ components/vc-mentions/src/Option.jsx | 15 + components/vc-mentions/src/mentionsProps.js | 48 +++ components/vc-mentions/src/placement.js | 1 + components/vc-mentions/src/util.js | 109 ++++++ site/components.js | 2 + site/demo.js | 6 + site/demoRoutes.js | 8 + 23 files changed, 1335 insertions(+) create mode 100644 components/mentions/demo/async.md create mode 100644 components/mentions/demo/basic.md create mode 100644 components/mentions/demo/form.md create mode 100644 components/mentions/demo/index.vue create mode 100644 components/mentions/demo/placement.md create mode 100644 components/mentions/demo/prefix.md create mode 100644 components/mentions/demo/readonly.md create mode 100644 components/mentions/index.jsx create mode 100644 components/mentions/style/index.js create mode 100644 components/mentions/style/index.less create mode 100644 components/vc-mentions/index.js create mode 100644 components/vc-mentions/src/DropdownMenu.jsx create mode 100644 components/vc-mentions/src/KeywordTrigger.jsx create mode 100644 components/vc-mentions/src/Mentions.jsx create mode 100644 components/vc-mentions/src/Option.jsx create mode 100644 components/vc-mentions/src/mentionsProps.js create mode 100644 components/vc-mentions/src/placement.js create mode 100644 components/vc-mentions/src/util.js diff --git a/components/index.js b/components/index.js index 535379fcef..dc581a4e6e 100644 --- a/components/index.js +++ b/components/index.js @@ -73,6 +73,8 @@ import { default as message } from './message'; import { default as Menu } from './menu'; +import { default as Mentions } from './mentions'; + import { default as Modal } from './modal'; import { default as notification } from './notification'; @@ -171,6 +173,7 @@ const components = [ List, LocaleProvider, Menu, + Mentions, Modal, Pagination, Popconfirm, @@ -258,6 +261,7 @@ export { List, LocaleProvider, Menu, + Mentions, Modal, Pagination, Popconfirm, diff --git a/components/mentions/demo/async.md b/components/mentions/demo/async.md new file mode 100644 index 0000000000..ac7f547290 --- /dev/null +++ b/components/mentions/demo/async.md @@ -0,0 +1,71 @@ + +#### 异步加载 +匹配内容列表为异步返回时。 + + + +#### Asynchronous loading +async. + + +```tpl + + + + +``` diff --git a/components/mentions/demo/basic.md b/components/mentions/demo/basic.md new file mode 100644 index 0000000000..607a23157e --- /dev/null +++ b/components/mentions/demo/basic.md @@ -0,0 +1,35 @@ + +#### 基础列表 +基本使用。 + + + +#### Basic usage +Basic usage. + + +```tpl + + +``` diff --git a/components/mentions/demo/form.md b/components/mentions/demo/form.md new file mode 100644 index 0000000000..a9345d32db --- /dev/null +++ b/components/mentions/demo/form.md @@ -0,0 +1,68 @@ + +#### 配合 Form 使用 +受控模式,例如配合 Form 使用。 + + + +#### With Form +Controlled mode, for example, to work with `Form`. + + +```tpl + + +``` diff --git a/components/mentions/demo/index.vue b/components/mentions/demo/index.vue new file mode 100644 index 0000000000..4176234c7a --- /dev/null +++ b/components/mentions/demo/index.vue @@ -0,0 +1,42 @@ + diff --git a/components/mentions/demo/placement.md b/components/mentions/demo/placement.md new file mode 100644 index 0000000000..48b7bc67b3 --- /dev/null +++ b/components/mentions/demo/placement.md @@ -0,0 +1,21 @@ + +#### 向上展开 +向上展开建议。 + + + +#### Placemen +Change the suggestions placement. + + +```tpl + +``` diff --git a/components/mentions/demo/prefix.md b/components/mentions/demo/prefix.md new file mode 100644 index 0000000000..1dacc97de4 --- /dev/null +++ b/components/mentions/demo/prefix.md @@ -0,0 +1,48 @@ + +#### 自定义触发字符 +通过 prefix 属性自定义触发字符。默认为 @, 可以定义为数组。 + + + +#### Customize Trigger Token +Customize Trigger Token by `prefix` props. Default to `@`, `Array` also supported. + + +```tpl + + +``` diff --git a/components/mentions/demo/readonly.md b/components/mentions/demo/readonly.md new file mode 100644 index 0000000000..97b8cca555 --- /dev/null +++ b/components/mentions/demo/readonly.md @@ -0,0 +1,41 @@ + +#### 无效或只读 +通过 `disabled` 属性设置是否生效。通过 `readOnly` 属性设置是否只读。 + + + +#### disabled or readOnly +Configurate disabled and readOnly. + + +```tpl + + +``` diff --git a/components/mentions/index.jsx b/components/mentions/index.jsx new file mode 100644 index 0000000000..8cd25eab3f --- /dev/null +++ b/components/mentions/index.jsx @@ -0,0 +1,171 @@ +import classNames from 'classnames'; +import omit from 'omit.js'; +import PropTypes from '../_util/vue-types'; +import VcMentions from '../vc-mentions'; +import { mentionsProps, defaultProps } from '../vc-mentions/src/mentionsProps'; +import Base from '../base'; +import Spin from '../spin'; +import BaseMixin from '../_util/BaseMixin'; +import { ConfigConsumerProps } from '../config-provider'; +import { getOptionProps, getListeners, filterEmpty, initDefaultProps } from '../_util/props-util'; + +const { Option } = VcMentions; + +function loadingFilterOption() { + return true; +} + +function getMentions(value = '', config) { + const { prefix = '@', split = ' ' } = config || {}; + const prefixList = Array.isArray(prefix) ? prefix : [prefix]; + + return value + .split(split) + .map((str = '') => { + let hitPrefix = null; + + prefixList.some(prefixStr => { + const startStr = str.slice(0, prefixStr.length); + if (startStr === prefixStr) { + hitPrefix = prefixStr; + return true; + } + return false; + }); + + if (hitPrefix !== null) { + return { + prefix: hitPrefix, + value: str.slice(hitPrefix.length), + }; + } + return null; + }) + .filter(entity => !!entity && !!entity.value); +} + +const Mentions = { + name: 'AMentions', + mixins: [BaseMixin], + Option: { ...Option, name: 'AMentionsOption' }, + getMentions, + props: initDefaultProps( + { + ...mentionsProps, + loading: PropTypes.bool, + }, + defaultProps, + ), + inject: { + configProvider: { default: () => ConfigConsumerProps }, + }, + data() { + return { + focused: false, + }; + }, + methods: { + onFocus(...args) { + this.$emit('focus', ...args); + this.setState({ + focused: true, + }); + }, + onBlur(...args) { + this.$emit('blur', ...args); + this.setState({ + focused: false, + }); + }, + onSelect(...args) { + this.$emit('select', ...args); + this.setState({ + focused: true, + }); + }, + getNotFoundContent(renderEmpty) { + const h = this.$createElement; + const { notFoundContent } = this.$props; + if (notFoundContent !== undefined) { + return notFoundContent; + } + + return renderEmpty(h, 'Select'); + }, + getOptions() { + const { loading } = this.$props; + const children = filterEmpty(this.$slots.default || []); + + if (loading) { + return ( + + ); + } + return children; + }, + getFilterOption() { + const { filterOption, loading } = this.$props; + if (loading) { + return loadingFilterOption; + } + return filterOption; + }, + focus() { + this.$refs.vcMentions.focus(); + }, + blur() { + this.$refs.vcMentions.blur(); + }, + }, + render() { + const { focused } = this.$data; + const { getPrefixCls, renderEmpty, getContextPopupContainer } = this.configProvider; + const { + prefixCls: customizePrefixCls, + disabled, + getPopupContainer, + ...restProps + } = getOptionProps(this); + const prefixCls = getPrefixCls('mentions', customizePrefixCls); + const otherProps = omit(restProps, ['loading']); + + const mergedClassName = classNames({ + [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-focused`]: focused, + }); + + const mentionsProps = { + props: { + prefixCls, + notFoundContent: this.getNotFoundContent(renderEmpty), + ...otherProps, + disabled, + filterOption: this.getFilterOption(), + getPopupContainer: getPopupContainer || getContextPopupContainer, + children: this.getOptions(), + }, + class: mergedClassName, + attrs: this.$attrs, + on: { + ...getListeners(this), + select: this.onSelect, + focus: this.onFocus, + blur: this.onBlur, + }, + ref: 'vcMentions', + }; + + return ; + }, +}; + +/* istanbul ignore next */ +Mentions.install = function(Vue) { + Vue.use(Base); + Vue.component(Mentions.name, Mentions); + Vue.component(Mentions.Option.name, Mentions.Option); +}; + +export default Mentions; diff --git a/components/mentions/style/index.js b/components/mentions/style/index.js new file mode 100644 index 0000000000..5de2c7949c --- /dev/null +++ b/components/mentions/style/index.js @@ -0,0 +1,6 @@ +import '../../style/index.less'; +import './index.less'; + +// style dependencies +import '../../empty/style'; +import '../../spin/style'; diff --git a/components/mentions/style/index.less b/components/mentions/style/index.less new file mode 100644 index 0000000000..c0b75d2d06 --- /dev/null +++ b/components/mentions/style/index.less @@ -0,0 +1,167 @@ +@import '../../style/themes/default'; +@import '../../style/mixins/index'; +@import '../../input/style/mixin'; + +@mention-prefix-cls: ~'@{ant-prefix}-mentions'; + +.@{mention-prefix-cls} { + .reset-component; + .input; + + position: relative; + display: inline-block; + height: auto; + padding: 0; + overflow: hidden; + line-height: @line-height-base; + white-space: pre-wrap; + vertical-align: bottom; + + // =================== Status =================== + &-disabled { + > textarea { + .disabled(); + } + } + + &-focused { + .active(); + } + + // ================= Input Area ================= + > textarea, + &-measure { + min-height: @input-height-base - 2px; + margin: 0; + padding: @input-padding-vertical-base @input-padding-horizontal-base; + overflow: inherit; + overflow-x: hidden; + overflow-y: auto; + font-weight: inherit; + font-size: inherit; + font-family: inherit; + font-style: inherit; + font-variant: inherit; + font-size-adjust: inherit; + font-stretch: inherit; + line-height: inherit; + direction: inherit; + letter-spacing: inherit; + white-space: inherit; + text-align: inherit; + vertical-align: top; + word-wrap: break-word; + word-break: inherit; + tab-size: inherit; + } + + > textarea { + width: 100%; + border: none; + outline: none; + resize: none; + & when (@theme = dark) { + background-color: transparent; + } + .placeholder(); + + &:read-only { + cursor: default; + } + } + + &-measure { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1; + color: transparent; + pointer-events: none; + + > span { + display: inline-block; + min-height: 1em; + } + } + + // ================== Dropdown ================== + &-dropdown { + // Ref select dropdown style + .reset-component; + + position: absolute; + top: -9999px; + left: -9999px; + z-index: @zindex-dropdown; + box-sizing: border-box; + font-size: @font-size-base; + font-variant: initial; + background-color: @mentions-dropdown-bg; + border-radius: @border-radius-base; + outline: none; + box-shadow: @box-shadow-base; + + &-hidden { + display: none; + } + + &-menu { + max-height: 250px; + margin-bottom: 0; + padding-left: 0; // Override default ul/ol + overflow: auto; + list-style: none; + outline: none; + + &-item { + position: relative; + display: block; + min-width: 100px; + padding: 5px @control-padding-horizontal; + overflow: hidden; + color: @text-color; + font-weight: normal; + line-height: 22px; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + transition: background 0.3s ease; + + &:hover { + background-color: @item-hover-bg; + } + + &:first-child { + border-radius: @border-radius-base @border-radius-base 0 0; + } + + &:last-child { + border-radius: 0 0 @border-radius-base @border-radius-base; + } + + &-disabled { + color: @disabled-color; + cursor: not-allowed; + + &:hover { + color: @disabled-color; + background-color: @mentions-dropdown-menu-item-hover-bg; + cursor: not-allowed; + } + } + + &-selected { + color: @text-color; + font-weight: @select-item-selected-font-weight; + background-color: @background-color-light; + } + + &-active { + background-color: @item-hover-bg; + } + } + } + } +} diff --git a/components/style.js b/components/style.js index e383390d3d..7d775b7f5b 100644 --- a/components/style.js +++ b/components/style.js @@ -14,6 +14,7 @@ import './tooltip/style'; import './popover/style'; import './popconfirm/style'; import './menu/style'; +import './mentions/style'; import './dropdown/style'; import './divider/style'; import './card/style'; diff --git a/components/vc-mentions/index.js b/components/vc-mentions/index.js new file mode 100644 index 0000000000..14b917af8b --- /dev/null +++ b/components/vc-mentions/index.js @@ -0,0 +1,6 @@ +import Mentions from './src/Mentions'; +import Option from './src/Option'; + +Mentions.Option = Option; + +export default Mentions; diff --git a/components/vc-mentions/src/DropdownMenu.jsx b/components/vc-mentions/src/DropdownMenu.jsx new file mode 100644 index 0000000000..5232079156 --- /dev/null +++ b/components/vc-mentions/src/DropdownMenu.jsx @@ -0,0 +1,63 @@ +import Menu, { MenuItem } from '../../vc-menu'; +import PropTypes from '../../_util/vue-types'; +import { OptionProps } from './Option'; + +export default { + name: 'DropdownMenu', + props: { + prefixCls: PropTypes.string, + options: PropTypes.arrayOf(OptionProps), + }, + inject: { + mentionsContext: { default: {} }, + }, + + render() { + const { + notFoundContent, + activeIndex, + setActiveIndex, + selectOption, + onFocus, + onBlur, + } = this.mentionsContext; + const { prefixCls, options } = this.$props; + const activeOption = options[activeIndex] || {}; + + return ( + { + const option = options.find(({ value }) => value === key); + selectOption(option); + }, + focus: onFocus, + blur: onBlur, + }, + }} + > + {options.map((option, index) => { + const { value, disabled, children } = option; + console.log(option); + return ( + { + setActiveIndex(index); + }} + > + {children} + + ); + })} + {!options.length && {notFoundContent}} + + ); + }, +}; diff --git a/components/vc-mentions/src/KeywordTrigger.jsx b/components/vc-mentions/src/KeywordTrigger.jsx new file mode 100644 index 0000000000..09d0dbcad4 --- /dev/null +++ b/components/vc-mentions/src/KeywordTrigger.jsx @@ -0,0 +1,70 @@ +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.bool, + options: PropTypes.arrayOf(OptionProps), + prefixCls: PropTypes.string, + placement: PropTypes.oneOf(PlaceMent), + visible: PropTypes.bool, + transitionName: PropTypes.string, + getPopupContainer: PropTypes.func, + }, + methods: { + getDropdownPrefix() { + return `${this.$props.prefixCls}-dropdown`; + }, + getDropdownElement() { + const { options } = this.$props; + return ; + }, + }, + + render() { + const { visible, placement, transitionName, getPopupContainer } = this.$props; + + const { $slots } = this; + + const children = $slots.default; + + const popupElement = this.getDropdownElement(); + + return ( + + {children} + + ); + }, +}; diff --git a/components/vc-mentions/src/Mentions.jsx b/components/vc-mentions/src/Mentions.jsx new file mode 100644 index 0000000000..9cb2c28f4b --- /dev/null +++ b/components/vc-mentions/src/Mentions.jsx @@ -0,0 +1,332 @@ +import classNames from 'classnames'; +import omit from 'omit.js'; +import KeyCode from '../../_util/KeyCode'; +import BaseMixin from '../../_util/BaseMixin'; +import { + getStyle, + getSlots, + hasProp, + getOptionProps, + getListeners, + 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'; + +function noop() {} + +const Mentions = { + name: 'Mentions', + mixins: [BaseMixin], + model: { + prop: 'value', + event: 'change.value', + }, + props: initDefaultProps(vcMentionsProps, defaultProps), + provide() { + const { notFoundContent } = this.$props; + const { activeIndex } = this.$data; + return { + mentionsContext: { + notFoundContent, + activeIndex, + setActiveIndex: this.setActiveIndex, + selectOption: this.selectOption, + onFocus: this.onDropdownFocus, + onBlur: this.onDropdownBlur, + }, + }; + }, + 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', value); + }, + onChange({ target: { value } }) { + 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 + const option = this.getOptions()[activeIndex]; + this.selectOption(option); + event.preventDefault(); + } + }, + /** + * 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); + }, 0); + }, + 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 => { + const children = getSlots(item).default; + return { ...getOptionProps(item), children }; + }) + .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, + autoFocus, + notFoundContent, + getPopupContainer, + ...restProps + } = getOptionProps(this); + + const inputProps = omit(restProps, [ + 'value', + 'defaultValue', + 'prefix', + 'split', + 'children', + 'validateSearch', + 'filterOption', + ]); + + const options = measuring ? this.getOptions() : []; + + return ( +
+
`; + +exports[`renders ./components/mentions/demo/basic.md correctly 1`] = `
`; + +exports[`renders ./components/mentions/demo/form.md correctly 1`] = ` +
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+`; + +exports[`renders ./components/mentions/demo/placement.md correctly 1`] = `
`; + +exports[`renders ./components/mentions/demo/prefix.md correctly 1`] = `
`; + +exports[`renders ./components/mentions/demo/readonly.md correctly 1`] = ` +
+
+
+
+
+
+`; diff --git a/components/mentions/__test__/demo.test.js b/components/mentions/__test__/demo.test.js new file mode 100644 index 0000000000..1946e55650 --- /dev/null +++ b/components/mentions/__test__/demo.test.js @@ -0,0 +1,3 @@ +import demoTest from '../../../tests/shared/demoTest'; + +demoTest('mentions'); diff --git a/components/mentions/__test__/index.test.js b/components/mentions/__test__/index.test.js new file mode 100644 index 0000000000..a9625d44df --- /dev/null +++ b/components/mentions/__test__/index.test.js @@ -0,0 +1,91 @@ +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +import Mentions from '..'; +import { asyncExpect } from '@/tests/utils'; +import focusTest from '../../../tests/shared/focusTest'; + +const { getMentions } = Mentions; + +function $$(className) { + return document.body.querySelectorAll(className); +} + +function triggerInput(wrapper, text = '') { + 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'); +} + +describe('Mentions', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('getMentions', () => { + const mentions = getMentions('@light #bamboo cat', { prefix: ['@', '#'] }); + expect(mentions).toEqual([ + { + prefix: '@', + value: 'light', + }, + { + prefix: '#', + value: 'bamboo', + }, + ]); + }); + + it('focus', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + + const wrapper = mount({ + render() { + return ; + }, + }); + wrapper.find('textarea').trigger('focus'); + expect(wrapper.find('.ant-mentions').classes('ant-mentions-focused')).toBeTruthy(); + expect(onFocus).toHaveBeenCalled(); + + wrapper.find('textarea').trigger('blur'); + jest.runAllTimers(); + expect(wrapper.classes()).not.toContain('ant-mentions-focused'); + expect(onBlur).toHaveBeenCalled(); + }); + + it('loading', done => { + const wrapper = mount( + { + render() { + return ; + }, + }, + { sync: false }, + ); + triggerInput(wrapper, '@'); + Vue.nextTick(() => { + mount( + { + render() { + return wrapper.find({ name: 'Trigger' }).vm.getComponent(); + }, + }, + { sync: false }, + ); + Vue.nextTick(() => { + expect($$('.ant-mentions-dropdown-menu-item').length).toBeTruthy(); + expect($$('.ant-spin')).toBeTruthy(); + done(); + }); + }); + }); + + focusTest(Mentions); +}); diff --git a/tests/__snapshots__/index.test.js.snap b/tests/__snapshots__/index.test.js.snap index 6a4f2902ce..1627daea6e 100644 --- a/tests/__snapshots__/index.test.js.snap +++ b/tests/__snapshots__/index.test.js.snap @@ -34,6 +34,7 @@ Array [ "List", "LocaleProvider", "Menu", + "Mentions", "Modal", "Pagination", "Popconfirm", From 9a88b1e01c650f64494ceee46fa36a36f7230e6b Mon Sep 17 00:00:00 2001 From: Amour1688 Date: Sun, 16 Feb 2020 13:17:58 +0800 Subject: [PATCH 06/13] feat: update mentions demo --- components/mentions/demo/async.md | 6 ------ components/mentions/demo/form.md | 25 +++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/components/mentions/demo/async.md b/components/mentions/demo/async.md index ac7f547290..747db1c428 100644 --- a/components/mentions/demo/async.md +++ b/components/mentions/demo/async.md @@ -62,10 +62,4 @@ export default { } ``` diff --git a/components/mentions/demo/form.md b/components/mentions/demo/form.md index a9345d32db..2f75f8b5a0 100644 --- a/components/mentions/demo/form.md +++ b/components/mentions/demo/form.md @@ -11,7 +11,7 @@ Controlled mode, for example, to work with `Form`. ```tpl