Skip to content
RTL Support: Full
Accessibility: Full
Translations: Work in progress

Data Table

A flexible, virtualized data table for rendering large datasets with sorting, filtering, pagination, row selection, reordering, sticky columns, and fully customizable cells.

It comes as two components: HLDataTable renders the columns and rows, and HLDataTableWrapper wraps it to provide the header — global search, layout options, selection actions, and the header/footer slots. Use them together, and reach for the examples below for each capability.

Basic Usage

The minimal setup: pass columns and data to HLDataTable inside an HLDataTableWrapper. Each column's cellFormatter lets you render custom content (badges, avatars, links) instead of plain text in that column's cells.

ID

First Name

Age

Progress

Rating

DOB

html
<HLDataTableWrapper id="basic-table-wrapper" max-width="100%">
  <HLDataTable
    id="basic-table"
    ref="tableInstance"
    :columns="columns"
    :page-size="6"
    :data="data"
    :horizontal-borders="false"
    :vertical-borders="false"
    max-height="900px"
  />
</HLDataTableWrapper>
ts
import { h, ref } from 'vue'
import { HLDataTable, HLDataTableWrapper, HLProgress, HLSpace, HLIcon, DataTableColumn } from '@platform-ui/highrise'
import { Star01Icon } from '@gohighlevel/ghl-icons/24/outline'
ts

interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
}

const columns:DataTableColumn<Person>[] = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'age',
    header: 'Age',
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id: 'progress',
    header: 'Progress',
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 250,
    cellFormatter: ({ row }) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
    meta: {
      headerAlign: 'center',
    },
  },
  {
    id: 'rating',
    accessorKey: 'rating',
    size: 200,
    header: 'Rating',
    meta: {
      headerAlign: 'start',
    },
    cellFormatter: ({ row }) => {
      const rating = row.original.rating
      const stars = []
      for (let i = 0; i < 5; i++) {
        const fill = i < rating ? 'var(--primary-600)' : 'var(--gray-400)'
        stars.push(h(HLIcon, { size: 16, color: fill }, Star01Icon as any))
      }
      return h(HLSpace, { align: 'center', wrapItem: false, size: 4 }, stars)
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
    },
    header: 'DOB',
  },
]
ts
const data = ref<Person[]>([
  {
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    age: 30,
    progress: 50,
    rating: 4,
    DOB: '2014-01-01',
  },
  {
    id: 2,
    firstName: 'Jane',
    lastName: 'Doe',
    age: 25,
    progress: 75,
    rating: 3,
    DOB: '2024-01-01',
  },
  {
    id: 3,
    firstName: 'Jim',
    lastName: 'Beam',
    age: 40,
    progress: 25,
    rating: 2,
    DOB: '2024-01-01',
  },
])

Table with Breadcrumb and Pagination

Pagination can also be used in the table header by passing the header slot to the HLDataTableWrapper component. The breadcrumb can be removed if not needed.

ID

First Name

Age

Progress

Rating

DOB

ts
import { ref } from 'vue'
import { HLDataTable, HLDataTableWrapper, HLBreadcrumb, HLPagination } from '@platform-ui/highrise'
html
<div class="h-[40vh]">
  <HLDataTableWrapper id="data-table-wrapper-default" max-width="100%" :fillParentHeight="true">
    <template #footer>
      <div class="flex justify-between items-center">
        <HLBreadcrumb size="md" :breadcrumbs="breadcrumbs" />
        <HLPagination
          v-if="tableInstanceBreadcrumbPagination"
          id="searchable-table-pagination"
          :item-count="tableInstanceBreadcrumbPagination.table.getFilteredRowModel().rows.length"
          :per-page="50"
          :current-page="tableInstanceBreadcrumbPagination.table?.getState().pagination.pageIndex + 1"
          :pages-to-display="7"
          :per-page-dropdown-options="paginationOptions"
          size="sm"
          per-page-text="Rows per page"
          @update:per-page="updatePageSizeBreadcrumbPagination"
          @update:page="
              page => {
                tableInstanceBreadcrumbPagination.table?.setPageIndex(page - 1)
              }
            "
        >
          <template #prev> Previous </template>
          <template #next> Next </template>
        </HLPagination>
      </div>
    </template>
    <HLDataTable
      id="data-table-default"
      :columns="columns"
      :data="data"
      max-height="auto"
      :page-size="50"
      ref="tableInstanceBreadcrumbPagination"
    >
    </HLDataTable>
  </HLDataTableWrapper>
</div>
ts
const breadcrumbs = [
  {
    label: 'BC1',
    href: 'https://google.com',
  },
  {
    label: 'BC2',
    href: 'https://google.com',
  },
  {
    label: 'BC3',
    href: 'https://google.com',
  },
]
ts
const paginationOptions = [
  {
    key: 50,
    label: 50,
  },
  {
    key: 100,
    label: 100,
  },
  {
    key: 200,
    label: 200,
  },
  {
    key: 300,
    label: 300,
  },
]
// Template ref: <HLDataTable ref="tableInstanceBreadcrumbPagination" />
const tableInstanceBreadcrumbPagination = ref(null)
const updatePageSizeBreadcrumbPagination = pageSize => {
  tableInstanceBreadcrumbPagination.value.table?.setPageSize(pageSize)
}

Responsive Columns

Set responsive-column-width to true on HLDataTableWrapper to size columns as a percentage of the table's width rather than in fixed pixels, so they scale with the container. With this on, each column's size is interpreted as a % instead of pixels.

First Name

Age

Rating

DOB

html
<HLDataTableWrapper id="basic-table-wrapper" max-width="800px" responsive-column-width>
  <HLDataTable
    id="responsive-table"
    ref="tableInstance"
    :columns="columnsResponsive"
    :data="data"
    :column-hover="true"
    :horizontal-borders="false"
    :vertical-borders="false"
    max-height="900px"
  >
  </HLDataTable>
</HLDataTableWrapper>
ts
  interface Person {
    id: number
    firstName: string
    lastName: string
    age: number
    progress: number
    rating: number
    DOB: string
  }
  
const columnsResponsive: DataTableColumn<Person>[] = [
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 30, // this will occupy 30% of the table width
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'age',
    header: 'Age',
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    size: 10, // this will occupy 10% of the table width
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id: 'rating',
    accessorKey: 'rating',
    header: 'Rating',
    size: 30, // this will occupy 30% of the table width
    meta: {
      align: 'center',
      headerAlign: 'start',
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    size: 30, // this will occupy 30% of the table width
    meta: {
      align: 'end',
    },
    header: 'DOB',
  },
]

Show a search box in the wrapper header by setting both show-header and show-global-search to true, then handle @update:global-filter to filter the rows as the user types.

The search placeholder can be customized using the search-placeholder prop on the HLDataTableWrapper.

Search table data
html
<HLButton class="mb-2" @click="updateGlobalFilterForTableWithSearch('')">Clear Filter</HLButton>
<HLDataTableWrapper
  id="row-expand-table-wrapper"
  max-width="1000px"
  :show-header="true"
  :show-global-search="true"
  :global-filter="filterString"
  search-placeholder="Search table data"
  @update:global-filter="updateGlobalFilter"
>
  <HLDataTable ref="tableInstance" id="table-with-search" :columns="columns" :data="data" max-height="auto" :page-size="6"> </HLDataTable>
</HLDataTableWrapper>
ts
import { ref } from 'vue'

const filterString = ref('')
const tableInstance = ref<any>(null)
const updateGlobalFilter = globalFilter => {
  filterString.value = globalFilter
  tableInstance.value.table?.setGlobalFilter(globalFilter) // set global filter to the table in current rows or make API call to get the filtered data from the server
}

Column Hover

Highlight the full column under the cursor to help users track values down a column on dense, wide tables. It's on by default; set column-hover to false to turn it off.

ID

First Name

Age

Progress

Rating

DOB

html
<HLDataTableWrapper id="basic-table-wrapper" max-width="1000px">
  <HLDataTable
    id="basic-table"
    ref="tableInstance"
    :columns="columns"
    :data="data"
    :column-hover="true"
    :horizontal-borders="false"
    :vertical-borders="false"
    max-height="900px"
  >
  </HLDataTable>
</HLDataTableWrapper>
ts
interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
}
const columns: DataTableColumn<Person>[] = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'age',
    header: 'Age',
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id: 'progress',
    header: 'Progress',
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 250,
    cellFormatter: ({ row }) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
    meta: {
      headerAlign: 'center',
    },
  },
  {
    id: 'rating',
    accessorKey: 'rating',
    size: 200,
    header: 'Rating',
    meta: {
      headerAlign: 'start',
    },
    cellFormatter: ({ row }) => {
      const rating = row.original.rating
      const stars = []
      for (let i = 0; i < 5; i++) {
        const fill = i < rating ? 'var(--primary-600)' : 'var(--gray-400)'
        stars.push(h(HLIcon, { size: 16, color: fill }, Star01Icon as any))
      }
      return h(HLSpace, { align: 'center', wrapItem: false, size: 4 }, stars)
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
    },
    header: 'DOB',
  },
]

const data = ref<Person[]>([
  {
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    age: 30,
    progress: 50,
    rating: 4,
    DOB: '2014-01-01',
  },
  {
    id: 2,
    firstName: 'Jane',
    lastName: 'Doe',
    age: 25,
    progress: 75,
    rating: 3,
    DOB: '2024-01-01',
  },
  {
    id: 3,
    firstName: 'Jim',
    lastName: 'Beam',
    age: 40,
    progress: 25,
    rating: 2,
    DOB: '2024-01-01',
  },
])

Column Resizing

Let users adjust column widths by dragging the resize handle on a column's right edge. Enable it with the column-resizing prop. Each column's starting width comes from its size (and is bounded by optional minSize / maxSize).

You can also resize columns programmatically via the setColumnSizing method on the table instance — useful for reflowing columns or keeping the last column flush to the table edge. The example below watches the active resize and grows the last column to fill the remaining space up to a max table width.

Normal Resizing

ID

First Name

Progress

DOB

html
<HLDataTableWrapper id="basic-table-wrapper" max-width="1000px">
  <HLDataTable id="basic-table" ref="tableInstance" :columns="columns" :data="data" :column-resizing="true" max-height="900px">
  </HLDataTable>
</HLDataTableWrapper>
ts
interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
}
const columns: DataTableColumn<Person>[] = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    cellFormatter: ({ row }) => {
      return row.original.id
    },
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 175,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'progress',
    header: 'Progress',
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 300,
    cellFormatter: ({ row }) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
    size: 250,
    header: 'DOB',
  },
]

Resizing with Auto-Grow Last Column

ID

First Name

Progress

DOB

html
<HLDataTableWrapper id="basic-table-wrapper">
  <HLDataTable id="basic-table" ref="tableInstanceColumnResize" :columns="columns" :data="data" :column-resizing="true" max-height="900px">
  </HLDataTable>
</HLDataTableWrapper>
ts

const tableInstanceColumnResize = ref<InstanceType<typeof HLDataTable>>()

const growLastColumn = () => {
  const TABLE_MAX_WIDTH = 800
  const table = tableInstanceColumnResize.value?.table
  if (!table) return

  const cols = table.getVisibleLeafColumns()
  if (cols.length < 2) return

  const last = cols[cols.length - 1]
  const usedByOthers = cols.slice(0, -1).reduce((s, c) => s + c.getSize(), 0)
  const target = Math.round(Math.max(TABLE_MAX_WIDTH - usedByOthers, last.columnDef.minSize ?? 0))

  // 🔑 bail if already at target — breaks the feedback loop
  if (Math.round(last.getSize()) === target) return

  if (Math.round(last.getSize()) > target) return

  table.setColumnSizing(prev => ({ ...prev, [last.id]: target }))
}

watch(
  () => tableInstanceColumnResize.value?.table.getState().columnSizingInfo.deltaOffset,
  () => {
    const id = tableInstanceColumnResize.value?.table.getState().columnSizingInfo.isResizingColumn
    const lastId = tableInstanceColumnResize.value?.table.getVisibleLeafColumns().at(-1)?.id
    if (id && id !== lastId) growLastColumn() // skip when dragging the last col itself
  }
)
ts
interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
}
const columns: DataTableColumn<Person>[] = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    cellFormatter: ({ row }) => {
      return row.original.id
    },
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 175,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'progress',
    header: 'Progress',
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 300,
    cellFormatter: ({ row }) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
    size: 250,
    header: 'DOB',
  },
]

Multi Row Header

Group related columns under a shared parent header by nesting columns inside a column definition. Use this for tables where several columns belong to one category (e.g. "Q1 / Q2 / Q3" under a "Revenue" header).

Info

DOB

Progress

Rating

Name

Sub text

20%

Age

html
<script setup lang="ts">
  import { DataTableColumn, HLContentWrap, HLDataTable, HLDataTableWrapper, HLIcon, HLProgress, HLSpace } from '@platform-ui/highrise'
  import AgeFilter from './AgeFilter.vue'
  import DateFilter from './DateFilter.vue'
  import { MaterialIdentityPlatformIcon } from '@gohighlevel/ghl-icons/24/material/rounded'
  import { CalendarIcon, Star01Icon } from '@gohighlevel/ghl-icons/24/outline'
  import MultiRowName from './MultiRowName.vue'
  import { h, ref } from 'vue'
  import commondata from './table-data'

  const data = ref<Person[]>(commondata)
  const tableInstance = ref<any>(null)
  const sortedColumn = ref<string>('')
  const sortDir = ref<string>('')

  function setSortSortFilter(id, desc) {
    sortedColumn.value = id
    sortDir.value = desc ? 'desc' : 'asc'
    tableInstance.value.table.setSorting([{ id, desc }])
  }
  function resetSortSortFilter() {
    sortedColumn.value = null
    sortDir.value = null
    tableInstance.value.table.setSorting([])
  }
  function setFilterSortFilter(id, filterFn, value) {
    const column = tableInstance.value.table.getColumn(id)
    column.columnDef.filterFn = filterFn
    column.setFilterValue(value)
  }
  function clearFilterSortFilter(id) {
    const column = tableInstance.value.table.getColumn(id)
    column.setFilterValue(undefined)
  }

interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
}
  const multiRowColumns: DataTableColumn<Person>[] = [
    {
      id: 'id',
      header: ' ',
      columns: [
        {
          id: 'id',
          header: ' ',
          accessorKey: 'id',
          size: 50,
          cellFormatter: ({ row }) => {
            return row.original.id
          },
          meta: {
            headerAlign: 'start',
          },
        },
      ],
    },
    {
      id: 'info',
      header: {
        text: 'Info',
        icon: MaterialIdentityPlatformIcon,
      },
      columns: [
        {
          id: 'firstName',
          header: () =>
            h(MultiRowName, {
              onSetFilter: setFilterSortFilter,
              onClearFilter: clearFilterSortFilter,
              onSetSort: setSortSortFilter,
              sortedColumn,
              sortDir,
              id: 'name-filter',
              onResetSort: resetSortSortFilter,
            }),
          sortingFn: 'alphanumeric',
          accessorKey: 'firstName',
          size: 200,
          meta: {
            align: 'start',
            headerAlign: 'start',
          },
        },
        {
          id: 'age',
          header: {
            text: 'Age',
            filterComponent: h(AgeFilter, {
              onSetFilter: setFilterSortFilter,
              onClearFilter: clearFilterSortFilter,
              onSetSort: setSortSortFilter,
              sortedColumn,
              sortDir,
              id: 'age-filter',
              onResetSort: resetSortSortFilter,
            }),
          },
          accessorKey: 'age',
          filterFn: 'greaterThan',
          sortingFn: 'alphanumeric',

          meta: {
            align: 'end',
            headerAlign: 'end',
          },
        },
      ],
      meta: {
        headerAlign: 'start',
      },
    },
    {
      id: 'DOB',
      header: {
        text: 'DOB',
        icon: CalendarIcon,
        filterComponent: h(DateFilter, {
          onSetFilter: setFilterSortFilter,
          onClearFilter: clearFilterSortFilter,
          onSetSort: setSortSortFilter,
          sortedColumn,
          sortDir,
          id: 'date-filter',
          onResetSort: resetSortSortFilter,
        }),
      },
      columns: [
        {
          id: 'DOB',
          accessorKey: 'DOB',
          meta: {
            align: 'end',
            headerAlign: 'end',
          },
          size: 200,
          header: ' ',
        },
      ],
    },
    {
      id: 'progress',
      header: 'Progress',
      columns: [
        {
          id: 'progress',
          header: ' ',
          accessorKey: 'progress',
          sortingFn: 'alphanumeric',
          size: 300,
          cellFormatter: ({ row }) => {
            return h(HLProgress, {
              id: 'progress',
              percentage: row.original.progress,
              type: 'line',
              dashboardSize: 'sm',
              valuePlacement: 'outside',
            })
          },
        },
      ],
    },
    {
      id: 'rating',
      header: 'Rating',
      columns: [
        {
          id: 'rating',
          accessorKey: 'rating',
          size: 200,
          header: ' ',
          meta: {
            headerAlign: 'start',
          },
          cellFormatter: ({ row }) => {
            const rating = row.original.rating
            const stars = []
            for (let i = 0; i < 5; i++) {
              const fill = i < rating ? 'var(--primary-600)' : 'var(--gray-400)'
              stars.push(h(HLIcon, { size: '16', color: fill }, Star01Icon as any))
            }
            return h(HLSpace, { align: 'center', wrapItem: false, size: 4 }, { default: () => stars })
          },
        },
      ],
    },
  ]
</script>

<template>
  <HLDataTableWrapper id="multi-row-table-wrapper">
    <HLDataTable id="multi-row-table" ref="tableInstance" :columns="multiRowColumns" :data="data" headerRowHeight="auto" :page-size="10" />
  </HLDataTableWrapper>
