---
title: "Table"
description: "A responsive table element to display data in rows and columns."
canonical_url: "https://vinicunca.dev/pohon/components/table"
---
# Table

> A responsive table element to display data in rows and columns.

## Usage

The Table component is built on top of [TanStack Table v8](https://tanstack.com/table/v8) and is powered by the [useVueTable](https://tanstack.com/table/v8/docs/framework/vue/vue-table#usevuetable) composable to provide a flexible and fully type-safe API.

It renders your data as rows and columns and supports sorting, filtering, pagination, row selection, expansion, grouping, pinning and virtualization, so you can build everything from a simple data table to a fully featured data grid.

```vue [TableExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { capitalize } from '@vinicunca/perkakas';
import { useClipboard } from '@vueuse/core';
import { h, resolveComponent } from 'vue';

const PButton = resolveComponent('PButton');
const PCheckbox = resolveComponent('PCheckbox');
const PBadge = resolveComponent('PBadge');
const PDropdownMenu = resolveComponent('PDropdownMenu');

const toast = useToast();
const { copy } = useClipboard();

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}, {
  id: '4595',
  date: '2024-03-10T13:40:00',
  status: 'refunded',
  email: 'ava.thomas@example.com',
  amount: 428,
}, {
  id: '4594',
  date: '2024-03-10T09:15:00',
  status: 'paid',
  email: 'michael.wilson@example.com',
  amount: 683,
}, {
  id: '4593',
  date: '2024-03-09T20:25:00',
  status: 'failed',
  email: 'olivia.taylor@example.com',
  amount: 947,
}, {
  id: '4592',
  date: '2024-03-09T18:45:00',
  status: 'paid',
  email: 'benjamin.jackson@example.com',
  amount: 851,
}, {
  id: '4591',
  date: '2024-03-09T16:05:00',
  status: 'paid',
  email: 'sophia.miller@example.com',
  amount: 762,
}, {
  id: '4590',
  date: '2024-03-09T14:20:00',
  status: 'paid',
  email: 'noah.clark@example.com',
  amount: 573,
}, {
  id: '4589',
  date: '2024-03-09T11:35:00',
  status: 'failed',
  email: 'isabella.lee@example.com',
  amount: 389,
}, {
  id: '4588',
  date: '2024-03-08T22:50:00',
  status: 'refunded',
  email: 'liam.walker@example.com',
  amount: 701,
}, {
  id: '4587',
  date: '2024-03-08T20:15:00',
  status: 'paid',
  email: 'charlotte.hall@example.com',
  amount: 856,
}, {
  id: '4586',
  date: '2024-03-08T17:40:00',
  status: 'paid',
  email: 'mason.young@example.com',
  amount: 492,
}, {
  id: '4585',
  date: '2024-03-08T14:55:00',
  status: 'failed',
  email: 'amelia.king@example.com',
  amount: 637,
}, {
  id: '4584',
  date: '2024-03-08T12:30:00',
  status: 'paid',
  email: 'elijah.wright@example.com',
  amount: 784,
}, {
  id: '4583',
  date: '2024-03-08T09:45:00',
  status: 'refunded',
  email: 'harper.scott@example.com',
  amount: 345,
}, {
  id: '4582',
  date: '2024-03-07T23:10:00',
  status: 'paid',
  email: 'evelyn.green@example.com',
  amount: 918,
}, {
  id: '4581',
  date: '2024-03-07T20:25:00',
  status: 'paid',
  email: 'logan.baker@example.com',
  amount: 567,
}]);

const columns: Array<TableColumn<Payment>> = [{
  id: 'select',
  header: ({ table }) => h(PCheckbox, {
    'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
    'aria-label': 'Select all',
  }),
  cell: ({ row }) => h(PCheckbox, {
    'modelValue': row.getIsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
    'aria-label': 'Select row',
  }),
  enableSorting: false,
  enableHiding: false,
}, {
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  enableSorting: true,
  header: ({ column }) => {
    const isSorted = column.getIsSorted();

    return h(PButton, {
      color: 'neutral',
      variant: 'ghost',
      label: 'Email',
      icon: isSorted ? (isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow') : 'i-lucide-arrow-up-down',
      class: '-mx-2.5',
      onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
    });
  },
  meta: {
    class: {
      td: 'lowercase',
    },
  },
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}, {
  id: 'actions',
  enableHiding: false,
  meta: {
    class: {
      td: 'text-right',
    },
  },
  cell: ({ row }) => {
    const items = [{
      type: 'label',
      label: 'Actions',
    }, {
      label: 'Copy payment ID',
      onSelect() {
        copy(row.original.id);

        toast.add({
          title: 'Payment ID copied to clipboard!',
          color: 'success',
          icon: 'i-lucide-circle-check',
        });
      },
    }, {
      label: row.getIsExpanded() ? 'Collapse' : 'Expand',
      onSelect() {
        row.toggleExpanded();
      },
    }, {
      type: 'separator',
    }, {
      label: 'View customer',
    }, {
      label: 'View payment details',
    }];

    return h(PDropdownMenu, {
      'content': {
        align: 'end',
      },
      items,
      'aria-label': 'Actions dropdown',
    }, () => h(PButton, {
      'icon': 'i-lucide-ellipsis-vertical',
      'color': 'neutral',
      'variant': 'ghost',
      'aria-label': 'Actions dropdown',
    }));
  },
}];

const table = useTemplateRef('table');

function randomize() {
  data.value = [...data.value].sort(() => Math.random() - 0.5);
}
</script>

<template>
  <div class="flex-1 w-full divide-border-accented divide-y">
    <div class="px-4 py-3.5 flex gap-2 items-center overflow-x-auto">
      <PInput
        :model-value="(table?.tableApi?.getColumn('email')?.getFilterValue() as string)"
        class="max-w-sm min-w-[12ch]"
        placeholder="Filter emails..."
        @update:model-value="table?.tableApi?.getColumn('email')?.setFilterValue($event)"
      />

      <PButton
        color="neutral"
        label="Randomize"
        @click="randomize"
      />

      <PDropdownMenu
        :items="table?.tableApi?.getAllColumns().filter(column => column.getCanHide()).map(column => ({
          label: capitalize(column.id),
          type: 'checkbox' as const,
          checked: column.getIsVisible(),
          onUpdateChecked(checked: boolean) {
            table?.tableApi?.getColumn(column.id)?.toggleVisibility(!!checked)
          },
          onSelect(e: Event) {
            e.preventDefault()
          },
        }))"
        :content="{ align: 'end' }"
      >
        <PButton
          label="Columns"
          color="neutral"
          variant="outline"
          trailing-icon="i-lucide-chevron-down"
          class="ml-auto"
          aria-label="Columns select dropdown"
        />
      </PDropdownMenu>
    </div>

    <PTable
      ref="table"
      :data="data"
      :columns="columns"
      sticky
      class="h-96"
    >
      <template #expanded="{ row }">
        <pre>{{ row.original }}</pre>
      </template>
    </PTable>

    <div class="text-sm color-text-muted px-4 py-3.5">
      {{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of
      {{ table?.tableApi?.getFilteredRowModel().rows.length || 0 }} row(s) selected.
    </div>
  </div>
</template>
```

> [!NOTE]
> See: https://github.com/vinicunca/docs/blob/main/app/components/content/examples/table/TableExample.vue
> 
> This example demonstrates the most common use case of the `Table` component. Check out the source code on GitHub.

### Data

Use the `data` prop as an array of objects, the columns will be generated based on the keys of the objects.

```vue
<script setup lang="ts">
const data = ref([
  {
    id: "4600",
    date: "2024-03-11T15:30:00",
    status: "paid",
    email: "james.anderson@example.com",
    amount: 594
  },
  {
    id: "4599",
    date: "2024-03-11T10:10:00",
    status: "failed",
    email: "mia.white@example.com",
    amount: 276
  },
  {
    id: "4598",
    date: "2024-03-11T08:50:00",
    status: "refunded",
    email: "william.brown@example.com",
    amount: 315
  },
  {
    id: "4597",
    date: "2024-03-10T19:45:00",
    status: "paid",
    email: "emma.davis@example.com",
    amount: 529
  },
  {
    id: "4596",
    date: "2024-03-10T15:55:00",
    status: "paid",
    email: "ethan.harris@example.com",
    amount: 639
  }
])
</script>

<template>
  <PTable :data="data" class="flex-1" />
</template>
```

### Columns

Use the `columns` prop as an array of [ColumnDef](https://tanstack.com/table/v8/docs/api/core/column-def) objects with properties like:

- `accessorKey`: The key of the row object to use when extracting the value for the column.
- `header`: The header to display for the column. If a string is passed, it can be used as a default for the column ID. If a function is passed, it will be passed a props object for the header and should return the rendered header value (the exact type depends on the adapter being used).
- [`footer`](#with-column-footer): The footer to display for the column. Works exactly like header, but is displayed under the table.
- `cell`: The cell to display each row for the column. If a function is passed, it will be passed a props object for the cell and should return the rendered cell value (the exact type depends on the adapter being used).
- `meta`: Extra properties for the column.
  - `class`:
  
    - `td`: The classes to apply to the `td` element.
    - `th`: The classes to apply to the `th` element.
  - `style`:
  
    - `td`: The style to apply to the `td` element.
    - `th`: The style to apply to the `th` element.
  - [`colspan`](#with-column-span):
  
    - `td`: The colspan attribute to apply to the `td` element.
  - [`rowspan`](#with-column-span):
  
    - `td`: The rowspan attribute to apply to the `td` element.

To render components or other HTML elements, you need to use the Vue [`h` function](https://vuejs.org/api/render-function.html#h) inside the `header` and `cell` props. This is different from other components that use slots but allows for more flexibility.

> [!TIP]
> See: #with-slots
> 
> You can also use slots to customize the header and data cells of the table.

```vue [TableColumnsExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];
</script>

<template>
  <PTable
    :data="data"
    :columns="columns"
    class="flex-1"
  />
</template>
```

> [!NOTE]
> 
> When rendering components with `h`, you can either use the `resolveComponent` function or import from `#components`.

### Meta

Use the `meta` prop as an object ([TableMeta](https://tanstack.com/table/v8/docs/api/core/table#meta)) to pass properties like:

- `class`:

  - `tr`: The classes to apply to the `tr` element.
- `style`:

  - `tr`: The style to apply to the `tr` element.

```vue [TableMetaExample.vue]
<script setup lang="ts">
import type { Row, TableMeta } from '@tanstack/vue-table';
import type { TableColumn } from 'pohon-ui';

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: 'ID',
  meta: {
    class: {
      th: 'text-center font-semibold',
      td: 'text-center font-mono',
    },
  },
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  meta: {
    class: {
      th: 'text-center',
      td: 'text-center',
    },
  },
  cell: ({ row }) => {
    const status = row.getValue('status') as string;
    const colorMap = {
      paid: 'color-success',
      failed: 'color-error',
      refunded: 'color-warning',
    };
    return h('span', { class: `font-semibold capitalize ${colorMap[status as keyof typeof colorMap]}` }, status);
  },
}, {
  accessorKey: 'email',
  header: 'Email',
  meta: {
    class: {
      th: 'text-left',
      td: 'text-left',
    },
  },
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right font-bold color-primary',
      td: 'text-right font-mono',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    const formatted = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
    }).format(amount);
    return h('span', { class: 'font-semibold color-success' }, formatted);
  },
}];

const meta: TableMeta<Payment> = {
  class: {
    tr: (row: Row<Payment>) => {
      if (row.original.status === 'failed') {
        return 'bg-error/10';
      }
      if (row.original.status === 'refunded') {
        return 'bg-warning/10';
      }
      return '';
    },
  },
};
</script>

<template>
  <PTable
    :data="data"
    :columns="columns"
    :meta="meta"
    class="flex-1"
  />
</template>
```

### Loading

Use the `loading` prop to display a loading state, the `loading-color` prop to change its color and the `loading-animation` prop to change its animation.

```vue
<script setup lang="ts">
const data = ref([
  {
    id: "4600",
    date: "2024-03-11T15:30:00",
    status: "paid",
    email: "james.anderson@example.com",
    amount: 594
  },
  {
    id: "4599",
    date: "2024-03-11T10:10:00",
    status: "failed",
    email: "mia.white@example.com",
    amount: 276
  },
  {
    id: "4598",
    date: "2024-03-11T08:50:00",
    status: "refunded",
    email: "william.brown@example.com",
    amount: 315
  },
  {
    id: "4597",
    date: "2024-03-10T19:45:00",
    status: "paid",
    email: "emma.davis@example.com",
    amount: 529
  },
  {
    id: "4596",
    date: "2024-03-10T15:55:00",
    status: "paid",
    email: "ethan.harris@example.com",
    amount: 639
  }
])
</script>

<template>
  <PTable loading loading-color="primary" loading-animation="carousel" :data="data" class="flex-1" />
</template>
```

> [!TIP]
> 
> The loading animation is automatically disabled when the user prefers reduced motion, the bar is displayed as a full width pulse instead.

### Sticky

Use the `sticky` prop to make the header or footer sticky.

```vue
<script setup lang="ts">
const data = ref([
  {
    id: "4600",
    date: "2024-03-11T15:30:00",
    status: "paid",
    email: "james.anderson@example.com",
    amount: 594
  },
  {
    id: "4599",
    date: "2024-03-11T10:10:00",
    status: "failed",
    email: "mia.white@example.com",
    amount: 276
  },
  {
    id: "4598",
    date: "2024-03-11T08:50:00",
    status: "refunded",
    email: "william.brown@example.com",
    amount: 315
  },
  {
    id: "4597",
    date: "2024-03-10T19:45:00",
    status: "paid",
    email: "emma.davis@example.com",
    amount: 529
  },
  {
    id: "4596",
    date: "2024-03-10T15:55:00",
    status: "paid",
    email: "ethan.harris@example.com",
    amount: 639
  },
  {
    id: "4595",
    date: "2024-03-10T15:55:00",
    status: "paid",
    email: "ethan.harris@example.com",
    amount: 639
  },
  {
    id: "4594",
    date: "2024-03-10T15:55:00",
    status: "paid",
    email: "ethan.harris@example.com",
    amount: 639
  }
])
</script>

<template>
  <PTable sticky :data="data" class="flex-1 max-h-[312px]" />
</template>
```

## Examples

### With row actions

You can add a new column that renders a [DropdownMenu](https://vinicunca.dev/pohon/components/dropdown-menu) component inside the `cell` to render row actions.

```vue [TableRowActionsExample.vue]
<script setup lang="ts">
import type { Row } from '@tanstack/vue-table';
import type { TableColumn } from 'pohon-ui';
import { useClipboard } from '@vueuse/core';
import { h, resolveComponent } from 'vue';

const PButton = resolveComponent('PButton');
const PBadge = resolveComponent('PBadge');
const PDropdownMenu = resolveComponent('PDropdownMenu');

const toast = useToast();
const { copy } = useClipboard();

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}, {
  id: 'actions',
  meta: {
    class: {
      td: 'text-right',
    },
  },
  cell: ({ row }) => {
    return h(PDropdownMenu, {
      'content': {
        align: 'end',
      },
      'items': getRowItems(row),
      'aria-label': 'Actions dropdown',
    }, () => h(PButton, {
      'icon': 'i-lucide-ellipsis-vertical',
      'color': 'neutral',
      'variant': 'ghost',
      'aria-label': 'Actions dropdown',
    }));
  },
}];

function getRowItems(row: Row<Payment>) {
  return [{
    type: 'label',
    label: 'Actions',
  }, {
    label: 'Copy payment ID',
    onSelect() {
      copy(row.original.id);

      toast.add({
        title: 'Payment ID copied to clipboard!',
        color: 'success',
        icon: 'i-lucide-circle-check',
      });
    },
  }, {
    type: 'separator',
  }, {
    label: 'View customer',
  }, {
    label: 'View payment details',
  }];
}
</script>

<template>
  <PTable
    :data="data"
    :columns="columns"
    class="flex-1"
  />
</template>
```

### With expandable rows

You can add a new column that renders a [Button](https://vinicunca.dev/pohon/components/button) component inside the `cell` to toggle the expandable state of a row using the TanStack Table [Expanding APIs](https://tanstack.com/table/v8/docs/api/features/expanding).

> [!CAUTION]
> 
> You need to define the `#expanded` slot to render the expanded content which will receive the row as a parameter.

```vue [TableRowExpandableExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PButton = resolveComponent('PButton');
const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  id: 'expand',
  cell: ({ row }) => h(PButton, {
    'color': 'neutral',
    'variant': 'ghost',
    'icon': 'i-lucide-chevron-down',
    'square': true,
    'aria-label': 'Expand',
    'ui': {
      leadingIcon: ['transition-transform', row.getIsExpanded() ? 'duration-200 rotate-180' : ''],
    },
    'onClick': () => row.toggleExpanded(),
  }),
}, {
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const expanded = ref({ 1: true });
</script>

<template>
  <PTable
    v-model:expanded="expanded"
    :data="data"
    :columns="columns"
    :ui="{ tr: 'data-[expanded=true]:bg-background-elevated/50' }"
    class="flex-1"
  >
    <template #expanded="{ row }">
      <pre>{{ row.original }}</pre>
    </template>
  </PTable>
</template>
```

> [!TIP]
> 
> You can use the `expanded` prop to control the expandable state of the rows (can be bound with `v-model`).

> [!NOTE]
> 
> You could also add this action to the [`DropdownMenu`](https://vinicunca.dev/pohon/components/dropdown-menu) component inside the `actions` column.

### With grouped rows

You can group rows based on a given column value and show/hide sub rows via some button added to the cell using the TanStack Table [Grouping APIs](https://tanstack.com/table/v8/docs/api/features/grouping).

#### Important parts

- Add `grouping` prop with an array of column ids you want to group by.
- Add `grouping-options` prop. It must include `getGroupedRowModel`, you can import it from `@tanstack/vue-table` or implement your own.
- Expand rows via `row.toggleExpanded()` method on any cell of the row. Keep in mind, it also toggles `#expanded` slot.
- Use `aggregateFn` on column definition to define how to aggregate the rows.
- `agregatedCell` renderer on column definition only works if there is no `cell` renderer.

```vue [TableGroupedRowsExample.vue]
<script setup lang="ts">
import type { GroupingOptions } from '@tanstack/vue-table';
import type { TableColumn } from 'pohon-ui';
import { getGroupedRowModel } from '@tanstack/vue-table';
import { resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');

interface Account {
  id: string;
  name: string;
}

type PaymentStatus = 'paid' | 'failed' | 'refunded';

interface Payment {
  id: string;
  date: string;
  status: PaymentStatus;
  email: string;
  amount: number;
  account: Account;
}

function getColorByStatus(status: PaymentStatus) {
  return {
    paid: 'success',
    failed: 'error',
    refunded: 'neutral',
  }[status];
}

const data = ref<Array<Payment>>([
  {
    id: '4600',
    date: '2024-03-11T15:30:00',
    status: 'paid',
    email: 'james.anderson@example.com',
    amount: 594,
    account: {
      id: '1',
      name: 'Account 1',
    },
  },
  {
    id: '4599',
    date: '2024-03-11T10:10:00',
    status: 'failed',
    email: 'mia.white@example.com',
    amount: 276,
    account: {
      id: '2',
      name: 'Account 2',
    },
  },
  {
    id: '4598',
    date: '2024-03-11T08:50:00',
    status: 'refunded',
    email: 'william.brown@example.com',
    amount: 315,
    account: {
      id: '1',
      name: 'Account 1',
    },
  },
  {
    id: '4597',
    date: '2024-03-10T19:45:00',
    status: 'paid',
    email: 'emma.davis@example.com',
    amount: 529,
    account: {
      id: '2',
      name: 'Account 2',
    },
  },
  {
    id: '4596',
    date: '2024-03-10T15:55:00',
    status: 'paid',
    email: 'ethan.harris@example.com',
    amount: 639,
    account: {
      id: '1',
      name: 'Account 1',
    },
  },
]);

const columns: Array<TableColumn<Payment>> = [
  {
    id: 'title',
    header: 'Item',
  },
  {
    id: 'account_id',
    accessorKey: 'account.id',
  },
  {
    accessorKey: 'id',
    header: '#',
    cell: ({ row }) =>
      row.getIsGrouped()
        ? `${row.getValue('id')} records`
        : `#${row.getValue('id')}`,
    aggregationFn: 'count',
  },
  {
    accessorKey: 'date',
    header: 'Date',
    cell: ({ row }) => {
      return new Date(row.getValue('date')).toLocaleString('en-US', {
        day: 'numeric',
        month: 'short',
        hour: '2-digit',
        minute: '2-digit',
        hour12: false,
      });
    },
    aggregationFn: 'max',
  },
  {
    accessorKey: 'status',
    header: 'Status',
  },
  {
    accessorKey: 'email',
    header: 'Email',
    meta: {
      class: {
        td: 'w-full',
      },
    },
    cell: ({ row }) =>
      row.getIsGrouped()
        ? `${row.getValue('email')} customers`
        : row.getValue('email'),
    aggregationFn: 'uniqueCount',
  },
  {
    accessorKey: 'amount',
    header: 'Amount',
    meta: {
      class: {
        th: 'text-right',
        td: 'text-right font-medium',
      },
    },
    cell: ({ row }) => {
      const amount = Number.parseFloat(row.getValue('amount'));
      return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'EUR',
      }).format(amount);
    },
    aggregationFn: 'sum',
  },
];

const grouping_options = ref<GroupingOptions>({
  groupedColumnMode: 'remove',
  getGroupedRowModel: getGroupedRowModel(),
});
</script>

<template>
  <PTable
    :data="data"
    :columns="columns"
    :grouping="['account_id', 'status']"
    :grouping-options="grouping_options"
    :ui="{
      root: 'min-w-full',
      td: 'empty:p-0', // helps with the colspaned row added for expand slot
    }"
  >
    <template #title-cell="{ row }">
      <div
        v-if="row.getIsGrouped()"
        class="flex items-center"
      >
        <span
          class="inline-block"
          :style="{ width: `calc(${row.depth} * 1rem)` }"
        />

        <PButton
          variant="outline"
          color="neutral"
          class="mr-2"
          size="xs"
          :icon="row.getIsExpanded() ? 'i-lucide-minus' : 'i-lucide-plus'"
          @click="row.toggleExpanded()"
        />
        <strong v-if="row.groupingColumnId === 'account_id'">{{
          row.original.account.name
        }}</strong>
        <PBadge
          v-else-if="row.groupingColumnId === 'status'"
          :color="getColorByStatus(row.original.status)"
          class="capitalize"
          variant="subtle"
        >
          {{ row.original.status }}
        </PBadge>
      </div>
    </template>
  </PTable>
</template>
```

### With row pinning

You can add a column that renders a [Button](https://vinicunca.dev/pohon/components/button) component inside the `cell` to toggle the pinning state of a row using the TanStack Table [Row Pinning APIs](https://tanstack.com/table/v8/docs/api/features/row-pinning). Pinned rows will stay at the top or bottom of the table regardless of sorting or filtering.

```vue [TableRowPinningExample.vue]
<script setup lang="ts">
import type { RowPinningState } from '@tanstack/table-core';
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PButton = resolveComponent('PButton');
const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}, {
  id: '4595',
  date: '2024-03-10T13:40:00',
  status: 'refunded',
  email: 'ava.thomas@example.com',
  amount: 428,
}, {
  id: '4594',
  date: '2024-03-10T09:15:00',
  status: 'paid',
  email: 'michael.wilson@example.com',
  amount: 683,
}, {
  id: '4593',
  date: '2024-03-09T20:25:00',
  status: 'failed',
  email: 'olivia.taylor@example.com',
  amount: 947,
}, {
  id: '4592',
  date: '2024-03-09T18:45:00',
  status: 'paid',
  email: 'benjamin.jackson@example.com',
  amount: 851,
}, {
  id: '4591',
  date: '2024-03-09T16:05:00',
  status: 'paid',
  email: 'sophia.miller@example.com',
  amount: 762,
}]);

const columns: Array<TableColumn<Payment>> = [{
  id: 'pin',
  cell: ({ row }) => h(PButton, {
    'icon': 'i-lucide-star',
    'color': row.getIsPinned() ? 'primary' : 'neutral',
    'variant': 'ghost',
    'aria-label': row.getIsPinned() ? 'Unpin row' : 'Pin row to top',
    'onClick': () => {
      if (row.getIsPinned()) {
        row.pin(false);
      } else {
        row.pin('top');
      }
    },
  }),
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
      timeZone: 'UTC',
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const rowPinning = ref<RowPinningState>({ top: ['4599', '4597'], bottom: [] });
</script>

<template>
  <PTable
    v-model:row-pinning="rowPinning"
    :data="data"
    :columns="columns"
    :get-row-id="(row: Payment) => row.id"
    class="flex-1 h-96"
  />
</template>
```

> [!TIP]
> 
> You can use the `row-pinning` prop to control the pinning state of the rows (can be bound with `v-model`).

### With row selection

You can add a new column that renders a [Checkbox](https://vinicunca.dev/pohon/components/checkbox) component inside the `header` and `cell` to select rows using the TanStack Table [Row Selection APIs](https://tanstack.com/table/v8/docs/api/features/row-selection).

```vue [TableRowSelectionExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PCheckbox = resolveComponent('PCheckbox');
const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  id: 'select',
  header: ({ table }) => h(PCheckbox, {
    'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
    'aria-label': 'Select all',
  }),
  cell: ({ row }) => h(PCheckbox, {
    'modelValue': row.getIsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
    'aria-label': 'Select row',
  }),
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const table = useTemplateRef('table');

const rowSelection = ref({ 1: true });
</script>

<template>
  <div class="flex-1 w-full">
    <PTable
      ref="table"
      v-model:row-selection="rowSelection"
      :data="data"
      :columns="columns"
    />

    <div class="text-sm color-text-muted px-4 py-3.5 border-t border-border-accented">
      {{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of
      {{ table?.tableApi?.getFilteredRowModel().rows.length || 0 }} row(s) selected.
    </div>
  </div>
</template>
```

> [!TIP]
> 
> You can use the `row-selection` prop to control the selection state of the rows (can be bound with `v-model`).

### With row select event

You can add a `@select` listener to make rows clickable with or without a checkbox column.

> [!NOTE]
> 
> The handler function receives the `Event` and `TableRow` instance as the first and second arguments respectively.

```vue [TableRowSelectEventExample.vue]
<script setup lang="ts">
import type { TableColumn, TableRow } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');
const PCheckbox = resolveComponent('PCheckbox');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  id: 'select',
  header: ({ table }) => h(PCheckbox, {
    'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
    'aria-label': 'Select all',
  }),
  cell: ({ row }) => h(PCheckbox, {
    'modelValue': row.getIsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
    'aria-label': 'Select row',
  }),
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const table = useTemplateRef('table');

const rowSelection = ref<Record<string, boolean>>({ });

function onSelect(e: Event, row: TableRow<Payment>) {
  /* If you decide to also select the column you can do this  */
  row.toggleSelected(!row.getIsSelected());
}
</script>

<template>
  <div class="flex flex-1 gap-1 w-full">
    <div class="flex-1">
      <PTable
        ref="table"
        v-model:row-selection="rowSelection"
        :data="data"
        :columns="columns"
        @select="onSelect"
      />

      <div class="text-sm color-text-muted px-4 py-3.5 border-t border-border-accented">
        {{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of
        {{ table?.tableApi?.getFilteredRowModel().rows.length || 0 }} row(s) selected.
      </div>
    </div>
  </div>
</template>
```

> [!TIP]
> 
> You can use this to navigate to a page, open a modal or even to select the row manually.

### With row context menu event

You can add a `@contextmenu` listener to make rows right clickable and wrap the Table in a [ContextMenu](https://vinicunca.dev/pohon/components/context-menu) component to display row actions for example.

> [!NOTE]
> 
> The handler function receives the `Event` and `TableRow` instance as the first and second arguments respectively.

```vue [TableRowContextMenuEventExample.vue]
<script setup lang="ts">
import type { ContextMenuItem, TableColumn, TableRow } from 'pohon-ui';
import { useClipboard } from '@vueuse/core';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');
const PCheckbox = resolveComponent('PCheckbox');

const toast = useToast();
const { copy } = useClipboard();

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  id: 'select',
  header: ({ table }) => h(PCheckbox, {
    'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
    'aria-label': 'Select all',
  }),
  cell: ({ row }) => h(PCheckbox, {
    'modelValue': row.getIsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
    'aria-label': 'Select row',
  }),
}, {
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const items = ref<Array<ContextMenuItem>>([]);

function getRowItems(row: TableRow<Payment>) {
  return [{
    type: 'label' as const,
    label: 'Actions',
  }, {
    label: 'Copy payment ID',
    onSelect() {
      copy(row.original.id);

      toast.add({
        title: 'Payment ID copied to clipboard!',
        color: 'success',
        icon: 'i-lucide-circle-check',
      });
    },
  }, {
    label: row.getIsExpanded() ? 'Collapse' : 'Expand',
    onSelect() {
      row.toggleExpanded();
    },
  }, {
    type: 'separator' as const,
  }, {
    label: 'View customer',
  }, {
    label: 'View payment details',
  }];
}

function onContextmenu(_e: Event, row: TableRow<Payment>) {
  items.value = getRowItems(row);
}
</script>

<template>
  <PContextMenu :items="items">
    <PTable
      :data="data"
      :columns="columns"
      class="flex-1"
      @contextmenu="onContextmenu"
    >
      <template #expanded="{ row }">
        <pre>{{ row.original }}</pre>
      </template>
    </PTable>
  </PContextMenu>
</template>
```

### With row hover event

You can add a `@hover` listener to make rows hoverable and use a [Popover](https://vinicunca.dev/pohon/components/popover) or a [Tooltip](https://vinicunca.dev/pohon/components/tooltip) component to display row details for example.

> [!NOTE]
> 
> The handler function receives the `Event` and `TableRow` instance as the first and second arguments respectively.

```vue [TableRowHoverEventExample.vue]
<script setup lang="ts">
import type { TableColumn, TableRow } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');
const PCheckbox = resolveComponent('PCheckbox');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  id: 'select',
  header: ({ table }) => h(PCheckbox, {
    'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
    'aria-label': 'Select all',
  }),
  cell: ({ row }) => h(PCheckbox, {
    'modelValue': row.getIsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
    'aria-label': 'Select row',
  }),
}, {
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const anchor = ref({ x: 0, y: 0 });

const reference = computed(() => ({
  getBoundingClientRect: () =>
    ({
      width: 0,
      height: 0,
      left: anchor.value.x,
      right: anchor.value.x,
      top: anchor.value.y,
      bottom: anchor.value.y,
      ...anchor.value,
    } as DOMRect),
}));

const open = ref(false);
const openDebounced = refDebounced(open, 10);
const selectedRow = ref<TableRow<Payment> | null>(null);

function onHover(_e: Event, row: TableRow<Payment> | null) {
  selectedRow.value = row;

  open.value = !!row;
}
</script>

<template>
  <div class="flex flex-1 gap-1 w-full">
    <PTable
      :data="data"
      :columns="columns"
      class="flex-1"
      @pointermove="(ev: PointerEvent) => {
        anchor.x = ev.clientX
        anchor.y = ev.clientY
      }"
      @hover="onHover"
    />

    <PPopover
      :content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }"
      :open="openDebounced"
      :reference="reference"
    >
      <template #content>
        <div class="p-4">
          {{ selectedRow?.original?.id }}
        </div>
      </template>
    </PPopover>
  </div>
</template>
```

> [!NOTE]
> 
> This example is similar as the Popover [with following cursor example](https://vinicunca.dev/pohon/components/popover#with-following-cursor) and uses a [`refDebounced`](https://vueuse.org/shared/refDebounced/#refdebounced) to prevent the Popover from opening and closing too quickly when moving the cursor from one row to another.

### With column footer

You can add a `footer` property to the column definition to render a footer for the column.

```vue [TableColumnFooterExample.vue]
<script setup lang="ts">
import type { TableColumn, TableRow } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  footer: ({ column }) => {
    const total = column.getFacetedRowModel().rows.reduce((acc: number, row: TableRow<Payment>) => acc + Number.parseFloat(row.getValue('amount')), 0);
    const formatted = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(total);
    return `Total: ${formatted}`;
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];
</script>

<template>
  <PTable
    :data="data"
    :columns="columns"
    class="flex-1"
  />
</template>
```

### With column span

You can use the `colspan` and `rowspan` properties in the column `meta` to merge cells. These properties accept a static value or a function that receives the cell and returns the span value.

> [!NOTE]
> 
> When using `rowspan`, cells that are "absorbed" by a previous row's span need to be visually hidden. Use the `class` meta with a function that returns `'hidden'` for those cells.

```vue [TableColumnSpanExample.vue]
<script setup lang="ts">
import type { Cell } from '@tanstack/vue-table';
import type { TableColumn } from 'pohon-ui';

interface Product {
  category: string;
  name: string;
  price: number;
  stock: number;
}

const data = ref<Array<Product>>([
  { category: 'Electronics', name: 'Laptop', price: 999, stock: 45 },
  { category: 'Electronics', name: 'Phone', price: 699, stock: 120 },
  { category: 'Electronics', name: 'Tablet', price: 499, stock: 78 },
  { category: 'Clothing', name: 'T-Shirt', price: 29, stock: 200 },
  { category: 'Clothing', name: 'Jeans', price: 59, stock: 150 },
]);

function getCategoryRowSpan(cell: Cell<Product, unknown>) {
  const category = cell.row.original.category;
  const rows = cell.getContext().table.getRowModel().rows;
  const rowIndex = rows.findIndex((r: typeof rows[number]) => r.id === cell.row.id);

  if (rowIndex > 0 && rows[rowIndex - 1]!.original.category === category) {
    return '1';
  }

  let span = 1;
  for (let i = rowIndex + 1; i < rows.length; i++) {
    if (rows[i]!.original.category === category) {
      span++;
    } else {
      break;
    }
  }

  return `${span}`;
}

function getCategoryClass(cell: Cell<Product, unknown>) {
  const category = cell.row.original.category;
  const rows = cell.getContext().table.getRowModel().rows;
  const rowIndex = rows.findIndex((r: typeof rows[number]) => r.id === cell.row.id);

  if (rowIndex > 0 && rows[rowIndex - 1]!.original.category === category) {
    return 'hidden';
  }

  return 'font-medium align-middle border-r border-border';
}

const columns: Array<TableColumn<Product>> = [{
  accessorKey: 'category',
  header: 'Category',
  meta: {
    rowspan: {
      td: getCategoryRowSpan,
    },
    class: {
      td: getCategoryClass,
    },
  },
}, {
  accessorKey: 'name',
  header: 'Name',
}, {
  accessorKey: 'price',
  header: 'Price',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right',
    },
  },
  cell: ({ row }) => {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
    }).format(row.getValue('price'));
  },
}, {
  accessorKey: 'stock',
  header: 'Stock',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right',
    },
  },
}];
</script>

<template>
  <PTable
    :data="data"
    :columns="columns"
    class="flex-1"
  />
</template>
```

### With column sorting

You can update a column `header` to render a [Button](https://vinicunca.dev/pohon/components/button) component inside the `header` to toggle the sorting state using the TanStack Table [Sorting APIs](https://tanstack.com/table/v8/docs/api/features/sorting).

Set `enableSorting: true` on those columns as well. This puts `aria-sort` on the `<th>` so screen readers can read the current sort state of the column: `none`, `ascending` or `descending`. The `Button` stays the control that changes it.

```vue [TableColumnSortingExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');
const PButton = resolveComponent('PButton');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  enableSorting: true,
  header: ({ column }) => {
    const isSorted = column.getIsSorted();

    return h(PButton, {
      color: 'neutral',
      variant: 'ghost',
      label: 'Email',
      icon: isSorted ? (isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow') : 'i-lucide-arrow-up-down',
      class: '-mx-2.5',
      onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
    });
  },
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const sorting = ref([{
  id: 'email',
  desc: false,
}]);
</script>

<template>
  <PTable
    v-model:sorting="sorting"
    :data="data"
    :columns="columns"
    class="flex-1"
  />
</template>
```

> [!TIP]
> 
> You can use the `sorting` prop to control the sorting state of the columns (can be bound with `v-model`).

You can also create a reusable component to make any column header sortable.

```vue [TableColumnSortingReusableExample.vue]
<script setup lang="ts">
import type { Column } from '@tanstack/vue-table';
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');
const PButton = resolveComponent('PButton');
const PDropdownMenu = resolveComponent('PDropdownMenu');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  enableSorting: true,
  header: ({ column }) => getHeader(column, 'ID'),
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  enableSorting: true,
  header: ({ column }) => getHeader(column, 'Date'),
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  enableSorting: true,
  header: ({ column }) => getHeader(column, 'Status'),
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  enableSorting: true,
  header: ({ column }) => getHeader(column, 'Email'),
}, {
  accessorKey: 'amount',
  enableSorting: true,
  header: ({ column }) => getHeader(column, 'Amount'),
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

function getHeader(column: Column<Payment>, label: string) {
  const isSorted = column.getIsSorted();

  return h(PDropdownMenu, {
    'content': {
      align: 'start',
    },
    'aria-label': 'Actions dropdown',
    'items': [{
      label: 'Asc',
      type: 'checkbox',
      icon: 'i-lucide-arrow-up-narrow-wide',
      checked: isSorted === 'asc',
      onSelect: () => {
        if (isSorted === 'asc') {
          column.clearSorting();
        } else {
          column.toggleSorting(false);
        }
      },
    }, {
      label: 'Desc',
      icon: 'i-lucide-arrow-down-wide-narrow',
      type: 'checkbox',
      checked: isSorted === 'desc',
      onSelect: () => {
        if (isSorted === 'desc') {
          column.clearSorting();
        } else {
          column.toggleSorting(true);
        }
      },
    }],
  }, () => h(PButton, {
    'color': 'neutral',
    'variant': 'ghost',
    label,
    'icon': isSorted ? (isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow') : 'i-lucide-arrow-up-down',
    'class': '-mx-2.5 data-[state=open]:bg-background-elevated',
    'aria-label': `Sort by ${isSorted === 'asc' ? 'descending' : 'ascending'}`,
  }));
}

const sorting = ref([{
  id: 'id',
  desc: false,
}]);
</script>

<template>
  <PTable
    v-model:sorting="sorting"
    :data="data"
    :columns="columns"
    class="flex-1"
  />
</template>
```

> [!NOTE]
> 
> In this example, we use a function to define the column header but you can also create an actual component.

### With column pinning

You can update a column `header` to render a [Button](https://vinicunca.dev/pohon/components/button) component inside the `header` to toggle the pinning state using the TanStack Table [Column Pinning APIs](https://tanstack.com/table/v8/docs/api/features/column-pinning).

> [!NOTE]
> 
> A pinned column will become sticky on the left or right side of the table. When using column pinning, you should define explicit `size` values for your columns to ensure proper column width handling, especially with multiple pinned columns.

```vue [TableColumnPinningExample.vue]
<script setup lang="ts">
import type { Column } from '@tanstack/vue-table';
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');
const PButton = resolveComponent('PButton');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600000000000000000000000000000000000000',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594000,
}, {
  id: '4599000000000000000000000000000000000000',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276000,
}, {
  id: '4598000000000000000000000000000000000000',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315000,
}, {
  id: '4597000000000000000000000000000000000000',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 5290000,
}, {
  id: '4596000000000000000000000000000000000000',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639000,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: ({ column }) => getHeader(column, 'ID', 'left'),
  cell: ({ row }) => `#${row.getValue('id')}`,
  size: 381,
}, {
  accessorKey: 'date',
  header: ({ column }) => getHeader(column, 'Date', 'left'),
  size: 172,
}, {
  accessorKey: 'status',
  header: ({ column }) => getHeader(column, 'Status', 'left'),
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
  size: 103,
}, {
  accessorKey: 'email',
  header: ({ column }) => getHeader(column, 'Email', 'left'),
  size: 232,
}, {
  accessorKey: 'amount',
  header: ({ column }) => getHeader(column, 'Amount', 'right'),
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
  size: 130,
}];

function getHeader(column: Column<Payment>, label: string, position: 'left' | 'right') {
  const isPinned = column.getIsPinned();

  return h(PButton, {
    color: 'neutral',
    variant: 'ghost',
    label,
    icon: isPinned ? 'i-lucide-pin-off' : 'i-lucide-pin',
    class: '-mx-2.5',
    onClick() {
      column.pin(isPinned === position ? false : position);
    },
  });
}

const columnPinning = ref({
  left: ['id'],
  right: ['amount'],
});
</script>

<template>
  <PTable
    v-model:column-pinning="columnPinning"
    :data="data"
    :columns="columns"
    class="flex-1"
  />
</template>
```

> [!TIP]
> 
> You can use the `column-pinning` prop to control the pinning state of the columns (can be bound with `v-model`).

### With column visibility

You can use a [DropdownMenu](https://vinicunca.dev/pohon/components/dropdown-menu) component to toggle the visibility of the columns using the TanStack Table [Column Visibility APIs](https://tanstack.com/table/v8/docs/api/features/column-visibility).

```vue [TableColumnVisibilityExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { capitalize } from '@vinicunca/perkakas';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const table = useTemplateRef('table');

const columnVisibility = ref({
  id: false,
});
</script>

<template>
  <div class="flex flex-1 flex-col w-full">
    <div class="px-4 py-3.5 border-b border-border-accented flex justify-end">
      <PDropdownMenu
        :items="table?.tableApi?.getAllColumns().filter(column => column.getCanHide()).map(column => ({
          label: capitalize(column.id),
          type: 'checkbox' as const,
          checked: column.getIsVisible(),
          onUpdateChecked(checked: boolean) {
            table?.tableApi?.getColumn(column.id)?.toggleVisibility(!!checked)
          },
          onSelect(e: Event) {
            e.preventDefault()
          },
        }))"
        :content="{ align: 'end' }"
      >
        <PButton
          label="Columns"
          color="neutral"
          variant="outline"
          trailing-icon="i-lucide-chevron-down"
        />
      </PDropdownMenu>
    </div>

    <PTable
      ref="table"
      v-model:column-visibility="columnVisibility"
      :data="data"
      :columns="columns"
    />
  </div>
</template>
```

> [!TIP]
> 
> You can use the `column-visibility` prop to control the visibility state of the columns (can be bound with `v-model`).

### With column filters

You can use an [Input](https://vinicunca.dev/pohon/components/input) component to filter per column the rows using the TanStack Table [Column Filtering APIs](https://tanstack.com/table/v8/docs/api/features/column-filtering).

```vue [TableColumnFiltersExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const table = useTemplateRef('table');

const columnFilters = ref([{
  id: 'email',
  value: 'james',
}]);
</script>

<template>
  <div class="flex flex-1 flex-col w-full">
    <div class="px-4 py-3.5 border-b border-border-accented flex">
      <PInput
        :model-value="(table?.tableApi?.getColumn('email')?.getFilterValue() as string)"
        class="max-w-sm"
        placeholder="Filter emails..."
        @update:model-value="table?.tableApi?.getColumn('email')?.setFilterValue($event)"
      />
    </div>

    <PTable
      ref="table"
      v-model:column-filters="columnFilters"
      :data="data"
      :columns="columns"
    />
  </div>
</template>
```

> [!TIP]
> 
> You can use the `column-filters` prop to control the filters state of the columns (can be bound with `v-model`).

### With global filter

You can use an [Input](https://vinicunca.dev/pohon/components/input) component to filter the rows using the TanStack Table [Global Filtering APIs](https://tanstack.com/table/v8/docs/api/features/global-filtering).

```vue [TableGlobalFilterExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  status: 'failed',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  status: 'refunded',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  status: 'paid',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  status: 'paid',
  email: 'ethan.harris@example.com',
  amount: 639,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const globalFilter = ref('45');
</script>

<template>
  <div class="flex flex-1 flex-col w-full">
    <div class="px-4 py-3.5 border-b border-border-accented flex">
      <PInput
        v-model="globalFilter"
        class="max-w-sm"
        placeholder="Filter..."
      />
    </div>

    <PTable
      ref="table"
      v-model:global-filter="globalFilter"
      :data="data"
      :columns="columns"
    />
  </div>
</template>
```

> [!TIP]
> 
> You can use the `global-filter` prop to control the global filter state (can be bound with `v-model`).

### With pagination

You can use a [Pagination](https://vinicunca.dev/pohon/components/pagination) component to control the pagination state using the [Pagination APIs](https://tanstack.com/table/v8/docs/api/features/pagination).

There are different pagination approaches as explained in [Pagination Guide](https://tanstack.com/table/v8/docs/guide/pagination#pagination-guide). In this example, we use client-side pagination so we need to manually pass `getPaginationRowModel()` function.

```vue [TablePaginationExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { getPaginationRowModel } from '@tanstack/vue-table';

const table = useTemplateRef('table');

interface Payment {
  id: string;
  date: string;
  email: string;
  amount: number;
}
const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  email: 'emma.davis@example.com',
  amount: 529,
}, {
  id: '4596',
  date: '2024-03-10T15:55:00',
  email: 'ethan.harris@example.com',
  amount: 639,
}, {
  id: '4595',
  date: '2024-03-10T13:20:00',
  email: 'sophia.miller@example.com',
  amount: 428,
}, {
  id: '4594',
  date: '2024-03-10T11:05:00',
  email: 'noah.wilson@example.com',
  amount: 673,
}, {
  id: '4593',
  date: '2024-03-09T22:15:00',
  email: 'olivia.jones@example.com',
  amount: 382,
}, {
  id: '4592',
  date: '2024-03-09T20:30:00',
  email: 'liam.taylor@example.com',
  amount: 547,
}, {
  id: '4591',
  date: '2024-03-09T18:45:00',
  email: 'ava.thomas@example.com',
  amount: 291,
}, {
  id: '4590',
  date: '2024-03-09T16:20:00',
  email: 'lucas.martin@example.com',
  amount: 624,
}, {
  id: '4589',
  date: '2024-03-09T14:10:00',
  email: 'isabella.clark@example.com',
  amount: 438,
}, {
  id: '4588',
  date: '2024-03-09T12:05:00',
  email: 'mason.rodriguez@example.com',
  amount: 583,
}, {
  id: '4587',
  date: '2024-03-09T10:30:00',
  email: 'sophia.lee@example.com',
  amount: 347,
}, {
  id: '4586',
  date: '2024-03-09T08:15:00',
  email: 'ethan.walker@example.com',
  amount: 692,
}, {
  id: '4585',
  date: '2024-03-08T23:40:00',
  email: 'amelia.hall@example.com',
  amount: 419,
}, {
  id: '4584',
  date: '2024-03-08T21:25:00',
  email: 'oliver.young@example.com',
  amount: 563,
}, {
  id: '4583',
  date: '2024-03-08T19:50:00',
  email: 'aria.king@example.com',
  amount: 328,
}, {
  id: '4582',
  date: '2024-03-08T17:35:00',
  email: 'henry.wright@example.com',
  amount: 647,
}, {
  id: '4581',
  date: '2024-03-08T15:20:00',
  email: 'luna.lopez@example.com',
  amount: 482,
}]);
const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const pagination = ref({
  pageIndex: 0,
  pageSize: 5,
});

const globalFilter = ref('');
</script>

<template>
  <div class="pb-4 w-full space-y-4">
    <div class="px-4 py-3.5 border-b border-border-accented flex">
      <PInput
        v-model="globalFilter"
        class="max-w-sm"
        placeholder="Filter..."
      />
    </div>

    <PTable
      ref="table"
      v-model:pagination="pagination"
      v-model:global-filter="globalFilter"
      :data="data"
      :columns="columns"
      :pagination-options="{
        getPaginationRowModel: getPaginationRowModel(),
      }"
      class="flex-1"
    />

    <div class="px-4 pt-4 border-t border-border flex justify-end">
      <PPagination
        :page="(table?.tableApi?.getState().pagination.pageIndex || 0) + 1"
        :items-per-page="table?.tableApi?.getState().pagination.pageSize"
        :total="table?.tableApi?.getFilteredRowModel().rows.length"
        @update:page="(p) => table?.tableApi?.setPageIndex(p - 1)"
      />
    </div>
  </div>
</template>
```

> [!TIP]
> 
> You can use the `pagination` prop to control the pagination state (can be bound with `v-model`).

### With fetched data

You can fetch data from an API and use them in the Table.

```vue [TableFetchExample.vue]
<script setup lang="ts">
import type { AvatarProps, TableColumn } from 'pohon-ui';

const PAvatar = resolveComponent('PAvatar');

interface User {
  id: number;
  name: string;
  username: string;
  email: string;
  avatar: AvatarProps;
  company: { name: string };
}

const { data, status } = useLazyFetch<Array<User>>('https://jsonplaceholder.typicode.com/users', {
  key: 'table-users',
  transform: (data) => {
    return data?.map((user) => ({
      ...user,
      avatar: { src: `https://i.pravatar.cc/120?img=${user.id}`, alt: `${user.name} avatar` },
    })) || [];
  },
  server: false,
});

const columns: Array<TableColumn<User>> = [{
  accessorKey: 'id',
  header: 'ID',
}, {
  accessorKey: 'name',
  header: 'Name',
  cell: ({ row }) => {
    return h('div', { class: 'flex items-center gap-3' }, [
      h(PAvatar, {
        ...row.original.avatar,
        loading: 'lazy',
        size: 'lg',
      }),
      h('div', undefined, [
        h('p', { class: 'font-medium color-text-highlighted' }, row.original.name),
        h('p', { class: '' }, `@${row.original.username}`),
      ]),
    ]);
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'company',
  header: 'Company',
  cell: ({ row }) => row.original.company.name,
}];
</script>

<template>
  <PTable
    :data="data"
    :columns="columns"
    :loading="status === 'pending' || status === 'idle'"
    class="flex-1 h-80"
  />
</template>
```

> [!NOTE]
> 
> This example uses `useLazyFetch` with `server: false` to fetch data on the client without blocking the initial render. The loading state checks for both `pending` and `idle` status to display a loading indicator before and during the fetch.

### With infinite scroll

If you use server-side pagination, you can use the [`useInfiniteScroll`](https://vueuse.org/core/useInfiniteScroll/#useinfinitescroll) composable to load more data  as the user scrolls.

```vue [TableInfiniteScrollExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { useInfiniteScroll } from '@vueuse/core';

const PAvatar = resolveComponent('PAvatar');

interface User {
  id: number;
  firstName: string;
  username: string;
  email: string;
  image: string;
}

interface UserResponse {
  users: Array<User>;
  total: number;
  skip: number;
  limit: number;
}

const skip = ref(0);

const { data, status } = useLazyFetch('https://dummyjson.com/users?limit=10&select=firstName,username,email,image', {
  key: 'table-users-infinite-scroll',
  query: { skip },
  transform: (data?: UserResponse) => {
    return data?.users;
  },
  server: false,
});

const columns: Array<TableColumn<User>> = [{
  accessorKey: 'id',
  header: 'ID',
}, {
  accessorKey: 'image',
  header: 'Avatar',
  cell: ({ row }) => h(PAvatar, { src: row.original.image, loading: 'lazy' }),
}, {
  accessorKey: 'firstName',
  header: 'First name',
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'username',
  header: 'Username',
}];

const users = ref<Array<User>>([]);

watch(data, () => {
  users.value = [
    ...users.value,
    ...(data.value || []),
  ];
});

const table = useTemplateRef('table');

onMounted(() => {
  useInfiniteScroll(table.value?.$el, () => {
    skip.value += 10;
  }, {
    distance: 200,
    canLoadMore: () => {
      return status.value !== 'pending';
    },
  });
});
</script>

<template>
  <PTable
    ref="table"
    :data="users"
    :columns="columns"
    :loading="status === 'pending' || status === 'idle'"
    sticky
    class="flex-1 h-80"
  />
</template>
```

> [!NOTE]
> 
> This example uses `useLazyFetch` with `server: false` to fetch data on the client without blocking the initial render. The loading state checks for both `pending` and `idle` status to display a loading indicator before and during the fetch. Additional pages are loaded as the user scrolls.

### With drag and drop

You can use the [`useSortable`](https://vueuse.org/integrations/useSortable/) composable from [`@vueuse/integrations`](https://vueuse.org/integrations/README.html) to enable drag and drop functionality on the Table. This integration wraps [Sortable.js](https://sortablejs.github.io/Sortable/) to provide a seamless drag and drop experience.

> [!NOTE]
> 
> Since the table ref doesn't expose the tbody element, add a unique class to it via the `:ui` prop to target it with `useSortable` (e.g. `:ui="{ tbody: 'my-table-tbody' }"`).

```vue [TableDragAndDropExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { useSortable } from '@vueuse/integrations/useSortable';

interface Payment {
  id: string;
  date: string;
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  email: 'james.anderson@example.com',
  amount: 594,
}, {
  id: '4599',
  date: '2024-03-11T10:10:00',
  email: 'mia.white@example.com',
  amount: 276,
}, {
  id: '4598',
  date: '2024-03-11T08:50:00',
  email: 'william.brown@example.com',
  amount: 315,
}, {
  id: '4597',
  date: '2024-03-10T19:45:00',
  email: 'emma.davis@example.com',
  amount: 529,
}]);

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

useSortable('.my-table-tbody', data, {
  animation: 150,
});
</script>

<template>
  <PTable
    ref="table"
    :data="data"
    :columns="columns"
    :ui="{
      tbody: 'my-table-tbody',
    }"
    class="flex-1"
  />
</template>
```

### With virtualization

Use the `virtualize` prop to enable virtualization for large datasets as a boolean or an object with options like `{ estimateSize: 65, overscan: 12 }`. You can also pass other [TanStack Virtual options](https://tanstack.com/virtual/latest/docs/api/virtualizer#optional-options) to customize the virtualization behavior. The `sticky` prop works in combination with `virtualize` to keep the header or footer visible while scrolling through large datasets.

> [!WARNING]
> 
> Row pinning is not supported when virtualization is enabled.

```vue [TableVirtualizeExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  status: 'paid' | 'failed' | 'refunded';
  email: string;
  amount: number;
}

const data = ref<Array<Payment>>(Array(1000).fill(0).map((_, i) => ({
  id: `4600-${i}`,
  date: '2024-03-11T15:30:00',
  status: 'paid',
  email: 'james.anderson@example.com',
  amount: 594,
})));

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];
</script>

