feat: add mentions (#1790)
* feat: mentions style * feat: theme default * feat: add mentions component * feat: mentions API * feat: add unit test for mentions * feat: update mentions demo * perf: model and inheritAttrs for mentions * perf: use getComponentFromProp instead of this.$props * perf: mentions rm defaultProps * feat: rm rows in mentionsProps * fix: mentions keyDown didn't work * docs: update mentions api * perf: mentions codepull/1845/head
parent
a04b35f242
commit
16e075502e
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders ./components/mentions/demo/async.md correctly 1`] = `<div class="ant-mentions"><textarea rows="1"></textarea></div>`;
|
||||
|
||||
exports[`renders ./components/mentions/demo/basic.md correctly 1`] = `<div class="ant-mentions"><textarea rows="1"></textarea></div>`;
|
||||
|
||||
exports[`renders ./components/mentions/demo/form.md correctly 1`] = `
|
||||
<form class="ant-form ant-form-horizontal">
|
||||
<div class="ant-row ant-form-item">
|
||||
<div class="ant-col-5 ant-form-item-label"><label for="mentions_coders" title="Top coders" class="">Top coders</label></div>
|
||||
<div class="ant-col-12 ant-form-item-control-wrapper">
|
||||
<div class="ant-form-item-control"><span class="ant-form-item-children"><div class="ant-mentions"><textarea rows="1" data-__meta="[object Object]" data-__field="[object Object]" id="mentions_coders"></textarea></div></span>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ant-row ant-form-item">
|
||||
<div class="ant-col-5 ant-form-item-label"><label for="mentions_bio" title="Bio" class="ant-form-item-required">Bio</label></div>
|
||||
<div class="ant-col-12 ant-form-item-control-wrapper">
|
||||
<div class="ant-form-item-control"><span class="ant-form-item-children"><div class="ant-mentions"><textarea rows="3" placeholder="You can use @ to ref user here" data-__meta="[object Object]" data-__field="[object Object]" id="mentions_bio"></textarea></div></span>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ant-row ant-form-item">
|
||||
<div class="ant-col-12 ant-col-offset-5 ant-form-item-control-wrapper">
|
||||
<div class="ant-form-item-control"><span class="ant-form-item-children"><button type="button" class="ant-btn ant-btn-primary"><span>Submit</span></button><button type="button" class="ant-btn" style="margin-left: 8px;"><span>Reset</span></button></span>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
exports[`renders ./components/mentions/demo/placement.md correctly 1`] = `<div class="ant-mentions"><textarea rows="1"></textarea></div>`;
|
||||
|
||||
exports[`renders ./components/mentions/demo/prefix.md correctly 1`] = `<div class="ant-mentions"><textarea rows="1" placeholder="input @ to mention people, # to mention tag"></textarea></div>`;
|
||||
|
||||
exports[`renders ./components/mentions/demo/readonly.md correctly 1`] = `
|
||||
<div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<div class="ant-mentions ant-mentions-disabled"><textarea disabled="disabled" rows="1" placeholder="this is disabled Mentions"></textarea></div>
|
||||
</div>
|
||||
<div class="ant-mentions"><textarea rows="1" placeholder="this is readOnly a-mentions" readonly=""></textarea></div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,3 @@
|
|||
import demoTest from '../../../tests/shared/demoTest';
|
||||
|
||||
demoTest('mentions');
|
|
@ -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 <Mentions onFocus={onFocus} onBlur={onBlur} />;
|
||||
},
|
||||
});
|
||||
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 <Mentions loading />;
|
||||
},
|
||||
},
|
||||
{ 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);
|
||||
});
|
|
@ -0,0 +1,65 @@
|
|||
<cn>
|
||||
#### 异步加载
|
||||
匹配内容列表为异步返回时。
|
||||
</cn>
|
||||
|
||||
<us>
|
||||
#### Asynchronous loading
|
||||
async.
|
||||
</us>
|
||||
|
||||
```tpl
|
||||
<template>
|
||||
<a-mentions @search="onSearch" :loading="loading">
|
||||
<a-mentions-option
|
||||
v-for="({ login, avatar_url: avatar }) in users"
|
||||
:key="login"
|
||||
:value="login"
|
||||
>
|
||||
<img :src="avatar" :alt="login" style="width: 20px; margin-right: 8px;">
|
||||
<span>{{login}}</span>
|
||||
</a-mentions-option>
|
||||
</a-mentions>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import debounce from 'lodash/debounce';
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
users: []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadGithubUsers = debounce(this.loadGithubUsers, 800);
|
||||
},
|
||||
methods: {
|
||||
onSearch(search) {
|
||||
this.search = search;
|
||||
this.loading = !!search;
|
||||
console.log(!!search)
|
||||
this.users = [];
|
||||
console.log('Search:', search);
|
||||
this.loadGithubUsers(search);
|
||||
},
|
||||
loadGithubUsers(key) {
|
||||
if (!key) {
|
||||
this.users = [];
|
||||
return;
|
||||
}
|
||||
fetch(`https://api.github.com/search/users?q=${key}`)
|
||||
.then(res => res.json())
|
||||
.then(({ items = [] }) => {
|
||||
const { search } = this;
|
||||
if (search !== key) return;
|
||||
|
||||
this.users = items.slice(0, 10);
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
```
|
|
@ -0,0 +1,35 @@
|
|||
<cn>
|
||||
#### 基础列表
|
||||
基本使用。
|
||||
</cn>
|
||||
|
||||
<us>
|
||||
#### Basic usage
|
||||
Basic usage.
|
||||
</us>
|
||||
|
||||
```tpl
|
||||
<template>
|
||||
<a-mentions
|
||||
defaultValue="@afc163"
|
||||
@change="onChange"
|
||||
@select="onSelect"
|
||||
>
|
||||
<a-mentions-option value="afc163">afc163</a-mentions-option>
|
||||
<a-mentions-option value="zombieJ">zombieJ</a-mentions-option>
|
||||
<a-mentions-option value="yesmeck">yesmeck</a-mentions-option>
|
||||
</a-mentions>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
methods: {
|
||||
onSelect(option) {
|
||||
console.log('select', option);
|
||||
},
|
||||
onChange(value) {
|
||||
console.log('Change:', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
|
@ -0,0 +1,89 @@
|
|||
<cn>
|
||||
#### 配合 Form 使用
|
||||
受控模式,例如配合 Form 使用。
|
||||
</cn>
|
||||
|
||||
<us>
|
||||
#### With Form
|
||||
Controlled mode, for example, to work with `Form`.
|
||||
</us>
|
||||
|
||||
```tpl
|
||||
<template>
|
||||
<a-form :form="form" layout="horizontal">
|
||||
<a-form-item label="Top coders" :label-col="{ span: 5 }" :wrapper-col="{ span: 12 }">
|
||||
<a-mentions
|
||||
rows="1"
|
||||
v-decorator="[
|
||||
'coders',
|
||||
{
|
||||
rules: [{ validator: checkMention }],
|
||||
},
|
||||
]"
|
||||
>
|
||||
<a-mentions-option value="afc163">afc163</a-mentions-option>
|
||||
<a-mentions-option value="zombieJ">zombieJ</a-mentions-option>
|
||||
<a-mentions-option value="yesmeck">yesmeck</a-mentions-option>
|
||||
</a-mentions>
|
||||
</a-form-item>
|
||||
<a-form-item label="Bio" :label-col="{ span: 5 }" :wrapper-col="{ span: 12 }">
|
||||
<a-mentions
|
||||
rows="3"
|
||||
placeholder="You can use @ to ref user here"
|
||||
v-decorator="[
|
||||
'bio',
|
||||
{
|
||||
rules: [{ required: true }],
|
||||
},
|
||||
]"
|
||||
>
|
||||
<a-mentions-option value="afc163">afc163</a-mentions-option>
|
||||
<a-mentions-option value="zombieJ">zombieJ</a-mentions-option>
|
||||
<a-mentions-option value="yesmeck">yesmeck</a-mentions-option>
|
||||
</a-mentions>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ span: 12, offset: 5 }">
|
||||
<a-button type="primary" @click="handleSubmit">
|
||||
Submit
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px;" @click="handleReset">Reset</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
<script>
|
||||
import { Mentions } from 'ant-design-vue';
|
||||
const { getMentions } = Mentions;
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
form: this.$form.createForm(this, { name: 'mentions' })
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleReset(e) {
|
||||
e.preventDefault();
|
||||
this.form.resetFields();
|
||||
},
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.form.validateFields((errors, values) => {
|
||||
if (errors) {
|
||||
console.log('Errors in the form!!!');
|
||||
return;
|
||||
}
|
||||
console.log('Submit!!!');
|
||||
console.log(values);
|
||||
});
|
||||
},
|
||||
checkMention(rule, value, callback) {
|
||||
const mentions = getMentions(value);
|
||||
if (mentions.length < 2) {
|
||||
callback(new Error('More than one must be selected!'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
|
@ -0,0 +1,48 @@
|
|||
<script>
|
||||
import Basic from './basic.md';
|
||||
import Async from './async.md';
|
||||
import Form from './form.md';
|
||||
import Prefix from './prefix.md';
|
||||
import Readonly from './readonly.md';
|
||||
import Placement from './placement.md';
|
||||
import CN from '../index.zh-CN.md';
|
||||
import US from '../index.en-US.md';
|
||||
|
||||
const md = {
|
||||
cn: `# Mentions提及
|
||||
提及组件。
|
||||
## 何时使用
|
||||
- 用于在输入中提及某人或某事,常用于发布、聊天或评论功能。
|
||||
|
||||
## 代码演示`,
|
||||
us: `# Mentions
|
||||
Mention component.
|
||||
## When To Use
|
||||
- When need to mention someone or something.
|
||||
## Examples `,
|
||||
};
|
||||
export default {
|
||||
category: 'Components',
|
||||
subtitle: '提及',
|
||||
type: 'Data Entry',
|
||||
title: 'Mentions',
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<md cn={md.cn} us={md.us} />
|
||||
<Basic />
|
||||
<Async />
|
||||
<Form />
|
||||
<Prefix />
|
||||
<Readonly />
|
||||
<Placement />
|
||||
<api>
|
||||
<CN slot="cn" />
|
||||
<US />
|
||||
</api>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,21 @@
|
|||
<cn>
|
||||
#### 向上展开
|
||||
向上展开建议。
|
||||
</cn>
|
||||
|
||||
<us>
|
||||
#### Placemen
|
||||
Change the suggestions placement.
|
||||
</us>
|
||||
|
||||
```tpl
|
||||
<template>
|
||||
<a-mentions
|
||||
placement="top"
|
||||
>
|
||||
<a-mentions-option value="afc163">afc163</a-mentions-option>
|
||||
<a-mentions-option value="zombieJ">zombieJ</a-mentions-option>
|
||||
<a-mentions-option value="yesmeck">yesmeck</a-mentions-option>
|
||||
</a-mentions>
|
||||
</template>
|
||||
```
|
|
@ -0,0 +1,48 @@
|
|||
<cn>
|
||||
#### 自定义触发字符
|
||||
通过 prefix 属性自定义触发字符。默认为 @, 可以定义为数组。
|
||||
</cn>
|
||||
|
||||
<us>
|
||||
#### Customize Trigger Token
|
||||
Customize Trigger Token by `prefix` props. Default to `@`, `Array<string>` also supported.
|
||||
</us>
|
||||
|
||||
```tpl
|
||||
<template>
|
||||
<a-mentions
|
||||
placeholder="input @ to mention people, # to mention tag"
|
||||
:prefix="['@', '#']"
|
||||
@search="onSearch"
|
||||
>
|
||||
<a-mentions-option
|
||||
v-for="value in (MOCK_DATA[prefix] || [])"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{value}}
|
||||
</a-mentions-option>
|
||||
</a-mentions>
|
||||
</template>
|
||||
<script>
|
||||
const MOCK_DATA = {
|
||||
'@': ['afc163', 'zombiej', 'yesmeck'],
|
||||
'#': ['1.0', '2.0', '3.0'],
|
||||
};
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
prefix: '@',
|
||||
MOCK_DATA,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSearch(_, prefix) {
|
||||
console.log(_, prefix)
|
||||
this.prefix = prefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
|
@ -0,0 +1,41 @@
|
|||
<cn>
|
||||
#### 无效或只读
|
||||
通过 `disabled` 属性设置是否生效。通过 `readOnly` 属性设置是否只读。
|
||||
</cn>
|
||||
|
||||
<us>
|
||||
#### disabled or readOnly
|
||||
Configurate disabled and readOnly.
|
||||
</us>
|
||||
|
||||
```tpl
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom: 10px">
|
||||
<a-mentions placeholder="this is disabled Mentions" disabled>
|
||||
<a-mentions-option
|
||||
v-for="value in options"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>{{value}}</a-mentions-option>
|
||||
</a-mentions>
|
||||
</div>
|
||||
<a-mentions placeholder="this is readOnly a-mentions" readOnly>
|
||||
<a-mentions-option
|
||||
v-for="value in options"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>{{value}}</a-mentions-option>
|
||||
</a-mentions>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
options: ['afc163', 'zombieJ', 'yesmeck']
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
|
@ -0,0 +1,39 @@
|
|||
## API
|
||||
|
||||
### Mention
|
||||
|
||||
| Property | Description | Type | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| autoFocus | Auto get focus when component mounted | boolean | `false` |
|
||||
| defaultValue | Default value | string | |
|
||||
| filterOption | Customize filter option logic | false \| (input: string, option: OptionProps) => boolean | |
|
||||
| notFoundContent | Set mentions content when not match | ReactNode | 'Not Found' |
|
||||
| placement | Set popup placement | `top` \| `bottom` | `bottom` |
|
||||
| prefix | Set trigger prefix keyword | string \| string[] | '@' |
|
||||
| split | Set split string before and after selected mention | string | ' ' |
|
||||
| validateSearch | Customize trigger search logic | (text: string, props: MentionsProps) => void | |
|
||||
| value(v-model) | Set value of mentions | string | |
|
||||
| getPopupContainer | Set the mount HTML node for suggestions | () => HTMLElement | |
|
||||
|
||||
### Events
|
||||
|
||||
| Events Name | Description | Arguments |
|
||||
| --- | --- | --- |
|
||||
| blur | remove focus | function |
|
||||
| change | Trigger when value changed | function(value: string) |
|
||||
| focus | get focus | function |
|
||||
| search | Trigger when prefix hit | function(value: string, prefix: string) |
|
||||
| select | Trigger when user select the option | function(option: OptionProps, prefix: string) |
|
||||
|
||||
### Mention methods
|
||||
|
||||
| Name | Description |
|
||||
| ------- | ------------ |
|
||||
| blur() | remove focus |
|
||||
| focus() | get focus |
|
||||
|
||||
### Option
|
||||
|
||||
| Property | Description | Type | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| value | value of suggestion, the value will insert into input filed while selected | string | '' |
|
|
@ -0,0 +1,193 @@
|
|||
import classNames from 'classnames';
|
||||
import omit from 'omit.js';
|
||||
import PropTypes from '../_util/vue-types';
|
||||
import VcMentions from '../vc-mentions';
|
||||
import { mentionsProps } 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,
|
||||
getComponentFromProp,
|
||||
getListeners,
|
||||
filterEmpty,
|
||||
} 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],
|
||||
inheritAttrs: false,
|
||||
model: {
|
||||
prop: 'value',
|
||||
event: 'change',
|
||||
},
|
||||
Option: { ...Option, name: 'AMentionsOption' },
|
||||
getMentions,
|
||||
props: {
|
||||
...mentionsProps,
|
||||
loading: PropTypes.bool,
|
||||
},
|
||||
inject: {
|
||||
configProvider: { default: () => ConfigConsumerProps },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
focused: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
if (this.autoFocus) {
|
||||
this.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
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,
|
||||
});
|
||||
},
|
||||
onChange(val) {
|
||||
this.$emit('change', val);
|
||||
},
|
||||
getNotFoundContent(renderEmpty) {
|
||||
const h = this.$createElement;
|
||||
const notFoundContent = getComponentFromProp(this, 'notFoundContent');
|
||||
if (notFoundContent !== undefined) {
|
||||
return notFoundContent;
|
||||
}
|
||||
|
||||
return renderEmpty(h, 'Select');
|
||||
},
|
||||
getOptions() {
|
||||
const { loading } = this.$props;
|
||||
const children = filterEmpty(this.$slots.default || []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Option value="ANTD_SEARCHING" disabled>
|
||||
<Spin size="small" />
|
||||
</Option>
|
||||
);
|
||||
}
|
||||
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,
|
||||
getPopupContainer: 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: { rows: 1, ...this.$attrs },
|
||||
on: {
|
||||
...getListeners(this),
|
||||
change: this.onChange,
|
||||
select: this.onSelect,
|
||||
focus: this.onFocus,
|
||||
blur: this.onBlur,
|
||||
},
|
||||
ref: 'vcMentions',
|
||||
};
|
||||
|
||||
return <VcMentions {...mentionsProps} />;
|
||||
},
|
||||
};
|
||||
|
||||
/* 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;
|
|
@ -0,0 +1,39 @@
|
|||
## API
|
||||
|
||||
### Mentions
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
| --- | --- | --- | --- |
|
||||
| autoFocus | 自动获得焦点 | boolean | `false` |
|
||||
| defaultValue | 默认值 | string | |
|
||||
| filterOption | 自定义过滤逻辑 | false \| (input: string, option: OptionProps) => boolean | |
|
||||
| notFoundContent | 当下拉列表为空时显示的内容 | ReactNode | 'Not Found' |
|
||||
| placement | 弹出层展示位置 | `top` \| `bottom` | `bottom` |
|
||||
| prefix | 设置触发关键字 | string \| string[] | '@' |
|
||||
| split | 设置选中项前后分隔符 | string | ' ' |
|
||||
| validateSearch | 自定义触发验证逻辑 | (text: string, props: MentionsProps) => void | |
|
||||
| value(v-model) | 设置值 | string | |
|
||||
| getPopupContainer | 指定建议框挂载的 HTML 节点 | () => HTMLElement | |
|
||||
|
||||
### 事件
|
||||
|
||||
| 事件名称 | 说明 | 回调参数 |
|
||||
| -------- | ------------------ | --------------------------------------------- |
|
||||
| blur | 失去焦点的时回调 | function |
|
||||
| change | 值改变时触发 | function(value: string) |
|
||||
| focus | 获得焦点时回调 | function |
|
||||
| search | 文本框值变化时回调 | function(value: string, prefix: string) |
|
||||
| select | 选择选项时触发 | function(option: OptionProps, prefix: string) |
|
||||
|
||||
### Mentions 方法
|
||||
|
||||
| 名称 | 描述 |
|
||||
| ------- | -------- |
|
||||
| blur() | 移除焦点 |
|
||||
| focus() | 获取焦点 |
|
||||
|
||||
### Option
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
| ----- | -------------- | ------ | ------ |
|
||||
| value | 选择时填充的值 | string | '' |
|
|
@ -0,0 +1,6 @@
|
|||
import '../../style/index.less';
|
||||
import './index.less';
|
||||
|
||||
// style dependencies
|
||||
import '../../empty/style';
|
||||
import '../../spin/style';
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -333,6 +334,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;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import Mentions from './src/Mentions';
|
||||
import Option from './src/Option';
|
||||
|
||||
Mentions.Option = Option;
|
||||
|
||||
export default Mentions;
|
|
@ -0,0 +1,62 @@
|
|||
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 (
|
||||
<Menu
|
||||
{...{
|
||||
props: {
|
||||
prefixCls: `${prefixCls}-menu`,
|
||||
activeKey: activeOption.value,
|
||||
},
|
||||
on: {
|
||||
select: ({ key }) => {
|
||||
const option = options.find(({ value }) => value === key);
|
||||
selectOption(option);
|
||||
},
|
||||
focus: onFocus,
|
||||
blur: onBlur,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const { value, disabled, children } = option;
|
||||
return (
|
||||
<MenuItem
|
||||
key={value}
|
||||
disabled={disabled}
|
||||
onMouseenter={() => {
|
||||
setActiveIndex(index);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{!options.length && <MenuItem disabled>{notFoundContent}</MenuItem>}
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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 <DropdownMenu prefixCls={this.getDropdownPrefix()} options={options} />;
|
||||
},
|
||||
},
|
||||
|
||||
render() {
|
||||
const { visible, placement, transitionName, getPopupContainer } = this.$props;
|
||||
|
||||
const { $slots } = this;
|
||||
|
||||
const children = $slots.default;
|
||||
|
||||
const popupElement = this.getDropdownElement();
|
||||
|
||||
return (
|
||||
<Trigger
|
||||
prefixCls={this.getDropdownPrefix()}
|
||||
popupVisible={visible}
|
||||
popup={popupElement}
|
||||
popupPlacement={placement === 'top' ? 'topRight' : 'bottomRight'}
|
||||
popupTransitionName={transitionName}
|
||||
builtinPlacements={BUILT_IN_PLACEMENTS}
|
||||
getPopupContainer={getPopupContainer}
|
||||
>
|
||||
{children}
|
||||
</Trigger>
|
||||
);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,322 @@
|
|||
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],
|
||||
inheritAttrs: false,
|
||||
model: {
|
||||
prop: 'value',
|
||||
event: 'change',
|
||||
},
|
||||
props: initDefaultProps(vcMentionsProps, defaultProps),
|
||||
provide() {
|
||||
return {
|
||||
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 } }) {
|
||||
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 (
|
||||
<div class={prefixCls}>
|
||||
<textarea
|
||||
ref="textarea"
|
||||
{...{
|
||||
attrs: { ...inputProps, ...this.$attrs },
|
||||
domProps: {
|
||||
value,
|
||||
},
|
||||
on: {
|
||||
...getListeners(this),
|
||||
select: noop,
|
||||
change: noop,
|
||||
input: this.onChange,
|
||||
keydown: this.onKeyDown,
|
||||
keyup: this.onKeyUp,
|
||||
blur: this.onInputBlur,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{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 Mentions;
|
|
@ -0,0 +1,15 @@
|
|||
import PropTypes from '../../_util/vue-types';
|
||||
|
||||
export const OptionProps = {
|
||||
value: PropTypes.string,
|
||||
disabled: PropTypes.boolean,
|
||||
children: PropTypes.any,
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'Option',
|
||||
props: OptionProps,
|
||||
render() {
|
||||
return null;
|
||||
},
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
import PropTypes from '../../_util/vue-types';
|
||||
import { initDefaultProps } from '../../_util/props-util';
|
||||
import {
|
||||
filterOption as defaultFilterOption,
|
||||
validateSearch as defaultValidateSearch,
|
||||
} from './util';
|
||||
import { PlaceMent } from './placement';
|
||||
|
||||
export const mentionsProps = {
|
||||
autoFocus: PropTypes.bool,
|
||||
prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
|
||||
prefixCls: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
defaultValue: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
notFoundContent: PropTypes.any,
|
||||
split: PropTypes.string,
|
||||
transitionName: PropTypes.string,
|
||||
placement: PropTypes.oneOf(PlaceMent),
|
||||
character: PropTypes.any,
|
||||
characterRender: PropTypes.func,
|
||||
filterOption: PropTypes.func,
|
||||
validateSearch: PropTypes.func,
|
||||
getPopupContainer: PropTypes.func,
|
||||
};
|
||||
|
||||
export const vcMentionsProps = {
|
||||
...mentionsProps,
|
||||
children: PropTypes.any,
|
||||
};
|
||||
|
||||
export const defaultProps = {
|
||||
prefix: '@',
|
||||
split: ' ',
|
||||
validateSearch: defaultValidateSearch,
|
||||
filterOption: defaultFilterOption,
|
||||
};
|
||||
|
||||
export default initDefaultProps(vcMentionsProps, defaultProps);
|
|
@ -0,0 +1 @@
|
|||
export const PlaceMent = ['top', 'bottom'];
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Cut input selection into 2 part and return text before selection start
|
||||
*/
|
||||
export function getBeforeSelectionText(input) {
|
||||
const { selectionStart } = input;
|
||||
return input.value.slice(0, selectionStart);
|
||||
}
|
||||
|
||||
function lower(char) {
|
||||
return (char || '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last match prefix index
|
||||
*/
|
||||
export function getLastMeasureIndex(text, prefix = '') {
|
||||
const prefixList = Array.isArray(prefix) ? prefix : [prefix];
|
||||
return prefixList.reduce(
|
||||
(lastMatch, prefixStr) => {
|
||||
const lastIndex = text.lastIndexOf(prefixStr);
|
||||
if (lastIndex > lastMatch.location) {
|
||||
return {
|
||||
location: lastIndex,
|
||||
prefix: prefixStr,
|
||||
};
|
||||
}
|
||||
return lastMatch;
|
||||
},
|
||||
{ location: -1, prefix: '' },
|
||||
);
|
||||
}
|
||||
|
||||
function reduceText(text, targetText, split) {
|
||||
const firstChar = text[0];
|
||||
if (!firstChar || firstChar === split) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Reuse rest text as it can
|
||||
let restText = text;
|
||||
const targetTextLen = targetText.length;
|
||||
for (let i = 0; i < targetTextLen; i += 1) {
|
||||
if (lower(restText[i]) !== lower(targetText[i])) {
|
||||
restText = restText.slice(i);
|
||||
break;
|
||||
} else if (i === targetTextLen - 1) {
|
||||
restText = restText.slice(targetTextLen);
|
||||
}
|
||||
}
|
||||
|
||||
return restText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint targetText into current text:
|
||||
* text: little@litest
|
||||
* targetText: light
|
||||
* => little @light test
|
||||
*/
|
||||
export function replaceWithMeasure(text, measureConfig) {
|
||||
const { measureLocation, prefix, targetText, selectionStart, split } = measureConfig;
|
||||
|
||||
// Before text will append one space if have other text
|
||||
let beforeMeasureText = text.slice(0, measureLocation);
|
||||
if (beforeMeasureText[beforeMeasureText.length - split.length] === split) {
|
||||
beforeMeasureText = beforeMeasureText.slice(0, beforeMeasureText.length - split.length);
|
||||
}
|
||||
if (beforeMeasureText) {
|
||||
beforeMeasureText = `${beforeMeasureText}${split}`;
|
||||
}
|
||||
|
||||
// Cut duplicate string with current targetText
|
||||
let restText = reduceText(
|
||||
text.slice(selectionStart),
|
||||
targetText.slice(selectionStart - measureLocation - prefix.length),
|
||||
split,
|
||||
);
|
||||
if (restText.slice(0, split.length) === split) {
|
||||
restText = restText.slice(split.length);
|
||||
}
|
||||
|
||||
const connectedStartText = `${beforeMeasureText}${prefix}${targetText}${split}`;
|
||||
|
||||
return {
|
||||
text: `${connectedStartText}${restText}`,
|
||||
selectionLocation: connectedStartText.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function setInputSelection(input, location) {
|
||||
input.setSelectionRange(location, location);
|
||||
|
||||
/**
|
||||
* Reset caret into view.
|
||||
* Since this function always called by user control, it's safe to focus element.
|
||||
*/
|
||||
input.blur();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
export function validateSearch(text = '', props = {}) {
|
||||
const { split } = props;
|
||||
return !split || text.indexOf(split) === -1;
|
||||
}
|
||||
|
||||
export function filterOption(input = '', { value = '' } = {}) {
|
||||
const lowerCase = input.toLowerCase();
|
||||
return value.toLowerCase().indexOf(lowerCase) !== -1;
|
||||
}
|
|
@ -28,6 +28,7 @@ import {
|
|||
LocaleProvider,
|
||||
message,
|
||||
Menu,
|
||||
Mentions,
|
||||
Modal,
|
||||
notification,
|
||||
Pagination,
|
||||
|
@ -105,6 +106,7 @@ Vue.use(Layout);
|
|||
Vue.use(List);
|
||||
Vue.use(LocaleProvider);
|
||||
Vue.use(Menu);
|
||||
Vue.use(Mentions);
|
||||
Vue.use(Modal);
|
||||
Vue.use(Pagination);
|
||||
Vue.use(Popconfirm);
|
||||
|
|
|
@ -73,6 +73,12 @@ export default {
|
|||
type: 'Data Entry',
|
||||
title: 'Input',
|
||||
},
|
||||
mentions: {
|
||||
category: 'Components',
|
||||
subtitle: '提及',
|
||||
type: 'Data Entry',
|
||||
title: 'Mentions',
|
||||
},
|
||||
select: {
|
||||
category: 'Components',
|
||||
subtitle: '选择器',
|
||||
|
|
|
@ -79,6 +79,14 @@ export default [
|
|||
path: 'input-cn',
|
||||
component: () => import('../components/input/demo/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'mentions-cn',
|
||||
component: () => import('../components/mentions/demo/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'mentions-cn',
|
||||
component: () => import('../components/mentions/demo/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'select',
|
||||
component: () => import('../components/select/demo/index.vue'),
|
||||
|
|
|
@ -34,6 +34,7 @@ Array [
|
|||
"List",
|
||||
"LocaleProvider",
|
||||
"Menu",
|
||||
"Mentions",
|
||||
"Modal",
|
||||
"Pagination",
|
||||
"Popconfirm",
|
||||
|
|
Loading…
Reference in New Issue