@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 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
+ "\">&laquo;</button>";
306
+ } else {
307
+ html +=
308
+ "<button class=\"alanbradley-pagination-item alanbradley-pagination-disabled\">&laquo;</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\">&hellip;</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\">&hellip;</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
+ "\">&raquo;</button>";
357
+ } else {
358
+ html +=
359
+ "<button class=\"alanbradley-pagination-item alanbradley-pagination-disabled\">&raquo;</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
+ })();