Skip to content
Afa' Afa'

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

vue
<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

vue
<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

vue
<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

vue
<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

vue
<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>
Something wrong or want to discuss this article? Get in touch

Search articles and projects

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