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.
<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>import { h, ref } from 'vue'
import { HLDataTable, HLDataTableWrapper, HLProgress, HLSpace, HLIcon, DataTableColumn } from '@platform-ui/highrise'
import { Star01Icon } from '@gohighlevel/ghl-icons/24/outline'
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',
},
])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.
import { ref } from 'vue'
import { HLDataTable, HLDataTableWrapper, HLBreadcrumb, HLPagination } from '@platform-ui/highrise'<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>const breadcrumbs = [
{
label: 'BC1',
href: 'https://google.com',
},
{
label: 'BC2',
href: 'https://google.com',
},
{
label: 'BC3',
href: 'https://google.com',
},
]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.
<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> 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',
},
]Table with Search
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.
<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>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.
<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>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
<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>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
<HLDataTableWrapper id="basic-table-wrapper">
<HLDataTable id="basic-table" ref="tableInstanceColumnResize" :columns="columns" :data="data" :column-resizing="true" max-height="900px">
</HLDataTable>
</HLDataTableWrapper>
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
}
)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).
<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><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.
<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>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.
<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>const scrollIntoViewInstance = ref()
const scroll25 = () =>{
scrollIntoViewInstance.value.rowVirtualizer.scrollToIndex(25,{behavior:'smooth',align:'center'})
}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.
<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.
<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>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.
<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>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([])
}<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>// 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.
<template>
<HLDataTableWrapper id="column-filter-table-wrapper">
<HLDataTable ref="tableInstance" id="column-filter-table" :columns="columns" :data="data" />
</HLDataTableWrapper>
</template>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)
}<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><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.
<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><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.
<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>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.
<!-- 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>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.
<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:
| Mode | What opens underneath |
|---|---|
| Custom Panel | Your own design — a card, a form, some text |
| Nested Table Rows | More rows, in the same table layout |
| Mixed Rows + Panel | Some nested rows, plus your own design |
TagGroup Usage
Using cellFormatter we can render custom content inside the table cells.
<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>import { HLDataTable, HLDataTableWrapper, HLProgress, HLSpace, HLIcon, DataTableColumn, HLTagGroup } from '@platform-ui/highrise'
import { Star01Icon } from '@gohighlevel/ghl-icons/24/outline'
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',
}
]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.
<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>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.
<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>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.
<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>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.
<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>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.
import { h, ref } from 'vue'
import { HLDataTable, HLDataTableWrapper, HLCheckbox } from '@platform-ui/highrise'<HLDataTableWrapper>
<HLDataTable
ref="tableInstance"
:columns="columns"
:data="data"
:highlight-selected-row="true"
:selected-rows="selectedRows"
:row-hover="true"
max-height="900px"
>
</HLDataTable>
</HLDataTableWrapper>// 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.
<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.
<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>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.
<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>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',
},
},
]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',
},
])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.
<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>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'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.
<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><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><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><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
import { HLDataTable, HLDataTableWrapper } from '@platform-ui/highrise'Accessibility
- Provide a caption or set
aria-label/aria-labelledbyonHLDataTableto summarize the dataset. - Maintain
aria-sorton sortable headers and expose selected rows viaaria-selectedoraria-describedby. - When paginating or virtualizing, link the pager with
aria-controlsand announce counts through helper text referenced byaria-describedby. - Pass attributes to be attached to the
<table>element totableAttributesforHLDataTableandtableWrapperAttributesforHLDataTableWrapper.
Props
DataTable
| Name | Type | Default | Description |
|---|---|---|---|
| id * | string | - | Unique identifier for the table |
| columns * | DataDataTableColumn[] | - | Columns (headers) |
| data * | any[] | - | Data (rows) |
| maxHeight | string | '800px' | Maximum height of the table |
| rowHeight | string | '36px' | Height of the row |
| headerRowHeight | string | '36px' | Height of the header row |
| striped | boolean | false | Applies alternating background colors to rows |
| horizontalBorders | boolean | true | Show horizontal borders |
| verticalBorders | boolean | true | Show vertical borders |
| rowHover | boolean | true | Applies hover effect to table rows |
| columnHover | boolean | true | Applies hover effect to table columns |
| highlightSelectedRow | boolean | false | Highlight selected row |
| highlightedRows | string[] | [] | Predefined highlighted rows |
| rowReordering | boolean | false | Enable row reordering |
| columnReordering | boolean | true | Enable column reordering |
| freezedColumns | { left?: string[], right?: string[] } | { left: [], right: [] } | Pins columns to the left or right side of the table |
| columnResizing | boolean | true | Enable column resizing |
| selectedRows | string[] | [] | Array of selected row IDs |
| currentLayout | 'rows' | 'columns' | 'tableHeight' | undefined | Current layout mode |
| columnOrder | string[] | undefined | Column order |
| rowClickable | boolean | false | Enable row clickable |
| rowUniqueId | string | 'id' | Unique identifier for the row |
| expandedRowRenderer | (data: any) => VNodeChild | null | Renderer for expanded rows |
| loading | boolean | { status: boolean; skeletonRows: number } | false | Show loading state, default skeletonRows is 10 |
| pageSize | number | 20 | Number of rows per page |
| tableBodyMinHeight | string | undefined | Minimum height of the table body |
| restrictRowReorderingUpto | number | undefined | Exclude rows reordering from 0 to this index |
| tableAttributes | HTMLAttributes | {} | Additional HTML attributes for the table element |
| emptyRowFactory | (index: number) => Record<string, unknown> | undefined | Factory 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' | hover | Enable scroll bar visibility on hover or always, none means scroll bar is always visible |
DataTableWrapper
| Name | Type | Default | Description |
|---|---|---|---|
| id * | string | - | Unique identifier for the table |
| showHeader | boolean | false | Show header |
| showLayoutOptions | boolean | false | Show layout options |
| showRowLayoutOption | boolean | false | Show row layout option |
| showColumnLayoutOption | boolean | false | Show column layout option |
| showTableHeightLayoutOption | boolean | false | Show table height layout option |
| showGlobalSearch | boolean | false | Show global search |
| searchPlaceholder | string | 'Search' | Placeholder text for the global search input field |
| columnOptions | ColumnOption[] | [] | Column options |
| freezedColumn | ColumnOption | undefined | Freezed column |
| layoutOptions | LayoutOption[] | [] | Layout options |
| selectedTableLayout | LayoutOption | undefined | Selected table layout |
| noOfRowsSelected | number | undefined | Number of rows selected |
| pageSize | number | undefined | Number of rows per page |
| tableHeightSelectedOption | string | 'default' | Table height selected option |
| userDrivenValue | number | null | null | User driven value |
| maxWidth | string | '800px' | Maximum width of the table |
| responsiveColumnWidth | boolean | false | Enable responsive column width, if this is set true then size of each column will rendered in % |
| globalFilter | string | undefined | Global filter value |
| fillParentHeight | boolean | undefined | Occupy the full height of the parent container |
| tableWrapperAttributes | HTMLAttributes | {} | 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
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>[]
}export interface TableColumnHeaderObject {
text: string
icon?: VNodeChild
filterComponent?: VNodeChild
}export interface CellValueObject {
text?: string
prefixIcon?: VNodeChild
[key: string]: unknown
}export interface PlaceholderRow {
__placeholder: true
index: number
[key: string]: unknown
}export interface ColumnOption {
value: string
label: string
checked?: boolean
frozen?: boolean
}export interface LayoutOption {
value: string
label: string
}Emits
DataTable
| Name | Parameters | Description |
|---|---|---|
@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
| Name | Parameters | Description |
|---|---|---|
@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
| Name | Description |
|---|---|
no-data | When table has no data |
DataTableWrapper
| Name | Description |
|---|---|
header | Header |
footer | Footer |
header-content-left | Header content left (left side of the header) |
header-content-right | Header content right (right side of the header) |
default | The 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).
<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>| Exposed | Type | Description |
|---|---|---|
table | Table<T> (TanStack) | The TanStack Table instance. Use its API for sorting, pagination, filtering, column order/visibility. |
rowVirtualizer | Virtualizer | The 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.