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.
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/package.json +35 -0
- 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
|
+
});
|