Create tabs from div elements, display another items in one row under

Viewed 72
1 Answers
  1. Create a derived/computed state, exposing the items in groups of N (4 in your case). Treating N as variable is going to help on mobile devices (where you're likely to change N's value):
const rows = computed(() => Array.from(
  { length: 1 + items.length / rowSize },
  (_, i) => items.slice(i * rowSize, (i + 1) * rowSize)
));
  1. v-for these computed groups, rendering:
    a) the group items;
    b) the children of the currently open/active item, if the active item is contained in this group

The rest are implementation details:

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>

Related