Now.js Framework Documentation

Now.js Framework Documentation

TableManager

EN 09 Nov 2026 03:28

TableManager

Overview

TableManager is the data table management system in Now.js Framework. It supports sorting, filtering, pagination, and API integration.

When to use:

  • Need data tables
  • Need sorting and filtering
  • Need pagination
  • Need CRUD operations
  • Need row selection

Why use it:

  • ✅ Auto-load from API
  • ✅ Sorting (client/server)
  • ✅ Filtering
  • ✅ Pagination
  • ✅ Row selection (checkbox)
  • ✅ Inline editing
  • ✅ URL state persistence
  • ✅ Sortable rows (drag & drop)
  • ✅ Drag UX: icon ghost at cursor, dashed placeholder row, vertical-only drag

Basic Usage

HTML Declarative

<table data-component="table"
       data-source="/api/users"
       data-page-size="10">
  <thead>
    <tr>
      <th data-field="id">ID</th>
      <th data-field="name" data-sortable>Name</th>
      <th data-field="email" data-sortable>Email</th>
      <th data-field="created_at" data-sortable>Created</th>
    </tr>
  </thead>
  <tbody>
    <!-- Data will be rendered here -->
  </tbody>
</table>

With Filters (column filters)

Put data-filter="true" on a <th> and TableManager builds the filter control
above the table — no filter form needed. data-type picks the control, and the
value is sent to data-source under the column's data-field (a date range
sends two parameters). The page-size selector and the search box are built as
well, from data-page-size and data-search-columns (data-search-placeholder
sets the text of the search box). A parameter that has no column of its own
(a year, a "show" choice, a typed amount) is a column with data-hidden="true":
it builds its filter and shows no cells.

<table data-table="usage" data-source="api/index/usage" data-page-size="25"
       data-search-columns="name,topic,reason">
  <thead>
    <tr>
      <th data-field="created_at" data-format="datetime"
          data-filter="true" data-type="daterange" data-range-names="from,to"
          data-range-presets="today,7d,30d,thismonth,lastmonth" data-label="Date">Created</th>
      <th data-field="src" data-filter="true" data-type="select"
          data-show-all="true" data-all-value="" data-label="Module">Module</th>
      <th data-field="due" data-filter="true" data-type="date" data-label="Due">Due</th>
    </tr>
  </thead>
  <tbody></tbody>
</table>
data-type Control Sent to the API
select Dropdown. Options from data-options, the response's filters, or the values found in the data field=value
text Text box (a client-side table matches "contains") field=value
date Date picker (a client-side table matches the whole day of a date-time) field=YYYY-MM-DD
daterange One date picker for a range; either side may stay empty from=YYYY-MM-DD&to=YYYY-MM-DD
number · currency Number box, sent when committed (Enter or leaving the box). currency takes negative values and 2 decimals field=1250.75
tabs A row of tabs above the other filters, one per option; a click filters at once field=value
Attribute on <th> Description
data-filter="true" Build a filter for this column
data-type Control type (above)
data-label Label of the control, a translation key (Date or {LNG_Review from} ({LNG_Leave Request})). Default: the key the header text came from (data-i18n), so the label follows the locale when it changes
data-unit Unit after the filter box (days · %), a translation key, shown as a <span class="unit"> inside the box · a column from the API sends unit in columns
data-placeholder Placeholder text
data-options JSON options of a select
data-show-all · data-all-value · data-all-label The "All" option of a select
data-range-names="from,to" The two parameter names of a daterange (default {field}_from, {field}_to)
data-range-presets Preset buttons: today yesterday 7d 30d thismonth lastmonth thisyear
data-range-max-days Longest range allowed, in days
data-min · data-max First / last selectable day (YYYY-MM-DD) of a date filter, or the smallest / largest number of a number filter
data-step · data-precision Decimals of a number filter (data-step="0.01" = 2 decimals), as an editable cell reads them
data-hidden="true" Filter only: the column shows no cells
Attribute on <table> Description
data-search-placeholder Text of the search box, a translation key ({LNG_Name or email}). Default: "Search in: " and the headers of the searched columns the table shows (fields with no column are left out; none at all = "Search...")

The options of select and tabs come from filters in the response, keyed
by the field: {"filters": {"status": [{"value": "", "text": "Every status"}, ...]}}.
An endpoint that sends its own "all" entry sets data-show-all="false" on the
column so no second one is added. A selected value the server settles on
(a default month, the first account) comes back in meta and is shown in the
filter.
A filter the viewer cannot use comes as false instead of options
({"filters": {"member_id": false}}): its control is hidden, and it comes back
when a later response offers options.

The same options serve two kinds of control, chosen by the column's data-type:

  • data-type="select" lists them (with "All" as usual).
  • data-type="text" is a box to type in and pick from the list (an input with a datalist: TextElementFactory autocomplete) —
    for long lists such as employees: type part of a name and pick; the box shows text, the filter sends value (member_id=8, not the name).
    • Typing alone does not filter; a pick, or Enter/leaving the box with the text of an option, does. An emptied box clears the filter.
    • An empty-value option ("All") is not offered. A value from the address or meta that names an option shows as its text; one that names none (e.g. 0) is not written into the box.
    • A text filter without options still filters on the typed text.
      <th data-field="member_id" data-filter="true" data-type="text" data-template="${name}">Name</th>

Column groups from data (data-column-group)

A table whose number of columns depends on data (a column per leave type, per
day) writes its prototype columns once in the template; the API sends only the
lists, as data, in columnGroups. All markup stays in the template (unlike
data-dynamic-columns, where the API sends the header).

<thead>
  <tr>
    <th data-field="name" rowspan="2">Name</th>
    <!-- group cell: repeated once per item, spanning its sub-columns -->
    <th data-column-group="types" class="center"><span class="one_line" title="{topic}">{topic}</span></th>
  </tr>
  <tr>
    <!-- prototype sub-columns of the group -->
    <th data-group-of="types" data-field="limit_{id}" data-i18n>Limit</th>
    <th data-group-of="types" data-field="taken_{id}" data-sort="taken_{id}"
        data-template="<a href='${taken_url_{id}}'>${taken_{id}}</a>" data-i18n>Taken</th>
  </tr>
</thead>
{"columnGroups": {"types": [{"id": 1, "topic": "Sick"}, {"id": 2, "topic": "Personal"}]},
 "data": [{"name": "...", "limit_1": "30 days", "taken_1": "2 days", "taken_url_1": "/leave-approvals?..."}]}
  • {key} in every attribute and in the text of a prototype becomes the value of that key in the item (escaped in text); {LNG_...} is left alone.
  • A group cell with no data-group-of cells is itself the column (a one-row group, e.g. a column per day): <th data-column-group="days" data-field="d_{key}">{label}</th>.
  • Several cells of one header row may share a group name: each is repeated per item in its own place, so a group switches a set of columns
    on data — a list of one item shows the whole set, an empty list hides it (e.g. the columns of the selected document: pnd1: [{key: 'pnd1'}], sso: []).
    <th data-column-group="pnd1" data-field="date" data-format="date">Date paid</th>
    <th data-column-group="pnd1" data-field="tax" class="right">Tax withheld</th>
    <th data-column-group="sso" data-field="code">Name title code</th>
    <th data-column-group="sso" data-field="wage" class="right">Wages</th>
  • The same lists keep the header; a changed list (another year, other types) rebuilds it. Generated columns sort (data-sort) and hold inputs (data-cell-element) like any column.
  • Gcms\Table: override getColumnGroups($params, $login) and return ['types' => [...]].