<template>
  <PTable
    sticky
    virtualize
    :data="data"
    :columns="columns"
    class="flex-1 h-80"
  />
</template>
```

> [!NOTE]
> 
> A height constraint is required on the table for virtualization to work properly (e.g. `class="h-[400px]"`).

> [!TIP]
> 
> Rows are measured as they render, so `estimateSize` only needs to be a close guess. Rows can have variable heights, and you can pass a custom `measureElement` to control how each one is measured (the height of its expanded row is added to it).

### With external scroll element

Pass a `getScrollElement` function in the `virtualize` prop to virtualize against an ancestor scroll container instead of the table's own root. Set `scrollMargin` to the table's offset from the scroll element's start (e.g. the height of the content above it), so a header and the table body share a single scrollbar.

```vue [TableExternalScrollExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PBadge = resolveComponent('PBadge');

interface Payment {
  id: string;
  date: string;
  customer: string;
  email: string;
  method: string;
  status: 'paid' | 'failed' | 'refunded';
  amount: number;
}

const names = ['James Anderson', 'Mary Johnson', 'Robert Williams', 'Patricia Brown', 'Michael Davis'];
const methods = ['Visa', 'Mastercard', 'PayPal', 'Amex'];
const statuses = ['paid', 'failed', 'refunded'] as const;

const data = ref<Array<Payment>>(Array.from({ length: 1000 }, (_, i) => {
  const customer = names[i % names.length]!;
  return {
    id: `4600-${i}`,
    date: '2024-03-11T15:30:00',
    customer,
    email: `${customer.toLowerCase().replace(' ', '.')}@example.com`,
    method: methods[i % methods.length]!,
    status: statuses[i % statuses.length]!,
    amount: 100 + ((i * 37) % 900),
  };
}));

// The container owns the scroll; the table virtualizes against it so the header and rows share one scrollbar.
const container = useTemplateRef('container');
const title = useTemplateRef('title');
const getScrollElement = () => container.value;

// `scrollMargin` is the table's offset within the scroll element (border-box height of the title above it).
const { height: titleHeight } = useElementSize(title, undefined, { box: 'border-box' });

const columns: Array<TableColumn<Payment>> = [{
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => `#${row.getValue('id')}`,
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => new Date(row.getValue('date')).toLocaleString('en-US', {
    day: 'numeric',
    month: 'short',
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  }),
}, {
  accessorKey: 'customer',
  header: 'Customer',
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'method',
  header: 'Method',
}, {
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const color = ({
      paid: 'success' as const,
      failed: 'error' as const,
      refunded: 'neutral' as const,
    })[row.getValue('status') as string];

    return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'));
  },
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'EUR',
  }).format(Number.parseFloat(row.getValue('amount'))),
}];
</script>