</template>
html
<script setup lang="ts">
  import { HLTag, HLText } from '@platform-ui/highrise'
  import NameFilter from './NameFilter.vue'
  import { MaterialArrowUpwardFillIcon } from '@gohighlevel/ghl-icons/24/material/rounded'
  import { Ref } from 'vue'

  interface Props {
    onSetFilter: (id: string, filterFn: any, value: any) => void
    onClearFilter: (id: string) => void
    onSetSort: (id: string, desc: boolean) => void
    onResetSort: () => void
    sortedColumn: Ref<string>
    sortDir: Ref<string>
    id?: string
  }

  defineProps<Props>()
</script>

<template>
  <div class="flex flex-col text-start">
    <span class="flex items-center justify-between">
      <HLText size="lg" weight="medium">Name</HLText>
      <NameFilter
        :id="id || 'age-filter'"
        :onSetFilter="onSetFilter"
        :onClearFilter="onClearFilter"
        :onSetSort="onSetSort"
        :sortedColumn="sortedColumn"
        :sortDir="sortDir"
        :onResetSort="onResetSort"
      />
    </span>
    <span class="flex items-center justify-start gap-2">
      <HLText size="lg" weight="semibold">Sub text</HLText>
      <HLTag color="success" :bordered="true">
        <template #icon>
          <component :is="MaterialArrowUpwardFillIcon" />
        </template>
        20%
      </HLTag>
    </span>
  </div>
</template>

Infinite Scroll

Append rows as the user scrolls toward the bottom instead of paging through fixed-size pages. Listen to @scroll, fetch the next batch when you near the end, and push it onto your data array; drive the loading indicator with the loading prop. Give each row a stable row-unique-id so appended rows don't remount. Unlike the paged examples, you grow data yourself here rather than calling setPageSize on the table instance.

ID

First Name

Age

Progress

Rating

DOB

html
<HLDataTableWrapper id="infinite-scroll-table-wrapper" max-width="1000px">
  <HLDataTable
    ref="infiniteScrollInstance"
    :columns="columns"
    :data="infiniteScrollData"
    :loading="infiniteScrollLoading"
    row-unique-id="id"
    @scroll="handleScroll"
    max-height="500px"
  />
</HLDataTableWrapper>
ts
import { ref } from 'vue'
import { HLDataTableWrapper, HLDataTable, DataTableColumn } from '@platform-ui/highrise'

const infiniteScrollLoading = ref(false)
const infiniteScrollInstance = ref(null)
const infiniteScrollData = ref<Person[]>([]) // your initial data
const handleScroll = async event => {
  const scrollPosition = event.target.scrollTop + event.target.clientHeight
  if (scrollPosition >= event.target.scrollHeight - 10) {
    infiniteScrollLoading.value = true
    const result = await fetchMoreData() // your API call to fetch more data
    infiniteScrollData.value = [...infiniteScrollData.value, ...result]
    infiniteScrollInstance.value.table?.setPageSize(infiniteScrollData.value.length)
    infiniteScrollLoading.value = false
  }
}

interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
}
const columns: DataTableColumn<Person>[] = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    cellFormatter: ({ row }) => {
      return row.original.id
    },
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'age',
    header: 'Age',
    accessorKey: 'age',
    filterFn: 'greaterThan',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id: 'progress',
    header: 'Progress',
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 300,
    cellFormatter: ({ row }) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
  },
  {
    id: 'rating',
    accessorKey: 'rating',
    size: 200,
    header: 'Rating',
    meta: {
      headerAlign: 'start',
    },
    cellFormatter: ({ row }) => {
      const rating = row.original.rating
      const stars = []
      for (let i = 0; i < 5; i++) {
        const fill = i < rating ? 'var(--primary-600)' : 'var(--gray-400)'
        stars.push(h(HLIcon, { size: '16', color: fill }, Star01Icon as any))
      }
      return h(HLSpace, { align: 'center', wrapItem: false, size: 4 }, { default: () => stars })
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
    size: 200,
    header: 'DOB',
  },
]

Scroll Into View

Programmatically scroll to a specific row using the exposed rowVirtualizer on the table instance.

ID

First Name

Age

Progress

Rating

DOB

html
<HLDataTableWrapper id="scroll-into-view-table-wrapper" max-width="1000px">
    <HLDataTable
      ref="scrollIntoViewInstance"
      :columns="columns"
      :data="data"
      row-unique-id="id"
      :page-size="30"
      max-height="500px"
    />
</HLDataTableWrapper>
<div>
  <br />
  <HLButton @click="scroll25">Scroll to row 25 </HLButton>
</div>
ts
const scrollIntoViewInstance = ref()
const scroll25 = () =>{
  scrollIntoViewInstance.value.rowVirtualizer.scrollToIndex(25,{behavior:'smooth',align:'center'})
}
ts
interface Options{
  align?: 'start' | 'center' | 'end' | 'auto' //default 'start'
  behavior?: 'auto' | 'smooth' //default 'auto'
}
function scrollToIndex(index:number,options:Options){
  //
}

Column Re-Order

Let users drag column headers to rearrange columns, so they can prioritize the fields they care about. It's on by default; set column-reordering to false to lock the order, and listen to @update:column-order to persist the user's arrangement.

ID

First Name

Age

Progress

Rating

DOB

Vue
html
<HLDataTableWrapper id="basic-table-wrapper" max-width="1000px">
  <HLDataTable id="basic-table" ref="tableInstance" :columns="columns" :data="data" :column-reordering="true" max-height="900px">
  </HLDataTable>
</HLDataTableWrapper>

Sticky Columns

Pin columns to the left or right edge so they stay in place while the rest of the table scrolls horizontally. Use this to keep an identifying column (a name or ID) or an actions column visible on wide tables. Set freezed-columns to { left: [...], right: [...] } with the column IDs to pin.

ID

First Name

Age

Progress

Rating

DOB

html
<HLDataTableWrapper id="sticky-column-table-wrapper" max-width="1000px">
  <HLDataTable
    ref="tableInstance"
    :columns="columns"
    :data="data"
    :striped="true"
    :horizontal-borders="true"
    :vertical-borders="true"
    max-height="900px"
    :freezed-columns="{ left: ['id', 'firstName'] }"
  >
  </HLDataTable>
</HLDataTableWrapper>
ts
interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
}
const columns: DataTableColumn<Person>[] = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'age',
    header: 'Age',
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id: 'progress',
    header: 'Progress',
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 250,
    cellFormatter: ({ row }) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
    meta: {
      headerAlign: 'center',
    },
  },
  {
    id: 'rating',
    accessorKey: 'rating',
    size: 200,
    header: 'Rating',
    meta: {
      headerAlign: 'start',
    },
    cellFormatter: ({ row }) => {
      const rating = row.original.rating
      const stars = []
      for (let i = 0; i < 5; i++) {
        const fill = i < rating ? 'var(--primary-600)' : 'var(--gray-400)'
        stars.push(h(HLIcon, { size: 16, color: fill }, Star01Icon as any))
      }
      return h(HLSpace, { align: 'center', wrapItem: false, size: 4 }, stars)
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
    },
    header: 'DOB',
  },
]

const data = ref<Person[]>([
  {
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    age: 30,
    progress: 50,
    rating: 4,
    DOB: '2014-01-01',
  },
  {
    id: 2,
    firstName: 'Jane',
    lastName: 'Doe',
    age: 25,
    progress: 75,
    rating: 3,
    DOB: '2024-01-01',
  },
  {
    id: 3,
    firstName: 'Jim',
    lastName: 'Beam',
    age: 40,
    progress: 25,
    rating: 2,
    DOB: '2024-01-01',
  },
])

Column Sort

Let users click a column header to sort by that column, toggling ascending and descending. Set a column's sortingFn to control how its values compare — alphanumeric, datetime, numeric, or a custom comparator.

Id

First Name

Age

Progress

Rating

DOB

html
<template>
  <HLDataTableWrapper id="data-table-wrapper" max-width="1000px">
    <HLDataTable
      id="data-table"
      ref="tableInstanceColumnSort"
      :columns="sortTableColumns"
      :data="data"
      :striped="true"
      :horizontal-borders="true"
      :vertical-borders="true"
      max-height="900px"
      row-height="40px"
      :page-size="6"
      header-row-height="40px"
      @update:column-clicked="handleColumnClickSort"
      @no-data="handleNoData"
      :row-hover="true"
      :column-hover="true"
    >
      <template #no-data>
        <HLEmpty
          id="empty-state"
          size="md"
          title="No data available to display. This is a placeholder slot"
          description="This is a placeholder"
          positive-text="Refresh"
          negative-text="Try again"
          icon="info"
        />
      </template>
    </HLDataTable>
  </HLDataTableWrapper>
</template>
ts
import { h, ref } from 'vue'
import {
  HLDataTableWrapper,
  HLDataTable,
  HLEmpty,
  HLProgress,
  HLIcon,
  HLTooltip,
  HLSpace,
  HLText,
} from '@platform-ui/highrise'
import type { DataTableColumn } from '@platform-ui/highrise'
import {
  UserCircleIcon,
  BarChartCircle01Icon,
  Star01Icon,
  InfoCircleIcon,
  CalendarIcon,
} from '@gohighlevel/ghl-icons/24/outline'
import SimpleSort from './SimpleSort.vue'
import { data } from './data'
const sortTableSortedColumn = ref(null)
const sortTableSortDir = ref(null)
const tableInstanceColumnSort = ref<any>(null)
interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
}
const sortTableColumns: DataTableColumn<Person>[] = [
  {
    id: 'id',
    header: 'Id',
    accessorKey: 'id',
    size: 50,
    meta: {
      align: 'center',
    },
  },
  {
    id: 'firstName',
    header: {
      text: 'First Name',
      icon: UserCircleIcon,
      filterComponent: h(SimpleSort, {
        sortDir: sortTableSortDir,
        sortedColumn: sortTableSortedColumn,
        columnName: 'firstName',
      }),
    },
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    cellFormatter: ({ row }) => {
      if (typeof row.original.firstName === 'string') {
        return row.original.firstName
      } else {
        return h(row.original.firstName)
      }
    },
    meta: {
      align: 'start',
    },
  },
  {
    id: 'age',
    header: {
      text: 'Age',
      icon: BarChartCircle01Icon,
      filterComponent: h(SimpleSort, {
        sortDir: sortTableSortDir,
        sortedColumn: sortTableSortedColumn,
        columnName: 'age',
      }),
    },
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id: 'progress',
    header: { text: 'Progress', icon: UserCircleIcon },
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 250,
    cellFormatter: ({ row }) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'rating',
    accessorKey: 'rating',
    header: { text: 'Rating', icon: Star01Icon },
    cellFormatter: ({ row }) => {
      const rating = row.original.rating
      const stars = []
      for (let i = 0; i < 5; i++) {
        const fill = i < rating ? 'var(--yellow-500)' : 'var(--gray-400)'
        stars.push(h(HLIcon, { size: '16', color: fill }, Star01Icon))
      }
      return h(
        HLTooltip,
        { trigger: 'hover', variant: 'dark', placement: 'top-start' },
        {
          trigger: () => h('div', { class: 'flex items-center gap-2 cursor-pointer' }, stars),
          default: () =>
            h(HLSpace, { align: 'center', wrapItem: false, size: 4 }, () => [
              h(HLIcon, { size: '20', color: 'white' }, InfoCircleIcon),
              h(HLText, { size: 'sm', weight: 'semibold' }, row.original.rating),
            ]),
        }
      )
    },
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
    header: {
      text: 'DOB',
      icon: CalendarIcon,
      filterComponent: h(SimpleSort, {
        sortDir: sortTableSortDir,
        sortedColumn: sortTableSortedColumn,
        columnName: 'DOB',
      }),
    },
  },
]

const handleColumnClickSort = columnId => {
  if (columnId === 'firstName' || columnId === 'DOB' || columnId === 'age') {
    if (tableInstanceColumnSort.value.table.getState()?.sorting[0]?.id === columnId) {
      const desc = tableInstanceColumnSort.value.table.getState()?.sorting[0]?.desc
      if (desc) {
        sortTableSortedColumn.value = ''
        sortTableSortDir.value = null
        tableInstanceColumnSort.value.table.setSorting([])
      } else {
        sortTableSortedColumn.value = columnId
        sortTableSortDir.value = 'desc'
        tableInstanceColumnSort.value.table.setSorting([{ id: columnId, desc: true }])
      }
    } else {
      sortTableSortedColumn.value = columnId
      sortTableSortDir.value = 'asc'
      tableInstanceColumnSort.value.table.setSorting([{ id: columnId, desc: false }])
    }
  }
}
function setSortTableSort(id, desc) {
  sortTableSortedColumn.value = id
  sortTableSortDir.value = desc ? 'desc' : 'asc'
  tableInstanceColumnSort.value.table.setSorting([{ id, desc }])
}
function resetSortTableSort() {
  sortTableSortedColumn.value = null
  sortTableSortDir.value = null
  tableInstanceColumnSort.value.table.setSorting([])
}
html
<script setup lang="ts">
  import { ArrowDownIcon } from '@gohighlevel/ghl-icons/24/outline'
  import { Ref } from 'vue'
  import { HLIcon } from '@platform-ui/highrise'

  interface Props {
    sortDir: Ref<'asc' | 'desc' | null>
    sortedColumn: Ref<string | null>
    columnName: string
  }
  const props = defineProps<Props>()
</script>
<template>
  <HLIcon
    v-if="sortedColumn.value === columnName"
    size="14"
    color="var(--primary-600)"
    :class="[sortDir.value === 'asc' ? 'rotate-180' : '']"
  >
    <ArrowDownIcon />
  </HLIcon>
</template>
ts
// data.ts — exported so `import { data } from './data'` resolves
export const data = [
  {
    id: 1,
    firstName: 'Felton',
    lastName: 'Ratke',
    age: 32,
    rating: 2,
    status: 'curatio',
    progress: 59,
    toggle: false,
    DOB: '2025/06/28',
  },
  {
    id: 2,
    firstName: 'Marlon',
    lastName: 'Graham',
    age: 54,
    rating: 2,
    status: 'adiuvo',
    progress: 32,
    toggle: false,
    DOB: '2024/05/01',
  },
  {
    id: 3,
    firstName: 'Woodrow',
    lastName: 'Cronin',
    age: 49,
    rating: 1,
    status: 'solio',
    progress: 8,
    toggle: false,
    DOB: '2025/05/31',
  },
  {
    id: 4,
    firstName: 'Abe',
    lastName: 'Padberg',
    age: 61,
    rating: 2,
    status: 'trans',
    progress: 41,
    toggle: false,
    DOB: '2024/10/26',
  },
  {
    id: 5,
    firstName: 'Stephany',
    lastName: 'Berge',
    age: 69,
    rating: 5,
    status: 'eius',
    progress: 30,
    toggle: true,
    DOB: '2026/02/18',
  },
]

Column Filter and Sort

Add per-column filtering and sorting by rendering a custom control in the column header via the header object's filterComponent. The example below uses two custom components (NameFilter and AgeFilter) that drive the table's filterFn and sortingFn for their columns.

Id

First Name

Age

Progress

Rating

DOB

html
<template>
  <HLDataTableWrapper id="column-filter-table-wrapper">
    <HLDataTable ref="tableInstance" id="column-filter-table" :columns="columns" :data="data" />
  </HLDataTableWrapper>
</template>
ts
import { BarChartCircle01Icon, Star01Icon, UserCircleIcon } from '@gohighlevel/ghl-icons/24/outline'
import { DataTableColumn, HLDataTable, HLDataTableWrapper, HLIcon, HLProgress, HLSpace } from '@platform-ui/highrise'
import { h, ref } from 'vue'
import NameFilter from './NameFilter.vue'

const sortedColumn = ref<string | null>(null)
const sortDir = ref<'asc' | 'desc' | null>(null)
const tableInstance = ref<any>(null)
const data = ref([]) // TODO: replace with actual data
const columns: DataTableColumn[] = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: {
      text: 'First Name',
      icon: h(UserCircleIcon),
      filterComponent: h(NameFilter, {
        onSetFilter: setFilter,
        onClearFilter: clearFilter,
        onSetSort: setSort,
        sortedColumn,
        sortDir,
        id: 'first-name-filter',
        onResetSort: resetSort,
      }),
    },
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
    },
  },
  {
    id: 'age',
    header: {
      text: 'Age',
      icon: h(BarChartCircle01Icon),
      filterComponent: h(AgeFilter, {
        onSetFilter: setFilter,
        onClearFilter: clearFilter,
        onSetSort: setSort,
        sortedColumn,
        sortDir,
        id: 'age-filter',
        onResetSort: resetSort,
      }),
    },
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id: 'progress',
    header: 'Progress',
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 250,
    cellFormatter: ({ row }: any) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
    meta: {
      headerAlign: 'center',
    },
  },
  {
    id: 'rating',
    accessorKey: 'rating',
    size: 200,
    header: 'Rating',
    meta: {
      headerAlign: 'start',
    },
    cellFormatter: ({ row }: any) => {
      const rating = row.original.rating
      const stars = []
      for (let i = 0; i < 5; i++) {
        const fill = i < rating ? 'var(--primary-600)' : 'var(--gray-400)'
        stars.push(h(HLIcon, { size: 16, color: fill }, Star01Icon as any))
      }
      return h(HLSpace, { align: 'center', wrapItem: false, size: 4 }, stars)
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
    header: 'DOB',
  },
]