Tabs filter (data-type="tabs")

  • An option may carry a count, {"value": "verify", "text": "Check payment", "count": 3};
    a count above zero is a badge beside the text. The counts come with every
    response, so they stay current as the list changes.
  • The tabs are the first row of the filter bar. The active tab is the
    current value (from the address, the remembered state or meta), and a link
    with ?field=value opens on that tab.

A table inside a form (inputs in the cells that the form saves): the filter
bar is inside the form too, so its controls are sent with the form under their
names (a year filter gives the save its year). Enter in a filter box filters
and does not submit the form.

Date range filter (data-type="daterange")

  • The picker has From / To tabs. The first day picked is the start and the To
    side comes next; the second day closes the picker. A day before the start
    becomes the start and the old start the end.
  • One side only: × empties a side. Pick a start and close the picker (Esc or a
    click outside) for "from" only; pick the To tab and a day for "to" only. An
    empty side is sent as an empty parameter (from=&to=2026-10-16).
  • The table reloads once per range, when the picker closes — not once per
    click. Backspace on the button empties both sides.
  • Both parameters go to the address bar (tableId.from when the page has
    several tables) and the remembered state, and a link puts them back into the
    picker.
  • The server reads them like any parameter: $request->get('from')->date(),
    $request->get('to')->date(). The end is a whole day, so a date-time column
    compares with to.' 23:59:59'.

External Filter Form (data-table-filter)

Use column filters (above) whenever they can express the filter — they cover
select, tabs, date, date range, number, text and search. A separate form is for
a layout they cannot build.

You can build a separate filter form and link it to a table via data-table-filter="tableId". This keeps the form layout flexible while the table still loads/syncs state, URL params, and API options automatically.

<!-- External Filter Form -->
<form data-table-filter="usage" class="table_nav">
  <div>
    <label>Show</label>
    <select name="pageSize">
      <option value="10">10 entries</option>
      <option value="25">25 entries</option>
      <option value="50">50 entries</option>
    </select>
  </div>

  <!-- Range filter: date -->
  <input type="date" name="from">
  <input type="date" name="to">

  <!-- Optional search -->
  <input type="search" name="search" placeholder="Search...">

  <button type="submit">Apply</button>
</form>

<!-- Table -->
<table data-table="usage"
       data-source="api/index/usage"
       data-default-sort="created_at desc"
       data-page-size="25"
       data-search-columns="name,topic,reason">
  <thead>
    <tr>
      <th data-field="id" data-sort="id">ID</th>
      <th data-field="created_at" data-sort="created_at" data-format="date">Created</th>
      <th data-field="topic" data-sort="topic">Topic</th>
      <th data-field="name" data-sort="name">Name</th>
    </tr>
  </thead>
  <tbody></tbody>
</table>

How it works

  • data-table-filter="tableId" links the form to the table (table data-table must match).
  • Submit the form to apply filters; TableManager prevents full page reload and reloads data.
  • Range filters work by naming pairs (e.g., from/to, price_min/price_max). All non-empty fields are sent to the API.
  • URL parameters stay in sync and restore form values on page load.
  • If the API returns options (e.g., { status: [{value:'active', text:'Active'}] }), select elements with matching name are populated automatically.

API payload example

GET /api/index/usage?page=1&pageSize=25&from=2024-01-01&to=2024-12-31&search=test

Notes

  • Internal header-based filters still work; when an external form exists, TableManager still reads <th> metadata (defaults/options) but uses the external form UI.
  • Keep field names consistent with API parameters (e.g., from, to, search).

Data Attributes

