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 code
pull/1845/head
Amour1688 2020-03-04 11:34:15 +08:00 committed by GitHub
parent a04b35f242
commit 16e075502e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1583 additions and 0 deletions

View File

@ -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,

View File

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

View File

@ -0,0 +1,3 @@
import demoTest from '../../../tests/shared/demoTest';
demoTest('mentions');

View File

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

View File

@ -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>
```

View File

@ -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>
```

View File

@ -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>
```

View File

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

View File

@ -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>
```

View File

@ -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>
```

View File

@ -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>
```

View File

@ -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 | '' |

View File

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

View File

@ -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 | '' |

View File

@ -0,0 +1,6 @@
import '../../style/index.less';
import './index.less';
// style dependencies
import '../../empty/style';
import '../../spin/style';

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import Mentions from './src/Mentions';
import Option from './src/Option';
Mentions.Option = Option;
export default Mentions;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export const PlaceMent = ['top', 'bottom'];

View File

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

View File

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

View File

@ -73,6 +73,12 @@ export default {
type: 'Data Entry',
title: 'Input',
},
mentions: {
category: 'Components',
subtitle: '提及',
type: 'Data Entry',
title: 'Mentions',
},
select: {
category: 'Components',
subtitle: '选择器',

View File

@ -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'),

View File

@ -34,6 +34,7 @@ Array [
"List",
"LocaleProvider",
"Menu",
"Mentions",
"Modal",
"Pagination",
"Popconfirm",