function setSort(id: string, dir: 'asc' | 'desc') {
  sortedColumn.value = id
  sortDir.value = dir ? 'desc' : 'asc'
  tableInstance.value?.table.setSorting([{ id, dir }])
}
function resetSort() {
  sortedColumn.value = null
  sortDir.value = null
  tableInstance.value?.table.setSorting([])
}
function setFilter(id: string, filterFn: any, value: any) {
  const column = tableInstance.value?.table.getColumn(id)
  column.columnDef.filterFn = filterFn
  column.setFilterValue(value)
}
function clearFilter(id: string) {
  const column = tableInstance.value?.table.getColumn(id)
  column.setFilterValue(undefined)
}
html
<script setup lang="ts">
  import { CheckIcon, SearchSmIcon } from '@gohighlevel/ghl-icons/24/outline'
  import {
    HLAccordion,
    HLAccordionItem,
    HLButton,
    HLCheckbox,
    HLCheckboxGroup,
    HLIcon,
    HLInput,
    HLSelect,
    HLSpace,
    HLText,
  } from '@platform-ui/highrise'
  import { nextTick, Ref, ref } from 'vue'
  import FilterCompoent from './FilterComponent.vue'

  const emit = defineEmits(['setSort', 'setFilter', 'clearFilter', 'resetSort'])
  const filterIconColor = ref('var(--gray-400)')
  const nameInputValue = ref('')
  const isFilterApplied = ref(false)
  defineProps<{
    id: string
    sortedColumn: Ref<string | null>
    sortDir: Ref<'asc' | 'desc' | null>
  }>()
  const dropdownOptions = [
    {
      value: 'none',
      label: 'None',
      inputCount: 0,
    },
    {
      value: 'isEmpty',
      label: 'Is Empty',
      inputCount: 0,
    },
    {
      value: 'isNotEmpty',
      label: 'Is Not Empty',
      inputCount: 0,
    },
    {
      value: 'contains',
      label: 'Text contains',
      inputCount: 1,
    },
    {
      value: 'notContains',
      label: 'Text does not contain',
      inputCount: 1,
    },
    {
      value: 'startsWith',
      label: 'Text starts with',
      inputCount: 1,
    },
    {
      value: 'endsWith',
      label: 'Text ends with',
      inputCount: 1,
    },
    {
      value: 'equals',
      label: 'Text is exactly',
      inputCount: 1,
    },
  ]

  const selectedDropdownOption = ref(dropdownOptions[0])

  const handleSort = (dir: 'asc' | 'desc') => {
    emit('setSort', 'firstName', dir == 'desc')
  }
  function handleResetSort() {
    emit('resetSort', 'firstName')
  }
  function handleClear() {
    nameInputValue.value = ''
    isFilterApplied.value = false
    filterIconColor.value = 'var(--gray-400)'
    checkboxValue.value = []
    emit('clearFilter', 'firstName')
  }
  function handleApply() {
    if (checkboxValue.value.length) {
      emit('setFilter', 'firstName', 'containsInArray', checkboxValue.value)
      isFilterApplied.value = true
      filterIconColor.value = 'var(--primary-600)'
      return
    }
    if (selectedDropdownOption.value.value === 'none') {
      handleClear()
      return
    }
    isFilterApplied.value = true
    filterIconColor.value = 'var(--primary-600)'
    emit('setFilter', 'firstName', selectedDropdownOption.value.value, nameInputValue.value)
  }
  const checkboxOptions = [
    { label: '(Blank)', value: '' },
    { label: 'Mark', value: 'Mark' },
    { label: 'Jhon', value: 'Jhon' },
    { label: 'Abdul', value: 'Abdul' },
    { label: 'Victor', value: 'Victor' },
  ]
  const checkboxValue = ref<string[]>([])
  const checkboxInputValue = ref('')
  const nameInputValueRef = ref<any>(null)
  const updateSelectedDropdownOption = (key: string, option: any) => {
    selectedDropdownOption.value = option
    nextTick(() => {
      if (option.inputCount) {
        nameInputValueRef.value?.focus()
      }
    })
  }
</script>
<template>
  <FilterCompoent
    :id="`${id}-name-filter`"
    column-name="firstName"
    :sorted-column="sortedColumn"
    :is-filter-applied="isFilterApplied"
    :sort-dir="sortDir"
    @reset-sort="handleResetSort"
    @set-sort="handleSort"
    @clear-filter="handleClear"
    @apply-click="handleApply"
  >
    <div class="p-2 flex flex-col gap-2">
      <div
        class="data-table-filter-item flex"
        :sort-selected="
          sortedColumn.value == 'firstName' && sortDir.value == 'asc'
        "
        :class="{
          'sort-selected':
            sortedColumn.value == 'firstName' && sortDir.value == 'asc',
        }"
        @click="handleSort('asc')"
      >
        <HLText size="md" :weight="'medium'">Sort Ascending A-Z </HLText>
        <HLIcon v-if="sortedColumn.value == 'firstName' && sortDir.value == 'asc'" size="16" color="var(--primary-700)">
          <CheckIcon />
        </HLIcon>
      </div>
      <div
        class="data-table-filter-item flex"
        :class="{
          'sort-selected':
            sortedColumn.value == 'firstName' && sortDir.value == 'desc',
        }"
        @click="handleSort('desc')"
      >
        <HLText size="md" :weight="'medium'">Sort Descending Z-A </HLText>
        <HLIcon v-if="sortedColumn.value == 'firstName' && sortDir.value == 'desc'" size="16" color="var(--primary-700)">
          <CheckIcon />
        </HLIcon>
      </div>
      <HLAccordion size="sm" :border="false" :zero-padding="true">
        <HLAccordionItem id="1" title="Filter by Condition" name="1" size="sm" :hover-effect="false">
          <div class="flex flex-col gap-1 pt-1">
            <HLSelect
              id="select-default"
              size="xs"
              :options="dropdownOptions"
              :value="selectedDropdownOption.value"
              :option-height="26"
              @update:value="updateSelectedDropdownOption"
            ></HLSelect>
            <HLInput
              v-if="selectedDropdownOption.inputCount"
              id="name-filter"
              ref="nameInputValueRef"
              v-model:model-value="nameInputValue"
              placeholder="Filter by name"
              size="xs"
              clearable
              @update:value="nameInputValue = $event"
            />
          </div>
        </HLAccordionItem>
      </HLAccordion>

      <HLAccordion size="sm" :border="false" :zero-padding="true" :default-expanded-names="['1']">
        <HLAccordionItem id="1" title="Filter by Values" name="1" :hover-effect="false">
          <div class="flex flex-col gap-2 pt-2">
            <HLSpace :size="12" align="center" :wrap-item="false">
              <HLButton
                id="select-all-checkbox"
                size="3xs"
                variant="text"
                color="blue"
                @click="checkboxValue = checkboxOptions.map(i => i.value)"
              >
                Select All {{ checkboxOptions.length }}
              </HLButton>
              <HLButton id="select-all-checkbox" size="3xs" variant="text" color="gray" @click="checkboxValue = []"> Clear </HLButton>
              <HLText size="xs" :weight="'medium'" style="margin-left: auto">Displaying {{ checkboxOptions.length }}</HLText>
            </HLSpace>
            <HLInput
              id="name-filter"
              v-model:model-value="checkboxInputValue"
              :prefix-icon="SearchSmIcon as any"
              placeholder="Filter by placeholder"
              size="2xs"
              clearable
              @update:value="checkboxInputValue = $event"
            />
            <HLCheckboxGroup
              id="checkbox-group-1"
              :value="checkboxValue"
              size="xs"
              style="padding: 4px"
              @update:value="e => (checkboxValue = e as string[])"
            >
              <HLSpace :vertical="true">
                <HLCheckbox
                  v-for="(i, index) in checkboxOptions.filter(i =>
                    i.label.includes(checkboxInputValue)
                  )"
                  :id="`${id}-checkbox-${index}`"
                  :key="index"
                  :value="i.value"
                  size="xs"
                >
                  {{ i.label }}
                </HLCheckbox>
              </HLSpace>
            </HLCheckboxGroup>
          </div>
        </HLAccordionItem>
      </HLAccordion>
    </div>
  </FilterCompoent>
</template>
html
<script setup lang="ts">
  import { ArrowDownIcon, ArrowUpIcon, FilterLinesIcon } from '@gohighlevel/ghl-icons/24/outline'
  import { HLButton, HLIcon, HLPopover } from '@platform-ui/highrise'
  import { Ref, ref } from 'vue'

  const emit = defineEmits(['setSort', 'clearFilter', 'applyClick', 'resetSort'])
  const filterIconColor = ref('var(--gray-400)')
  const props = withDefaults(
    defineProps<{
      id: string
      sortedColumn: Ref<string | null>
      columnName: string
      isFilterApplied: boolean
      showSort?: boolean
      sortDir: Ref<'asc' | 'desc' | null>
    }>(),
    {
      showSort: true,
    }
  )

  function handleClear() {
    filterIconColor.value = 'var(--gray-400)'
    emit('clearFilter', props.columnName)
  }
  function handleApply() {
    filterIconColor.value = 'var(--primary-600)'
    emit('applyClick', props.columnName)
  }
  function handleShow(show: boolean) {
    if (show) {
      filterIconColor.value = 'var(--primary-600)'
    } else if (!props.isFilterApplied) {
      filterIconColor.value = 'var(--gray-400)'
    }
  }
  function toggleSort() {
    if (props.sortDir.value == 'asc') {
      emit('setSort', props.columnName, true)
    } else if (props.sortDir.value == 'desc') {
      emit('resetSort', props.columnName)
    }
  }
</script>
<template>
  <div>
    <HLIcon v-if="sortedColumn.value == columnName" size="14" color="var(--primary-600)" style="cursor: pointer" @click="toggleSort()">
      <ArrowUpIcon v-if="sortDir.value == 'asc'" />
      <ArrowDownIcon v-if="sortDir.value == 'desc'" />
    </HLIcon>
    <HLPopover :id="id" trigger="click" placement="bottom-end" :show-arrow="false" @show="handleShow">
      <template #trigger>
        <HLIcon size="14" :color="filterIconColor">
          <FilterLinesIcon />
        </HLIcon>
      </template>
      <template #default>
        <slot></slot>
      </template>
      <template #footer>
        <div class="flex gap-2 justify-end p-2">
          <HLButton :id="`${id}-filter-clear`" variant="ghost" color="gray" size="3xs" @click="handleClear"> Clear </HLButton>
          <HLButton :id="`${id}-filter-apply`" variant="ghost" color="blue" size="3xs" @click="handleApply"> Apply </HLButton>
        </div>
      </template>
    </HLPopover>
  </div>
</template>

Table Advance Filter

Build a multi-condition filter bar above the table using the HLAdvancedFilter component. Use this when users need to combine several criteria (field, operator, value) rather than a single search box.

Advanced Filters
Search

First Name

Last Name

Age

Rating

Date

html
<script setup lang="ts">
  import { h, ref, useAttrs } from 'vue'
  import { DataTableColumn, HLDataTable, HLDataTableWrapper, HLEmpty } from '@platform-ui/highrise'
  import FilterDrawer from './FilterDrawer.vue'

  const globalFilter = ref('')
  const tableInstance = ref<{ table: any }>()
  const data = ref<Person[]>([]) // your data
  // Global filter change handler
  const handleGlobalFilterChange = value => {
    globalFilter.value = value
    tableInstance.value.table?.setGlobalFilter(value)
  }

  interface Person {
    id: number
    firstName: string
    lastName: string
    age: number
    progress: number
    rating: number
    DOB: string
  }

  const columns: DataTableColumn<Person>[] = [
    {
      id: 'firstName',
      header: {
        text: 'First Name',
      },
      sortingFn: 'alphanumeric',
      accessorKey: 'firstName',
      size: 100,
      meta: {
        align: 'start',
        headerAlign: 'start',
      },
    },
    {
      id: 'lastName',
      header: {
        text: 'Last Name',
      },
      accessorKey: 'lastName',
      sortingFn: 'alphanumeric',
      size: 100,
      meta: {
        align: 'start',
        headerAlign: 'start',
      },
    },
    {
      id: 'age',
      header: {
        text: 'Age',
      },
      accessorKey: 'age',
      sortingFn: 'alphanumeric',
      size: 100,
      meta: {
        align: 'end',
        headerAlign: 'end',
      },
    },
    {
      id: 'rating',
      accessorKey: 'rating',
      header: {
        text: 'Rating',
      },
      size: 100,
      meta: {
        align: 'center',
        headerAlign: 'start',
      },
    },
    {
      id: 'DOB',
      accessorKey: 'DOB',
      size: 100,
      meta: {
        align: 'end',
      },
      header: {
        text: 'Date',
      },
      cellFormatter: ({ row }) => {
        return h('div', { style: { textAlign: 'right' } }, new Date(row.original.DOB).toLocaleDateString())
      },
    },
  ]

  const handleDataUpdate = (updatedData: any[]) => {
    data.value = updatedData
  }
</script>

<template>
  <div style="height: calc(100vh - 62px)">
    <HLDataTableWrapper
      id="data-table-wrapper-default"
      max-width="1000px"
      :show-global-search="true"
      :show-header="true"
      :global-filter="globalFilter"
      responsive-column-width
      @update:global-filter="handleGlobalFilterChange"
    >
      <template #header-content-left>
        <FilterDrawer :dataJson="data" @update:data="handleDataUpdate" />
      </template>
      <template #header-content-right> </template>
      <HLDataTable id="data-table-default" ref="tableInstance" :data="data" :columns="columns">
        <template #no-data>
          <HLEmpty
            id="empty-state"
            size="md"
            title="No data available to display. This is a placeholder slot"
            description="This is a placeholder"
            positive-text="Refresh"
            negative-text="Try again"
            icon="info"
          />
        </template>
      </HLDataTable>
    </HLDataTableWrapper>
  </div>
</template>
html
<script setup lang="ts">
  import { MaterialFilterListOffFillIcon } from '@gohighlevel/ghl-icons/24/material/rounded'
  import { FilterFunnel01Icon, Trash01Icon } from '@gohighlevel/ghl-icons/24/outline'
  import { HLAdvanceFilter } from '@platform-ui/highrise'
  import { HLAdvanceFilterColumnOption, HLAdvanceFilterData } from '@platform-ui/highrise'
  import { h, nextTick, ref } from 'vue'
  import {
    HLButton,
    HLDrawer,
    HLDrawerContent,
    HLFormItem,
    HLInput,
    HLModal,
    HLSectionFooter,
    HLSectionFooterItem,
    HLSelect,
    HLTag,
    HLText,
  } from '@platform-ui/highrise'

  const props = defineProps<{
    dataJson: any[]
  }>()

  const emit = defineEmits<{
    'update:data': [data: any[]]
  }>()

  // Filter options and filter data
  const filterOptions = ref([
    {
      label: 'Default',
      value: 'Default',
      data: {
        condition: 'OR',
        rules: [
          {
            condition: 'AND',
            rules: [
              {
                field: null,
                operator: null,
                value: null,
              },
            ],
          },
        ],
      },
    },
    {
      label: 'Nikhil',
      value: 'Nikhil',
      data: {
        condition: 'OR',
        rules: [
          {
            condition: 'AND',
            rules: [
              {
                field: 'firstName',
                operator: 'equal',
                value: 'Nikhil',
              },
            ],
          },
        ],
      },
    },
    {
      label: 'Age',
      value: 'Age',
      data: {
        condition: 'OR',
        rules: [
          {
            condition: 'AND',
            rules: [
              {
                field: 'age',
                operator: 'equal',
                value: 56,
              },
            ],
          },
        ],
      },
    },
  ])
  const filterColumnOptions: HLAdvanceFilterColumnOption[] = [
    { label: 'First Name', value: 'firstName', columnType: 'string' },
    {
      label: 'Last Name',
      value: 'lastName',
      columnType: 'string',
    },
    { label: 'Age', value: 'age', columnType: 'number' },
    {
      label: 'Rating',
      value: 'rating',
      columnType: 'select',
      operatorOptions: [
        {
          type: 'group',
          label: 'Filter by value',
          key: 'Filter by value',
          children: [
            {
              label: 'In',
              value: 'in',
              valueType: 'select',
              props: {
                multiple: true,
                showAvatarInTags: false,
                options: [
                  { label: '1', value: 1 },
                  { label: '2', value: 2 },
                  { label: '3', value: 3 },
                  { label: '4', value: 4 },
                  { label: '5', value: 5 },
                ],
              },
            },
          ],
        },
        {
          type: 'group',
          label: 'Filter by value',
          key: 'Filter by value',
          children: [
            {
              label: 'Is equal to',
              value: 'equal',
              valueType: 'number',
              props: { min: 1, max: 5 },
            },
          ],
        },
      ],
    },
    {
      label: 'Birthday',
      value: 'DOB',
      columnType: 'date',
    },
  ]

  // Modal and button states
  const selectedFilterOption = ref('Default')
  let prevSlectedOption = null
  const isDrawerOpen = ref(false)
  const isChangesMade = ref(false)
  const showSaveAsModal = ref(false)
  const showDeleteFilterModal = ref(false)
  const filterName = ref('')
  const saveAsInput = ref()
  const deleteFilterOption = ref<any>({})
  const advanceFilterRef = ref<InstanceType<typeof HLAdvanceFilter> | null>(null)
  const filterData = ref(filterOptions.value[0].data as HLAdvanceFilterData)
  const filterNameHasError = ref(true)
  const saveAsRule = {
    trigger: ['input', 'blur'],
    required: true,
    validator: () => {
      if (!filterName.value) {
        filterNameHasError.value = true
        return new Error('Filter name is Empty')
      }
      if (filterOptions.value.find(option => option.value == filterName.value)) {
        filterNameHasError.value = true
        return new Error('Filter name already exists')
      }
      filterNameHasError.value = false
      return true
    },
    level: 'error',
  }
  const openAdvancedFilters = () => {
    prevSlectedOption = selectedFilterOption.value
    isDrawerOpen.value = true
  }

  const hanldeSelect = (value: string, option) => {
    selectedFilterOption.value = value
    filterData.value = option.data
  }

  const handleUpdates = () => {
    isChangesMade.value = true
  }

  const handleClearFilters = () => {
    filterData.value = {
      condition: 'OR',
      rules: [
        {
          condition: 'AND',
          rules: [
            {
              field: null,
              operator: null,
              value: null,
            },
          ],
        },
      ],
    }
    isChangesMade.value = true
    advanceFilterRef.value?.setFilterData(filterData.value)
  }

  const handleCancel = () => {
    selectedFilterOption.value = prevSlectedOption
    filterData.value = filterOptions.value.find(option => option.value === selectedFilterOption.value).data as HLAdvanceFilterData
    isDrawerOpen.value = false
  }

  const handleSave = () => {
    filterOptions.value.find(option => option.value === selectedFilterOption.value).data = filterData.value
    isDrawerOpen.value = false
    handleApply()
    isChangesMade.value = false
  }

  const handleApply = () => {
    const result = mongoFilter(filterData.value, props.dataJson) // Your API call or own logic to filter the data
    emit('update:data', result)
    isDrawerOpen.value = false
  }

  const closeModal = () => {
    filterName.value = ''
    filterNameHasError.value = false
    deleteFilterOption.value = {}
    showSaveAsModal.value = false
    showDeleteFilterModal.value = false
  }
  const handleSaveAsFilter = () => {
    showSaveAsModal.value = true
    filterNameHasError.value = true
    filterName.value = ''
    nextTick(() => {
      saveAsInput.value?.focus()
    })
  }

  const handleSaveAs = () => {
    const option = {
      label: filterName.value,
      value: filterName.value,
      data: filterData.value,
    }
    filterOptions.value.push(option)
    selectedFilterOption.value = filterName.value
    closeModal()
  }

  const deleteFilter = () => {
    filterOptions.value = filterOptions.value.filter(op => op.value !== deleteFilterOption.value.value)
    selectedFilterOption.value = 'Default'
    hanldeSelect('Default', filterOptions.value[0])
    closeModal()
  }
  const handleDeleteFilter = (option: any, e: Event) => {
    deleteFilterOption.value = option
    e.stopImmediatePropagation()
    e.preventDefault()
    showDeleteFilterModal.value = true
  }