<template>
  <div
    ref="container"
    class="h-96 w-full overflow-auto"
  >
    <div
      ref="title"
      class="p-6 bg-background-elevated/50 flex gap-4 items-end left-0 justify-between sticky z-10"
    >
      <div>
        <h2 class="text-2xl color-text-highlighted font-bold">
          Payments
        </h2>
        <p class="color-text-muted">
          The title stays put while the wide table scrolls both axes under one scrollbar.
        </p>
      </div>
      <PBadge
        color="neutral"
        variant="subtle"
        :label="`${data.length} rows`"
      />
    </div>

    <PTable
      sticky
      :data="data"
      :columns="columns"
      :virtualize="{ scrollMargin: titleHeight, getScrollElement }"
      :ui="{ base: 'min-w-[1200px]' }"
    />
  </div>
</template>
```

> [!NOTE]
> 
> In this mode the table root's `overflow` is `visible` and the external container owns scrolling on both axes, so give it `overflow-auto` (not just `overflow-y-auto`) to keep wide tables horizontally scrollable. A `sticky` header then anchors to that container.

### With tree data

You can use the `get-sub-rows` prop to display hierarchical (tree) data in the table.
For example, if your data objects have a `children` array, set `:get-sub-rows="row => row.children"` to enable expandable rows.

```vue [TableTreeDataExample.vue]
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui';
import { h, resolveComponent } from 'vue';

