Skip to content
bahtuiv0.18.8

<baht-table>

Server-side paginated table with a ServiceNow-style column picker. It never slices or fetches; you feed the current page.

Ledger

Toolbar count, selection, sortable headers, numeric column, tinted pills and a row-actions button. Every control emits; the host fetches and sorts. Search and filters live outside the table (e.g. <baht-filter> above it).

<baht-table row-key="id" total="143" selectable="true" row-actions="true"></baht-table>

<script>
  const t = document.querySelector('baht-table');
  const tone = { food: 'success', transport: 'warning', fun: 'danger', salary: 'info', other: 'neutral' };
  t.renderers = {
  category: (v) => Object.assign(document.createElement('baht-badge'), { tone: tone[v] ?? 'neutral', label: String(v), dot: false }),
  account: (v) => Object.assign(document.createElement('baht-badge'), { tone: 'outline', label: String(v) }),
  amount: (v) => {
    const el = document.createElement('span');
    el.className = v < 0 ? 'baht-text--danger' : 'baht-text--success';
    el.textContent = (v < 0 ? '-' : '') + '฿' + Math.abs(v).toLocaleString();
    return el;
  },
  };
  t.addEventListener('baht-selection-change', (e) => (t.selected = e.detail.selected));
  t.addEventListener('baht-sort-change', (e) => (t.sort = e.detail));
</script>

Basic

Listen to baht-page-change and fetch that page yourself.

<baht-table row-key="id"></baht-table>

<script>
  const el = document.querySelector('baht-table');
  el.columns = {
    "name": "Name",
    "role": "Role",
    "status": "Status"
  };
  el.items = [
    {
      "id": 1,
      "name": "Somchai",
      "role": "Admin",
      "status": "active"
    },
    {
      "id": 2,
      "name": "Malee",
      "role": "Editor",
      "status": "active"
    },
    {
      "id": 3,
      "name": "Prasert",
      "role": "Viewer",
      "status": "invited"
    }
  ];
  el.page = 1;
  el.totalPages = 4;
</script>

Dot-walking into reference columns

Open Columns, expand Requested by, and add Manager, Name. The header reads "Requested by › Manager › Name" and the cell resolves the path. OK emits baht-columns-change; feed detail.visibleColumns back to remember the layout.

<baht-table row-key="number"></baht-table>

<script>
  const el = document.querySelector('baht-table');
  el.columns = {
    "number": "Number",
    "state": "State",
    "requester": {
      "label": "Requested by",
      "fields": {
        "name": "Name",
        "department": "Department",
        "manager": {
          "label": "Manager",
          "fields": {
            "name": "Name",
            "title": "Title"
          }
        }
      }
    }
  };
  el.visibleColumns = [
    "number",
    "requester.name",
    "requester.department",
    "state"
  ];
  el.items = [
    {
      "number": "REQ-1041",
      "state": "approval",
      "requester": {
        "name": "Alice Tanaka",
        "department": "IT Operations",
        "manager": {
          "name": "Nok Srisuk",
          "title": "Head of IT"
        }
      }
    },
    {
      "number": "REQ-1042",
      "state": "open",
      "requester": {
        "name": "Chai Boonmee",
        "department": "Finance",
        "manager": {
          "name": "Prasert Wong",
          "title": "CFO"
        }
      }
    }
  ];
  el.showIndex = false;
</script>

Cursor pagination

No total count: the footer is Prev/Next and baht-cursor-change tells you which way to fetch.

<baht-table cursor-mode="true" has-next="true" row-key="id"></baht-table>

<script>
  const el = document.querySelector('baht-table');
  el.columns = {
    "id": "Id",
    "name": "Name"
  };
  el.items = [
    {
      "id": "c_01",
      "name": "Somchai"
    },
    {
      "id": "c_02",
      "name": "Malee"
    }
  ];
</script>

Loading skeleton

<baht-table loading="true" skeleton-rows="3"></baht-table>

<script>
  const el = document.querySelector('baht-table');
  el.columns = {
    "name": "Name",
    "role": "Role"
  };
</script>

Empty + error states

<baht-table empty-message="No users yet. Invite one."></baht-table>

<script>
  const el = document.querySelector('baht-table');
  el.columns = {
    "name": "Name"
  };
  el.items = [];
</script>

filterable adds a Filter button. Its panel is a <baht-filter-builder> over every column, dot-walked ones included; Run emits baht-filter-change and the conditions stay above the table as breadcrumbs, each one removable. The table never filters: refetch, or test rows with matchesFilter, and feed filter back. type and options on a column say how it compares. filterFieldsOf(columns) lists what can be filtered on.

Set savedFilters (an empty list will do) and the panel can name a filter, apply one and delete one, through <baht-saved-filters>. The table does not store them: keep baht-saved-filters-change’s detail.filters in localStorage or on a server and feed them back. A saved filter naming a column no longer on offer can be deleted but not applied.

Filter on a dot-walked column

Press Filter, add a condition on Requested by › Manager › Name and press Run. This preview applies it with matchesFilter; a list the server pages sends it in its query.

<baht-table row-key="number" filterable="true"></baht-table>

<script>
  const t = document.querySelector('baht-table');
  const all = t.items;
  const { matchesFilter } = await import('bank20baht-ui');
  t.addEventListener('baht-filter-change', (e) => {
  t.filter = e.detail.value;
  t.items = all.filter((row) => matchesFilter(row, e.detail.value));
  });
</script>

