Appearance
Table Component Code ​
Dependencies
This component requires:
- Vue 3 with Composition API
OScrollObservercomponent for infinite scroll functionality
Full Component Code ​
vue
<script setup>
import { toRefs, ref, computed } from 'vue';
import ScrollObserver from './OScrollObserver.vue';
/**
* OTable - A versatile table component with dynamic headers, content, and infinite scroll
*/
const emits = defineEmits([
'cellClicked',
'scrolledToEndInTable',
'onTableCellKeyDown',
'onTableCellBlur',
'onSort',
'onPageChange',
]);
const props = defineProps({
/**
* Array of header objects that define the table columns
* @example [{ text: "Name", key: "name", classes: "center", headerClasses: "right", sortable: true }]
*/
headers: {
type: Array,
required: true,
default: () => [],
},
/**
* Array of data objects to be displayed in the table
* Each object should have keys matching the header keys
*/
tableData: {
type: Array,
required: true,
default: () => [],
},
/**
* Enables or disables the hover effect on table rows
*/
enableHover: {
type: Boolean,
default: true,
},
/**
* Enables or disables the infinite scroll functionality
*/
enableInfiniteScroll: {
type: Boolean,
default: true,
},
/**
* Enables or disables pagination
*/
enablePagination: {
type: Boolean,
default: false,
},
/**
* Number of items per page when pagination is enabled
*/
itemsPerPage: {
type: Number,
default: 10,
},
/**
* Current page number when pagination is enabled
*/
currentPage: {
type: Number,
default: 1,
},
/**
* Total number of items for pagination
*/
totalItems: {
type: Number,
default: 0,
},
/**
* If true, all user actions (sort, pagination) will be emitted
* If false, actions will be handled internally
*/
async: {
type: Boolean,
default: false,
},
isAlternateRowColored: {
type: Boolean,
default: false,
},
/**
* Default width for table cells
*/
defaultCellWidth: {
type: String,
default: '1fr',
},
isAlternateColumnColored: {
type: Boolean,
default: false,
},
});
// Internal state for sorting and pagination
const sortState = ref({
key: null,
direction: 'asc',
});
// Computed properties for sorting and pagination
const sortedData = computed(() => {
if (!sortState.value.key || props.async) {
return props.tableData;
}
return [...props.tableData].sort((a, b) => {
let aValue = a[sortState.value.key];
let bValue = b[sortState.value.key];
// Handle null/undefined values
if (aValue === null || aValue === undefined) aValue = '';
if (bValue === null || bValue === undefined) bValue = '';
// Check if both values are numbers (including string numbers)
const aIsNumber = !isNaN(aValue) && aValue !== '';
const bIsNumber = !isNaN(bValue) && bValue !== '';
if (aIsNumber && bIsNumber) {
// Numerical comparison
return sortState.value.direction === 'asc' ? Number(aValue) - Number(bValue) : Number(bValue) - Number(aValue);
} else {
// String comparison (case-insensitive)
const comparison = String(aValue).toLowerCase().localeCompare(String(bValue).toLowerCase());
return sortState.value.direction === 'asc' ? comparison : -comparison;
}
});
});
const paginatedData = computed(() => {
if (!props.enablePagination || props.async) {
return sortedData.value;
}
const start = (props.currentPage - 1) * props.itemsPerPage;
const end = start + props.itemsPerPage;
return sortedData.value.slice(start, end);
});
const totalPages = computed(() => {
if (!props.enablePagination) return 1;
return Math.ceil(props.totalItems / props.itemsPerPage);
});
// Methods for handling sorting and pagination
const handleSort = (header) => {
if (!header.sortable) return;
if (props.async) {
let direction = sortState.value.key === header.key && sortState.value.direction === 'asc' ? 'desc' : 'asc';
emits('onSort', {
key: header.key,
direction,
});
sortState.value.direction = direction;
sortState.value.key = header.key;
return;
}
if (sortState.value.key === header.key) {
sortState.value.direction = sortState.value.direction === 'asc' ? 'desc' : 'asc';
} else {
sortState.value.key = header.key;
sortState.value.direction = 'asc';
}
};
const handlePageChange = (page) => {
emits('onPageChange', page);
};
/**
* Handles cell click events and emits data
*/
const tableCellClicked = (data, cell) => {
emits('cellClicked', data, cell);
};
/**
* Handles reaching the end of the table for infinite scroll
*/
function handelLoadMoreData() {
console.log('handelLoadMoreData');
emits('scrolledToEndInTable');
}
/**
* Handles keydown events on table cells
*/
function onTableCellKeyDown(event, rowData, cell) {
emits('onTableCellKeyDown', event, rowData, cell);
}
/**
* Handles blur events on table cells
*/
function onTableCellBlur(event, rowData, cell) {
emits('onTableCellBlur', rowData, cell);
}
const gridTemplate = computed(() => props.headers.map((h) => h.width || props.defaultCellWidth).join(' '));
// const gridTemplate = computed(() =>
// props.headers.map(h => h.width || "1fr").join(" ")
// );
const copyValue = (rowData, cell) => {
navigator.clipboard.writeText(rowData[cell.key]);
};
</script>
<template>
<div class="table-wrapper">
<div class="table-container">
<div class="table-body">
<div class="table-body-row table-header" :style="{ gridTemplateColumns: gridTemplate }">
<div
class="table-header-cell"
v-for="(header, index) in headers"
:key="index"
:class="[
header.headerClasses,
{
sortable: header.sortable,
'sorted-asc': sortState.key === header.key && sortState.direction === 'asc',
'sorted-desc': sortState.key === header.key && sortState.direction === 'desc',
},
]"
@click="header.sortable && handleSort(header)"
>
<slot :name="'header-' + header.key" :cell="header">
<div class="header-content">
{{ header.text }}
<span v-if="header.sortable" class="sort-icon">
<svg
v-if="sortState.key !== header.key"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M7 15l5 5 5-5M7 9l5-5 5 5" />
</svg>
<svg
v-else-if="sortState.direction === 'asc'"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M7 15l5 5 5-5" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M7 9l5-5 5 5" />
</svg>
</span>
</div>
</slot>
</div>
</div>
<div class="table-body-wrapper">
<div
class="table-body-row"
v-for="(rowData, rowIndex) in paginatedData"
:key="rowIndex"
:class="{
'hover-enabled': enableHover,
'alternate-row-colored': isAlternateRowColored,
'alternate-column-colored': isAlternateColumnColored,
}"
:style="{ gridTemplateColumns: gridTemplate }"
>
<div
class="table-body-cell"
v-for="(cell, cellIndex) in headers"
:key="cellIndex"
:class="cell.classes"
@click.stop="tableCellClicked(rowData, cell)"
tabindex="0"
@keydown="onTableCellKeyDown($event, rowData, cell)"
@blur="onTableCellBlur($event, rowData, cell)"
>
<slot :name="'cell-' + cell.key" :rowData="rowData" :cell="cell" :tableCellClicked="{ tableCellClicked }">
<div class="cell-content" :class="{ 'copy-enabled': cell.enableCopy }">
<div class="cell-content-text">
{{
cell.domFunc
? cell.domFunc(rowData[cell.key] != undefined ? rowData[cell.key] : '-')
: rowData[cell.key]
}}
</div>
<svg
v-if="cell.enableCopy"
@click.stop="copyValue(rowData, cell)"
class="copy-icon"
fill="#555"
viewBox="0 0 36 36"
version="1.1"
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<path
d="M27,3.56A1.56,1.56,0,0,0,25.43,2H5.57A1.56,1.56,0,0,0,4,3.56V28.44A1.56,1.56,0,0,0,5.57,30h.52V4.07H27Z"
class="clr-i-solid clr-i-solid-path-1"
></path>
<rect
x="8"
y="6"
width="23"
height="28"
rx="1.5"
ry="1.5"
class="clr-i-solid clr-i-solid-path-2"
></rect>
<rect x="0" y="0" width="36" height="36" fill-opacity="0"></rect>
</g>
</svg>
</div>
</slot>
</div>
</div>
<ScrollObserver v-if="enableInfiniteScroll" @scrolledToEnd="(...args) => handelLoadMoreData()" />
</div>
</div>
</div>
<div v-if="enablePagination" class="pagination">
<button :disabled="currentPage === 1" @click="handlePageChange(currentPage - 1)" class="pagination-button">
Previous
</button>
<span class="page-info"> Page {{ currentPage }} of {{ totalPages }} </span>
<button
:disabled="currentPage === totalPages"
@click="handlePageChange(currentPage + 1)"
class="pagination-button"
>
Next
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.table-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
padding: 0 0.25rem;
border-radius: 0.25rem;
background: white;
*::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
}
*::-webkit-scrollbar-track {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
}
*::-webkit-scrollbar-thumb {
background-color: #969696;
border-radius: 0.375rem;
}
.table-container {
height: 100%;
overflow: none;
border-radius: 0.25rem;
background: white;
.table-body {
display: grid;
grid-template-rows: max-content 1fr;
overflow-y: auto;
width: 100%;
height: 100%;
position: relative;
.table-body-wrapper {
width: 100%;
height: 100%;
}
.table-body-row {
display: grid;
align-items: center;
width: 100%;
.table-body-cell {
border: 1px solid grey;
.cell-content {
padding: 0;
margin: 0;
overflow: hidden;
.cell-content-text {
text-overflow: ellipsis;
white-space: nowrap;
}
&.copy-enabled {
cursor: pointer;
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: 0.25rem;
.cell-content-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
svg {
display: none;
width: 1rem;
height: 1rem;
}
}
&:hover {
&.copy-enabled {
svg {
display: inline-flex;
}
}
}
}
&.left-align {
.cell-content-text {
text-align: left;
}
}
&.right-align {
.cell-content-text {
text-align: right;
}
}
&.center-align {
.cell-content-text {
text-align: center;
}
}
}
&.table-header {
position: sticky;
top: 0;
left: 0;
z-index: 1;
.table-header-cell {
background: #eee;
border: 1px solid grey;
&:first-child {
border-top-left-radius: 0.5rem;
}
&:last-child {
border-top-right-radius: 0.5rem;
}
&.left-align {
.header-content {
text-align: left;
}
}
&.right-align {
.header-content {
text-align: right;
}
}
&.center-align {
.header-content {
text-align: center;
}
}
}
}
&.alternate-row-colored {
&:nth-child(odd) {
.table-body-cell {
background: #f5f5f5;
}
}
}
&:last-child {
.table-body-cell {
&:first-child {
border-bottom-left-radius: 0.5rem;
}
&:last-child {
border-bottom-right-radius: 0.5rem;
}
}
}
&.alternate-column-colored {
.table-body-cell {
&:nth-child(even) {
background: #e2fff4;
}
}
}
}
}
.table-body-cell,
.table-header-cell {
padding: 0.5rem;
align-self: stretch;
place-content: center;
}
}
&::-webkit-scrollbar-thumb {
background-color: #b697ed;
border-radius: 1rem;
}
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem;
background: white;
// border-top: 1px solid #d9d9d9;
.pagination-button {
padding: 0.5rem 1rem;
border: 1px solid #b697ed;
border-radius: 0.25rem;
background: white;
color: #b697ed;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: #b697ed;
color: white;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.page-info {
font-size: 0.875rem;
color: #666;
}
}
</style>