</script>

<template>
  <HLTag
    id="add-filter"
    round
    size="lg"
    :count="filterData.rules[0].rules[0].value ? filterData.rules.length : undefined"
    @click="openAdvancedFilters"
  >
    <template #icon>
      <FilterFunnel01Icon />
    </template>
    Advanced Filters
  </HLTag>

  <HLDrawer id="advanced-filters-drawer" v-model:show="isDrawerOpen" :defaultWidth="360" :maskClosable="false">
    <HLDrawerContent id="advanced-filters-drawer-content">
      <div class="flex justify-between items-center w-full pb-2">
        <HLText size="lg" weight="semibold"> All Filters </HLText>
        <HLButton
          id="clear-filters-button"
          size="xs"
          variant="ghost"
          color="gray"
          :disabled="!filterData.rules[0].rules[0].value"
          @click="handleClearFilters"
        >
          <template #iconLeft>
            <MaterialFilterListOffFillIcon />
          </template>
          Clear Filters
        </HLButton>
      </div>
      <div class="pb-6">
        <HLSelect
          :value="selectedFilterOption"
          size="2xs"
          :option-renderer="
            option =>
              h('div', { class: 'flex w-full', style: { justifyContent: 'space-between' } }, [
                h('span', {}, option.label),
                h(
                  HLButton,
                  {
                    id: 'delete-filter-button',
                    size: '3xs',
                    onClick: (e: Event) => handleDeleteFilter(option, e),
                    variant: 'ghost',
                    disabled: option.value === 'Default',
                  },
                  { icon: () => h(Trash01Icon) }
                ),
              ])
          "
          :options="filterOptions"
          @update:value="hanldeSelect"
        />
      </div>
      <HLAdvanceFilter
        ref="advanceFilterRef"
        :key="selectedFilterOption"
        v-model:data="filterData"
        :columnOptions="filterColumnOptions"
        @update:data="handleUpdates"
      />
      <template #footer>
        <div id="footer" class="flex justify-between items-center w-full">
          <HLButton id="cancel-button" variant="secondary" color="gray" size="2xs" @click="handleCancel"> Cancel </HLButton>
          <div class="flex gap-2">
            <HLButton id="save-as-button" variant="secondary" color="gray" size="2xs" @click="handleSaveAsFilter"> Save As </HLButton>
            <HLButton id="save-button" :disabled="!isChangesMade" variant="secondary" color="blue" size="2xs" @click="handleSave">
              Save
            </HLButton>
            <HLButton id="reset-button" variant="primary" color="primary" size="2xs" @click="handleApply"> Apply </HLButton>
          </div>
        </div>
      </template>
    </HLDrawerContent>
  </HLDrawer>

  <!-- Modal for save as filter and delete filter -->
  <HLModal id="save-filter-modal" v-model:show="showSaveAsModal" :footer-divider="true" type="success" :show-header-icon="false">
    <template #header> Save Filter As </template>
    <HLFormItem label="Filter Name" :rule="saveAsRule">
      <HLInput id="filter-name" ref="saveAsInput" v-model:model-value="filterName" size="xs" placeholder="Enter filter name" />
    </HLFormItem>
    <template #footer>
      <HLSectionFooter id="save-filter-footer">
        <HLSectionFooterItem>
          <HLButton id="cancel-button" size="xs" variant="secondary" color="gray" @click="closeModal"> Cancel </HLButton>
          <HLButton id="save-button" size="xs" variant="primary" color="blue" :disabled="filterNameHasError" @click="handleSaveAs">
            Save
          </HLButton>
        </HLSectionFooterItem>
      </HLSectionFooter>
    </template>
  </HLModal>
  <HLModal id="delete-filter-modal" v-model:show="showDeleteFilterModal" :footer-divider="true" type="error">
    <template #header> Delete {{ deleteFilterOption.label }} </template>
    Are you sure you want to delete this filter? {{ selectedFilterOption == deleteFilterOption.value ? 'This filter is currently applied.' :
    '' }}
    <template #footer>
      <HLSectionFooter id="delete-filter-footer">
        <HLSectionFooterItem>
          <HLButton id="cancel-button" size="xs" variant="secondary" color="gray" @click="closeModal"> Cancel </HLButton>
          <HLButton id="delete-button" size="xs" variant="primary" color="red" @click="deleteFilter"> Delete </HLButton>
        </HLSectionFooterItem>
      </HLSectionFooter>
    </template>
  </HLModal>
</template>

Empty State

Render a friendly placeholder — an illustration, message, or call to action — when there are no rows to show, instead of a blank table. The no-data slot renders whenever the data prop is an empty array.

ID

First Name

Progress

Rating

DOB

No data available to display. This is a placeholder slot

This is a placeholder

html
<HLDataTableWrapper>
  <HLDataTable
    ref="tableInstance"
    :columns="columns"
    :data="data"
    :striped="true"
    :horizontal-borders="true"
    :vertical-borders="true"
    max-height="900px"
  >
    <template #no-data>
      <HLEmpty
        id="empty-state"
        size="md"
        title="No data available to display. This is a placeholder slot"
        description="This is a placeholder"
        positive-text="Refresh"
        negative-text="Try again"
      />
    </template>
  </HLDataTable>
</HLDataTableWrapper>
ts
import { HLDataTable, HLDataTableWrapper, HLEmpty } from '@platform-ui/highrise'

Error State

Show a retry-oriented message when data fails to load, reusing the no-data slot to render an error illustration and action. Switch the slot content based on your own request state so users can tell an error apart from a genuinely empty result.

ID

First Name

Progress

Rating

DOB

Something went wrong while fetching your appointments

You can try again now or after 10 minutes

html
<!-- When your request fails, render the `no-data` slot with error semantics.
     Pass empty `data` so the slot shows; swap the slot content on success/empty/error. -->
<HLDataTableWrapper id="error-state-table-wrapper" max-width="1000px">
  <HLDataTable
    ref="tableInstance"
    :columns="columns"
    :data="hasError ? [] : data"
    :horizontal-borders="true"
    :vertical-borders="true"
    max-height="900px"
  >
    <template #no-data>
      <HLEmpty
        id="error-state"
        size="md"
        icon="error"
        title="Something went wrong while fetching your appointments"
        description="You can try again now or after 10 minutes"
        negative-text="Retry"
        positive-text=""
      />
    </template>
  </HLDataTable>
</HLDataTableWrapper>
ts
import { ref } from 'vue'
import { HLDataTable, HLDataTableWrapper, HLEmpty } from '@platform-ui/highrise'

// Drive the slot from your own request state so an error is
// distinguishable from a genuinely empty result.
const hasError = ref(true)

Highlighted Rows

Give specific rows a persistent background highlight by passing their row IDs to the highlighted-rows prop. Use this to draw attention to rows that matter — new records, matches, or rows flagged by an action elsewhere in the UI. This is independent of row selection and hover.

ID

First Name

Age

Progress

Rating

DOB

Vue
html
<HLContentWrap :dir="direction">
  <HLDataTableWrapper id="highlighted-rows-table-wrapper" max-width="1000px">
    <HLDataTable
      ref="tableInstance"
      :columns="columns"
      :data="data"
      :horizontal-borders="true"
      :vertical-borders="true"
      :row-hover="false"
      :column-hover="false"
      max-height="900px"
      :highlighted-rows="[1,5,8]"
    >
    </HLDataTable>
  </HLDataTableWrapper>
</HLContentWrap>

Row Expansion

Click a row to open more content underneath it. You put that content in the row's expandData. There are three ways it can look:

ModeWhat opens underneath
Custom PanelYour own design — a card, a form, some text
Nested Table RowsMore rows, in the same table layout
Mixed Rows + PanelSome nested rows, plus your own design

TagGroup Usage

Using cellFormatter we can render custom content inside the table cells.

ID

First Name

Age

tag

DOB

html
<HLDataTableWrapper id="basic-table-wrapper" max-width="100%">
  <HLDataTable
    id="basic-table"
    ref="tableInstance"
    :columns="columns"
    :page-size="6"
    :data="data"
    :horizontal-borders="false"
    :vertical-borders="false"
    max-height="900px"
  />
</HLDataTableWrapper>
ts
import { HLDataTable, HLDataTableWrapper, HLProgress, HLSpace, HLIcon, DataTableColumn, HLTagGroup } from '@platform-ui/highrise'
import { Star01Icon } from '@gohighlevel/ghl-icons/24/outline'
ts

interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
  tags: string[]
}

const columns:DataTableColumn<Person>[] = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'age',
    header: 'Age',
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id:'tag',
    accessorKey: 'tag',
    cellFormatter: ({ row }) =>{
      const defaultValue = row.original.tag || []
      return h('div',{style:'overflow: hidden;'},h(HLTagGroup,{ size:'sm',defaultValue, color:'primary',tooltipVariant:'light',bordered:true}))
    },
    size:200
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
    },
    header: 'DOB',
  }
]
ts
const data = ref<Person[]>([
  {
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    age: 30,
    progress: 50,
    rating: 4,
    DOB: '2014-01-01',
    tag: ['tag1', 'tag2']
  },
  {
    id: 2,
    firstName: 'Jane',
    lastName: 'Doe',
    age: 25,
    progress: 75,
    rating: 3,
    DOB: '2024-01-01',
  },
  {
    id: 3,
    firstName: 'Jim',
    lastName: 'Beam',
    age: 40,
    progress: 25,
    rating: 2,
    DOB: '2024-01-01',
  },
])

Custom Panel

Open a row to show your own content underneath — a card, a form, some text. You design what appears using expandedRowRenderer.

ID

First Name

Age

Progress

Rating

DOB

html
<HLContentWrap :dir="direction">
  <HLDataTableWrapper>
    <HLDataTable
      ref="tableInstance"
      :columns="columns"
      :data="data"
      :horizontal-borders="true"
      :vertical-borders="true"
      :row-hover="false"
      :column-hover="false"
      max-height="900px"
      @table-row-clicked="handleTableRowClick"
      :expanded-row-renderer="(row)=>h('div',{class: 'bg-gray-200 p-2'}, row.data)"
    >
    </HLDataTable>
  </HLDataTableWrapper>
</HLContentWrap>
ts
const columns = [
  {
    id: 'expand',
    header: '',
    cellFormatter: ({ row }) => {
      return h(
        HLButton,
        {
          variant: 'ghost',
          size: '3xs',
          label: 'Expand',
        },
        { icon: row.getIsExpanded() ? ChevronUpIcon : ChevronDownIcon }
      )
    },
    size: 32,
    meta: {
      align: 'center',
    },
  },
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    cellFormatter: ({ row }) => {
      return row.original.id
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      sticky: true,
    },
  },
]

const data = ref([
  {
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    age: 30,
    progress: 50,
    DOB: '2014-01-01',
    expandData: ['This is expanded content for John Doe'],
  },
  {
    id: 2,
    firstName: 'Jane',
    lastName: 'Doe',
    age: 25,
    progress: 75,
    DOB: '2024-01-01',
    expandData: ['This is expanded content for Jane Doe'],
  },
  {
    id: 3,
    firstName: 'Jim',
    lastName: 'Beam',
    age: 40,
    progress: 25,
    DOB: '2024-01-01',
    expandData: ['This is expanded content for Jim Beam'],
  },
])
const handleTableRowClick = (row, rowInfo) => {
  rowInfo.getToggleExpandedHandler()()
}

Nested Table Rows

Open a row to show more rows underneath, laid out with the same columns. Good for nested data like an order and its items. Leave out expandedRowRenderer and the table handles the layout for you.

[Note]

Don't pass the expandedRowRenderer prop to the table to get the nested data as per the columns.

Last Name

ID

First Name

Age

Progress

Rating

DOB

html
<HLContentWrap :dir="direction">
  <HLDataTableWrapper>
    <HLDataTable
      ref="tableInstance"
      :columns="columns"
      :data="date"
      :horizontal-borders="true"
      :vertical-borders="true"
      :row-hover="false"
      :column-hover="false"
      max-height="900px"
    >
    </HLDataTable>
  </HLDataTableWrapper>
</HLContentWrap>
ts
interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string
}
const columns: DataTableColumn<Person>[] = [
  {
    id: 'lastName',
    header: 'Last Name',
    accessorKey: 'lastName',
    size: 250,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
    cellFormatter: ({ row }) => {
      return h('div', { class: 'flex items-center' }, [
        h('div', { style: { paddingLeft: `${row.depth * 32}px` } }, null),
        row.originalSubRows &&
          h(
            'div',
            { style: { paddingRight: `12px` } },
            h(
              HLIcon,
              {
                id: 'expand-button',
                size: '20',
                class: 'cursor-pointer',
                onClick: () => {
                  row.getToggleExpandedHandler()()
                },
              },
              row.getIsExpanded() ? ChevronUpIcon : ChevronDownIcon
            )
          ),
        h(
          HLText,
          {
            size: 'lg',
            weight: 'medium',
          },
          row.original.lastName
        ),
      ])
    },
  },
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    cell: ({ row }) => {
      return row.original.id
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      sticky: true,
    },
  },
]

const data = ref<Person[]>([
  {
    id: 1,
    firstName: 'Jerel',
    lastName: 'Rath',
    age: 38,
    rating: 5,
    status: 'vilicus',
    progress: 19,
    toggle: false,
    DOB: '04/11/1995',
    expandData: [
      {
        id: 7,
        firstName: 'Darius',
        lastName: 'Abshire',
        age: 18,
        rating: 1,
        status: 'neque',
        progress: 37,
        toggle: false,
        DOB: '27/11/1970',
        expandData: [
          {
            id: 22,
            firstName: 'Jayden',
            lastName: 'Padberg',
            age: 55,
            rating: 3,
            status: 'cumque',
            progress: 99,
            toggle: false,
            DOB: '08/04/1974',
          },
          {
            id: 23,
            firstName: 'Chyna',
            lastName: 'Bruen',
            age: 25,
            rating: 5,
            status: 'laboriosam',
            progress: 46,
            toggle: false,
            DOB: '15/11/1997',
          },
          {
            id: 24,
            firstName: 'Shanon',
            lastName: 'Sauer',
            age: 24,
            rating: 3,
            status: 'dolor',
            progress: 80,
            toggle: true,
            DOB: '04/05/1947',
          },
        ],
      },
      {
        id: 8,
        firstName: 'Damon',
        lastName: 'Frami',
        age: 25,
        rating: 2,
        status: 'corona',
        progress: 37,
        toggle: false,
        DOB: '14/10/1976',
      },
      {
        id: 9,
        firstName: 'Margret',
        lastName: 'Maggio',
        age: 24,
        rating: 3,
        status: 'voluptatem',
        progress: 60,
        toggle: true,
        DOB: '12/08/1969',
      },
    ],
  },
  {
    id: 2,
    firstName: 'Avery',
    lastName: 'Pagac',
    age: 67,
    rating: 1,
    status: 'candidus',
    progress: 0,
    toggle: false,
    DOB: '16/08/1996',
  },
  {
    id: 3,
    firstName: 'Jessica',
    lastName: 'Bailey',
    age: 24,
    rating: 3,
    status: 'dens',
    progress: 74,
    toggle: true,
    DOB: '02/03/1980',
  },
  {
    id: 4,
    firstName: 'Emmalee',
    lastName: 'Anderson',
    age: 18,
    rating: 2,
    status: 'cognatus',
    progress: 92,
    toggle: true,
    DOB: '09/02/1994',
  },
  {
    id: 5,
    firstName: 'Christian',
    lastName: 'Ritchie',
    age: 53,
    rating: 5,
    status: 'strenuus',
    progress: 100,
    toggle: true,
    DOB: '08/06/1963',
  },
])

Mixed Rows + Panel

Open a row to show a mix underneath — some items as normal table rows, others as your own design. Add isTableRow: true to an item to show it as a table row; the rest use your expandedRowRenderer.

[Note]

Pass the expandedRowRenderer prop to the table to get the custom slot. to get nested rows, pass isTableRow as true in the row data.

Last Name

ID

First Name

