jtable-pro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -0
  3. package/package.json +35 -0
  4. package/src/jtable.js +861 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hanifi Çorak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # jtable-pro
2
+
3
+ A premium, app-like jQuery data-table plugin: a floating soft-shadow card,
4
+ sticky blurred header, rounded controls and circular hover action buttons —
5
+ with **global search**, **per-column search**, **column sorting**,
6
+ **dropdown filters**, **client-side pagination** and **row selection**, all
7
+ built in.
8
+
9
+ Ships as a single UMD file (no build step of its own) — works via a plain
10
+ `<script>` tag, any bundler (Vite/webpack/Rollup), or AMD.
11
+
12
+ ```js
13
+ $("#myTable").jtable({
14
+ columns: [
15
+ { key: "name", label: "Name", sortable: true, columnSearch: true },
16
+ { key: "price", label: "Price", sortable: true, align: "right" },
17
+ ],
18
+ data: rows, // or url: "/api/rows"
19
+ idField: "id",
20
+ });
21
+ ```
22
+
23
+ ## Requirements
24
+
25
+ - **jQuery 3+** (peer dependency — bring your own, this package doesn't bundle it).
26
+ - **Tailwind CSS**, v4-style (this plugin's markup uses opacity-modifier
27
+ classes like `bg-primary/10` and arbitrary-value classes like
28
+ `shadow-[0_1px_2px_rgba(16,24,40,0.04)]`).
29
+ - A handful of **semantic design tokens** the rendered markup references by
30
+ name — these are not stock Tailwind colors/sizes, so add them to your
31
+ `@theme` (Tailwind v4) if they don't already exist in your project:
32
+
33
+ ```css
34
+ @theme {
35
+ --color-primary: #004ac6;
36
+ --color-on-primary: #ffffff;
37
+ --color-surface: #ffffff;
38
+ --color-surface-container-lowest: #ffffff;
39
+ --color-surface-container: #f3f4f6;
40
+ --color-surface-container-high: #e5e7eb;
41
+ --color-on-surface: #1f2937;
42
+ --color-on-surface-variant: #6b7280;
43
+ --color-outline-variant: #d1d5db;
44
+
45
+ --text-body-sm: 0.8125rem;
46
+ --text-body-md: 0.875rem;
47
+ --text-label-md: 0.75rem;
48
+ }
49
+ ```
50
+
51
+ Rename/retint these to match your own brand — jtable-pro only cares that
52
+ the token *names* resolve to something, not their exact values. (If your
53
+ project already has a Material-3-style semantic palette — `primary`,
54
+ `on-surface`, `surface-container*`, `outline-variant` — you very likely
55
+ already have all of these and can skip this step.)
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ npm install jtable-pro jquery
61
+ ```
62
+
63
+ ```js
64
+ import $ from "jquery";
65
+ import "jtable-pro"; // registers $.fn.jtable
66
+
67
+ $("#myTable").jtable({ /* ... */ });
68
+ ```
69
+
70
+ Or a plain `<script>` tag, with jQuery loaded first:
71
+
72
+ ```html
73
+ <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
74
+ <script src="https://unpkg.com/jtable-pro/src/jtable.js"></script>
75
+ <script>
76
+ $("#myTable").jtable({ /* ... */ });
77
+ </script>
78
+ ```
79
+
80
+ ## Markup
81
+
82
+ jtable-pro takes over an **empty container element** — a plain `<div>` —
83
+ and renders the toolbar (search box, dropdown filters), the table, and the
84
+ footer (summary + pagination) inside it:
85
+
86
+ ```html
87
+ <div id="myTable"></div>
88
+ ```
89
+
90
+ Don't hand-write `<table>`/`<thead>`/`<tbody>` — the plugin builds all of it.
91
+ If the container already has children when `.jtable()` is called, they're
92
+ replaced.
93
+
94
+ ## Options
95
+
96
+ | Option | Type | Default | Description |
97
+ |---|---|---|---|
98
+ | `columns` | `Column[]` | `[]` | See **Column definition** below. |
99
+ | `data` | `array` | `null` | Static array of row objects. Use this or `url`, not both. |
100
+ | `url` | `string` | `null` | Fetched once via `$.ajax` on init and on `reload()`. The response is unwrapped by `responseAdapter` into a row array. |
101
+ | `requestParams` | `object` | `{}` | Extra query params/body sent with the `url` request. |
102
+ | `requestMethod` | `string` | `"GET"` | HTTP method for the `url` request. |
103
+ | `responseAdapter` | `function(json) => array` | handles a plain array, `{data:[...]}`, `{data:{data:[...]}}` (Laravel paginator shape), `{items:[...]}`, `{data:{items:[...]}}` | Customize this if your API returns rows some other way. |
104
+ | `idField` | `string` | `"id"` | Row identifier field (supports dot paths, e.g. `"product.id"`). Used by selection. |
105
+ | `search` | `boolean` | `true` | Show the global search box. Matches any column not marked `searchable: false`. |
106
+ | `searchPlaceholder` | `string` | `"Search..."` | |
107
+ | `columnSearch` | `boolean` | `false` | Turns on a per-column filter-input row under the header for every column that has a `key` and hasn't opted out (`column.columnSearch: false`). A column can also opt **in** individually with `column.columnSearch: true` while this stays `false`. |
108
+ | `filters` | `Filter[]` | `[]` | Toolbar dropdown filters (category/status-style selects). See below. |
109
+ | `selectable` | `false \| "single" \| "multi"` | `false` | `"single"`: click a row to select it (one at a time, left accent bar). `"multi"`: adds a checkbox column with a header select-all. |
110
+ | `onSelectionChange` | `function(rows, idSet)` | `null` | Fires after any selection change. |
111
+ | `onRowClick` | `function(row, event)` | `null` | Fires on any row click (besides clicks on buttons/links/inputs inside the row), regardless of `selectable`. |
112
+ | `pagination` | `boolean` | `true` | `false` renders every filtered row with no paging controls. |
113
+ | `pageSize` | `number` | `10` | Rows per page. |
114
+ | `pageSizes` | `number[]` | `null` | If set, renders a page-size `<select>` in the footer (e.g. `[10, 25, 50]`). |
115
+ | `rowClass` | `function(row) => string` | `null` | Extra classes appended to a row's `<tr>`. |
116
+ | `emptyText` / `loadingText` / `errorText` | `string` | English defaults | Shown in place of rows. |
117
+ | `summaryTemplate` | `string` | `":count records"` | Footer left-hand text. `:count` is replaced with the filtered total. |
118
+ | `pageTemplate` | `string` | `":page / :total"` | Footer page indicator. |
119
+ | `prevLabel` / `nextLabel` | `string` | English defaults | `aria-label`s for the pagination buttons. |
120
+ | `toolbar` | `function($slot, instance)` | `null` | Called once at init with a jQuery element appended to the toolbar row — use it to inject bespoke controls that don't fit the generic `filters` shape. |
121
+
122
+ ### Column definition
123
+
124
+ ```js
125
+ {
126
+ key: "price", // dot-path into the row object; omit for a pure render-only column (e.g. actions)
127
+ label: "Price", // header text
128
+ sortable: true, // default: true when `key` is set (unless options.sortable === false, or this column sets false)
129
+ searchable: true, // default: true when `key` is set — whether the global search box matches this column
130
+ columnSearch: true, // opt this column into (or out of, with false) the per-column filter row
131
+ align: "right", // "left" (default) | "right" | "center"
132
+ sortValue: (row) => row.price_cents, // optional: sort by a computed value instead of the raw `key` lookup
133
+ render: (row, value) => `<span class="...">${value}</span>`, // optional: custom cell HTML; omit to just escape-and-print `value`
134
+ cellClass: "whitespace-nowrap",
135
+ }
136
+ ```
137
+
138
+ `render` receives the **raw row** as well as the already-looked-up `value`,
139
+ so a render function can pull in other fields too (e.g. a thumbnail column
140
+ reading both `row.image` and `row.name`).
141
+
142
+ **Action columns** (edit/delete buttons) are just a normal column with
143
+ `sortable: false`, no `columnSearch`, and a `render` that returns whatever
144
+ button markup you need — jtable-pro renders that HTML as-is and does
145
+ **not** wire up clicks on it. Wire those yourself (e.g. `$(document).on("click", ".my-edit-btn", ...)`)
146
+ against whatever stable class/attribute your `render` function emits.
147
+
148
+ ### Filter definition (toolbar dropdowns)
149
+
150
+ ```js
151
+ filters: [
152
+ {
153
+ key: "category_id", // matched with strict-string equality against getValue(row, key)
154
+ label: "Category", // fallback text if allLabel is omitted
155
+ allLabel: "All categories",
156
+ options: [
157
+ { value: "3", label: "Dairy" },
158
+ { value: "7", label: "Produce" },
159
+ ],
160
+ },
161
+ ],
162
+ ```
163
+
164
+ Selecting an option filters rows where `String(getValue(row, key)) ===
165
+ String(option.value)`; selecting the "all" entry clears that filter.
166
+
167
+ ## Methods
168
+
169
+ Call these the usual jQuery-plugin way — `$("#myTable").jtable("methodName", ...args)`:
170
+
171
+ | Method | Description |
172
+ |---|---|
173
+ | `"setData"`, `data` | Replaces the row set and re-renders (resets to page 1). Use this after your own `$.ajax`/`fetch` call when you're not using the plugin's built-in `url` option. |
174
+ | `"getData"` | Returns the current raw row array (unfiltered). |
175
+ | `"reload"` | Re-runs the pipeline; if `url` was configured, re-fetches from the server first. |
176
+ | `"getSelected"` | Returns the array of currently-selected row objects. |
177
+ | `"clearSelection"` | Clears selection and re-renders. |
178
+ | `"destroy"` | Unbinds events and empties the container. |
179
+
180
+ ```js
181
+ // Fetch yourself and hand the plugin the rows:
182
+ $.get("/api/products").done((response) => {
183
+ $("#myTable").jtable("setData", response.data);
184
+ });
185
+
186
+ // Read the bulk-action selection:
187
+ const selectedIds = $("#myTable").jtable("getSelected").map((row) => row.id);
188
+ ```
189
+
190
+ ## Selection & row identity
191
+
192
+ Row identity for selection is always the **string** form of
193
+ `getValue(row, idField)` (`idField` supports dot-paths, e.g.
194
+ `"product.id"`). DOM reads use `.attr("data-jtable-id")` rather than
195
+ jQuery's auto-coercing `.data()`, so a row's identity stays consistent
196
+ whether real ids are numeric, UUIDs, or codes that merely *look* numeric
197
+ (e.g. `"007"`, which jQuery's `.data()` would otherwise silently mis-coerce
198
+ into the number `7`).
199
+
200
+ ## i18n
201
+
202
+ Every user-facing string is a plain option — `searchPlaceholder`,
203
+ `emptyText`, `loadingText`, `errorText`, `summaryTemplate`, `prevLabel`,
204
+ `nextLabel`, column `label`s, filter `label`/`allLabel`/option `label`s.
205
+ There's no bundled translation system; pass whatever locale strings your own
206
+ app already has.
207
+
208
+ ## What it doesn't do
209
+
210
+ - No CSS is bundled — you provide Tailwind. There's no fallback/plain-CSS
211
+ mode.
212
+ - No server-side pagination/sorting/filtering — `url` mode fetches once and
213
+ everything after that runs client-side. For very large datasets, fetch a
214
+ bounded page yourself and call `setData()` again as the user paginates on
215
+ your end, or filter server-side and reload.
216
+ - No built-in row-action wiring (edit/delete/etc.) — a render-only "actions"
217
+ column plus your own click handlers, same as any other column.
218
+
219
+ ## License
220
+
221
+ MIT
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "jtable-pro",
3
+ "version": "1.0.0",
4
+ "description": "Premium, app-like jQuery data-table plugin: global search, per-column search, sortable columns, dropdown filters, client-side pagination and row selection — one file, no build step.",
5
+ "main": "src/jtable.js",
6
+ "module": "src/jtable.js",
7
+ "unpkg": "src/jtable.js",
8
+ "type": "commonjs",
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "keywords": [
15
+ "jquery",
16
+ "jquery-plugin",
17
+ "table",
18
+ "datatable",
19
+ "data-table",
20
+ "grid",
21
+ "sort",
22
+ "filter",
23
+ "search",
24
+ "pagination",
25
+ "tailwind",
26
+ "tailwindcss"
27
+ ],
28
+ "license": "MIT",
29
+ "peerDependencies": {
30
+ "jquery": ">=3.0.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=14"
34
+ }
35
+ }
package/src/jtable.js ADDED
@@ -0,0 +1,861 @@
1
+ /**
2
+ * jtable-pro — premium, app-like jQuery data-table plugin.
3
+ *
4
+ * A floating soft-shadow card, sticky blurred header and rounded, circular
5
+ * hover controls, wired up as a single drop-in plugin: global search,
6
+ * per-column search, column sorting, dropdown filters, client-side
7
+ * pagination and row selection.
8
+ *
9
+ * Requires Tailwind CSS (v4-style opacity-modifier/arbitrary-value classes,
10
+ * e.g. `bg-primary/10`, `shadow-[...]`) with a handful of semantic design
11
+ * tokens defined — see README.md "Requirements" for the exact `@theme`
12
+ * block to add if your project doesn't already have them.
13
+ *
14
+ * Usage: see README.md for the full option/method reference.
15
+ *
16
+ * // Bundler (Vite/webpack/Rollup): CJS/ESM interop handles the import.
17
+ * import $ from "jquery";
18
+ * import "jtable-pro";
19
+ * $("#myTable").jtable({ columns: [...], data: [...] });
20
+ *
21
+ * // Or a plain <script> tag, after jQuery is already loaded:
22
+ * // <script src="https://.../jquery.min.js"></script>
23
+ * // <script src="https://.../jtable-pro/dist/jtable.js"></script>
24
+ *
25
+ * Ships as a single UMD file (like classic jQuery plugins — DataTables,
26
+ * Select2, etc.) so it works both ways above and via AMD, with no build
27
+ * step of its own required.
28
+ */
29
+ (function (factory) {
30
+ if (typeof define === "function" && define.amd) {
31
+ define(["jquery"], factory);
32
+ } else if (typeof module === "object" && module.exports) {
33
+ module.exports = factory(require("jquery"));
34
+ } else {
35
+ factory(jQuery);
36
+ }
37
+ })(function ($) {
38
+
39
+ // -- Icons (inlined Heroicons-outline-style SVGs, no icon font/library needed) --
40
+
41
+ const SEARCH_ICON =
42
+ '<svg class="h-4.5 w-4.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
43
+ '<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />' +
44
+ "</svg>";
45
+
46
+ const CLEAR_ICON =
47
+ '<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
48
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />' +
49
+ "</svg>";
50
+
51
+ const CHEVRON_DOWN_ICON =
52
+ '<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
53
+ '<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />' +
54
+ "</svg>";
55
+
56
+ const CHEVRON_LEFT_ICON =
57
+ '<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
58
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />' +
59
+ "</svg>";
60
+
61
+ const CHEVRON_RIGHT_ICON =
62
+ '<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
63
+ '<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />' +
64
+ "</svg>";
65
+
66
+ // Neutral "sortable, not currently sorted" indicator (chevron-up-down glyph).
67
+ const SORT_NEUTRAL_ICON =
68
+ '<svg class="h-3.5 w-3.5 text-on-surface-variant/40 group-hover:text-on-surface-variant/70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
69
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />' +
70
+ "</svg>";
71
+
72
+ const SORT_ASC_ICON =
73
+ '<svg class="h-3.5 w-3.5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">' +
74
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M5 12.5 12 6l7 6.5" />' +
75
+ "</svg>";
76
+
77
+ const SORT_DESC_ICON =
78
+ '<svg class="h-3.5 w-3.5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">' +
79
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M5 11.5 12 18l7-6.5" />' +
80
+ "</svg>";
81
+
82
+ // -- Small helpers --
83
+
84
+ function escapeHtml(value) {
85
+ return $("<div>").text(value ?? "").html();
86
+ }
87
+
88
+ /** Reads `a.b.c` style dotted paths off a row object. */
89
+ function getValue(row, key) {
90
+ if (!key) {
91
+ return undefined;
92
+ }
93
+ return key.split(".").reduce((acc, part) => (acc == null ? acc : acc[part]), row);
94
+ }
95
+
96
+ /** Locale/numeric-aware comparator used by default column sorting. */
97
+ function compareValues(a, b) {
98
+ if (a == null && b == null) return 0;
99
+ if (a == null) return -1;
100
+ if (b == null) return 1;
101
+
102
+ if (typeof a === "number" && typeof b === "number") {
103
+ return a - b;
104
+ }
105
+
106
+ const na = Number(a);
107
+ const nb = Number(b);
108
+ if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) {
109
+ return na - nb;
110
+ }
111
+
112
+ return String(a).localeCompare(String(b), undefined, { sensitivity: "base", numeric: true });
113
+ }
114
+
115
+ /**
116
+ * Default `url`-mode response unwrapping: works out of the box for a plain
117
+ * array, `{ data: [...] }`, or a nested `{ data: { data: [...] } }` (the
118
+ * common shape of a Laravel paginator's JSON). Pass your own
119
+ * `responseAdapter` option if your API returns rows some other way.
120
+ */
121
+ function defaultResponseAdapter(json) {
122
+ if (Array.isArray(json)) {
123
+ return json;
124
+ }
125
+ if (Array.isArray(json?.data)) {
126
+ return json.data;
127
+ }
128
+ if (Array.isArray(json?.data?.data)) {
129
+ return json.data.data;
130
+ }
131
+ if (Array.isArray(json?.items)) {
132
+ return json.items;
133
+ }
134
+ if (Array.isArray(json?.data?.items)) {
135
+ return json.data.items;
136
+ }
137
+ return [];
138
+ }
139
+
140
+ let instanceCounter = 0;
141
+
142
+ // A single page-lifetime delegated listener (not one per instance) that
143
+ // closes any open filter dropdown on an outside click.
144
+ let outsideClickBound = false;
145
+ function ensureOutsideClickHandler() {
146
+ if (outsideClickBound) {
147
+ return;
148
+ }
149
+ outsideClickBound = true;
150
+ $(document).on("click", (e) => {
151
+ if (!$(e.target).closest(".jtable-filter").length) {
152
+ $(".jtable-filter-panel").addClass("hidden");
153
+ }
154
+ });
155
+ }
156
+
157
+ const DEFAULTS = {
158
+ // Column definitions — see README.md for the full shape.
159
+ columns: [],
160
+
161
+ // Data source: pass one of these two.
162
+ data: null, // static array of row objects
163
+ url: null, // ajax endpoint, fetched once via $.ajax({ url, data: requestParams })
164
+ requestParams: {},
165
+ requestMethod: "GET",
166
+ responseAdapter: defaultResponseAdapter, // (json) => row array
167
+
168
+ // Unique row identifier field, used by selection.
169
+ idField: "id",
170
+
171
+ // Global search box (searches every column not explicitly excluded).
172
+ search: true,
173
+ searchPlaceholder: "Search...",
174
+
175
+ // Per-column search inputs under the header. `false` disables globally;
176
+ // individual columns can still opt in with `column.columnSearch: true`.
177
+ columnSearch: false,
178
+
179
+ // Dropdown filters rendered in the toolbar, e.g. category/status selects.
180
+ // [{ key, label, allLabel, options: [{ value, label }] }]
181
+ filters: [],
182
+
183
+ // 'single' (click a row to select it, one at a time) | 'multi' (checkbox
184
+ // column, multi-select) | false (no selection UI).
185
+ selectable: false,
186
+ onSelectionChange: null,
187
+
188
+ // Pagination (client-side). Set to false to render every filtered row.
189
+ pagination: true,
190
+ pageSize: 10,
191
+ pageSizes: null, // e.g. [10, 25, 50] to render a page-size <select>
192
+
193
+ // Row-level extras.
194
+ rowClass: null, // function(row) => extra class string
195
+ onRowClick: null, // function(row, event)
196
+
197
+ // Text — all user-facing strings are plain options, so you can localize
198
+ // them however your project already does (see README.md "i18n").
199
+ emptyText: "No records found",
200
+ loadingText: "Loading...",
201
+ errorText: "Failed to load data",
202
+ summaryTemplate: ":count records",
203
+ prevLabel: "Previous page",
204
+ nextLabel: "Next page",
205
+ pageTemplate: ":page / :total",
206
+
207
+ // Called once, right after the toolbar is built, with the toolbar's
208
+ // right-hand slot ($el) so callers can append their own extra controls.
209
+ toolbar: null,
210
+ };
211
+
212
+ const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, label, .jtable-no-row-click";
213
+
214
+ class JTable {
215
+ constructor(el, options) {
216
+ this.id = `jtable-${++instanceCounter}`;
217
+ this.$el = $(el);
218
+ this.options = $.extend(true, {}, DEFAULTS, options);
219
+ this.state = {
220
+ rawData: [],
221
+ search: "",
222
+ columnFilters: {},
223
+ activeFilters: {},
224
+ sortKey: null,
225
+ sortDir: null, // 'asc' | 'desc'
226
+ page: 1,
227
+ pageSize: this.options.pageSize,
228
+ selected: new Set(),
229
+ loading: false,
230
+ error: false,
231
+ };
232
+
233
+ this._buildSkeleton();
234
+ this._bindEvents();
235
+ this.reload();
236
+ }
237
+
238
+ // -- Rendering the static shell (toolbar + table + footer) --
239
+
240
+ _buildSkeleton() {
241
+ const o = this.options;
242
+ const hasToolbar = o.search || o.filters.length > 0 || typeof o.toolbar === "function";
243
+
244
+ let html = "";
245
+
246
+ if (hasToolbar) {
247
+ html += `
248
+ <div class="jtable-toolbar flex flex-col gap-3 rounded-2xl bg-surface-container-lowest p-4 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_4px_24px_-8px_rgba(16,24,40,0.1)] ring-1 ring-black/[0.03] sm:flex-row sm:items-center">
249
+ ${o.search ? this._searchBoxHtml() : ""}
250
+ <div class="jtable-filters flex flex-wrap items-center gap-2"></div>
251
+ <div class="jtable-toolbar-extra flex flex-wrap items-center gap-2"></div>
252
+ </div>
253
+ `;
254
+ }
255
+
256
+ html += `
257
+ <div class="jtable-card mt-6 overflow-hidden rounded-2xl bg-surface-container-lowest shadow-[0_1px_2px_rgba(16,24,40,0.04),0_4px_24px_-8px_rgba(16,24,40,0.1)] ring-1 ring-black/[0.03]">
258
+ <div class="jtable-scroll overflow-auto">
259
+ <table class="w-full whitespace-nowrap text-left">
260
+ <thead class="sticky top-0 z-10"></thead>
261
+ <tbody class="jtable-body divide-y divide-outline-variant/40"></tbody>
262
+ </table>
263
+ </div>
264
+ <div class="jtable-footer flex flex-wrap items-center justify-between gap-3 border-t border-outline-variant/60 px-4 py-3">
265
+ <p class="jtable-summary text-body-sm text-on-surface-variant"></p>
266
+ <div class="jtable-pagination flex items-center gap-1"></div>
267
+ </div>
268
+ </div>
269
+ `;
270
+
271
+ if (!hasToolbar) {
272
+ // Strip the leading margin the card would otherwise carry when
273
+ // it's the very first thing inside the container.
274
+ html = html.replace("jtable-card mt-6", "jtable-card");
275
+ }
276
+
277
+ this.$el.addClass("jtable").html(html);
278
+
279
+ this.$search = this.$el.find(".jtable-search-input");
280
+ this.$searchClear = this.$el.find(".jtable-search-clear");
281
+ this.$filters = this.$el.find(".jtable-filters");
282
+ this.$toolbarExtra = this.$el.find(".jtable-toolbar-extra");
283
+ this.$thead = this.$el.find("thead");
284
+ this.$tbody = this.$el.find(".jtable-body");
285
+ this.$summary = this.$el.find(".jtable-summary");
286
+ this.$pagination = this.$el.find(".jtable-pagination");
287
+
288
+ this._renderFilters();
289
+ this._renderHead();
290
+
291
+ if (typeof o.toolbar === "function") {
292
+ o.toolbar(this.$toolbarExtra, this);
293
+ }
294
+ }
295
+
296
+ _searchBoxHtml() {
297
+ return `
298
+ <div class="jtable-search relative flex-1">
299
+ <span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface-variant">
300
+ ${SEARCH_ICON}
301
+ </span>
302
+ <input
303
+ type="text"
304
+ class="jtable-search-input w-full rounded-xl border-0 bg-surface-container/60 py-2.5 pl-10 pr-9 text-body-sm text-on-surface placeholder:text-on-surface-variant transition-all duration-200 focus:bg-surface-container-lowest focus:shadow-[0_1px_2px_rgba(16,24,40,0.06)] focus:outline-none focus:ring-2 focus:ring-primary"
305
+ placeholder="${escapeHtml(this.options.searchPlaceholder)}"
306
+ >
307
+ <button type="button" class="jtable-search-clear jtable-no-row-click absolute inset-y-0 right-0 hidden items-center pr-3 text-on-surface-variant transition hover:text-on-surface" aria-label="Clear">
308
+ ${CLEAR_ICON}
309
+ </button>
310
+ </div>
311
+ `;
312
+ }
313
+
314
+ _renderFilters() {
315
+ const filters = this.options.filters || [];
316
+ if (!filters.length) {
317
+ return;
318
+ }
319
+
320
+ const html = filters
321
+ .map((filter) => {
322
+ const optionsHtml = [`<a href="#" data-value="" class="block px-3.5 py-2 text-body-sm text-on-surface-variant hover:bg-surface-container">${escapeHtml(filter.allLabel ?? filter.label)}</a>`]
323
+ .concat(
324
+ (filter.options || []).map(
325
+ (opt) =>
326
+ `<a href="#" data-value="${escapeHtml(opt.value)}" class="block px-3.5 py-2 text-body-sm text-on-surface-variant hover:bg-surface-container">${escapeHtml(opt.label)}</a>`
327
+ )
328
+ )
329
+ .join("");
330
+
331
+ return `
332
+ <div class="jtable-filter relative" data-filter-key="${escapeHtml(filter.key)}">
333
+ <button type="button" class="jtable-filter-toggle flex items-center justify-between gap-2 rounded-xl bg-surface-container/60 px-3.5 py-2.5 text-body-sm font-medium text-on-surface transition-all duration-200 hover:bg-surface-container">
334
+ <span class="jtable-filter-label truncate">${escapeHtml(filter.allLabel ?? filter.label)}</span>
335
+ ${CHEVRON_DOWN_ICON}
336
+ </button>
337
+ <div class="jtable-filter-panel absolute left-0 right-0 z-20 mt-2 hidden max-h-64 min-w-[10rem] overflow-y-auto rounded-xl bg-surface-container-lowest py-1 shadow-[0_4px_24px_-4px_rgba(16,24,40,0.15)] ring-1 ring-black/[0.05]">
338
+ ${optionsHtml}
339
+ </div>
340
+ </div>
341
+ `;
342
+ })
343
+ .join("");
344
+
345
+ this.$filters.html(html);
346
+ }
347
+
348
+ _renderHead() {
349
+ const o = this.options;
350
+ const columns = o.columns;
351
+ const anyColumnSearch = columns.some((c) => c.columnSearch === true) || (o.columnSearch && columns.some((c) => c.key && c.columnSearch !== false));
352
+
353
+ let headRow = "<tr>";
354
+ if (o.selectable === "multi") {
355
+ headRow += `<th class="w-10 py-3 pl-4"><input type="checkbox" class="jtable-select-all h-4 w-4 rounded border-outline-variant text-primary focus:ring-primary"></th>`;
356
+ }
357
+
358
+ columns.forEach((col, index) => {
359
+ const sortable = col.sortable !== false && col.key && o.sortable !== false;
360
+ const align = col.align === "right" ? "text-right justify-end" : col.align === "center" ? "text-center justify-center" : "text-left justify-start";
361
+ const padStart = index === 0 && o.selectable !== "multi" ? "pl-4" : "px-3";
362
+ const padEnd = index === columns.length - 1 ? "pr-4" : "";
363
+
364
+ headRow += `
365
+ <th class="${padStart} ${padEnd} py-3 text-label-md font-semibold uppercase tracking-wider text-on-surface-variant ${col.align === "right" ? "text-right" : ""}" data-col="${index}">
366
+ ${
367
+ sortable
368
+ ? `<button type="button" class="jtable-sort-btn jtable-no-row-click group inline-flex items-center gap-1 ${align}" data-key="${escapeHtml(col.key)}">
369
+ <span>${escapeHtml(col.label ?? "")}</span>
370
+ <span class="jtable-sort-icon inline-flex">${SORT_NEUTRAL_ICON}</span>
371
+ </button>`
372
+ : `<span>${escapeHtml(col.label ?? "")}</span>`
373
+ }
374
+ </th>
375
+ `;
376
+ });
377
+ headRow += "</tr>";
378
+
379
+ let filterRow = "";
380
+ if (anyColumnSearch) {
381
+ filterRow = "<tr class=\"jtable-filter-row border-b border-outline-variant/40 bg-surface-container/40\">";
382
+ if (o.selectable === "multi") {
383
+ filterRow += `<td class="py-2 pl-4"></td>`;
384
+ }
385
+ columns.forEach((col, index) => {
386
+ const enabled = col.columnSearch === true || (o.columnSearch && col.key && col.columnSearch !== false);
387
+ const padStart = index === 0 && o.selectable !== "multi" ? "pl-4" : "px-3";
388
+ filterRow += `<td class="${padStart} py-2">`;
389
+ if (enabled) {
390
+ filterRow += `<input type="text" class="jtable-col-search w-full rounded-lg border-0 bg-surface-container-lowest px-2.5 py-1.5 text-body-sm text-on-surface placeholder:text-on-surface-variant/70 ring-1 ring-inset ring-outline-variant/60 focus:outline-none focus:ring-2 focus:ring-primary" data-key="${escapeHtml(col.key)}" placeholder="Filter...">`;
391
+ }
392
+ filterRow += "</td>";
393
+ });
394
+ filterRow += "</tr>";
395
+ }
396
+
397
+ this.$thead.html(headRow.replace("<tr>", '<tr class="border-b border-outline-variant/60 bg-surface-container/95 backdrop-blur">') + filterRow);
398
+ }
399
+
400
+ // -- Events (all scoped to this.$el — the whole subtree can simply be
401
+ // torn down/rebuilt by your own routing/navigation code with no extra
402
+ // cleanup, since jQuery's own cleanData runs on any removed descendant) --
403
+
404
+ _bindEvents() {
405
+ let searchTimer = null;
406
+ this.$el.on("input", ".jtable-search-input", (e) => {
407
+ const value = $(e.target).val();
408
+ this.$searchClear.toggleClass("hidden", !value);
409
+ clearTimeout(searchTimer);
410
+ searchTimer = setTimeout(() => {
411
+ this.state.search = value.trim().toLowerCase();
412
+ this.state.page = 1;
413
+ this._render();
414
+ }, 150);
415
+ });
416
+
417
+ this.$el.on("click", ".jtable-search-clear", () => {
418
+ this.$search.val("").trigger("focus");
419
+ this.$searchClear.addClass("hidden");
420
+ this.state.search = "";
421
+ this.state.page = 1;
422
+ this._render();
423
+ });
424
+
425
+ let colSearchTimer = null;
426
+ this.$el.on("input", ".jtable-col-search", (e) => {
427
+ const $input = $(e.target);
428
+ const key = $input.data("key");
429
+ const value = $input.val();
430
+ clearTimeout(colSearchTimer);
431
+ colSearchTimer = setTimeout(() => {
432
+ if (value) {
433
+ this.state.columnFilters[key] = value.trim().toLowerCase();
434
+ } else {
435
+ delete this.state.columnFilters[key];
436
+ }
437
+ this.state.page = 1;
438
+ this._render();
439
+ }, 150);
440
+ });
441
+
442
+ this.$el.on("click", ".jtable-sort-btn", (e) => {
443
+ const key = $(e.currentTarget).data("key");
444
+ if (this.state.sortKey !== key) {
445
+ this.state.sortKey = key;
446
+ this.state.sortDir = "asc";
447
+ } else if (this.state.sortDir === "asc") {
448
+ this.state.sortDir = "desc";
449
+ } else {
450
+ this.state.sortKey = null;
451
+ this.state.sortDir = null;
452
+ }
453
+ this._render();
454
+ });
455
+
456
+ // Dropdown filters (category/status style selects in the toolbar).
457
+ this.$el.on("click", ".jtable-filter-toggle", (e) => {
458
+ e.stopPropagation();
459
+ const $panel = $(e.currentTarget).siblings(".jtable-filter-panel");
460
+ const wasOpen = !$panel.hasClass("hidden");
461
+ this.$el.find(".jtable-filter-panel").addClass("hidden");
462
+ $panel.toggleClass("hidden", wasOpen);
463
+ });
464
+
465
+ this.$el.on("click", ".jtable-filter-panel a", (e) => {
466
+ e.preventDefault();
467
+ const $option = $(e.currentTarget);
468
+ const $filter = $option.closest(".jtable-filter");
469
+ const key = $filter.data("filter-key");
470
+ const value = $option.data("value");
471
+
472
+ $filter.find(".jtable-filter-label").text($option.text());
473
+ $filter.find(".jtable-filter-panel").addClass("hidden");
474
+
475
+ if (value === "" || value == null) {
476
+ delete this.state.activeFilters[key];
477
+ } else {
478
+ this.state.activeFilters[key] = String(value);
479
+ }
480
+ this.state.page = 1;
481
+ this._render();
482
+ });
483
+
484
+ ensureOutsideClickHandler();
485
+
486
+ // Row selection.
487
+ this.$tbody.on("click", "tr[data-jtable-id]", (e) => {
488
+ if ($(e.target).closest(INTERACTIVE_SELECTOR).length) {
489
+ return;
490
+ }
491
+ const $row = $(e.currentTarget);
492
+ const id = $row.attr("data-jtable-id");
493
+ const row = this.state.rawData.find((r) => this._rowId(r) === id);
494
+
495
+ if (this.options.selectable === "single") {
496
+ this.state.selected = this.state.selected.has(id) ? new Set() : new Set([id]);
497
+ this._updateSelectionUi();
498
+ this._fireSelectionChange();
499
+ }
500
+
501
+ if (typeof this.options.onRowClick === "function") {
502
+ this.options.onRowClick(row, e);
503
+ }
504
+ });
505
+
506
+ this.$tbody.on("change", ".jtable-row-check", (e) => {
507
+ const id = $(e.currentTarget).attr("data-jtable-id");
508
+ if (e.currentTarget.checked) {
509
+ this.state.selected.add(id);
510
+ } else {
511
+ this.state.selected.delete(id);
512
+ }
513
+ this._updateSelectionUi();
514
+ this._fireSelectionChange();
515
+ });
516
+
517
+ this.$thead.on("change", ".jtable-select-all", (e) => {
518
+ const checked = e.currentTarget.checked;
519
+ this._currentPageRows().forEach((row) => {
520
+ const id = this._rowId(row);
521
+ if (checked) {
522
+ this.state.selected.add(id);
523
+ } else {
524
+ this.state.selected.delete(id);
525
+ }
526
+ });
527
+ this._render();
528
+ this._fireSelectionChange();
529
+ });
530
+
531
+ // Pagination.
532
+ this.$pagination.on("click", "[data-page]", (e) => {
533
+ const action = $(e.currentTarget).data("page");
534
+ const totalPages = this._totalPages();
535
+ if (action === "prev") this.state.page = Math.max(1, this.state.page - 1);
536
+ else if (action === "next") this.state.page = Math.min(totalPages, this.state.page + 1);
537
+ this._render();
538
+ });
539
+
540
+ this.$pagination.on("change", ".jtable-page-size", (e) => {
541
+ this.state.pageSize = Number($(e.target).val());
542
+ this.state.page = 1;
543
+ this._render();
544
+ });
545
+ }
546
+
547
+ // -- Data loading --
548
+
549
+ reload() {
550
+ const o = this.options;
551
+
552
+ if (!o.url) {
553
+ this.state.rawData = Array.isArray(o.data) ? o.data : [];
554
+ this._render();
555
+ return;
556
+ }
557
+
558
+ this.state.loading = true;
559
+ this.state.error = false;
560
+ this._render();
561
+
562
+ $.ajax({ url: o.url, method: o.requestMethod, data: o.requestParams, dataType: "json" })
563
+ .done((response) => {
564
+ this.state.rawData = o.responseAdapter(response) || [];
565
+ this.state.loading = false;
566
+ })
567
+ .fail(() => {
568
+ this.state.rawData = [];
569
+ this.state.loading = false;
570
+ this.state.error = true;
571
+ })
572
+ .always(() => this._render());
573
+ }
574
+
575
+ setData(data) {
576
+ this.state.rawData = Array.isArray(data) ? data : [];
577
+ this.state.loading = false;
578
+ this.state.error = false;
579
+ this.state.page = 1;
580
+ this._render();
581
+ }
582
+
583
+ getData() {
584
+ return this.state.rawData;
585
+ }
586
+
587
+ // -- Filtering / sorting / pagination pipeline --
588
+
589
+ _matchesGlobalSearch(row) {
590
+ const term = this.state.search;
591
+ if (!term) {
592
+ return true;
593
+ }
594
+
595
+ return this.options.columns.some((col) => {
596
+ if (!col.key || col.searchable === false) {
597
+ return false;
598
+ }
599
+ const value = getValue(row, col.key);
600
+ return value != null && String(value).toLowerCase().includes(term);
601
+ });
602
+ }
603
+
604
+ _matchesColumnFilters(row) {
605
+ return Object.entries(this.state.columnFilters).every(([key, term]) => {
606
+ const value = getValue(row, key);
607
+ return value != null && String(value).toLowerCase().includes(term);
608
+ });
609
+ }
610
+
611
+ _matchesActiveFilters(row) {
612
+ return Object.entries(this.state.activeFilters).every(([key, value]) => String(getValue(row, key)) === value);
613
+ }
614
+
615
+ _filteredSorted() {
616
+ let rows = this.state.rawData.filter(
617
+ (row) => this._matchesGlobalSearch(row) && this._matchesColumnFilters(row) && this._matchesActiveFilters(row)
618
+ );
619
+
620
+ const { sortKey, sortDir } = this.state;
621
+ if (sortKey && sortDir) {
622
+ const column = this.options.columns.find((c) => c.key === sortKey);
623
+ const sortValue = column?.sortValue;
624
+ rows = rows.slice().sort((a, b) => {
625
+ const av = sortValue ? sortValue(a) : getValue(a, sortKey);
626
+ const bv = sortValue ? sortValue(b) : getValue(b, sortKey);
627
+ const result = compareValues(av, bv);
628
+ return sortDir === "asc" ? result : -result;
629
+ });
630
+ }
631
+
632
+ return rows;
633
+ }
634
+
635
+ _totalPages() {
636
+ if (this.options.pagination === false) {
637
+ return 1;
638
+ }
639
+ const total = this._filteredSorted().length;
640
+ return Math.max(1, Math.ceil(total / this.state.pageSize));
641
+ }
642
+
643
+ _currentPageRows() {
644
+ const filtered = this._filteredSorted();
645
+ if (this.options.pagination === false) {
646
+ return filtered;
647
+ }
648
+
649
+ const totalPages = Math.max(1, Math.ceil(filtered.length / this.state.pageSize));
650
+ this.state.page = Math.min(this.state.page, totalPages);
651
+ const start = (this.state.page - 1) * this.state.pageSize;
652
+ return filtered.slice(start, start + this.state.pageSize);
653
+ }
654
+
655
+ // -- Rendering rows / footer / sort indicators --
656
+
657
+ _render() {
658
+ this._renderSortIndicators();
659
+
660
+ if (this.state.loading) {
661
+ this._renderSkeleton();
662
+ this._renderFooter(0);
663
+ return;
664
+ }
665
+
666
+ if (this.state.error) {
667
+ this._renderMessageRow(this.options.errorText, "text-red-600");
668
+ this._renderFooter(0);
669
+ return;
670
+ }
671
+
672
+ const filtered = this._filteredSorted();
673
+ const pageRows = this._currentPageRows();
674
+
675
+ if (!filtered.length) {
676
+ this._renderMessageRow(this.options.emptyText);
677
+ this._renderFooter(0);
678
+ return;
679
+ }
680
+
681
+ this.$tbody.html(pageRows.map((row) => this._rowHtml(row)).join(""));
682
+ this._updateSelectionUi();
683
+ this._renderFooter(filtered.length);
684
+ }
685
+
686
+ _colSpan() {
687
+ return this.options.columns.length + (this.options.selectable === "multi" ? 1 : 0);
688
+ }
689
+
690
+ _renderMessageRow(text, extraClass = "") {
691
+ this.$tbody.html(
692
+ `<tr><td colspan="${this._colSpan()}" class="px-4 py-10 text-center text-body-sm text-on-surface-variant ${extraClass}">${escapeHtml(text)}</td></tr>`
693
+ );
694
+ }
695
+
696
+ _renderSkeleton() {
697
+ const cols = this._colSpan();
698
+ const rows = Array.from({ length: 5 })
699
+ .map(() => {
700
+ const cells = Array.from({ length: cols })
701
+ .map(() => `<td class="px-3 py-3.5"><div class="h-3.5 w-full max-w-[10rem] animate-pulse rounded-full bg-surface-container"></div></td>`)
702
+ .join("");
703
+ return `<tr>${cells}</tr>`;
704
+ })
705
+ .join("");
706
+ this.$tbody.html(rows);
707
+ }
708
+
709
+ _rowHtml(row) {
710
+ const o = this.options;
711
+ const id = this._rowId(row);
712
+ const isSelected = this.state.selected.has(id);
713
+ const extraClass = typeof o.rowClass === "function" ? o.rowClass(row) ?? "" : "";
714
+
715
+ let selectionClass = "border-l-transparent";
716
+ if (o.selectable === "single" && isSelected) {
717
+ selectionClass = "border-l-primary bg-primary/5";
718
+ }
719
+
720
+ let html = `<tr data-jtable-id="${escapeHtml(id)}" class="${o.selectable ? "cursor-pointer" : ""} border-l-2 ${selectionClass} transition-colors duration-150 hover:bg-surface-container/40 ${extraClass}">`;
721
+
722
+ if (o.selectable === "multi") {
723
+ html += `<td class="py-3 pl-4"><input type="checkbox" class="jtable-row-check jtable-no-row-click h-4 w-4 rounded border-outline-variant text-primary focus:ring-primary" data-jtable-id="${escapeHtml(id)}" ${isSelected ? "checked" : ""}></td>`;
724
+ }
725
+
726
+ o.columns.forEach((col, index) => {
727
+ const value = getValue(row, col.key);
728
+ const content = typeof col.render === "function" ? col.render(row, value) : escapeHtml(value ?? "—");
729
+ const padStart = index === 0 && o.selectable !== "multi" ? "pl-4" : "px-3";
730
+ const padEnd = index === o.columns.length - 1 ? "pr-4" : "";
731
+ const align = col.align === "right" ? "text-right" : col.align === "center" ? "text-center" : "";
732
+ html += `<td class="${padStart} ${padEnd} py-3 ${align} ${col.cellClass ?? ""}">${content}</td>`;
733
+ });
734
+
735
+ html += "</tr>";
736
+ return html;
737
+ }
738
+
739
+ _renderSortIndicators() {
740
+ this.$thead.find(".jtable-sort-btn").each((_, btn) => {
741
+ const $btn = $(btn);
742
+ const key = $btn.data("key");
743
+ const $icon = $btn.find(".jtable-sort-icon");
744
+ if (this.state.sortKey === key) {
745
+ $icon.html(this.state.sortDir === "asc" ? SORT_ASC_ICON : SORT_DESC_ICON);
746
+ } else {
747
+ $icon.html(SORT_NEUTRAL_ICON);
748
+ }
749
+ });
750
+ }
751
+
752
+ _updateSelectionUi() {
753
+ const o = this.options;
754
+ if (!o.selectable) {
755
+ return;
756
+ }
757
+
758
+ this.$tbody.find("tr[data-jtable-id]").each((_, tr) => {
759
+ const $row = $(tr);
760
+ const id = $row.attr("data-jtable-id");
761
+ const isSelected = this.state.selected.has(id);
762
+ if (o.selectable === "single") {
763
+ $row.toggleClass("border-l-primary bg-primary/5", isSelected).toggleClass("border-l-transparent", !isSelected);
764
+ }
765
+ });
766
+
767
+ if (o.selectable === "multi") {
768
+ const pageIds = this._currentPageRows().map((row) => this._rowId(row));
769
+ const allSelected = pageIds.length > 0 && pageIds.every((id) => this.state.selected.has(id));
770
+ const someSelected = pageIds.some((id) => this.state.selected.has(id));
771
+ const $selectAll = this.$thead.find(".jtable-select-all");
772
+ $selectAll.prop("checked", allSelected);
773
+ $selectAll.prop("indeterminate", !allSelected && someSelected);
774
+ }
775
+ }
776
+
777
+ _fireSelectionChange() {
778
+ if (typeof this.options.onSelectionChange === "function") {
779
+ this.options.onSelectionChange(this.getSelected(), this.state.selected);
780
+ }
781
+ }
782
+
783
+ // Selection state always keys on the String()-coerced id — DOM reads use
784
+ // .attr() (never jQuery's auto-coercing .data()) so a row's identity
785
+ // matches consistently whether ids are numeric or string-typed (UUIDs,
786
+ // zero-padded codes, etc.).
787
+ _rowId(row) {
788
+ return String(getValue(row, this.options.idField));
789
+ }
790
+
791
+ getSelected() {
792
+ return this.state.rawData.filter((row) => this.state.selected.has(this._rowId(row)));
793
+ }
794
+
795
+ clearSelection() {
796
+ this.state.selected = new Set();
797
+ this._render();
798
+ this._fireSelectionChange();
799
+ }
800
+
801
+ _renderFooter(count) {
802
+ this.$summary.text(this.options.summaryTemplate.replace(":count", count));
803
+
804
+ if (this.options.pagination === false) {
805
+ this.$pagination.empty();
806
+ return;
807
+ }
808
+
809
+ const totalPages = this._totalPages();
810
+ const pageText = this.options.pageTemplate.replace(":page", this.state.page).replace(":total", totalPages);
811
+
812
+ let html = "";
813
+ if (this.options.pageSizes) {
814
+ html += `
815
+ <select class="jtable-page-size mr-2 rounded-lg border-0 bg-surface-container/60 px-2 py-1.5 text-body-sm text-on-surface ring-1 ring-inset ring-outline-variant/60 focus:outline-none focus:ring-2 focus:ring-primary">
816
+ ${this.options.pageSizes.map((size) => `<option value="${size}" ${size === this.state.pageSize ? "selected" : ""}>${size}</option>`).join("")}
817
+ </select>
818
+ `;
819
+ }
820
+
821
+ html += `
822
+ <button type="button" data-page="prev" class="flex h-8 w-8 items-center justify-center rounded-xl border border-outline-variant text-on-surface-variant transition hover:bg-surface-container disabled:cursor-not-allowed disabled:opacity-40" aria-label="${escapeHtml(this.options.prevLabel)}" ${this.state.page <= 1 ? "disabled" : ""}>
823
+ ${CHEVRON_LEFT_ICON}
824
+ </button>
825
+ <span class="px-2 text-body-sm font-medium text-on-surface">${escapeHtml(pageText)}</span>
826
+ <button type="button" data-page="next" class="flex h-8 w-8 items-center justify-center rounded-xl border border-outline-variant text-on-surface-variant transition hover:bg-surface-container disabled:cursor-not-allowed disabled:opacity-40" aria-label="${escapeHtml(this.options.nextLabel)}" ${this.state.page >= totalPages ? "disabled" : ""}>
827
+ ${CHEVRON_RIGHT_ICON}
828
+ </button>
829
+ `;
830
+
831
+ this.$pagination.html(html);
832
+ }
833
+
834
+ destroy() {
835
+ this.$el.off();
836
+ this.$el.removeData("jtable").empty().removeClass("jtable");
837
+ }
838
+ }
839
+
840
+ $.fn.jtable = function jtablePlugin(optionsOrMethod, ...args) {
841
+ if (typeof optionsOrMethod === "string") {
842
+ let result;
843
+ this.each(function () {
844
+ const instance = $(this).data("jtable");
845
+ if (instance && typeof instance[optionsOrMethod] === "function") {
846
+ result = instance[optionsOrMethod](...args);
847
+ }
848
+ });
849
+ return result !== undefined ? result : this;
850
+ }
851
+
852
+ return this.each(function () {
853
+ if ($(this).data("jtable")) {
854
+ return;
855
+ }
856
+ $(this).data("jtable", new JTable(this, optionsOrMethod));
857
+ });
858
+ };
859
+
860
+ return $.fn.jtable;
861
+ });