feat: add mention component (#17586)

* feat: add mention component

* fix: build error

* fix: build error

* feat: delete as a whole

* fix: update docs

* fix: update global.d.ts

* fix: update

* fix: update code

* fix: update code

* fix: update code

* fix: rename

* fix: update code

* fix: upload code

* fix: update code

* fix: fixed cursor position abnormality

* fix: update code

* fix: docs add avatar

* fix: tooltip position is wrong when placing on top

* feat: add overview icon

* fix: overview icon color
This commit is contained in:
赵添 2024-08-02 11:00:26 +08:00 committed by GitHub
parent 76ee10ab69
commit 0456c790db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 1737 additions and 12 deletions

View File

@ -106,6 +106,11 @@
"link": "/input-number",
"text": "Input Number"
},
{
"link": "/mention",
"text": "Mention",
"promotion": "2.8.0"
},
{
"link": "/radio",
"text": "Radio"

View File

@ -71,6 +71,7 @@ import OvPopover from './ov-popover.vue'
import OvTooltip from './ov-tooltip.vue'
import OvDivider from './ov-divider.vue'
import OvWatermark from './ov-watermark.vue'
import OvMention from './ov-mention.vue'
export default {
button: OvButton,
@ -149,4 +150,5 @@ export default {
tooltip: OvTooltip,
divider: OvDivider,
watermark: OvWatermark,
mention: OvMention,
}

View File

@ -0,0 +1,104 @@
<template>
<svg
width="280"
height="180"
viewBox="0 0 280 180"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="280" height="180" fill="var(--el-fill-color-light)" />
<rect
x="85.5"
y="40.5"
width="99"
height="19"
rx="1.5"
fill="var(--el-fill-color-blank)"
stroke="var(--el-color-primary)"
/>
<g filter="url(#filter0_d_1544_8231)">
<rect
x="85"
y="62"
width="100"
height="68"
rx="2"
fill="var(--el-bg-color-overlay)"
/>
<rect
x="84.7"
y="61.7"
width="100.6"
height="68.6"
rx="2.3"
stroke="var(--el-border-color-dark)"
stroke-width="0.6"
/>
</g>
<rect
x="93"
y="74"
width="25"
height="4"
rx="2"
fill="var(--el-border-color-dark)"
/>
<path
d="M93.47 46.72C94.55 46.72 95.4 47.0433 96.02 47.69C96.5533 48.2567 96.82 48.9467 96.82 49.76C96.82 50.4533 96.5967 51.1033 96.15 51.71C95.6967 52.3033 95.1833 52.6 94.61 52.6C94.1967 52.6 93.9533 52.3933 93.88 51.98C93.5933 52.4 93.2067 52.61 92.72 52.61C92.2733 52.61 91.91 52.46 91.63 52.16C91.35 51.86 91.21 51.4833 91.21 51.03C91.21 50.33 91.4267 49.7133 91.86 49.18C92.2867 48.6333 92.8033 48.36 93.41 48.36C93.8367 48.36 94.14 48.59 94.32 49.05L94.46 48.55H95.35L94.67 50.99C94.57 51.35 94.52 51.5733 94.52 51.66C94.52 51.7933 94.5933 51.86 94.74 51.86C95.0133 51.86 95.2933 51.6767 95.58 51.31C95.9 50.89 96.06 50.3767 96.06 49.77C96.06 49.15 95.84 48.6233 95.4 48.19C94.92 47.7167 94.2567 47.48 93.41 47.48C92.5167 47.48 91.8 47.7633 91.26 48.33C90.7267 48.8833 90.46 49.61 90.46 50.51C90.46 51.4967 90.7433 52.24 91.31 52.74C91.83 53.2 92.5767 53.43 93.55 53.43C94.1833 53.43 94.7367 53.3267 95.21 53.12C95.63 52.92 95.9667 52.6433 96.22 52.29H97.16C96.8067 52.9233 96.29 53.4 95.61 53.72C94.99 54 94.3033 54.14 93.55 54.14C92.2633 54.14 91.2667 53.81 90.56 53.15C89.8533 52.49 89.5 51.5967 89.5 50.47C89.5 49.4233 89.8433 48.5533 90.53 47.86C91.27 47.1 92.25 46.72 93.47 46.72ZM93.59 49.23C93.21 49.23 92.88 49.42 92.6 49.8C92.32 50.18 92.18 50.6033 92.18 51.07C92.18 51.31 92.23 51.49 92.33 51.61C92.43 51.7433 92.5867 51.81 92.8 51.81C92.9933 51.81 93.18 51.74 93.36 51.6C93.56 51.44 93.7 51.2333 93.78 50.98L93.98 50.26C94.06 49.9733 94.1 49.7967 94.1 49.73C94.1 49.5833 94.0467 49.4633 93.94 49.37C93.8467 49.2767 93.73 49.23 93.59 49.23Z"
fill="var(--el-color-primary)"
/>
<rect
x="93"
y="94"
width="25"
height="4"
rx="2"
fill="var(--el-color-primary)"
/>
<rect
x="93"
y="114"
width="25"
height="4"
rx="2"
fill="var(--el-border-color-dark)"
/>
<defs>
<filter
id="filter0_d_1544_8231"
x="81"
y="59"
width="108"
height="76"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_1544_8231"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_1544_8231"
result="shape"
/>
</filter>
</defs>
</svg>
</template>

View File

@ -82,6 +82,7 @@ declare module 'vue' {
OvLayout: typeof import('./.vitepress/vitepress/components/overview-icons/ov-layout.vue')['default']
OvLink: typeof import('./.vitepress/vitepress/components/overview-icons/ov-link.vue')['default']
OvLoading: typeof import('./.vitepress/vitepress/components/overview-icons/ov-loading.vue')['default']
OvMention: typeof import('./.vitepress/vitepress/components/overview-icons/ov-mention.vue')['default']
OvMenu: typeof import('./.vitepress/vitepress/components/overview-icons/ov-menu.vue')['default']
OvMessage: typeof import('./.vitepress/vitepress/components/overview-icons/ov-message.vue')['default']
OvMessageBox: typeof import('./.vitepress/vitepress/components/overview-icons/ov-message-box.vue')['default']

View File

@ -173,17 +173,18 @@ input/length-limiting
### Exposes
| Name | Description | Type |
| -------------- | -------------------------------- | ------------------------------------------------------- |
| blur | blur the input element | ^[Function]`() => void` |
| clear | clear input value | ^[Function]`() => void` |
| focus | focus the input element | ^[Function]`() => void` |
| input | HTML input element | ^[object]`Ref<HTMLInputElement>` |
| ref | HTML element, input or textarea | ^[object]`Ref<HTMLInputElement \| HTMLTextAreaElement>` |
| resizeTextarea | resize textarea | ^[Function]`() => void` |
| select | select the text in input element | ^[Function]`() => void` |
| textarea | HTML textarea element | ^[object]`Ref<HTMLTextAreaElement>` |
| textareaStyle | style of textarea | ^[object]`Ref<StyleValue>` |
| Name | Description | Type |
| -------------------- | -------------------------------- | ------------------------------------------------------- |
| blur | blur the input element | ^[Function]`() => void` |
| clear | clear input value | ^[Function]`() => void` |
| focus | focus the input element | ^[Function]`() => void` |
| input | HTML input element | ^[object]`Ref<HTMLInputElement>` |
| ref | HTML element, input or textarea | ^[object]`Ref<HTMLInputElement \| HTMLTextAreaElement>` |
| resizeTextarea | resize textarea | ^[Function]`() => void` |
| select | select the text in input element | ^[Function]`() => void` |
| textarea | HTML textarea element | ^[object]`Ref<HTMLTextAreaElement>` |
| textareaStyle | style of textarea | ^[object]`Ref<StyleValue>` |
| isComposing ^(2.8.0) | is input composing | ^[object]`Ref<boolean>` |
## FAQ

View File

@ -0,0 +1,145 @@
---
title: Mention
lang: en-US
---
# Mention
Used to mention someone or something in an input.
## Basic Usage
The most basic usage.
:::demo
mention/basic
:::
## Textarea
The input type can be set to `textarea`.
:::demo
mention/textarea
:::
## Customize label
Customize label by `label` slot.
:::demo
mention/label
:::
## Load remote options
Load options asynchronously.
:::demo
mention/loading
:::
## Customize trigger token
Customize trigger token by `prefix` props. Default to `@`, `Array<string>` also supported.
:::demo
mention/prefix
:::
## Delete as a whole
Set the `whole` attribute to `true`, and when you press the backspace, the mention will be deleted as a whole.
Set the `check-is-whole` attribute to customize the checking logic.
:::demo
mention/whole
:::
## Work with form
to work with `el-form`.
:::demo
mention/form
:::
::: tip
Since this component is developed based on the component [`el-input`](./input.md#attributes) , the original properties have not changed, so no repetition here,
and please go to the original component to view the documentation.
:::
## API
### Attributes
| Name | Description | Type | Default |
| ------------------------------------ | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------- |
| options | mention options list | ^[array]`MentionOption[]` | [] |
| prefix | prefix character to trigger mentions. The string length must be exactly 1 | ^[string] \| ^[array]`string[]` | `'@'` |
| split | character to split mentions. The string length must be exactly 1 | ^[string] | `' '` |
| filter-option | customize filter option logic | ^[false] \| ^[Function]`(pattern: string, option: MentionOption) => boolean` | - |
| placement | set popup placement | ^[string]`'bottom' \| 'top'` | `'bottom'` |
| show-arrow | whether the dropdown panel has an arrow | ^[boolean] | `false` |
| offset | offset of the dropdown panel | ^[number] | `0` |
| whole | when backspace is pressed to delete, whether the mention content is deleted as a whole | ^[boolean] | `false` |
| check-is-whole | when backspace is pressed to delete, check if the mention is a whole | ^[Function]`(pattern: string, prefix: string) => boolean` | - |
| loading | whether the dropdown panel of mentions is in a loading state | ^[boolean] | `false` |
| model-value / v-model | input value | ^[string] | - |
| popper-class | custom class name for dropdown panel | ^[string] | - |
| popper-options | [popper.js](https://popper.js.org/docs/v2/) parameters | ^[object] refer to [popper.js doc](https://popper.js.org/docs/v2/) | - |
| [input props](./input.md#attributes) | - | - | - |
### Events
| Name | Description | Type |
| --------------------------------- | ----------------------------------- | ------------------------------------------------------------ |
| search | trigger when prefix hit | ^[Function]`(pattern: string, prefix: string) => void` |
| select | trigger when user select the option | ^[Function]`(option: MentionOption, prefix: string) => void` |
| [input events](./input.md#events) | - | - |
### Slots
| Name | Description | Type |
| ------------------------------- | ------------------------------------- | ------------------------------------------------- |
| label | content as option label | ^[object]`{ item: MentionOption, index: number }` |
| loading | content as option loading | - |
| header | content at the top of the dropdown | - |
| footer | content at the bottom of the dropdown | - |
| [input slots](./input.md#slots) | - | - |
### Exposes
| Name | Description | Type |
| ------- | ----------------------------- | --------------------------------------- |
| input | el-input component instance | ^[object]`Ref<InputInstance \| null>` |
| tooltip | el-tooltip component instance | ^[object]`Ref<TooltipInstance \| null>` |
## Type Declarations
<details>
<summary>Show declarations</summary>
```ts
type MentionOption = {
value: string
label?: string
disabled?: boolean
[key: string]: any
}
```
</details>

View File

@ -0,0 +1,32 @@
<template>
<el-mention
v-model="value"
:options="options"
style="width: 320px"
placeholder="Please input"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const value = ref('@')
const options = ref([
{
label: 'Fuphoenixes',
value: 'Fuphoenixes',
},
{
label: 'kooriookami',
value: 'kooriookami',
},
{
label: 'Jeremy',
value: 'Jeremy',
},
{
label: 'btea',
value: 'btea',
},
])
</script>

View File

@ -0,0 +1,76 @@
<template>
<el-form
ref="ruleFormRef"
style="max-width: 600px"
:model="ruleForm"
:rules="rules"
>
<el-form-item label="name" prop="name">
<el-mention v-model="ruleForm.name" :options="options" />
</el-form-item>
<el-form-item label="desc" prop="desc">
<el-mention v-model="ruleForm.desc" type="textarea" :options="options" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm(ruleFormRef)">
Submit
</el-button>
<el-button @click="resetForm(ruleFormRef)">Reset</el-button>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
interface RuleForm {
name: string
desc: string
}
const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<RuleForm>({
name: '',
desc: '',
})
const options = ref([
{
label: 'Fuphoenixes',
value: 'Fuphoenixes',
},
{
label: 'kooriookami',
value: 'kooriookami',
},
{
label: 'Jeremy',
value: 'Jeremy',
},
{
label: 'btea',
value: 'btea',
},
])
const rules = reactive<FormRules<RuleForm>>({
name: [{ required: true, message: 'Please input name', trigger: 'blur' }],
desc: [{ required: true, message: 'Please input desc', trigger: 'blur' }],
})
const submitForm = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid, fields) => {
if (valid) {
console.log('submit!')
} else {
console.log('error submit!', fields)
}
})
}
const resetForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.resetFields()
}
</script>

View File

@ -0,0 +1,39 @@
<template>
<el-mention
v-model="value"
:options="options"
style="width: 320px"
placeholder="Please input"
>
<template #label="{ item }">
<div style="display: flex; align-items: center">
<el-avatar :size="24" :src="item.avatar" />
<span style="margin-left: 6px">{{ item.value }}</span>
</div>
</template>
</el-mention>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const value = ref('')
const options = ref([
{
value: 'Fuphoenixes',
avatar: 'https://avatars.githubusercontent.com/u/27912232',
},
{
value: 'kooriookami',
avatar: 'https://avatars.githubusercontent.com/u/38392315',
},
{
value: 'Jeremy',
avatar: 'https://avatars.githubusercontent.com/u/15975785',
},
{
value: 'btea',
avatar: 'https://avatars.githubusercontent.com/u/24516654',
},
])
</script>

View File

@ -0,0 +1,39 @@
<template>
<el-mention
v-model="value"
:options="options"
:loading="loading"
style="width: 320px"
placeholder="Please input"
@search="handleSearch"
/>
</template>
<script setup lang="ts">
import { onBeforeMount, ref } from 'vue'
import type { MentionOption } from 'element-plus'
const value = ref('')
const loading = ref(false)
const options = ref<MentionOption[]>([])
let timer: ReturnType<typeof setTimeout>
const handleSearch = (pattern: string) => {
if (timer) clearTimeout(timer)
loading.value = true
timer = setTimeout(() => {
options.value = ['Fuphoenixes', 'kooriookami', 'Jeremy', 'btea'].map(
(item) => ({
label: pattern + item,
value: pattern + item,
})
)
loading.value = false
}, 1500)
}
onBeforeMount(() => {
if (timer) clearTimeout(timer)
})
</script>

View File

@ -0,0 +1,28 @@
<template>
<el-mention
v-model="value"
:options="options"
:prefix="['@', '#']"
style="width: 320px"
placeholder="input @ to mention people, # to mention tag"
@search="handleSearch"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { MentionOption } from 'element-plus'
const MOCK_DATA: Record<string, string[]> = {
'@': ['Fuphoenixes', 'kooriookami', 'Jeremy', 'btea'],
'#': ['1.0', '2.0', '3.0'],
}
const value = ref('')
const options = ref<MentionOption[]>([])
const handleSearch = (_: string, prefix: string) => {
options.value = (MOCK_DATA[prefix] || []).map((value) => ({
value,
}))
}
</script>

View File

@ -0,0 +1,33 @@
<template>
<el-mention
v-model="value"
type="textarea"
:options="options"
style="width: 320px"
placeholder="Please input"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const value = ref('')
const options = ref([
{
label: 'Fuphoenixes',
value: 'Fuphoenixes',
},
{
label: 'kooriookami',
value: 'kooriookami',
},
{
label: 'Jeremy',
value: 'Jeremy',
},
{
label: 'btea',
value: 'btea',
},
])
</script>

View File

@ -0,0 +1,46 @@
<template>
<el-mention
v-model="value1"
whole
:options="options1"
style="width: 320px"
placeholder="Please input"
/>
<el-divider />
<el-mention
v-model="value2"
:options="options2"
:prefix="['@', '#']"
whole
:check-is-whole="checkIsWhole"
style="width: 320px"
placeholder="input @ to mention people, # to mention tag"
@search="handleSearch"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { MentionOption } from 'element-plus'
const MOCK_DATA: Record<string, string[]> = {
'@': ['Fuphoenixes', 'kooriookami', 'Jeremy', 'btea'],
'#': ['1.0', '2.0', '3.0'],
}
const value1 = ref('')
const value2 = ref('')
const options1 = ref<MentionOption[]>(
MOCK_DATA['@'].map((value) => ({ value }))
)
const options2 = ref<MentionOption[]>([])
const handleSearch = (_: string, prefix: string) => {
options2.value = (MOCK_DATA[prefix] || []).map((value) => ({
value,
}))
}
const checkIsWhole = (pattern: string, prefix: string) => {
return (MOCK_DATA[prefix] || []).includes(pattern)
}
</script>

1
global.d.ts vendored
View File

@ -100,6 +100,7 @@ declare module 'vue' {
ElWatermark: typeof import('element-plus')['ElWatermark']
ElTour: typeof import('element-plus')['ElTour']
ElTourStep: typeof import('element-plus')['ElTourStep']
ElMention: typeof import('element-plus')['ElMention']
}
interface ComponentCustomProperties {

View File

@ -73,6 +73,7 @@ export * from './watermark'
export * from './tour'
export * from './anchor'
export * from './segmented'
export * from './mention'
// plugins
export * from './infinite-scroll'

View File

@ -541,6 +541,9 @@ defineExpose({
/** @description from props (used on unit test) */
autosize: toRef(props, 'autosize'),
/** @description is input composing */
isComposing,
/** @description HTML input element native method */
focus,
/** @description HTML input element native method */

View File

@ -0,0 +1,89 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, test } from 'vitest'
import sleep from '@element-plus/test-utils/sleep'
import Mention from '../src/mention.vue'
describe('Mention.vue', () => {
afterEach(() => {
document.body.innerHTML = ''
})
const options = [
{
label: 'Fuphoenixes',
value: 'Fuphoenixes',
},
{
label: 'kooriookami',
value: 'kooriookami',
},
{
label: 'Jeremy',
value: 'Jeremy',
},
{
label: 'btea',
value: 'btea',
},
]
test('should work with `options` prop', async () => {
const wrapper = mount(Mention, {
attachTo: document.body,
props: { options },
})
wrapper.find('input').element.focus()
wrapper.find('input').setValue('@')
await sleep(150)
expect(document.querySelector('.el-mention-dropdown')).not.toEqual(null)
expect(document.querySelectorAll('.el-mention-dropdown__item').length).toBe(
4
)
})
test('should work with `type` prop', async () => {
const wrapper = mount(Mention, {
attachTo: document.body,
props: { options },
})
expect(wrapper.find('input').exists()).toBe(true)
await wrapper.setProps({ type: 'text' })
expect(wrapper.find('input').exists()).toBe(true)
await wrapper.setProps({ type: 'textarea' })
expect(wrapper.find('textarea').exists()).toBe(true)
})
test('should work with `loading` prop', async () => {
const wrapper = mount(Mention, {
attachTo: document.body,
props: { options, loading: true },
})
wrapper.find('input').element.focus()
await wrapper.find('input').setValue('@')
await sleep(150)
expect(document.querySelector('.el-mention-dropdown__loading')).not.toEqual(
null
)
})
test('should work with `prefix` prop', async () => {
const wrapper = mount(Mention, {
attachTo: document.body,
props: { options, prefix: '#' },
})
wrapper.find('input').element.focus()
await wrapper.find('input').setValue('#')
await sleep(150)
expect(document.querySelector('.el-mention-dropdown')).not.toEqual(null)
expect(document.querySelectorAll('.el-mention-dropdown__item').length).toBe(
4
)
})
})

View File

@ -0,0 +1,8 @@
import { withInstall } from '@element-plus/utils'
import Mention from './src/mention.vue'
import type { SFCWithInstall } from '@element-plus/utils'
export const ElMention: SFCWithInstall<typeof Mention> = withInstall(Mention)
export default ElMention
export * from './src/mention'

View File

@ -0,0 +1,219 @@
import { ensureArray, isFirefox } from '@element-plus/utils'
import type { MentionCtx, MentionOption } from './types'
export const filterOption = (
pattern: string,
option: MentionOption
): boolean => {
const lowerCase = pattern.toLowerCase()
const label = option.label || option.value
return label.toLowerCase().includes(lowerCase)
}
export const getMentionCtx = (
inputEl: HTMLInputElement | HTMLTextAreaElement,
prefix: string | string[],
split: string
) => {
const { selectionEnd } = inputEl
if (selectionEnd === null) return
const inputValue = inputEl.value
const prefixArray = ensureArray(prefix)
let splitIndex = -1
let mentionCtx: MentionCtx | undefined
for (let i = selectionEnd - 1; i >= 0; --i) {
const char = inputValue[i]
if (char === split || char === '\n' || char === '\r') {
splitIndex = i
continue
}
if (prefixArray.includes(char)) {
const end = splitIndex === -1 ? selectionEnd : splitIndex
const pattern = inputValue.slice(i + 1, end)
mentionCtx = {
pattern,
start: i + 1,
end,
prefix: char,
prefixIndex: i,
splitIndex,
selectionEnd,
}
break
}
}
return mentionCtx
}
/**
* fork from textarea-caret-position
* https://github.com/component/textarea-caret-position
* The MIT License (MIT)
* Copyright (c) 2015 Jonathan Ong me@jongleberry.com
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export const getCursorPosition = (
element: HTMLInputElement | HTMLTextAreaElement,
options = {
debug: false,
useSelectionEnd: false,
}
) => {
const selectionStart =
element.selectionStart !== null ? element.selectionStart : 0
const selectionEnd = element.selectionEnd !== null ? element.selectionEnd : 0
const position = options.useSelectionEnd ? selectionEnd : selectionStart
// We'll copy the properties below into the mirror div.
// Note that some browsers, such as Firefox, do not concatenate properties
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
// so we have to list every single property explicitly.
const properties: string[] = [
'direction', // RTL support
'boxSizing',
'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
'height',
'overflowX',
'overflowY', // copy the scrollbar for IE
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderStyle',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontSizeAdjust',
'lineHeight',
'fontFamily',
'textAlign',
'textTransform',
'textIndent',
'textDecoration', // might not make a difference, but better be safe
'letterSpacing',
'wordSpacing',
'tabSize',
'MozTabSize',
]
if (options.debug) {
const el = document.querySelector(
'#input-textarea-caret-position-mirror-div'
)
if (el?.parentNode) el.parentNode.removeChild(el)
}
// The mirror div will replicate the textareas style
const div = document.createElement('div')
div.id = 'input-textarea-caret-position-mirror-div'
document.body.appendChild(div)
const style = div.style
const computed = window.getComputedStyle(element)
const isInput = element.nodeName === 'INPUT'
// Default textarea styles
style.whiteSpace = isInput ? 'nowrap' : 'pre-wrap'
if (!isInput) style.wordWrap = 'break-word' // only for textarea-s
// Position off-screen
style.position = 'absolute' // required to return coordinates properly
if (!options.debug) style.visibility = 'hidden' // not 'display: none' because we want rendering
// Transfer the element's properties to the div
properties.forEach((prop) => {
if (isInput && prop === 'lineHeight') {
// Special case for <input>s because text is rendered centered and line height may be != height
if (computed.boxSizing === 'border-box') {
const height = Number.parseInt(computed.height as string)
const outerHeight =
Number.parseInt(computed.paddingTop as string) +
Number.parseInt(computed.paddingBottom as string) +
Number.parseInt(computed.borderTopWidth as string) +
Number.parseInt(computed.borderBottomWidth as string)
const targetHeight =
outerHeight + Number.parseInt(computed.lineHeight as string)
if (height > targetHeight) {
style.lineHeight = `${height - outerHeight}px`
} else if (height === targetHeight) {
style.lineHeight = computed.lineHeight
} else {
style.lineHeight = '0'
}
} else {
style.lineHeight = computed.height
}
} else {
style[prop as any] = computed[prop as any]
}
})
if (isFirefox()) {
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
if (element.scrollHeight > Number.parseInt(computed.height as string)) {
style.overflowY = 'scroll'
}
} else {
style.overflow = 'hidden' // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
}
div.textContent = element.value.slice(0, Math.max(0, position))
// The second special handling for input type="text" vs textarea:
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
if (isInput && div.textContent) {
div.textContent = div.textContent.replace(/\s/g, '\u00A0')
}
const span = document.createElement('span')
// Wrapping must be replicated *exactly*, including when a long word gets
// onto the next line, with whitespace at the end of the line before (#7).
// The *only* reliable way to do that is to copy the *entire* rest of the
// textareas content into the <span> created at the caret position.
// For inputs, just '.' would be enough, but no need to bother.
span.textContent = element.value.slice(Math.max(0, position)) || '.' // || because a completely empty faux span doesn't render at all
span.style.position = 'relative'
span.style.left = `${-element.scrollLeft}px`
span.style.top = `${-element.scrollTop}px`
div.appendChild(span)
const relativePosition = {
top: span.offsetTop + Number.parseInt(computed.borderTopWidth as string),
left: span.offsetLeft + Number.parseInt(computed.borderLeftWidth as string),
// We don't use line-height since it may be too large for position. Eg. 34px
// for input
height: Number.parseInt(computed.fontSize as string) * 1.5,
}
if (options.debug) {
span.style.backgroundColor = '#aaa'
} else {
document.body.removeChild(div)
}
if (relativePosition.left >= element.clientWidth) {
relativePosition.left = element.clientWidth
}
return relativePosition
}

View File

@ -0,0 +1,16 @@
import { buildProps, definePropType, isString } from '@element-plus/utils'
import type { MentionOption } from './types'
export const mentionDropdownProps = buildProps({
options: {
type: definePropType<MentionOption[]>(Array),
default: () => [],
},
loading: Boolean,
disabled: Boolean,
})
export const mentionDropdownEmits = {
select: (option: MentionOption) => isString(option.value),
}

View File

@ -0,0 +1,141 @@
<template>
<div ref="dropdownRef" :class="ns.b('dropdown')">
<div v-if="$slots.header" :class="ns.be('dropdown', 'header')">
<slot name="header" />
</div>
<el-scrollbar
v-show="options.length > 0 && !loading"
ref="scrollbarRef"
tag="ul"
:wrap-class="ns.be('dropdown', 'wrap')"
:view-class="ns.be('dropdown', 'list')"
>
<li
v-for="(item, index) in options"
ref="optionRefs"
:key="item.value"
:class="optionkls(item, index)"
@mouseenter="handleMouseEnter(index)"
@click.stop="handleSelect(item)"
>
<slot name="label" :item="item" :index="index">
<span>{{ item.label ?? item.value }}</span>
</slot>
</li>
</el-scrollbar>
<div v-if="loading" :class="ns.be('dropdown', 'loading')">
<slot name="loading"> {{ t('el.mention.loading') }} </slot>
</div>
<div v-if="$slots.footer" :class="ns.be('dropdown', 'footer')">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useLocale, useNamespace } from '@element-plus/hooks'
import { scrollIntoView } from '@element-plus/utils'
import ElScrollbar from '@element-plus/components/scrollbar'
import { mentionDropdownEmits, mentionDropdownProps } from './mention-dropdown'
import type { MentionOption } from './types'
defineOptions({
name: 'ElMentionDropdown',
})
const props = defineProps(mentionDropdownProps)
const emit = defineEmits(mentionDropdownEmits)
const ns = useNamespace('mention')
const { t } = useLocale()
const hoveringIndex = ref(-1)
const scrollbarRef = ref<InstanceType<typeof ElScrollbar>>()
const optionRefs = ref<HTMLElement[]>()
const dropdownRef = ref<HTMLElement>()
const optionkls = (item: MentionOption, index: number) => [
ns.be('dropdown', 'item'),
ns.is('hovering', hoveringIndex.value === index),
ns.is('disabled', item.disabled || props.disabled),
]
const handleSelect = (item: MentionOption) => {
if (item.disabled || props.disabled) return
emit('select', item)
}
const handleMouseEnter = (index: number) => {
hoveringIndex.value = index
}
const filteredAllDisabled = computed(
() => props.disabled || props.options.every((item) => item.disabled)
)
const hoverOption = computed(() => props.options[hoveringIndex.value])
const selectHoverOption = () => {
if (!hoverOption.value) return
emit('select', hoverOption.value)
}
const navigateOptions = (direction: 'next' | 'prev') => {
const { options } = props
if (options.length === 0 || filteredAllDisabled.value) return
if (direction === 'next') {
hoveringIndex.value++
if (hoveringIndex.value === options.length) {
hoveringIndex.value = 0
}
} else if (direction === 'prev') {
hoveringIndex.value--
if (hoveringIndex.value < 0) {
hoveringIndex.value = options.length - 1
}
}
const option = options[hoveringIndex.value]
if (option.disabled) {
navigateOptions(direction)
return
}
nextTick(() => scrollToOption(option))
}
const scrollToOption = (option: MentionOption) => {
const { options } = props
const index = options.findIndex((item) => item.value === option.value)
const target = optionRefs.value?.[index]
if (target) {
const menu = dropdownRef.value?.querySelector?.(
`.${ns.be('dropdown', 'wrap')}`
)
if (menu) {
scrollIntoView(menu as HTMLElement, target)
}
}
scrollbarRef.value?.handleScroll()
}
const resetHoveringIndex = () => {
if (filteredAllDisabled.value || props.options.length === 0) {
hoveringIndex.value = -1
} else {
hoveringIndex.value = 0
}
}
watch(() => props.options, resetHoveringIndex, {
immediate: true,
})
defineExpose({
navigateOptions,
selectHoverOption,
hoverOption,
})
</script>

View File

@ -0,0 +1,123 @@
import {
buildProps,
definePropType,
isFunction,
isString,
} from '@element-plus/utils'
import { UPDATE_MODEL_EVENT } from '@element-plus/constants'
import { inputProps } from '@element-plus/components/input'
import { filterOption } from './helper'
import type { ExtractPropTypes } from 'vue'
import type Mention from './mention.vue'
import type { MentionOption } from './types'
import type { Options } from '@element-plus/components/popper'
export const mentionProps = buildProps({
...inputProps,
/**
* @description mention options list
*/
options: {
type: definePropType<MentionOption[]>(Array),
default: () => [],
},
/**
* @description prefix character to trigger mentions. The string length must be exactly 1.
*/
prefix: {
type: definePropType<string | string[]>([String, Array]),
default: '@',
validator: (val: string | string[]) => {
if (isString(val)) return val.length === 1
return val.every((v) => isString(v) && v.length === 1)
},
},
/**
* @description character to split mentions. The string length must be exactly 1.
*/
split: {
type: String,
default: ' ',
validator: (val: string) => val.length === 1,
},
/**
* @description customize filter option logic.
*/
filterOption: {
type: definePropType<false | typeof filterOption>([Boolean, Function]),
default: () => filterOption,
validator: (val) => {
if (val === false) return true
return isFunction(val)
},
},
/**
* @description set popup placement
*/
placement: {
type: definePropType<'bottom' | 'top'>(String),
default: 'bottom',
},
/**
* @description whether the dropdown panel has an arrow
*/
showArrow: Boolean,
/**
* @description offset of the dropdown panel
*/
offset: {
type: Number,
default: 0,
},
/**
* @description when backspace is pressed to delete, whether the mention content is deleted as a whole
*/
whole: Boolean,
/**
* @description when backspace is pressed to delete, check if the mention is a whole
*/
checkIsWhole: {
type: definePropType<(pattern: string, prefix: string) => boolean>(
Function
),
},
/**
* @description input value
*/
modelValue: String,
/**
* @description whether the dropdown panel of mentions is in a loading state.
*/
loading: Boolean,
/**
* @description custom class name for dropdown panel
*/
popperClass: {
type: String,
default: '',
},
/**
* @description [popper.js](https://popper.js.org/docs/v2/) parameters
*/
popperOptions: {
type: definePropType<Partial<Options>>(Object),
default: () => ({} as Partial<Options>),
},
})
export const mentionEmits = {
[UPDATE_MODEL_EVENT]: (value: string) => isString(value),
search: (pattern: string, prefix: string) =>
isString(pattern) && isString(prefix),
select: (option: MentionOption, prefix: string) =>
isString(option.value) && isString(prefix),
focus: (evt: FocusEvent) => evt instanceof FocusEvent,
blur: (evt: FocusEvent) => evt instanceof FocusEvent,
}
export type MentionEmits = typeof mentionEmits
export type MentionProps = ExtractPropTypes<typeof mentionProps>
export type MentionInstance = InstanceType<typeof Mention>
export type { MentionOption } from './types'

View File

@ -0,0 +1,251 @@
<template>
<div ref="wrapperRef" :class="ns.b()">
<el-input
v-bind="mergeProps(passInputProps, $attrs)"
ref="elInputRef"
:model-value="modelValue"
@input="handleInputChange"
@keydown="handleInputKeyDown"
@focus="handleFocus"
@blur="handleBlur"
@mousedown="handleInputMouseDown"
>
<template v-for="(_, name) in $slots" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</el-input>
<el-tooltip
ref="tooltipRef"
:visible="visible && (!!filteredOptions.length || loading)"
:popper-class="[ns.e('popper'), popperClass]"
:popper-options="popperOptions"
:placement="computedPlacement"
:fallback-placements="computedFallbackPlacements"
effect="light"
pure
:offset="offset"
:show-arrow="showArrow"
>
<template #default>
<div :style="cursorStyle" />
</template>
<template #content>
<el-mention-dropdown
ref="dropdownRef"
:options="filteredOptions"
:disabled="disabled"
:loading="loading"
@select="handleSelect"
@click.stop="elInputRef?.focus"
>
<template v-for="(_, name) in $slots" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</el-mention-dropdown>
</template>
</el-tooltip>
</div>
</template>
<script lang="ts" setup>
import { computed, mergeProps, nextTick, ref } from 'vue'
import { pick } from 'lodash-unified'
import { useFocusController, useNamespace } from '@element-plus/hooks'
import ElInput, { inputProps } from '@element-plus/components/input'
import ElTooltip from '@element-plus/components/tooltip'
import { UPDATE_MODEL_EVENT } from '@element-plus/constants'
import { isFunction } from '@element-plus/utils'
import { mentionEmits, mentionProps } from './mention'
import { getCursorPosition, getMentionCtx } from './helper'
import ElMentionDropdown from './mention-dropdown.vue'
import type { Placement } from '@popperjs/core'
import type { CSSProperties } from 'vue'
import type { InputInstance } from '@element-plus/components/input'
import type { TooltipInstance } from '@element-plus/components/tooltip'
import type { MentionCtx, MentionOption } from './types'
defineOptions({
name: 'ElMention',
})
const props = defineProps(mentionProps)
const emit = defineEmits(mentionEmits)
const passInputProps = computed(() => pick(props, Object.keys(inputProps)))
const ns = useNamespace('mention')
const elInputRef = ref<InputInstance>()
const tooltipRef = ref<TooltipInstance>()
const dropdownRef = ref<InstanceType<typeof ElMentionDropdown>>()
const visible = ref(false)
const cursorStyle = ref<CSSProperties>()
const mentionCtx = ref<MentionCtx>()
const computedPlacement = computed<Placement>(() =>
props.showArrow ? props.placement : `${props.placement}-start`
)
const computedFallbackPlacements = computed<Placement[]>(() =>
props.showArrow ? ['bottom', 'top'] : ['bottom-start', 'top-start']
)
const filteredOptions = computed(() => {
const { filterOption, options } = props
if (!mentionCtx.value || !filterOption) return options
return options.filter((option) =>
filterOption(mentionCtx.value!.pattern, option)
)
})
const handleInputChange = (value: string) => {
emit('update:modelValue', value)
syncAfterCursorMove()
}
const handleInputKeyDown = (e: KeyboardEvent | Event) => {
if (!('key' in e)) return
if (elInputRef.value?.isComposing) return
if (['ArrowLeft', 'ArrowRight'].includes(e.key)) {
syncAfterCursorMove()
} else if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
if (!visible.value) return
e.preventDefault()
const direction = e.key === 'ArrowUp' ? 'prev' : 'next'
dropdownRef.value?.navigateOptions(direction)
} else if (['Enter'].includes(e.key)) {
if (!visible.value) return
e.preventDefault()
if (dropdownRef.value?.hoverOption) {
dropdownRef.value?.selectHoverOption()
} else {
visible.value = false
}
} else if (['Backspace'].includes(e.key)) {
if (props.whole && mentionCtx.value) {
const { splitIndex, selectionEnd, pattern, prefixIndex, prefix } =
mentionCtx.value
const inputEl = getInputEl()
if (!inputEl) return
const inputValue = inputEl.value
const matchOption = props.options.find((item) => item.value === pattern)
const isWhole = isFunction(props.checkIsWhole)
? props.checkIsWhole(pattern, prefix)
: matchOption
if (isWhole && splitIndex !== -1 && splitIndex + 1 === selectionEnd) {
e.preventDefault()
const newValue =
inputValue.slice(0, prefixIndex) + inputValue.slice(splitIndex + 1)
emit(UPDATE_MODEL_EVENT, newValue)
const newSelectionEnd = prefixIndex
nextTick(() => {
// input value is updated
inputEl.selectionStart = newSelectionEnd
inputEl.selectionEnd = newSelectionEnd
syncDropdownVisible()
})
}
}
}
}
const { wrapperRef, handleFocus, handleBlur } = useFocusController(elInputRef, {
afterFocus() {
syncAfterCursorMove()
},
beforeBlur(event) {
return tooltipRef.value?.isFocusInsideContent(event)
},
afterBlur() {
visible.value = false
},
})
const handleInputMouseDown = () => {
syncAfterCursorMove()
}
const handleSelect = (item: MentionOption) => {
if (!mentionCtx.value) return
const inputEl = getInputEl()
if (!inputEl) return
const inputValue = inputEl.value
const { split } = props
const newEndPart = inputValue.slice(mentionCtx.value.end)
const alreadySeparated = newEndPart.startsWith(split)
const newMiddlePart = `${item.value}${alreadySeparated ? '' : split}`
const newValue =
inputValue.slice(0, mentionCtx.value.start) + newMiddlePart + newEndPart
emit(UPDATE_MODEL_EVENT, newValue)
emit('select', item, mentionCtx.value.prefix)
const newSelectionEnd =
mentionCtx.value.start + newMiddlePart.length + (alreadySeparated ? 1 : 0)
nextTick(() => {
// input value is updated
inputEl.selectionStart = newSelectionEnd
inputEl.selectionEnd = newSelectionEnd
inputEl.focus()
syncDropdownVisible()
})
}
const getInputEl = () =>
props.type === 'textarea'
? elInputRef.value?.textarea
: elInputRef.value?.input
const syncAfterCursorMove = () => {
// can't use nextTick(), get cursor position will be wrong
setTimeout(() => {
syncCursor()
syncDropdownVisible()
nextTick(() => tooltipRef.value?.updatePopper())
}, 0)
}
const syncCursor = () => {
const inputEl = getInputEl()
if (!inputEl) return
const caretPosition = getCursorPosition(inputEl)
const inputRect = inputEl.getBoundingClientRect()
const elInputRect = elInputRef.value!.$el.getBoundingClientRect()
cursorStyle.value = {
position: 'absolute',
width: 0,
height: `${caretPosition.height}px`,
left: `${caretPosition.left + inputRect.left - elInputRect.left}px`,
top: `${caretPosition.top + inputRect.top - elInputRect.top}px`,
}
}
const syncDropdownVisible = () => {
const inputEl = getInputEl()
if (document.activeElement !== inputEl) {
visible.value = false
return
}
const { prefix, split } = props
mentionCtx.value = getMentionCtx(inputEl, prefix, split)
if (mentionCtx.value && mentionCtx.value.splitIndex === -1) {
visible.value = true
emit('search', mentionCtx.value.pattern, mentionCtx.value.prefix)
return
}
visible.value = false
}
defineExpose({
input: elInputRef,
tooltip: tooltipRef,
})
</script>

View File

@ -0,0 +1,16 @@
export type MentionOption = {
value: string
label?: string
disabled?: boolean
[key: string]: any
}
export interface MentionCtx {
pattern: string
start: number
end: number
prefix: string
prefixIndex: number
splitIndex: number
selectionEnd: number
}

View File

@ -0,0 +1,5 @@
import '@element-plus/components/base/style/css'
import '@element-plus/components/input/style/css'
import '@element-plus/components/tooltip/style/css'
import '@element-plus/components/scrollbar/style/css'
import '@element-plus/theme-chalk/el-mention.css'

View File

@ -0,0 +1,5 @@
import '@element-plus/components/base/style'
import '@element-plus/components/input/style'
import '@element-plus/components/tooltip/style'
import '@element-plus/components/scrollbar/style'
import '@element-plus/theme-chalk/src/mention.scss'

View File

@ -106,6 +106,7 @@ import { ElWatermark } from '@element-plus/components/watermark'
import { ElTour, ElTourStep } from '@element-plus/components/tour'
import { ElAnchor, ElAnchorLink } from '@element-plus/components/anchor'
import { ElSegmented } from '@element-plus/components/segmented'
import { ElMention } from '@element-plus/components/mention'
import type { Plugin } from 'vue'
@ -214,4 +215,5 @@ export default [
ElAnchor,
ElAnchorLink,
ElSegmented,
ElMention,
] as Plugin[]

View File

@ -13,7 +13,7 @@ interface UseFocusControllerOptions {
afterBlur?: () => void
}
export function useFocusController<T extends HTMLElement>(
export function useFocusController<T extends { focus: () => void }>(
target: ShallowRef<T | undefined>,
{ afterFocus, beforeBlur, afterBlur }: UseFocusControllerOptions = {}
) {

View File

@ -68,6 +68,9 @@ export default {
noData: 'Geen data',
placeholder: 'Kies',
},
mention: {
loading: 'Laai',
},
cascader: {
noMatch: 'Geen toepaslike data',
loading: 'Laai',

View File

@ -78,6 +78,9 @@ export default {
noData: 'لايوجد بيانات',
placeholder: 'إختر',
},
mention: {
loading: 'جار التحميل',
},
dropdown: {
toggleDropdown: 'تبديل القائمة',
},

View File

@ -78,6 +78,9 @@ export default {
noData: 'لايوجد بيانات',
placeholder: 'إختر',
},
mention: {
loading: 'جار التحميل',
},
dropdown: {
toggleDropdown: 'تبديل القائمة',
},

View File

@ -68,6 +68,9 @@ export default {
noData: 'Məlumat yoxdur',
placeholder: 'Seç',
},
mention: {
loading: 'Yüklənir',
},
cascader: {
noMatch: 'Nəticə tapılmadı',
loading: 'Yüklənir',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Няма данни',
placeholder: 'Избери',
},
mention: {
loading: 'Зареждане',
},
cascader: {
noMatch: 'Няма намерени',
loading: 'Зареждане',

View File

@ -68,6 +68,9 @@ export default {
noData: 'কোন ডাটা নেই',
placeholder: 'নির্বাচন করুন',
},
mention: {
loading: 'লোড হচ্ছে',
},
cascader: {
noMatch: 'কোন মিল পওয়া যায়নি',
loading: 'লোড হচ্ছে',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Sense Dades',
placeholder: 'Seleccionar',
},
mention: {
loading: 'Carregant',
},
cascader: {
noMatch: 'No hi ha dades que coincideixin',
loading: 'Carregant',

View File

@ -89,6 +89,9 @@ export default {
noData: 'هیچ داتایەک نیە',
placeholder: 'هەڵبژاردن',
},
mention: {
loading: 'بارکردن',
},
dropdown: {
toggleDropdown: 'کردنەوەو داخستنی کشاو',
},

View File

@ -70,6 +70,9 @@ export default {
noData: 'Žádná data',
placeholder: 'Vybrat',
},
mention: {
loading: 'Načítání',
},
cascader: {
noMatch: 'Žádná shoda',
loading: 'Načítání',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Ingen data',
placeholder: 'Vælg',
},
mention: {
loading: 'Henter',
},
cascader: {
noMatch: 'Ingen matchende data',
loading: 'Henter',

View File

@ -70,6 +70,9 @@ export default {
noData: 'Keine Daten',
placeholder: 'Daten wählen',
},
mention: {
loading: 'Lädt.',
},
cascader: {
noMatch: 'Nichts gefunden.',
loading: 'Lädt.',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Χωρίς δεδομένα',
placeholder: 'Επιλογή',
},
mention: {
loading: 'Φόρτωση',
},
cascader: {
noMatch: 'Δεν βρέθηκαν αποτελέσματα',
loading: 'Φόρτωση',

View File

@ -90,6 +90,9 @@ export default {
noData: 'No data',
placeholder: 'Select',
},
mention: {
loading: 'Loading',
},
dropdown: {
toggleDropdown: 'Toggle Dropdown',
},

View File

@ -68,6 +68,9 @@ export default {
noData: 'Neniuj datumoj',
placeholder: 'Bonvolu elekti',
},
mention: {
loading: 'Ŝarĝante',
},
cascader: {
noMatch: 'Neniuj kongruaj datumoj',
loading: 'Ŝarĝante',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Sin datos',
placeholder: 'Seleccionar',
},
mention: {
loading: 'Cargando',
},
cascader: {
noMatch: 'No hay datos que coincidan',
loading: 'Cargando',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Andmed puuduvad',
placeholder: 'Vali',
},
mention: {
loading: 'Laadimine',
},
cascader: {
noMatch: 'Sobivad andmed puuduvad',
loading: 'Laadimine',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Daturik ez',
placeholder: 'Hautatu',
},
mention: {
loading: 'Kargatzen',
},
cascader: {
noMatch: 'Bat datorren daturik ez',
loading: 'Kargatzen',

View File

@ -68,6 +68,9 @@ export default {
noData: 'اطلاعاتی وجود ندارد',
placeholder: 'انتخاب کنید',
},
mention: {
loading: 'در حال بارگذاری',
},
cascader: {
noMatch: 'هیچ نتیجه‌ای پیدا نشد',
loading: 'در حال بارگذاری',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Ei tietoja',
placeholder: 'Valitse',
},
mention: {
loading: 'Lataa',
},
cascader: {
noMatch: 'Ei vastaavia tietoja',
loading: 'Lataa',

View File

@ -91,6 +91,9 @@ export default {
noData: 'Aucune donnée',
placeholder: 'Choisir',
},
mention: {
loading: 'Chargement',
},
cascader: {
noMatch: 'Aucune correspondance',
loading: 'Chargement',

View File

@ -68,6 +68,9 @@ export default {
noData: 'אין נתונים',
placeholder: 'שומר מקום',
},
mention: {
loading: 'טוען',
},
cascader: {
noMatch: 'לא נמצאה התאמה',
loading: 'טוען',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Nema podataka',
placeholder: 'Izaberi',
},
mention: {
loading: 'Učitavanje',
},
cascader: {
noMatch: 'Nema pronađenih podataka',
loading: 'Učitavanje',

View File

@ -67,6 +67,9 @@ export default {
noData: 'Nincs adat',
placeholder: 'Válassz',
},
mention: {
loading: 'Betöltés',
},
cascader: {
noMatch: 'Nincs találat',
loading: 'Betöltés',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Տվյալներ չկան',
placeholder: 'Ընտրել',
},
mention: {
loading: 'Բեռնում',
},
cascader: {
noMatch: 'Համապատասխան տուեալներ չկան',
loading: 'Բեռնում',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Tidak ada data',
placeholder: 'Pilih',
},
mention: {
loading: 'Memuat',
},
cascader: {
noMatch: 'Tidak ada data yg cocok',
loading: 'Memuat',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Nessun dato',
placeholder: 'Seleziona',
},
mention: {
loading: 'Caricamento',
},
cascader: {
noMatch: 'Nessuna corrispondenza',
loading: 'Caricamento',

View File

@ -68,6 +68,9 @@ export default {
noData: 'データなし',
placeholder: '選択してください',
},
mention: {
loading: 'ロード中',
},
cascader: {
noMatch: 'データなし',
loading: 'ロード中',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Деректер жоқ',
placeholder: 'Таңдаңыз',
},
mention: {
loading: 'Жүктелуде',
},
cascader: {
noMatch: 'Сәйкес деректер жоқ',
loading: 'Жүктелуде',

View File

@ -68,6 +68,9 @@ export default {
noData: 'គ្មានទិន្នន័យ',
placeholder: 'ជ្រើសរើស',
},
mention: {
loading: 'កំពុងផ្ទុក',
},
cascader: {
noMatch: 'គ្មានទិន្នន័យដូច',
loading: 'កំពុងផ្ទុក',

View File

@ -79,6 +79,9 @@ export default {
noData: '데이터 없음',
placeholder: '선택',
},
mention: {
loading: '불러오는 중',
},
dropdown: {
toggleDropdown: '드롭다운 전환',
},

View File

@ -68,6 +68,9 @@ export default {
noData: 'Agahî tune',
placeholder: 'Bibijêre',
},
mention: {
loading: 'Bardibe',
},
cascader: {
noMatch: 'Li hembere ve agahî tune',
loading: 'Bardibe',

View File

@ -68,6 +68,9 @@ export default {
noData: 'маалымат жок',
placeholder: 'тандоо',
},
mention: {
loading: 'Жүктөлүүдө',
},
cascader: {
noMatch: 'Дал келген маалыматтар',
loading: 'Жүктөлүүдө',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Nėra duomenų',
placeholder: 'Pasirink',
},
mention: {
loading: 'Kraunasi',
},
cascader: {
noMatch: 'Duomenų nerasta',
loading: 'Kraunasi',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Nav datu',
placeholder: 'Izvēlēties',
},
mention: {
loading: 'Ielādē',
},
cascader: {
noMatch: 'Nav atbilstošu datu',
loading: 'Ielādē',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Tsy misy angona',
placeholder: 'Safidy',
},
mention: {
loading: 'Eo ampiandrasana',
},
cascader: {
noMatch: 'Tsy misy angona mifanentana',
loading: 'Eo ampiandrasana',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Өгөгдөл байхгүй',
placeholder: 'Сонгох',
},
mention: {
loading: 'Ачаалж байна',
},
cascader: {
noMatch: 'Тохирох өгөгдөл байхгүй',
loading: 'Ачаалж байна',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Ingen resulater',
placeholder: 'Velg',
},
mention: {
loading: 'Laster',
},
cascader: {
noMatch: 'Ingen samsvarende resultater',
loading: 'Laster',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Geen data',
placeholder: 'Selecteer',
},
mention: {
loading: 'Laden',
},
cascader: {
noMatch: 'Geen overeenkomende resultaten',
loading: 'Laden',

View File

@ -68,6 +68,9 @@ export default {
noData: 'هیڅ معلومات نشته',
placeholder: 'ځای لرونکی',
},
mention: {
loading: 'بار کول',
},
cascader: {
noMatch: 'هیڅه ونه موندل شول',
loading: 'بار کول',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Brak danych',
placeholder: 'Wybierz',
},
mention: {
loading: 'Ładowanie',
},
cascader: {
noMatch: 'Brak dopasowań',
loading: 'Ładowanie',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Sem dados',
placeholder: 'Selecione',
},
mention: {
loading: 'Carregando',
},
cascader: {
noMatch: 'Sem resultados',
loading: 'Carregando',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Sem dados',
placeholder: 'Selecione',
},
mention: {
loading: 'A carregar',
},
cascader: {
noMatch: 'Sem correspondência',
loading: 'A carregar',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Nu există date',
placeholder: 'Selectează',
},
mention: {
loading: 'Se încarcă',
},
cascader: {
noMatch: 'Nu există date potrivite',
loading: 'Se încarcă',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Нет данных',
placeholder: 'Выбрать',
},
mention: {
loading: 'Загрузка',
},
cascader: {
noMatch: 'Совпадений не найдено',
loading: 'Загрузка',

View File

@ -70,6 +70,9 @@ export default {
noData: 'Žiadne dáta',
placeholder: 'Vybrať',
},
mention: {
loading: 'Načítavanie',
},
cascader: {
noMatch: 'Žiadna zhoda',
loading: 'Načítavanie',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Ni podatkov',
placeholder: 'Izberi',
},
mention: {
loading: 'Nalaganje',
},
cascader: {
noMatch: 'Ni ustreznih podatkov',
loading: 'Nalaganje',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Нема података',
placeholder: 'Изабери',
},
mention: {
loading: 'Учитавање',
},
cascader: {
noMatch: 'Нема резултата',
loading: 'Учитавање',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Ingen data',
placeholder: 'Välj',
},
mention: {
loading: 'Laddar',
},
cascader: {
noMatch: 'Hittade inget',
loading: 'Laddar',

View File

@ -90,6 +90,9 @@ export default {
noData: 'hakuna data',
placeholder: 'chagua',
},
mention: {
loading: 'inapakia',
},
dropdown: {
toggleDropdown: 'geuza kunyuzi',
},

View File

@ -67,6 +67,9 @@ export default {
noData: 'தரவு இல்லை',
placeholder: 'தேர்வு செய்',
},
mention: {
loading: 'தயாராகிக்கொண்டிருக்கிறது',
},
cascader: {
noMatch: 'பொருத்தமான தரவு கிடைக்கவில்லை',
loading: 'தயாராகிக்கொண்டிருக்கிறது',

View File

@ -68,6 +68,9 @@ export default {
noData: 'ไม่พบข้อมูล',
placeholder: 'เลือก',
},
mention: {
loading: 'กำลังโหลด',
},
cascader: {
noMatch: 'ไม่พบข้อมูลที่ตรงกัน',
loading: 'กำลังโหลด',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Hiçzat ýok',
placeholder: 'Saýla',
},
mention: {
loading: 'Indirilýär',
},
cascader: {
noMatch: 'Hiçzat tapylmady',
loading: 'Indirilýär',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Veri yok',
placeholder: 'Seç',
},
mention: {
loading: 'Yükleniyor',
},
cascader: {
noMatch: 'Eşleşen veri bulunamadı',
loading: 'Yükleniyor',

View File

@ -68,6 +68,9 @@ export default {
noData: 'ئۇچۇر يوق',
placeholder: 'تاللاڭ',
},
mention: {
loading: 'يۈكلىنىۋاتىدۇ',
},
cascader: {
noMatch: 'ئۇچۇر تېپىلمىدى',
loading: 'يۈكلىنىۋاتىدۇ',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Немає даних',
placeholder: 'Обрати',
},
mention: {
loading: 'Завантаження',
},
cascader: {
noMatch: 'Співпадінь не знайдено',
loading: 'Завантаження',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Maʼlumot yoʻq',
placeholder: 'Tanladizngiz',
},
mention: {
loading: 'Yuklanmoqda',
},
cascader: {
noMatch: 'Mos maʼlumot topilmadi',
loading: 'Yuklanmoqda',

View File

@ -68,6 +68,9 @@ export default {
noData: 'Không tìm thấy dữ liệu',
placeholder: 'Chọn',
},
mention: {
loading: 'Đang tải',
},
cascader: {
noMatch: 'Dữ liệu không phù hợp',
loading: 'Đang tải',

View File

@ -71,6 +71,9 @@ export default {
noData: '无数据',
placeholder: '请选择',
},
mention: {
loading: '加载中',
},
cascader: {
noMatch: '无匹配数据',
loading: '加载中',

View File

@ -87,6 +87,9 @@ export default {
noData: '無資料',
placeholder: '請選擇',
},
mention: {
loading: '載入中',
},
dropdown: {
toggleDropdown: '切換下拉選單',
},

View File

@ -851,6 +851,31 @@ $segmented: map.merge(
$segmented
);
// Mention
// css3 var in packages/theme-chalk/src/mention.scss
$mention: () !default;
$mention: map.merge(
(
'font-size': getCssVar('font-size-base'),
'bg-color': getCssVar('bg-color', 'overlay'),
'shadow': getCssVar('box-shadow-light'),
'border': 1px solid getCssVar('border-color-light'),
'option-color': getCssVar('text-color-regular'),
'option-height': 34px,
'option-min-width': 100px,
'option-hover-background': getCssVar('fill-color', 'light'),
'option-selected-color': getCssVar('color-primary'),
'option-disabled-color': getCssVar('text-color-placeholder'),
'option-loading-color': getCssVar('text-color-secondary'),
'option-loading-padding': 10px 0,
'max-height': 174px,
'padding': 6px 0,
'header-padding': 10px,
'footer-padding': 10px,
),
$mention
);
// Table
// css3 var in packages/theme-chalk/src/table.scss
$table: () !default;

View File

@ -107,3 +107,4 @@
@use './anchor.scss';
@use './anchor-link.scss';
@use './segmented.scss';
@use './mention.scss';

View File

@ -0,0 +1,90 @@
@use 'sass:map';
@use 'mixins/mixins' as *;
@use 'mixins/var' as *;
@use 'common/var' as *;
@include b(mention) {
position: relative;
width: 100%;
@include e(popper) {
@include picker-popper(
map.get($mention, 'bg-color'),
map.get($mention, 'border'),
map.get($mention, 'shadow')
);
}
}
@include b(mention-dropdown) {
@include set-component-css-var('mention', $mention);
@include e(item) {
font-size: getCssVar('mention-font-size');
padding: 0 20px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: getCssVar('mention-option-color');
height: getCssVar('mention-option-height');
line-height: getCssVar('mention-option-height');
box-sizing: border-box;
min-width: getCssVar('mention-option-min-width');
cursor: pointer;
@include when(hovering) {
background-color: getCssVar('mention-option-hover-background');
}
@include when(selected) {
color: getCssVar('mention-option-selected-color');
font-weight: bold;
}
@include when(disabled) {
color: getCssVar('mention-option-disabled-color');
cursor: not-allowed;
background-color: unset;
}
}
}
@include b(mention-dropdown) {
z-index: calc(#{getCssVar('index-top')} + 1);
border-radius: getCssVar('border-radius-base');
box-sizing: border-box;
}
@include b(mention-dropdown__loading) {
padding: 10px 0;
margin: 0;
text-align: center;
color: getCssVar('mention-option-loading-color');
font-size: 12px;
min-width: getCssVar('mention-option-min-width');
}
@include b(mention-dropdown__wrap) {
max-height: getCssVar('mention-max-height');
}
@include b(mention-dropdown__list) {
list-style: none;
padding: getCssVar('mention-padding');
margin: 0;
box-sizing: border-box;
}
@include b(mention-dropdown__header) {
padding: getCssVar('mention-header-padding');
border-bottom: getCssVar('mention-border');
}
@include b(mention-dropdown__footer) {
padding: getCssVar('mention-footer-padding');
border-top: getCssVar('mention-border');
}

View File

@ -101,6 +101,7 @@ declare module 'vue' {
ElAnchor: typeof import('../packages/element-plus')['ElAnchor']
ElAnchorLink: typeof import('../packages/element-plus')['ElAnchorLink']
ElSegmented: typeof import('../packages/element-plus')['ElSegmented']
ElMention: typeof import('../packages/element-plus')['ElMention']
}
interface ComponentCustomProperties {