Edit: I've built this on codesandbox. Some of the implementations aren't working for whatever reason (it doesn't like my img src routes)
LangDropdown.vue
<template>
<div @blur="dropdownIsOpen = false">
<div
style="display: flex; flex-direction: column; justify-content: space-between"
@click="dropdownIsOpen = !dropdownIsOpen"
>
<div style="display: flex">
<img style="width: 34px" src="../assets/languages-icon.svg" alt="Change Language Icon" />
<div style="flex: 1; display: flex; justify-content: space-between">
<div v-if="!collapsed" >
<div>
{{ selected }}
</div>
<img src="../assets/chevron-down.svg" alt="" />
</div>
</div>
</div>
<transition name="slide">
<ul v-if="dropdownIsOpen && !collapsed" >
<li v-for="(option, i) of options" :key="i" @click="selectOption(option)">
{{ option }}
</li>
</ul>
</transition>
</div>
</div>
</template>
<script lang="ts" setup>
import { PropType, ref } from "vue"
import { collapsed } from "./state"
const props = defineProps({
options: { type: Array as PropType<string[]>, required: true },
default: { type: String, required: true }
})
const emit = defineEmits(["input"])
const selected = ref(props.default ? props.default : props.options.length > 0 ? props.options[0] : null)
const dropdownIsOpen = ref(true)
if (collapsed) dropdownIsOpen.value = false
function selectOption(_option: any) {
selected.value = _option
dropdownIsOpen.value = false
emit("input", _option)
}
</script>
<style lang="sass">
._custom-select
position: relative
width: 100%
text-align: left
outline: none
font-size: 16px
border-radius: 6px
&:hover
._icon,
._selected-option
background-color: var(--sidebar-item-hover)
._selected-option
flex: 1
display: flex
justify-content: space-between
margin-left: 1em
text-align: left
border-radius: 6px
padding: 8px 18px
cursor: pointer
user-select: none
line-height: 26px
._icon
width: 24px
border-radius: 6px
cursor: pointer
._options
// position: absolute
// right: 0
// top: 100%
margin: 0
margin-left: auto
padding: 8px
padding-top: 0
list-style-type: none
transform-origin: top
transition: transform 300ms ease-in-out
overflow: hidden
> *
border-radius: 6px
text-align: left
cursor: pointer
user-select: none
padding: 6px
width: 100%
> *:hover
background-color: var( --sidebar-item-hover)
.slide-move,
.slide-enter-from,
.slide-leave-to
transform: scaleY(0)
._custom-select ._options div:hover
background-color: var( --sidebar-item-hover)
</style>
Sidebar.vue
<!-- eslint-disable vue/multi-word-component-names -->
<script lang="ts" setup>
import SidebarLink from "./SidebarLink.vue"
import { collapsed, toggleSidebar, sidebarWidth } from "./state"
import LangDropdown from "./LangDropdown.vue"
import MyAccountDropDown from "./MyAccountDropDown.vue"
const emit = defineEmits(["change"])
function changeLang(lang: any) {
emit("change", lang)
}
</script>
<template>
<div :style="{ width: sidebarWidth }">
<div : @click="toggleSidebar">
<img src="../assets/chevron-left.svg" alt="Collapse Sidebar" />
</div>
<router-link style="text-decoration: none" to="/">
<div >
<img style="width: 34px" src="../assets/logo.svg" alt="" />
<div v-if="!collapsed" >Home</div>
</div>
</router-link>
<div >
<SidebarLink to="/videos" icon="videos-icon" label="Videos" />
<SidebarLink to="/annotator" icon="annotator-icon" label="Annotator" />
<SidebarLink to="/training" icon="training-icon" label="Training" />
<SidebarLink to="/inference" icon="inference-icon" label="Inference" />
<SidebarLink to="/work-insights" icon="work-insights-icon" label="Work Insights" />
</div>
<div >
<LangDropdown
tabindex="0"
:options="['English', 'Simplified Chinese', 'Traditional Chinese']"
:default="'English'"
@input="changeLang"
/>
<MyAccountDropDown tabindex="0" />
</div>
</div>
</template>
<style lang="sass">
\:root
--sidebar-bg-color: #4272ce
--sidebar-item-hover: #5489ef
--sidebar-item-active: #5489ef
</style>
<style lang="sass" scoped>
._sidebar
position: relative
display: flex
flex-direction: column
height: 100vh
width: auto
padding: 0.5em
color: white
background-color: var(--sidebar-bg-color)
transition: 0.3s ease
z-index: 1
&::after
content: ''
position: absolute
top: 0
bottom: 0
left: 0
right: 0
width: 50px
height: 100%
display: block
background-color: #2d5ab2
z-index: -1
._sidebar-links > *:not(:last-child)
margin-bottom: 2em
._collapse-icon
position: absolute
top: 4em
right: -12px
display: inline-block
background-color: #4b4bd9
width: 1.5em
height: 1.5em
border: 0.25em solid #4b4bd9
border-radius: 50%
text-align: center
cursor: pointer
._home-link
position: relative
display: flex
align-items: center
cursor: pointer
user-select: none
margin-top: 1em
margin-bottom: 4em
border-radius: 0.25em
height: 1.5em
color: white
&-text
flex: 1
display: flex
margin-left: 2rem
text-align: left
text-decoration: none
font-size: 18px
._rotate-180
transform: rotate(180deg)
transition: 0.3s linear
._dropdowns
margin-top: auto
margin-bottom: 8em
> *:first-child
margin-bottom: 2em
</style>
CodePudding user response:
What you're trying to create is not technically a dropdown, but a collapse.
By definition, a dropdown is an element which has a toggle and a menu. When opened, the menu is displayed on top of the rest of the page. Typically, it's opaque and features a shadow. Opening the dropdown has no effect on the rest of the page (the layout does not change), the rest of the page does not re-render.
What you are trying to achieve here is a collapse. Collapse elements have a toggle and a body, very similar to dropdowns. But, unlike dropdowns, when opening, they push everything below, according to their body height. They are much heavier on browser rendering, because they trigger repaints on all layers (layout, paint and copositor), while animating, on all elements changing layout position while the collapse animates (typically on subsequent siblings - but potentially on the entire rest of the page).
The collapse body has a wrapper element which has maxHeight: 0
initially and then transitions to its scrollHeight
value when toggled. This creates a smooth transition for everything under it.
Here's a basic example, to demonstrate the principle:
const {
createApp,
defineComponent,
reactive,
watchEffect,
onMounted,
onBeforeUnmount,
toRefs
} = Vue;
const Collapse = defineComponent({
template: `
<div @click="toggle">
<slot name="toggle">{{ title }}</slot>
</div>
<div ref="bodyEl" :style="bodyStyle">
<slot></slot>
</div>
`,
props: {
title: {
type: String,
default: '--'
}
},
setup() {
const state = reactive({
isOpen: false,
bodyEl: null,
bodyStyle: {},
toggle: () => state.isOpen = !state.isOpen
});
const update = () => state.bodyStyle = {
maxHeight: `${state.isOpen ? state.bodyEl.scrollHeight : 0}px`
};
watchEffect(update);
onMounted(() => window.addEventListener('resize', update));
onBeforeUnmount(() => window.removeEventListener('resize', update));
return toRefs(state)
}
})
createApp({
components: { Collapse }
}).mount('#app')
.collapse-body {
overflow: hidden;
background-color: #f5f5f5;
max-height: 0;
padding: 0 1rem;
transition: max-height .3s cubic-bezier(.4,0,.2,1);
}
.collapse-toggle, .collapse-toggle * {
cursor: pointer;
}
<script src="https://unpkg.com/vue/dist/vue.global.prod.js"></script>
<div id="app">
<Collapse title="Collapse 1">
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
</Collapse>
<Collapse title="Collapse 2">
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
</Collapse>
<Collapse>
<template #toggle>
<button>With #toggle slot</button>
</template>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
</Collapse>
</div>
Important note: Avoid setting top/bottom margins on the collapse wrapper. They'll create jumps in the animation. If you need such spacing, place them as top padding on the first content element, or bottom padding on the last one, respectively.