html
<HLDataTableWrapper id="multi-row-customslot-table-wrapper" max-width="1000px">
    <HLDataTable
        id="multi-row-customslot-table" ref="tableInstance" :columns="columns"
        :expandedRowRenderer="rowExpandRenderer"
        :data="data" :horizontal-borders="true" :vertical-borders="true" :row-hover="false" :column-hover="false"
        max-height="900px" row-height="auto">
    </HLDataTable>
</HLDataTableWrapper>
ts
import { ChevronDownIcon, ChevronUpIcon } from '@gohighlevel/ghl-icons/24/outline'
import { h, ref } from 'vue'
import { DataTableColumn, HLDataTable, HLDataTableWrapper, HLIcon, HLText } from '@platform-ui/highrise'

interface Person {
  id: number
  firstName: string
  lastName: string
  age: number
  progress: number
  rating: number
  DOB: string,
  isTableRow?: boolean,
  expandData?: Person[]
}
const columns: DataTableColumn<Person>[] = [
  {
    id: 'lastName',
    header: 'Last Name',
    accessorKey: 'lastName',
    size: 250,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
    cellFormatter: ({ row }) => {
      return h('div', { class: 'flex items-center' }, [
        h('div', { style: { paddingLeft: `${row.depth * 32}px` } }, null),
        row.originalSubRows &&
        h(
          'div',
          { style: { paddingRight: `12px` } },
          h(
            HLIcon,
            {
              id: 'expand-button',
              size: '20',
              class: 'cursor-pointer',
              onClick: () => {
                row.getToggleExpandedHandler()()
              },
            },
            row.getIsExpanded() ? h(ChevronUpIcon) : h(ChevronDownIcon)
          )
        ),
        h(
          HLText,
          {
            size: 'lg',
            weight: 'medium',
          },
          row.original.lastName
        ),
      ])
    },
  },
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    cellFormatter: ({ row }) => {
      return row.original.id
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
    },
  },
]

const rowExpandRenderer = (row) => {
  return h('div', { class: 'bg-red-200 w-full', style: { height: '100px', padding: '10px' } }, row.data.firstName)
}

const data = ref<Person[]>([
  {
    id: 1,
    firstName: 'Jerel',
    lastName: 'Rath',
    age: 38,
    rating: 5,
    progress: 19,
    DOB: '04/11/1995',
    expandData: [
      {
        id: 7,
        firstName: 'Darius',
        lastName: 'Abshire',
        age: 18,
        rating: 1,
        progress: 37,
        DOB: '27/11/1970',
      },
      {
        id: 22,
        firstName: 'Jayden',
        lastName: 'Padberg',
        age: 55,
        rating: 3,
        progress: 99,
        isTableRow: true,
        DOB: '08/04/1974',
      },
      {
        id: 23,
        firstName: 'Chyna',
        lastName: 'Bruen',
        age: 25,
        rating: 5,
        isTableRow: true,
        progress: 46,
        DOB: '15/11/1997',
      },
      {
        id: 24,
        firstName: 'Shanon',
        lastName: 'Sauer',
        age: 24,
        rating: 3,
        progress: 80,
        DOB: '04/05/1947',
        isTableRow: true,
      },
    ],

  },

  {
    id: 2,
    firstName: 'Avery',
    lastName: 'Pagac',
    age: 67,
    rating: 1,
    progress: 0,
    DOB: '16/08/1996',
    expandData: [
      {
        id: 8,
        firstName: 'Damon',
        lastName: 'Frami',
        age: 25,
        rating: 2,
      },
    ],
  },
  {
    id: 3,
    firstName: 'Jessica',
    lastName: 'Bailey',
    age: 24,
    rating: 3,
    progress: 74,
    DOB: '02/03/1980',
  },
  {
    id: 4,
    firstName: 'Emmalee',
    lastName: 'Anderson',
    age: 18,
    rating: 2,
    progress: 92,
    DOB: '09/02/1994',
  },
  {
    id: 5,
    firstName: 'Christian',
    lastName: 'Ritchie',
    age: 53,
    rating: 5,
    progress: 100,
    DOB: '08/06/1963',
  },
])

Row Reorder

Let users drag rows to change their order — useful for manually ranked or prioritized lists. Enable it with row-reordering, and listen to @update:row-reordered to persist the new order. Use restrict-row-reordering-upto to keep leading rows fixed.

ID

First Name

Age

Progress

Rating

DOB

html
<HLContentWrap :dir="direction">
  <HLDataTableWrapper id="row-re-reorder-example">
    <HLDataTable
      id="row-re-reorder-table"
      ref="tableInstance"
      :columns="columns"
      :data="data"
      :horizontal-borders="true"
      :vertical-borders="true"
      max-height="900px"
      :row-reordering="true"
    >
    </HLDataTable>
  </HLDataTableWrapper>
</HLContentWrap>
ts
const columns = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'age',
    header: 'Age',
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id: 'progress',
    header: 'Progress',
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 250,
    cellFormatter: ({ row }) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
    meta: {
      headerAlign: 'center',
    },
  },
  {
    id: 'rating',
    accessorKey: 'rating',
    size: 200,
    header: 'Rating',
    meta: {
      headerAlign: 'start',
    },
    cellFormatter: ({ row }) => {
      const rating = row.original.rating
      const stars = []
      for (let i = 0; i < 5; i++) {
        const fill = i < rating ? 'var(--primary-600)' : 'var(--gray-400)'
        stars.push(h(HLIcon, { size: 16, color: fill }, Star01Icon as any))
      }
      return h(HLSpace, { align: 'center', wrapItem: false, size: 4 }, stars)
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
    },
    header: 'DOB',
  },
]

const data = ref([
  {
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    age: 30,
    progress: 50,
    rating: 4,
    DOB: '2014-01-01',
  },
  {
    id: 2,
    firstName: 'Jane',
    lastName: 'Doe',
    age: 25,
    progress: 75,
    rating: 3,
    DOB: '2024-01-01',
  },
  {
    id: 3,
    firstName: 'Jim',
    lastName: 'Beam',
    age: 40,
    progress: 25,
    rating: 2,
    DOB: '2024-01-01',
  },
])

Row Selection

Let users pick one or more rows via checkboxes, then act on the selection with bulk actions (delete, export, assign). Track the chosen rows through selected-rows and surface the count and CTAs in the wrapper header.

ID

First Name

Age

Progress

Rating

DOB

ts
import { h, ref } from 'vue'
import { HLDataTable, HLDataTableWrapper, HLCheckbox } from '@platform-ui/highrise'
html
<HLDataTableWrapper>
  <HLDataTable
    ref="tableInstance"
    :columns="columns"
    :data="data"
    :highlight-selected-row="true"
    :selected-rows="selectedRows"
    :row-hover="true"
    max-height="900px"
  >
  </HLDataTable>
</HLDataTableWrapper>
ts
// Your row data — each row needs a unique `id`
const data = ref([
  { id: '1', firstName: 'John' },
  { id: '2', firstName: 'Jane' },
  { id: '3', firstName: 'Jim' },
])

const selectedRows = ref<string[]>([])

const toggleRowSelection = (rowId: string, isChecked: boolean) => {
  if (isChecked) {
    selectedRows.value.push(rowId)
  } else {
    selectedRows.value = selectedRows.value.filter(id => id !== rowId)
  }
}

const toggleAllRowsSelection = (isChecked: boolean) => {
  if (isChecked) {
    selectedRows.value = data.value.map(row => row.id)
  } else {
    selectedRows.value = []
  }
}
const columns = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 50,
    meta: {
      align: 'center',
    },
  },
  {
    id: 'checkbox',
    header: ({ table }) => {
      return h(
        'div',
        { class: 'flex items-center justify-center h-full' },
        h(HLCheckbox, {
          checked: selectedRows.value.length === data.value.length,
          indeterminate: selectedRows.value.length > 0 && selectedRows.value.length < data.value.length,
          id: 'jkl',
          onUpdateChecked: e => toggleAllRowsSelection(e),
          style: {
            justifyContent: 'center',
          },
        })
      )
    },
    cellFormatter: ({ row }) => {
      return h(HLCheckbox, {
        checked: selectedRows.value.includes(row.original.id),
        id: `select-${row.original.id}`,
        onUpdateChecked: () => toggleRowSelection(row.original.id, !selectedRows.value.includes(row.original.id)),
      })
    },
    size: 50,
    meta: {
      align: 'center',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
    },
  },
]

Row Striped

Alternate row background colors to make wide, dense tables easier to scan across. Enable it with the striped prop.

ID

First Name

Age

Progress

Rating

DOB

Vue
html
<HLDataTableWrapper>
  <HLDataTable
    ref="tableInstance"
    :columns="columns"
    :data="data"
    :striped="true"
    :horizontal-borders="true"
    :vertical-borders="true"
    max-height="900px"
  >
  </HLDataTable>
</HLDataTableWrapper>

Table Cell Borders

Draw a colored border around a cell to signal its status — for example, validation errors or warnings on editable data. Set a state on the cell's value in your data and the table renders the matching border.

ID

First Name

Age

Progress

Rating

DOB

html
<HLDataTableWrapper id="table-cell-borders-table-wrapper" max-width="1000px">
  <HLDataTable
    ref="tableInstance"
    id="table-cell-borders-table"
    :columns="columns"
    :data="cellScenariosData"
    :horizontal-borders="false"
    :vertical-borders="false"
    :page-size="6"
    max-height="900px"
  >
  </HLDataTable>
</HLDataTableWrapper>
ts
const columns = [
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'age',
    header: 'Age',
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
]

const data = ref([
  {
    id: 1,
    firstName: { text: 'John', state: 'var(--success-500)' },
    lastName: 'Doe',
    age: 30,
    progress: 50,
    DOB: '2014-01-01',
  },
  {
    id: 2,
    firstName: { text: 'Jane', state: 'var(--error-500)' },
    lastName: 'Doe',
    age: 25,
    progress: 75,
    DOB: '2024-01-01',
  },
  {
    id: 3,
    firstName: { text: 'Jim', state: 'var(--warning-500)' },
    lastName: 'Beam',
    age: 40,
    progress: 25,
    DOB: '2024-01-01',
  },
])

Table Cell Icon

Show an icon before a cell's text to add visual context — a status dot, a file type, a channel. Set prefixIcon on the cell's value object in your data; the table renders it inline ahead of the text.

Last Name

ID

First Name

Age

Progress

Rating

DOB

html
<HLDataTableWrapper id="table-cell-borders-table-wrapper" max-width="1000px">
  <HLDataTable
    ref="tableInstance"
    id="table-cell-borders-table"
    :columns="columns"
    :data="data"
    :horizontal-borders="false"
    :vertical-borders="false"
    :page-size="6"
    max-height="900px"
  >
  </HLDataTable>
</HLDataTableWrapper>
ts
const columns = [
  {
    id: 'lastName',
    header: 'Last Name',
    accessorKey: 'lastName',
    size: 150,
    meta: {
      align: 'start',
      sticky: true,
    },
  },
  {
    id: 'id',
    header: 'ID',
    accessorKey: 'id',
    size: 100,
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'firstName',
    header: 'First Name',
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    meta: {
      align: 'start',
      headerAlign: 'start',
    },
  },
  {
    id: 'age',
    header: 'Age',
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
]
ts
const data = ref([
  {
    id: 1,
    firstName: 'John',
    lastName: { text: 'Doe', prefixIcon: Wallet01Icon },
    age: 30,
    progress: 50,
    DOB: '2014-01-01',
  },
  {
    id: 2,
    firstName: 'Jane',
    lastName: { text: 'Doe', prefixIcon: Wallet02Icon },
    age: 25,
    progress: 75,
    DOB: '2024-01-01',
  },
  {
    id: 3,
    firstName: 'Jim',
    lastName: { text: 'Beam', prefixIcon: Wallet03Icon },
    age: 40,
    progress: 25,
    DOB: '2024-01-01',
  },
])
ts
import { Wallet01Icon, Wallet02Icon, Wallet03Icon } from '@gohighlevel/ghl-icons/24/outline'

Advanced Usage of Table

A comprehensive example combining global search, saved layout options, column freezing, row selection, and a header alert — showing how the wrapper's header features compose into a full-featured table toolbar.

Search

Id

Toggle

First Name

Age

Progress

Radio

Rating

DOB

html
<div v-if="showAlert" class="hr-table-header-alerts pb-2">
  <HLAlert
    id="hr-table-header-alert"
    closable
    :title="alertTitle"
    role="alert"
    height="auto"
    width="fit-content"
    color="green"
    @close="showAlert = false"
  >
  </HLAlert>
</div>
<HLDataTableWrapper
  id="data-table-wrapper"
  :column-options="columnOptions"
  :page-size="pageSize"
  :no-of-rows-selected="selectedRows?.length"
  :layout-options="preSetLayouts"
  :freezed-column="freezedColumn"
  :table-height-selected-option="tableHeightSelectedOption"
  :user-driven-value="userDrivenValue"
  :show-global-search="true"
  :show-layout-options="true"
  :show-header="true"
  :selected-table-layout="tableLayout"
  :show-row-layout-option="true"
  :show-column-layout-option="!selectedRows?.length"
  :show-table-height-layout-option="!selectedRows?.length"
  @update:column-reorder="handleReorder"
  @update:column-checked="handleChecked"
  @update:column-freeze="handleFreeze"
  @update:column-unfreeze="handleUnfreeze"
  @update:page-size="updateCustomPageSize"
  @update:table-height-selected-option="setTableHeightSelectedOption"
  @update:row-deselect-all="handleDeselectAll"
  @update:global-filter="handleGlobalFilterChange"
  @update:table-layout="handleSaveLayout"
  @update:table-layout-as="handleSaveAsLayout"
  @update:cancel="handleCancelLayout"
  @update:switch-table-layout="handleSwitchLayout"
  @update:delete-table-layout="handleDeleteLayout"
  @update:current-layout="updateCurrentLayout"
>
  <template #header-content-right>
    <HLButton v-if="selectedRows?.length" id="header-button-cta" size="2xs" variant="secondary" color="gray">
      Download
      <template #iconLeft>
        <Download01Icon />
      </template>
    </HLButton>
    <HLButton v-if="selectedRows?.length" id="header-button-cta" size="2xs" variant="secondary" color="gray">
      Share
      <template #iconLeft>
        <Share01Icon />
      </template>
    </HLButton>
    <HLButton v-if="selectedRows?.length" id="header-button-cta" size="2xs" variant="secondary" color="red">
      Delete
      <template #iconLeft>
        <Trash01Icon />
      </template>
    </HLButton>
    <HLDropdown
      v-if="selectedRows?.length"
      id="header-button-cta"
      width="32px"
      trigger="click"
      placement="bottom"
      :options="[{
        label: 'Download',
        icon: Download01Icon,
      },
      {
        label: 'Share',
        icon: Share01Icon,
      }]"
      :show-arrow="false"
    >
      <HLButton id="header-button-cta" size="2xs" variant="text" color="gray">
        <template #icon>
          <DotsVerticalIcon />
        </template>
      </HLButton>
    </HLDropdown>
    <HLButton v-if="!selectedRows?.length" id="header-button-cta" size="2xs" variant="primary" color="blue"> Button CTA </HLButton>
  </template>
  <template v-if="selectedRows?.length" #header-content-left>
    <HLText size="sm" weight="regular"> {{selectedRows?.length}} rows selected </HLText>
    <HLButton id="header-button-select-all" size="2xs" variant="text" color="blue" @click="handleSelectAll">
      Select All ({{data?.length}})
    </HLButton>
    <HLButton id="header-button-deselect-all" size="2xs" variant="ghost" @click="handleDeselectAll" style="font-size: 14px !important;">
      <template #icon>
        <CloseIcon />
      </template>
    </HLButton>
  </template>
  <HLDataTable
    id="data-table"
    ref="tableInstance"
    :columns="SearchTableColumns"
    :data="searchTableData"
    :striped="true"
    :horizontal-borders="true"
    :vertical-borders="true"
    max-height="900px"
    row-height="40px"
    header-row-height="40px"
    :current-layout="currentLayout"
    @update:column-checked="handleChecked"
    @update:column-order="handleColumnOrder"
    @update:column-clicked="handleColumnClick"
    :selected-rows="selectedRows"
    @no-data="handleNoData"
    :column-order="columnOrder"
    :freezed-columns="{left: freezedColumns}"
    :row-hover="true"
    :column-hover="true"
    :highlight-selected-row="true"
    :row-reordering="true"
    :column-reordering="true"
  >
    <template #no-data>
      <HLEmpty
        id="empty-state"
        size="md"
        title="No data available to display. This is a placeholder slot"
        description="This is a placeholder"
        positive-text="Refresh"
        negative-text="Try again"
        icon="info"
      />
    </template>
  </HLDataTable>
  <template #footer v-if="!noData">
    <HLSpace v-if="table && tableHeightSelectedOption !== 'all'" :size="4" align="center" justify="end">
      <HLPagination
        id="searchable-table-pagination"
        :item-count="table.getFilteredRowModel().rows?.length"
        :per-page="table.getState().pagination.pageSize"
        :current-page="table.getState().pagination.pageIndex + 1"
        :pages-to-display="7"
        :per-page-dropdown-options="paginationOptions"
        size="sm"
        per-page-text="Rows per page"
        @update:per-page="updatePageSize"
        @update:page="
          page => {
            table.setPageIndex(page - 1)
          }
        "
      >
        <template #prev> Previous </template>
        <template #next> Next </template>
      </HLPagination>
    </HLSpace>
    <HLSpace v-if="table && tableHeightSelectedOption === 'all'" :size="4" align="center" justify="center">
      <HLButton size="2xs" variant="secondary" color="gray" @click="handleLoadMore">Load More </HLButton>
    </HLSpace>
  </template>
