1

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)

CodeSandbox Link

So I am trying to build a responsive navbar that has a couple custom dropdowns. Intitally I started with making the dropdown options absolute to the parent element, but that would make it so that I need space between both dropdowns so that they don't overflow/cover each other.

I have them closer together now and the options are no longer relative, but as you can tell from the gif, the language change dropdown now jumps to make space for the options. Shifting up is totally fine and really the only option, but is there some sort of animation or transition that I can use to make it not so jittery/ more smooth?

Any tips or ideas would be greatly appreciated!

Cheers!

enter image description here

LangDropdown.vue

<template>
  <div class="_custom-select" @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" class="_icon" src="../assets/languages-icon.svg" alt="Change Language Icon" />
        <div style="flex: 1; display: flex; justify-content: space-between">
          <div v-if="!collapsed" class="_selected-option">
            <div>
              {{ selected }}
            </div>
            <img class="_icon" src="../assets/chevron-down.svg" alt="" />
          </div>
        </div>
      </div>
      <transition name="slide">
        <ul v-if="dropdownIsOpen && !collapsed" class="_options">
          <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 class="_sidebar" :style="{ width: sidebarWidth }">
    <div class="_collapse-icon" :class="{ '_rotate-180': collapsed }" @click="toggleSidebar">
      <img src="../assets/chevron-left.svg" alt="Collapse Sidebar" />
    </div>
    <router-link style="text-decoration: none" to="/">
      <div class="_home-link">
        <img style="width: 34px" src="../assets/logo.svg" alt="" />
        <div v-if="!collapsed" class="_home-link-text">Home</div>
      </div>
    </router-link>

    <div class="_sidebar-links">
      <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 class="_dropdowns">
      <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>
LovelyAndy
  • 841
  • 8
  • 22
  • 1
    I would say to avoid `*` as css selector (it works from right to left so it will check for every element in you DOM https://css-tricks.com/why-browsers-read-selectors-right-to-left/) and perhaps add a working snippet/fiddle so we can reproduce/play with the setup – Toni Michel Caubet Jul 18 '22 at 21:40
  • 1
    It's better to anchor your "English" menu and push the "My Account" menu when the animation start – Duannx Jul 19 '22 at 02:28
  • @ToniMichelCaubet Thank you for the information! I didn't know that! I'll work on getting an example made today. Been having some issues with codesandbox – LovelyAndy Jul 19 '22 at 12:50
  • @ToniMichelCaubet I've added the codesandbox link! Functionally it works the same, but just some oddness coming from codesandbox when it comes to my icons. Otherwise it's ready to go! – LovelyAndy Jul 19 '22 at 15:39
  • @Duannx Intersting. I've created a codesandbox, but any idea how I would make that happen with my current flexbox setup? – LovelyAndy Jul 19 '22 at 15:40

1 Answers1

3

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 class="collapse-toggle" @click="toggle">
    <slot name="toggle">{{ title }}</slot>
  </div>
  <div class="collapse-body" 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.

tao
  • 82,996
  • 16
  • 114
  • 150
  • Thanks a ton for the breakdown here! As well as explaining the naming conventions of these elements; I wasn't aware. Would you say that collapse menus aren't worth it if they are so heavy on the browser? – LovelyAndy Jul 19 '22 at 19:23
  • 1
    No, that's not what I'm saying. I would, however, give the sidebar `position: absolute`, to avoid it triggering repaints on the page contents, when changing. For good measure. A bit more detail over what's involved in animating a change in layout vs a change in compositor [here](https://stackoverflow.com/questions/7108941/css-transform-vs-position/53892597#53892597). Since you can't change the fact a collapse affects layout, you can limit the number of affected elements by giving one of its parents `position: absolute`. In your case, sidebar is the perfect candidate. – tao Jul 19 '22 at 19:30
  • 1
    But, again, it largely depends on how "heavy" your page contents is. If it's light, you won't experience any jitter on any device. If you have heavy content render-wise (e.g: sliders or any other components doing loads of computing every time the container changes), you'll notice some hiccups in animation if you're not careful. – tao Jul 19 '22 at 19:32
  • 1
    To unpack the info in the above link: when using a dropdown, because its menu is out of the document flow, it's equivalent of a `transform` change. It only affects itself. When using a collapse, it's the equivalent of changing `margin` in that answer, because it affects all siblings following it. And, if it changes the parent height, any of its consequent siblings, as well. – tao Jul 19 '22 at 19:37
  • Interesting! In my siderbar component I set the position to relative so that I can set a few absolute elements on it. Then in my App.vue, I put `style="position: absolute" there are it actually grows a bit. Not sure what that is about, but it certainly gives the elements a bit more breathing room. I'll be putting some canvas charts on these pages I suspect and those don't rerender anyway, so hopefully that won't be a big deal. – LovelyAndy Jul 19 '22 at 19:41