摘要:
本文實現了一個Vue3摺疊面板組件,包含兩個核心文件:ibmCollpase.vue(父組件)和ibmCollpaseItem.vue(子組件)。父組件通過provide提供共享狀態和方法,子組件通過inject獲取。主要功能包括:

支持手風琴(accordion)模式和普通模式
提供beforeCollapse鈎子函數驗證
實現展開/摺疊動畫效果
支持自定義展開圖標位置
包含disabled禁用狀態處理
組件通過v-model雙向綁定activeNames,並暴露setActiveNames方法供外部調用。

//ibmCollpase.vue

<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

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

//app.vue

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