</HLDataTableWrapper>
ts
import {
  HLDataTable,
  HLDataTableWrapper,
  HLAlert,
  HLSpace,
  HLPagination,
  HLButton,
  HLText,
  HLEmpty,
  HLDropdown,
  HLCheckbox,
  HLProgress,
  HLRadio,
  HLToggle,
  HLTooltip,
} from '@platform-ui/highrise'
import { Download01Icon, Share01Icon, Trash01Icon, DotsVerticalIcon } from '@gohighlevel/ghl-icons/24/outline'
ts
const searchTableData = ref([
  {
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    age: 25,
    rating: 4,
    status: 'Active',
    progress: 75,
    toggle: true,
    DOB: '2024-01-01',
  },
])

const sortedColumn = ref(null)
const sortDir = ref(null)
const SearchTableColumns = [
  {
    id: 'checkbox',
    header: () => {
      return h(HLCheckbox, {
        checked: selectedRows.value?.length === data.value?.length,
        indeterminate: selectedRows.value?.length > 0 && selectedRows.value?.length < data.value?.length,
        id: 'header-checkbox',
        size: 'xs',
        onUpdateChecked: e => toggleAllRowsSelection(e),
        style: {
          justifyContent: 'center',
        },
      })
    },
    cellFormatter: ({ row }) => {
      return h(HLCheckbox, {
        checked: selectedRows.value.includes(row.original.id),
        id: `select-${row.original.id}`,
        size: 'xs',
        style: {
          justifyContent: 'center',
        },
        onUpdateChecked: () => toggleRowSelection(row.original.id, !selectedRows.value.includes(row.original.id)),
      })
    },
    size: 32,
    meta: {
      align: 'center',
    },
  },
  {
    id: 'id',
    header: 'Id',
    accessorKey: 'id',
    size: 50,
    meta: {
      align: 'center',
    },
  },
  {
    id: 'toggle',
    accessorKey: 'toggle',
    header: 'Toggle',
    size: 70,
    cellFormatter: ({ row }) =>
      h(HLSpace, { align: 'center', wrapItem: false, justify: 'center' }, () =>
        h(HLToggle, {
          id: 'toggle',
          'onUpdate:value': value => {
            row.original.toggle = value
          },
          value: row.original.toggle,
        })
      ),
    meta: {
      align: 'center',
    },
  },
  {
    id: 'firstName',
    header: {
      text: 'First Name',
      icon: UserCircleIcon,
      filterComponent: h(NameFilter, {
        onSetFilter: setFilter,
        onClearFilter: clearFilter,
        onSetSort: setSort,
        sortedColumn,
        sortDir,
        id: 'first-name-filter',
        onResetSort: resetSort,
      }),
    },
    sortingFn: 'alphanumeric',
    accessorKey: 'firstName',
    size: 150,
    cellFormatter: ({ row }) => {
      if (typeof row.original.firstName === 'string') {
        return row.original.firstName
      } else {
        return h(row.original.firstName)
      }
    },
    meta: {
      align: 'start',
    },
  },
  {
    id: 'age',
    header: {
      text: 'Age',
      icon: BarChartCircle01Icon,
      filterComponent: h(AgeFilter, {
        onSetFilter: setFilter,
        onClearFilter: clearFilter,
        onSetSort: setSort,
        sortedColumn,
        sortDir,
        id: 'age-filter',
        onResetSort: resetSort,
      }),
    },
    accessorKey: 'age',
    sortingFn: 'alphanumeric',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
  },
  {
    id: 'progress',
    header: { text: 'Progress', icon: UserCircleIcon },
    accessorKey: 'progress',
    sortingFn: 'alphanumeric',
    size: 250,
    cellFormatter: ({ row }) => {
      return h(HLProgress, {
        id: 'progress',
        percentage: row.original.progress,
        type: 'line',
        dashboardSize: 'sm',
        valuePlacement: 'outside',
      })
    },
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'radio',
    header: 'Radio',
    size: 70,
    cellFormatter: ({ row }) => {
      return h(HLRadio, {
        id: 'radio',
        value: row.original.id,
        size: 'xs',
        onChange: () => {
          selectedRadio.value = row.original.id
        },
        checked: selectedRadio.value === row.original.id,
      })
    },
    meta: {
      align: 'center',
    },
  },
  {
    id: 'rating',
    accessorKey: 'rating',
    header: { text: 'Rating', icon: Star01Icon },
    cellFormatter: ({ row }) => {
      const rating = row.original.rating
      const stars = []
      for (let i = 0; i < 5; i++) {
        const fill = i < rating ? 'var(--yellow-500)' : 'var(--gray-400)'
        stars.push(h(HLIcon, { size: '16', color: fill }, Star01Icon))
      }
      return h(
        HLTooltip,
        { trigger: 'hover', variant: 'dark', placement: 'top-start' },
        {
          trigger: h('div', { class: 'flex items-center gap-2 cursor-pointer' }, stars),
          default: h(HLSpace, { align: 'center', wrapItem: false, size: 4 }, [
            h(HLIcon, { size: '20', color: 'white' }, InfoCircleIcon),
            h(HLText, { size: 'sm', weight: 'semibold' }, row.original.rating),
          ]),
        }
      )
    },
    meta: {
      headerAlign: 'start',
    },
  },
  {
    id: 'DOB',
    accessorKey: 'DOB',
    meta: {
      align: 'end',
      headerAlign: 'end',
    },
    header: {
      text: 'DOB',
      icon: CalendarIcon,
      filterComponent: h(DateFilter, {
        onSetFilter: setFilter,
        onClearFilter: clearFilter,
        onSetSort: setSort,
        sortedColumn,
        sortDir,
        id: 'date-filter',
        onResetSort: resetSort,
      }),
    },
  },
]
const freezedColumns = ref(['checkbox'])
const columnOptions = ref([
  {
    value: 'id',
    label: 'Id',
    checked: true,
  },

  {
    value: 'toggle',
    label: 'Toggle',
    checked: true,
    icon: Wallet01Icon,
  },
  {
    value: 'firstName',
    label: 'First Name',
    checked: true,
    icon: Wallet01Icon,
  },
  {
    value: 'age',
    label: 'Age',
    checked: true,
    icon: Wallet01Icon,
  },
  {
    value: 'progress',
    label: 'Progress',
    checked: true,
    icon: BarChartCircle01Icon,
  },
  {
    value: 'radio',
    label: 'Radio',
    checked: true,
  },
  {
    value: 'rating',
    label: 'Rating',
    checked: true,
    icon: Star01Icon,
  },
  {
    value: 'DOB',
    label: 'DOB',
    checked: true,
    icon: CalendarDateIcon,
  },
])
const paginationOptions = ref([
  {
    key: 50,
    label: 50,
  },
  {
    key: 100,
    label: 100,
  },
  {
    key: 200,
    label: 200,
  },
  {
    key: 300,
    label: 300,
  },
])

const preSetLayouts = ref([
  {
    value: 'custom',
    label: 'Custom',
  },
  {
    value: 'all',
    label: 'All',
  },
])
const selectedRadio = ref(null)
function handleSaveAsLayout(value) {
  preSetLayouts.value.push({
    value: value,
    label: value,
  })
  showAlert.value = true
  alertTitle.value = `${value} Layout saved successfully`
}
function handleSaveLayout() {
  showAlert.value = true
  alertTitle.value = `${tableLayout.value.label || 'Default'} Layout saved successfully`
}
let backUpLayoutColumnData = {
  columnOptions: [],
  freezedColumn: {},
}
let backUpLayoutTableHeight = {
  tableHeightSelectedOption: '',
  pageSize: '',
  userDrivenValue: '',
}

function handleCancelLayout(value) {
  if (previousLayout === 'columns') {
    columnOptions.value = [...backUpLayoutColumnData.columnOptions]

    // Handle visibility for each column
    columnOptions.value.forEach(option => {
      const tableColumn = tableInstance.value.table?.getColumn(option.value)
      if (tableColumn?.getIsVisible() !== option.checked) {
        tableColumn?.toggleVisibility(option.checked)
      }
    })

    // Handle column order
    const columnIdsInOrder = columnOptions.value.map(option => option.value)
    tableInstance.value.table?.setColumnOrder(columnIdsInOrder)
    // Handle frozen columns
    if (backUpLayoutColumnData?.freezedColumn?.value && backUpLayoutColumnData?.freezedColumn?.value !== 'None') {
      const columnIds = ['checkbox', ...columnOptions.value.map(option => option.value)]
      freezedColumn.value = { ...backUpLayoutColumnData.freezedColumn }
      const columnIndex = columnIds.indexOf(backUpLayoutColumnData.freezedColumn.value)
      tableInstance.value.table?.setColumnPinning({
        left: columnIds.slice(0, columnIndex + 1),
        right: [],
      })
    } else {
      freezedColumn.value = {
        value: 'None',
        label: 'None',
      }
      tableInstance.value.table?.setColumnPinning({
        left: ['checkbox'],
        right: [],
      })
    }
  }
  if (previousLayout === 'tableHeight') {
    tableHeightSelectedOption.value = backUpLayoutTableHeight.tableHeightSelectedOption
    pageSize.value = backUpLayoutTableHeight.pageSize
    userDrivenValue.value = backUpLayoutTableHeight.userDrivenValue
    if (backUpLayoutTableHeight.tableHeightSelectedOption === 'userDriven') {
      tableInstance.value.table?.setPageSize(backUpLayoutTableHeight.userDrivenValue)
    } else {
      tableInstance.value.table?.setPageSize(pageSize.value)
    }
  }
}
function handleGlobalFilterChange(value) {
  table.value.setGlobalFilter(String(value))
}

const handleColumnClick = columnId => {
  if (columnId === 'firstName' || columnId === 'DOB' || columnId === 'age') {
    if (table.value.getState()?.sorting[0]?.id === columnId) {
      const desc = table.value.getState()?.sorting[0]?.desc
      if (desc) {
        sortedColumn.value = ''
        sortDir.value = null
        table.value.setSorting([])
      } else {
        sortedColumn.value = columnId
        sortDir.value = 'desc'
        table.value.setSorting([{ id: columnId, desc: true }])
      }
    } else {
      sortedColumn.value = columnId
      sortDir.value = 'asc'
      table.value.setSorting([{ id: columnId, desc: false }])
    }
  }
}
function setSort(id, desc) {
  sortedColumn.value = id
  sortDir.value = desc ? 'desc' : 'asc'
  table.value.setSorting([{ id, desc }])
}
function resetSort() {
  sortedColumn.value = null
  sortDir.value = null
  table.value.setSorting([])
}
function setFilter(id, filterFn, value) {
  const column = table.value.getColumn(id)
  column.columnDef.filterFn = filterFn
  column.setFilterValue(value)
}
function clearFilter(id) {
  const column = table.value.getColumn(id)
  column.setFilterValue(undefined)
}

const handleColumnOrder = columnIds => {
  columnIds.shift()
  columnOptions.value = columnIds.map(id => columnOptions.value.find(option => option.value === id))
}
const handleReorder = (...args) => {
  columnOptions.value = args[0]
  const columnIds = columnOptions.value.filter(option => option.checked !== false).map(option => option.value)
  tableInstance.value.table?.setColumnOrder(columnIds)
}
const handleChecked = (...args) => {
  const [field, checked] = args
  const column = columnOptions.value.find(option => option.value === field)
  column.checked = checked
  const tableColumn = tableInstance.value.table?.getColumn(field)
  tableColumn?.toggleVisibility(checked)
}
const freezedColumn = ref()
const handleFreeze = item => {
  freezedColumn.value = item
  const columnIds = ['checkbox', ...columnOptions.value.map(option => option.value)]
  const columnIndex = columnIds.indexOf(item.value)
  tableInstance.value.table?.setColumnPinning({
    left: columnIds.slice(0, columnIndex + 1),
    right: [],
  })
}
const handleUnfreeze = () => {
  columnOptions.value.forEach(option => {
    option.frozen = false
  })
  tableInstance.value.table?.setColumnPinning({
    left: ['checkbox'],
    right: [],
  })
}
const pageSize = ref(20)
const tableHeightSelectedOption = ref('default')
const userDrivenValue = ref(null)
const updateCustomPageSize = userDrivenNumber => {
  userDrivenValue.value = userDrivenNumber
  tableInstance.value.table?.setPageSize(userDrivenNumber)
  // add in pagination options with ascending order
  paginationOptions.value = paginationOptions.value.filter(option => option.key !== userDrivenNumber)
  paginationOptions.value.push({
    key: userDrivenNumber,
    label: userDrivenNumber,
  })
  paginationOptions.value.sort((a, b) => a.key - b.key)
}
const setTableHeightSelectedOption = value => {
  tableHeightSelectedOption.value = value
  if (tableHeightSelectedOption.value === 'default') {
    tableInstance.value.table?.setPageSize(pageSize.value)
  }
}
const updatePageSize = value => {
  tableHeightSelectedOption.value = 'default'
  pageSize.value = Number(value)
  tableInstance.value.table?.setPageSize(pageSize.value)
}
const handleLoadMore = () => {
  tableInstance.value.table?.setPageSize(table.value.getState().pagination.pageSize + 50)
}

const handleDeselectAll = () => {
  selectedRows.value = []
}
const handleSelectAll = () => {
  selectedRows.value = data.value.map(row => row.id)
}

const currentLayout = ref('')
let previousLayout = ''
const updateCurrentLayout = value => {
  if (value) previousLayout = value
  currentLayout.value = value
  if (value === 'columns') {
    backUpLayoutColumnData = {
      columnOptions: columnOptions.value.map(option => ({
        ...option,
        icon: option.icon, // Preserve the icon reference directly
      })),
      freezedColumn: freezedColumn.value
        ? {
            ...freezedColumn.value,
            icon: freezedColumn.value.icon, // Preserve the icon reference if it exists
          }
        : null,
    }
  } else if (value === 'tableHeight') {
    backUpLayoutTableHeight = {
      tableHeightSelectedOption: tableHeightSelectedOption.value,
      pageSize: pageSize.value,
      userDrivenValue: userDrivenValue.value,
    }
  }
}
const noData = ref(false)
const handleNoData = value => {
  noData.value = value
}

const tableLayout = ref({
  value: 'default',
  label: 'Default',
})
const handleSwitchLayout = value => {
  tableLayout.value = value
  handleReorder(columnOptions.value.reverse())
}
const columnOrder = computed(() => ['checkbox', ...columnOptions.value.map(option => option.value)])
const handleDeleteLayout = value => {
  if (value.value === tableLayout.value.value) {
    tableLayout.value = {
      value: 'default',
      label: 'Default',
    }
  }
  preSetLayouts.value = preSetLayouts.value.filter(option => option.value !== value.value)
  showAlert.value = true
  alertTitle.value = `${value.value} Layout deleted successfully`
}
const alertTitle = ref('')
const showAlert = ref(false)

Table CRUD

A full example wiring the table into create, read, update, and delete flows — adding rows, editing in place, and removing them while keeping data in sync. Use it as a reference for building an editable table backed by your own state.

Search

First Name

Last Name

Age

Rating

Date

