{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "data-table",
  "title": "Data Table",
  "type": "registry:ui",
  "description": "Full-feature table with sorting, filtering, column pinning, pagination, row selection, and an opinionated header/toolbar. Built on TanStack Table — pass `columns` + `data` and configure as needed.",
  "categories": [
    "data"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/data-table/data-table.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport {\n  ArrowDown,\n  ArrowUp,\n  ArrowUpDown,\n  ChevronLeft,\n  ChevronRight,\n  ChevronsLeft,\n  ChevronsRight,\n  Download,\n  Search,\n  SlidersHorizontal,\n} from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { icon } from '../../lib/icon'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nexport interface DataTableColumn<T = any> {\n  id?: string\n  key?: string\n  accessorKey?: string\n  header?: string | ((col: DataTableColumn<T>) => unknown)\n  label?: string\n  sortable?: boolean\n  cell?: (info: { row: T; value: any }) => unknown\n  headerClass?: string\n  cellClass?: string\n  hidden?: boolean\n}\n\nexport type DataTableDensity = 'compact' | 'cozy' | 'comfortable'\n\n/**\n * <uip-data-table> — Data-driven table with sorting, search filtering,\n * pagination, row selection, density toggle, and CSV export.\n */\nexport class UipDataTable extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        width: 100%;\n      }\n    `,\n  ]\n\n  static properties = {\n    data: { type: Array, attribute: false },\n    columns: { type: Array, attribute: false },\n    filterColumn: { type: String, attribute: 'filter-column' },\n    filterPlaceholder: { type: String, attribute: 'filter-placeholder' },\n    enableSearch: {\n      type: Boolean,\n      attribute: 'enable-search',\n      converter: { fromAttribute: (v: string | null) => v !== null && v !== 'false' },\n    },\n    enablePagination: {\n      type: Boolean,\n      attribute: 'enable-pagination',\n      converter: { fromAttribute: (v: string | null) => v !== null && v !== 'false' },\n    },\n    enableExport: { type: Boolean, attribute: 'enable-export' },\n    enableDensityToggle: { type: Boolean, attribute: 'enable-density-toggle' },\n    density: { type: String },\n    pageSize: { type: Number, attribute: 'page-size' },\n    selectable: { type: Boolean },\n    search: { state: true },\n    pageIndex: { state: true },\n    sortBy: { state: true },\n    sortOrder: { state: true },\n    selectedIds: { state: true },\n  }\n\n  data: any[] = []\n  columns: DataTableColumn[] = []\n  filterColumn?: string\n  filterPlaceholder = 'Filter...'\n  enableSearch = true\n  enablePagination = true\n  enableExport = false\n  enableDensityToggle = false\n  density: DataTableDensity = 'cozy'\n  pageSize = 10\n  selectable = false\n\n  search = ''\n  pageIndex = 0\n  sortBy: string | null = null\n  sortOrder: 'asc' | 'desc' = 'asc'\n  selectedIds = new Set<string>()\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'data-table')\n  }\n\n  private getColumnKey(col: DataTableColumn): string {\n    return col.id ?? col.accessorKey ?? col.key ?? ''\n  }\n\n  private getColumnLabel(col: DataTableColumn): string {\n    if (typeof col.header === 'string') return col.header\n    return col.label ?? col.accessorKey ?? col.key ?? ''\n  }\n\n  private toggleSort(key: string) {\n    if (this.sortBy === key) {\n      if (this.sortOrder === 'asc') {\n        this.sortOrder = 'desc'\n      } else {\n        this.sortBy = null\n        this.sortOrder = 'asc'\n      }\n    } else {\n      this.sortBy = key\n      this.sortOrder = 'asc'\n    }\n    this.dispatchEvent(\n      new CustomEvent('sort-change', {\n        detail: { sortBy: this.sortBy, sortOrder: this.sortOrder },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n    this.requestUpdate()\n  }\n\n  private toggleSelectRow(id: string) {\n    const next = new Set(this.selectedIds)\n    if (next.has(id)) next.delete(id)\n    else next.add(id)\n    this.selectedIds = next\n    this.dispatchEvent(\n      new CustomEvent('selection-change', {\n        detail: { selectedIds: Array.from(next) },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n    this.requestUpdate()\n  }\n\n  private toggleSelectAll(items: any[]) {\n    const allSelected = items.length > 0 && items.every((r) => this.selectedIds.has(r.id))\n    const next = new Set<string>()\n    if (!allSelected) {\n      items.forEach((r) => next.add(r.id))\n    }\n    this.selectedIds = next\n    this.dispatchEvent(\n      new CustomEvent('selection-change', {\n        detail: { selectedIds: Array.from(next) },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n    this.requestUpdate()\n  }\n\n  private exportCsv() {\n    if (!this.data.length) return\n    const headers = this.columns.map((c) => this.getColumnLabel(c)).join(',')\n    const rows = this.data.map((row) =>\n      this.columns\n        .map((c) => {\n          const val = row[this.getColumnKey(c)]\n          return typeof val === 'string' && val.includes(',') ? `\"${val}\"` : String(val ?? '')\n        })\n        .join(','),\n    )\n    const csv = [headers, ...rows].join('\\n')\n    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })\n    const link = document.createElement('a')\n    link.href = URL.createObjectURL(blob)\n    link.download = 'export.csv'\n    link.click()\n  }\n\n  render() {\n    // Filter\n    let filtered = this.data\n    if (this.search) {\n      const q = this.search.toLowerCase()\n      if (this.filterColumn) {\n        filtered = filtered.filter((row) =>\n          String(row[this.filterColumn!] ?? '')\n            .toLowerCase()\n            .includes(q),\n        )\n      } else {\n        filtered = filtered.filter((row) =>\n          Object.values(row).some((val) =>\n            String(val ?? '')\n              .toLowerCase()\n              .includes(q),\n          ),\n        )\n      }\n    }\n\n    // Sort\n    if (this.sortBy) {\n      const key = this.sortBy\n      const order = this.sortOrder === 'asc' ? 1 : -1\n      filtered = [...filtered].sort((a, b) => {\n        const valA = a[key]\n        const valB = b[key]\n        if (valA == null) return 1\n        if (valB == null) return -1\n        if (typeof valA === 'number' && typeof valB === 'number') {\n          return (valA - valB) * order\n        }\n        return String(valA).localeCompare(String(valB)) * order\n      })\n    }\n\n    // Paginate\n    const totalItems = filtered.length\n    const totalPages = this.enablePagination\n      ? Math.max(1, Math.ceil(totalItems / this.pageSize))\n      : 1\n    const currentPage = Math.min(this.pageIndex, totalPages - 1)\n    const paged = this.enablePagination\n      ? filtered.slice(currentPage * this.pageSize, (currentPage + 1) * this.pageSize)\n      : filtered\n\n    const allChecked = paged.length > 0 && paged.every((r) => this.selectedIds.has(r.id))\n    const someChecked = paged.some((r) => this.selectedIds.has(r.id)) && !allChecked\n\n    const densityPadding =\n      this.density === 'compact' ? 'py-1.5' : this.density === 'comfortable' ? 'py-4' : 'py-2.5'\n\n    return html`\n      <div part=\"base\" class=\"space-y-4\">\n        <!-- Toolbar -->\n        ${this.enableSearch || this.enableExport || this.enableDensityToggle\n          ? html`\n              <div class=\"flex items-center justify-between gap-2\">\n                ${this.enableSearch\n                  ? html`\n                      <div class=\"relative max-w-sm flex-1\">\n                        ${icon(\n                          Search,\n                          'search',\n                          'text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2',\n                        )}\n                        <input\n                          type=\"text\"\n                          class=\"border-input bg-background focus:border-ring focus:ring-ring/30 h-9 w-full rounded-md pr-3 pl-8 text-sm outline-none focus:ring-2\"\n                          placeholder=${this.filterPlaceholder}\n                          .value=${this.search}\n                          @input=${(e: Event) => {\n                            this.search = (e.target as HTMLInputElement).value\n                            this.pageIndex = 0\n                            this.dispatchEvent(\n                              new CustomEvent('search-change', {\n                                detail: { search: this.search },\n                                bubbles: true,\n                                composed: true,\n                              }),\n                            )\n                          }}\n                        />\n                      </div>\n                    `\n                  : html`<div></div>`}\n\n                <div class=\"flex items-center gap-2\">\n                  ${this.enableDensityToggle\n                    ? html`\n                        <button\n                          type=\"button\"\n                          class=\"border-input hover:bg-accent hover:text-accent-foreground inline-flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors\"\n                          @click=${() => {\n                            this.density =\n                              this.density === 'compact'\n                                ? 'cozy'\n                                : this.density === 'cozy'\n                                  ? 'comfortable'\n                                  : 'compact'\n                          }}\n                        >\n                          ${icon(SlidersHorizontal, 'sliders-horizontal', 'size-3.5')}\n                          <span class=\"capitalize\">${this.density}</span>\n                        </button>\n                      `\n                    : nothing}\n                  ${this.enableExport\n                    ? html`\n                        <button\n                          type=\"button\"\n                          class=\"border-input hover:bg-accent hover:text-accent-foreground inline-flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors\"\n                          @click=${this.exportCsv}\n                        >\n                          ${icon(Download, 'download', 'size-3.5')} Export\n                        </button>\n                      `\n                    : nothing}\n                </div>\n              </div>\n            `\n          : nothing}\n\n        <!-- Table Container -->\n        <div class=\"border-border bg-card text-card-foreground overflow-hidden rounded-lg border shadow-xs\">\n          <div class=\"overflow-x-auto\">\n            <table class=\"w-full text-left text-sm border-collapse\">\n              <thead class=\"border-border bg-muted/50 text-muted-foreground border-b text-xs font-medium\">\n                <tr>\n                  ${this.selectable\n                    ? html`\n                        <th class=\"w-10 px-3 py-3 text-center\">\n                          <input\n                            type=\"checkbox\"\n                            class=\"rounded border-input text-primary focus:ring-ring size-4\"\n                            .checked=${allChecked}\n                            .indeterminate=${someChecked}\n                            @change=${() => this.toggleSelectAll(paged)}\n                          />\n                        </th>\n                      `\n                    : nothing}\n                  ${this.columns.map((col) => {\n                    const key = this.getColumnKey(col)\n                    const label = this.getColumnLabel(col)\n                    const isSorted = this.sortBy === key\n                    const isSortable = col.sortable ?? true\n\n                    return html`\n                      <th\n                        class=${cn(\n                          'px-4 py-3 font-semibold select-none',\n                          isSortable && 'cursor-pointer hover:text-foreground',\n                          col.headerClass,\n                        )}\n                        @click=${() => (isSortable ? this.toggleSort(key) : null)}\n                      >\n                        <div class=\"flex items-center gap-1.5\">\n                          <span>${typeof col.header === 'function' ? col.header(col) : label}</span>\n                          ${isSortable\n                            ? isSorted\n                              ? this.sortOrder === 'asc'\n                                ? icon(ArrowUp, 'arrow-up', 'size-3.5 text-primary')\n                                : icon(ArrowDown, 'arrow-down', 'size-3.5 text-primary')\n                              : icon(ArrowUpDown, 'arrow-up-down', 'size-3.5 opacity-40')\n                            : nothing}\n                        </div>\n                      </th>\n                    `\n                  })}\n                </tr>\n              </thead>\n              <tbody class=\"divide-border divide-y\">\n                ${paged.length === 0\n                  ? html`\n                      <tr>\n                        <td\n                          colspan=${this.columns.length + (this.selectable ? 1 : 0)}\n                          class=\"text-muted-foreground px-4 py-8 text-center\"\n                        >\n                          No results.\n                        </td>\n                      </tr>\n                    `\n                  : paged.map((row) => {\n                      const isSelected = this.selectedIds.has(row.id)\n                      return html`\n                        <tr\n                          class=${cn(\n                            'hover:bg-muted/50 transition-colors',\n                            isSelected && 'bg-primary/5',\n                          )}\n                        >\n                          ${this.selectable\n                            ? html`\n                                <td class=\"w-10 px-3 py-2 text-center\">\n                                  <input\n                                    type=\"checkbox\"\n                                    class=\"rounded border-input text-primary focus:ring-ring size-4\"\n                                    .checked=${isSelected}\n                                    @change=${() => this.toggleSelectRow(row.id)}\n                                  />\n                                </td>\n                              `\n                            : nothing}\n                          ${this.columns.map((col) => {\n                            const key = this.getColumnKey(col)\n                            const val = row[key]\n                            return html`\n                              <td class=${cn('px-4 text-foreground', densityPadding, col.cellClass)}>\n                                ${col.cell ? col.cell({ row, value: val }) : val}\n                              </td>\n                            `\n                          })}\n                        </tr>\n                      `\n                    })}\n              </tbody>\n            </table>\n          </div>\n        </div>\n\n        <!-- Pagination Footer -->\n        ${this.enablePagination\n          ? html`\n              <div class=\"flex items-center justify-between text-xs text-muted-foreground\">\n                <div>\n                  ${this.selectable && this.selectedIds.size > 0\n                    ? html`<span>${this.selectedIds.size} of ${totalItems} row(s) selected.</span>`\n                    : html`<span>Total ${totalItems} row${totalItems === 1 ? '' : 's'}</span>`}\n                </div>\n\n                <div class=\"flex items-center gap-4\">\n                  <div class=\"flex items-center gap-1 font-medium\">\n                    <span>Page</span>\n                    <span class=\"text-foreground\">${currentPage + 1}</span>\n                    <span>of</span>\n                    <span class=\"text-foreground\">${totalPages}</span>\n                  </div>\n\n                  <div class=\"flex items-center gap-1\">\n                    <button\n                      type=\"button\"\n                      class=\"border-input hover:bg-accent hover:text-accent-foreground inline-flex size-8 items-center justify-center rounded-md border transition-colors disabled:opacity-40\"\n                      ?disabled=${currentPage === 0}\n                      @click=${() => {\n                        this.pageIndex = 0\n                        this.dispatchEvent(\n                          new CustomEvent('page-change', { detail: { page: 0 }, bubbles: true }),\n                        )\n                      }}\n                    >\n                      ${icon(ChevronsLeft, 'chevrons-left', 'size-4')}\n                    </button>\n                    <button\n                      type=\"button\"\n                      class=\"border-input hover:bg-accent hover:text-accent-foreground inline-flex size-8 items-center justify-center rounded-md border transition-colors disabled:opacity-40\"\n                      ?disabled=${currentPage === 0}\n                      @click=${() => {\n                        this.pageIndex = Math.max(0, currentPage - 1)\n                        this.dispatchEvent(\n                          new CustomEvent('page-change', { detail: { page: this.pageIndex }, bubbles: true }),\n                        )\n                      }}\n                    >\n                      ${icon(ChevronLeft, 'chevron-left', 'size-4')}\n                    </button>\n                    <button\n                      type=\"button\"\n                      class=\"border-input hover:bg-accent hover:text-accent-foreground inline-flex size-8 items-center justify-center rounded-md border transition-colors disabled:opacity-40\"\n                      ?disabled=${currentPage >= totalPages - 1}\n                      @click=${() => {\n                        this.pageIndex = Math.min(totalPages - 1, currentPage + 1)\n                        this.dispatchEvent(\n                          new CustomEvent('page-change', { detail: { page: this.pageIndex }, bubbles: true }),\n                        )\n                      }}\n                    >\n                      ${icon(ChevronRight, 'chevron-right', 'size-4')}\n                    </button>\n                    <button\n                      type=\"button\"\n                      class=\"border-input hover:bg-accent hover:text-accent-foreground inline-flex size-8 items-center justify-center rounded-md border transition-colors disabled:opacity-40\"\n                      ?disabled=${currentPage >= totalPages - 1}\n                      @click=${() => {\n                        this.pageIndex = totalPages - 1\n                        this.dispatchEvent(\n                          new CustomEvent('page-change', { detail: { page: this.pageIndex }, bubbles: true }),\n                        )\n                      }}\n                    >\n                      ${icon(ChevronsRight, 'chevrons-right', 'size-4')}\n                    </button>\n                  </div>\n                </div>\n              </div>\n            `\n          : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-data-table') || customElements.define('uip-data-table', UipDataTable)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-data-table': UipDataTable\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/data-table/data-table.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
