feat(mention)

This commit is contained in:
07akioni 2021-03-27 12:06:08 +08:00
parent 7ff45e670d
commit 8fbd1526e7
32 changed files with 1188 additions and 3 deletions

View File

@ -4,6 +4,7 @@
### Feats
- Add `n-mention` component.
- `n-data-table` supports expanding rows.
### Fixes

View File

@ -4,6 +4,7 @@
### Feats
- 新增 `n-mention` 组件
- `n-data-table` 支持行展开
### Fixes

View File

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

View File

@ -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: '单选',

View File

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

View File

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

View File

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

View 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)
}
}
}
})
```

View 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'
}
]
}
}
})
```

View 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'
}
]
}
}
})
```

View 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
}
}
}
}
})
```

View 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()
}
}
}
})
```

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

View 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'
}
]
}
}
})
```

View 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)
}
}
}
})
```

View 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号'
}
]
}
}
})
```

View 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号'
}
]
}
}
})
```

View 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
}
}
}
}
})
```

View 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()
}
}
}
})
```

View 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` | 在选项中应该是唯一的 |

View 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
View File

@ -0,0 +1 @@
export { default as NMention } from './src/Mention'

428
src/mention/src/Mention.tsx Normal file
View 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>
)
}
})

View File

@ -0,0 +1,3 @@
import type { SelectBaseOption } from '../../select'
export type MentionOption = SelectBaseOption<string>

View 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()
])
])

View 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

View File

@ -0,0 +1,3 @@
export { default as mentionDark } from './dark'
export { default as mentionLight } from './light'
export type { MentionTheme, MentionThemeVars } from './light'

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

View File

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

View File

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

View File

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

View File

@ -453,6 +453,8 @@
- [ ] selection popover 滚动
- [x] card 设定高度
- [x] dropdown 手动定位有 bugmousemoveoutside
- [x] select menu loading
- [ ] refactor layout to make position work on first shot
## Build