Skip to content
Afa' Afa'

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

vue
<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: 0.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

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