Transition Handler Component
Define a transition wrapper to animate expand and collapse actions.
<template>
<transition
@before-enter="prepEnter"
@enter="startEnter"
@after-enter="finishEnter"
@before-leave="prepLeave"
@leave="startLeave"
@after-leave="finishLeave"
>
<slot />
</transition>
</template>
<script setup lang="ts">
function prepEnter(elem: HTMLElement) {
elem.classList.add('anim-slide');
elem.dataset.initPadTop = elem.style.paddingTop || '';
elem.dataset.initPadBot = elem.style.paddingBottom || '';
elem.dataset.initOver = elem.style.overflow || '';
elem.style.overflow = 'hidden';
elem.style.maxHeight = '0';
elem.style.paddingTop = '0';
elem.style.paddingBottom = '0';
}
function startEnter(elem: HTMLElement) {
elem.style.maxHeight = `${elem.scrollHeight}px`;
elem.style.paddingTop = elem.dataset.initPadTop!;
elem.style.paddingBottom = elem.dataset.initPadBot!;
}
function finishEnter(elem: HTMLElement) {
elem.classList.remove('anim-slide');
elem.style.maxHeight = '';
elem.style.overflow = elem.dataset.initOver!;
}
function prepLeave(elem: HTMLElement) {
elem.dataset.initPadTop = elem.style.paddingTop || '';
elem.dataset.initPadBot = elem.style.paddingBottom || '';
elem.dataset.initOver = elem.style.overflow || '';
elem.style.maxHeight = `${elem.scrollHeight}px`;
elem.style.overflow = 'hidden';
}
function startLeave(elem: HTMLElement) {
elem.classList.add('anim-slide');
elem.style.maxHeight = '0';
elem.style.paddingTop = '0';
elem.style.paddingBottom = '0';
}
function finishLeave(elem: HTMLElement) {
elem.classList.remove('anim-slide');
elem.style.maxHeight = '';
elem.style.overflow = elem.dataset.initOver!;
elem.style.paddingTop = elem.dataset.initPadTop!;
elem.style.paddingBottom = elem.dataset.initPadBot!;
}
</script>
<style scoped>
.anim-slide {
transition: all 0.4s ease-in-out;
}
</style>
Individual Panel Item
Compose a collapsible section with header and enimated content area.
<template>
<div class="panel-block">
<header class="panel-header" @click.stop="switchPanel">
<div class="header-main">
<span v-if="!$slots.headerText" class="default-header">{{ propTitle }}</span>
<slot name="headerText" />
</div>
<div class="indicator">
<span class="label">Details</span>
<el-icon class="arrow-icon" :class="{ rotated: isOpen }">
<ArrowRight />
</el-icon>
</div>
</header>
<SlideAnim>
<section v-show="isOpen" class="panel-body">
<slot name="bodyContent" />
</section>
</SlideAnim>
</div>
</template>
<script setup lang="ts">
import { useSlots, useAttrs, inject, computed } from 'vue';
import SlideAnim from './TransitionHandler.vue';
const slots = useSlots();
const attrs = useAttrs();
const activeSet: any = inject('activeSet');
const switchFn: any = inject('switchPanel');
const propTitle = attrs.title ?? '';
const isOpen = computed(() => activeSet.value.includes(attrs.name));
function switchPanel() {
switchFn(attrs.name);
}
</script>
<style scoped>
.panel-block {
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 4px 4px 0 0;
overflow: hidden;
}
.indicator {
display: flex;
align-items: center;
color: #2578f2;
cursor: pointer;
user-select: none;
}
.arrow-icon {
margin-left: 6px;
font-size: 16px;
transition: transform 0.3s;
transform-origin: center;
}
.arrow-icon.rotated {
transform: rotate(90deg);
}
.header-main {
flex: 1;
min-width: 0;
}
.default-header {
font-size: 14px;
color: #1b1b1b;
}
</style>
Container for Multiple Panels
Manage group state and provide context to children.
<template>
<div class="panels-wrapper">
<slot />
</div>
</template>
<script setup lang="ts">
import { ref, watch, provide, defineProps, defineEmits } from 'vue';
const props = defineProps({
modelValue: [String, Number, Array],
accordion: Boolean
});
const emit = defineEmits(['update:modelValue', 'change']);
const activeSet = ref<any[]>([]);
watch(() => props.modelValue, initActive, { immediate: true });
function initActive() {
activeSet.value = Array.isArray(props.modelValue)
? [...props.modelValue]
: [props.modelValue];
}
function switchPanel(key: any) {
if (activeSet.value.includes(key)) {
activeSet.value = activeSet.value.filter(k => k !== key);
} else {
if (props.accordion) {
activeSet.value = [key];
} else {
activeSet.value.push(key);
}
}
emit('update:modelValue', activeSet.value);
emit('change', activeSet.value);
}
provide('activeSet', activeSet);
provide('switchPanel', switchPanel);
</script>
<style scoped>
.panels-wrapper {
/* container styles if needed */
}
</style>
Usage Example
Integrate components to buildd a collapsible interface.
<template>
<div class="demo-box">
<PanelsWrapper v-model="openKey" :accordion="true" @change="onChange">
<PanelItem :name="1">
<template #headerText>
<!-- custom header -->
</template>
<template #bodyContent>
<!-- custom body -->
</template>
</PanelItem>
</PanelsWrapper>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import PanelsWrapper from './Container.vue';
import PanelItem from './Item.vue';
const openKey = ref<number[]>([1]);
function onChange(val: number[]) {
console.log(val);
}
</script>
<style scoped>
.demo-box {
padding: 20px;
}
</style>