Skip to content

Table Component Code ​

Dependencies

This component requires:

  • Vue 3 with Composition API
  • OScrollObserver component 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>