Docs
CodeRabbit
Cloudflare
Railway
Clerk
WorkOS
AG Grid
Netlify
OpenRouter
SerpAPI
Prisma
Electric
Sentry
Unkey
CodeRabbit
Cloudflare
Railway
Clerk
WorkOS
AG Grid
Netlify
OpenRouter
SerpAPI
Prisma
Electric
Sentry
Unkey
Table API Reference
Column API Reference
Row API Reference
Cell API Reference
Header API Reference
Features API Reference
Static Functions API Reference

Lit Example: Kitchen Sink

import { customElement, state } from 'lit/decorators.js'
import { LitElement, html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { styleMap } from 'lit/directives/style-map.js'
import { faker } from '@faker-js/faker'
import {
  FlexRender,
  TableController,
  aggregationFns,
  createColumnHelper,
  createExpandedRowModel,
  createFacetedMinMaxValues,
  createFacetedRowModel,
  createFacetedUniqueValues,
  createFilteredRowModel,
  createGroupedRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFns,
  sortFns,
  stockFeatures,
} from '@tanstack/lit-table'
import { compareItems, rankItem } from '@tanstack/match-sorter-utils'
import { makeData } from './makeData'
import type { RankingInfo } from '@tanstack/match-sorter-utils'
import type { Person } from './makeData'
import type {
  Cell,
  CellData,
  Column,
  ColumnDef,
  FilterFn,
  Header,
  LitTable,
  Row,
  RowData,
  SortFn,
  TableFeatures,
} from '@tanstack/lit-table'

declare module '@tanstack/lit-table' {
  interface ColumnMeta<
    TFeatures extends TableFeatures,
    TData extends RowData,
    TValue extends CellData = CellData,
  > {
    filterVariant?: 'text' | 'range' | 'select'
  }
  interface FilterFns {
    fuzzy: FilterFn<typeof stockFeatures, Person>
  }
  interface FilterMeta {
    itemRank?: RankingInfo
  }
}

const fuzzyFilter: FilterFn<typeof stockFeatures, Person> = (
  row,
  columnId,
  value,
  addMeta,
) => {
  const itemRank = rankItem(row.getValue(columnId), value)
  addMeta?.({ itemRank })
  return itemRank.passed
}

const fuzzySort: SortFn<typeof stockFeatures, Person> = (
  rowA,
  rowB,
  columnId,
) => {
  let dir = 0
  if (rowA.columnFiltersMeta[columnId]) {
    dir = compareItems(
      rowA.columnFiltersMeta[columnId].itemRank!,
      rowB.columnFiltersMeta[columnId].itemRank!,
    )
  }
  return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir
}

const sortStatusFn: SortFn<typeof stockFeatures, Person> = (rowA, rowB) => {
  const statusOrder = ['single', 'complicated', 'relationship']
  return (
    statusOrder.indexOf(rowA.original.status) -
    statusOrder.indexOf(rowB.original.status)
  )
}

const columnHelper = createColumnHelper<typeof stockFeatures, Person>()

const columns: Array<ColumnDef<typeof stockFeatures, Person>> =
  columnHelper.columns([
    columnHelper.display({
      id: 'select',
      size: 80,
      minSize: 80,
      maxSize: 80,
      enableSorting: false,
      enableGrouping: false,
      enableHiding: false,
      enableResizing: false,
      header: '',
      cell: '',
    }),
    columnHelper.accessor('firstName', {
      id: 'firstName',
      size: 200,
      header: 'First Name',
      filterFn: 'fuzzy',
      sortFn: fuzzySort,
      meta: { filterVariant: 'text' },
      getGroupingValue: (row) => `${row.firstName} ${row.lastName}`,
    }),
    columnHelper.accessor((row) => row.lastName, {
      id: 'lastName',
      size: 180,
      header: 'Last Name',
      meta: { filterVariant: 'text' },
    }),
    columnHelper.accessor('age', {
      id: 'age',
      size: 200,
      header: 'Age',
      meta: { filterVariant: 'range' },
      aggregationFn: 'median',
      aggregatedCell: ({ getValue }) =>
        Math.round(getValue<number>() * 100) / 100,
    }),
    columnHelper.accessor('visits', {
      id: 'visits',
      size: 200,
      header: 'Visits',
      meta: { filterVariant: 'range' },
      aggregationFn: 'sum',
      aggregatedCell: ({ getValue }) => getValue<number>().toLocaleString(),
    }),
    columnHelper.accessor('status', {
      id: 'status',
      size: 200,
      header: 'Status',
      sortFn: sortStatusFn,
      meta: { filterVariant: 'select' },
    }),
    columnHelper.accessor('progress', {
      id: 'progress',
      size: 200,
      header: 'Profile Progress',
      meta: { filterVariant: 'range' },
      aggregationFn: 'mean',
      cell: ({ getValue }) => `${Math.round(getValue<number>() * 100) / 100}%`,
      aggregatedCell: ({ getValue }) =>
        `${Math.round(getValue<number>() * 100) / 100}%`,
    }),
  ])

@customElement('lit-table-example')
class LitTableExample extends LitElement {
  @state()
  private _data: Array<Person> = makeData(1_000)

  private tableController = new TableController<typeof stockFeatures, Person>(
    this,
  )

  private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()

  protected render() {
    const table = this.tableController.table(
      {
        features: stockFeatures,
        rowModels: {
          expandedRowModel: createExpandedRowModel(),
          filteredRowModel: createFilteredRowModel({
            ...filterFns,
            fuzzy: fuzzyFilter,
          }),
          facetedRowModel: createFacetedRowModel(),
          facetedMinMaxValues: createFacetedMinMaxValues(),
          facetedUniqueValues: createFacetedUniqueValues(),
          groupedRowModel: createGroupedRowModel(aggregationFns),
          paginatedRowModel: createPaginatedRowModel(),
          sortedRowModel: createSortedRowModel(sortFns),
        },
        columns,
        data: this._data,
        getSubRows: (row) => row.subRows,
        globalFilterFn: 'fuzzy',
        columnResizeMode: 'onChange',
        defaultColumn: { minSize: 200, maxSize: 800 },
        initialState: {
          columnOrder: columns.map((c) => c.id!),
          columnPinning: { left: ['select'], right: [] },
          pagination: { pageIndex: 0, pageSize: 20 },
        },
        keepPinnedRows: true,
        debugTable: true,
      },
      (state) => state,
    )

    return html`
      <div class="demo-root">
        <h1>Kitchen Sink - All Features</h1>
        <div class="toolbar">
          <div class="toolbar-row">
            <input
              class="global-filter-input"
              placeholder="Fuzzy search all columns..."
              .value=${String(table.state.globalFilter ?? '')}
              @input=${(event: InputEvent) =>
                this.debounceSet('global', () =>
                  table.setGlobalFilter(
                    (event.target as HTMLInputElement).value,
                  ),
                )}
            />
          </div>
          <div class="toolbar-row">
            <button
              class="demo-button demo-button-sm"
              @click=${() => (this._data = makeData(1_000))}
            >
              Flat 1k
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => (this._data = makeData(100, 5, 3))}
            >
              Nested 100x5x3
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => (this._data = makeData(10_000))}
            >
              Stress 10k (flat)
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => (this._data = makeData(100_000))}
            >
              Stress 100k (flat)
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => table.reset()}
            >
              Reset Table
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => this.shuffleColumns(table)}
            >
              Shuffle Columns
            </button>
            <span class="nowrap">
              ${table.getSelectedRowModel().flatRows.length.toLocaleString()} of
              ${table.getCoreRowModel().flatRows.length.toLocaleString()}
              selected
            </span>
          </div>
          <details class="column-toggle-panel">
            <summary class="column-toggle-panel-header">
              Column visibility
            </summary>
            <div class="column-toggle-row">
              <label>
                <input
                  type="checkbox"
                  ?checked=${table.getIsAllColumnsVisible()}
                  @change=${table.getToggleAllColumnsVisibilityHandler()}
                />
                Toggle All
              </label>
            </div>
            ${table.getAllLeafColumns().map(
              (column) => html`
                <div class="column-toggle-row">
                  <label>
                    <input
                      type="checkbox"
                      ?checked=${column.getIsVisible()}
                      ?disabled=${!column.getCanHide()}
                      @change=${column.getToggleVisibilityHandler()}
                    />
                    ${column.id}
                  </label>
                </div>
              `,
            )}
          </details>
        </div>

        <div class="table-container">
          <table style=${this.tableStyle(table)}>
            <thead>
              ${repeat(
                table.getHeaderGroups(),
                (headerGroup) => headerGroup.id,
                (headerGroup) => html`
                  <tr>
                    ${headerGroup.headers.map(
                      (header) => html`
                        <th
                          colspan=${header.colSpan}
                          style=${styleMap(this.headerStyle(header))}
                        >
                          ${header.isPlaceholder
                            ? null
                            : this.renderHeader(table, header)}
                        </th>
                      `,
                    )}
                  </tr>
                `,
              )}
            </thead>
            <tbody>
              ${table
                .getTopRows()
                .map((row) => this.renderPinnedRow(table, row))}
              ${table.getCenterRows().map(
                (row) => html`
                  <tr>
                    ${row
                      .getVisibleCells()
                      .map((cell) => this.renderCell(table, cell))}
                  </tr>
                `,
              )}
              ${table
                .getBottomRows()
                .map((row) => this.renderPinnedRow(table, row))}
            </tbody>
          </table>
        </div>
        <div class="spacer-sm"></div>
        <div class="controls">
          <button
            class="demo-button demo-button-sm"
            @click=${() => table.setPageIndex(0)}
            ?disabled=${!table.getCanPreviousPage()}
          >
            &lt;&lt;
          </button>
          <button
            class="demo-button demo-button-sm"
            @click=${() => table.previousPage()}
            ?disabled=${!table.getCanPreviousPage()}
          >
            &lt;
          </button>
          <button
            class="demo-button demo-button-sm"
            @click=${() => table.nextPage()}
            ?disabled=${!table.getCanNextPage()}
          >
            &gt;
          </button>
          <button
            class="demo-button demo-button-sm"
            @click=${() => table.setPageIndex(table.getPageCount() - 1)}
            ?disabled=${!table.getCanNextPage()}
          >
            &gt;&gt;
          </button>
          <span class="inline-controls">
            <div>Page</div>
            <strong>
              ${(table.state.pagination.pageIndex + 1).toLocaleString()} of
              ${table.getPageCount().toLocaleString()}
            </strong>
          </span>
          <span class="inline-controls">
            | Go to page:
            <input
              type="number"
              min="1"
              max=${table.getPageCount()}
              .value=${String(table.state.pagination.pageIndex + 1)}
              @input=${(event: InputEvent) => {
                const page = (event.target as HTMLInputElement).value
                  ? Number((event.target as HTMLInputElement).value) - 1
                  : 0
                table.setPageIndex(page)
              }}
              class="page-size-input"
            />
          </span>
          <select
            .value=${String(table.state.pagination.pageSize)}
            @change=${(event: Event) =>
              table.setPageSize(
                Number((event.target as HTMLSelectElement).value),
              )}
          >
            ${[10, 20, 30, 50, 100].map(
              (pageSize) =>
                html`<option value=${pageSize}>Show ${pageSize}</option>`,
            )}
          </select>
        </div>
        <div class="spacer-sm"></div>
        <div class="nowrap">
          ${table.getRowModel().rows.length.toLocaleString()} rows on this page
          (${table.getFilteredRowModel().rows.length.toLocaleString()} filtered
          of ${table.getCoreRowModel().rows.length.toLocaleString()} total)
        </div>
        <div class="spacer-md"></div>
        <details>
          <summary>Table state (live)</summary>
          <pre class="state-dump">${JSON.stringify(table.state, null, 2)}</pre>
        </details>
      </div>
      ${this.styles()}
    `
  }

  private renderHeader(
    table: LitTable<typeof stockFeatures, Person>,
    header: Header<typeof stockFeatures, Person, unknown>,
  ) {
    const column = header.column
    return html`
      <div class="header-row">
        <div style="flex: 1; min-width: 0">
          <div class="header-controls">
            ${column.getCanPin()
              ? html`
                  <span class="pin-actions">
                    ${column.getIsPinned() !== 'left'
                      ? html`<button
                          class="pin-button"
                          @click=${() => column.pin('left')}
                        >
                          &lt;
                        </button>`
                      : null}
                    ${column.getIsPinned()
                      ? html`<button
                          class="pin-button"
                          @click=${() => column.pin(false)}
                        >
                          x
                        </button>`
                      : null}
                    ${column.getIsPinned() !== 'right'
                      ? html`<button
                          class="pin-button"
                          @click=${() => column.pin('right')}
                        >
                          &gt;
                        </button>`
                      : null}
                  </span>
                `
              : null}
            ${column.getCanGroup()
              ? html`<button
                  class="pin-button"
                  @click=${column.getToggleGroupingHandler()}
                >
                  ${column.getIsGrouped()
                    ? `Stop (${column.getGroupedIndex()})`
                    : 'Group'}
                </button>`
              : null}
          </div>
          ${column.id === 'select'
            ? html`<input
                type="checkbox"
                ?checked=${table.getIsAllPageRowsSelected()}
                .indeterminate=${table.getIsSomePageRowsSelected()}
                @change=${table.getToggleAllPageRowsSelectedHandler()}
              />`
            : column.getCanSort()
              ? html`<span
                  class="sortable-header"
                  @click=${column.getToggleSortingHandler()}
                >
                  ${FlexRender({ header })}
                  ${column.getIsSorted() === 'asc'
                    ? ' â–²'
                    : column.getIsSorted() === 'desc'
                      ? ' â–¼'
                      : ''}
                </span>`
              : FlexRender({ header })}
          ${column.getCanFilter() ? this.renderFilter(column) : null}
        </div>
      </div>
      ${column.getCanResize()
        ? html`<div
            @dblclick=${() => column.resetSize()}
            @mousedown=${header.getResizeHandler()}
            @touchstart=${header.getResizeHandler()}
            class="resizer ${column.getIsResizing() ? 'isResizing' : ''}"
          ></div>`
        : null}
    `
  }

  private renderFilter(column: Column<typeof stockFeatures, Person>) {
    const filterVariant = column.columnDef.meta?.filterVariant
    const columnFilterValue = column.getFilterValue()
    const minMaxValues =
      filterVariant === 'range' ? column.getFacetedMinMaxValues() : undefined

    if (filterVariant === 'range') {
      return html`
        <div class="filter-row">
          <input
            type="number"
            class="filter-input"
            min=${Number(minMaxValues?.[0] ?? '')}
            max=${Number(minMaxValues?.[1] ?? '')}
            .value=${String(
              (columnFilterValue as [number, number] | undefined)?.[0] ?? '',
            )}
            placeholder=${`Min${minMaxValues?.[0] !== undefined ? ` (${minMaxValues[0]})` : ''}`}
            @input=${(event: InputEvent) =>
              this.debounceSet(`${column.id}-min`, () =>
                column.setFilterValue((old: [number, number] | undefined) => [
                  (event.target as HTMLInputElement).value,
                  old?.[1],
                ]),
              )}
          />
          <input
            type="number"
            class="filter-input"
            min=${Number(minMaxValues?.[0] ?? '')}
            max=${Number(minMaxValues?.[1] ?? '')}
            .value=${String(
              (columnFilterValue as [number, number] | undefined)?.[1] ?? '',
            )}
            placeholder=${`Max${minMaxValues?.[1] !== undefined ? ` (${minMaxValues[1]})` : ''}`}
            @input=${(event: InputEvent) =>
              this.debounceSet(`${column.id}-max`, () =>
                column.setFilterValue((old: [number, number] | undefined) => [
                  old?.[0],
                  (event.target as HTMLInputElement).value,
                ]),
              )}
          />
        </div>
      `
    }

    const sortedUniqueValues = Array.from(
      column.getFacetedUniqueValues().keys(),
    )
      .sort()
      .slice(0, 5000)

    if (filterVariant === 'select') {
      return html`
        <select
          class="filter-select"
          .value=${String(columnFilterValue ?? '')}
          @change=${(event: Event) =>
            column.setFilterValue((event.target as HTMLSelectElement).value)}
        >
          <option value="">All</option>
          ${sortedUniqueValues.map(
            (value) =>
              html`<option value=${String(value)}>${String(value)}</option>`,
          )}
        </select>
      `
    }

    return html`
      <datalist id=${column.id + 'list'}>
        ${sortedUniqueValues.map(
          (value) => html`<option value=${String(value)}></option>`,
        )}
      </datalist>
      <input
        type="text"
        class="filter-select"
        list=${column.id + 'list'}
        .value=${String(columnFilterValue ?? '')}
        placeholder=${`Search (${column.getFacetedUniqueValues().size})`}
        @input=${(event: InputEvent) =>
          this.debounceSet(column.id, () =>
            column.setFilterValue((event.target as HTMLInputElement).value),
          )}
      />
    `
  }

  private renderPinnedRow(
    table: LitTable<typeof stockFeatures, Person>,
    row: Row<typeof stockFeatures, Person>,
  ) {
    return html`
      <tr class="pinned-row" style=${styleMap(this.pinnedRowStyle(table, row))}>
        ${row.getVisibleCells().map((cell) => this.renderCell(table, cell))}
      </tr>
    `
  }

  private renderCell(
    table: LitTable<typeof stockFeatures, Person>,
    cell: Cell<typeof stockFeatures, Person, unknown>,
  ) {
    return html`
      <td
        style=${styleMap(this.cellStyle(cell))}
        class=${this.cellClass(table, cell) ?? ''}
      >
        ${cell.column.id === 'select'
          ? html`<div class="column-toggle-row">
              <input
                type="checkbox"
                ?checked=${cell.row.getIsSelected()}
                ?disabled=${!cell.row.getCanSelect()}
                .indeterminate=${cell.row.getIsSomeSelected()}
                @change=${cell.row.getToggleSelectedHandler()}
              />
              <button
                class="pin-button"
                @click=${() =>
                  cell.row.pin(
                    cell.row.getIsPinned() === 'top' ? false : 'top',
                  )}
              >
                ${cell.row.getIsPinned() === 'top' ? 'Pinned' : 'Pin'}
              </button>
            </div>`
          : cell.column.id === 'firstName'
            ? html`<div style="padding-left: ${cell.row.depth * 1.5}rem">
                ${cell.row.getCanExpand()
                  ? html`<button
                      @click=${cell.row.getToggleExpandedHandler()}
                      style="cursor: pointer; margin-right: 0.25rem"
                    >
                      ${cell.row.getIsExpanded() ? 'v' : '>'}
                    </button>`
                  : html`<span style="margin-right: 0.25rem">-</span>`}
                ${FlexRender({ cell })}
              </div>`
            : cell.getIsGrouped()
              ? html`<button
                  @click=${cell.row.getToggleExpandedHandler()}
                  style="cursor: ${cell.row.getCanExpand()
                    ? 'pointer'
                    : 'normal'}"
                >
                  ${cell.row.getIsExpanded() ? 'v' : '>'}
                  ${FlexRender({ cell })}
                  (${cell.row.subRows.length.toLocaleString()})
                </button>`
              : FlexRender({ cell })}
      </td>
    `
  }

  private debounceSet(key: string, setValue: () => void) {
    clearTimeout(this.debounceTimers.get(key))
    this.debounceTimers.set(
      key,
      setTimeout(() => {
        setValue()
        this.debounceTimers.delete(key)
      }, 300),
    )
  }

  private shuffleColumns(table: LitTable<typeof stockFeatures, Person>) {
    table.setColumnOrder(
      faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
    )
  }

  private getCommonPinningStyle(column: Column<typeof stockFeatures, Person>) {
    const isPinned = column.getIsPinned()
    const isLastLeftPinnedColumn =
      isPinned === 'left' && column.getIsLastColumn('left')
    const isFirstRightPinnedColumn =
      isPinned === 'right' && column.getIsFirstColumn('right')

    return {
      boxShadow: isLastLeftPinnedColumn
        ? '-4px 0 4px -4px gray inset'
        : isFirstRightPinnedColumn
          ? '4px 0 4px -4px gray inset'
          : undefined,
      left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
      right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
      opacity: isPinned ? '0.97' : '1',
      position: isPinned ? 'sticky' : 'relative',
      zIndex: isPinned ? '1' : '0',
    }
  }

  private headerStyle(header: Header<typeof stockFeatures, Person, unknown>) {
    return {
      ...this.getCommonPinningStyle(header.column),
      whiteSpace: 'nowrap',
      width: `calc(var(--header-${header.id}-size) * 1px)`,
    }
  }

  private cellStyle(cell: Cell<typeof stockFeatures, Person, unknown>) {
    return {
      ...this.getCommonPinningStyle(cell.column),
      width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
    }
  }

  private pinnedRowStyle(
    table: LitTable<typeof stockFeatures, Person>,
    row: Row<typeof stockFeatures, Person>,
  ) {
    const bottomRows = table.getBottomRows()
    return {
      position: 'sticky',
      top:
        row.getIsPinned() === 'top'
          ? `${row.getPinnedIndex() * 32 + 48}px`
          : undefined,
      bottom:
        row.getIsPinned() === 'bottom'
          ? `${(bottomRows.length - 1 - row.getPinnedIndex()) * 32}px`
          : undefined,
      zIndex: '1',
    }
  }

  private cellClass(
    table: LitTable<typeof stockFeatures, Person>,
    cell: Cell<typeof stockFeatures, Person, unknown>,
  ) {
    const groupingActive = table.state.grouping.length > 0
    const hasAggregation = !!cell.column.columnDef.aggregationFn
    return !groupingActive
      ? undefined
      : cell.getIsGrouped()
        ? 'cell-grouped'
        : hasAggregation && cell.getIsAggregated()
          ? 'cell-aggregated'
          : cell.getIsPlaceholder()
            ? 'cell-placeholder'
            : undefined
  }

  private tableStyle(table: LitTable<typeof stockFeatures, Person>) {
    const styles = [`width: ${table.getTotalSize()}px`]
    for (const header of table.getFlatHeaders()) {
      styles.push(`--header-${header.id}-size: ${header.getSize()}`)
      styles.push(`--col-${header.column.id}-size: ${header.column.getSize()}`)
    }
    return styles.join('; ')
  }

  private styles() {
    return html`<style>
      * {
        box-sizing: border-box;
        font-family: sans-serif;
        font-size: 14px;
      }

      .table-container {
        overflow-x: auto;
        width: 100%;
        max-width: 1400px;
        border: 1px solid lightgray;
      }

      table {
        border-collapse: collapse;
        border-spacing: 0;
        table-layout: fixed;
      }

      th {
        background-color: #f3f4f6;
        border-bottom: 1px solid lightgray;
        border-right: 1px solid lightgray;
        font-weight: bold;
        padding: 2px 4px;
        position: relative;
        text-align: left;
        vertical-align: top;
      }

      td {
        background-color: white;
        border-bottom: 1px solid #eee;
        padding: 2px 4px;
        vertical-align: middle;
      }

      .resizer {
        position: absolute;
        top: 0;
        right: 0;
        height: 100%;
        width: 5px;
        background: rgba(0, 0, 0, 0.5);
        cursor: col-resize;
        user-select: none;
        touch-action: none;
        z-index: 2;
      }

      .resizer.isResizing {
        background: blue;
      }

      .demo-root {
        padding: 0.5rem;
      }

      .spacer-sm {
        height: 0.5rem;
      }

      .spacer-md {
        height: 1rem;
      }

      .controls,
      .button-row,
      .inline-controls,
      .pin-actions,
      .filter-row,
      .header-row,
      .header-controls,
      .toolbar-row {
        display: flex;
        align-items: center;
      }

      .header-row,
      .inline-controls,
      .pin-actions,
      .filter-row,
      .header-controls,
      .controls,
      .toolbar-row {
        gap: 0.5rem;
      }

      .toolbar {
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
        padding: 0.5rem;
        border: 1px solid lightgray;
        margin-bottom: 0.5rem;
      }

      .toolbar-row {
        flex-wrap: wrap;
      }

      .column-toggle-panel {
        display: inline-block;
        border: 1px solid #000;
        border-radius: 0.25rem;
        box-shadow: 0 1px 3px rgb(0 0 0 / 0.2);
        padding: 0.25rem;
      }

      .column-toggle-panel-header {
        border-bottom: 1px solid #000;
        padding: 0 0.25rem;
        margin-bottom: 0.25rem;
      }

      .column-toggle-row {
        padding: 0 0.25rem;
      }

      .demo-button,
      .pin-button,
      .filter-input,
      .filter-select,
      .page-size-input,
      .global-filter-input {
        border: 1px solid currentColor;
        border-radius: 0.25rem;
      }

      .demo-button {
        padding: 0.5rem;
      }

      .demo-button-sm {
        padding: 0.25rem;
      }

      .pin-button {
        padding: 0 0.25rem;
        cursor: pointer;
        background: transparent;
        font-size: 0.875rem;
      }

      .nowrap {
        white-space: nowrap;
      }

      .page-size-input {
        width: 4rem;
        padding: 0.25rem;
      }

      .filter-input {
        width: 6rem;
      }

      .filter-select {
        width: 9rem;
      }

      .global-filter-input {
        padding: 0.5rem;
        width: 100%;
        max-width: 24rem;
      }

      .sortable-header {
        cursor: pointer;
        user-select: none;
      }

      .cell-grouped {
        background: #0aff0082;
      }

      .cell-aggregated {
        background: #ffa50078;
      }

      .cell-placeholder {
        background: #ff000042;
      }

      .state-dump {
        max-height: 24rem;
        overflow: auto;
      }
    </style>`
  }
}
import { customElement, state } from 'lit/decorators.js'
import { LitElement, html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { styleMap } from 'lit/directives/style-map.js'
import { faker } from '@faker-js/faker'
import {
  FlexRender,
  TableController,
  aggregationFns,
  createColumnHelper,
  createExpandedRowModel,
  createFacetedMinMaxValues,
  createFacetedRowModel,
  createFacetedUniqueValues,
  createFilteredRowModel,
  createGroupedRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFns,
  sortFns,
  stockFeatures,
} from '@tanstack/lit-table'
import { compareItems, rankItem } from '@tanstack/match-sorter-utils'
import { makeData } from './makeData'
import type { RankingInfo } from '@tanstack/match-sorter-utils'
import type { Person } from './makeData'
import type {
  Cell,
  CellData,
  Column,
  ColumnDef,
  FilterFn,
  Header,
  LitTable,
  Row,
  RowData,
  SortFn,
  TableFeatures,
} from '@tanstack/lit-table'

declare module '@tanstack/lit-table' {
  interface ColumnMeta<
    TFeatures extends TableFeatures,
    TData extends RowData,
    TValue extends CellData = CellData,
  > {
    filterVariant?: 'text' | 'range' | 'select'
  }
  interface FilterFns {
    fuzzy: FilterFn<typeof stockFeatures, Person>
  }
  interface FilterMeta {
    itemRank?: RankingInfo
  }
}

const fuzzyFilter: FilterFn<typeof stockFeatures, Person> = (
  row,
  columnId,
  value,
  addMeta,
) => {
  const itemRank = rankItem(row.getValue(columnId), value)
  addMeta?.({ itemRank })
  return itemRank.passed
}

const fuzzySort: SortFn<typeof stockFeatures, Person> = (
  rowA,
  rowB,
  columnId,
) => {
  let dir = 0
  if (rowA.columnFiltersMeta[columnId]) {
    dir = compareItems(
      rowA.columnFiltersMeta[columnId].itemRank!,
      rowB.columnFiltersMeta[columnId].itemRank!,
    )
  }
  return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir
}

const sortStatusFn: SortFn<typeof stockFeatures, Person> = (rowA, rowB) => {
  const statusOrder = ['single', 'complicated', 'relationship']
  return (
    statusOrder.indexOf(rowA.original.status) -
    statusOrder.indexOf(rowB.original.status)
  )
}

const columnHelper = createColumnHelper<typeof stockFeatures, Person>()

const columns: Array<ColumnDef<typeof stockFeatures, Person>> =
  columnHelper.columns([
    columnHelper.display({
      id: 'select',
      size: 80,
      minSize: 80,
      maxSize: 80,
      enableSorting: false,
      enableGrouping: false,
      enableHiding: false,
      enableResizing: false,
      header: '',
      cell: '',
    }),
    columnHelper.accessor('firstName', {
      id: 'firstName',
      size: 200,
      header: 'First Name',
      filterFn: 'fuzzy',
      sortFn: fuzzySort,
      meta: { filterVariant: 'text' },
      getGroupingValue: (row) => `${row.firstName} ${row.lastName}`,
    }),
    columnHelper.accessor((row) => row.lastName, {
      id: 'lastName',
      size: 180,
      header: 'Last Name',
      meta: { filterVariant: 'text' },
    }),
    columnHelper.accessor('age', {
      id: 'age',
      size: 200,
      header: 'Age',
      meta: { filterVariant: 'range' },
      aggregationFn: 'median',
      aggregatedCell: ({ getValue }) =>
        Math.round(getValue<number>() * 100) / 100,
    }),
    columnHelper.accessor('visits', {
      id: 'visits',
      size: 200,
      header: 'Visits',
      meta: { filterVariant: 'range' },
      aggregationFn: 'sum',
      aggregatedCell: ({ getValue }) => getValue<number>().toLocaleString(),
    }),
    columnHelper.accessor('status', {
      id: 'status',
      size: 200,
      header: 'Status',
      sortFn: sortStatusFn,
      meta: { filterVariant: 'select' },
    }),
    columnHelper.accessor('progress', {
      id: 'progress',
      size: 200,
      header: 'Profile Progress',
      meta: { filterVariant: 'range' },
      aggregationFn: 'mean',
      cell: ({ getValue }) => `${Math.round(getValue<number>() * 100) / 100}%`,
      aggregatedCell: ({ getValue }) =>
        `${Math.round(getValue<number>() * 100) / 100}%`,
    }),
  ])

@customElement('lit-table-example')
class LitTableExample extends LitElement {
  @state()
  private _data: Array<Person> = makeData(1_000)

  private tableController = new TableController<typeof stockFeatures, Person>(
    this,
  )

  private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()

  protected render() {
    const table = this.tableController.table(
      {
        features: stockFeatures,
        rowModels: {
          expandedRowModel: createExpandedRowModel(),
          filteredRowModel: createFilteredRowModel({
            ...filterFns,
            fuzzy: fuzzyFilter,
          }),
          facetedRowModel: createFacetedRowModel(),
          facetedMinMaxValues: createFacetedMinMaxValues(),
          facetedUniqueValues: createFacetedUniqueValues(),
          groupedRowModel: createGroupedRowModel(aggregationFns),
          paginatedRowModel: createPaginatedRowModel(),
          sortedRowModel: createSortedRowModel(sortFns),
        },
        columns,
        data: this._data,
        getSubRows: (row) => row.subRows,
        globalFilterFn: 'fuzzy',
        columnResizeMode: 'onChange',
        defaultColumn: { minSize: 200, maxSize: 800 },
        initialState: {
          columnOrder: columns.map((c) => c.id!),
          columnPinning: { left: ['select'], right: [] },
          pagination: { pageIndex: 0, pageSize: 20 },
        },
        keepPinnedRows: true,
        debugTable: true,
      },
      (state) => state,
    )

    return html`
      <div class="demo-root">
        <h1>Kitchen Sink - All Features</h1>
        <div class="toolbar">
          <div class="toolbar-row">
            <input
              class="global-filter-input"
              placeholder="Fuzzy search all columns..."
              .value=${String(table.state.globalFilter ?? '')}
              @input=${(event: InputEvent) =>
                this.debounceSet('global', () =>
                  table.setGlobalFilter(
                    (event.target as HTMLInputElement).value,
                  ),
                )}
            />
          </div>
          <div class="toolbar-row">
            <button
              class="demo-button demo-button-sm"
              @click=${() => (this._data = makeData(1_000))}
            >
              Flat 1k
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => (this._data = makeData(100, 5, 3))}
            >
              Nested 100x5x3
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => (this._data = makeData(10_000))}
            >
              Stress 10k (flat)
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => (this._data = makeData(100_000))}
            >
              Stress 100k (flat)
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => table.reset()}
            >
              Reset Table
            </button>
            <button
              class="demo-button demo-button-sm"
              @click=${() => this.shuffleColumns(table)}
            >
              Shuffle Columns
            </button>
            <span class="nowrap">
              ${table.getSelectedRowModel().flatRows.length.toLocaleString()} of
              ${table.getCoreRowModel().flatRows.length.toLocaleString()}
              selected
            </span>
          </div>
          <details class="column-toggle-panel">
            <summary class="column-toggle-panel-header">
              Column visibility
            </summary>
            <div class="column-toggle-row">
              <label>
                <input
                  type="checkbox"
                  ?checked=${table.getIsAllColumnsVisible()}
                  @change=${table.getToggleAllColumnsVisibilityHandler()}
                />
                Toggle All
              </label>
            </div>
            ${table.getAllLeafColumns().map(
              (column) => html`
                <div class="column-toggle-row">
                  <label>
                    <input
                      type="checkbox"
                      ?checked=${column.getIsVisible()}
                      ?disabled=${!column.getCanHide()}
                      @change=${column.getToggleVisibilityHandler()}
                    />
                    ${column.id}
                  </label>
                </div>
              `,
            )}
          </details>
        </div>

        <div class="table-container">
          <table style=${this.tableStyle(table)}>
            <thead>
              ${repeat(
                table.getHeaderGroups(),
                (headerGroup) => headerGroup.id,
                (headerGroup) => html`
                  <tr>
                    ${headerGroup.headers.map(
                      (header) => html`
                        <th
                          colspan=${header.colSpan}
                          style=${styleMap(this.headerStyle(header))}
                        >
                          ${header.isPlaceholder
                            ? null
                            : this.renderHeader(table, header)}
                        </th>
                      `,
                    )}
                  </tr>
                `,
              )}
            </thead>
            <tbody>
              ${table
                .getTopRows()
                .map((row) => this.renderPinnedRow(table, row))}
              ${table.getCenterRows().map(
                (row) => html`
                  <tr>
                    ${row
                      .getVisibleCells()
                      .map((cell) => this.renderCell(table, cell))}
                  </tr>
                `,
              )}
              ${table
                .getBottomRows()
                .map((row) => this.renderPinnedRow(table, row))}
            </tbody>
          </table>
        </div>
        <div class="spacer-sm"></div>
        <div class="controls">
          <button
            class="demo-button demo-button-sm"
            @click=${() => table.setPageIndex(0)}
            ?disabled=${!table.getCanPreviousPage()}
          >
            &lt;&lt;
          </button>
          <button
            class="demo-button demo-button-sm"
            @click=${() => table.previousPage()}
            ?disabled=${!table.getCanPreviousPage()}
          >
            &lt;
          </button>
          <button
            class="demo-button demo-button-sm"
            @click=${() => table.nextPage()}
            ?disabled=${!table.getCanNextPage()}
          >
            &gt;
          </button>
          <button
            class="demo-button demo-button-sm"
            @click=${() => table.setPageIndex(table.getPageCount() - 1)}
            ?disabled=${!table.getCanNextPage()}
          >
            &gt;&gt;
          </button>
          <span class="inline-controls">
            <div>Page</div>
            <strong>
              ${(table.state.pagination.pageIndex + 1).toLocaleString()} of
              ${table.getPageCount().toLocaleString()}
            </strong>
          </span>
          <span class="inline-controls">
            | Go to page:
            <input
              type="number"
              min="1"
              max=${table.getPageCount()}
              .value=${String(table.state.pagination.pageIndex + 1)}
              @input=${(event: InputEvent) => {
                const page = (event.target as HTMLInputElement).value
                  ? Number((event.target as HTMLInputElement).value) - 1
                  : 0
                table.setPageIndex(page)
              }}
              class="page-size-input"
            />
          </span>
          <select
            .value=${String(table.state.pagination.pageSize)}
            @change=${(event: Event) =>
              table.setPageSize(
                Number((event.target as HTMLSelectElement).value),
              )}
          >
            ${[10, 20, 30, 50, 100].map(
              (pageSize) =>
                html`<option value=${pageSize}>Show ${pageSize}</option>`,
            )}
          </select>
        </div>
        <div class="spacer-sm"></div>
        <div class="nowrap">
          ${table.getRowModel().rows.length.toLocaleString()} rows on this page
          (${table.getFilteredRowModel().rows.length.toLocaleString()} filtered
          of ${table.getCoreRowModel().rows.length.toLocaleString()} total)
        </div>
        <div class="spacer-md"></div>
        <details>
          <summary>Table state (live)</summary>
          <pre class="state-dump">${JSON.stringify(table.state, null, 2)}</pre>
        </details>
      </div>
      ${this.styles()}
    `
  }

  private renderHeader(
    table: LitTable<typeof stockFeatures, Person>,
    header: Header<typeof stockFeatures, Person, unknown>,
  ) {
    const column = header.column
    return html`
      <div class="header-row">
        <div style="flex: 1; min-width: 0">
          <div class="header-controls">
            ${column.getCanPin()
              ? html`
                  <span class="pin-actions">
                    ${column.getIsPinned() !== 'left'
                      ? html`<button
                          class="pin-button"
                          @click=${() => column.pin('left')}
                        >
                          &lt;
                        </button>`
                      : null}
                    ${column.getIsPinned()
                      ? html`<button
                          class="pin-button"
                          @click=${() => column.pin(false)}
                        >
                          x
                        </button>`
                      : null}
                    ${column.getIsPinned() !== 'right'
                      ? html`<button
                          class="pin-button"
                          @click=${() => column.pin('right')}
                        >
                          &gt;
                        </button>`
                      : null}
                  </span>
                `
              : null}
            ${column.getCanGroup()
              ? html`<button
                  class="pin-button"
                  @click=${column.getToggleGroupingHandler()}
                >
                  ${column.getIsGrouped()
                    ? `Stop (${column.getGroupedIndex()})`
                    : 'Group'}
                </button>`
              : null}
          </div>
          ${column.id === 'select'
            ? html`<input
                type="checkbox"
                ?checked=${table.getIsAllPageRowsSelected()}
                .indeterminate=${table.getIsSomePageRowsSelected()}
                @change=${table.getToggleAllPageRowsSelectedHandler()}
              />`
            : column.getCanSort()
              ? html`<span
                  class="sortable-header"
                  @click=${column.getToggleSortingHandler()}
                >
                  ${FlexRender({ header })}
                  ${column.getIsSorted() === 'asc'
                    ? ' â–²'
                    : column.getIsSorted() === 'desc'
                      ? ' â–¼'
                      : ''}
                </span>`
              : FlexRender({ header })}
          ${column.getCanFilter() ? this.renderFilter(column) : null}
        </div>
      </div>
      ${column.getCanResize()
        ? html`<div
            @dblclick=${() => column.resetSize()}
            @mousedown=${header.getResizeHandler()}
            @touchstart=${header.getResizeHandler()}
            class="resizer ${column.getIsResizing() ? 'isResizing' : ''}"
          ></div>`
        : null}
    `
  }

  private renderFilter(column: Column<typeof stockFeatures, Person>) {
    const filterVariant = column.columnDef.meta?.filterVariant
    const columnFilterValue = column.getFilterValue()
    const minMaxValues =
      filterVariant === 'range' ? column.getFacetedMinMaxValues() : undefined

    if (filterVariant === 'range') {
      return html`
        <div class="filter-row">
          <input
            type="number"
            class="filter-input"
            min=${Number(minMaxValues?.[0] ?? '')}
            max=${Number(minMaxValues?.[1] ?? '')}
            .value=${String(
              (columnFilterValue as [number, number] | undefined)?.[0] ?? '',
            )}
            placeholder=${`Min${minMaxValues?.[0] !== undefined ? ` (${minMaxValues[0]})` : ''}`}
            @input=${(event: InputEvent) =>
              this.debounceSet(`${column.id}-min`, () =>
                column.setFilterValue((old: [number, number] | undefined) => [
                  (event.target as HTMLInputElement).value,
                  old?.[1],
                ]),
              )}
          />
          <input
            type="number"
            class="filter-input"
            min=${Number(minMaxValues?.[0] ?? '')}
            max=${Number(minMaxValues?.[1] ?? '')}
            .value=${String(
              (columnFilterValue as [number, number] | undefined)?.[1] ?? '',
            )}
            placeholder=${`Max${minMaxValues?.[1] !== undefined ? ` (${minMaxValues[1]})` : ''}`}
            @input=${(event: InputEvent) =>
              this.debounceSet(`${column.id}-max`, () =>
                column.setFilterValue((old: [number, number] | undefined) => [
                  old?.[0],
                  (event.target as HTMLInputElement).value,
                ]),
              )}
          />
        </div>
      `
    }

    const sortedUniqueValues = Array.from(
      column.getFacetedUniqueValues().keys(),
    )
      .sort()
      .slice(0, 5000)

    if (filterVariant === 'select') {
      return html`
        <select
          class="filter-select"
          .value=${String(columnFilterValue ?? '')}
          @change=${(event: Event) =>
            column.setFilterValue((event.target as HTMLSelectElement).value)}
        >
          <option value="">All</option>
          ${sortedUniqueValues.map(
            (value) =>
              html`<option value=${String(value)}>${String(value)}</option>`,
          )}
        </select>
      `
    }

    return html`
      <datalist id=${column.id + 'list'}>
        ${sortedUniqueValues.map(
          (value) => html`<option value=${String(value)}></option>`,
        )}
      </datalist>
      <input
        type="text"
        class="filter-select"
        list=${column.id + 'list'}
        .value=${String(columnFilterValue ?? '')}
        placeholder=${`Search (${column.getFacetedUniqueValues().size})`}
        @input=${(event: InputEvent) =>
          this.debounceSet(column.id, () =>
            column.setFilterValue((event.target as HTMLInputElement).value),
          )}
      />
    `
  }

  private renderPinnedRow(
    table: LitTable<typeof stockFeatures, Person>,
    row: Row<typeof stockFeatures, Person>,
  ) {
    return html`
      <tr class="pinned-row" style=${styleMap(this.pinnedRowStyle(table, row))}>
        ${row.getVisibleCells().map((cell) => this.renderCell(table, cell))}
      </tr>
    `
  }

  private renderCell(
    table: LitTable<typeof stockFeatures, Person>,
    cell: Cell<typeof stockFeatures, Person, unknown>,
  ) {
    return html`
      <td
        style=${styleMap(this.cellStyle(cell))}
        class=${this.cellClass(table, cell) ?? ''}
      >
        ${cell.column.id === 'select'
          ? html`<div class="column-toggle-row">
              <input
                type="checkbox"
                ?checked=${cell.row.getIsSelected()}
                ?disabled=${!cell.row.getCanSelect()}
                .indeterminate=${cell.row.getIsSomeSelected()}
                @change=${cell.row.getToggleSelectedHandler()}
              />
              <button
                class="pin-button"
                @click=${() =>
                  cell.row.pin(
                    cell.row.getIsPinned() === 'top' ? false : 'top',
                  )}
              >
                ${cell.row.getIsPinned() === 'top' ? 'Pinned' : 'Pin'}
              </button>
            </div>`
          : cell.column.id === 'firstName'
            ? html`<div style="padding-left: ${cell.row.depth * 1.5}rem">
                ${cell.row.getCanExpand()
                  ? html`<button
                      @click=${cell.row.getToggleExpandedHandler()}
                      style="cursor: pointer; margin-right: 0.25rem"
                    >
                      ${cell.row.getIsExpanded() ? 'v' : '>'}
                    </button>`
                  : html`<span style="margin-right: 0.25rem">-</span>`}
                ${FlexRender({ cell })}
              </div>`
            : cell.getIsGrouped()
              ? html`<button
                  @click=${cell.row.getToggleExpandedHandler()}
                  style="cursor: ${cell.row.getCanExpand()
                    ? 'pointer'
                    : 'normal'}"
                >
                  ${cell.row.getIsExpanded() ? 'v' : '>'}
                  ${FlexRender({ cell })}
                  (${cell.row.subRows.length.toLocaleString()})
                </button>`
              : FlexRender({ cell })}
      </td>
    `
  }

  private debounceSet(key: string, setValue: () => void) {
    clearTimeout(this.debounceTimers.get(key))
    this.debounceTimers.set(
      key,
      setTimeout(() => {
        setValue()
        this.debounceTimers.delete(key)
      }, 300),
    )
  }

  private shuffleColumns(table: LitTable<typeof stockFeatures, Person>) {
    table.setColumnOrder(
      faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
    )
  }

  private getCommonPinningStyle(column: Column<typeof stockFeatures, Person>) {
    const isPinned = column.getIsPinned()
    const isLastLeftPinnedColumn =
      isPinned === 'left' && column.getIsLastColumn('left')
    const isFirstRightPinnedColumn =
      isPinned === 'right' && column.getIsFirstColumn('right')

    return {
      boxShadow: isLastLeftPinnedColumn
        ? '-4px 0 4px -4px gray inset'
        : isFirstRightPinnedColumn
          ? '4px 0 4px -4px gray inset'
          : undefined,
      left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
      right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
      opacity: isPinned ? '0.97' : '1',
      position: isPinned ? 'sticky' : 'relative',
      zIndex: isPinned ? '1' : '0',
    }
  }

  private headerStyle(header: Header<typeof stockFeatures, Person, unknown>) {
    return {
      ...this.getCommonPinningStyle(header.column),
      whiteSpace: 'nowrap',
      width: `calc(var(--header-${header.id}-size) * 1px)`,
    }
  }

  private cellStyle(cell: Cell<typeof stockFeatures, Person, unknown>) {
    return {
      ...this.getCommonPinningStyle(cell.column),
      width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
    }
  }

  private pinnedRowStyle(
    table: LitTable<typeof stockFeatures, Person>,
    row: Row<typeof stockFeatures, Person>,
  ) {
    const bottomRows = table.getBottomRows()
    return {
      position: 'sticky',
      top:
        row.getIsPinned() === 'top'
          ? `${row.getPinnedIndex() * 32 + 48}px`
          : undefined,
      bottom:
        row.getIsPinned() === 'bottom'
          ? `${(bottomRows.length - 1 - row.getPinnedIndex()) * 32}px`
          : undefined,
      zIndex: '1',
    }
  }

  private cellClass(
    table: LitTable<typeof stockFeatures, Person>,
    cell: Cell<typeof stockFeatures, Person, unknown>,
  ) {
    const groupingActive = table.state.grouping.length > 0
    const hasAggregation = !!cell.column.columnDef.aggregationFn
    return !groupingActive
      ? undefined
      : cell.getIsGrouped()
        ? 'cell-grouped'
        : hasAggregation && cell.getIsAggregated()
          ? 'cell-aggregated'
          : cell.getIsPlaceholder()
            ? 'cell-placeholder'
            : undefined
  }

  private tableStyle(table: LitTable<typeof stockFeatures, Person>) {
    const styles = [`width: ${table.getTotalSize()}px`]
    for (const header of table.getFlatHeaders()) {
      styles.push(`--header-${header.id}-size: ${header.getSize()}`)
      styles.push(`--col-${header.column.id}-size: ${header.column.getSize()}`)
    }
    return styles.join('; ')
  }

  private styles() {
    return html`<style>
      * {
        box-sizing: border-box;
        font-family: sans-serif;
        font-size: 14px;
      }

      .table-container {
        overflow-x: auto;
        width: 100%;
        max-width: 1400px;
        border: 1px solid lightgray;
      }

      table {
        border-collapse: collapse;
        border-spacing: 0;
        table-layout: fixed;
      }

      th {
        background-color: #f3f4f6;
        border-bottom: 1px solid lightgray;
        border-right: 1px solid lightgray;
        font-weight: bold;
        padding: 2px 4px;
        position: relative;
        text-align: left;
        vertical-align: top;
      }

      td {
        background-color: white;
        border-bottom: 1px solid #eee;
        padding: 2px 4px;
        vertical-align: middle;
      }

      .resizer {
        position: absolute;
        top: 0;
        right: 0;
        height: 100%;
        width: 5px;
        background: rgba(0, 0, 0, 0.5);
        cursor: col-resize;
        user-select: none;
        touch-action: none;
        z-index: 2;
      }

      .resizer.isResizing {
        background: blue;
      }

      .demo-root {
        padding: 0.5rem;
      }

      .spacer-sm {
        height: 0.5rem;
      }

      .spacer-md {
        height: 1rem;
      }

      .controls,
      .button-row,
      .inline-controls,
      .pin-actions,
      .filter-row,
      .header-row,
      .header-controls,
      .toolbar-row {
        display: flex;
        align-items: center;
      }

      .header-row,
      .inline-controls,
      .pin-actions,
      .filter-row,
      .header-controls,
      .controls,
      .toolbar-row {
        gap: 0.5rem;
      }

      .toolbar {
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
        padding: 0.5rem;
        border: 1px solid lightgray;
        margin-bottom: 0.5rem;
      }

      .toolbar-row {
        flex-wrap: wrap;
      }

      .column-toggle-panel {
        display: inline-block;
        border: 1px solid #000;
        border-radius: 0.25rem;
        box-shadow: 0 1px 3px rgb(0 0 0 / 0.2);
        padding: 0.25rem;
      }

      .column-toggle-panel-header {
        border-bottom: 1px solid #000;
        padding: 0 0.25rem;
        margin-bottom: 0.25rem;
      }

      .column-toggle-row {
        padding: 0 0.25rem;
      }

      .demo-button,
      .pin-button,
      .filter-input,
      .filter-select,
      .page-size-input,
      .global-filter-input {
        border: 1px solid currentColor;
        border-radius: 0.25rem;
      }

      .demo-button {
        padding: 0.5rem;
      }

      .demo-button-sm {
        padding: 0.25rem;
      }

      .pin-button {
        padding: 0 0.25rem;
        cursor: pointer;
        background: transparent;
        font-size: 0.875rem;
      }

      .nowrap {
        white-space: nowrap;
      }

      .page-size-input {
        width: 4rem;
        padding: 0.25rem;
      }

      .filter-input {
        width: 6rem;
      }

      .filter-select {
        width: 9rem;
      }

      .global-filter-input {
        padding: 0.5rem;
        width: 100%;
        max-width: 24rem;
      }

      .sortable-header {
        cursor: pointer;
        user-select: none;
      }

      .cell-grouped {
        background: #0aff0082;
      }

      .cell-aggregated {
        background: #ffa50078;
      }

      .cell-placeholder {
        background: #ff000042;
      }

      .state-dump {
        max-height: 24rem;
        overflow: auto;
      }
    </style>`
  }
}