group-by buckets the current page under a header row per value (ServiceNow-style “Column: value (n)”). Headers are disclosures unless group-collapsible="false"; group-collapsed starts them folded. hide-header drops the column header row for list-style ledgers. Row numbers keep host order, so a reordered bucket may read 1, 3, 2.

Group by category

groupLabel (JS property) replaces the header text; baht-group-toggle reports collapses. Sorting still happens on the host — the table only buckets what it was given.

<baht-table row-key="id" group-by="category" hide-header="true"></baht-table>

<script>
  const el = document.querySelector('baht-table');
  el.columns = {
    "date": "Date",
    "category": "Category",
    "note": "Note",
    "amount": {
      "label": "Amount",
      "numeric": true
    }
  };
  el.items = [
    {
      "id": "g1",
      "date": "2026-09-13",
      "category": "food",
      "note": "Street noodles",
      "amount": -405.22
    },
    {
      "id": "g2",
      "date": "2026-09-12",
      "category": "transport",
      "note": "BTS top up",
      "amount": -155.92
    },
    {
      "id": "g3",
      "date": "2026-09-12",
      "category": "food",
      "note": "Dinner out",
      "amount": -433.82
    },
    {
      "id": "g4",
      "date": "2026-09-11",
      "category": "fun",
      "note": "Concert ticket",
      "amount": -640
    },
    {
      "id": "g5",
      "date": "2026-09-10",
      "category": "food",
      "note": "Groceries",
      "amount": -1210.5
    },
    {
      "id": "g6",
      "date": "2026-09-08",
      "category": "transport",
      "note": "Taxi",
      "amount": -180
    }
  ];
</script>
NameTypeDefaultDescription
itemsJSRecord<string, unknown>[][]Rows of the CURRENT page
columnsJSColumnMap{}key → header, or { label, fields } for a reference column the picker can dot-walk into (fields nest). Empty = derive from the first row; nested objects become reference columns.
loadingbooleanfalseSkeleton rows
errorbooleanfalseError state
error-messagestring"Couldn't load data. Try again."Error text
empty-messagestring'No records yet.'Empty text
show-indexbooleantrueRow-number column
index-offsetnumber0Add to row numbers (page offset)
skeleton-rowsnumber10Skeletons while loading
row-keystring''Row property used as id in baht-row-click
clickablebooleantrueRows clickable
renderersJSRecord<string, CellRenderer>{}Custom cell renderers by key or dotted path (JS only)
visibleColumnsJSstring[][]Subset + order of columns to render; dotted keys allowed (requester.department). Empty = all top-level columns
group-byGroupKey | undefined—Group the page's rows by this (dotted) column key — or a function of the row (property). Unset = no grouping.
groupLabelJSGroupLabelRenderer | undefined—Group header content (property); default is "Column: value (n)".
group-collapsiblebooleantrueGroup headers are disclosures (click to collapse). Set the JS property to `false` for static headers.
group-collapsedbooleanfalseStart every group collapsed (per page — resets when `items`/`page` change).
hide-headerbooleanfalseDrop the column header row (list-style tables, e.g. grouped ledgers).
column-pickerbooleantrueBuilt-in "Columns" button opening the Available/Selected picker. Set the JS property to false to hide it
totalnumber | undefined | undefinedundefinedTotal matching rows across pages; shows "{n} results" in the toolbar
selectablebooleanfalseCheckbox column + select-all header; controlled by `selected`
selectedJSunknown[][]Selected row keys (row-key values, or row indexes without one)
sortablebooleanfalseEvery header becomes a sort button (or set sortable per column)
sortJSSortState | nullnullCurrent sort { key, direction }; mirrored as aria-sort
filterablebooleanfalseA Filter button: a condition builder over every column, and the applied conditions as breadcrumbs
filterJSFilterValue{ match: 'all', conditions: [] }The applied filter; the host applies it and feeds it back
savedFiltersJSSavedFilter[] | nullnullThe filters this person saved; null hides saving. The host keeps them
row-actionsbooleanfalseTrailing "…" button per row; emits baht-row-actions
pagenumber1Current page
total-pagesnumber1
always-paginatebooleanfalseKeep the pager with one page too
cursor-modebooleanfalsePrev/Next footer instead of page numbers (cursor-based APIs)
has-nextbooleanfalsecursor-mode only
has-prevbooleanfalsecursor-mode only
totalPagesnumber1Total pages

Column specs accept numeric (right-aligned tabular figures), align (start | center | end), sortable, and for the filter type, options and filterable, alongside label and fields. A flat row may carry a dotted key itself ({ 'owner.name': 'Mali' }); it answers before the path is walked.

EventdetailDescription
baht-row-click{ row, index, id? }Row activated
baht-selection-change{ selected: unknown[], rows }Checkbox toggled or select-all. Feed `selected` back
baht-sort-change{ key, direction: 'asc'|'desc' }Header clicked. Refetch sorted and feed `sort` back
baht-columns-change{ visibleColumns: string[], columns: { key, label }[] }Picker confirmed with OK. Persist visibleColumns and feed it back
baht-filter-change{ value: FilterValue }Run pressed, Clear pressed or a breadcrumb removed. Apply it and feed `filter` back
baht-group-toggleCustomEventA group was collapsed or expanded.
baht-row-actions{ row, index, id? }Row "…" button pressed. Open your menu
baht-page-change{ page }User wants another page. Fetch it
baht-cursor-change{ direction: 'next'|'prev' }cursor-mode: fetch that direction
baht-saved-filters-change{ filters: SavedFilter[] }A filter was saved or deleted. Keep the list and feed it back
nameCustomEvent