const PCheckbox = resolveComponent('PCheckbox');
const PButton = resolveComponent('PButton');

interface Payment {
  id: string;
  date: string;
  email: string;
  amount: number;
  children?: Array<Payment>;
}

const data = ref<Array<Payment>>([{
  id: '4600',
  date: '2024-03-11T15:30:00',
  email: 'james.anderson@example.com',
  amount: 594,
  children: [
    {
      id: '4599',
      date: '2024-03-11T10:10:00',
      email: 'mia.white@example.com',
      amount: 276,
    },
    {
      id: '4598',
      date: '2024-03-11T08:50:00',
      email: 'william.brown@example.com',
      amount: 315,
    },
    {
      id: '4597',
      date: '2024-03-10T19:45:00',
      email: 'emma.davis@example.com',
      amount: 529,
      children: [
        {
          id: '4592',
          date: '2024-03-09T18:45:00',
          email: 'benjamin.jackson@example.com',
          amount: 851,
        },
        {
          id: '4591',
          date: '2024-03-09T16:05:00',
          email: 'sophia.miller@example.com',
          amount: 762,
        },
        {
          id: '4590',
          date: '2024-03-09T14:20:00',
          email: 'noah.clark@example.com',
          amount: 573,
          children: [
            {
              id: '4596',
              date: '2024-03-10T15:55:00',
              email: 'ethan.harris@example.com',
              amount: 639,
            },
            {
              id: '4595',
              date: '2024-03-10T13:40:00',
              email: 'ava.thomas@example.com',
              amount: 428,
            },
          ],
        },
      ],
    },
  ],
}, {
  id: '4589',
  date: '2024-03-09T11:35:00',
  email: 'isabella.lee@example.com',
  amount: 389,
}]);

