Element-Plus 可编辑单元格的实现

在前端开发时,会经常用到table 元素来展示内容。虽然为每条记录都设置一个编辑的页面,会使项目结构更清晰,但是在一些要求不那么严格的场合,如果能直接编辑表格内容,则无论是对用户、还是对开发来讲,都是更友好的选择。

在寻找方案时,遇到了EditableCell.vue 这个组件,觉得实现挺好的。因为这个组件是vue2 实现的,这里用vue3 语法复现一下,也可以用来学习Vue 中的一些特性。

需要在.vuepree/client.ts 中启用第三方组件的支持。这样可以直接在Markdown 文件中预览组件的效果。

ElTable 的用法

<el-table></el-table> 组件属于Element-Plus 的自带组件,主要用于展示列表内容。其核心属性:data=[{},{}] 即为要展示的内容。表格包含若干个<el-table-column></el-table-column> 子元素,对应数组中内元素的不同属性。 我们也可以通过插槽想表格中添加按钮等其他任何合法的元素。 以下面代码为例:

<template>
    <el-table style="margin:0" :data="data" height="250" border stripe >
        <el-table-column prop="name" label="Name"></el-table-column>  
        <el-table-column prop="date" label="Date"></el-table-column>  
        <el-table-column prop="gender" label="Gender"></el-table-column>  
        <el-table-column label="Operation">        
            <template #default="scope">
                <el-button type="primary">Edit {{scope.row.name}}</el-button>
            </template>
        </el-table-column>  
    </el-table>
</template>


<script setup lang="ts">

const data=[
    {name:'Tom', date:'2023-03-15', gender:'male'},
    {name:'Jerry', date:'2023-03-15', gender:'male'},
    {name:'Tara', date:'2023-03-15', gender:'female'},
    {name:'Tuffy', date:'2023-03-15', gender:'female'},
]

</script>  

<style>
    table{
        margin:0 !important; /* 可能是Element 的样式与主题默认样式冲突,所以需要手动修复 */
    }
</style>

默认插槽

上面的代码中,通过<template #default="scope"></template> 将模板中的内容添加到表格中,所利用的就是插槽。

插槽是在新建元素时,提供一个占位符,以便后期向元素中添加更多元素,模板与插槽的功能也是HTML 默认所支持的模板与插槽,但是使用上并没有vue 的插槽简单、强大:

<!-- 定义MyDiv 组件 -->
<template>
    <div>
        <slot>缺省内容</slot>
    </div>
</template>  

<!-- 使用MyDiv 组件 -->
<MyDiv></MyDiv>
<MyDiv>替换缺省内容</MyDiv>

具名插槽

顾名思义,这类插槽在定义时需要指定name 属性,在使用时也会按name 属性替换响应的内容,el-table-clomun 中的插槽就是这一类,只不过它的名字属于缺省名字default#slot-name 相当于v-slot:slot-name 的缩写)。具名插槽可以实现一个组件中存在多个插槽:

<!-- 定义MyDiv 组件 -->
<template>
    <div>
        <slot name="header">缺省头部</slot><br/>
        <slot name="body">缺省内容</slot>
    </div>
</template>  


<!-- 使用MyDiv 组件 -->
<MyDiv>
    <span #header>header</span>
    <div v-slot:body>body</div>
</MyDiv>

作用域插槽

因为插槽中的内容无法访问到子组件的状态,但是有时我们需要用到父、子组件域内的数据,就像我们需要在<el-table></el-table> 中使用数据一样,这时我们就需要在插槽的出口上传递一些attributes:

<!-- <MyComponent> 的模板 -->
<script setup>
import {ref} from 'vue'

const count = ref(1)
const count2 = ref(1)

function add(){
  count.value++;
  count2.value++;
}
</script>

<template>
  <div  @click="add">
  	<slot :text="'slot1'" :count="count">original slot 1</slot>
    <div></div>
  	<slot name="slot-2" :text="'slot2'" :count="count2">original slot 2</slot>
	</div>
</template>



<!-- 使用MyComponent 组件 -->
<MyComponent #slot-2="slotProps">
  	{{ slotProps.text }} {{ slotProps.count }}
</MyComponent>

<!-- 渲染效果就是:
original slot 1
slot2 1
-->

也就是说, 子组件通过作用域插槽向父组件暴露了一个访问其内部数据的接口。 这样回头看<el-table-column></el-table-column> 也就容易理解,这个组件默认会暴露内部的row 也就是行数据或者叫做某个元素到父组件。下面两种写法是等效的:

<el-table-column label="Operation" #default="scope">
    <el-button type="primary">Edit {{scope.row.name}}</el-button>
</el-table-column>    

<!-- 或者我们应该更习惯下面这种写法 -->
<el-table-column label="Operation">
    <template #default="scope">
        <el-button type="primary">Edit {{scope.row.name}}</el-button>
    </template>
</el-table-column>  

事实上也可以访问到scope.column,但是比较少用。

可编辑单元格组件

我们需要的效果是要有这么一个组件,平时是文本,在被单击时,其内部的元素变成input, select, date-picker 等类型。首先我们可以通过is 属性来实现动态组件的切换。

动态组件