html
<script setup lang="ts">
  import { ArrowDownIcon, ArrowUpIcon, FilterLinesIcon } from '@gohighlevel/ghl-icons/24/outline'
  import {
    HLButton,
    HLDataTable,
    HLDataTableWrapper,
    HLDatePicker,
    HLDivider,
    HLDropdown,
    HLEmpty,
    HLIcon,
    HLPopover,
    HLTable,
    HLTag,
    HLText,
  } from '@platform-ui/highrise'
  import { computed, h, nextTick, ref, watch } from 'vue'
  import TableCrudLeft from './TableCrudLeft.vue'
  import TableCrudRight from './TableCrudRight.vue'

  const globalFilter = ref('')
  const tableInstance = ref<{ table: HLTable<any> }>()
  const data = ref([
    {
      id: 1,
      firstName: 'Nikhil',
      lastName: 'Mara',
      age: 53,
      rating: 2,
      DOB: 1762021800000,
      progress: 32,
    },
    {
      id: 2,
      firstName: 'Nathanial',
      lastName: 'Johnson',
      age: 71,
      rating: 5,
      DOB: 1757183400000,
      progress: 48,
    },
  ])
  // Global filter change handler
  const handleGlobalFilterChange = (value: string) => {
    globalFilter.value = value
    tableInstance.value?.table?.setGlobalFilter(value)
  }

  // Table-level config. In Storybook these came from `args`; in an app set them directly.
  const searchPlaceholder = 'Search'
  const fillParentHeight = true

  const defaultColumnOptions = ref<any[]>([
    {
      value: 'name',
      label: 'Name',
      expand: false,
      options: [
        { value: 'firstName', label: 'First Name', checked: true, frozen: true },
        { value: 'lastName', label: 'Last Name', checked: true },
      ],
    },
    {
      value: 'others',
      label: 'Others',
      expand: false,
      options: [
        { value: 'age', label: 'Age', checked: true },
        { value: 'rating', label: 'Rating', checked: true },
        { value: 'DOB', label: 'DOB', checked: true },
      ],
    },
  ])

  // Column re ordering
  const columnOptions = ref(defaultColumnOptions.value.flatMap(option => option.options || [option]) as any[])

  const handleColumnOrder = (columnIds: string[]) => {
    columnOptions.value = columnIds.map((id: string) => columnOptions.value.find(option => option.value === id))
  }

  const handleFilterChange = (columnId: string, filterValues: string[] | number[]) => {
    const column: any = tableInstance.value?.table.getColumn(columnId)
    column.columnDef.filterFn = 'containsInArray' as any
    if (filterValues.length === 0) {
      column.setFilterValue(undefined)
      return
    }
    column.setFilterValue(filterValues)
  }

  const handleSort = (sortObj: any) => {
    tableInstance.value?.table?.setSorting(sortObj)
  }

  const handleColumnOrdering = (columns: any[]) => {
    const remainingColumns = columnOptions.value.filter(option => option.checked == false)
    columnOptions.value = [...columns, ...remainingColumns]
    const columnIds = columnOptions.value.filter(option => option.checked !== false).map(option => option.value)
    tableInstance.value?.table?.setColumnOrder(columnIds)
  }

  const handleColumnChecked = (field: string, checked: boolean) => {
    const tableColumn = tableInstance.value?.table?.getColumn(field)
    tableColumn?.toggleVisibility(checked)
    const column = columnOptions.value.find(option => option.value === field)
    column.checked = checked
  }

  const tableCrudLeftRef = ref<any>(null)

  const callCrud = (columnId: string, value: string, option: any) => {
    if (value === 'filter') {
      tableCrudLeftRef.value?.handleAddFilter(columnId, option)
    } else if (value === 'desc') {
      tableCrudLeftRef.value?.handleSort(columnId, option, 'desc')
    } else {
      tableCrudLeftRef.value?.handleSort(columnId, option, 'asc')
    }
  }

  const getFilterComponent = (columnId: string, label: string) => {
    return () =>
      h(
        HLDropdown,
        {
          id: `${columnId}-dropdown`,
          options: [
            { key: 'asc', label: 'Sort A → Z', selected: false },
            { key: 'desc', label: 'Sort Z → A', selected: false },
            { key: 'filter', label: 'Filter This Column', selected: false },
          ],

          onSelect: value => {
            callCrud(columnId, value, { label: label })
          },
        },
        h(HLIcon, { size: 14, color: 'var(--gray-600)' }, h(FilterLinesIcon))
      )
  }

  const columns = [
    {
      id: 'firstName',
      header: {
        text: 'First Name',
        filterComponent: getFilterComponent('firstName', 'First Name'),
      },
      sortingFn: 'alphanumeric',
      accessorKey: 'firstName',
      size: 100,
      meta: {
        align: 'start',
        headerAlign: 'start',
      },
    },
    {
      id: 'lastName',
      header: {
        text: 'Last Name',
        filterComponent: getFilterComponent('lastName', 'Last Name'),
      },
      accessorKey: 'lastName',
      sortingFn: 'alphanumeric',
      size: 100,
      meta: {
        align: 'start',
        headerAlign: 'start',
      },
    },
    {
      id: 'age',
      header: {
        text: 'Age',
        filterComponent: getFilterComponent('age', 'Age'),
      },
      accessorKey: 'age',
      sortingFn: 'alphanumeric',
      size: 100,
      meta: {
        align: 'end',
        headerAlign: 'end',
      },
    },
    {
      id: 'rating',
      accessorKey: 'rating',
      header: {
        text: 'Rating',
        filterComponent: getFilterComponent('rating', 'Rating'),
      },
      size: 100,
      meta: {
        align: 'center',
        headerAlign: 'start',
      },
    },
    {
      id: 'DOB',
      accessorKey: 'DOB',
      size: 100,
      meta: {
        align: 'end',
      },
      header: {
        text: 'Date',
        filterComponent: () =>
          h(
            HLDropdown,
            {
              id: 'DOB-dropdown',
              options: [
                { key: 'asc', label: 'Sort Newest First' },
                { key: 'desc', label: 'Sort Oldest First' },
                { key: 'filter', label: 'Filter This Column' },
              ],
              showSearch: false,
              showArrow: false,
              onSelect: value => {
                callCrud('DOB', value, { label: 'Date' })
              },
            },
            h(HLIcon, { size: 14, color: 'var(--gray-600)' }, h(FilterLinesIcon))
          ),
      },
      cellFormatter: ({ row }: { row: any }) => {
        return h('div', { style: { textAlign: 'right' } }, new Date(row.original.DOB).toLocaleDateString())
      },
    },
  ]

  const today = new Date().setHours(0, 0, 0, 0)
  const times = [
    { label: 'Yesterday', selected: false, value: today - 24 * 60 * 60 * 1000 },
    {
      label: 'Past Week',
      selected: false,
      value: today - 7 * 24 * 60 * 60 * 1000,
    },
    {
      label: 'Past Month',
      selected: false,
      value: today - 30 * 24 * 60 * 60 * 1000,
    },
    { label: 'Tomorrow', selected: false, value: today + 24 * 60 * 60 * 1000 },
  ]
  const selectedDate = ref<any>(null)
  const selectedDateLabel = computed(() => {
    if (times.find(time => time.value === selectedDate.value)) {
      return times.find(time => time.value === selectedDate.value)?.label
    }
    return selectedDate.value ? new Date(selectedDate.value).toLocaleDateString() : 'Select'
  })
  const handleDateConfirm = () => {
    handleFilterChange('DOB', [selectedDate.value])
    showFilterDropdown.value = false
    setTimeout(() => {
      showFilterDropdown.value = undefined
    }, 100)
  }

  const handleDateCancel = () => {
    handleFilterChange('DOB', [])
    selectedDate.value = null
    showFilterDropdown.value = false
    setTimeout(() => {
      showFilterDropdown.value = undefined
    }, 100)
  }
  const showWatcher = ref(false)
  const showFilterDropdown = ref<boolean | undefined>(undefined)
  watch(showWatcher, newValue => {
    if (newValue) {
      nextTick(() => {
        showFilterDropdown.value = newValue
      })
    } else {
      showFilterDropdown.value = undefined
    }
  })
</script>

<template>
  <div>
    <HLDataTableWrapper
      id="data-table-wrapper-default"
      max-width="1000px"
      :show-global-search="true"
      :show-header="true"
      :global-filter="globalFilter"
      :search-placeholder="searchPlaceholder"
      :fill-parent-height="fillParentHeight"
      responsive-column-width
      @update:global-filter="handleGlobalFilterChange"
    >
      <template #header-content-left>
        <TableCrudLeft
          ref="tableCrudLeftRef"
          :custom-filter-dropdown-for="['DOB']"
          :data="data"
          :columnOptions="columnOptions"
          @set-sort="handleSort"
          @filter-change="handleFilterChange"
        >
          <template #filter-dropdown="{ onClose, show }">
            <HLPopover
              :show="showFilterDropdown"
              :data-dummy="(showWatcher = show)"
              trigger="click"
              :show-arrow="false"
              @clickoutside="showFilterDropdown = undefined"
            >
              <template #trigger>
                <HLTag size="lg" round closable @close="onClose">
                  <span>
                    Date
                    <HLTag id="filter-tag-group" size="xs" :bordered="false">
                      <HLText size="md" weight="medium"> {{ selectedDateLabel }} </HLText>
                    </HLTag>
                  </span>
                </HLTag>
              </template>
              <div class="p-2 flex flex-col gap-2">
                <div class="flex gap-3">
                  <div class="flex flex-col gap-1 pt-2">
                    <HLButton
                      v-for="time in times"
                      :id="time.label"
                      :key="time.label"
                      size="2xs"
                      variant="secondary"
                      :color="time.value === selectedDate ? 'primary' : 'gray'"
                      @click="selectedDate = time.value"
                    >
                      {{ time.label }}
                    </HLButton>
                  </div>
                  <HLDatePicker id="date-picker-with-side-content" v-model:value="selectedDate" :panel="true" type="date"> </HLDatePicker>
                </div>
                <HLDivider :margin-bottom="false" :margin-top="false" />
                <div class="flex justify-end gap-1">
                  <HLButton id="date-picker-cancel" size="2xs" variant="secondary" @click="handleDateCancel"> Cancel </HLButton>
                  <HLButton id="date-picker-confirm" size="2xs" variant="primary" color="blue" @click="handleDateConfirm">
                    Confirm
                  </HLButton>
                </div>
              </div>
            </HLPopover>
          </template>
          <template #sort-dropdown="{ onClose, isAscending, onToggleSort, sortOptions }">
            <HLTag size="lg" round closable @close="onClose">
              <template #icon>
                <span style="--n-text-color: var(--primary-600)" @click="onToggleSort">
                  <ArrowUpIcon v-if="isAscending" />
                  <ArrowDownIcon v-else />
                </span>
              </template>
              <HLDropdown
                id="add-filter-dropdown"
                :width="240"
                :options="sortOptions"
                :show-arrow="false"
                :multiple="false"
                show-search-highlight
                show-selected-mark
                @select="
                  (key, option) =>
                    callCrud(key, isAscending ? 'asc' : 'desc', option)
                "
              >
                <HLTag id="filter-tag-group" size="xs" :bordered="false">
                  <HLText size="md" weight="medium"> Date {{ isAscending ? 'Newest' : 'Oldest' }} </HLText>
                </HLTag>
              </HLDropdown>
            </HLTag>
          </template>
        </TableCrudLeft>
      </template>
      <template #header-content-right>
        <TableCrudRight
          :columnOptions="columnOptions"
          :defaultColumnOptions="defaultColumnOptions"
          @column-ordering="handleColumnOrdering"
          @column-checked="handleColumnChecked"
        />
      </template>
      <HLDataTable
        id="data-table-default"
        ref="tableInstance"
        :data="data"
        :columns="columns"
        @update:column-order="handleColumnOrder"
      >
        <template #no-data>
          <HLEmpty
            id="empty-state"
            size="md"
            title="No data available to display. This is a placeholder slot"
            description="This is a placeholder"
            positive-text="Refresh"
            negative-text="Try again"
            icon="info"
          />
        </template>
      </HLDataTable>
    </HLDataTableWrapper>
  </div>
</template>
html
<script setup lang="ts">
  import { MaterialArrowRightIcon } from '@gohighlevel/ghl-icons/24/material/rounded'
  import { Columns03Icon, SearchLgIcon } from '@gohighlevel/ghl-icons/24/outline'
  import { HLButton, HLCheckbox, HLIcon, HLInput, HLPopover, HLTableDragGroup, HLText } from '@platform-ui/highrise'
  import { computed, h, PropType, ref } from 'vue'

  const emit = defineEmits(['column-ordering', 'column-checked'])

  const props = defineProps({
    defaultColumnOptions: {
      type: Array as PropType<any[]>,
      required: true,
    },
    columnOptions: {
      type: Array as PropType<any[]>,
      required: true,
    },
  })

  const styles = {
    '--n-padding': '8px',
    border: '1px solid var(--gray-300)',
    minWidth: '300px',
  }

  const searchColumns = ref('')
  const searchColumnsInput = ref()
  const columnPopoverRef = ref()
  const isPopoverOpen = ref(false)
  const selectOptions = computed(() => {
    return props.columnOptions.filter(column => column.checked)
  })

  const filteredDefaultColumnOptions = computed(() => {
    const filterOptions = props.defaultColumnOptions.map(option => {
      if (searchColumns.value) {
        if (option.options) {
          option.options.forEach((option: any) => {
            option.show = option.label.toLowerCase().includes(searchColumns.value.toLowerCase())
          })
          option.show = option.options.some((option: any) => option.show)
        } else {
          option.show = option.label.toLowerCase().includes(searchColumns.value.toLowerCase())
        }
      } else {
        option.show = true
        if (option.options) {
          option.options.forEach((option: any) => {
            option.show = true
          })
        }
      }
      return option
    })
    return filterOptions
  })

  const handleReorder = (args: any) => {
    emit('column-ordering', args)
  }
  const handleChecked = (field: string, checked: boolean) => {
    emit('column-checked', field, checked)

    setTimeout(() => {
      columnPopoverRef.value?.syncPosition()
    }, 50)
  }

  const handleSelectAll = () => {
    props.columnOptions.forEach(column => {
      handleChecked(column.value, true)
    })
  }

  const handlePopoverUpdate = (show: boolean) => {
    isPopoverOpen.value = show
    if (show) {
      setTimeout(() => {
        searchColumnsInput.value?.focus()
      }, 100)
    }
  }

  const allOptionsChecked = (options: any[]) => {
    return options.every(option => option.checked)
  }

  const someOptionsChecked = (options: any[]) => {
    return options.some(option => option.checked)
  }

  const toggleAllOptions = (options: any[], checked: boolean) => {
    options.forEach(option => {
      if (option.frozen) return
      handleChecked(option.value, checked)
    })
  }
  function highlightSplit(st1: string, st2: string) {
    const index = st1.toLowerCase().indexOf(st2.toLowerCase())
    if (index === -1) return [st1] // substring not found
    const before = st1.slice(0, index)
    const match = st1.slice(index, index + st2.length)
    const after = st1.slice(index + st2.length)
    return [before, match, after]
  }

  const highlightSearched = (label: string) => {
    const searchValue = searchColumns.value
    if (!searchValue) {
      return h('span', {}, label)
    }
    const parts = highlightSplit(label, searchValue).map(part => {
      return h(
        'span',
        {
          style: {
            fontWeight: part.toLowerCase() === searchValue.toLowerCase() ? 'var(--hr-font-weight-bold)' : 'var(--hr-font-weight-regular)',
          },
        },
        part
      )
    })
    return h('span', {}, parts)
  }
</script>

<template>
  <HLPopover
    ref="columnPopoverRef"
    trigger="click"
    placement="bottom"
    :style="styles"
    :show-arrow="false"
    @update:show="handlePopoverUpdate"
  >
    <template #trigger>
      <HLButton id="column-eye-icon" variant="tertiary" size="xs">
        <template #iconLeft>
          <Columns03Icon />
        </template>
        {{ selectOptions.length }}/{{ columnOptions.length }} Columns
      </HLButton>
    </template>
    <div>
      <HLInput id="search-columns" ref="searchColumnsInput" v-model="searchColumns" size="2xs" :prefix-icon="SearchLgIcon as any" />
      <span class="flex items-center gap-2 justify-between p-2">
        <HLText size="sm"> {{ selectOptions.length }} Fields Selected </HLText>
        <HLButton id="select-all-button" variant="text" size="2xs" @click="handleSelectAll">
          Select All ({{ columnOptions.length }})
        </HLButton>
      </span>
      <HLTableDragGroup
        id="column-drag"
        :disabled="searchColumns.length > 0"
        :options="columnOptions.filter(column => column.checked)"
        :search="searchColumns"
        max-height="400px"
        @reorder="handleReorder"
        @update:column-checked="handleChecked"
      />
      <div class="remaining-columns">
        <div v-for="column in filteredDefaultColumnOptions" :key="column.value">
          <div v-if="column.options && column.show">
            <div class="remaining-column--group">
              <span class="cursor-pointer inline-flex" @click="column.expand = !column.expand">
                <HLIcon :size="18" :class="column.expand ? 'rotate-90' : ''"><MaterialArrowRightIcon /></HLIcon>
              </span>
              <HLCheckbox
                id="remaining-column-checkbox"
                :checked="allOptionsChecked(column.options)"
                :indeterminate="
                  !allOptionsChecked(column.options) &&
                  someOptionsChecked(column.options)
                "
                size="xs"
                @update:checked="toggleAllOptions(column.options, $event)"
              >
                <HLText size="sm"> {{ column.label }} </HLText>
              </HLCheckbox>
            </div>
            <div v-for="option in column.options" v-show="column.expand" :key="option.value" class="remaining-column--child-item">
              <HLCheckbox
                v-show="option.show"
                id="remaining-column-checkbox"
                :checked="option.checked"
                :disabled="option.frozen"
                size="xs"
                @update:checked="handleChecked(option.value, $event)"
              >
                <component :is="highlightSearched(option.label)" />
              </HLCheckbox>
            </div>
          </div>
          <div v-else-if="column.show" class="remaining-column--item">
            <HLCheckbox
              id="remaining-column-checkbox"
              :checked="column.checked"
              size="xs"
              @update:checked="handleChecked(column.value, $event)"
            >
              <component :is="highlightSearched(column.label)" />
            </HLCheckbox>
          </div>
        </div>
      </div>
    </div>
  </HLPopover>
</template>
<style lang="scss" scoped>
  .remaining-columns {
    display: flex;
    flex-direction: column;
    gap: 2px;
    .remaining-column--item {
      padding: 4px 8px;
    }
    .remaining-column--child-item {
      padding: 4px 12px 4px 30px;
    }
    .remaining-column--group {
      background-color: var(--primary-50);
      display: flex;
      align-items: center;
      gap: 4px;
      padding: 4px 6px;
    }
  }
