摘要:
本文实现了一个Vue3折叠面板组件,包含两个核心文件:ibmCollpase.vue(父组件)和ibmCollpaseItem.vue(子组件)。父组件通过provide提供共享状态和方法,子组件通过inject获取。主要功能包括:
支持手风琴(accordion)模式和普通模式
提供beforeCollapse钩子函数验证
实现展开/折叠动画效果
支持自定义展开图标位置
包含disabled禁用状态处理
组件通过v-model双向绑定activeNames,并暴露setActiveNames方法供外部调用。
html
<script setup>
import { provide, computed, watch,defineEmits } from 'vue';
const props = defineProps({
accordion: Boolean,
expandIconPosition: {
type: String,
default: 'right'
},
beforeCollapse:Function,
})
const modelValue = defineModel({
type: [Array, String, Number],
default: () => []
})
const emit = defineEmits(['change'])
// 修复 ensureArray 函数
const ensureArray = (value) => {
if (value === undefined || value === null || value === '') return []
let result = []
if (typeof value === 'string') {
// 将字符串拆分为单个字符的数组
result = value.split('')
} else if (typeof value === 'number') {
// 数字转为字符串再拆分
result = String(value).split('')
} else if (Array.isArray(value)) {
result = [...value]
}
// 根据 accordion 模式过滤结果
if (props.accordion && result.length > 0) {
return [result[0]] // 手风琴模式只取第一个元素
}
return result
}
// 使用计算属性 - 修复这里
const activeNames = computed({
get: () => {
const result = ensureArray(modelValue.value)
return result
},
set: (value) => {
// 直接设置到 modelValue,让父组件同步更新
modelValue.value = value
}
})
// 点击面板项的处理
const handleClick = (name) => {
const {beforeCollapse}= props
// 如果没有传入beforeCollapse方法,则直接执行_handleClick方法
if(!beforeCollapse){
_handleClick(name)
return
}
const sholdChange=beforeCollapse(name)
const bool= [isBoolean(sholdChange),isPromise(sholdChange)].includes(true)
if(!bool){
throw new Error('beforeCollapse 必须返回布尔值或 Promise 对象')
}
if(isPromise(sholdChange)){
sholdChange.then((res) => {
//调的是resolve方法
if(res!=false){
handleClick(name)
}
}).catch((error) => {
//调的是reject方法
//不会调用_handleClick方法
})
}
else if(sholdChange){
_handleClick(name)
}
}
//判断是不是布尔类型
const isBoolean = (value) => {
return typeof value === 'boolean'
}
//判断是不是promise类型
const isPromise = (value) => {
return value instanceof Promise
}
const _handleClick = (name) => {
const currentValue = [...activeNames.value]
if (props.accordion) {
const index = currentValue.indexOf(name)
activeNames.value = index > -1 ? [] : [name]
emit('change', activeNames)
} else {
const index = currentValue.indexOf(name)
if (index > -1) {
currentValue.splice(index, 1)
} else {
currentValue.push(name)
activeNames.value = currentValue
emit('change', activeNames)
}
}
activeNames.value = currentValue
emit('change', activeNames)
}
// provide 依赖注入
provide("collapseContentKey", {
activeNames,
handleClick,
})
// 修复 setActiveNames 方法
const setActiveNames = (_activeNames) => {
activeNames.value = ensureArray(_activeNames)
}
defineExpose({
activeNames,
setActiveNames,
})
</script>
<template>
<!-- collpase 折叠组件 -->
<!-- 这里是折叠组件的容器 开始
icon-position-left/right 这个class是控制折叠面板的标题位置
expand-icon-position: left/right 这个class是控制展开按钮的位置
默认在右边
-->
<div class="ibc-collpase" :class="[`icon-position-${props.expandIconPosition}`]">
<!-- 面板项 开始
面板展开效果,通过is-active来控制
is-disabled 禁用状态
-->
<slot></slot>
</div>
<!-- 这里是折叠组件的容器 结束 -->
</template>
<style scoped>
/* CSS代码全部写这里 */
.ibc-collpase{
border-top:1px solid #ccc;
}
</style>
//ibmCollpaseItem
html
<script setup>
import { ref, inject, defineProps,defineExpose, computed, useId, watch } from 'vue'
// const isActive = ref(false)
//defineProps的作用是:定义props,并将props注入到当前组件的实例上
//props是父组件传递给子组件的自定义属性,子组件可以通过props来接收父组件传递过来的数据
const props = defineProps({
name: String,
disabled: Boolean,
title: String,
})
//在这里,我们要判断是否有传name,如果没有传,则要给name一个唯一的默认值,防止报错
//如果传了name,则不处理
const name = props.name == undefined ? useId() : props.name
// 注入父组件的状态
//inject 从父组件接收数据
//父组件里有 collapseContenKey 带有值,值的内容如下:
//父组件里有 activeNames 数组
//父组件里有 handleClick 方法
const collapseContent = inject("collapseContentKey")
//定义一个变量,用来保存当前组件最开始渲染时的状态
let isStartDisabled = collapseContent.activeNames.value.includes(name)
watch(
() => props.disabled,
() => {
isStartDisabled = collapseContent.activeNames.value.includes(name)
}
);
//操作到这里,我们拿到了子组件的自定义属性name,以及父组件传递过来的activeNames数组
//我们可以根据activeNames数组完成isActive的状态的切换
const isActive = computed(() => {
if (props.disabled)
return isStartDisabled
return collapseContent.activeNames.value.includes(name)
})
function onBeforeEnter(el) {
el.style.height = '0px'
}
function onEnter(el, done) {
el.style.height = el.scrollHeight + 'px'
// done()
}
function onBeforeLeave(el) {
el.style.height = el.scrollHeight + 'px'
}
function onLeave(el, done) {
el.style.height = '0px'
// done()
}
//点击标题问题
const handleClick = () => {
// 判断当前是否禁用
if (props.disabled) {
return
}
// 切换isActive状态
collapseContent.handleClick(name)
}
defineExpose({
isActive,
})
</script>
<template>
<!-- 面板项 开始
面板展开效果,通过is-active来控制
is-disabled 禁用状态
-->
<div class="ibc-collpase-item" :class="{
'is-active': isActive,
'is-disabled': disabled,
}">
<!-- 面板项头部 开始 -->
<div class="ibc-collpase-item__header" @click='handleClick'>
<div class="ibc-collpase-item__title" :class="{ 'is-active': isActive }">
<slot name="title" :isActive="isActive" >
{{ props.title }}
</slot>
</div>
<div class="ibc-collpase-item__icon" :class="{ 'is-active': isActive }">
<slot name="icon" :isActive="isActive">
<div class="ibc-icon-arrow"></div>
</slot>
</div>
</div>
<!-- 面板项头部 结束 -->
<!-- 面板项内容 开始
style='height: 200px;' 控制面板项内容的高度
-->
<Transition @before-enter="onBeforeEnter" @enter="onEnter" @before-leave="onBeforeLeave" @leave="onLeave">
<div class="ibc-collpase-item__wrap" v-show="isActive">
<div class="ibc-collpase-item__content">
<slot></slot>
</div>
</div>
</Transition>
<!-- 面板项内容 结束 -->
</div>
<!-- 面板项 结束 -->
</template>
<style scoped>
/* CSS代码全部写这里 */
.ibc-collpase-item {
border-bottom: 1px solid #ccc;
}
.ibc-collpase-item__header {
height: 80px;
/* background-color: skyblue; */
display: flex;
align-items: center;
cursor: pointer;
}
.ibc-collpase-item__title {
flex: 1;
/* 占据剩余空间 */
font-size: 18px;
}
.ibc-icon-arrow {
width: 24px;
height: 24px;
/* border:1px solid red; */
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease-in-out;
}
.ibc-icon-arrow::before {
content: "";
width: 12px;
height: 12px;
display: block;
border-top: 1px solid #333;
border-right: 1px solid #333;
transform: translateX(-25%) rotate(45deg);
}
.ibc-collpase-item.is-active .ibc-collpase-item__title {
color: tomato;
}
.ibc-collpase-item__icon.is-active .ibc-icon-arrow {
transform: rotate(90deg);
}
.ibc-collpase-item__icon.is-active .ibc-icon-arrow::before {
border-color: tomato;
}
.ibc-collpase.icon-position-right .ibc-collpase-item__title {
order: 0;
/* 标题在右边 */
}
.ibc-collpase.icon-position-left .ibc-collpase-item__title {
order: 1;
/* 标题在左边 */
}
.ibc-collpase-item__content {
line-height: 25px;
font-size: 14px;
padding-bottom: 20px;
color: brown;
}
.ibc-collpase-item__wrap {
/* height: 0; */
overflow: hidden;
/* transition: height 2s ease-in-out; */
}
.ibc-collpase-item:hover .ibc-collpase-item__wrap {
/* height: 200px; */
}
.ibc-collpase-item.is-disabled .ibc-collpase-item__title {
color: #ddd;
}
.ibc-collpase-item.is-disabled .ibc-icon-arrow::before {
border-color: #ddd;
}
.ibc-collpase-item.is-disabled .ibc-collpase-item__header {
cursor: not-allowed;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
.v-enter-active,
.v-leave-active {
transition: height 0.5s ease-in-out, opacity 0.5s;
}
.v-enter-to,
.v-leave-from {
opacity: 1;
}
</style>
html
<script setup>
// JavaScript代码全部写这里
import IbcCollapse from './components/collpase/ibmCollpase.vue'
import IbmCollpaseItem from './components/collpase/ibmCollpaseItem.vue';
import { ref, watch,useTemplateRef, onMounted, nextTick } from 'vue';
// 用我们这个折叠组件,用户一个需求,他希望能人为的指定某个折叠项的初始状态是打开还是关闭,而不是默认的全部都是打开的。
// 所以我们需要在组件中增加一个属性,用来控制某个折叠项的初始状态。
//定义一个数组,把需要展开的项放在一个数组中。
//ref 创建一个响应式数据,可以绑定到组件的data中。通过v-model绑定到组件的modelValue中。
// v-model=activeNames,把activeNames绑定到组件的modelValue中。
//const activeNames=ref(["a","b","c"]); // 这里指定了初始状态为打开的项是a和c。
const activeNames = ref(["a", 'b',"c","d"]); // 这里指定了初始状态为打开的项是a和c。
const collapse=useTemplateRef('collapse');
onMounted(async () => {
// 监听activeNames变化,把变化同步到collapse的modelValue中。
//collapse.value.setActiveNames("abcdef");
// 等待下一个 tick 确保响应式更新完成
await nextTick()
console.log("onMounted--collapse.value.activeNames-", collapse.value.activeNames,);
});
const onChange = (activeNames) => {
console.log("onChange---", activeNames.value);
}
function onBeforeCollapse () {
return new Promise((resolve, reject) => {
resolve(false);
});
}
const collpaseItem=useTemplateRef('collpaseItem');
onMounted(async () => {
// 等待下一个 tick 确保响应式更新完成
await nextTick()
console.log("onMounted---", collpaseItem.value.isActive);
});
//如何禁用某个面板项?
// 我们可以给某个面板项增加一个属性,用来控制是否禁用。
// 我们可以给这个属性绑定一个布尔值,当为true时,表示禁用,false时,表示启用。disabled属性的默认值是false。
// 我们可以在组件中增加一个disabled属性,用来控制某个面板项是否禁用。
// 我们可以在handleClick方法中增加一个判断,如果某个面板项被禁用,则不做任何操作。
//如果用户加了accordion属性,表示手风琴效果,则只能展开一个面板项。
// 我们可以在组件中增加一个accordion属性,用来控制是否是手风琴效果。
</script>
<template> <!-- HTML代码全部写这里 -->
<div class="wrap">
<IbcCollapse v-model="activeNames" expand-icon-position="left"
ref="collapse"
@change="onChange"
>
<!-- :before-collapse="onBeforeCollapse" -->
<IbmCollpaseItem name="a" title="面板项一的标题" disabled>
<p> 面板项2的内容 </p><p> 面板项2的内容 </p><p> 面板项2的内容 </p><p> 面板项2的内容 </p><p> 面板项2的内容 </p>
</IbmCollpaseItem>
<IbmCollpaseItem name="x" ref="collpaseItem" >
<template #title="{ isActive }">面板项2的标题
<span class="ibc-collpase-item__title_left">
{{ isActive ? '▼' : '◀' }}
</span>
</template>
<template #icon="{ isActive }">
<div class="icon">
{{ isActive ? '▼' : '▶' }}
</div>
</template>
<p> 面板项2的内容 </p><p> 面板项2的内容 </p><p> 面板项2的内容 </p><p> 面板项2的内容 </p><p> 面板项2的内容 </p>
</IbmCollpaseItem>
<IbmCollpaseItem name="c" title="面板项三的标题">
<template #icon="{ isActive }">
<div class="icon">
{{ isActive ? '▼' : '▶' }}
</div>
</template>
<p> 面板项3的内容 </p><p> 面板项3的内容 </p>
</IbmCollpaseItem>
<IbmCollpaseItem name="d" title="面板项四的标题"></IbmCollpaseItem>
<IbmCollpaseItem name="e" title="面板项五的标题"></IbmCollpaseItem>
</IbcCollapse>
</div>
</template>
<style scoped>
/* CSS代码全部写这里 */
.icon {
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
font-size: 20px;
/* border: 1px solid red; */
}
.ibc-collpase-item__icon.is-active .icon {
color: tomato;
}
.wrap {
width: 600px;
min-height: 200px;
margin: 50px auto;
/* background-color: khaki; */
}
.ibc-collpase.icon-position-left .ibc-collpase-item__title_left {
display: inline-block;
width: 24px;
height: 24px;
margin-right: 10px;
font-size: 20px;
text-align: center;
/* border: 1px solid blue; */
}
.ibc-collpase.icon-position-right .ibc-collpase-item__title_left {
display: none;
}
</style>