mirror of
https://github.com/tusen-ai/naive-ui.git
synced 2025-04-12 14:40:47 +08:00
feat(mention)
This commit is contained in:
parent
7ff45e670d
commit
8fbd1526e7
@ -4,6 +4,7 @@
|
||||
|
||||
### Feats
|
||||
|
||||
- Add `n-mention` component.
|
||||
- `n-data-table` supports expanding rows.
|
||||
|
||||
### Fixes
|
||||
|
@ -4,6 +4,7 @@
|
||||
|
||||
### Feats
|
||||
|
||||
- 新增 `n-mention` 组件
|
||||
- `n-data-table` 支持行展开
|
||||
|
||||
### Fixes
|
||||
|
@ -367,6 +367,10 @@ export const enComponentRoutes = [
|
||||
path: 'n-ellipsis',
|
||||
component: () => import('../../src/ellipsis/demos/enUS/index.demo-entry.md')
|
||||
},
|
||||
{
|
||||
path: 'n-mention',
|
||||
component: () => import('../../src/mention/demos/enUS/index.demo-entry.md')
|
||||
},
|
||||
// deprecated
|
||||
{
|
||||
path: 'n-nimbus-service-layout',
|
||||
@ -674,6 +678,10 @@ export const zhComponentRoutes = [
|
||||
path: 'n-ellipsis',
|
||||
component: () => import('../../src/ellipsis/demos/zhCN/index.demo-entry.md')
|
||||
},
|
||||
{
|
||||
path: 'n-mention',
|
||||
component: () => import('../../src/mention/demos/zhCN/index.demo-entry.md')
|
||||
},
|
||||
// deprecated
|
||||
{
|
||||
path: 'n-nimbus-service-layout',
|
||||
|
@ -248,6 +248,12 @@ export function createComponentMenuOptions ({ lang, theme, mode }) {
|
||||
enSuffix: true,
|
||||
path: '/n-input-number'
|
||||
},
|
||||
{
|
||||
en: 'Mention',
|
||||
zh: '提及',
|
||||
enSuffix: true,
|
||||
path: '/n-mention'
|
||||
},
|
||||
{
|
||||
en: 'Radio',
|
||||
zh: '单选',
|
||||
|
@ -114,6 +114,7 @@
|
||||
"highlight.js": "^10.4.1",
|
||||
"lodash-es": "^4.17.15",
|
||||
"seemly": "^0.1.18",
|
||||
"textarea-caret-ts": "^4.1.1",
|
||||
"treemate": "^0.2.4",
|
||||
"vdirs": "^0.1.0",
|
||||
"vfonts": "^0.1.0",
|
||||
|
@ -37,6 +37,7 @@ export * from './list'
|
||||
export * from './loading-bar'
|
||||
export * from './log'
|
||||
export * from './menu'
|
||||
export * from './mention'
|
||||
export * from './message'
|
||||
export * from './modal'
|
||||
export * from './notification'
|
||||
|
@ -36,6 +36,7 @@ import type { ListTheme } from '../../list/styles'
|
||||
import type { LoadingBarTheme } from '../../loading-bar/styles'
|
||||
import type { LogTheme } from '../../log/styles'
|
||||
import type { MenuTheme } from '../../menu/styles'
|
||||
import type { MentionTheme } from '../../mention/styles'
|
||||
import type { MessageTheme } from '../../message/styles'
|
||||
import type { ModalTheme } from '../../modal/styles'
|
||||
import type { NotificationTheme } from '../../notification/styles'
|
||||
@ -113,6 +114,7 @@ interface GlobalThemeWithoutCommon {
|
||||
LoadingBar?: LoadingBarTheme
|
||||
Log?: LogTheme
|
||||
Menu?: MenuTheme
|
||||
Mention?: MentionTheme
|
||||
Message?: MessageTheme
|
||||
Modal?: ModalTheme
|
||||
Notification?: NotificationTheme
|
||||
|
45
src/mention/demos/enUS/async.demo.md
Normal file
45
src/mention/demos/enUS/async.demo.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Load Remote Options
|
||||
|
||||
Load options async.
|
||||
|
||||
```html
|
||||
<n-mention
|
||||
:options="options"
|
||||
default-value="@"
|
||||
@search="handleSearch"
|
||||
:loading="loading"
|
||||
/>
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
const optionsRef = ref([])
|
||||
const loadingRef = ref(false)
|
||||
let searchTimerId = null
|
||||
return {
|
||||
options: optionsRef,
|
||||
loading: loadingRef,
|
||||
handleSearch (pattern, prefix) {
|
||||
if (searchTimerId !== null) clearTimeout(searchTimerId)
|
||||
console.log(pattern, prefix)
|
||||
loadingRef.value = true
|
||||
searchTimerId = setTimeout(() => {
|
||||
optionsRef.value = [
|
||||
'它烫不了你的舌',
|
||||
'也烧不了你的口',
|
||||
'喝醉吧',
|
||||
'不要回头'
|
||||
].map((v) => ({
|
||||
label: pattern + v,
|
||||
value: pattern + v
|
||||
}))
|
||||
loadingRef.value = false
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
34
src/mention/demos/enUS/autosize.demo.md
Normal file
34
src/mention/demos/enUS/autosize.demo.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Autosize
|
||||
|
||||
```html
|
||||
<n-mention type="textarea" :options="options" autosize />
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
return {
|
||||
options: [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
},
|
||||
{
|
||||
label: 'Guandong-Road',
|
||||
value: 'Guandong-Road'
|
||||
},
|
||||
{
|
||||
label: 'No.5-Yiheyuan-Road',
|
||||
value: 'No.5-Yiheyuan-Road'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
34
src/mention/demos/enUS/basic.demo.md
Normal file
34
src/mention/demos/enUS/basic.demo.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Basic Usage
|
||||
|
||||
```html
|
||||
<n-mention :options="options" default-value="@" />
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
return {
|
||||
options: [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
},
|
||||
{
|
||||
label: 'Guandong-Road',
|
||||
value: 'Guandong-Road'
|
||||
},
|
||||
{
|
||||
label: 'No.5-Yiheyuan-Road',
|
||||
value: 'No.5-Yiheyuan-Road'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
75
src/mention/demos/enUS/custom-prefix.demo.md
Normal file
75
src/mention/demos/enUS/custom-prefix.demo.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Custom Trigger Prefix
|
||||
|
||||
Use `prefix` to set trigger char.
|
||||
|
||||
```html
|
||||
<n-mention :options="options" :prefix="['@', '#']" @search="handleSearch" />
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
const atOptions = [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
},
|
||||
{
|
||||
label: 'Guandong-Road',
|
||||
value: 'Guandong-Road'
|
||||
},
|
||||
{
|
||||
label: 'No.5-Yiheyuan-Road',
|
||||
value: 'No.5-Yiheyuan-Road'
|
||||
}
|
||||
]
|
||||
const sharpOptions = [
|
||||
{
|
||||
label: 'We',
|
||||
value: 'We'
|
||||
},
|
||||
{
|
||||
label: 'all',
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
label: 'live',
|
||||
value: 'live'
|
||||
},
|
||||
{
|
||||
label: 'in',
|
||||
value: 'in'
|
||||
},
|
||||
{
|
||||
label: 'a',
|
||||
value: 'a'
|
||||
},
|
||||
{
|
||||
label: 'yellow',
|
||||
value: 'yellow'
|
||||
},
|
||||
{
|
||||
label: 'submarine',
|
||||
value: 'submarine'
|
||||
}
|
||||
]
|
||||
const optionsRef = ref([])
|
||||
return {
|
||||
options: optionsRef,
|
||||
handleSearch (_, prefix) {
|
||||
if (prefix === '@') {
|
||||
optionsRef.value = atOptions
|
||||
} else {
|
||||
optionsRef.value = sharpOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
66
src/mention/demos/enUS/form.demo.md
Normal file
66
src/mention/demos/enUS/form.demo.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Work with Form
|
||||
|
||||
```html
|
||||
<n-space vertical>
|
||||
<n-form :model="formModel" :rules="rules" ref="formInstRef">
|
||||
<n-form-item label="Cool" path="cool">
|
||||
<n-mention :options="options" v-model:value="formModel.cool" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Very Cool" path="veryCool">
|
||||
<n-mention
|
||||
type="textarea"
|
||||
:options="options"
|
||||
v-model:value="formModel.veryCool"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button @click="handleButtonClick">Validate</n-button>
|
||||
</n-space>
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
const formInstRef = ref(null)
|
||||
const formModelRef = ref({
|
||||
cool: '',
|
||||
veryCool: ''
|
||||
})
|
||||
const rules = {
|
||||
cool: {
|
||||
trigger: ['input', 'blur'],
|
||||
required: true,
|
||||
message: 'Cool is required'
|
||||
},
|
||||
veryCool: {
|
||||
trigger: ['input', 'blur'],
|
||||
validator () {
|
||||
if (!formModelRef.value.veryCool.includes('@07akioni')) {
|
||||
return Error('07akioni should be very cool!')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
formModel: formModelRef,
|
||||
formInstRef,
|
||||
rules,
|
||||
options: [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
}
|
||||
],
|
||||
handleButtonClick () {
|
||||
formInstRef.value.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
49
src/mention/demos/enUS/index.demo-entry.md
Normal file
49
src/mention/demos/enUS/index.demo-entry.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Mention
|
||||
|
||||
A year ago, product manager ask me if I could implement the feature. At that time, I told them to use multiple select as a workaround.
|
||||
|
||||
## Demos
|
||||
|
||||
```demo
|
||||
basic
|
||||
textarea
|
||||
async
|
||||
autosize
|
||||
form
|
||||
custom-prefix
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
Mention is provided after `v2.2.0`.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| autosize | `boolean \| { maxRows?: number, minRows?: number }` | `false` | |
|
||||
| options | `MentionOption[]` | `[]` | |
|
||||
| type | `'input' \| 'textarea'` | `'input'` | |
|
||||
| separator | `string` | `' '` | Char to split mentions whose length must be 1. |
|
||||
| bordered | `boolean` | `true` | |
|
||||
| disabled | `boolean` | `false` | |
|
||||
| value | `string \| null` | `undefined` | |
|
||||
| default-value | `string` | `''` | |
|
||||
| loading | `boolean` | `false` | |
|
||||
| prefix | `string \| string[]` | `'@'` | Prefix char to trigger mentions whose length must be 1. |
|
||||
| placeholder | `string` | `''` | |
|
||||
| size | `'small' \| 'medium' \| 'large'` | `'medium'` | |
|
||||
| on-update:value | `(value: string) => void` | `undefined` | |
|
||||
| on-select | `(option: MentionOption, prefix: string) => void` | `undefined` | |
|
||||
| on-focus | `(e: FocusEvent) => void` | `undefined` | |
|
||||
| on-search | `(pattern: string, prefix: string) => void` | `undefined` | |
|
||||
| on-blur | `(e: FocusEvent) => void` | `undefined` | |
|
||||
|
||||
### MentionOption Properties
|
||||
|
||||
| Name | Type | Description |
|
||||
| -------- | --------------------------------------- | ---------------- |
|
||||
| class | `string` | |
|
||||
| disabled | `boolean` | |
|
||||
| label | `string` | |
|
||||
| render | `(option: MentionOption) => VNodeChild` | |
|
||||
| style | `string` | |
|
||||
| value | `string` | Should be unique |
|
36
src/mention/demos/enUS/textarea.demo.md
Normal file
36
src/mention/demos/enUS/textarea.demo.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Textarea
|
||||
|
||||
Set `type` to `'textarea'`.
|
||||
|
||||
```html
|
||||
<n-mention type="textarea" :options="options" />
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
return {
|
||||
options: [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
},
|
||||
{
|
||||
label: 'Guandong-Road',
|
||||
value: 'Guandong-Road'
|
||||
},
|
||||
{
|
||||
label: 'No.5-Yiheyuan-Road',
|
||||
value: 'No.5-Yiheyuan-Road'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
45
src/mention/demos/zhCN/async.demo.md
Normal file
45
src/mention/demos/zhCN/async.demo.md
Normal file
@ -0,0 +1,45 @@
|
||||
# 远程加载
|
||||
|
||||
异步加载选项。
|
||||
|
||||
```html
|
||||
<n-mention
|
||||
:options="options"
|
||||
default-value="@"
|
||||
@search="handleSearch"
|
||||
:loading="loading"
|
||||
/>
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
const optionsRef = ref([])
|
||||
const loadingRef = ref(false)
|
||||
let searchTimerId = null
|
||||
return {
|
||||
options: optionsRef,
|
||||
loading: loadingRef,
|
||||
handleSearch (pattern, prefix) {
|
||||
if (searchTimerId !== null) clearTimeout(searchTimerId)
|
||||
console.log(pattern, prefix)
|
||||
loadingRef.value = true
|
||||
searchTimerId = setTimeout(() => {
|
||||
optionsRef.value = [
|
||||
'它烫不了你的舌',
|
||||
'也烧不了你的口',
|
||||
'喝醉吧',
|
||||
'不要回头'
|
||||
].map((v) => ({
|
||||
label: pattern + v,
|
||||
value: pattern + v
|
||||
}))
|
||||
loadingRef.value = false
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
34
src/mention/demos/zhCN/autosize.demo.md
Normal file
34
src/mention/demos/zhCN/autosize.demo.md
Normal file
@ -0,0 +1,34 @@
|
||||
# 自动换行
|
||||
|
||||
```html
|
||||
<n-mention type="textarea" :options="options" autosize />
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
return {
|
||||
options: [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
},
|
||||
{
|
||||
label: '广东路',
|
||||
value: '广东路'
|
||||
},
|
||||
{
|
||||
label: '颐和园路5号',
|
||||
value: '颐和园路5号'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
34
src/mention/demos/zhCN/basic.demo.md
Normal file
34
src/mention/demos/zhCN/basic.demo.md
Normal file
@ -0,0 +1,34 @@
|
||||
# 基本用法
|
||||
|
||||
```html
|
||||
<n-mention :options="options" default-value="@" />
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
return {
|
||||
options: [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
},
|
||||
{
|
||||
label: '广东路',
|
||||
value: '广东路'
|
||||
},
|
||||
{
|
||||
label: '颐和园路5号',
|
||||
value: '颐和园路5号'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
63
src/mention/demos/zhCN/custom-prefix.demo.md
Normal file
63
src/mention/demos/zhCN/custom-prefix.demo.md
Normal file
@ -0,0 +1,63 @@
|
||||
# 自定义触发字符
|
||||
|
||||
使用 `prefix` 设定触发字符。
|
||||
|
||||
```html
|
||||
<n-mention :options="options" :prefix="['@', '#']" @search="handleSearch" />
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
const atOptions = [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
},
|
||||
{
|
||||
label: '广东路',
|
||||
value: '广东路'
|
||||
},
|
||||
{
|
||||
label: '颐和园路5号',
|
||||
value: '颐和园路5号'
|
||||
}
|
||||
]
|
||||
const sharpOptions = [
|
||||
{
|
||||
label: '它烫不了你的舌',
|
||||
value: '它烫不了你的舌'
|
||||
},
|
||||
{
|
||||
label: '也烧不了你的口',
|
||||
value: '也烧不了你的口'
|
||||
},
|
||||
{
|
||||
label: '喝醉吧',
|
||||
value: '喝醉吧'
|
||||
},
|
||||
{
|
||||
label: '不要回头',
|
||||
value: '不要回头'
|
||||
}
|
||||
]
|
||||
const optionsRef = ref([])
|
||||
return {
|
||||
options: optionsRef,
|
||||
handleSearch (_, prefix) {
|
||||
if (prefix === '@') {
|
||||
optionsRef.value = atOptions
|
||||
} else {
|
||||
optionsRef.value = sharpOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
66
src/mention/demos/zhCN/form.demo.md
Normal file
66
src/mention/demos/zhCN/form.demo.md
Normal file
@ -0,0 +1,66 @@
|
||||
# 配合表单
|
||||
|
||||
```html
|
||||
<n-space vertical>
|
||||
<n-form :model="formModel" :rules="rules" ref="formInstRef">
|
||||
<n-form-item label="Cool" path="cool">
|
||||
<n-mention :options="options" v-model:value="formModel.cool" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Very Cool" path="veryCool">
|
||||
<n-mention
|
||||
type="textarea"
|
||||
:options="options"
|
||||
v-model:value="formModel.veryCool"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button @click="handleButtonClick">Validate</n-button>
|
||||
</n-space>
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
const formInstRef = ref(null)
|
||||
const formModelRef = ref({
|
||||
cool: '',
|
||||
veryCool: ''
|
||||
})
|
||||
const rules = {
|
||||
cool: {
|
||||
trigger: ['input', 'blur'],
|
||||
required: true,
|
||||
message: 'Cool is required'
|
||||
},
|
||||
veryCool: {
|
||||
trigger: ['input', 'blur'],
|
||||
validator () {
|
||||
if (!formModelRef.value.veryCool.includes('@07akioni')) {
|
||||
return Error('07akioni should be very cool!')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
formModel: formModelRef,
|
||||
formInstRef,
|
||||
rules,
|
||||
options: [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
}
|
||||
],
|
||||
handleButtonClick () {
|
||||
formInstRef.value.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
49
src/mention/demos/zhCN/index.demo-entry.md
Normal file
49
src/mention/demos/zhCN/index.demo-entry.md
Normal file
@ -0,0 +1,49 @@
|
||||
# 提及 Mention
|
||||
|
||||
一年多之前产品经理问我能不能搞这个东西,当时我让他们用多选凑活一下。
|
||||
|
||||
## 演示
|
||||
|
||||
```demo
|
||||
basic
|
||||
textarea
|
||||
async
|
||||
autosize
|
||||
form
|
||||
custom-prefix
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
Mention 在 `v2.2.0` 及以后可用。
|
||||
|
||||
| 名称 | 类型 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| autosize | `boolean \| { maxRows?: number, minRows?: number }` | `false` | |
|
||||
| options | `MentionOption[]` | `[]` | |
|
||||
| type | `'input' \| 'textarea'` | `'input'` | |
|
||||
| separator | `string` | `' '` | 切分提及使用的字符,长度必须为 1 |
|
||||
| bordered | `boolean` | `true` | |
|
||||
| disabled | `boolean` | `false` | |
|
||||
| value | `string \| null` | `undefined` | |
|
||||
| default-value | `string` | `''` | |
|
||||
| loading | `boolean` | `false` | |
|
||||
| prefix | `string \| string[]` | `'@'` | 触发提及的前缀,长度必须为 1 |
|
||||
| placeholder | `string` | `''` | |
|
||||
| size | `'small' \| 'medium' \| 'large'` | `'medium'` | |
|
||||
| on-update:value | `(value: string) => void` | `undefined` | |
|
||||
| on-select | `(option: MentionOption, prefix: string) => void` | `undefined` | |
|
||||
| on-focus | `(e: FocusEvent) => void` | `undefined` | |
|
||||
| on-search | `(pattern: string, prefix: string) => void` | `undefined` | |
|
||||
| on-blur | `(e: FocusEvent) => void` | `undefined` | |
|
||||
|
||||
### MentionOption Properties
|
||||
|
||||
| 名称 | 类型 | 说明 |
|
||||
| -------- | --------------------------------------- | -------------------- |
|
||||
| class | `string` | |
|
||||
| disabled | `boolean` | |
|
||||
| label | `string` | |
|
||||
| render | `(option: MentionOption) => VNodeChild` | |
|
||||
| style | `string` | |
|
||||
| value | `string` | 在选项中应该是唯一的 |
|
36
src/mention/demos/zhCN/textarea.demo.md
Normal file
36
src/mention/demos/zhCN/textarea.demo.md
Normal file
@ -0,0 +1,36 @@
|
||||
# 文本区域
|
||||
|
||||
将 `type` 设为 `'textarea'`。
|
||||
|
||||
```html
|
||||
<n-mention type="textarea" :options="options" />
|
||||
```
|
||||
|
||||
```js
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
return {
|
||||
options: [
|
||||
{
|
||||
label: '07akioni',
|
||||
value: '07akioni'
|
||||
},
|
||||
{
|
||||
label: 'star-kirby',
|
||||
value: 'star-kirby'
|
||||
},
|
||||
{
|
||||
label: '广东路',
|
||||
value: '广东路'
|
||||
},
|
||||
{
|
||||
label: '颐和园路5号',
|
||||
value: '颐和园路5号'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
1
src/mention/index.ts
Normal file
1
src/mention/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as NMention } from './src/Mention'
|
428
src/mention/src/Mention.tsx
Normal file
428
src/mention/src/Mention.tsx
Normal file
@ -0,0 +1,428 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
PropType,
|
||||
ref,
|
||||
toRef,
|
||||
nextTick,
|
||||
computed,
|
||||
Transition,
|
||||
CSSProperties
|
||||
} from 'vue'
|
||||
import { createTreeMate } from 'treemate'
|
||||
import { NInput } from '../../input'
|
||||
import type { InputRef } from '../../input'
|
||||
import type { InternalSelectMenuRef } from '../../_internal'
|
||||
import { NInternalSelectMenu } from '../../_internal'
|
||||
import { Caret } from 'textarea-caret-ts'
|
||||
import { VBinder, VFollower, VTarget, FollowerRef } from 'vueuc'
|
||||
import {
|
||||
SelectBaseOption,
|
||||
SelectGroupOption,
|
||||
SelectIgnoredOption
|
||||
} from '../../select'
|
||||
import { call, useAdjustedTo, warn } from '../../_utils'
|
||||
import type { MaybeArray } from '../../_utils'
|
||||
import { useIsMounted, useMergedState } from 'vooks'
|
||||
import { useConfig, useFormItem, useTheme } from '../../_mixins'
|
||||
import type { ThemeProps } from '../../_mixins'
|
||||
import { mentionLight } from '../styles'
|
||||
import type { MentionTheme } from '../styles'
|
||||
import style from './styles/index.cssr'
|
||||
import type { MentionOption } from './interface'
|
||||
import type { Size as InputSize } from '../../input/src/interface'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Mention',
|
||||
props: {
|
||||
...(useTheme.props as ThemeProps<MentionTheme>),
|
||||
autosize: [Boolean, Object] as PropType<
|
||||
boolean | { maxRows?: number, minRows?: number }
|
||||
>,
|
||||
options: {
|
||||
type: Array as PropType<MentionOption[]>,
|
||||
default: []
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<'input' | 'textarea'>,
|
||||
default: 'input'
|
||||
},
|
||||
separator: {
|
||||
type: String,
|
||||
validator: (separator: string) => {
|
||||
if (separator.length !== 1) {
|
||||
warn('mention', "`separator`'s length must be 1.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
default: ' '
|
||||
},
|
||||
to: [String, Object] as PropType<string | HTMLElement>,
|
||||
bordered: {
|
||||
type: Boolean as PropType<boolean | undefined>,
|
||||
default: undefined
|
||||
},
|
||||
disabled: Boolean,
|
||||
value: String as PropType<string | null>,
|
||||
defaultValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
loading: Boolean,
|
||||
prefix: {
|
||||
type: [String, Array] as PropType<string | string[]>,
|
||||
default: '@'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: String as PropType<InputSize>,
|
||||
'onUpdate:value': [Array, Function] as PropType<
|
||||
MaybeArray<(value: string) => void>
|
||||
>,
|
||||
onUpdateValue: [Array, Function] as PropType<
|
||||
MaybeArray<(value: string) => void>
|
||||
>,
|
||||
onSearch: Function as PropType<(pattern: string, prefix: string) => void>,
|
||||
onSelect: Function as PropType<
|
||||
(option: MentionOption, prefix: string) => void
|
||||
>,
|
||||
onFocus: Function as PropType<(e: FocusEvent) => void>,
|
||||
onBlur: Function as PropType<(e: FocusEvent) => void>,
|
||||
// private
|
||||
internalDebug: Boolean
|
||||
},
|
||||
setup (props) {
|
||||
const mergedTheme = useTheme(
|
||||
'Mention',
|
||||
'Mention',
|
||||
style,
|
||||
mentionLight,
|
||||
props
|
||||
)
|
||||
const formItem = useFormItem(props)
|
||||
const inputInstRef = ref<InputRef | null>(null)
|
||||
const cursorRef = ref<HTMLElement | null>(null)
|
||||
const followerRef = ref<FollowerRef | null>(null)
|
||||
const partialPatternRef = ref<string>('')
|
||||
let cachedPrefix: string | null = null
|
||||
// cached pattern end is for partial pattern
|
||||
// for example @abc|def
|
||||
// end is after `c`
|
||||
let cachedPartialPatternStart: number | null = null
|
||||
let cachedPartialPatternEnd: number | null = null
|
||||
const filteredOptionsRef = computed(() => {
|
||||
const { value: pattern } = partialPatternRef
|
||||
return props.options.filter((option) => {
|
||||
if (!pattern) return true
|
||||
return option.label.startsWith(pattern)
|
||||
})
|
||||
})
|
||||
const treeMateRef = computed(() => {
|
||||
return createTreeMate<
|
||||
SelectBaseOption,
|
||||
SelectGroupOption,
|
||||
SelectIgnoredOption
|
||||
>(filteredOptionsRef.value, {
|
||||
getKey: (v) => {
|
||||
return (v as any).value
|
||||
}
|
||||
})
|
||||
})
|
||||
const selectMenuInstRef = ref<InternalSelectMenuRef | null>(null)
|
||||
const showMenuRef = ref(false)
|
||||
const uncontrolledValueRef = ref(props.defaultValue)
|
||||
const controlledValueRef = toRef(props, 'value')
|
||||
const mergedValueRef = useMergedState(
|
||||
controlledValueRef,
|
||||
uncontrolledValueRef
|
||||
)
|
||||
function doUpdateShowMenu (show: boolean): void {
|
||||
if (props.disabled) return
|
||||
if (!show) {
|
||||
cachedPrefix = null
|
||||
cachedPartialPatternStart = null
|
||||
cachedPartialPatternEnd = null
|
||||
}
|
||||
showMenuRef.value = show
|
||||
}
|
||||
function doUpdateValue (value: string): void {
|
||||
const { onUpdateValue, 'onUpdate:value': _onUpdateValue } = props
|
||||
const { nTriggerFormChange, nTriggerFormInput } = formItem
|
||||
if (_onUpdateValue) {
|
||||
call(_onUpdateValue, value)
|
||||
}
|
||||
if (onUpdateValue) {
|
||||
call(onUpdateValue, value)
|
||||
}
|
||||
nTriggerFormInput()
|
||||
nTriggerFormChange()
|
||||
uncontrolledValueRef.value = value
|
||||
}
|
||||
function getInputEl (): HTMLInputElement | HTMLTextAreaElement {
|
||||
return props.type === 'input'
|
||||
? inputInstRef.value!.inputElRef!
|
||||
: inputInstRef.value!.textareaElRef!
|
||||
}
|
||||
function deriveShowMenu (): void {
|
||||
const inputEl = getInputEl()
|
||||
if (document.activeElement !== inputEl) {
|
||||
doUpdateShowMenu(false)
|
||||
return
|
||||
}
|
||||
const { selectionEnd } = inputEl
|
||||
if (selectionEnd === null) {
|
||||
doUpdateShowMenu(false)
|
||||
return
|
||||
}
|
||||
const inputValue = inputEl.value
|
||||
const { separator } = props
|
||||
const { prefix } = props
|
||||
const prefixArray = typeof prefix === 'string' ? [prefix] : prefix
|
||||
for (let i = selectionEnd - 1; i >= 0; --i) {
|
||||
const char = inputValue[i]
|
||||
if (char === separator || char === '\n' || char === '\r') {
|
||||
doUpdateShowMenu(false)
|
||||
return
|
||||
}
|
||||
if (prefixArray.includes(char)) {
|
||||
const partialPattern = inputValue.slice(i + 1, selectionEnd)
|
||||
doUpdateShowMenu(true)
|
||||
props.onSearch?.(partialPattern, char)
|
||||
partialPatternRef.value = partialPattern
|
||||
cachedPrefix = char
|
||||
cachedPartialPatternStart = i + 1
|
||||
cachedPartialPatternEnd = selectionEnd
|
||||
return
|
||||
}
|
||||
}
|
||||
doUpdateShowMenu(false)
|
||||
}
|
||||
function syncCursor (): void {
|
||||
const { value: cursorAnchor } = cursorRef
|
||||
if (!cursorAnchor) return
|
||||
const inputEl = getInputEl()
|
||||
const cursorPos: {
|
||||
left: number
|
||||
top: number
|
||||
height: number
|
||||
} = Caret.getRelativePosition(inputEl)
|
||||
if (props.type === 'textarea') {
|
||||
cursorPos.top -= inputEl.scrollTop
|
||||
}
|
||||
cursorPos.left += inputEl.parentElement!.offsetLeft
|
||||
cursorAnchor.style.left = `${cursorPos.left}px`
|
||||
cursorAnchor.style.top = `${cursorPos.top + cursorPos.height}px`
|
||||
}
|
||||
function syncPosition (): void {
|
||||
if (!showMenuRef.value) return
|
||||
followerRef.value?.syncPosition()
|
||||
}
|
||||
function handleInputUpdateValue (value: string): void {
|
||||
doUpdateValue(value)
|
||||
void nextTick().then(() => {
|
||||
// dom (input value) is updated
|
||||
deriveShowMenu()
|
||||
syncCursor()
|
||||
// menu is ready, we can sync menu position now
|
||||
void nextTick().then(syncPosition)
|
||||
})
|
||||
}
|
||||
function syncAfterCursorMove (): void {
|
||||
setTimeout(() => {
|
||||
syncCursor()
|
||||
deriveShowMenu()
|
||||
void nextTick().then(syncPosition)
|
||||
}, 0)
|
||||
}
|
||||
function handleInputKeyDown (e: KeyboardEvent): void {
|
||||
if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') {
|
||||
if (inputInstRef.value?.isCompositing) return
|
||||
syncAfterCursorMove()
|
||||
} else if (
|
||||
e.code === 'ArrowUp' ||
|
||||
e.code === 'ArrowDown' ||
|
||||
e.code === 'Enter'
|
||||
) {
|
||||
if (inputInstRef.value?.isCompositing) return
|
||||
const { value: selectMenuInst } = selectMenuInstRef
|
||||
if (showMenuRef.value) {
|
||||
if (selectMenuInst) {
|
||||
e.preventDefault()
|
||||
if (e.code === 'ArrowUp') {
|
||||
selectMenuInst.prev()
|
||||
} else if (e.code === 'ArrowDown') {
|
||||
selectMenuInst.next()
|
||||
} else {
|
||||
// Enter
|
||||
const option = selectMenuInst.getPendingOption()
|
||||
if (option) {
|
||||
handleSelect(option)
|
||||
} else {
|
||||
doUpdateShowMenu(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
syncAfterCursorMove()
|
||||
}
|
||||
}
|
||||
}
|
||||
function handleInputFocus (e: FocusEvent): void {
|
||||
const { onFocus } = props
|
||||
onFocus?.(e)
|
||||
const { nTriggerFormFocus } = formItem
|
||||
nTriggerFormFocus()
|
||||
syncAfterCursorMove()
|
||||
}
|
||||
function handleInputBlur (e: FocusEvent): void {
|
||||
const { onBlur } = props
|
||||
onBlur?.(e)
|
||||
const { nTriggerFormBlur } = formItem
|
||||
nTriggerFormBlur()
|
||||
doUpdateShowMenu(false)
|
||||
}
|
||||
function handleSelect (option: SelectBaseOption): void {
|
||||
if (
|
||||
cachedPrefix === null ||
|
||||
cachedPartialPatternStart === null ||
|
||||
cachedPartialPatternEnd === null
|
||||
) {
|
||||
if (__DEV__) {
|
||||
warn(
|
||||
'mention',
|
||||
'Cache works unexpectly, this is probably a bug. Please create an issue.'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
const { value } = option
|
||||
const inputEl = getInputEl()
|
||||
const inputValue = inputEl.value
|
||||
const { separator } = props
|
||||
const nextEndPart = inputValue.slice(cachedPartialPatternEnd)
|
||||
const alreadySeparated = nextEndPart.startsWith(separator)
|
||||
const nextMiddlePart = `${value}${alreadySeparated ? '' : separator}`
|
||||
doUpdateValue(
|
||||
inputValue.slice(0, cachedPartialPatternStart) +
|
||||
nextMiddlePart +
|
||||
nextEndPart
|
||||
)
|
||||
props.onSelect?.(option as MentionOption, cachedPrefix)
|
||||
const nextSelectionEnd =
|
||||
cachedPartialPatternStart +
|
||||
nextMiddlePart.length +
|
||||
(alreadySeparated ? 1 : 0)
|
||||
void nextTick().then(() => {
|
||||
// input value is updated
|
||||
inputEl.selectionStart = nextSelectionEnd
|
||||
inputEl.selectionEnd = nextSelectionEnd
|
||||
deriveShowMenu()
|
||||
})
|
||||
}
|
||||
return {
|
||||
...useConfig(props),
|
||||
mergedSize: formItem.mergedSize,
|
||||
mergedTheme,
|
||||
treeMate: treeMateRef,
|
||||
selectMenuInstRef,
|
||||
inputInstRef,
|
||||
cursorRef,
|
||||
followerRef,
|
||||
showMenu: showMenuRef,
|
||||
adjustedTo: useAdjustedTo(props),
|
||||
isMounted: useIsMounted(),
|
||||
mergedValue: mergedValueRef,
|
||||
handleInputFocus,
|
||||
handleInputBlur,
|
||||
handleInputUpdateValue,
|
||||
handleInputKeyDown,
|
||||
handleSelect,
|
||||
cssVars: computed(() => {
|
||||
const {
|
||||
self: { menuBoxShadow }
|
||||
} = mergedTheme.value
|
||||
return {
|
||||
'--menu-box-shadow': menuBoxShadow
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
render () {
|
||||
return (
|
||||
<div class="n-mention" style={{ position: 'relative' }}>
|
||||
<NInput
|
||||
size={this.mergedSize}
|
||||
autosize={this.autosize}
|
||||
type={this.type}
|
||||
ref="inputInstRef"
|
||||
placeholder={this.placeholder}
|
||||
onUpdateValue={this.handleInputUpdateValue}
|
||||
onKeydown={this.handleInputKeyDown}
|
||||
onFocus={this.handleInputFocus}
|
||||
onBlur={this.handleInputBlur}
|
||||
bordered={this.mergedBordered}
|
||||
disabled={this.disabled}
|
||||
value={this.mergedValue}
|
||||
/>
|
||||
<VBinder>
|
||||
{{
|
||||
default: () => [
|
||||
<VTarget>
|
||||
{{
|
||||
default: () => {
|
||||
const style: CSSProperties = {
|
||||
position: 'absolute',
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
if (__DEV__ && this.internalDebug) {
|
||||
style.width = '1px'
|
||||
style.height = '1px'
|
||||
style.background = 'red'
|
||||
}
|
||||
return <div style={style} ref="cursorRef"></div>
|
||||
}
|
||||
}}
|
||||
</VTarget>,
|
||||
<VFollower
|
||||
ref="followerRef"
|
||||
placement="bottom-start"
|
||||
show={this.showMenu}
|
||||
containerClass={this.namespace}
|
||||
>
|
||||
{{
|
||||
default: () => (
|
||||
<Transition
|
||||
name="n-fade-in-scale-up-transition"
|
||||
appear={this.isMounted}
|
||||
>
|
||||
{{
|
||||
default: () =>
|
||||
this.showMenu ? (
|
||||
<NInternalSelectMenu
|
||||
autoPending
|
||||
ref="selectMenuInstRef"
|
||||
class="n-mention-menu"
|
||||
loading={this.loading}
|
||||
treeMate={this.treeMate}
|
||||
virtualScroll={false}
|
||||
style={this.cssVars as CSSProperties}
|
||||
onMenuToggleOption={this.handleSelect}
|
||||
/>
|
||||
) : null
|
||||
}}
|
||||
</Transition>
|
||||
)
|
||||
}}
|
||||
</VFollower>
|
||||
]
|
||||
}}
|
||||
</VBinder>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
3
src/mention/src/interface.ts
Normal file
3
src/mention/src/interface.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { SelectBaseOption } from '../../select'
|
||||
|
||||
export type MentionOption = SelectBaseOption<string>
|
12
src/mention/src/styles/index.cssr.ts
Normal file
12
src/mention/src/styles/index.cssr.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import fadeInScaleUp from '../../../_styles/transitions/fade-in-scale-up'
|
||||
import { c, cB } from '../../../_utils/cssr'
|
||||
|
||||
// --menu-box-shadow
|
||||
export default c([
|
||||
cB('mention', 'width: 100%;'),
|
||||
cB('mention-menu', `
|
||||
box-shadow: var(--menu-box-shadow);
|
||||
`, [
|
||||
fadeInScaleUp()
|
||||
])
|
||||
])
|
21
src/mention/styles/dark.ts
Normal file
21
src/mention/styles/dark.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { commonDark } from '../../_styles/common'
|
||||
import type { MentionTheme } from './light'
|
||||
import { internalSelectMenuDark } from '../../_internal/select-menu/styles'
|
||||
import { inputDark } from '../../input/styles'
|
||||
|
||||
const listDark: MentionTheme = {
|
||||
name: 'Mention',
|
||||
common: commonDark,
|
||||
peers: {
|
||||
InternalSelectMenu: internalSelectMenuDark,
|
||||
Input: inputDark
|
||||
},
|
||||
self (vars) {
|
||||
const { boxShadow2 } = vars
|
||||
return {
|
||||
menuBoxShadow: boxShadow2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default listDark
|
3
src/mention/styles/index.ts
Normal file
3
src/mention/styles/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as mentionDark } from './dark'
|
||||
export { default as mentionLight } from './light'
|
||||
export type { MentionTheme, MentionThemeVars } from './light'
|
26
src/mention/styles/light.ts
Normal file
26
src/mention/styles/light.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { commonLight } from '../../_styles/common'
|
||||
import type { ThemeCommonVars } from '../../_styles/common'
|
||||
import { createTheme } from '../../_mixins'
|
||||
import { internalSelectMenuLight } from '../../_internal/select-menu/styles'
|
||||
import { inputLight } from '../../input/styles'
|
||||
|
||||
const self = (vars: ThemeCommonVars) => {
|
||||
const { boxShadow2 } = vars
|
||||
return {
|
||||
menuBoxShadow: boxShadow2
|
||||
}
|
||||
}
|
||||
|
||||
const mentionLight = createTheme({
|
||||
name: 'Mention',
|
||||
common: commonLight,
|
||||
peers: {
|
||||
InternalSelectMenu: internalSelectMenuLight,
|
||||
Input: inputLight
|
||||
},
|
||||
self
|
||||
})
|
||||
|
||||
export default mentionLight
|
||||
export type MentionTheme = typeof mentionLight
|
||||
export type MentionThemeVars = ReturnType<typeof self>
|
@ -5,8 +5,8 @@ export type SelectMixedOption =
|
||||
| SelectBaseOption
|
||||
| SelectGroupOption
|
||||
| SelectIgnoredOption
|
||||
export interface SelectBaseOption {
|
||||
value: string | number
|
||||
export interface SelectBaseOption<V = string | number> {
|
||||
value: V
|
||||
label: string
|
||||
class?: string
|
||||
style?: string | CSSProperties
|
||||
|
@ -32,6 +32,7 @@ export { layoutDark } from './layout/styles'
|
||||
export { listDark } from './list/styles'
|
||||
export { loadingBarDark } from './loading-bar/styles'
|
||||
export { logDark } from './log/styles'
|
||||
export { mentionDark } from './mention/styles'
|
||||
export { menuDark } from './menu/styles'
|
||||
export { messageDark } from './message/styles'
|
||||
export { modalDark } from './modal/styles'
|
||||
@ -65,5 +66,5 @@ export { treeDark } from './tree/styles'
|
||||
export { uploadDark } from './upload/styles'
|
||||
|
||||
// danger zone, internal styles
|
||||
export { internalSelectMenuLight } from './_internal/select-menu/styles'
|
||||
export { internalSelectMenuDark } from './_internal/select-menu/styles'
|
||||
export { internalSelectionDark } from './_internal/selection/styles'
|
||||
|
@ -34,6 +34,7 @@ import { listDark } from '../list/styles'
|
||||
import { loadingBarDark } from '../loading-bar/styles'
|
||||
import { logDark } from '../log/styles'
|
||||
import { menuDark } from '../menu/styles'
|
||||
import { mentionDark } from '../mention/styles'
|
||||
import { messageDark } from '../message/styles'
|
||||
import { modalDark } from '../modal/styles'
|
||||
import { notificationDark } from '../notification/styles'
|
||||
@ -103,6 +104,7 @@ export const darkTheme: BuiltInGlobalTheme = {
|
||||
LoadingBar: loadingBarDark,
|
||||
Log: logDark,
|
||||
Menu: menuDark,
|
||||
Mention: mentionDark,
|
||||
Message: messageDark,
|
||||
Modal: modalDark,
|
||||
Notification: notificationDark,
|
||||
|
Loading…
x
Reference in New Issue
Block a user