const {
createApp,
computed,
reactive,
toRefs,
nextTick,
watch,
onMounted,
onBeforeUnmount
} = Vue
const { random, round } = Math
const randomBetween = (min, max) => round(random() * (max - min)) + min
createApp({
setup() {
const state = reactive({
active: null,
items: Array.from({ length: randomBetween(7, 12) }, (_, i) => ({
label: `${i + 1}`,
children: Array.from({ length: randomBetween(2, 7) }, (_, key) => ({
label: `${i + 1}.${key + 1}`
}))
})),
rowSize: 4,
rows: computed(() =>
Array.from(
{ length: 1 + state.items.length / state.rowSize },
(_, i) =>
state.items.slice(i * state.rowSize, (i + 1) * state.rowSize)
)
),
isOpen: (row) => row.map((i) => i.label).includes(state.active),
select: (id) => {
state.active = id === state.active ? null : id
},
children: computed(
() =>
state.items.find((i) => i.label === state.active)?.children || []
),
containerStyle: computed(() => ({
"--item-width": `calc(${100 / state.rowSize}% - 1rem)`,
margin: "0 auto",
maxWidth: 175 * state.rowSize + "px"
})),
animators: []
})
const updateHeights = () =>
nextTick(() => {
state.animators.forEach((el) => {
el.style.setProperty(
"--height",
(el.querySelector("div")?.offsetHeight || 0) + "px"
)
})
})
watch(() => [state.active, state.rowSize], updateHeights, {
immediate: true
})
onMounted(() => window.addEventListener("resize", updateHeights))
onBeforeUnmount(() =>
window.removeEventListener("resize", updateHeights)
)
return { ...toRefs(state) }
}
}).mount("#app")
* {
box-sizing: border-box;
}
#app {
--transition: 0.3s cubic-bezier(0.2, 0, 0.4, 1);
--gap: 0.5rem;
}
.row {
display: flex;
flex-wrap: wrap;
padding: var(--gap);
}
.row > * {
flex: 0 0 var(--item-width);
margin: 0 var(--gap);
border: 1px solid #e2e4e8;
aspect-ratio: 1.618;
padding: var(--gap);
}
.row .selected {
background-color: #f2f3f5;
margin-bottom: calc((var(--gap) + 1px) * -1);
border-bottom-width: 0;
}
.children {
border: 1px solid #e2e4e8;
border-bottom: none;
}
.children > * {
margin: var(--gap);
background-color: #fff;
}
.animator {
transition:
max-height var(--transition),
min-height var(--transition);
max-height: var(--height);
min-height: var(--height);
overflow: hidden;
background-color: #f2f3f5;
}
.children > * {
transition: opacity .2s ease .1s, transform .2s ease .1s;
transform-origin: 50% 0;
}
.list-enter-from {
opacity: 0;
transform: translateY(.5rem) scale(0.9);
}
.list-leave-to {
transition-duration: 0s;
transition-delay: 0s;
}
<script src="https://unpkg.com/vue@3.2.39/dist/vue.global.prod.js"></script>
<div id="app">
Row size: <input type="range" min="2" max="6" step="1" v-model="rowSize" /> {{
rowSize }}
<div :style="containerStyle">
<template v-for="(row, key) in rows" :key="key">
<div class="row">
<div
v-for="({ label }, index) in row"
:key="index"
v-text="label"
@click="select(label)"
:class="{ selected: label === active }"
></div>
</div>
<div ref="animators" class="animator">
<div v-if="isOpen(row)" class="row children">
<transition
name="list"
mode="out-in"
appear
v-for="{ label } in children"
>
<div :key="label" v-text="label"></div>
</transition>
</div>
</div>
</template>
</div>
</div>