Vue Elements

A set of reusable Vue 2.7 form and layout components.

5 min read Updated

A small library of reusable Vue 2.7 components: form fields, a group box, a popup, a sticky element and a tab view. Each component is reproduced below.

For Vue 2.7

FormField.vue

text
<script>
  export default {
    props: {
      label: {
        type: String,
        required: true
      },
      required: Boolean
    }
  }
</script>

<template>
  <div class="form-field">
    <div class="form-field__label">
      <span class="form-field__label__text" v-text="required ? `${label} *` : label"/>
      <div class="form-field__label__buttons">
        <slot name="buttons"/>
      </div>
    </div>
    <div class="form-field__input">
      <slot name="input"/>
      <slot/>
    </div>
  </div>
</template>

<style lang="scss">
  .form-field {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: .2rem;

    position: relative;
    width: 100%;

    &__label {
      display: flex;
      align-items: center;
      gap: 1rem;

      flex: 1;
      font-weight: 700;

      &__text {
        flex: 1;
        white-space: nowrap;
      }

      &__buttons {
        & > * {
          border: none;
          color: inherit;
          background-color: transparent;
          cursor: pointer;
          // transform: $transition;

          &:disabled {
            opacity: .3;
            cursor: not-allowed;
          }
        }
      }
    }

    &__input {
      & > input,
      & > select,
      & > textarea {
        border: var(--sbs-box-border);
        border-radius: var(--sbs-box-border-radius);
        padding: .5rem 1rem;
        // min-width: 250px;
        width: 100%;
      }
    }
  }
</style>

GroupBox.vue

text
<script>
  export default {
    props: {
      label: {
        type: String,
        required: true
      }
    }
  }
</script>

<template>
  <div class="group-box">
    <div class="group-box__label">
      <span class="group-box__label__text" v-text="label"/>
      <div class="group-box__label__buttons">
        <slot name="buttons"/>
      </div>
    </div>
    <div class="group-box__container">
      <slot/>
    </div>
  </div>
</template>

<style lang="scss">
  .group-box {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: .2rem;

    position: relative;
    width: 100%;

    &__label {
      display: flex;
      align-items: center;
      gap: 1px;

      flex: 1;
      font-weight: 700;

      &__text {
        flex: 1;
        white-space: nowrap;
      }

      &__buttons {
        & > * {
          border: none;
          background-color: transparent;
          cursor: pointer;
          // transform: $transition;

          &:disabled {
            opacity: .3;
            cursor: not-allowed;
          }
        }
      }
    }

    &__container {}
  }
</style>

Popup.vue

text
<script>
  export default {
    name: 'popup',
    props: {
      align: {
        type: String,
        default: 'middle' // top, middle, bottom
      }
    },
    methods: {
      onkey (event) { if (event.detail === 27) this.closePopup() },
      closePopup () { this.$emit('closePopup') }
    },
    created () { window.addEventListener('_keydown', this.onkey) },
    beforeDestroy () { window.removeEventListener('_keydown', this.onkey) }
  }
</script>

<template>
  <div class="popup" :class="`popup--align-${align}`" @click.self="closePopup()">
    <slot />
  </div>
</template>

<style lang="scss" scoped>
.popup {
  animation: enter 0.15s linear forwards;
  background-color: rgba(0, 0, 0, 0.5);
  bottom: 0;
  display: none;
  left: 0;
  height: 100%;
  justify-content: center;
  opacity: 0;
  overflow: auto;
  position: fixed;
  right: 0;
  top: 0;
  width: 100%;
  z-index: 200;

  &--align-top {
    align-items: flex-start;
    padding-top: 10vh;
  }

  &--align-middle {
    align-items: center;
  }

  &--align-bottom {
    align-items: flex-end;
    padding-bottom: 10vh;
  }

  &[data-show] {
    display: flex;
  }
}

@keyframes enter {
  100% {
    opacity: 1;
  }
}

/deep/ .close {
  color: $gray;
  height: 17px;
  position: absolute;
  right: 10px;
  top: 10px;
  width: 17px;
  z-index: 1;
}
</style>

StickyElement.vue

text
<script>
  import { getCurrentInstance, onMounted, ref } from 'vue'

  export default {
    props: {
      position: { // top, left, right, bottom
        type: String,
        required: true
      },
      tag: {
        type: String,
        default: 'div'
      }
    },
    setup (props) {
      const vm = getCurrentInstance()
      const floating = ref(false)

      function onMarkerVisible ([entry]) {
        floating.value = !entry.isIntersecting
      }

      onMounted(() => {
        const element = vm.proxy.$el
        const observer = new IntersectionObserver(onMarkerVisible, {
          root: null,
          rootMargin: '0px',
          threshold: 0
        })

        const markerElement = document.createElement('div')
        const shadowElement = document.createElement('div')

        shadowElement.classList.add('sticky-element__shadow')
        markerElement.classList.add('sticky-element__marker')
        markerElement.classList.add(`sticky-element__marker--position-${props.position}`)

        switch (props.position) {
          case 'top':
          case 'left':
            element.insertAdjacentElement('beforebegin', markerElement)
            element.insertAdjacentElement('beforeend', shadowElement)
            break

          case 'right':
          case 'bottom':
            element.insertAdjacentElement('afterend', markerElement)
            element.insertAdjacentElement('afterbegin', shadowElement)
            break
        }

        observer.observe(markerElement)
      })

      return { floating }
    }
  }