const columns: Array<TableColumn<Payment>> = [{
  id: 'select',
  header: ({ table }) => h(PCheckbox, {
    'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
    'aria-label': 'Select all',
  }),
  cell: ({ row }) => h(PCheckbox, {
    'modelValue': row.getIsSelected() ? true : row.getIsSomeSelected() ? 'indeterminate' : false,
    'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
    'aria-label': 'Select row',
  }),
}, {
  accessorKey: 'id',
  header: '#',
  cell: ({ row }) => {
    return h(
      'div',
      {
        style: {
          paddingInlineStart: `${row.depth}rem`,
        },
        class: 'flex items-center gap-2',
      },
      [
        h(PButton, {
          color: 'neutral',
          variant: 'outline',
          size: 'xs',
          icon: row.getIsExpanded() ? 'i-lucide-minus' : 'i-lucide-plus',
          class: !row.getCanExpand() && 'invisible',
          ui: {
            base: 'p-0 rounded-sm',
            leadingIcon: 'size-4',
          },
          onClick: row.getToggleExpandedHandler(),
        }),
        row.getValue('id') as string,
      ],
    );
  },
}, {
  accessorKey: 'date',
  header: 'Date',
  cell: ({ row }) => {
    return new Date(row.getValue('date')).toLocaleString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
    });
  },
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'amount',
  header: 'Amount',
  meta: {
    class: {
      th: 'text-right',
      td: 'text-right font-medium',
    },
  },
  cell: ({ row }) => {
    const amount = Number.parseFloat(row.getValue('amount'));
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'EUR',
    }).format(amount);
  },
}];

