@renderorange/alanbradley 2.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 +258 -0
- package/package.json +46 -0
- package/src/alanbradley.css +235 -0
- package/src/alanbradley.js +733 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Blaine Motsinger
|
|
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,258 @@
|
|
|
1
|
+
# @renderorange/alanbradley
|
|
2
|
+
|
|
3
|
+
Lightweight table filter, sorting, and pagination library, with optional background chunking.
|
|
4
|
+
|
|
5
|
+
`alanbradley` fights for the user on the grid.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- zero dependencies
|
|
10
|
+
- progressive background chunking for large datasets
|
|
11
|
+
- client-side sort (numeric, date, string)
|
|
12
|
+
- global text search across configurable fields (including nested data)
|
|
13
|
+
- per-column dropdown filters
|
|
14
|
+
- pagination with configurable page sizes
|
|
15
|
+
- expandable subtable rows
|
|
16
|
+
- fully themable via CSS custom properties
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @renderorange/alanbradley
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or copy `src/alanbradley.js` and `src/alanbradley.css` into your project.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```html
|
|
29
|
+
<link rel="stylesheet" href="alanbradley.css" />
|
|
30
|
+
|
|
31
|
+
<table id="my-table">
|
|
32
|
+
<tbody></tbody>
|
|
33
|
+
</table>
|
|
34
|
+
<div class="alanbradley-status"></div>
|
|
35
|
+
|
|
36
|
+
<script src="alanbradley.js"></script>
|
|
37
|
+
<script>
|
|
38
|
+
new AlanBradley("#my-table", {
|
|
39
|
+
api: "/api/items",
|
|
40
|
+
columns: [
|
|
41
|
+
{ key: "id", label: "ID", sortable: true },
|
|
42
|
+
{ key: "name", label: "Name", sortable: true },
|
|
43
|
+
{ key: "status", label: "Status", sortable: true },
|
|
44
|
+
],
|
|
45
|
+
filters: [
|
|
46
|
+
{ key: "status", label: "Status", options: ["active", "closed"] },
|
|
47
|
+
],
|
|
48
|
+
search_fields: ["name", "status"],
|
|
49
|
+
render_row: function (item) {
|
|
50
|
+
return (
|
|
51
|
+
"<tr>" +
|
|
52
|
+
'<td data-label="ID">' + item.id + "</td>" +
|
|
53
|
+
'<td data-label="Name">' + item.name + "</td>" +
|
|
54
|
+
'<td data-label="Status">' + item.status + "</td>" +
|
|
55
|
+
"</tr>"
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
</script>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API Contract
|
|
63
|
+
|
|
64
|
+
Your API endpoint must accept:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
GET /api/items?chunk=1&page_size=500
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
And return:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"data": [{ "id": 1, "name": "Item 1" }],
|
|
75
|
+
"total": 150,
|
|
76
|
+
"chunk": 1,
|
|
77
|
+
"page_size": 500
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Field | Type | Description |
|
|
82
|
+
| ----------- | ------ | ---------------------------- |
|
|
83
|
+
| `data` | array | Array of row objects |
|
|
84
|
+
| `total` | number | Total records in the dataset |
|
|
85
|
+
| `chunk` | number | Current chunk number |
|
|
86
|
+
| `page_size` | number | Records per chunk |
|
|
87
|
+
|
|
88
|
+
## Options
|
|
89
|
+
|
|
90
|
+
| Option | Type | Default | Description |
|
|
91
|
+
| -------------------- | -------- | --------------------- | -------------------------------------------- |
|
|
92
|
+
| `api` | string | required | API endpoint URL |
|
|
93
|
+
| `columns` | array | required | Column definitions |
|
|
94
|
+
| `columns[].key` | string | required | Data field name (also used as sort key) |
|
|
95
|
+
| `columns[].label` | string | required | Header display text |
|
|
96
|
+
| `columns[].sortable` | boolean | `false` | Whether column is sortable |
|
|
97
|
+
| `filters` | array | `[]` | Dropdown filter definitions |
|
|
98
|
+
| `filters[].key` | string | required | Data field to filter on |
|
|
99
|
+
| `filters[].label` | string | required | Display label |
|
|
100
|
+
| `filters[].options` | array | required | Values (strings or `{value, label}` objects) |
|
|
101
|
+
| `search_fields` | array | `[]` | Field names to search across (supports dot-notation for nested data) |
|
|
102
|
+
| `render_row` | function | required | Returns HTML string for a data row |
|
|
103
|
+
| `render_expanded` | function | `null` | Returns HTML string for expanded row content |
|
|
104
|
+
| `on_expand` | function | `null` | Callback fired when a row is expanded |
|
|
105
|
+
| `on_collapse` | function | `null` | Callback fired when a row is collapsed |
|
|
106
|
+
| `page_size` | number | `50` | Rows per page |
|
|
107
|
+
| `page_size_options` | array | `[25, 50, 100]` | Available page sizes |
|
|
108
|
+
| `chunk` | boolean | `true` | Enable progressive chunked loading |
|
|
109
|
+
| `chunk_size` | number | `500` | Records per chunk |
|
|
110
|
+
| `search_placeholder` | string | `'Search...'` | Search input placeholder |
|
|
111
|
+
| `empty_message` | string | `'No records found.'` | Message when no data |
|
|
112
|
+
|
|
113
|
+
## Public Methods
|
|
114
|
+
|
|
115
|
+
| Method | Description |
|
|
116
|
+
| ----------------------------- | ---------------------------------------------- |
|
|
117
|
+
| `refresh()` | Re-fetch all data |
|
|
118
|
+
| `go_to_page(n)` | Navigate to page n |
|
|
119
|
+
| `set_sort(column, direction)` | Set sort programmatically |
|
|
120
|
+
| `set_filter(key, value)` | Set a filter value |
|
|
121
|
+
| `clear_filters()` | Reset all filters and search |
|
|
122
|
+
| `search(term)` | Set search term programmatically |
|
|
123
|
+
| `toggle_row(index)` | Toggle expanded state of row at index |
|
|
124
|
+
| `expand_row(index)` | Expand row at index |
|
|
125
|
+
| `collapse_row(index)` | Collapse row at index |
|
|
126
|
+
| `collapse_all()` | Collapse all expanded rows |
|
|
127
|
+
| `destroy()` | Remove all generated DOM elements |
|
|
128
|
+
|
|
129
|
+
## HTML Structure
|
|
130
|
+
|
|
131
|
+
```html
|
|
132
|
+
<!-- Controls row: search + filters -->
|
|
133
|
+
<table class="alanbradley">
|
|
134
|
+
<thead>
|
|
135
|
+
<!-- generated by JS -->
|
|
136
|
+
</thead>
|
|
137
|
+
<tbody>
|
|
138
|
+
<!-- generated by JS -->
|
|
139
|
+
<!-- with render_expanded, each row gets a toggle cell, -->
|
|
140
|
+
<!-- and expanded rows get a colspan td with your content -->
|
|
141
|
+
</tbody>
|
|
142
|
+
</table>
|
|
143
|
+
|
|
144
|
+
<!-- Status bar: page size, pagination, count -->
|
|
145
|
+
<div class="alanbradley-status">
|
|
146
|
+
<select class="alanbradley-page-size">...</select>
|
|
147
|
+
<span class="alanbradley-page-size-label">per page</span>
|
|
148
|
+
<div class="alanbradley-pagination">...</div>
|
|
149
|
+
<span class="alanbradley-status-text">Showing 1-50 of 200</span>
|
|
150
|
+
</div>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Expandable Rows
|
|
154
|
+
|
|
155
|
+
When `render_expanded` is provided, each row gets a toggle button in the first column. Clicking it reveals the content returned by `render_expanded`:
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
new AlanBradley("#my-table", {
|
|
159
|
+
api: "/api/items",
|
|
160
|
+
columns: [
|
|
161
|
+
{ key: "id", label: "ID", sortable: true },
|
|
162
|
+
{ key: "name", label: "Name", sortable: true },
|
|
163
|
+
{ key: "status", label: "Status", sortable: true },
|
|
164
|
+
],
|
|
165
|
+
search_fields: ["name", "items.description"],
|
|
166
|
+
render_row: function (item) {
|
|
167
|
+
return (
|
|
168
|
+
"<tr>" +
|
|
169
|
+
'<td data-label="ID">' + item.id + "</td>" +
|
|
170
|
+
'<td data-label="Name">' + item.name + "</td>" +
|
|
171
|
+
'<td data-label="Status">' + item.status + "</td>" +
|
|
172
|
+
"</tr>"
|
|
173
|
+
);
|
|
174
|
+
},
|
|
175
|
+
render_expanded: function (item) {
|
|
176
|
+
if (!item.items || item.items.length === 0) {
|
|
177
|
+
return "<p>No items found.</p>";
|
|
178
|
+
}
|
|
179
|
+
var html = '<dl>';
|
|
180
|
+
for (var i = 0; i < item.items.length; i++) {
|
|
181
|
+
html += '<dt>' + item.items[i].label + '</dt>';
|
|
182
|
+
html += '<dd>' + item.items[i].description + '</dd>';
|
|
183
|
+
}
|
|
184
|
+
html += '</dl>';
|
|
185
|
+
return html;
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Expanded rows are collapsed automatically when sorting, filtering, searching, or changing pages.
|
|
191
|
+
|
|
192
|
+
### Nested Search
|
|
193
|
+
|
|
194
|
+
`search_fields` supports dot-notation paths for searching nested data:
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
search_fields: ["name", "items.description"]
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
When a path resolves to an array (e.g., `items`), each item's field (e.g., `description`) is searched. A match on any nested item includes the parent row in results.
|
|
201
|
+
|
|
202
|
+
## CSS Customization
|
|
203
|
+
|
|
204
|
+
All styling uses CSS custom properties. Override `:root` or target a specific container:
|
|
205
|
+
|
|
206
|
+
```css
|
|
207
|
+
:root {
|
|
208
|
+
--alanbradley-sort-arrow-color: #6c757d;
|
|
209
|
+
--alanbradley-active-sort-color: #0d6efd;
|
|
210
|
+
--alanbradley-row-hover-bg: #f1f3f5;
|
|
211
|
+
--alanbradley-pagination-active-bg: #0d6efd;
|
|
212
|
+
--alanbradley-pagination-active-color: #fff;
|
|
213
|
+
--alanbradley-pagination-hover-bg: #e9ecef;
|
|
214
|
+
--alanbradley-loading-opacity: 0.5;
|
|
215
|
+
--alanbradley-filter-bg: #fff;
|
|
216
|
+
--alanbradley-filter-border: #ced4da;
|
|
217
|
+
--alanbradley-filter-border-radius: 0.375rem;
|
|
218
|
+
--alanbradley-search-bg: #fff;
|
|
219
|
+
--alanbradley-search-border: #ced4da;
|
|
220
|
+
--alanbradley-search-border-radius: 0.375rem;
|
|
221
|
+
--alanbradley-status-color: #6c757d;
|
|
222
|
+
--alanbradley-empty-color: #6c757d;
|
|
223
|
+
--alanbradley-toggle-color: #6c757d;
|
|
224
|
+
--alanbradley-toggle-hover-color: #0d6efd;
|
|
225
|
+
--alanbradley-expanded-bg: #f8f9fa;
|
|
226
|
+
--alanbradley-expanded-border: #dee2e6;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Dark mode
|
|
231
|
+
|
|
232
|
+
```css
|
|
233
|
+
[data-theme="dark"] {
|
|
234
|
+
--alanbradley-row-hover-bg: #363636;
|
|
235
|
+
--alanbradley-filter-bg: #333;
|
|
236
|
+
--alanbradley-filter-border: #555;
|
|
237
|
+
--alanbradley-search-bg: #333;
|
|
238
|
+
--alanbradley-search-border: #555;
|
|
239
|
+
--alanbradley-status-color: #999;
|
|
240
|
+
--alanbradley-empty-color: #999;
|
|
241
|
+
--alanbradley-toggle-color: #999;
|
|
242
|
+
--alanbradley-toggle-hover-color: #6c9eff;
|
|
243
|
+
--alanbradley-expanded-bg: #2b2b2b;
|
|
244
|
+
--alanbradley-expanded-border: #444;
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Bootstrap 5
|
|
249
|
+
|
|
250
|
+
Add Bootstrap's `.table` class alongside `.alanbradley` for base table styling:
|
|
251
|
+
|
|
252
|
+
```html
|
|
253
|
+
<table id="my-table" class="table alanbradley"></table>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## License
|
|
257
|
+
|
|
258
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@renderorange/alanbradley",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "lightweight table filter, sorting, and pagination library, with optional background chunking",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Blaine Motsinger <blaine@renderorange.com>",
|
|
7
|
+
"main": "src/alanbradley.js",
|
|
8
|
+
"style": "src/alanbradley.css",
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"table",
|
|
16
|
+
"sort",
|
|
17
|
+
"filter",
|
|
18
|
+
"pagination",
|
|
19
|
+
"grid",
|
|
20
|
+
"vanilla-js"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/renderorange/alanbradley.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/renderorange/alanbradley/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/renderorange/alanbradley#readme",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "jest",
|
|
35
|
+
"test:watch": "jest --watch",
|
|
36
|
+
"test:coverage": "jest --coverage",
|
|
37
|
+
"lint": "node node_modules/eslint/bin/eslint.js . --fix",
|
|
38
|
+
"lint:check": "node node_modules/eslint/bin/eslint.js ."
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"eslint": "^9.0.0",
|
|
42
|
+
"@stylistic/eslint-plugin": "^3.0.0",
|
|
43
|
+
"jest": "^29.7.0",
|
|
44
|
+
"jest-environment-jsdom": "^29.7.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/* AlanBradley - Interactive table enhancements */
|
|
2
|
+
/* Does NOT style base table — bring your own CSS */
|
|
3
|
+
/* Namespace: alanbradley-* */
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--alanbradley-sort-arrow-color: #6c757d;
|
|
7
|
+
--alanbradley-active-sort-color: #0d6efd;
|
|
8
|
+
--alanbradley-row-hover-bg: #f1f3f5;
|
|
9
|
+
--alanbradley-pagination-active-bg: #0d6efd;
|
|
10
|
+
--alanbradley-pagination-active-color: #fff;
|
|
11
|
+
--alanbradley-pagination-hover-bg: #e9ecef;
|
|
12
|
+
--alanbradley-loading-opacity: 0.5;
|
|
13
|
+
--alanbradley-filter-bg: #fff;
|
|
14
|
+
--alanbradley-filter-border: #ced4da;
|
|
15
|
+
--alanbradley-filter-border-radius: 0.375rem;
|
|
16
|
+
--alanbradley-search-bg: #fff;
|
|
17
|
+
--alanbradley-search-border: #ced4da;
|
|
18
|
+
--alanbradley-search-border-radius: 0.375rem;
|
|
19
|
+
--alanbradley-status-color: #6c757d;
|
|
20
|
+
--alanbradley-empty-color: #6c757d;
|
|
21
|
+
--alanbradley-toggle-color: #6c757d;
|
|
22
|
+
--alanbradley-toggle-hover-color: #0d6efd;
|
|
23
|
+
--alanbradley-expanded-bg: #f8f9fa;
|
|
24
|
+
--alanbradley-expanded-border: #dee2e6;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Controls bar: search + filters */
|
|
28
|
+
.alanbradley-controls {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 0.75rem;
|
|
32
|
+
margin-bottom: 0.75rem;
|
|
33
|
+
flex-wrap: wrap;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.alanbradley-search {
|
|
37
|
+
width: 200px;
|
|
38
|
+
padding: 0.375rem 0.75rem;
|
|
39
|
+
font-size: 0.875rem;
|
|
40
|
+
background: var(--alanbradley-search-bg);
|
|
41
|
+
border: 1px solid var(--alanbradley-search-border);
|
|
42
|
+
border-radius: var(--alanbradley-search-border-radius);
|
|
43
|
+
outline: none;
|
|
44
|
+
transition: border-color 0.15s ease;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.alanbradley-search:focus {
|
|
48
|
+
border-color: var(--alanbradley-active-sort-color);
|
|
49
|
+
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.15);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.alanbradley-page-size {
|
|
53
|
+
padding: 0.25rem 0.5rem;
|
|
54
|
+
font-size: 0.8125rem;
|
|
55
|
+
background: var(--alanbradley-filter-bg);
|
|
56
|
+
border: 1px solid var(--alanbradley-filter-border);
|
|
57
|
+
border-radius: var(--alanbradley-filter-border-radius);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.alanbradley-filter {
|
|
61
|
+
padding: 0.375rem 0.75rem;
|
|
62
|
+
font-size: 0.875rem;
|
|
63
|
+
background: var(--alanbradley-filter-bg);
|
|
64
|
+
border: 1px solid var(--alanbradley-filter-border);
|
|
65
|
+
border-radius: var(--alanbradley-filter-border-radius);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.alanbradley-filter-label {
|
|
69
|
+
font-size: 0.75rem;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
color: var(--alanbradley-status-color);
|
|
72
|
+
text-transform: uppercase;
|
|
73
|
+
letter-spacing: 0.3px;
|
|
74
|
+
margin-right: 0.25rem;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Sortable headers */
|
|
78
|
+
.alanbradley-sortable {
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
user-select: none;
|
|
81
|
+
position: relative;
|
|
82
|
+
padding-right: 1.5rem;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.alanbradley-sortable:hover {
|
|
86
|
+
background: var(--alanbradley-row-hover-bg);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.alanbradley-sortable::after {
|
|
90
|
+
content: "\2195";
|
|
91
|
+
position: absolute;
|
|
92
|
+
right: 0.5rem;
|
|
93
|
+
top: 50%;
|
|
94
|
+
transform: translateY(-50%);
|
|
95
|
+
color: var(--alanbradley-sort-arrow-color);
|
|
96
|
+
opacity: 0.4;
|
|
97
|
+
font-size: 0.6rem;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.alanbradley-sort-asc::after {
|
|
101
|
+
content: "\2191";
|
|
102
|
+
opacity: 1;
|
|
103
|
+
font-size: 0.6rem;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.alanbradley-sort-desc::after {
|
|
107
|
+
content: "\2193";
|
|
108
|
+
opacity: 1;
|
|
109
|
+
font-size: 0.6rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Row hover */
|
|
113
|
+
.alanbradley tbody tr:hover {
|
|
114
|
+
background: var(--alanbradley-row-hover-bg);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Loading state */
|
|
118
|
+
.alanbradley-loading {
|
|
119
|
+
opacity: var(--alanbradley-loading-opacity);
|
|
120
|
+
pointer-events: none;
|
|
121
|
+
transition: opacity 0.15s ease;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* Empty state */
|
|
125
|
+
.alanbradley-empty td {
|
|
126
|
+
text-align: center;
|
|
127
|
+
padding: 2rem;
|
|
128
|
+
color: var(--alanbradley-empty-color);
|
|
129
|
+
font-style: italic;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Expandable rows */
|
|
133
|
+
.alanbradley-toggle {
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
width: 2rem;
|
|
136
|
+
text-align: center;
|
|
137
|
+
color: var(--alanbradley-toggle-color);
|
|
138
|
+
user-select: none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.alanbradley-toggle:hover {
|
|
142
|
+
color: var(--alanbradley-toggle-hover-color);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.alanbradley-expanded td {
|
|
146
|
+
background: var(--alanbradley-expanded-bg);
|
|
147
|
+
border-bottom: 1px solid var(--alanbradley-expanded-border);
|
|
148
|
+
padding: 0.75rem 1rem;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Status bar: [page size] per page [pagination] [showing X-Y of Z] */
|
|
152
|
+
.alanbradley-status {
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 0.5rem;
|
|
156
|
+
margin-top: 0.5rem;
|
|
157
|
+
font-size: 0.8125rem;
|
|
158
|
+
color: var(--alanbradley-status-color);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.alanbradley-page-size-label {
|
|
162
|
+
margin-right: auto;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.alanbradley-pagination {
|
|
166
|
+
display: flex;
|
|
167
|
+
align-items: center;
|
|
168
|
+
gap: 0.25rem;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.alanbradley-pagination-item {
|
|
172
|
+
display: inline-flex;
|
|
173
|
+
align-items: center;
|
|
174
|
+
justify-content: center;
|
|
175
|
+
min-width: 2rem;
|
|
176
|
+
height: 2rem;
|
|
177
|
+
padding: 0 0.5rem;
|
|
178
|
+
font-size: 0.875rem;
|
|
179
|
+
border: 1px solid #dee2e6;
|
|
180
|
+
border-radius: 0.25rem;
|
|
181
|
+
background: transparent;
|
|
182
|
+
cursor: pointer;
|
|
183
|
+
transition: background 0.15s ease;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.alanbradley-pagination-item:hover:not(.alanbradley-pagination-active):not(
|
|
187
|
+
.alanbradley-pagination-disabled
|
|
188
|
+
) {
|
|
189
|
+
background: var(--alanbradley-pagination-hover-bg);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.alanbradley-pagination-active {
|
|
193
|
+
background: var(--alanbradley-pagination-active-bg);
|
|
194
|
+
color: var(--alanbradley-pagination-active-color);
|
|
195
|
+
border-color: var(--alanbradley-pagination-active-bg);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.alanbradley-pagination-disabled {
|
|
199
|
+
opacity: 0.4;
|
|
200
|
+
cursor: not-allowed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.alanbradley-status-text {
|
|
204
|
+
margin-left: auto;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* Mobile responsive */
|
|
208
|
+
@media (max-width: 767.98px) {
|
|
209
|
+
.alanbradley-controls {
|
|
210
|
+
flex-direction: column;
|
|
211
|
+
align-items: stretch;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.alanbradley-search {
|
|
215
|
+
width: 100%;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.alanbradley-filter {
|
|
219
|
+
width: 100%;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
[data-theme="dark"] {
|
|
224
|
+
--alanbradley-row-hover-bg: #363636;
|
|
225
|
+
--alanbradley-filter-bg: #333;
|
|
226
|
+
--alanbradley-filter-border: #555;
|
|
227
|
+
--alanbradley-search-bg: #333;
|
|
228
|
+
--alanbradley-search-border: #555;
|
|
229
|
+
--alanbradley-status-color: #999;
|
|
230
|
+
--alanbradley-empty-color: #999;
|
|
231
|
+
--alanbradley-toggle-color: #999;
|
|
232
|
+
--alanbradley-toggle-hover-color: #6c9eff;
|
|
233
|
+
--alanbradley-expanded-bg: #2b2b2b;
|
|
234
|
+
--alanbradley-expanded-border: #444;
|
|
235
|
+
}
|
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlanBradley - lightweight table filter, sorting, and pagination library
|
|
3
|
+
* with optional background chunking.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* const table = new AlanBradley('#my-table', {
|
|
7
|
+
* api: '/api/endpoint',
|
|
8
|
+
* columns: [{ key: 'name', label: 'Name', sortable: true }],
|
|
9
|
+
* filters: [{ key: 'status', label: 'Status', options: ['active', 'closed'] }],
|
|
10
|
+
* search_fields: ['name', 'email'],
|
|
11
|
+
* render_row: (item) => '<tr><td>' + item.name + '</td></tr>'
|
|
12
|
+
* });
|
|
13
|
+
*/
|
|
14
|
+
(function () {
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
function AlanBradley (selector, options) {
|
|
18
|
+
this.el =
|
|
19
|
+
typeof selector === "string"
|
|
20
|
+
? document.querySelector(selector)
|
|
21
|
+
: selector;
|
|
22
|
+
if (!this.el)
|
|
23
|
+
throw new Error("AlanBradley: element not found: " + selector);
|
|
24
|
+
|
|
25
|
+
this.api = options.api;
|
|
26
|
+
this.columns = options.columns || [];
|
|
27
|
+
this.filters = options.filters || [];
|
|
28
|
+
this.search_fields = options.search_fields || [];
|
|
29
|
+
this.render_row = options.render_row;
|
|
30
|
+
this.page_size = options.page_size || 50;
|
|
31
|
+
this.page_size_options = options.page_size_options || [25, 50, 100];
|
|
32
|
+
this.chunk_size = options.chunk_size || 500;
|
|
33
|
+
this.chunk = options.chunk !== false;
|
|
34
|
+
this.search_placeholder = options.search_placeholder || "Search...";
|
|
35
|
+
this.empty_message = options.empty_message || "No records found.";
|
|
36
|
+
this.on_sort = options.on_sort || null;
|
|
37
|
+
this.on_filter = options.on_filter || null;
|
|
38
|
+
this.render_expanded = options.render_expanded || null;
|
|
39
|
+
this.on_expand = options.on_expand || null;
|
|
40
|
+
this.on_collapse = options.on_collapse || null;
|
|
41
|
+
|
|
42
|
+
this.all_data = [];
|
|
43
|
+
this.loaded_chunks = {};
|
|
44
|
+
this.total = 0;
|
|
45
|
+
this.fully_loaded = false;
|
|
46
|
+
this.current_page = 1;
|
|
47
|
+
this.sort_column = null;
|
|
48
|
+
this.sort_dir = "asc";
|
|
49
|
+
this.search_term = "";
|
|
50
|
+
this.filter_values = {};
|
|
51
|
+
this.expanded_rows = new Set();
|
|
52
|
+
this.search_timeout = null;
|
|
53
|
+
|
|
54
|
+
this.init();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
AlanBradley.prototype.init = function () {
|
|
58
|
+
this.build_controls();
|
|
59
|
+
this.build_table();
|
|
60
|
+
this.build_status();
|
|
61
|
+
this.el.querySelector("tbody").classList.add("alanbradley-loading");
|
|
62
|
+
|
|
63
|
+
if (this.render_expanded) {
|
|
64
|
+
let self = this;
|
|
65
|
+
this._onToggleClick = function (e) {
|
|
66
|
+
let td = e.target.closest(".alanbradley-toggle");
|
|
67
|
+
if (!td) return;
|
|
68
|
+
let index = parseInt(td.getAttribute("data-alanbradley-row"), 10);
|
|
69
|
+
if (isNaN(index)) return;
|
|
70
|
+
self.toggle_row(index);
|
|
71
|
+
};
|
|
72
|
+
this.el.querySelector("tbody")
|
|
73
|
+
.addEventListener("click", this._onToggleClick);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.fetch_chunk(1);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// --- Data loading ---
|
|
80
|
+
|
|
81
|
+
AlanBradley.prototype.fetch_chunk = function (chunk_num) {
|
|
82
|
+
let self = this;
|
|
83
|
+
if (this.loaded_chunks[chunk_num]) return;
|
|
84
|
+
|
|
85
|
+
let params = new URLSearchParams();
|
|
86
|
+
params.set("chunk", chunk_num);
|
|
87
|
+
params.set("page_size", this.chunk ? this.chunk_size : 999999);
|
|
88
|
+
|
|
89
|
+
let url = this.api + "?" + params.toString();
|
|
90
|
+
this.loaded_chunks[chunk_num] = "loading";
|
|
91
|
+
|
|
92
|
+
fetch(url)
|
|
93
|
+
.then(function (response) {
|
|
94
|
+
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
95
|
+
return response.json();
|
|
96
|
+
})
|
|
97
|
+
.then(function (result) {
|
|
98
|
+
self.total = result.total;
|
|
99
|
+
self.all_data = self.all_data.concat(result.data);
|
|
100
|
+
self.loaded_chunks[chunk_num] = "done";
|
|
101
|
+
|
|
102
|
+
if (!self.chunk) {
|
|
103
|
+
self.fully_loaded = true;
|
|
104
|
+
} else {
|
|
105
|
+
let total_chunks = Math.ceil(self.total / self.chunk_size);
|
|
106
|
+
if (chunk_num >= total_chunks) {
|
|
107
|
+
self.fully_loaded = true;
|
|
108
|
+
} else {
|
|
109
|
+
self.fetch_chunk(chunk_num + 1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
self.render();
|
|
114
|
+
})
|
|
115
|
+
.catch(function (err) {
|
|
116
|
+
console.error("AlanBradley fetch error:", err);
|
|
117
|
+
self.loaded_chunks[chunk_num] = "error";
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// --- Data accessors ---
|
|
122
|
+
|
|
123
|
+
AlanBradley.prototype.get_filtered_data = function () {
|
|
124
|
+
let data = this.all_data;
|
|
125
|
+
let self = this;
|
|
126
|
+
|
|
127
|
+
// Column filters
|
|
128
|
+
let filter_keys = Object.keys(this.filter_values);
|
|
129
|
+
if (filter_keys.length > 0) {
|
|
130
|
+
data = data.filter(function (row) {
|
|
131
|
+
for (let i = 0; i < filter_keys.length; i++) {
|
|
132
|
+
let key = filter_keys[i];
|
|
133
|
+
let val = String(row[key] || "")
|
|
134
|
+
.toLowerCase();
|
|
135
|
+
if (val !== self.filter_values[key].toLowerCase()) return false;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Search
|
|
142
|
+
if (this.search_term && this.search_fields.length > 0) {
|
|
143
|
+
let term = this.search_term.toLowerCase();
|
|
144
|
+
data = data.filter(function (row) {
|
|
145
|
+
for (let i = 0; i < self.search_fields.length; i++) {
|
|
146
|
+
let field = self.search_fields[i];
|
|
147
|
+
let val = self._resolve_field(row, field);
|
|
148
|
+
if (val === null || val === undefined) continue;
|
|
149
|
+
if (Array.isArray(val)) {
|
|
150
|
+
for (let j = 0; j < val.length; j++) {
|
|
151
|
+
if (val[j] && String(val[j])
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
.indexOf(term) !== -1) return true;
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
if (String(val)
|
|
157
|
+
.toLowerCase()
|
|
158
|
+
.indexOf(term) !== -1) return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return data;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
AlanBradley.prototype.get_sorted_data = function () {
|
|
169
|
+
let data = this.get_filtered_data();
|
|
170
|
+
|
|
171
|
+
if (this.sort_column) {
|
|
172
|
+
let col = this.sort_column;
|
|
173
|
+
let dir = this.sort_dir === "desc" ? -1 : 1;
|
|
174
|
+
data = data.slice()
|
|
175
|
+
.sort(function (a, b) {
|
|
176
|
+
let va = a[col];
|
|
177
|
+
let vb = b[col];
|
|
178
|
+
|
|
179
|
+
// Handle nulls
|
|
180
|
+
if (va == null && vb == null) return 0;
|
|
181
|
+
if (va == null) return dir;
|
|
182
|
+
if (vb == null) return -dir;
|
|
183
|
+
|
|
184
|
+
// Date sort (ISO date strings)
|
|
185
|
+
if (
|
|
186
|
+
typeof va === "string" &&
|
|
187
|
+
/^\d{4}-\d{2}-\d{2}/.test(va) &&
|
|
188
|
+
typeof vb === "string" &&
|
|
189
|
+
/^\d{4}-\d{2}-\d{2}/.test(vb)
|
|
190
|
+
) {
|
|
191
|
+
let da = new Date(va)
|
|
192
|
+
.getTime();
|
|
193
|
+
let db = new Date(vb)
|
|
194
|
+
.getTime();
|
|
195
|
+
if (!isNaN(da) && !isNaN(db)) {
|
|
196
|
+
return (da - db) * dir;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Numeric sort (handles string-encoded numbers)
|
|
201
|
+
let na = parseFloat(va);
|
|
202
|
+
let nb = parseFloat(vb);
|
|
203
|
+
if (!isNaN(na) && !isNaN(nb) && String(va)
|
|
204
|
+
.indexOf("-") !== 0) {
|
|
205
|
+
return (na - nb) * dir;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// String sort
|
|
209
|
+
va = String(va)
|
|
210
|
+
.toLowerCase();
|
|
211
|
+
vb = String(vb)
|
|
212
|
+
.toLowerCase();
|
|
213
|
+
if (va < vb) return -1 * dir;
|
|
214
|
+
if (va > vb) return 1 * dir;
|
|
215
|
+
return 0;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return data;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
AlanBradley.prototype.get_page_data = function () {
|
|
223
|
+
let sorted = this.get_sorted_data();
|
|
224
|
+
let start = (this.current_page - 1) * this.page_size;
|
|
225
|
+
let end = start + this.page_size;
|
|
226
|
+
return sorted.slice(start, end);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
AlanBradley.prototype.get_total_filtered = function () {
|
|
230
|
+
return this.get_filtered_data().length;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
AlanBradley.prototype.get_total_pages = function () {
|
|
234
|
+
let total = this.get_total_filtered();
|
|
235
|
+
return Math.max(1, Math.ceil(total / this.page_size));
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// --- Rendering ---
|
|
239
|
+
|
|
240
|
+
AlanBradley.prototype.render = function () {
|
|
241
|
+
this.render_rows();
|
|
242
|
+
this.render_pagination();
|
|
243
|
+
this.render_status();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
AlanBradley.prototype.render_rows = function () {
|
|
247
|
+
let tbody = this.el.querySelector("tbody");
|
|
248
|
+
let page_data = this.get_page_data();
|
|
249
|
+
let colspan = this.columns.length + (this.render_expanded ? 1 : 0);
|
|
250
|
+
|
|
251
|
+
if (page_data.length === 0 && this.all_data.length > 0) {
|
|
252
|
+
tbody.innerHTML =
|
|
253
|
+
"<tr class=\"alanbradley-empty\"><td colspan=\"" +
|
|
254
|
+
colspan +
|
|
255
|
+
"\">" +
|
|
256
|
+
this.escape_html(this.empty_message) +
|
|
257
|
+
"</td></tr>";
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (page_data.length === 0) {
|
|
262
|
+
tbody.classList.add("alanbradley-loading");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
tbody.classList.remove("alanbradley-loading");
|
|
267
|
+
let html = "";
|
|
268
|
+
for (let i = 0; i < page_data.length; i++) {
|
|
269
|
+
let row_html = this.render_row(page_data[i]);
|
|
270
|
+
|
|
271
|
+
if (this.render_expanded) {
|
|
272
|
+
let toggle_icon = this.expanded_rows.has(i) ? "\u2212" : "+";
|
|
273
|
+
let toggle_td = "<td class=\"alanbradley-toggle\" data-alanbradley-row=\"" + i + "\">" + toggle_icon + "</td>";
|
|
274
|
+
row_html = row_html.replace("<tr>", "<tr>" + toggle_td);
|
|
275
|
+
|
|
276
|
+
if (this.expanded_rows.has(i)) {
|
|
277
|
+
let expanded_content = this.render_expanded(page_data[i]);
|
|
278
|
+
html += row_html;
|
|
279
|
+
html += "<tr class=\"alanbradley-expanded\"><td colspan=\"" + colspan + "\">" + expanded_content + "</td></tr>";
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
html += row_html;
|
|
285
|
+
}
|
|
286
|
+
tbody.innerHTML = html;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
AlanBradley.prototype.render_pagination = function () {
|
|
290
|
+
let el = this.pagination_el;
|
|
291
|
+
let total_pages = this.get_total_pages();
|
|
292
|
+
|
|
293
|
+
if (total_pages <= 1) {
|
|
294
|
+
el.innerHTML = "";
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let html = "";
|
|
299
|
+
|
|
300
|
+
// Previous
|
|
301
|
+
if (this.current_page > 1) {
|
|
302
|
+
html +=
|
|
303
|
+
"<button class=\"alanbradley-pagination-item\" data-alanbradley-page=\"" +
|
|
304
|
+
(this.current_page - 1) +
|
|
305
|
+
"\">«</button>";
|
|
306
|
+
} else {
|
|
307
|
+
html +=
|
|
308
|
+
"<button class=\"alanbradley-pagination-item alanbradley-pagination-disabled\">«</button>";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Page numbers (sliding window of 5)
|
|
312
|
+
let start = Math.max(1, this.current_page - 2);
|
|
313
|
+
let end = Math.min(total_pages, this.current_page + 2);
|
|
314
|
+
|
|
315
|
+
if (start > 1) {
|
|
316
|
+
html +=
|
|
317
|
+
"<button class=\"alanbradley-pagination-item\" data-alanbradley-page=\"1\">1</button>";
|
|
318
|
+
if (start > 2)
|
|
319
|
+
html +=
|
|
320
|
+
"<span class=\"alanbradley-pagination-item alanbradley-pagination-disabled\">…</span>";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (let p = start; p <= end; p++) {
|
|
324
|
+
if (p === this.current_page) {
|
|
325
|
+
html +=
|
|
326
|
+
"<button class=\"alanbradley-pagination-item alanbradley-pagination-active\">" +
|
|
327
|
+
p +
|
|
328
|
+
"</button>";
|
|
329
|
+
} else {
|
|
330
|
+
html +=
|
|
331
|
+
"<button class=\"alanbradley-pagination-item\" data-alanbradley-page=\"" +
|
|
332
|
+
p +
|
|
333
|
+
"\">" +
|
|
334
|
+
p +
|
|
335
|
+
"</button>";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (end < total_pages) {
|
|
340
|
+
if (end < total_pages - 1)
|
|
341
|
+
html +=
|
|
342
|
+
"<span class=\"alanbradley-pagination-item alanbradley-pagination-disabled\">…</span>";
|
|
343
|
+
html +=
|
|
344
|
+
"<button class=\"alanbradley-pagination-item\" data-alanbradley-page=\"" +
|
|
345
|
+
total_pages +
|
|
346
|
+
"\">" +
|
|
347
|
+
total_pages +
|
|
348
|
+
"</button>";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Next
|
|
352
|
+
if (this.current_page < total_pages) {
|
|
353
|
+
html +=
|
|
354
|
+
"<button class=\"alanbradley-pagination-item\" data-alanbradley-page=\"" +
|
|
355
|
+
(this.current_page + 1) +
|
|
356
|
+
"\">»</button>";
|
|
357
|
+
} else {
|
|
358
|
+
html +=
|
|
359
|
+
"<button class=\"alanbradley-pagination-item alanbradley-pagination-disabled\">»</button>";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
el.innerHTML = html;
|
|
363
|
+
|
|
364
|
+
// Bind click handlers
|
|
365
|
+
let self = this;
|
|
366
|
+
let buttons = el.querySelectorAll("[data-alanbradley-page]");
|
|
367
|
+
for (let i = 0; i < buttons.length; i++) {
|
|
368
|
+
buttons[i].addEventListener("click", function () {
|
|
369
|
+
self.go_to_page(
|
|
370
|
+
parseInt(this.getAttribute("data-alanbradley-page"), 10),
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
AlanBradley.prototype.render_status = function () {
|
|
377
|
+
let filtered_total = this.get_total_filtered();
|
|
378
|
+
if (filtered_total === 0 && this.all_data.length === 0) {
|
|
379
|
+
this.status_text_el.textContent = "";
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
let start = (this.current_page - 1) * this.page_size + 1;
|
|
383
|
+
let end = Math.min(this.current_page * this.page_size, filtered_total);
|
|
384
|
+
let text = "Showing " + start + "-" + end + " of " + filtered_total;
|
|
385
|
+
|
|
386
|
+
if (!this.fully_loaded) {
|
|
387
|
+
let loaded = this.all_data.length;
|
|
388
|
+
let remaining = this.total - loaded;
|
|
389
|
+
text += " (" + remaining + " more loading)";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this.status_text_el.textContent = text;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// --- UI builders ---
|
|
396
|
+
|
|
397
|
+
AlanBradley.prototype.build_controls = function () {
|
|
398
|
+
let container = this.el.parentElement;
|
|
399
|
+
|
|
400
|
+
// Search + filters row
|
|
401
|
+
let controls = document.createElement("div");
|
|
402
|
+
controls.className = "alanbradley-controls";
|
|
403
|
+
let self = this;
|
|
404
|
+
|
|
405
|
+
// Search input (left)
|
|
406
|
+
let search = document.createElement("input");
|
|
407
|
+
search.type = "text";
|
|
408
|
+
search.className = "alanbradley-search";
|
|
409
|
+
search.placeholder = this.search_placeholder;
|
|
410
|
+
search.addEventListener("input", function () {
|
|
411
|
+
clearTimeout(self.search_timeout);
|
|
412
|
+
self.search_timeout = setTimeout(function () {
|
|
413
|
+
self.search_term = search.value.trim();
|
|
414
|
+
self.expanded_rows.clear();
|
|
415
|
+
self.current_page = 1;
|
|
416
|
+
self.render();
|
|
417
|
+
}, 300);
|
|
418
|
+
});
|
|
419
|
+
this.search_input = search;
|
|
420
|
+
controls.appendChild(search);
|
|
421
|
+
|
|
422
|
+
// Filter dropdowns (right)
|
|
423
|
+
for (let f = 0; f < this.filters.length; f++) {
|
|
424
|
+
let filter = this.filters[f];
|
|
425
|
+
let filter_wrapper = document.createElement("span");
|
|
426
|
+
|
|
427
|
+
let label = document.createElement("label");
|
|
428
|
+
label.className = "alanbradley-filter-label";
|
|
429
|
+
label.textContent = filter.label;
|
|
430
|
+
filter_wrapper.appendChild(label);
|
|
431
|
+
|
|
432
|
+
let select = document.createElement("select");
|
|
433
|
+
select.className = "alanbradley-filter";
|
|
434
|
+
select.setAttribute("data-alanbradley-filter", filter.key);
|
|
435
|
+
|
|
436
|
+
let all_opt = document.createElement("option");
|
|
437
|
+
all_opt.value = "";
|
|
438
|
+
all_opt.textContent = "All " + filter.label;
|
|
439
|
+
select.appendChild(all_opt);
|
|
440
|
+
|
|
441
|
+
for (let o = 0; o < filter.options.length; o++) {
|
|
442
|
+
let option = document.createElement("option");
|
|
443
|
+
let opt_val = filter.options[o];
|
|
444
|
+
if (typeof opt_val === "object" && opt_val !== null) {
|
|
445
|
+
option.value = opt_val.value;
|
|
446
|
+
option.textContent = opt_val.label;
|
|
447
|
+
} else {
|
|
448
|
+
option.value = opt_val;
|
|
449
|
+
option.textContent = opt_val;
|
|
450
|
+
}
|
|
451
|
+
select.appendChild(option);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
(function (key) {
|
|
455
|
+
select.addEventListener("change", function () {
|
|
456
|
+
if (this.value) {
|
|
457
|
+
self.filter_values[key] = this.value;
|
|
458
|
+
} else {
|
|
459
|
+
delete self.filter_values[key];
|
|
460
|
+
}
|
|
461
|
+
self.expanded_rows.clear();
|
|
462
|
+
self.current_page = 1;
|
|
463
|
+
self.render();
|
|
464
|
+
if (self.on_filter) self.on_filter(self.filter_values);
|
|
465
|
+
});
|
|
466
|
+
})(filter.key);
|
|
467
|
+
|
|
468
|
+
filter_wrapper.appendChild(select);
|
|
469
|
+
controls.appendChild(filter_wrapper);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
container.insertBefore(controls, this.el);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
AlanBradley.prototype.build_table = function () {
|
|
476
|
+
let thead = this.el.querySelector("thead");
|
|
477
|
+
if (!thead) {
|
|
478
|
+
thead = document.createElement("thead");
|
|
479
|
+
this.el.insertBefore(thead, this.el.firstChild);
|
|
480
|
+
}
|
|
481
|
+
thead.innerHTML = "";
|
|
482
|
+
|
|
483
|
+
let tr = document.createElement("tr");
|
|
484
|
+
let self = this;
|
|
485
|
+
|
|
486
|
+
if (this.render_expanded) {
|
|
487
|
+
let toggle_th = document.createElement("th");
|
|
488
|
+
toggle_th.className = "alanbradley-th";
|
|
489
|
+
tr.appendChild(toggle_th);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
for (let i = 0; i < this.columns.length; i++) {
|
|
493
|
+
let col = this.columns[i];
|
|
494
|
+
let th = document.createElement("th");
|
|
495
|
+
th.className = "alanbradley-th";
|
|
496
|
+
th.textContent = col.label;
|
|
497
|
+
|
|
498
|
+
if (col.sortable) {
|
|
499
|
+
th.classList.add("alanbradley-sortable");
|
|
500
|
+
th.setAttribute("data-alanbradley-sort", col.key);
|
|
501
|
+
(function (key) {
|
|
502
|
+
th.addEventListener("click", function () {
|
|
503
|
+
if (self.sort_column === key) {
|
|
504
|
+
self.sort_dir = self.sort_dir === "asc" ? "desc" : "asc";
|
|
505
|
+
} else {
|
|
506
|
+
self.sort_column = key;
|
|
507
|
+
self.sort_dir = "asc";
|
|
508
|
+
}
|
|
509
|
+
self.expanded_rows.clear();
|
|
510
|
+
self.current_page = 1;
|
|
511
|
+
self.update_sort_indicators();
|
|
512
|
+
self.render();
|
|
513
|
+
if (self.on_sort) self.on_sort(self.sort_column, self.sort_dir);
|
|
514
|
+
});
|
|
515
|
+
})(col.key);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
tr.appendChild(th);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
thead.appendChild(tr);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
AlanBradley.prototype.build_status = function () {
|
|
525
|
+
let next = this.el.nextElementSibling;
|
|
526
|
+
if (next && next.classList.contains("alanbradley-status")) {
|
|
527
|
+
this.status_el = next;
|
|
528
|
+
} else {
|
|
529
|
+
this.status_el = document.createElement("div");
|
|
530
|
+
this.status_el.className = "alanbradley-status";
|
|
531
|
+
this.el.parentElement.insertBefore(this.status_el, next);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let self = this;
|
|
535
|
+
|
|
536
|
+
// Page size selector
|
|
537
|
+
let page_size_select = document.createElement("select");
|
|
538
|
+
page_size_select.className = "alanbradley-page-size";
|
|
539
|
+
for (let i = 0; i < this.page_size_options.length; i++) {
|
|
540
|
+
let opt = document.createElement("option");
|
|
541
|
+
opt.value = this.page_size_options[i];
|
|
542
|
+
opt.textContent = this.page_size_options[i];
|
|
543
|
+
if (this.page_size_options[i] === this.page_size) opt.selected = true;
|
|
544
|
+
page_size_select.appendChild(opt);
|
|
545
|
+
}
|
|
546
|
+
page_size_select.addEventListener("change", function () {
|
|
547
|
+
self.page_size = parseInt(this.value, 10);
|
|
548
|
+
self.current_page = 1;
|
|
549
|
+
self.render();
|
|
550
|
+
});
|
|
551
|
+
this.page_size_el = page_size_select;
|
|
552
|
+
|
|
553
|
+
// "per page" label
|
|
554
|
+
let page_size_label = document.createElement("span");
|
|
555
|
+
page_size_label.className = "alanbradley-page-size-label";
|
|
556
|
+
page_size_label.textContent = "per page";
|
|
557
|
+
|
|
558
|
+
// Pagination container
|
|
559
|
+
this.pagination_el = document.createElement("div");
|
|
560
|
+
this.pagination_el.className = "alanbradley-pagination";
|
|
561
|
+
|
|
562
|
+
// Status text
|
|
563
|
+
this.status_text_el = document.createElement("span");
|
|
564
|
+
this.status_text_el.className = "alanbradley-status-text";
|
|
565
|
+
|
|
566
|
+
this.status_el.appendChild(this.page_size_el);
|
|
567
|
+
this.status_el.appendChild(page_size_label);
|
|
568
|
+
this.status_el.appendChild(this.pagination_el);
|
|
569
|
+
this.status_el.appendChild(this.status_text_el);
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
AlanBradley.prototype.update_sort_indicators = function () {
|
|
573
|
+
let ths = this.el.querySelectorAll(".alanbradley-sortable");
|
|
574
|
+
for (let i = 0; i < ths.length; i++) {
|
|
575
|
+
ths[i].classList.remove("alanbradley-sort-asc", "alanbradley-sort-desc");
|
|
576
|
+
if (
|
|
577
|
+
this.sort_column &&
|
|
578
|
+
ths[i].getAttribute("data-alanbradley-sort") === this.sort_column
|
|
579
|
+
) {
|
|
580
|
+
ths[i].classList.add(
|
|
581
|
+
this.sort_dir === "asc"
|
|
582
|
+
? "alanbradley-sort-asc"
|
|
583
|
+
: "alanbradley-sort-desc",
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// --- Public API ---
|
|
590
|
+
|
|
591
|
+
AlanBradley.prototype.go_to_page = function (page) {
|
|
592
|
+
let total_pages = this.get_total_pages();
|
|
593
|
+
if (page < 1 || page > total_pages) return;
|
|
594
|
+
this.expanded_rows.clear();
|
|
595
|
+
this.current_page = page;
|
|
596
|
+
this.render();
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
AlanBradley.prototype.set_sort = function (column, direction) {
|
|
600
|
+
this.sort_column = column;
|
|
601
|
+
this.sort_dir = direction;
|
|
602
|
+
this.expanded_rows.clear();
|
|
603
|
+
this.current_page = 1;
|
|
604
|
+
this.update_sort_indicators();
|
|
605
|
+
this.render();
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
AlanBradley.prototype.set_filter = function (key, value) {
|
|
609
|
+
if (value) {
|
|
610
|
+
this.filter_values[key] = value;
|
|
611
|
+
} else {
|
|
612
|
+
delete this.filter_values[key];
|
|
613
|
+
}
|
|
614
|
+
let select = this.el.parentElement.querySelector(
|
|
615
|
+
"[data-alanbradley-filter=\"" + key + "\"]",
|
|
616
|
+
);
|
|
617
|
+
if (select) select.value = value || "";
|
|
618
|
+
this.expanded_rows.clear();
|
|
619
|
+
this.current_page = 1;
|
|
620
|
+
this.render();
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
AlanBradley.prototype.clear_filters = function () {
|
|
624
|
+
this.filter_values = {};
|
|
625
|
+
this.search_term = "";
|
|
626
|
+
this.expanded_rows.clear();
|
|
627
|
+
if (this.search_input) this.search_input.value = "";
|
|
628
|
+
let selects = this.el.parentElement.querySelectorAll(
|
|
629
|
+
"[data-alanbradley-filter]",
|
|
630
|
+
);
|
|
631
|
+
for (let i = 0; i < selects.length; i++) {
|
|
632
|
+
selects[i].value = "";
|
|
633
|
+
}
|
|
634
|
+
this.current_page = 1;
|
|
635
|
+
this.render();
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
AlanBradley.prototype.search = function (term) {
|
|
639
|
+
this.search_term = term;
|
|
640
|
+
this.expanded_rows.clear();
|
|
641
|
+
if (this.search_input) this.search_input.value = term;
|
|
642
|
+
this.current_page = 1;
|
|
643
|
+
this.render();
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
AlanBradley.prototype.refresh = function () {
|
|
647
|
+
this.all_data = [];
|
|
648
|
+
this.loaded_chunks = {};
|
|
649
|
+
this.fully_loaded = false;
|
|
650
|
+
this.current_page = 1;
|
|
651
|
+
let tbody = this.el.querySelector("tbody");
|
|
652
|
+
tbody.classList.add("alanbradley-loading");
|
|
653
|
+
this.fetch_chunk(1);
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
AlanBradley.prototype.destroy = function () {
|
|
657
|
+
let controls = this.el.parentElement.querySelector(".alanbradley-controls");
|
|
658
|
+
if (controls) controls.remove();
|
|
659
|
+
this.pagination_el.innerHTML = "";
|
|
660
|
+
this.status_el.innerHTML = "";
|
|
661
|
+
if (this._onToggleClick) {
|
|
662
|
+
this.el.querySelector("tbody")
|
|
663
|
+
.removeEventListener("click", this._onToggleClick);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
AlanBradley.prototype.toggle_row = function (index) {
|
|
668
|
+
if (this.expanded_rows.has(index)) {
|
|
669
|
+
this.collapse_row(index);
|
|
670
|
+
} else {
|
|
671
|
+
this.expand_row(index);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
AlanBradley.prototype.expand_row = function (index) {
|
|
676
|
+
if (this.expanded_rows.has(index)) return;
|
|
677
|
+
this.expanded_rows.add(index);
|
|
678
|
+
let page_data = this.get_page_data();
|
|
679
|
+
this.render_rows();
|
|
680
|
+
if (this.on_expand) this.on_expand(page_data[index], index);
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
AlanBradley.prototype.collapse_row = function (index) {
|
|
684
|
+
if (!this.expanded_rows.has(index)) return;
|
|
685
|
+
this.expanded_rows.delete(index);
|
|
686
|
+
let page_data = this.get_page_data();
|
|
687
|
+
this.render_rows();
|
|
688
|
+
if (this.on_collapse) this.on_collapse(page_data[index], index);
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
AlanBradley.prototype.collapse_all = function () {
|
|
692
|
+
this.expanded_rows.clear();
|
|
693
|
+
this.render_rows();
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
AlanBradley.prototype._resolve_field = function (obj, path) {
|
|
697
|
+
if (path.indexOf(".") === -1) {
|
|
698
|
+
return obj[path] != null ? obj[path] : null;
|
|
699
|
+
}
|
|
700
|
+
let parts = path.split(".");
|
|
701
|
+
let current = obj;
|
|
702
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
703
|
+
if (current == null || typeof current !== "object") return null;
|
|
704
|
+
current = current[parts[i]];
|
|
705
|
+
}
|
|
706
|
+
if (current == null) return null;
|
|
707
|
+
let lastKey = parts[parts.length - 1];
|
|
708
|
+
if (Array.isArray(current)) {
|
|
709
|
+
let results = [];
|
|
710
|
+
for (let i = 0; i < current.length; i++) {
|
|
711
|
+
if (current[i] != null && current[i][lastKey] != null) {
|
|
712
|
+
results.push(current[i][lastKey]);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return results.length > 0 ? results : null;
|
|
716
|
+
}
|
|
717
|
+
if (current[lastKey] != null) return current[lastKey];
|
|
718
|
+
return null;
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
AlanBradley.prototype.escape_html = function (str) {
|
|
722
|
+
let div = document.createElement("div");
|
|
723
|
+
div.appendChild(document.createTextNode(str));
|
|
724
|
+
return div.innerHTML;
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// Export
|
|
728
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
729
|
+
module.exports = AlanBradley;
|
|
730
|
+
} else {
|
|
731
|
+
window.AlanBradley = AlanBradley;
|
|
732
|
+
}
|
|
733
|
+
})();
|