</script>

<template>
  <component :is="tag" class="sticky-element" :class="{ [`sticky-element--position-${position}`]: true, 'sticky-element--floating': floating }">
    <slot/>
  </component>
</template>

<style lang="scss">
  .sticky-element {
    --sticky-element-size: 6px;
    --sticky-element-color: rgba(0,0,0,.5);

    position: sticky;

    &--floating &__shadow {
      opacity: 1;
    }

    &--position-top {
      top: 0;
    }

    &--position-top &__shadow {
      bottom: 0px;
      left: 0px;
      height: 1px;
      width: 100%;
      box-shadow: 0px calc(var(--sticky-element-size) / 2) var(--sticky-element-size) 0px var(--sticky-element-color);
    }

    &--position-bottom {
      bottom: 0;
    }

    &--position-bottom &__shadow {
      box-shadow: 0px calc(-1 * var(--sticky-element-size) / 2) var(--sticky-element-size) 0px var(--sticky-element-color);
    }

    &--position-left {
      left: 0;
    }

    &--position-left &__shadow {
      top: 0px;
      right: 0px;
      width: 1px;
      box-shadow: calc(var(--sticky-element-size) / 2) 0px var(--sticky-element-size) 0px var(--sticky-element-color);
    }

    &--position-right {
      right: 0;
    }

    &--position-right &__shadow {
      top: 0px;
      left: 0px;
      width: 1px;
      box-shadow: calc(-1 * var(--sticky-element-size) / 2) 0px var(--sticky-element-size) 0px var(--sticky-element-color);
    }

    &__shadow {
      position: absolute;
      width: 100%;
      height: 100%;
      background-color: var(--sticky-element-color);
      opacity: 0;
      transition: all .3s cubic-bezier(.75,0,.125,1);
    }

    &__marker {
      width: 1px;
      height: 1px;
      z-index: 10;

      &--position-top {
        position: absolute;
        top: 0;
      }

      &--position-bottom {
        position: absolute;
        bottom: 0;
      }
    }
  }
</style>

TabView.vue

text
<script>
  export default {
    props: {
      tabs: {
        type: Array,
        required: true
      }
    },
    data: () => ({
      activeTab: null,
      containerSize: 0
    }),
    watch: {
      activeTab () {
        this.updateContainerSize()
      }
    },
    created () {
      if (this.tabs.length) {
        this.activeTab = this.tabs[0]
      }
    },
    updated () {
      this.updateContainerSize()
    },
    methods: {
      updateContainerSize () {
        if (this.$refs.container.offsetWidth > this.containerSize) {
          if (this.containerSize) {
            // this.$refs.container.style.width = `${this.$refs.container.offsetWidth}px`
          }

          this.containerSize = this.$refs.container.offsetWidth
        }
      }
    }
  }
</script>

<template>
  <div class="tabview">
    <div class="tabview__tabs pitch">
      <button
        class="tabview__tabs__item"
        :class="{ 'tabview__tabs__item--active': tab === activeTab }"
        v-for="tab of tabs"
        :key="tab.name"
        v-text="tab.name"
        @click="activeTab = tab"/>
    </div>
    <div ref="container" class="tabview__container">
      <transition name="transition-slide" mode="out-in">
        <keep-alive>
          <component
            :is="activeTab.component"
            v-bind="activeTab.props"
            v-on="activeTab.on"/>
        </keep-alive>
      </transition>
    </div>
  </div>
</template>

<style lang="scss" scoped>
  .tabview {
    background: $white;
    border-radius: 2px;
    padding: 12px;
    max-width: 90%;
    max-height: 90%;
    transition: all .3s cubic-bezier(.75,0,.125,1);

    display: grid;
    gap: 1rem;
    align-items: flex-start;
    grid-auto-flow: row;
    grid-template-areas:
      "title"
      "body";

    &__tabs {
      grid-area: title;

      display: flex;
      gap: 1rem;

      margin: 0;
      padding: .5rem 1rem 0;

      &__item {
        border: none;
        background-color: transparent;
        cursor: pointer;
        padding: .7em 1em .5em;

        &--active {
          font-weight: 700;
          background-color: #fff;
        }
      }
    }

    &__container {
      grid-area: body;
      overflow-x: hidden;
      transition: all .3s cubic-bezier(.75,0,.125,1);
    }
  }
</style>

Search articles

Type to filter articles. Use the arrow keys to move through results and Enter to open one. Press Escape to close.