const expanded = ref({ 0: true });
</script>

<template>
  <PTable
    v-model:expanded="expanded"
    :data="data"
    :columns="columns"
    :get-sub-rows="row => row.children"
    class="flex-1"
    :ui="{
      base: 'border-separate border-spacing-0',
      tbody: '[&>tr]:last:[&>td]:border-b-0',
      tr: 'group',
      td: 'empty:p-0 group-has-[td:not(:empty)]:border-b border-border',
    }"
  />
</template>
```

### With slots

You can use slots to customize the header and data cells of the table.

Use the `#<column>-header` slot to customize the header of a column. You will have access to the `column`, `header` and `table` properties in the slot scope.

Use the `#<column>-cell` slot to customize the cell of a column. You will have access to the `cell`, `column`, `getValue`, `renderValue`, `row`, and `table` properties in the slot scope.

```vue [TableSlotsExample.vue]
<script setup lang="ts">
import type { DropdownMenuItem, TableColumn } from 'pohon-ui';
import { useClipboard } from '@vueuse/core';

interface User {
  id: number;
  name: string;
  position: string;
  email: string;
  role: string;
}

const toast = useToast();
const { copy } = useClipboard();

const data = ref<Array<User>>([{
  id: 1,
  name: 'Lindsay Walton',
  position: 'Front-end Developer',
  email: 'lindsay.walton@example.com',
  role: 'Member',
}, {
  id: 2,
  name: 'Courtney Henry',
  position: 'Designer',
  email: 'courtney.henry@example.com',
  role: 'Admin',
}, {
  id: 3,
  name: 'Tom Cook',
  position: 'Director of Product',
  email: 'tom.cook@example.com',
  role: 'Member',
}, {
  id: 4,
  name: 'Whitney Francis',
  position: 'Copywriter',
  email: 'whitney.francis@example.com',
  role: 'Admin',
}, {
  id: 5,
  name: 'Leonard Krasner',
  position: 'Senior Designer',
  email: 'leonard.krasner@example.com',
  role: 'Owner',
}, {
  id: 6,
  name: 'Floyd Miles',
  position: 'Principal Designer',
  email: 'floyd.miles@example.com',
  role: 'Member',
}]);

const columns: Array<TableColumn<User>> = [{
  accessorKey: 'id',
  header: 'ID',
}, {
  accessorKey: 'name',
  header: 'Name',
}, {
  accessorKey: 'email',
  header: 'Email',
}, {
  accessorKey: 'role',
  header: 'Role',
}, {
  id: 'action',
}];

function getDropdownActions(user: User): Array<Array<DropdownMenuItem>> {
  return [
    [{
      label: 'Copy user Id',
      icon: 'i-lucide-copy',
      onSelect: () => {
        copy(user.id.toString());

        toast.add({
          title: 'User ID copied to clipboard!',
          color: 'success',
          icon: 'i-lucide-circle-check',
        });
      },
    }],
    [{
      label: 'Edit',
      icon: 'i-lucide-edit',
    }, {
      label: 'Delete',
      icon: 'i-lucide-trash',
      color: 'error',
    }],
  ];
}
</script>

<template>
  <PTable
    :data="data"
    :columns="columns"
    class="flex-1"
  >
    <template #name-cell="{ row }">
      <div class="flex gap-3 items-center">
        <PAvatar
          :src="`https://i.pravatar.cc/120?img=${row.original.id}`"
          size="lg"
          loading="lazy"
          :alt="`${row.original.name} avatar`"
        />
        <div>
          <p class="color-text-highlighted font-medium">
            {{ row.original.name }}
          </p>
          <p>
            {{ row.original.position }}
          </p>
        </div>
      </div>
    </template>
    <template #action-cell="{ row }">
      <PDropdownMenu :items="getDropdownActions(row.original)">
        <PButton
          icon="i-lucide-ellipsis-vertical"
          color="neutral"
          variant="ghost"
          aria-label="Actions"
        />
      </PDropdownMenu>
    </template>
  </PTable>
