升级版多选框
前言:
大家好,这里是藤原豆腐店,相信大家都有用过ElementUI的CheckBox多选框和Select选择器,如果是有层级的多项框,这两个就不是那么好用了
下面这个升级版多项框,整合了Select和CheckBox,同时支持搜索,二级标签多选和已选列表展示等功能
主体结构
<template>
<div>
<el-popover
:popper-class="`select-tag-popover ${popperClass}`"
placement="bottom-start"
title=""
width="570"
trigger="click"
@show="toShowPopover"
@hide="toHidePopover"
@after-leave="toHideAfterPopover">
<!-- 标签选项列表 -->
<div :class="['select-vertical-wrap', `vertical-${themeColor}`]">
<!-- 左半边 -->
<div class="left-wrap">
<!-- 操作区域 -->
<div class="operate-area"></div>
<!-- 选择标签区域 -->
<div class="group-area"></div>
</div>
<!-- 右半边 -->
<div class="left-wrap">
<!-- 操作区域 -->
<div class="operate-area"></div>
<!-- 选择标签区域 -->
<div class="group-area"></div>
<!-- 垂直方向:已选标签 -->
<div class="selected-area"></div>
</div>
</div>
<!-- 下拉选择框 -->
<template slot="reference">
<div :class="['select-horizontal-wrap', `horizontal-${themeColor}`, isShowSelect ? 'horizontal-click' : '']" @click="selectEmit">
<div class="main-wrap"></div>
<div class="icon-wrap"></div>
</div>
</template>
</el-popover>
</div>
</template>
el-popover作为标签选择器的容器
通过popper-class配置popover样式
.select-tag-popover {
padding: 0;
/deep/ .el-checkbox {
display: flex;
align-items: center;
}
/deep/ .el-checkbox .el-checkbox__input.is-checked .el-checkbox__inner {
background-color: #4471e1;
border-color: #4471e1;
&::after {
border-color: #fff;
}
}
/deep/ .el-checkbox .el-checkbox__input.is-indeterminate .el-checkbox__inner {
background-color: #4471e1;
border-color: #4471e1;
&::after {
border-color: #fff;
}
}
}
同时可以通过props项:popperClass,用于定制标签显示框的样式
popperClass: { // popperClass
type: String,
default: ''
},
el-popover的几个内置方法
// 显示popover触发
toShowPopover() {
this.isShowSelect = true
this.$emit('toShowPopover')
},
// 隐藏popover触发
toHidePopover() {
this.isShowSelect = false
this.$emit('toHidePopover')
},
// 隐藏popover动画之后触发
toHideAfterPopover() {
this.searchWord = '' //清空关键词
this.renderList = this.groupList //将数据添加渲染列表
this.$emit('toHideAfterPopover')
},
isShowSelect用于控制Popover显示的效果
// 水平方向选择框-点击效果
.horizontal-click {
border: 1px solid #409EFF;
}
.icon-wrap {
width: 35px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid #DCDFE6;
.arrow{
transition: transform .3s,-webkit-transform .3s;
transform:rotateZ(0deg);
}
.up{
transform:rotateZ(-180deg) !important;
}
}
标签选择器主体
<!-- 标签选项列表 -->
<div :class="['select-vertical-wrap', `vertical-${themeColor}`]">
<div class="left-wrap">
<!-- 操作区域 -->
<div class="operate-area">
<!-- 搜索 -->
<el-input
class="search-wrap"
placeholder="搜索"
v-model="searchWord"
@input="onSearchInput"
@keyup.enter.native="toSearch">
<i slot="suffix" class="el-input__icon el-icon-search" @click="toSearch"></i>
</el-input>
<div class="checked-all-wrap">
<!-- 全选 -->
<el-checkbox :indeterminate="isI" :value="isAll" @change="handleCheckAllGroupChange">全选</el-checkbox>
<!-- 标签总数 -->
<div class="total-tag">共{{total}}项</div>
</div>
</div>
<!-- 选择标签区域 -->
<div class="group-area">
<div class="group-cell" v-for="(group, index) in renderList" :key="index">
<div class="group-top">
<!-- 收起、展开标签组 -->
<img
class="icon-put"
:class="{'right': group.isPut}"
:src="`xx/images/icon-down.png`"
@click="toChangePut(group)"
/>
<!-- 标签组名称 -->
<el-checkbox :indeterminate="group.isI" :value="group.isAll" @change="handleCheckAllChange(group, $event)">
{{group.tagGroupName}}
</el-checkbox>
</div>
<!-- 组下标签列表 -->
<div class="group-content" :class="{'hidden': group.isPut}" v-for="(tag, n) in group.tags" :key="n">
<el-checkbox :value="tag.isSelected" @change="handleCheckChange(group, tag, $event)" :disabled="!!tag.disabled">
<div class="tag-name">{{tag.name}}</div>
</el-checkbox>
</div>
</div>
</div>
</div>
<div class="right-wrap">
<!-- 操作区域 -->
<div class="operate-area">
<!-- 已选项 -->
<div class="count">
已选
<div class="blue">{{selectedList.length}}</div>
项
</div>
<!-- 清空 -->
<div class="delete" @click="toDeleteAll">清空</div>
</div>
<!-- 垂直方向:已选标签 -->
<div class="selected-area">
<div class="selected-cell" v-for="(tag, k) in selectedList" :key="k">{{tag.name}}</div>
</div>
</div>
</div>
// 垂直方向选择框-选项列表
.select-vertical-wrap {
display: flex;
height: 100%;
// height: 360px;
// overflow: hidden;
/deep/ .el-checkbox {
display: flex;
align-items: center;
}
/deep/ .el-checkbox, .el-checkbox__input{
white-space: normal !important;
}
/deep/ .el-checkbox .el-checkbox__input.is-checked .el-checkbox__inner {
background-color: #4471e1;
border-color: #4471e1;
&::after {
border-color: #fff;
}
}
/deep/ .el-checkbox .el-checkbox__input.is-indeterminate .el-checkbox__inner {
background-color: #4471e1;
border-color: #4471e1;
&::after {
border-color: #fff;
}
&::before {
background-color: #fff;
}
}
.disable{
cursor: not-allowed;
}
}
data() {
return {
renderList: [], // 渲染列表
groupList: [], // 标签组列表
searchList: [], // 搜索列表
isShowSelect: false,
searchWord: '', // 搜索关键词
isI: false, // 是否显示 -
isAll: false, // 是否全选
}
},
左半边的核心功能
样式实现
.left-wrap {
width: 50%;
border-right: 1px solid #ebebeb;
/* 操作区域 */
.operate-area {
padding: 10px 10px 0;
border-bottom: 1px solid #ebebeb;
.search-wrap {
.el-icon-search {
cursor: pointer;
}
}
.checked-all-wrap {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 0;
.total-tag {
font-size: 12px;
color: #999;
}
}
}
/* 选择标签区域 */
.group-area {
max-height: 320px;
overflow-x: hidden;
overflow-y: scroll;
padding: 15px 0;
@include scrollbarColor(#ddd);
.group-cell {
display: flex;
flex-direction: column;
margin-bottom: 15px;
&:last-child {
margin-bottom: 0;
}
.group-top {
display: flex;
align-items: center;
padding: 0 10px;
.icon-put {
cursor: pointer;
margin-right: 10px;
margin-bottom: 2px;
}
.right {
transform: rotate(-90deg)
}
.up {
margin-bottom: 5px;
}
}
.group-content {
display: flex;
align-items: center;
padding: 10px 3px 0 56px;
.tag-name {
max-width: 210px;
font-size: 14px;
color: #333;
// text-overflow: ellipsis;
// white-space: nowrap;
// overflow: hidden;
}
}
.hidden {
height: 0;
display: none;
}
}
}
}
勾选
勾选全选
// 监听 全选所有
handleCheckAllGroupChange(val) {
this.renderList.forEach(g => {
g.isAll = val
g.isI = false
// 选择所有未禁用的父标签
g.tags && g.tags.forEach(t => {
if(!t.disabled){
t.isSelected = val
}
})
});
},
勾选标签组
// 监听 选择标签组
handleCheckAllChange(group, val) {
group.isI = false //控制全选框的样式
group.isAll = val //标签组选择设置为true
// 存在子标签的话
group.tags && group.tags.forEach(t => {
// 选择当前标签组所有未禁用的子标签
if(!t.disabled){
t.isSelected = val
}
})
},
勾选单个标签
// 监听 选择标签
handleCheckChange(group, tag, val) {
// 更改当前标签状态
tag.isSelected = val
// 判断当前标签组是否已全选
group.isAll = group.tags && group.tags.filter(m => !m.disabled).every(m => m.isSelected)
// 判断全选框的样式
group.isI = !!(group.tags && group.tags.filter(m => !m.disabled).some(m => m.isSelected) && !group.isAll)
},
搜索
<el-input
class="search-wrap"
placeholder="搜索"
v-model="searchWord"
@input="onSearchInput"
@keyup.enter.native="toSearch">
<i slot="suffix" class="el-input__icon el-icon-search" @click="toSearch"></i>
</el-input>
// 搜索输入值改变回调
onSearchInput(val) {
if (!val) {
// 重置标签组数据
this.renderList = this.groupList
}
},
// 搜索
toSearch() {
if (!this.groupList.length) return
let searchList = []
this.groupList.forEach(g => {
// 搜索标签组
const isMacthGroup = g.tagGroupName && g.tagGroupName.includes(this.searchWord)
// 搜索标签项
const isMacthTag = g.tags && g.tags.some(t => t.name.includes(this.searchWord))
// 只要匹配到就显示整个标签组
if (isMacthGroup || isMacthTag) {
g.isPut = false
searchList.push(g)
}
});
// 更新标签组列表
this.renderList = searchList
},
右半边的核心功能
样式实现
.right-wrap {
width: 50%;
/* 操作区域 */
.operate-area {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #ebebeb;
padding: 10px 15px;
.count {
display: flex;
align-items: center;
font-size: 14px;
color: #333;
.blue {
font-size: 14px;
color: #2C6FEA;
}
}
.delete {
font-size: 14px;
color: #2C6FEA;
cursor: pointer;
&:active {
opacity: 0.7;
}
}
}
/* 垂直方向:已选标签 */
.selected-area {
max-height: 400px;
// max-height: 321px;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow-y: scroll;
overflow-x: hidden;
padding: 15px;
@include scrollbarColor(#ddd);
.selected-cell {
font-size: 14px;
color: #333;
margin-bottom: 15px;
&:last-child {
margin-bottom: 0;
}
}
}
}
清空已选择
// 清空
toDeleteAll() {
this.groupList.forEach(g => {
g.isI = false
g.isAll = false
g.tags && g.tags.forEach(t => {
t.isSelected = false
})
})
}
这里通过props配置项也可以执行清空操作
isClear: { // 是否清空
type: Boolean,
default: false
},
watch: {
isClear(nVal) {
this.toDeleteAll()
},
},
数据展示
显示已选择列表
<!-- 垂直方向:已选标签 -->
<div class="selected-area">
<div class="selected-cell" v-for="(tag, k) in selectedList" :key="k">{{tag.name}}</div>
</div>
computed:{
// 已选择的标签列表
selectedList() {
// 处理全选框样式
this.handleAllBtnStyle()
// 已选对象列表
const selectedList = this.groupList.reduce(
(pre, cur) =>
cur.tags && cur.tags.length ? pre.concat(cur.tags.filter(m => m.isSelected)) : pre,
[]
)
// 已选id列表
const outPutIdList = selectedList.map(m => m.id)
this.$emit('toGetTagIdList', outPutIdList)
return selectedList
}
}
// 处理全选按钮样式
handleAllBtnStyle() {
let leftSelectedCount = 0 // 左侧列表中已选个数(包括搜索+未搜索情况下)
let leftDisabledCount = 0 // 左侧列表中已禁用个数(包括搜索+未搜索情况下)
// 遍历列表
this.renderList.forEach(g => {
if (g.tags && g.tags.length) {
const isSelectedList = g.tags.filter(t => {
// 统计已禁用的个数
leftDisabledCount += t.disabled ? 1 : 0
// 返回已选的
return t.isSelected
})
leftSelectedCount += isSelectedList.length
}
})
// 判断是否全选(已选的 = 总数-已禁用的)
this.isAll = !!(leftSelectedCount && leftSelectedCount === (this.total - leftDisabledCount))
this.isI = !!(leftSelectedCount && leftSelectedCount < (this.total - leftDisabledCount))
},
select显示框
此处模仿el-select的多选样式
<template slot="reference">
<!-- 下拉选择框 -->
<div :class="['select-horizontal-wrap', `horizontal-${themeColor}`, isShowSelect ? 'horizontal-click' : '']" @click="selectEmit">
<div class="main-wrap">
<!-- 水平方向:已选标签 -->
<div class="list-wrap" v-if="selectedList.length">
<div class="list-cell" v-for="(item, index) in selectedList" :key="index">
<div class="cell-name">{{item.name}}</div>
<img class="icon-delete" src="@/images/icon-close-1.png" @click.stop="toDelete(item)"/>
</div>
</div>
<!-- 占位符 -->
<div class="place-name" v-else>请选择</div>
<!-- 已选个数 -->
<div class="count" v-if="selectedList.length">{{selectedList.length}}</div>
</div>
<div class="icon-wrap">
<img :class="['arrow',{'up':isShowSelect}]" src="@/images/icon-down.png" />
</div>
</div>
</template>
样式实现
// 水平方向选择框-选项列表
.select-horizontal-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
border: 1px solid #DCDFE6;
box-sizing: border-box;
background: #fff;
cursor: pointer;
border-radius: 4px;
.main-wrap {
min-width: 0;
height: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 10px;
/* 列表框 */
.list-wrap {
min-width: 0;
height: calc(100% - 2px);
display: flex;
align-items: center;
overflow-x: scroll;
flex: 1;
// 隐藏滚动条及其轨道
scrollbar-color: transparent transparent;
scrollbar-track-color: transparent;
-ms-scrollbar-track-color: transparent;
scrollbar-width: none;
// 隐藏滚动条(适配多个浏览器)
&::-webkit-scrollbar {
height: 0;
display: none;
}
&::-moz-scrollbar {
display: none;
}
&::-ms-scrollbar {
display: none;
}
&::-o-scrollbar {
display: none;
}
.list-cell {
height: 100%;
display: flex;
align-items: center;
background: #f7f8f9;
border-left: 1px solid #DCDFE6;
border-right: 1px solid #DCDFE6;
box-sizing: border-box;
border-radius: 2px;
flex-shrink: 0;
margin-right: 6px;
padding: 4px 7px;
&:first-child {
border-left: none;
}
.cell-name {
font-size: 12px;
color: #333;
}
.icon-delete {
width: 12px;
height: 12px;
margin-left: 5px;
cursor: pointer;
&:active {
opacity: 0.7;
}
}
}
}
.place-name {
font-size: 14px;
color: #c0c4cc;
margin-left: 10px;
}
.count {
width: 24px;
height: 24px;
background: #f7f8f9;
border-radius: 50%;
font-size: 12px;
color: #333;
line-height: 24px;
text-align: center;
margin-left: 10px;
}
}
/* 下拉图标 */
.icon-wrap {
width: 35px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid #DCDFE6;
.arrow{
transition: transform .3s,-webkit-transform .3s;
transform:rotateZ(0deg);
}
.up{
transform:rotateZ(-180deg) !important;
}
}
}
// 水平方向选择框-点击效果
.horizontal-click {
border: 1px solid #409EFF;
}
// 水平方向选择框-蓝色主题
.horizontal-blue {
background: #1e2846;
border: 1px solid #39415d;
/deep/ .main-wrap {
.place-name {
color: #434D6B;
font-size: 14px;
}
.list-wrap {
height: calc(100% - 8px);
padding-left: 5px;
.list-cell {
background: #27314f;
border: none;
border-radius: 4px;
padding: 5px 7px;
.cell-name {
line-height: 1;
color: #fff;
}
.icon-delete {
width: 10px;
height: 10px;
}
}
}
.count {
color: #A4A8B4;
background: #27304F;
margin-left: 5px;
}
}
.icon-wrap {
border-left: 1px solid #434D6B;
}
}
删除单个标签项
// 删除
toDelete(item) {
const groupIndex = item.groupIndex
const group = this.groupList[groupIndex]
item.isSelected = false
group.isAll = group.tags.every(m => m.isSelected)
group.isI = !!(group.tags.some(m => m.isSelected) && !group.isAll)
this.$emit('toDelete', item)
},
点击下拉框
// 点击下拉框
selectEmit(){
this.$emit('visibleChange')
}
数据请求
通过type跟tagsParams传入接口请求参数,通过watch监听触发对应的方法
这里也可以改为在外面触发接口,传数据进来
props: {
type: { // 下拉标签列表参数:type:视频:1,音频:2,文案:3,图片
type: Number,
default: 1
},
tagsParams: { // 下拉标签列表:请求体
type: Object,
default: () => ({})
},
}
watch: {
type: {
handler(nVal) {
this.getList()
},
immediate: true
},
tagsParams: {
handler(nVal) {
this.getList()
},
deep: true,
// immediate: true
},
}
// 获取标签列表
async getList() {
try {
this.getPopperTagList()
} catch (error) {
console.error(error)
this.$message.error(error)
}
},
// 获取下拉标签列表请求参数
getTagsParams() {
let params = {}
if (this.tagsParams && Object.keys(this.tagsParams).length) {
params = this.tagsParams
} else {
params = {
scope: this.type
}
}
return params
},
// 获取下拉标签列表
async getPopperTagList() {
try {
const params = this.getTagsParams()
const res = await getTagList(params)
if (res && res.data && res.data.code === 0) {
let { data } = res.data
this.handleSelectList(data)
} else {
this.$message.error(res && res.data && res.data.msg)
}
} catch (error) {
console.error(error)
this.$message.error(error)
}
},
回显标签组的数据
ids: { // 传入标签组id列表
type: Array,
default: () => ([])
},
watch:{
ids: {
handler(nVal) {
this.echoIdList = nVal //存储传入标签组id
this.getList()
},
deep: true,
immediate: true
},
}
对标签组id列表进行匹配
// 获取 { groupId: [id] } 回显标签对象
getEchoTagObj(echoIds = [], tagsList = []) {
let idObj = {}
if (!echoIds.length) return idObj
// 回显ids是组id的对象数组
const isExistGroupId = echoIds.some(m => m.tagGroupId)
// 回显ids是标签id的数组
const isExistTagId = !isExistGroupId && echoIds.some(m => (m !== null && Number(m) > 0))
if (isExistGroupId) {
idObj = echoIds.reduce((pre, cur) => {
let group = { ...pre }
if (cur.tagGroupId && cur.tags && cur.tags.length) {
// 将只存有id的映射数组传给group
group[cur.tagGroupId] = cur.tags.map(m => m.id)
}
return group
}, {})
// 回显ids是标签id的对象数组
} else if (isExistTagId) {
idObj = this.getEchoObjByTagIds(echoIds, tagsList)
}
// 返回只有id的数组
return idObj
},
// 根据回显的标签id数组,返回 { groupId: [id] }对象
getEchoObjByTagIds(echoIds, tagsList) {
let idObj = {}
tagsList.forEach((group) => {
if (group.tagGroupId && group.tags && group.tags.length) {
const tagIds = echoIds
const matchingTags = group.tags.filter(tag => tagIds.includes(tag.id));
if (matchingTags.length) {
// 将只存有id的映射数组传给group
idObj[group.tagGroupId] = matchingTags.map(tag => tag.id);
}
}
})
return idObj
},
处理标签列表
// 处理标签列表
handleSelectList(data){
if(!data)return
const echoTagObj = this.getEchoTagObj(this.echoIdList, data)
console.log('idlist',echoTagObj)
// 遍历标签列表,寻找回想对象
data && data.forEach((g, i) => {
// 将对象的属性名转成数组
const groupIdsArr = Object.keys(echoTagObj) || []
const isExistEcho = groupIdsArr.includes(String(g.tagGroupId))
// 当前组id存在回显对象中
if (isExistEcho) {
const idsArr = echoTagObj[g.tagGroupId]
g.isI = g.tags && idsArr && idsArr.length && idsArr.length < g.tags.length || false
g.isAll = g.tags && idsArr && idsArr.length === g.tags.length || false
g.tags && g.tags.forEach(t => {
t.isSelected = idsArr.includes(t.id)
t.groupIndex = i
})
} else {
g.isI = false
g.isAll = false
g.tags && g.tags.forEach(t => {
t.isSelected = false
t.groupIndex = i
})
}
g.isPut = false
});
this.renderList = data
this.groupList = data
},
父组件配合el-form使用
<el-form-item label="标签:" label-width="72px" prop="tagIds">
<div class="right-content">
<SelectTag
ref="selectTag"
class="my-select-tag"
popperClass="blue-select-tag-popover"
themeColor="white"
:tagsParams="tagsApiParams"
@toGetTagIdList="toGetTagIdList"
@visibleChange="visibleChange"
/>
</div>
</el-form-item>
自定义css样式
.right-content{
.my-select-tag {
overflow-x: hidden;
width: 470px;
height: 32px;
border-radius: 4px;
}
}
.blue-select-tag-popover {
border: none;
.popper__arrow {
border-bottom-color: #27314f !important;
border-top-color: #27314f !important;
&::after {
border-bottom-color: #27314f !important;
border-top-color: #27314f !important;
}
}
@media (width: 1280px) {
.option-list .left-wrap .group-area {
max-height: 160px !important;
}
.option-list .right-wrap .selected-area {
max-height: 240px !important;
}
}
@media (width: 1440px) {
.option-list .left-wrap .group-area {
max-height: 160px !important;
}
.option-list .right-wrap .selected-area {
max-height: 240px !important;
}
}
@media (width: 1920px) {
.option-list .left-wrap .group-area {
max-height: 320px !important;
}
.option-list .right-wrap .selected-area {
max-height: 400px !important;
}
}
}
标签是必选项时,使用自定义校验规则进行校验
data() {
var validatePass = (rule, value, callback) => {
// 只在提交的时候显示提示
if (value.length==0 && this.isAudit) {
callback(new Error('请选择标签'));
} else {
callback();
}
};
return(){
isAudit:false//是否点击提交
// 审核弹窗表单规则
auditRules:{
tagIds: [
{ required: true,validator: validatePass,trigger: 'change'}
],
},
}
}
获取标签选择数据
// 获取选择的标签id列表
toGetTagIdList(ids) {
this.auditForm.tagIds = ids
},
点击下拉框时,清空选择框的必选校验提示
// 点击下拉框
visibleChange(){
this.$refs.auditForm.clearValidate("tagIds");
},
重置表单数据需要调用多选框方法清空已选标签
// 重置
toReset(type) {
this.$refs.selectTag.toDeleteAll();//重置标签选项
this.$refs.auditForm.resetFields();//重置表单
this.isAudit=false
},
转载自:https://juejin.cn/post/7267837037785415735