通过<component :is="conponent_type"></component> 可以动态修改一个元素的类型。该动态元素内部还可以增加插槽,用于添加更多自定义的内容。v-bind="$attrs" 可以透传组件上的所有props 包括v-model 双向绑定的指令。

<!-- editablecell.vue  -->
<template>
    <component :is="props.component_type" v-bind="$attrs">
        <slot name="edit-component-slot">"slot content"</slot>
    </component>
</template>

<script setup lang="ts">
import { defineProps } from 'vue';
const props = defineProps(['component_type'])
</script>

编辑状态切换

设计双击单元格可以切换编辑状态,按EnterESC 退出编辑状态。于是我们需要另一个插槽,通过v-if 指令渲染具体的组件。

<template>
    <div @dblclick="editable = true">
        <div v-if="!editable">
            <slot name="content"></slot>
        </div>
        <component :is="component_type" v-bind="$attrs" 
            v-if="editable" 
            @keyup.enter="editable = false"
            @keyup.esc="editable = false">
            <slot name="edit-component-slot">"slot content"</slot>
        </component>
    </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const props = defineProps(['component_type'])
const editable = ref(false)
</script>

可编辑性

有时我们还需要根据条件去判断表格内容是否可编辑,例如人员权限等,所以需要额外的判断条件。另外因为vue 在渲染空白字符串时可能会造成<div></div> 标签高度为0,所以还需要人为修复一个小bug,以下就是完整代码:

<template>
    <div @click="edit" class="editable-cell">
        <!-- 如果单元格是可编辑的,则鼠标移上去之后会变形,且会有消息提示 -->
        <ElTooltip v-if="editable && !isBeingEdited" placement="bottom">
            <template v-if="editable" #content>Click to edit<br />Press Enter/Esc/Tab to exit</template>

            <slot></slot>
        </ElTooltip>
        <component ref="input" autofocus="true" v-else-if="editable && isBeingEdited" :is="component" v-bind="$attrs"
            @keyup.enter="finishEditing" @keyup.esc="finishEditing" @keyup.tab="finishEditing">
            <slot name="sub-component"></slot>
        </component>
        <div v-else class="uneditable-cell">
            <slot></slot>
        </div>
    </div>
</template>

<script setup lang="ts">
import { ElTooltip } from 'element-plus';
import { nextTick, ref } from 'vue';

defineProps([
    'editable',   // the editable switch
    'component',  // editable componenet instance
]);
const isBeingEdited = ref(false);  // internal editing state
const input = ref()


function finishEditing() {
    isBeingEdited.value = false
}

function edit(e) {
    if (props.editable) {
        isBeingEdited.value = true
        // can not get ref input before the element is created
        nextTick(() => {
            input.value.focus()
        })
    }
}

</script>



<style scoped>
.editable-cell {
    min-height: 32px;
    /* set minimum height to extend the div container */
    cursor: pointer;
}

.uneditable-cell {
    cursor: default;
}
</style>

使用示例

下面的例子可以演示用法,双击文本就可以打开一个<el-select></el-select> 并编辑选项,按enteresc 键就能退出编辑状态。唯一需要注意的是:如果res_1 的类型与<el-select></el-select> 中的<el-option></el-option>value 的类型不一致,则label 标签渲染可能会有异常。

<editablecell component="el-select" v-model="res_1" @change="test" editable="true"> 
    <span>单击这里:-- {{res_1}} --</span>
    <template #sub-component>
        <el-option v-for="n in 3" :key="n" :label="`option: ${n}`" :value="n"/>
    </template>
</editablecell>
可以看到所有的日期信息都是可以修改的,并且只有`Tuffy` 的性别可以被修改:
<div>{{ `$ {data[3].name}: ${data[3].date}: ${data[3].gender}` }}</div> 

Bug 修复

  • 添加按键,用于修复el-select 点击option 时导致选择框失焦的问题。
<template>
    <div class="editable-cell">
        <div v-if="editable && !isBeingEdited" placement="bottom" @click="edit">
            <el-link type="primary" :icon="Edit" plain />
            <slot></slot>
        </div>
        <div v-else-if="editable && isBeingEdited">
            <el-link type="success" :icon="Check" plain @click="finishSelectEditing" />
            <component ref="input" autofocus="true" :is="component" v-bind="$attrs" @blur="finishEditing">
                <slot name="sub-component"></slot>
            </component>
        </div>
        <div v-else class="uneditable-cell">
            <slot></slot>
        </div>
    </div>
</template>

<script setup lang="ts">
import { Check, Edit } from '@element-plus/icons-vue';
import { computed, nextTick, ref } from 'vue';

const props = defineProps([
    'editable',   // the editable switch
    'component',  // editable componenet instance
]);
const isSelect = computed(() => {
    return props.component == 'el-select'
})
const isBeingEdited = ref(false);  // internal editing state
const input = ref()


function finishEditing() {
    if (isSelect.value) {
    } else {
        isBeingEdited.value = false
    }
}


function edit(e) {
    isBeingEdited.value = true
    // can not get ref input before the element is created
    nextTick(() => {
        input.value.focus()
    })
}

function finishSelectEditing() {
    isBeingEdited.value = false
}
</script>



<style scoped>
.editable-cell {
    min-height: zpx;
    /* set minimum height to extend the div container */
    cursor: pointer;
}

.uneditable-cell {
    cursor: default;
}
</style>