ListView

A reusable Vue list view with typed columns, sorting and a sticky header element.

4 min read Updated

A reusable Vue list view: typed column definitions, sorting and a sticky header element that stays visible while the list scrolls. The component and a usage example are reproduced below.

Usage

vue
<!-- eslint-disable no-undef -->
<script lang="ts" setup>
  import { computed } from 'vue';
  import ListView from './ListView.vue';

  const headers = [
    { key: 'uid', label: 'UID' },
    { key: 'username', label: 'Username' },
    { key: 'email', label: 'Email' },
    { key: 'age', label: 'Age' },
  ];

  const dataTypeByColumns = {
    uid: 'string',
    username: 'string',
    email: 'string',
    age: 'number',
  };

  const accounts = [/* ... */];
</script>

<template>
  <ListView :headers="headers" :items="accounts" :types="dataTypeByColumns">
    <template #additional-header-columns>
      <th>Actions</th>
    </template>
    <template #additional-row-columns="{ rowIndex }">
      <td>
        <div class="flex items-center justify-center gap-3">
          <button type="button">Open</button>
        </div>
      </td>
    </template>
  </ListView>
</template>

<style lang="scss">
  [data-colname="email"] {
    width: 100%;
  }

  [data-colname="username"] {
    white-space: nowrap;
  }
</style>

ListView.vue

text
<script lang="ts" setup>
  import { computed } from 'vue';

  import StickyElement from './StickyElement.vue';

  //
  // Globals
  const props = withDefaults(defineProps<{
    headers: Array<{ key: string; label: string; }>;
    items: Record<string, string | number | boolean | Date | null | undefined>[];
    types?: Record<string, string>;
    styles?: Record<string, string | undefined>[];
  }>(), { types: () => ({}), styles: () => ([]) });

  //
  // Computed
  const parsedItems = computed(() => props.items.map((item) => props.headers.map((header) => ({
    key: header.key,
    value: item[header.key],
    type: props.types[header.key] || typeof item[header.key],
  }))));
</script>

<template>
  <table class="listing-view" cellspacing="0" cellpadding="0">
    <StickyElement class="listing-view__header" tag="tr" position="top">
      <slot name="additional-header-columns-prefix"/>
      <template v-for="header in headers" :key="header.key">
        <th v-text="header.label"/>
      </template>
      <slot name="additional-header-columns"/>
    </StickyElement>
    <transition-group tag="tbody" name="transition-list">
      <template v-for="data, rowIndex in parsedItems" :key="rowIndex">
        <tr :style="styles[rowIndex] || {}">
          <slot name="additional-row-columns-prefix" v-bind="{ rowIndex, data }"/>
          <template v-for="item in data" :key="item.key">
            <td :data-colname="item.key" :data-coltype="item.type" v-text="item.value"/>
          </template>
          <slot name="additional-row-columns" v-bind="{ rowIndex, data }"/>
        </tr>
      </template>
    </transition-group>
  </table>
</template>

<style>
  :root {
    --color-black-400: #858F94;
    --color-black-100: #DAE2E5;
    --color-black-50: #f8f9fa;
  }
</style>

<style lang="scss">
  .listing-view {
    padding: 10px 0 15px;

    &, &__header {
      width: 100%;
      text-align: left;
    }

    &__header {
      background-color: #fff;

      th {
        border-bottom: 1px solid var(--color-black-400);
        white-space: nowrap;
      }
    }

    tbody {
      tr {
        &:hover {
          background-color: var(--color-black-50);
        }

        &:not(:last-child) {
          td {
            border-bottom: 1px solid var(--color-black-100);
          }
        }

        td {
          vertical-align: top;
        }
      }
    }

    td, th {
      padding: .5rem 0;

      &:first-child {
        padding-left: 25px;
      }

      &:last-child {
        padding-right: 25px;
      }

      &:not(:first-child) {
        padding-left: 2rem;
      }
    }

    [data-coltype="number"] {
      text-align: right;
      white-space: nowrap;
    }

    .item-enter {
      opacity: 0;
    }

    .item-leave-active {
      position: absolute;
      opacity: 0;
    }
  }
</style>

StickyElement.vue

text
<!-- eslint-disable no-undef -->
<script lang="ts" setup>
  import { getCurrentInstance, onMounted, ref } from 'vue';

  //
  // Globals
  const props = withDefaults(defineProps<{
    position: string;
    tag?: string;
  }>(), { tag: 'div' });

  const vm = getCurrentInstance();
  const floating = ref(false);

  //
  // Data
  const element = ref<HTMLElement | null>(null);

  //
  // Methods
  function onMarkerVisible ([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) {
    floating.value = !entry.isIntersecting;
  }

  //
  // Init
  onMounted(() => {
    if (element.value) {
      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.value.insertAdjacentElement('beforebegin', markerElement);
          element.value.insertAdjacentElement('beforeend', shadowElement);
          break;

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

      observer.observe(markerElement);
    }
  });
</script>

<template>
  <component ref="element" :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: var(--sticky-element-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 {
      display: var(--sticky-element-shadow-display, block);
      position: absolute;
      width: 100%;
      height: var(--sticky-element-shadow-height, 100%);
      background-color: var(--sticky-element-border-color, #d6d6d6);
      opacity: 0;
    }

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

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

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

Search articles

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