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>