实现的基本思路:
Collapse 组件作为外层容器,负责主要逻辑,通过 ActiveNames
属性存储当前折叠面板中哪些collapseItem
项处于展开状态。对于手风琴模式(只能又一个展开),则保持 ActiveNames 中只有一项即可;
CollapseItem 组件作为单个项容器,负责单层 UI 交互逻辑、动画等;
ActiveNames
是 Collapse 和 CollapseItem 组件内部使用,初始值是父组件通过 v-model 传入的,内部通过emit:updateValue
和 watch
来保持与外部同步;
代码
类型
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;