Attribute Description
data-component="table" Initialize table
data-table Table identifier (required for all tables)
data-source API endpoint
data-cache Enable caching for GET requests from data-source
data-cache-time Cache TTL in milliseconds for data-source
data-load-target CSS selector of a container that should be rebound from the normalized table payload after data loads · several, comma-separated (#report-top, #report-body), each get the same payload
data-page-size Items per page
data-show-footer Enable footer processing
data-footer-aggregates Legacy auto-footer aggregate mapping for blank tfoot cells

When a table mutation succeeds and TableManager reloads the remote source, that reload bypasses cache automatically so post-action data stays fresh.
| data-page-sizes | Available page sizes |
| data-default-sort | Default sort order (e.g., "name asc" or "created_at desc,name asc") |
| data-show-checkbox | Show row checkboxes |
| data-show-caption | Show/hide caption (default: true, but auto-disabled when using data-editable-rows="true") |
| data-editable-rows | Enable row editing (add/delete) |
| data-row-sortable | Enable row drag-drop reordering (works independently from data-editable-rows) |
| data-sortable-rows | Enable drag-drop reorder |
| data-url-params | Persist state in URL (with several tables on a page each uses tableId.key) |
| data-remember-state | Remember page size and sort per table, and the filters marked data-remember="true", for a new visit from a menu or an address (default true; moving around inside the app keeps every choice either way; see Remembered State) |
| data-persist-column-widths | Resizable columns whose widths are remembered (default true; see Column Resizing) |
| data-refresh-interval | Seconds between automatic reloads from data-source (0 = off, the default) |

Automatic Refresh

Give a table data-refresh-interval and it reloads itself from data-source on a
timer, keeping the current page, sort and filters.

<table data-table="logLines" data-source="/api/v2/logs" data-refresh-interval="5">

The default is 0, so tables without the attribute never poll and behave exactly as
before.

Three behaviours you get for free, because a polling screen that ignores them becomes
a problem within a day:

Situation What happens Why
Reload is slower than the interval Next tick is scheduled only after the previous one settles Chained setTimeout, not setInterval — requests can never stack up
Tab is hidden Polling pauses; on return it reloads once immediately if a tick was missed A page left open overnight would otherwise fire thousands of requests nobody sees
Rows are selected That tick is skipped Reloading clears the selection, so refreshing mid-action would throw the user's picks away

A failed reload does not stop the timer — the endpoint may only be briefly unavailable,
and giving up would leave a frozen screen with no sign that refreshing had stopped.

Change the rate at runtime with setRefreshInterval(),
which is what a "refresh every…" dropdown binds to. Each automatic reload emits
table:refreshed, so a page can show when data last arrived.

Remembered State

Every table remembers what the user chose, with no code.

Moving around inside the app brings every choice back. The table returns exactly
as it was: every filter, with or without data-remember, the search, the page, the
page size and the sort — also with data-remember-state="false". That holds when:

  • A form was saved (redirect back, or a redirect to the path of the list), a row action ran, or a plain link in the page was followed
  • The browser's back or forward button was pressed
  • The page was reloaded (F5, or location.reload() after a save)
  • The table was built again on the same page (a component or template rendered again)

A new visit from a menu or an address uses the remember settings. The rules below
apply only when:

  • A link in a menu was clicked (inside data-component="menu", .sidemenu or .topmenu)
  • An address was opened in the browser (typed, a link from elsewhere, a new tab)
  • A link or redirect inside the app names table parameters in its address (/orders?status=pending): it shows what it names, without the old search
Kept Where Why
Page size, sort localStorage A choice the user made on purpose; it should hold on the next visit
A filter whose column has data-remember="true" localStorage Only where the page needs it (a fiscal year someone works in all day)

Filters are not remembered by default. A person, a status or a date picked for one
look at a list must not greet the next visit, so a filter starts from its default (or
from meta) unless its column opts in:

<th data-field="year" data-filter="true" data-type="select" data-remember="true" data-hidden="true">Fiscal year</th>

A date range filter opts in on its own column (both of its parameters are kept). A value
remembered before a column stopped being remembered is ignored.

The search term and the page are not remembered for a new visit, so a new visit
starts on page one with an empty search box, while the filters marked
data-remember="true" stay. The search stays out of the address, and so does the page
during a search: it is a page of the results. Moving around inside the app keeps both,
and a link that carries ?search= or ?page= still opens with it.

Rules:

  • An address with table parameters wins on a new visit (?page=3, ?status=1, a
    shared link) and is shown exactly as it is. The remembered state is used only when the
    address carries none of the table's parameters — the address is then updated to
    show it.
  • A new visit is told apart from moving around inside the app by the browser (how the
    document was opened) and by the source of
    route:before (a menu sends menu). The last state of each
    table is kept in sessionStorage under now.table.live: for the tab session.
  • On back and forward, what the address of that history entry holds wins, and what it
    does not hold (the search) comes from the last state of the table. A new visit
    replaces that last state, so going back past a menu click brings the filters in the
    address back but not the old search.
  • State is kept per route, per table id and per non-table parameter, so
    ?module_id=5 and ?module_id=6 are two separate lists.
  • Only what still fits is restored: a page size the selector offers, filters that
    still exist and are still marked data-remember="true", columns that can still be sorted. A page past the end shows the last
    page (server tables are corrected from meta, client tables locally).
  • A data-dynamic-columns table has no header until the first response, so its
    remembered sort is sent as it is (the server's allowed sort columns are the
    guard) and checked once the header arrives: a column the API no longer sends is
    dropped from the sort, the address and the remembered state.
  • Turn it off per table with data-remember-state="false" (moving around inside the
    app still keeps every choice); forget it with
    clearTableCache(tableId). Removing a table
    from the page does not forget anything.

With several tables on one page their address parameters are prefixed with the table
id (?orders.page=2&items.sort=name+asc) so they cannot overwrite each other; a page with
a single table keeps the plain names.

Column Resizing

Every table can have its columns resized, with no code:

  • Drag the right border of a header — mouse, touch or pen.
  • Focus a border (Tab) and press ← / → (10px, Shift = 50px).
  • Double-click a border to return every column to its automatic width.

Widths are stored per column field (not position) and per route + table id, so hiding
or moving a column never hands its width to a neighbour. Widths saved by older releases
(table_<id>_columns) are converted once. Turn resizing off with
data-persist-column-widths="false".

Declarative Load Target Binding

Use data-load-target when a table response should also hydrate another container without custom page JavaScript. After TableManager normalizes the loaded payload, it passes that payload into the target through TemplateManager.processTemplate() and processDataOnLoad().

This is useful for headers, summary cards, result counters, empty states, and side panels that should react to the same API response as the table.

Bound state

The target receives the normalized payload on context.state, including:

{
  data: [...],
  meta: {
    page: 1,
    pageSize: 25,
    total: 120,
    totalPages: 5
  },
  filters: {},
  options: {},
  columns: [],
  params: {type: 'customer'},
  tableId: 'customers',
  tableSource: 'api/customer/customers',
  isServerSide: true,
  hasData: true,
  empty: false,
  raw: {/* original payload */}
}

The primary rows array is also exposed on context.data.

Example: Update header from table payload

<header id="customer-table-header">
  <div>
    <h1 data-text="params.type === 'partner' ? '{LNG_Partner}' : '{LNG_Customer}'">Customer</h1>
    <p data-text="params.type === 'partner' ? 'Manage partner information' : 'Manage customer information'">
      Manage customer information
    </p>
  </div>
  <a class="btn btn-primary icon-new"
     href="/customer"
     data-attr="href:'/customer?type=' + (params.type || 'customer')"
     data-text="params.type === 'partner' ? '{LNG_Add} {LNG_Partner}' : '{LNG_Add} {LNG_Customer}'">
    {LNG_Add} {LNG_Customer}
  </a>
</header>

<table data-table="customers"
       data-source="api/customer/customers"
       data-load-target="#customer-table-header"
       data-page-size="25">
  <thead>...</thead>
  <tbody></tbody>
</table>

Notes

  • data-load-target runs after data normalization, so the target can use data, meta, filters, options, and current table params immediately.
  • A form beside the table fills its selects from the same response: <select data-options-key="shift_id"> in a target reads options.shift_id · a bound table in a target (data-attr="data:options.departments") works as it does in a data-component
  • The target can use normal TemplateManager directives such as data-text, data-attr, data-class, data-if, data-for, and data-on-load.
  • The target is rebound every time the table reloads from API or state through TableManager.
  • Prefer using API fields directly when the server already knows the correct label or summary. Use params.* only for values derived from current table state or URL params.

Hide/Show Columns

You can hide columns in 2 ways:

1. Use data-visible="false" or data-hidden="true" in HTML

<table data-table="users" data-source="/api/users">
  <thead>
    <tr>
      <th data-field="id" data-visible="false">ID</th>
      <th data-field="internal_code" data-hidden="true">Internal Code</th>
      <th data-field="name">Name</th>
      <th data-field="email">Email</th>
    </tr>
  </thead>
</table>

data-hidden="true" is an alias of data-visible="false". Use either form in declarative HTML; both hide the column header, its body cells and its cell in the footer the table builds (data-show-footer), so the totals stay under their own headers. toggleColumnVisibility() hides and shows the footer cell along with the column.

2. Use JavaScript dynamically

// Hide column
TableManager.toggleColumnVisibility('users', 'email', false);

// Show column
TableManager.toggleColumnVisibility('users', 'email', true);

Note: Hidden columns won't display in <th> or <td> but data remains in memory.

Built-in Cell Formatting

Use data-format on a column <th> when you want TableManager to format display text without a custom formatter.

Attribute Applies to Description
data-format HTML headers and dynamic columns Built-in format such as text, number, currency, percent, lookup, date, time, datetime, bytes, duration, boolean, humanize, or truncate
data-decimals number, currency, percent Fixed number of decimal places
data-locale number, currency, percent Locale passed to Intl.NumberFormat
data-currency currency Currency code/label passed to the shared currency formatter
data-pattern date, time, datetime Display pattern passed to Utils.date.format()
data-options lookup Fallback lookup options when API/filter options are unavailable

currency columns now use the same shared formatter contract as template bindings, so decimal behavior is consistent between data-text="amount | currency:THB:th-TH:4" and table columns.

Arguments after : (spelled as in data-text)

data-format accepts arguments after the format name, spelled exactly as in a template's data-text / data-format, so one can be copied to the other.

Written as Same as
number:2 data-format="number" data-decimals="2"
percent:1 data-format="percent" data-decimals="1"
currency:THB:th-TH:2 data-currency="THB" data-locale="th-TH" data-decimals="2"
date:D MMM YYYY · datetime:D/M/YYYY HH:mm · time:HH:mm data-pattern="..." (the pattern may contain :; everything after the name is one pattern)
truncate:30 Cut at 30 characters (the ... counts towards them)

An explicit attribute wins over an argument — data-format="number:2" data-decimals="0" shows no decimals. It works in cells, footer aggregates (data-aggregate) and exports alike.

<th data-field="cost" data-format="number:2" class="right" data-cell-class="right">Cost</th>
<th data-field="created_at" data-format="datetime:D MMM YYYY HH:mm">Date</th>

Static HTML example

<table data-table="stockMovements" data-source="api/inventory/stockmovements">
  <thead>
    <tr>
      <th data-field="quantity" data-format="currency" data-decimals="4">Quantity</th>
      <th data-field="unit_cost" data-format="currency" data-currency="THB" data-locale="th-TH" data-decimals="2">Unit Cost</th>
      <th data-field="occurred_at" data-format="date" data-pattern="D MMM YYYY">Date</th>
    </tr>
  </thead>
</table>

Dynamic column example

{
  "columns": [
    {
      "field": "quantity",
      "label": "Quantity",
      "format": "currency",
      "decimals": 4
    },
    {
      "field": "total_cost",
      "label": "Total",
      "format": "currency",
      "currency": "THB",
      "locale": "th-TH",
      "decimals": 2
    }
  ],
  "data": []
}

Notes:

  • lookup formatting still resolves options from API response options, filter options, or data-options.
    An option label carrying {LNG_...} (such as {LNG_Level} 1) is translated in the cell, exactly as the
    column's filter select shows it; a plain label, and a row value that matches no option, are shown as written.
  • Non-lookup built-in formats read column attributes directly, so decimals, currency, locale, and pattern can be declared per column.
  • Use formatter when the cell needs row-aware logic, custom HTML, or a completely custom rendering pipeline.

data-template cells and row data

The data-template on a <th> is HTML the developer wrote; ${field} is a row value (server data
that a user may have typed). The two are kept apart from the start:

  • Template tokens ({LNG_...}, {{ expr }}, data-* directives) are processed from the template
    only
    . Row values are put back into the finished HTML, already escaped — a stored {{7*7}} renders as
    {{7*7}} and {LNG_Delete} shows literally, untranslated (they used to be re-processed: template
    injection through table data).
  • A {html: '...'} value is still inserted as HTML, sanitized as before.
  • {html: '...', i18n: true} — the server says this markup carries {LNG_...} markers to resolve on the
    client: TableManager translates them on every render (only the marker; any other brace is left alone), and
    since the table re-renders from the raw value on locale change the cell follows the UI language without a
    request — unlike markup PHP translated with Language::trans(), which stays in the language the server
    answered in. Works for a data-template="${field}" column and a plain column alike; no flag = as before.
  • ${field|formatter:args} formats the field with the same formatters as data-text (date, datetime, number, currency ...) in the current locale
    — the API sends the raw value (2026-09-01) and the template formats it: data-template="${month_date|date:MMMM YYYY}<small>${from|date} – ${to|date}</small>".
    An empty/null value renders empty; an unknown formatter shows the raw value; works inside data-each too.
  • A ${...} that is not a plain field name (e.g. ${id != 1}) passes through verbatim, for data-if.
  • data-if inside a cell decides per row — data-if="${can_edit}", data-if="${id != 1}" and
    data-if="can_edit" all work (null/empty = hidden); an element that fails is removed from the cell at once.
  • After rendering, template cells and plain value cells get translate="no", so I18nManager never
    re-translates the data in them (the table re-renders itself on locale change). data-formatter cells are
    not marked: a formatter owns its cell (Now.translate() for labels, cell.setAttribute('translate', 'no')
    around user data).
<th data-field="title" data-template="<a href='/doc?id=${id}'>${title}</a>
  <button class='btn' data-if='${can_edit}'>{LNG_Edit}</button>"></th>

Cell Elements (ElementManager)

Render form controls inside cells using data-cell-element (or data-type as a shorthand). TableManager passes the attributes to ElementManager and stores data-element-id on the cell.

Supported attributes: placeholder, class, read-only, disabled, required, wrapper, options (select), multiple, allow-empty, rows/cols (textarea), min/max/step/precision (number — precision or decimals sets the decimals that can be typed; without either, a fractional step allows as many as it has, e.g. step="0.5" → 1; a negative min allows typing "-"), datalist, pattern, min-length, max-length (applies only when > 0).

Lists in a cell: data-each="tags" repeats data-template for each item of that array field of the row — ${key} reads the item first, then the row; a list of plain values uses ${item}
(badges of a day, skills of a person) <th data-field="badges" data-each="badges" data-template="<span class='status is-${tag}'>${text}</span>"> · data-if in the template reads row fields.

Disable the control on some rows: data-cell-disabled="field" disables the control of the rows where that field is true; "!field" where it is false
— for a status the viewer may change on their own team's tasks only: the other rows still show their value in a disabled control.
A table without data-editable-rows posts a changed value to data-action-url as {action: 'update', field, ids: [row id], value}.

<table data-table="tasks" data-source="api/onboarding/checklist" data-action-url="api/onboarding/checklist/action">
  <thead>
    <tr>
      <th data-field="name">Task</th>
      <th data-field="status" data-cell-element="select" data-options-key="task_options" data-cell-disabled="!can_mark">Status</th>
    </tr>
  </thead>
  <tbody></tbody>
</table>

Footer totals: data-show-footer="true" data-footer-aggregates='{"weight":"sum"}' (sum · avg · count · min · max) — an edit in the rows of a table without data-action-url recomputes them at once
(a weight column that must add up to 100); <tfoot> cells that already hold text or span more than one column stay as written.

Example

<table data-table="element-table" data-source="/api/users" data-editable-rows="true">
  <thead>
    <tr>
      <th data-field="name" data-cell-element="text" data-placeholder="Full name"></th>
      <th data-field="email" data-type="email" data-placeholder="user@example.com"></th>
      <th data-field="status" data-cell-element="select" data-options='{"1":"Active","0":"Inactive"}' data-allow-empty="true"></th>
      <th data-field="note" data-cell-element="textarea" data-rows="2" data-max-length="200" data-placeholder="Internal note"></th>
    </tr>
  </thead>
  <tbody></tbody>
</table>

Dynamic Columns

Automatically generate table headers from API response without defining <thead> in HTML.

Basic Usage

<table data-table="langEditor"
       data-attr="data:translate"
       data-dynamic-columns="true"
       data-editable-rows="true">
  <tbody></tbody>
</table>

API Response Format

{
  "translate": {
    "columns": [
      {
        "field": "key",
        "label": "Key",
        "cellElement": "text",
        "placeholder": "Enter key",
        "i18n": true
      },
      {
        "field": "en",
        "label": "English",
        "cellElement": "textarea",
        "rows": 3,
        "i18n": true
      }
    ],
    "data": [
      {"key": "Welcome", "en": "Welcome"},
      {"key": "Goodbye", "en": "Goodbye"}
    ]
  }
}

Column Metadata Attributes

You can specify the following attributes in column definitions:

Attribute Type Description
field string Required - Field name
label string Required - Column header label
cellElement string Element type (text, textarea, select, color, checkbox, switch, etc)
checkedValue string Value posted by a ticked checkbox/switch cell (default '1')
type string Data type (text, number, date)
placeholder string Placeholder text
class string CSS class for <th>
cellClass string CSS class for <td>
i18n boolean Enable translation
sort boolean Enable sorting
filter boolean Enable filtering; type is then the filter control (select, tabs, date, daterange, number, text)
showAll/allValue/allLabel The "All" option of a select or tabs filter
rangeNames/rangePresets/rangeMaxDays string As data-range-names, data-range-presets, data-range-max-days of a daterange filter
formatter string Formatter function name
format string Display format
locale string Locale for number, currency, and percent formatting
currency string Currency code/label for format: 'currency'
decimals number Fixed decimal places for number, currency, and percent
precision number Decimals a number cell accepts (same as decimals for editable cells)
min/max/step number Limits of a number cell — a negative min allows negative values
options object Options for select
rows/cols number For textarea
min/max/step number For number input
minLength/maxLength number Text length constraints
pattern string RegEx pattern
readOnly boolean Read-only mode
disabled boolean Disabled state
required boolean Required field
autoNumber boolean Auto-generate a 1-based row number for this column
visible boolean Show/hide the column (false hides it)
hidden boolean Alias for visible: false, useful for compatibility with declarative data-hidden="true"

Data Binding (data-attr)

Use data-attr to bind data from nested objects in API response or form/page state:

Format: data-attr="data:path.to.data"

Examples:

<!-- Simple binding -->
<table data-table="users" data-attr="data:users"></table>
<!-- API: {"users": [...]} -->

<!-- Nested binding -->
<table data-table="langEditor" data-attr="data:translate"></table>
<!-- API: {"translate": {"columns": [...], "data": [...]}} -->

<!-- Deep nested -->
<table data-table="monthlySales" data-attr="data:report.sales.monthly"></table>
<!-- API: {"report": {"sales": {"monthly": [...]}}} -->

When bound through data-attr, TableManager accepts either:

  • A plain array of rows, using the <thead> already defined in HTML.
  • A canonical object such as {data: [...], columns: [...], meta: {...}} for dynamic columns and metadata.

This is useful for read-only sub-tables inside forms, such as payment history or status logs, where the parent form already loaded the full payload and you want to keep the rendering pattern consistent with the rest of the admin UI.

Auto Row Numbering

Use data-auto-number="true" on a <th> when you want TableManager to render a reusable row sequence column without requiring the backend to send row_no, payment_no, or similar fields.

<table data-table="payments" data-attr="data:payments">
  <thead>
    <tr>
      <th data-auto-number="true" class="center" data-cell-class="center" data-format="number">#</th>
      <th data-field="payment_method">Method</th>
      <th data-field="amount" data-format="number" class="right" data-cell-class="right">Amount</th>
    </tr>
  </thead>
  <tbody></tbody>
</table>

Behavior:

  • Uses 1-based numbering.
  • When pagination is active, numbering continues across pages.
  • When pagination is not active, numbering uses the local rendered row index.

Benefits

✅ Flexible - Columns adapt to API data
✅ Clean HTML - No repetitive <th> tags
✅ Dynamic - Add/remove columns without frontend changes
✅ Type-safe - Define element types from backend

Real-world Example

// PHP API Response
return [
    'translate' => [
        'columns' => [
            ['field' => 'key', 'label' => 'Key', 'cellElement' => 'text'],
            ['field' => 'th', 'label' => 'Thai', 'cellElement' => 'textarea'],
            ['field' => 'en', 'label' => 'English', 'cellElement' => 'textarea']
        ],
        'data' => $translations
    ]
];

TableManager will auto-generate headers:

<thead>
  <tr>
    <th data-field="key" data-cell-element="text">Key</th>
    <th data-field="th" data-cell-element="textarea">Thai</th>
    <th data-field="en" data-cell-element="textarea">English</th>
  </tr>
</thead>

Separate CSS Classes for Header and Body Cells

TableManager supports separate CSS class definitions for <th> (header) and <td> (body cells) with no fallback between them.

Static HTML Table

For tables with <thead> defined in HTML:

<table data-table="myTable" data-source="/api/data">
  <thead>
    <tr>
      <!-- Use standard class attribute for <th> -->
      <th data-field="id" class="header-mono">ID</th>

      <!-- Use data-cell-class for <td> -->
      <th data-field="name" class="header-bold" data-cell-class="text-bold">Name</th>

      <!-- Can use data-cell-class only -->
      <th data-field="status" data-cell-class="badge center">Status</th>
    </tr>
  </thead>
  <tbody></tbody>
</table>

Result:

<!-- Header -->
<th class="header-mono">ID</th>
<th class="header-bold">Name</th>
<th>Status</th>

<!-- Body -->
<td>001</td>  <!-- no class -->
<td class="text-bold">John Doe</td>
<td class="badge center">Active</td>

Dynamic Columns (API Response)

For tables using data-dynamic-columns="true":

{
  "columns": [
    {
      "field": "id",
      "label": "ID",
      "class": "header-mono",
      "cellClass": "mono small"
    },
    {
      "field": "name",
      "label": "Name",
      "class": "header-bold",
      "cellClass": "text-bold"
    },
    {
      "field": "status",
      "label": "Status",
      "cellClass": "badge center"
    }
  ],
  "data": [...]
}

Result:

<!-- Header -->
<th class="header-mono">ID</th>
<th class="header-bold">Name</th>
<th>Status</th>

<!-- Body -->
<td class="mono small">001</td>
<td class="text-bold">John Doe</td>
<td class="badge center">Active</td>

Summary

Type TH Class TD Class
Static HTML class="..." (standard attribute) data-cell-class="..."
Dynamic API "class": "..." (in JSON) "cellClass": "..." (in JSON)

Important Notes:

  • ⚠️ No data-class attribute exists
  • Completely separate, no fallback between th and td
  • Can use either one alone or both together

Recommended approach: declare tfoot explicitly and let footer cells describe their own behavior. This is easier to read than index-based footer generation and works better when you need fixed labels such as Total, custom placement, colspan, or footer-specific classes.

<table data-table="orders"
       data-source="/api/orders"
       data-show-footer="true">
  <thead>
    <tr>
      <th data-field="id">ID</th>
      <th data-field="customer">Customer</th>
      <th data-field="status">Status</th>
      <th data-field="amount" data-format="number">Amount</th>
      <th data-field="created_at" data-format="date">Created</th>
    </tr>
  </thead>
  <tbody></tbody>
  <tfoot>
    <tr>
      <th data-field="id" data-aggregate="count" data-format="number" data-cell-class="center"></th>
      <th colspan="2" class="right">Total</th>
      <th data-field="amount" data-aggregate="sum" data-format="number" data-cell-class="right"></th>
      <th data-field="created_at" data-aggregate="count" data-format="number" data-cell-class="center"></th>
    </tr>
  </tfoot>
</table>
  • tfoot may use either <th> or <td>.
  • Static text is preserved. A cell that already contains text such as Total is not recalculated.
  • If a footer cell omits data-field, TableManager falls back to the corresponding leaf column from thead.
  • sum, avg, min, and max only run when the source values are fully numeric.
  • Non-numeric or mixed values are skipped instead of being guessed.
  • count counts non-empty values.
  • data-cell-class also applies to footer output, so footer alignment can differ from the header.
  • colspan and rowspan in tfoot are respected.
  • Before each draw the table puts back the starting markup only of the cells it fills itself (data-sum/data-aggregate, or a
    column in data-footer-aggregates). Any other cell belongs to the template, so totals bound to the page's own data stay through
    every draw of the rows (no data-show-footer needed):
<form data-load-api="api/shop/order/view">
  <table data-table="items" data-attr="data:items" data-url-params="false">
    <thead>...</thead>
    <tbody></tbody>
    <tfoot>
      <tr><td colspan="5" data-i18n>Sub total</td><td data-text="subtotal | number:2"></td></tr>
      <tr data-if="delivery"><td colspan="5" data-i18n>Shipping fee</td><td data-text="shipping_cost | number:2"></td></tr>
    </tfoot>
  </table>
</form>
Attribute Description
data-field Source field for this footer cell
data-aggregate Aggregate type: sum, avg, count, min, max, or custom
data-format Output format for the aggregate value
data-formatter Custom formatter function name
data-cell-class CSS classes applied to the rendered footer cell
data-class Not supported. Use the normal HTML class attribute instead
data-prefix Text inserted before the formatted value
data-suffix Text inserted after the formatted value
data-align Footer-only text alignment
data-custom Custom aggregate function name when data-aggregate="custom"

Custom formatter and custom aggregate

Footer cells can use the same formatter style as body cells:

<th data-field="amount"
    data-aggregate="sum"
    data-formatter="renderFooterMoney"></th>

<th data-field="amount"
    data-aggregate="custom"
    data-custom="calculateGrandTotal"></th>
function renderFooterMoney(cell, value, rows, attrs) {
  cell.textContent = `USD ${Number(value).toLocaleString()}`;
}

function calculateGrandTotal(rows, cell, table, attrs) {
  return rows.reduce((sum, row) => sum + Number(row.amount || 0), 0);
}

data-formatter receives (cell, value, rows, attrs). In footer mode, attrs includes isFooter, field, aggregateType, tableId, column, table, and aggregate.

Legacy auto mode

If you do not need explicit footer text or layout, the old mapping style still works:

<table data-table="orders"
       data-source="/api/orders"
       data-show-footer="true"
       data-footer-aggregates='{"amount":"sum","id":"count"}'>
  <thead>...</thead>
  <tbody></tbody>
  <tfoot></tfoot>
</table>

In this mode, TableManager auto-fills only blank single-column footer cells. Cells that already contain content, or cells covered by colspan, are left untouched.

Column Attributes

Attribute Description
data-field Field name
data-sortable Enable sorting
data-type Data type (text, number, date)
data-format Display format

403 / 401 Response Handling

data-table's own data loading (data-source) reacts to 403/401 the same
way ApiComponent does — they
are two separate component systems with two separate request paths, so this
is a genuinely separate implementation, not shared code:

Status Default behavior
403 Forbidden RouterManager.navigate() to RouterManager.config.auth.redirects.forbidden, falling back to /forbidden
401 Unauthorized RouterManager.navigate('/login')

Same rule as ApiComponent: before acting on either, TableManager checks that
the table is still genuinely mounted (TableManager.isAlive(tableId) — still
tracked in state.tables, and its <table> element still attached to
document.body). A data-table embedded inside a data-if-gated container
(an admin-only table on an otherwise shared page) still issues its request
the moment it's created — hiding the wrapper does not stop the request — so
without this check, a table the current user was never shown could still
redirect the entire page over its own moot 403.

Row actions and filter-form submissions (data-row-actions, data-table-filter)
handle 403/401 differently on purpose: those are responses to something the
user explicitly clicked, so they show a notification (403) or route through
AuthErrorHandler (401) instead of navigating away — losing the rest of the
page over one failed button click would be worse than the button just not
working.

Configuration

TableManager.init({
  debug: false,
  urlParams: true,
  pageSizes: [10, 25, 50, 100],
  showCaption: true,
  showCheckbox: false,
  allowRowModification: false,
  rowSortable: true, // Drag handles show when allowRowModification is true
  refreshInterval: 0 // Seconds between automatic reloads, 0 disables it
});

API Reference

TableManager.initTable(element, options)

Initialize table

Parameter Type Description
element HTMLTableElement Table element
options object Configuration

TableManager.setData(tableId, data)

Set table data

Parameter Type Description
tableId string Table ID
data array Data array
TableManager.setData('users-table', [
  { id: 1, name: 'John', email: 'john@example.com' },
  { id: 2, name: 'Jane', email: 'jane@example.com' }
]);

TableManager.loadTableData(tableId, options?)

Reload data from source

await TableManager.loadTableData('users-table');

TableManager.renderTable(tableId)

Re-render table

TableManager.renderTable('users-table');

TableManager.setRefreshInterval(tableId, seconds)

Change how often a table reloads itself, or stop it. See
Automatic Refresh.

Parameter Type Description
tableId string Table ID
seconds number Seconds between reloads, 0 to stop

Returns: boolean — whether a timer is now running

The value is stored on the table config, so a later startAutoRefresh() keeps it.

// Bind a "refresh every…" dropdown
picker.addEventListener('change', () => {
  TableManager.setRefreshInterval('logLines', Number(picker.value));
});

TableManager.getRefreshInterval(tableId)

Returns: number — seconds between automatic reloads, 0 when off

if (TableManager.getRefreshInterval('logLines') === 0) {
  // refreshing is off
}

TableManager.startAutoRefresh(tableId)

Start the timer using the table's current refreshInterval. Called automatically by
initTable(), so you only need it after changing the config by hand. Does nothing
when the interval is 0 or the table has no data-source.

Returns: boolean — whether a timer is now running

TableManager.stopAutoRefresh(tableId)

Stop the timer. Safe to call when none is running; destroyTable() calls it for you.

TableManager.setFilters(tableId, filters)

Set filter values

TableManager.setFilters('users-table', {
  name: 'John',
  status: 'active'
});

TableManager.clearSelection(table, tableId, options?)

Clear row selection

TableManager.enableRowSort(tableId)

Enable drag-drop sorting. Drag handles render when data-row-sortable="true" is set (does not require data-editable-rows).

TableManager.disableRowSort(tableId)

Disable drag-drop sorting and hide drag handles.

TableManager.toggleColumnVisibility(tableId, fieldName, visible)

Show/hide columns dynamically.

Parameters:

  • tableId (string) - Table identifier
  • fieldName (string) - Field name of the column
  • visible (boolean) - true = show, false = hide

Example:

// Hide email column
TableManager.toggleColumnVisibility('users', 'email', false);

// Show email column again
TableManager.toggleColumnVisibility('users', 'email', true);

TableManager.clearTableCache(tableId)

Forgets what a table remembered: its column widths, its remembered page size, sort,
filters and page, and the last state kept for a reload (see
Remembered State). The table keeps its current view; the next
visit starts from the defaults.

// A "reset view" button
TableManager.clearTableCache('users');

Row Sorting (drag & drop)

  • Enable: Add data-row-sortable="true" to show drag handles (⋮⋮) and enable row reordering.
  • No need for data-editable-rows: Both features work independently:
    • data-editable-rows="true" = Enable add/delete row buttons
    • data-row-sortable="true" = Enable drag-drop row reordering
  • Disable per table: Call TableManager.disableRowSort(tableId).
  • UX updates: Small icon ghost follows the cursor, placeholder row uses a dashed outline, and dragging is locked to vertical movement for predictable reordering.
<table data-table="rows"
       data-source="/api/rows"
       data-row-sortable="true">
  <thead>
    <tr>
      <th data-field="name">Name</th>
      <th data-field="position">Position</th>
    </tr>
  </thead>
  <tbody></tbody>
</table>

<div class="controls">
  <button onclick="TableManager.disableRowSort('rows')">Disable drag</button>
  <button onclick="TableManager.enableRowSort('rows')">Enable drag</button>
</div>

TableManager.exportData(tableId, format, options)

Export table data to a file

Parameter Type Description
tableId string Table ID
format string File format: 'csv', 'json', or 'excel'
options object Additional options
Options: Option Type Description
filename string Filename to save

Notes:

  • Export uses filtered/sorted data (what user sees)
  • Values are formatted (e.g., shows "Engineering" instead of "1")
  • Headers use column names from <th> instead of field names
// Export to CSV
TableManager.exportData('users-table', 'csv', {
  filename: 'employees.csv'
});

// Export to JSON
TableManager.exportData('users-table', 'json', {
  filename: 'employees.json'
});

Bulk Actions (Checkbox Selection)

Bulk action system for selected rows via checkboxes, with action dropdown and submit button.

Usage

<table data-table="deals"
       data-source="api/crm/deals"
       data-show-checkbox="true"
       data-actions='{"delete":"Delete","activate":"Activate","stage|lead":"Lead","stage|won":"Won"}'
       data-action-url="api/crm/deals/action"
       data-action-button="Process|btn-success">
  <!-- table content -->
</table>

Attributes

Attribute Description
data-show-checkbox Show checkboxes for row selection
data-actions JSON object of actions (key: action value, value: label)
data-action-url URL to send action (POST)
data-action-button Button text, or "label\|className"

Action Key Format

Simple Action:

{"delete": "Delete", "activate": "Activate"}

Sends: {action: "delete", ids: [...]}

Compound Action (using | separator):

{"stage|lead": "Lead", "stage|won": "Won", "stage|lost": "Lost"}

Sends: {action: "stage", stage: "lead", ids: [...]}

The action|value format splits the key into:

  • action = first part (before |)
  • First part becomes an additional parameter name with value = second part (after |)

Real-World Example

<table data-table="deals"
       data-source="api/crm/deals"
       data-show-checkbox="true"
       data-actions='{
         "stage|lead":"Lead",
         "stage|qualified":"Qualified",
         "stage|proposal":"Proposal",
         "stage|negotiation":"Negotiation",
         "stage|won":"Won",
         "stage|lost":"Lost",
         "delete":"Delete"
       }'
       data-action-url="api/crm/deals/action"
       data-action-button="Process|btn-success">
  <thead>
    <tr>
      <th data-field="title">Title</th>
      <th data-field="stage">Stage</th>
    </tr>
  </thead>
  <tbody></tbody>