</style>
html
<script setup lang="ts">
  import { ArrowDownIcon, ArrowUpIcon, ChevronSelectorVerticalIcon, PlusIcon } from '@gohighlevel/ghl-icons/24/outline'
  import { HLDivider, HLDropdown, HLIcon, HLPopover, HLTag, HLText } from '@platform-ui/highrise'
  import { computed, h, PropType, ref } from 'vue'
  import TableCRUDDropdown from './TableCRUDDropdown.vue'

  const props = defineProps({
    data: {
      type: Array as PropType<any[]>,
      required: true,
    },
    columnOptions: {
      type: Array as PropType<any[]>,
      required: true,
    },
    customFilterDropdownFor: {
      type: Array as PropType<string[]>,
      default: () => [],
    },
  })

  const selectedFilters = ref<any[]>([])
  const selectedSort = ref<{ key: string; label: string } | null>(null)

  const isAscending = ref(true)
  const toggleSort = () => {
    isAscending.value = !isAscending.value
    if (selectedSort.value) {
      emit('set-sort', [{ id: selectedSort.value.key, desc: !isAscending.value }])
    }
  }

  // Sort
  const handleSort = (key: string, option: any, dir: string) => {
    isAscending.value = dir ? dir === 'asc' : isAscending.value
    selectedSort.value = { key: key, label: option.label }
    emit('set-sort', [{ id: key, desc: !isAscending.value }])
  }

  const popoverShow = ref<boolean | undefined>(undefined)

  // show filter open
  const openFilter = (index: number) => {
    if (index > 1 || (index > 0 && selectedSort.value)) {
      popoverShow.value = true
    }
    setTimeout(() => {
      selectedFilters.value[index].show = true
    }, 100)
    setTimeout(() => {
      selectedFilters.value[index].show = undefined
    }, 1000)
  }

  // Filters
  const handleAddFilter = (key: string, option: any) => {
    const index = selectedFilters.value.findIndex(filter => filter.columnId === key)
    if (index !== -1) {
      openFilter(index)
      return
    }
    selectedFilters.value.splice(0, 0, {
      columnId: key,
      label: option.label,
      data: {
        value: option.label,
        options: Array.from(new Set(props.data.map(data => data[key]))).map(option => ({ key: option, label: option })),
      },
    })
    openFilter(0)
  }
  const emit = defineEmits(['filter-change', 'set-sort'])
  const handleFilterChange = (columnId: string, filterValues: string[]) => {
    emit('filter-change', columnId, filterValues)
  }
  const handleTagClose = (columnId: string) => {
    emit('filter-change', columnId, [])
    selectedFilters.value = selectedFilters.value.filter(filter => filter.columnId !== columnId)
  }

  const getOptions = (isSort: boolean = false) => {
    const allOptions = props.columnOptions.map(column => ({
      key: column.value,
      label: column.label,
      selected: isSort
        ? selectedSort.value
          ? selectedSort.value.key === column.value
          : false
        : selectedFilters.value.find(filter => filter.columnId === column.value),
      checked: column.checked,
    }))
    const allCheckedOptions = allOptions.filter(option => option.checked)
    const allUnCheckedOptions = allOptions.filter(option => !option.checked)
    if (allUnCheckedOptions.length > 0) {
      return [...allCheckedOptions, { key: 'unChecked', label: 'Hidden Fields', type: 'header' }, ...allUnCheckedOptions]
    }
    return allOptions
  }

  const options = computed(() => {
    return getOptions()
  })

  const sortOptions = computed(() => [
    {
      label: 'Sort By:',
      type: 'render',
      render: () =>
        h(
          'div',
          {
            class: 'flex items-center justify-between cursor-auto',
            style: { width: '222px', padding: '4px 8px' },
            onClick: e => {
              e.preventDefault()
              e.stopPropagation()
            },
          },
          [
            h(HLText, { size: 'md' }, ['Sort By:']),
            h(HLText, { style: { display: 'inline-flex', gap: '4px' }, size: 'md' }, [
              isAscending.value ? 'Ascending' : 'Descending',
              h(
                'span',
                {
                  class: 'cursor-pointer flex items-center gap-0.5',
                  style: { color: 'var(--primary-600)' },
                  onClick: toggleSort,
                },
                [
                  isAscending.value ? '(A → Z)' : '(Z → A)',
                  h(HLIcon, { size: '14' }, { default: isAscending.value ? ArrowUpIcon : ArrowDownIcon }),
                ]
              ),
            ]),
          ]
        ),
      value: 'sort',
    },
    { type: 'divider', value: 'divider' },
    ...getOptions(true),
  ])

  const handleCloseSort = () => {
    selectedSort.value = null
    emit('set-sort', [])
  }
  const sliceNumber = computed(() => {
    return selectedSort.value ? 1 : 2
  })

  defineExpose({
    handleAddFilter,
    handleSort,
    toggleSort,
  })
</script>
<template>
  <div v-if="selectedSort" class="flex items-center gap-2">
    <slot
      v-if="customFilterDropdownFor.includes(selectedSort.key)"
      name="sort-dropdown"
      :is-ascending="isAscending"
      :sort-options="sortOptions"
      @close="handleCloseSort"
      @toggle-sort="toggleSort"
    >
    </slot>
    <HLTag v-else size="lg" round closable @close="handleCloseSort">
      <template #icon>
        <span style="--n-text-color: var(--primary-600)" @click="toggleSort">
          <ArrowUpIcon v-if="isAscending" />
          <ArrowDownIcon v-else />
        </span>
      </template>
      <HLDropdown
        id="add-filter-dropdown"
        :width="240"
        :options="sortOptions"
        :show-arrow="false"
        :multiple="false"
        show-search-highlight
        show-selected-mark
        @select="handleSort"
      >
        <HLTag id="filter-tag-group" size="xs" :bordered="false">
          <HLText size="md" weight="medium"> {{ selectedSort.label }} {{ isAscending ? 'A -> Z' : 'Z -> A' }} </HLText>
        </HLTag>
      </HLDropdown>
    </HLTag>
  </div>
  <HLDivider v-if="selectedSort" vertical style="height: 14px"></HLDivider>
  <div v-if="selectedFilters.length" class="flex items-center gap-2">
    <div v-for="filter in selectedFilters.slice(0, sliceNumber)" :key="filter.columnId" class="">
      <slot
        v-if="customFilterDropdownFor.includes(filter.columnId)"
        name="filter-dropdown"
        :column-id="filter.columnId"
        :options="filter.data.options"
        :label="filter.label"
        :show="filter.show"
        @select="handleFilterChange(filter.columnId, $event)"
        @close="handleTagClose(filter.columnId)"
      >
      </slot>
      <TableCRUDDropdown
        v-else
        :options="filter.data.options"
        :label="filter.label"
        :show="filter.show"
        @select="handleFilterChange(filter.columnId, $event)"
        @close="handleTagClose(filter.columnId)"
      />
    </div>
    <div v-if="selectedFilters.length > sliceNumber">
      <HLPopover trigger="click" style="max-width: 433px" :show="popoverShow" @clickoutside="popoverShow = undefined">
        <template #trigger>
          <HLTag size="lg" round> +{{ selectedFilters.length - sliceNumber }} </HLTag>
        </template>
        <div style="display: flex; gap: 4px; padding: 8px; flex-wrap: wrap">
          <div v-for="filter in selectedFilters.slice(sliceNumber)" :key="filter.columnId">
            <slot
              v-if="customFilterDropdownFor.includes(filter.columnId)"
              name="filter-dropdown"
              :column-id="filter.columnId"
              :options="filter.data.options"
              :label="filter.label"
              :show="filter.show"
              @select="handleFilterChange(filter.columnId, $event)"
              @close="handleTagClose(filter.columnId)"
            >
            </slot>
            <TableCRUDDropdown
              v-else
              :options="filter.data.options"
              :label="filter.label"
              :show="filter.show"
              @select="handleFilterChange(filter.columnId, $event)"
              @close="handleTagClose(filter.columnId)"
            />
          </div>
        </div>
      </HLPopover>
    </div>
  </div>
  <HLDivider v-if="selectedFilters.length" vertical style="height: 14px"></HLDivider>
  <div class="flex items-center gap-2">
    <HLDropdown
      id="add-filter-dropdown"
      :options="options"
      :show-arrow="false"
      show-search-highlight
      show-selected-mark
      multiple
      @select="handleAddFilter"
    >
      <HLTag round size="lg">
        <template #icon>
          <PlusIcon />
        </template>
        Add Filter
      </HLTag>
    </HLDropdown>
    <HLDropdown
      id="add-filter-dropdown"
      :width="240"
      :options="sortOptions"
      :show-arrow="false"
      :multiple="false"
      show-search-highlight
      show-selected-mark
      @select="handleSort"
    >
      <HLTag round size="lg">
        <template #icon>
          <ChevronSelectorVerticalIcon />
        </template>
        Sort
      </HLTag>
    </HLDropdown>
  </div>
</template>
html
<script setup lang="ts">
  import { HLDropdown, HLTag, HLText } from '@platform-ui/highrise'
  import { computed, PropType, ref, watch } from 'vue'
  const props = defineProps({
    options: {
      type: Array as PropType<any[]>,
      required: true,
    },
    label: {
      type: String,
      default: '',
    },
    show: {
      type: Boolean,
      default: undefined,
    },
  })

  const updatedShow = ref(props.show)
  watch(
    () => props.show,
    newVal => {
      if (newVal) updatedShow.value = newVal
    },
    { immediate: true }
  )

  const updateUpdatedShow = (value: boolean) => {
    updatedShow.value = value
  }

  const emit = defineEmits(['select', 'close'])
  const allOptions = computed(() => {
    if (isAllSelected.value) {
      return [{ label: 'All', key: 'all', selected: isAllSelected.value }, ...props.options.map(option => ({ ...option, selected: false }))]
    } else {
      return [
        { label: 'All', key: 'all', selected: isAllSelected.value },
        ...props.options.map((option: any) => ({
          ...option,
          selected: selectedArray.value.includes(option.label) ? true : false,
        })),
      ]
    }
  })
  const isAllSelected = ref(true)
  let selectedArray = ref<string[]>([])
  const handleSelect = (key: string, option: any) => {
    if (key === 'all') {
      selectedArray.value = []
      isAllSelected.value = true
    } else {
      if (option.selected) {
        selectedArray.value.push(option.label)
      } else {
        selectedArray.value = selectedArray.value.filter(item => item !== option.label)
      }
      isAllSelected.value = false
    }
    if (selectedArray.value.length === 0) {
      isAllSelected.value = true
    }
    emit('select', selectedArray.value)
  }
  const handleClose = () => {
    emit('close')
  }
</script>
<template>
  <HLDropdown
    id="table-crud-dropdown"
    max-height="300px"
    :options="allOptions"
    :show-arrow="false"
    multiple
    show-selected-mark
    :close-on-select="false"
    :show-search-highlight="true"
    :show="updatedShow"
    @select="handleSelect"
    @update:show="updateUpdatedShow"
  >
    <span :key="selectedArray.length">
      <HLTag size="lg" round closable @close="handleClose">
        {{ label }}
        <HLTag id="filter-tag-group" :key="selectedArray.length" size="xs" :bordered="false">
          <HLText size="md" weight="medium">
            {{ isAllSelected ? 'All' : selectedArray[0] }}
            <span v-if="selectedArray.length > 1" :key="selectedArray.length"> , +{{ selectedArray.length - 1 }} </span>
          </HLText>
        </HLTag>
      </HLTag>
    </span>
  </HLDropdown>
</template>

Imports

ts
import { HLDataTable, HLDataTableWrapper } from '@platform-ui/highrise'

Accessibility

  • Provide a caption or set aria-label / aria-labelledby on HLDataTable to summarize the dataset.
  • Maintain aria-sort on sortable headers and expose selected rows via aria-selected or aria-describedby.
  • When paginating or virtualizing, link the pager with aria-controls and announce counts through helper text referenced by aria-describedby.
  • Pass attributes to be attached to the <table> element to tableAttributes for HLDataTable and tableWrapperAttributes for HLDataTableWrapper.

Props

DataTable

NameTypeDefaultDescription
id *string-Unique identifier for the table
columns *DataDataTableColumn[]-Columns (headers)
data *any[]-Data (rows)
maxHeightstring'800px'Maximum height of the table
rowHeightstring'36px'Height of the row
headerRowHeightstring'36px'Height of the header row
stripedbooleanfalseApplies alternating background colors to rows
horizontalBordersbooleantrueShow horizontal borders
verticalBordersbooleantrueShow vertical borders
rowHoverbooleantrueApplies hover effect to table rows
columnHoverbooleantrueApplies hover effect to table columns
highlightSelectedRowbooleanfalseHighlight selected row
highlightedRowsstring[][]Predefined highlighted rows
rowReorderingbooleanfalseEnable row reordering
columnReorderingbooleantrueEnable column reordering
freezedColumns{ left?: string[], right?: string[] }{ left: [], right: [] }Pins columns to the left or right side of the table
columnResizingbooleantrueEnable column resizing
selectedRowsstring[][]Array of selected row IDs
currentLayout'rows' | 'columns' | 'tableHeight'undefinedCurrent layout mode
columnOrderstring[]undefinedColumn order
rowClickablebooleanfalseEnable row clickable
rowUniqueIdstring'id'Unique identifier for the row
expandedRowRenderer(data: any) => VNodeChildnullRenderer for expanded rows
loadingboolean | { status: boolean; skeletonRows: number }falseShow loading state, default skeletonRows is 10
pageSizenumber20Number of rows per page
tableBodyMinHeightstringundefinedMinimum height of the table body
restrictRowReorderingUptonumberundefinedExclude rows reordering from 0 to this index
tableAttributesHTMLAttributes{}Additional HTML attributes for the table element
emptyRowFactory(index: number) => Record<string, unknown>undefinedFactory used to generate deterministic placeholder rows while loading/skeleton states are rendered
tooltipVariant'light' | 'dark''dark'Variant of the overflow tooltip shown on header and cell text ellipsis
scrollBarTrigger'hover' | 'none'hoverEnable scroll bar visibility on hover or always, none means scroll bar is always visible

DataTableWrapper

NameTypeDefaultDescription
id *string-Unique identifier for the table
showHeaderbooleanfalseShow header
showLayoutOptionsbooleanfalseShow layout options
showRowLayoutOptionbooleanfalseShow row layout option
showColumnLayoutOptionbooleanfalseShow column layout option
showTableHeightLayoutOptionbooleanfalseShow table height layout option
showGlobalSearchbooleanfalseShow global search
searchPlaceholderstring'Search'Placeholder text for the global search input field
columnOptionsColumnOption[][]Column options
freezedColumnColumnOptionundefinedFreezed column
layoutOptionsLayoutOption[][]Layout options
selectedTableLayoutLayoutOptionundefinedSelected table layout
noOfRowsSelectednumberundefinedNumber of rows selected
pageSizenumberundefinedNumber of rows per page
tableHeightSelectedOptionstring'default'Table height selected option
userDrivenValuenumber | nullnullUser driven value
maxWidthstring'800px'Maximum width of the table
responsiveColumnWidthbooleanfalseEnable responsive column width, if this is set true then size of each column will rendered in %
globalFilterstringundefinedGlobal filter value
fillParentHeightbooleanundefinedOccupy the full height of the parent container
tableWrapperAttributesHTMLAttributes{}Additional HTML attributes for the table wrapper element

A11y

Add aria-label and aria-describedby labels to tableAttributes or tableWrapperAttributes to maintain accessibility and WCAG compliance. Refer to guidelines here

Types

ts
export interface DataTableColumn<T = any> {
  id: string
  header: string | TableColumnHeaderObject | VNode | (() => VNode)
  accessorKey?: string
  size?: number // if responsiveColumnWidth is `true` then size will treated as `%` else in `pixels`
  minSize?: number // min width of the column in pixels
  maxSize?: number // max width of the column in pixels
  filterFn?: FilterFns
  sortingFn?: SortingFn
  cellFormatter?: ({ row }: { row: Row<T> }) => VNode
  meta?: {
    align?: 'start' | 'end' | 'center' | 'space-around' | 'space-between' | 'space-evenly'
    headerAlign?: 'start' | 'end' | 'center' | 'space-around' | 'space-between' | 'space-evenly'
  }
  columns?: DataTableColumn<T>[]
}
ts
export interface TableColumnHeaderObject {
  text: string
  icon?: VNodeChild
  filterComponent?: VNodeChild
}
ts
export interface CellValueObject {
  text?: string
  prefixIcon?: VNodeChild
  [key: string]: unknown
}
ts
export interface PlaceholderRow {
  __placeholder: true
  index: number
  [key: string]: unknown
}
ts
export interface ColumnOption {
  value: string
  label: string
  checked?: boolean
  frozen?: boolean
}
ts
export interface LayoutOption {
  value: string
  label: string
}

Emits

DataTable

NameParametersDescription
@no-data(isEmpty: boolean)Emitted when table has no data
@table-row-clicked(row: DataTableRow, rowInfo: RowInfo)Emitted when row is clicked
@update:column-checked(columnId: string, checked: boolean)Emitted when column visibility changes
@update:column-clicked(columnId: string)Emitted when column is clicked
@update:column-order(order: string[])Emitted when column order changes
@update:row-reordered(draggedRowIndex: number, targetIndex: number)Emitted when row is reordered

DataTableWrapper

NameParametersDescription
@update:cancel(layout: string)Emitted when table layout is cancelled
@update:column-checked(columnId: string, checked: boolean)Emitted when column visibility changes
@update:column-freeze(columnId: string)Emitted when column is frozen
@update:column-reorder(columnIds: string[])Emitted when columns are reordered
@update:column-unfreeze(columnId: string)Emitted when columns are unfrozen
@update:current-layout(layout: string)Emitted when current layout changes
@update:delete-table-layout(layout: string)Emitted when table layout is deleted
@update:global-filter(filter: string)Emitted when global filter changes
@update:page-size(pageSize: number)Emitted when page size changes
@update:row-deselect-all(rowIds: string[])Emitted when all rows are deselected
@update:switch-table-layout(layout: string)Emitted when table layout is switched
@update:table-height-selected-option(option: string)Emitted when table height selected option changes
@update:table-layout-as(layout: string)Emitted when table layout is saved as
@update:table-layout(layout: string)Emitted when table layout changes

Slots

DataTable

NameDescription
no-dataWhen table has no data

DataTableWrapper

NameDescription
headerHeader
footerFooter
header-content-leftHeader content left (left side of the header)
header-content-rightHeader content right (right side of the header)
defaultThe default content slot for table content

Instance API

HLDataTable exposes its internals through a template ref. Attach a ref, then read .table — the underlying TanStack Table instance — to drive sorting, pagination, filtering, column order, and visibility imperatively. This is the pattern used throughout the examples above (pagination, column sort, CRUD).

vue
<template>
  <HLDataTable ref="tableInstance" :columns="columns" :data="data" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
const tableInstance = ref(null)

// Access the TanStack Table instance once mounted:
// tableInstance.value.table.setSorting([{ id: 'age', desc: true }])
// tableInstance.value.table.setPageSize(50)
// tableInstance.value.table.setGlobalFilter('john')
// tableInstance.value.table.getColumn('firstName')?.toggleVisibility(false)
// tableInstance.value.table.getState().pagination.pageIndex
</script>
ExposedTypeDescription
tableTable<T> (TanStack)The TanStack Table instance. Use its API for sorting, pagination, filtering, column order/visibility.
rowVirtualizerVirtualizerThe TanStack virtualizer driving row virtualization, e.g. for programmatic scrollToIndex.

INFO

setPageSize on the table instance is the supported way to change page size when paginating manually (as in the breadcrumb pagination example). It differs from the wrapper's own page-size dropdown, which emits @update:page-size — with infinite scroll you drive size through the instance rather than the dropdown.