</template>
```

## API

### Props

```ts
/**
 * Props for the Table component
 */
interface TableProps {
}
```

> [!NOTE]
> See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/table#attributes
> 
> This component also supports all native `<table>` HTML attributes.

### Slots

```ts
/**
 * Slots for the Table component
 */
interface TableSlots {
}
```

### Expose

You can access the typed component instance using [`useTemplateRef`](https://vuejs.org/api/composition-api-helpers.html#usetemplateref).

```vue
<script setup lang="ts">
const table = useTemplateRef('table');
</script>

<template>
  <PTable ref="table" />
</template>
```

This will give you access to the following:

| Name | Type |
| --- | --- |
| `tableRef` | `Ref<HTMLTableElement \| null>` |
| `tableApi` | [`Table`](https://tanstack.com/table/v8/docs/api/core/table#table-api) |

## Theme

```ts [app.config.ts]
export default defineAppConfig({
  ui: {
    table: {
      slots: {
        root: 'relative overflow-auto outline-primary/25 focus-visible:outline-3',
        base: 'min-w-full overflow-clip',
        caption: 'sr-only',
        thead: 'relative',
        tbody: 'isolate divide-border divide-y [&>tr[data-selectable=true]]:outline-primary/25 [&>tr[data-selectable=true]:focus-visible]:outline-3 [&>tr[data-selectable=true]:hover]:bg-background-elevated/50 [&>tr[data-selectable=true]]:-outline-offset-3',
        tfoot: 'relative [&>[data-slot=separator]]:z-2',
        tr: 'data-[selected=true]:bg-background-elevated/50',
        th: 'px-4 py-3.5 text-sm color-text-highlighted text-start font-600 [&:has([role=checkbox])]:pe-0',
        td: 'p-4 text-sm color-text-muted whitespace-nowrap [&:has([role=checkbox])]:pe-0',
        separator: 'bg-border-accented h-px w-full start-0 absolute z-1',
        empty: 'py-6 text-center text-sm color-text-muted',
        loading: 'py-6 text-center'
      },
      variants: {
        pinned: {
          true: {
            th: 'sticky bg-background/75 z-1',
            td: 'sticky bg-background/75 z-1'
          }
        },
        sticky: {
          true: {
            thead: 'sticky top-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1',
            tfoot: 'sticky bottom-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1'
          },
          header: {
            thead: 'sticky top-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1'
          },
          footer: {
            tfoot: 'sticky bottom-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1'
          }
        },
        loading: {
          true: {
            thead: 'after:h-px after:content-empty after:absolute after:z-1 motion-reduce:after:inset-x-0 motion-reduce:after:animate-pulse'
          }
        },
        externalScroll: {
          true: {
            root: 'overflow-visible'
          }
        },
        loadingAnimation: {
          carousel: '',
          'carousel-inverse': '',
          swing: '',
          elastic: ''
        },
        loadingColor: {
          primary: '',
          secondary: '',
          success: '',
          info: '',
          warning: '',
          error: '',
          neutral: ''
        }
      },
      compoundVariants: [
        {
          loading: true,
          loadingColor: 'primary',
          class: {
            thead: 'after:bg-primary'
          }
        },
        {
          loading: true,
          loadingColor: 'secondary',
          class: {
            thead: 'after:bg-secondary'
          }
        },
        {
          loading: true,
          loadingColor: 'success',
          class: {
            thead: 'after:bg-success'
          }
        },
        {
          loading: true,
          loadingColor: 'info',
          class: {
            thead: 'after:bg-info'
          }
        },
        {
          loading: true,
          loadingColor: 'warning',
          class: {
            thead: 'after:bg-warning'
          }
        },
        {
          loading: true,
          loadingColor: 'error',
          class: {
            thead: 'after:bg-error'
          }
        },
        {
          loading: true,
          loadingColor: 'neutral',
          class: {
            thead: 'after:bg-background-inverted'
          }
        },
        {
          loading: true,
          loadingAnimation: 'carousel',
          class: {
            thead: 'motion-safe:after:animate-carousel motion-safe:rtl:after:animate-carousel-rtl'
          }
        },
        {
          loading: true,
          loadingAnimation: 'carousel-inverse',
          class: {
            thead: 'motion-safe:after:animate-carousel-inverse motion-safe:rtl:after:animate-carousel-inverse-rtl'
          }
        },
        {
          loading: true,
          loadingAnimation: 'swing',
          class: {
            thead: 'motion-safe:after:animate-swing'
          }
        },
        {
          loading: true,
          loadingAnimation: 'elastic',
          class: {
            thead: 'motion-safe:after:animate-elastic'
          }
        }
      ],
      defaultVariants: {
        loadingColor: 'primary',
        loadingAnimation: 'carousel'
      }
    }
  }
})
```

## Changelog

See commit history for [component](https://github.com/vinicunca/pohon/commits/main/src/runtime/components/Table.vue) and [theme](https://github.com/vinicunca/pohon/commits/main/src/theme/table.ts).

---

- [TanStack Table](https://tanstack.com/table/v8)
- [GitHub](https://github.com/vinicunca/pohon/blob/main/src/runtime/components/Table.vue)


## Sitemap

See the full [sitemap](https://vinicunca.dev/sitemap.md) for all pages.