</table>

Backend Handler (PHP)

public function action()
{
    $action = $this->request->post('action');
    $ids = $this->request->post('ids');

    switch ($action) {
        case 'stage':
            $stage = $this->request->post('stage'); // "lead", "won", etc.
            $this->model->updateStage($ids, $stage);
            break;
        case 'delete':
            $this->model->deleteByIds($ids);
            break;
    }

    return ['success' => true];
}

Event

EventManager.on('table:actionComplete', (e) => {
  const {tableId, action, ids, response} = e.detail;
  console.log(`Action "${action}" completed for ${ids.length} rows`);
});

Filter Actions (Table-Level Actions)

Buttons and links that send filter parameters to an action URL - for export, report generation, or bulk operations

Usage

<table data-table="my-table"
       data-source="api/users"
       data-filter-actions='{
         "export": {
           "label": "Export Filtered",
           "url": "api/export",
           "type": "button",
           "className": "btn-primary"
         },
         "report": {
           "label": "View Report",
           "url": "/reports",
           "type": "link",
           "className": "btn-info",
           "target": "_blank"
         }
       }'>
  <!-- table content -->
</table>

Filter Action Options

Property Type Default Description
label string key name Text displayed on button/link
url string required URL for the action
type string "button" "button" (POST) or "link" (GET redirect)
className string "" Additional CSS classes
target string "_self" For links: "_blank" opens new tab
confirm string - Confirmation message before action

