Skip to content

实现的基本思路:

Collapse 组件作为外层容器,负责主要逻辑,通过 ActiveNames 属性存储当前折叠面板中哪些collapseItem项处于展开状态。对于手风琴模式(只能又一个展开),则保持 ActiveNames 中只有一项即可;

CollapseItem 组件作为单个项容器,负责单层 UI 交互逻辑、动画等;

ActiveNames是 Collapse 和 CollapseItem 组件内部使用,初始值是父组件通过 v-model 传入的,内部通过emit:updateValuewatch 来保持与外部同步;

代码

类型

typescript

import type { Ref } from "vue";
export type CollapseItemName = string | number;

export interface CollapseProps {
  modelValue: CollapseItemName[];
  accordion?: boolean;
}

export interface CollapseItemProps {
  name: CollapseItemName;
  title?: string;
  disabled?: boolean;
}
// Collapse触发事件,用于父组件监听
export interface CollapseEmits {
  (e: "update:modelValue", value: CollapseItemName[]): void;
  (e: "change", value: CollapseItemName[]): void;
}
// 上下文
export interface CollapseContext {
  activeNames: Ref<CollapseItemName[]>;
  handleItemClick(name: CollapseItemName): void;
}

Collapse 组件:

typescript
// Collapse.vue
<script setup lang="ts">
import type { CollapseProps, CollapseEmits, CollapseItemName } from "./types";
import { ref, provide, watch, watchEffect } from "vue";
import { debugWarn } from "@toy-element/utils";
import { COLLAPSE_CTX_KEY } from "./constants";

const COMP_NAME = "ErCollapse" as const;

defineOptions({
  name: COMP_NAME,
});
const props = defineProps<CollapseProps>();
const emits = defineEmits<CollapseEmits>();
const activeNames = ref(props.modelValue);

function handleItemClick(item: CollapseItemName) {
  let _activeNames = [...activeNames.value];
  // 是否为手风琴模式
  if (props.accordion) {
    _activeNames = [_activeNames[0] === item ? "" : item];
    updateActiveNames(_activeNames);
    return;
  }

  const index = _activeNames.indexOf(item);
  if (index > -1) {
    // 收起折叠面板
    _activeNames.splice(index, 1);
  } else {
    //展开折叠面板
    _activeNames.push(item);
  }
  updateActiveNames(_activeNames);
}

function updateActiveNames(newNames: CollapseItemName[]) {
  activeNames.value = newNames;
  // 修改父组件v-model绑定的值,这里没有用vue3.4的defineModel语法
  emits("update:modelValue", newNames);
  emits("change", newNames);
}

watchEffect(() => {
  if (props.accordion && activeNames.value.length > 1) {
    debugWarn(COMP_NAME, "accordion mode should only have one active item");
  }
});

// 这里是为了当父组件传入的modelValue发生变化时,也能正确的触发Collapse的收缩折叠以及change事件等
watch(
  () => props.modelValue,
  (newNames) => updateActiveNames(newNames)
);

provide(COLLAPSE_CTX_KEY, {
  activeNames,
  handleItemClick,
});
</script>

<template>
  <div class="er-collapse">
    <slot></slot>
  </div>
</template>

<style scoped>
@import "./style.css";
</style>

CollapseItem 组件:

typescript
//CollapseItem.vue
<script setup lang="ts">
import type { CollapseItemProps } from "./types";
import { inject, computed } from "vue";
import { COLLAPSE_CTX_KEY } from "./constants";
import ErIcon from "../Icon/Icon.vue";
import transitionEvents from "./transitionEvents";

defineOptions({ name: "ErCollapseItem" });
const props = defineProps<CollapseItemProps>();
const ctx = inject(COLLAPSE_CTX_KEY, void 0);
const isActive = computed(() => ctx?.activeNames.value?.includes(props.name));

function handleClick() {
  if (props.disabled) return;
  ctx?.handleItemClick(props.name);
}
</script>

<template>
  <div
    class="er-collapse-item"
    :class="{
      'is-disabled': disabled,
    }"
  >
    <div
      class="er-collapse-item__header"
      :id="`item-header-${name}`"
      :class="{
        'is-disabled': disabled,
        'is-active': isActive,
      }"
      @click="handleClick"
    >
      <span class="er-collapse-item__title">
        <slot name="title">
          {{ title }}
        </slot>
      </span>
      <er-icon icon="angle-right" class="header-angle" />
    </div>
    <transition name="slide" v-on="transitionEvents">
      <div class="er-collapse-item__wapper" v-show="isActive">
        <div class="er-collapse-item__content" :id="`item-content-${name}`">
          <slot></slot>
        </div>
      </div>
    </transition>
  </div>
</template>

<style scoped>
@import "./style.css";
</style>

动画实现

展开收起动画的实现,利用几个钩子函数在不同的过渡过程中为包裹组件设置高度,注意这里是为包裹组件,包裹组件本身不设置 height 属性,从而不影响子元素:

css
.slide-enter-active,
.slide-leave-active {
  transition: height var(--er-transition-duration) ease-in-out;
}
javascript
//动画组件的调用:
//<transition name="slide" v-on="transitionEvents">
//</transition>

const _setHeightZero = (el: HTMLElement) => (el.style.height = "0px");
const _setHeightScroll = (el: HTMLElement) =>
  (el.style.height = `${el.scrollHeight}px`);
const _setHeightEmpty = (el: HTMLElement) => (el.style.height = "");
const _setOverflowHidden = (el: HTMLElement) => (el.style.overflow = "hidden");
const _setOverflowEmpty = (el: HTMLElement) => (el.style.overflow = "");

const transitionEvents: Record<string, (el: HTMLElement) => void> = {
  //在元素被插入到 DOM 之前被调用,设为零
  beforeEnter(el) {
    _setHeightZero(el);
    _setOverflowHidden(el);
  },
  //在元素被插入到 DOM 之后的下一帧被调用,设置显示高度
  enter: (el) => _setHeightScroll(el),
  //当进入过渡完成时调用,恢复原始
  afterEnter(el) {
    _setHeightEmpty(el);
    _setOverflowEmpty(el);
  },
  //在 leave 钩子之前调用,为包裹元素设置高度
  beforeLeave(el) {
    _setHeightScroll(el);
    _setOverflowHidden(el);
  },
  // 开始离开动画
  leave: (el) => _setHeightZero(el),
  // 恢复默认值:在离开过渡完成,且元素已从 DOM 中移除时调用,
  afterLeave(el) {
    _setHeightEmpty(el);
    _setOverflowEmpty(el);
  },
};

export default transitionEvents;