Button Behavior

When clicked, sends a POST request to the URL with data:

{
  "action": "export",
  "tableId": "my-table",
  "filters": {"status": "1", "department": "5"},
  "sort": {"name": "asc"}
}

When clicked, redirects to URL with filter params in query string:

/reports?status=1&department=5

Event

Emitted through EventManager.emit(), which does not dispatch a DOM event —
listen with EventManager.on(), not addEventListener():

EventManager.on('table:filterAction', (data) => {
  const {tableId, action, type, params} = data;
  console.log(`Filter action: ${action} (${type})`);
  console.log('Filters:', params);
});

type is 'link' (payload also carries url) or 'button' (payload also
carries response).

Per-Row Action Conditions

Every entry in data-row-actions may carry a condition — an expression evaluated
against that row's own data, deciding whether the button is rendered for that row.
An action with no condition is always rendered.

<table data-table="sftpAccounts" data-source="/api/v2/sftp/accounts"
       data-row-actions='{
         "manage": {
           "title": "Manage",
           "className": "btn btn-success icon-edit",
           "method": "get",
           "url": "/app/user?id={id}",
           "condition": "${can_manage}"
         },
         "disable": {
           "title": "Disable",
           "className": "btn btn-danger icon-ban",
           "method": "delete",
           "url": "/api/v2/users/{id}/sftp",
           "condition": "${can_manage && sftp_enabled}",
           "confirm": "Disable SFTP for this account?"
         }
       }'>

Field names come from the row, unprefixed. sftp_enabled means this row's
sftp_enabled, not a page-level variable — a row action never sees the page's scope, so
anything it needs to decide on has to be a column the endpoint sends (can_manage above
is exactly that: a per-row copy of the viewer's permission).

${...} is optional, and where you put it does not matter. These three are the same
condition:

"condition": "${can_manage && sftp_enabled}"
"condition": "${can_manage} && ${sftp_enabled}"
"condition": "can_manage && sftp_enabled"

A boolean is accepted directly as well ("condition": true), which is useful when the
server composes the action config itself.

Hiding a button is presentation, never protection — the row still carries the data and
the endpoint is still reachable. Check the permission again on the route behind it.

Events

Event When Triggered Detail
table:render Table finished rendering {tableId, data, totalRecords, filteredRecords, isServerSide}
table:refreshed After each auto-refresh cycle {tableId, automatic, timestamp}
table:selectionChange Row selection changed {tableId, selectedRows}
table:fieldChange An editable cell changed {tableId, field, value, rowData, element}
table:rowAction A row action button was pressed {tableId, action, payload, row}
table:rowCopied A row was duplicated {tableId, originalItem, newItem}
table:rowDeleted A row was deleted {tableId, item}
table:rowRemoved A row was removed from the DOM after a bulk action {tableId, rowId}
table:action An action was sent and answered {tableId, action, success, response, timestamp}
table:filterAction A filter action button was pressed {tableId, action, type, url, params}
table:export Export finished {tableId, format, success}
table:footerSetup The footer was built {tableId, tfoot, data}
table:stateUpdate State-bound data changed {tableId, statePath, recordCount, timestamp}
table:bound Table bound to a state path {tableId, statePath, timestamp}
table:error An action failed {tableId, action, error, timestamp}
table:destroyed One table was destroyed {tableId, timestamp}
tablemanager:destroyed destroy() tore down every table {timestamp}
EventManager.on('table:loaded', (e) => {
  console.log(`Loaded ${e.detail.data.length} rows`);
});

Row Template

<table data-component="table" data-source="/api/users">
  <thead>
    <tr>
      <th data-field="name">Name</th>
      <th data-field="email">Email</th>
      <th>Actions</th>
    </tr>
  </thead>
  <tbody>
    <template>
      <tr data-id="{{id}}">
        <td>{{name}}</td>
        <td>{{email}}</td>
        <td>
          <button data-action="edit" data-id="{{id}}">Edit</button>
          <button data-action="delete" data-id="{{id}}">Delete</button>
        </td>
      </tr>
    </template>
  </tbody>
</table>

CSS Styling

/* Table container */
.table-container {
  background: white;
  border-radius: 8px;
  overflow: hidden;
}

/* Table */
[data-component="table"] {
  width: 100%;
  border-collapse: collapse;
}

[data-component="table"] th,
[data-component="table"] td {
  padding: 12px;
  text-align: left;
  border-bottom: 1px solid #e5e7eb;
}

/* Sortable header */
th[data-sortable] {
  cursor: pointer;
  user-select: none;
}

th[data-sortable]:hover {
  background: #f3f4f6;
}

th[data-sortable].asc::after { content: ' ↑'; }
th[data-sortable].desc::after { content: ' ↓'; }

/* Selected row */
tr.selected {
  background: rgba(59, 130, 246, 0.1);
}

Common Pitfalls

⚠️ 1. Row Data Must Have id Field (Critical!)

For tables with data-editable-rows="true" each row data object must have an id field, otherwise add/delete operations will not work correctly.

// ❌ No id - +/- buttons will malfunction
{
  "userstatus": [
    {"status": 0, "color": "#000", "topic": "Member"},
    {"status": 1, "color": "#333", "topic": "Admin"}
  ]
}

// ✅ Has id - works correctly
{
  "userstatus": [
    {"id": 0, "status": 0, "color": "#000", "topic": "Member"},
    {"id": 1, "status": 1, "color": "#333", "topic": "Admin"}
  ]
}

Why: TableManager uses id to:

  • Track rows in DOM (tr[data-id="${item.id}"])
  • Find rows to copy/delete
  • Update the correct row data

Backend Fix:

// Use an existing unique field as id
foreach ($data as $key => $value) {
    $result[] = [
        'id' => $key,  // ← Add this line
        'status' => $key,
        'name' => $value
    ];
}

⚠️ 2. Element ID Format for Error Highlighting

Elements inside cells are named {field}[{rowKey}] and get the ID {tableId}_{field}_{rowKey}, so an API can point an error at one cell. In an editable table (data-editable-rows="true") rowKey is a synthetic key such as row_2, which stays the same even when the user edits the row's id column; in other tables it is the row's id, or its position when it has none.

// ✅ Cell of row row_2 in the table "userStatus"
name: "topic[row_2]"
id:   "userStatus_topic_row_2"   // tableId_field_rowKey

// ✅ API Error Response
{
  "errors": {
    "userStatus_topic_row_2": "Name is required"  // matches the element ID
  }
}

How it works:

  • FormManager reads error keys and highlights elements with matching IDs
  • Format: {tableId}_{fieldName}_{rowKey}
  • If API sends a key matching an element ID, that field will show error highlight

Which table it is — _editable_tables[]: the POST data carries topic[row_2] but not the table id. So an editable table inside a form adds a hidden field _editable_tables[] holding its table id (one per editable table in the form, removed when the table is destroyed), and the server builds the error key from it. The cell names themselves do not change.

$table = $request->post('_editable_tables', [])->topic()[0] ?? '';
$errors = [];
foreach ($request->post('topic', [])->topic() as $rowKey => $topic) {
    if ($topic === '') {
        $errors[$table.'_topic_'.$rowKey] = 'Please fill in';
    }
}
if (!empty($errors)) {
    return $this->formErrorResponse($errors);
}

⚠️ 3. Caption Auto-Disabled for Editable Tables

Tables with data-editable-rows="true" auto-disable caption:

<!-- ✅ Caption auto-disabled, no need to specify data-show-caption -->
<table data-table="userStatus" data-editable-rows="true">

<!-- ✅ Force caption display (if needed) -->
<table data-table="userStatus" data-editable-rows="true" data-show-caption="true">

Why: Editable tables show all data without pagination, so a caption saying "page 1 of 1" is unnecessary.

Note: This warning has been updated since captions are now auto-disabled; no manual configuration needed.

⚠️ 2. Caption for Editable Tables

Tables with data-editable-rows="true" and no pagination should disable caption:

<!-- ✅ Disable caption for non-paginated editable table -->
<table data-table="userStatus"
       data-editable-rows="true"
       data-show-caption="false">

Why: Caption displays text like "All 5 entries, displayed 1 to 5, page 1 of 1 pages" which is inappropriate for editable tables showing all data.

⚠️ 3. Field Names Must Match Data

<!-- ❌ Field doesn't match -->
<th data-field="userName">Name</th>
<!-- data: { name: 'John' } -->

<!-- ✅ Field matches -->
<th data-field="name">Name</th>

⚠️ 2. Template in tbody

  • ⚠️ Text inputs only honor maxLength when the value is greater than 0; use positive integers or omit the attribute for unlimited input.
<!-- ❌ No template -->
<tbody>
  <tr><td>Static row</td></tr>
</tbody>

<!-- ✅ Has template -->
<tbody>
  <template>
    <tr><td>{{field}}</td></tr>
  </template>
</tbody>