@phillipsharring/graspr-framework 0.1.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.
@@ -0,0 +1,290 @@
1
+ // ---------------------------
2
+ // Form error handling + modal form lifecycle
3
+ // ---------------------------
4
+ // Manages inline form errors, error response interception, form submission
5
+ // tracking, modal auto-open on content swap, and modal close + refresh on success.
6
+ // Fully self-registering — import for side effects only.
7
+
8
+ import htmx from '../lib/htmx.js';
9
+ import { GrasprToast } from '../ui/toast.js';
10
+ import {
11
+ openGlobalModal,
12
+ closeGlobalModal,
13
+ isGlobalModalOpen,
14
+ } from '../ui/index.js';
15
+
16
+ // ---------------------------
17
+ // Form inline error handling
18
+ // ---------------------------
19
+
20
+ /**
21
+ * Show inline errors on form fields.
22
+ * @param {HTMLFormElement} form - The form element
23
+ * @param {Object} errors - Object mapping field names to error messages
24
+ * @param {string} generalMessage - General error message for the form
25
+ */
26
+ function showFormErrors(form, errors, generalMessage) {
27
+ if (!(form instanceof HTMLFormElement)) {
28
+ return;
29
+ }
30
+
31
+ // Show field-level errors
32
+ for (const [fieldName, errorMessage] of Object.entries(errors || {})) {
33
+ const input = form.querySelector(`[name="${fieldName}"]`);
34
+ if (!input) continue;
35
+
36
+ // Add error class to input
37
+ input.classList.add('field-error');
38
+
39
+ // Find or create error text element
40
+ let errorText = input.parentElement?.querySelector('.field-error-text');
41
+ if (!errorText) {
42
+ errorText = document.createElement('div');
43
+ errorText.className = 'field-error-text hidden';
44
+ input.parentElement?.appendChild(errorText);
45
+ }
46
+
47
+ errorText.textContent = errorMessage;
48
+ errorText.classList.remove('hidden');
49
+ }
50
+
51
+ // Show general error message at top of form
52
+ if (generalMessage) {
53
+ let errorBanner = form.querySelector('.form-error-banner');
54
+ if (!errorBanner) {
55
+ errorBanner = document.createElement('div');
56
+ errorBanner.className = 'form-error-banner';
57
+ form.insertBefore(errorBanner, form.firstChild);
58
+ }
59
+ errorBanner.textContent = generalMessage;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Clear all inline errors from a form.
65
+ * @param {HTMLFormElement} form - The form element
66
+ */
67
+ function clearFormErrors(form) {
68
+ if (!(form instanceof HTMLFormElement)) return;
69
+
70
+ // Remove error classes from inputs
71
+ form.querySelectorAll('.field-error').forEach((input) => {
72
+ input.classList.remove('field-error');
73
+ });
74
+
75
+ // Hide error text elements
76
+ form.querySelectorAll('.field-error-text').forEach((errorText) => {
77
+ errorText.classList.add('hidden');
78
+ errorText.textContent = '';
79
+ });
80
+
81
+ // Remove error banner
82
+ form.querySelectorAll('.form-error-banner').forEach((banner) => {
83
+ banner.remove();
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Clear error state for a specific field.
89
+ * @param {HTMLElement} input - The input element
90
+ */
91
+ function clearFieldError(input) {
92
+ if (!(input instanceof HTMLElement)) return;
93
+
94
+ input.classList.remove('field-error');
95
+
96
+ const errorText = input.parentElement?.querySelector('.field-error-text');
97
+ if (errorText) {
98
+ errorText.classList.add('hidden');
99
+ errorText.textContent = '';
100
+ }
101
+ }
102
+
103
+ // Clear field errors when user interacts with the field
104
+ document.addEventListener('focus', (e) => {
105
+ const input = e.target;
106
+ if (input instanceof HTMLElement && input.classList.contains('field-error')) {
107
+ clearFieldError(input);
108
+ }
109
+ }, true);
110
+
111
+ // ---------------------------
112
+ // Form submission tracking
113
+ // ---------------------------
114
+
115
+ // Track the currently submitting form for error handling
116
+ let currentSubmittingForm = null;
117
+
118
+ document.body.addEventListener('htmx:beforeRequest', (e) => {
119
+ const elt = e.detail?.elt;
120
+ if (elt instanceof HTMLFormElement) {
121
+ currentSubmittingForm = elt;
122
+ } else if (elt instanceof Element) {
123
+ currentSubmittingForm = elt.closest('form');
124
+ }
125
+ });
126
+
127
+ // ---------------------------
128
+ // Error response interception
129
+ // ---------------------------
130
+
131
+ // Intercept HTMX responses BEFORE swap to handle form errors (inline or toast)
132
+ document.body.addEventListener('htmx:beforeSwap', (e) => {
133
+ const detail = e.detail || {};
134
+ const xhr = detail.xhr;
135
+ const elt = detail.elt;
136
+
137
+ // Try to parse JSON response
138
+ let responseData = null;
139
+ try {
140
+ const responseText = xhr?.responseText;
141
+ if (responseText) {
142
+ responseData = JSON.parse(responseText);
143
+ }
144
+ } catch {
145
+ // Not JSON or parsing failed
146
+ return;
147
+ }
148
+
149
+ // Check if this is an error response
150
+ const status = responseData?.status;
151
+ const message = responseData?.message;
152
+
153
+ if (status !== 'error' || !message) {
154
+ return; // Not an error response, let HTMX handle normally
155
+ }
156
+
157
+ // Find the form that triggered this request
158
+ // Use the tracked form first, then try to detect from the element
159
+ let form = currentSubmittingForm;
160
+
161
+ if (!form && elt instanceof HTMLFormElement) {
162
+ form = elt;
163
+ } else if (!form && elt instanceof Element) {
164
+ form = elt.closest('form');
165
+ }
166
+
167
+ // If elt is not inside a form (e.g., it's the toast target), look for forms in the modal
168
+ if (!form && isGlobalModalOpen()) {
169
+ form = document.querySelector('#global-modal-content form[data-form-errors="inline"]');
170
+ }
171
+
172
+ const useInlineErrors = form?.getAttribute('data-form-errors') === 'inline';
173
+ const hasFieldErrors = responseData.errors && typeof responseData.errors === 'object' && Object.keys(responseData.errors).length > 0;
174
+
175
+ // Handle error responses
176
+ if (useInlineErrors && hasFieldErrors) {
177
+ // Show inline errors on the form - prevent the swap to toast
178
+ detail.shouldSwap = false;
179
+ clearFormErrors(form);
180
+ showFormErrors(form, responseData.errors, message);
181
+ } else if (useInlineErrors && !hasFieldErrors) {
182
+ // Invariant/form-level errors on inline-error forms: show in form, not toast
183
+ detail.shouldSwap = false;
184
+ clearFormErrors(form);
185
+ showFormErrors(form, {}, message);
186
+ } else {
187
+ // Forms without inline errors: show toast
188
+ // Allow the swap if targeting toast, otherwise show manually
189
+ if (detail.target && detail.target.id === 'global-toast-content') {
190
+ detail.shouldSwap = true; // Allow HTMX to swap the error response into toast
191
+ } else {
192
+ detail.shouldSwap = false;
193
+ GrasprToast?.show?.({ message, status: 'error' });
194
+ }
195
+ }
196
+ });
197
+
198
+ // ---------------------------
199
+ // Modal auto-open + close/refresh on success
200
+ // ---------------------------
201
+
202
+ // If an HTMX request swaps content into the modal container, auto-open it.
203
+ document.body.addEventListener('htmx:afterSwap', (e) => {
204
+ const target = e.detail?.target;
205
+ if (target && target.id === 'global-modal-content') {
206
+ openGlobalModal();
207
+ }
208
+ });
209
+
210
+ // If an HTMX request is triggered from inside the global modal and succeeds,
211
+ // automatically dismiss the modal.
212
+ //
213
+ // Example: POST /api/series from the "Create series" modal should close the modal on success.
214
+ document.body.addEventListener('htmx:afterRequest', (e) => {
215
+ // Clear the tracked submitting form
216
+ currentSubmittingForm = null;
217
+
218
+ const detail = e.detail || {};
219
+ const xhr = detail.xhr;
220
+ const status = typeof xhr?.status === 'number' ? xhr.status : null;
221
+ const ok = status !== null ? status >= 200 && status < 300 : !!detail.successful;
222
+ if (!ok) return;
223
+ if (!isGlobalModalOpen()) return;
224
+
225
+ const elt = detail.elt;
226
+ if (!(elt instanceof Element)) return;
227
+
228
+ // Only close if the request originated inside the modal (not just targeting it).
229
+ if (elt.closest('#global-modal')) {
230
+ // Check for success redirect - first from response meta, then from data attribute
231
+ const form = elt.closest('form');
232
+ let successRedirect = form?.getAttribute('data-success-redirect');
233
+
234
+ // Try to get redirect from response meta
235
+ try {
236
+ const responseData = JSON.parse(xhr?.responseText || '{}');
237
+ if (responseData.meta?.redirect) {
238
+ successRedirect = responseData.meta.redirect;
239
+ }
240
+ } catch {
241
+ // Ignore parse errors
242
+ }
243
+
244
+ if (successRedirect) {
245
+ // If already on a real page (not landing/login), reload in place —
246
+ // auth-state.js handles permission checks after the page loads.
247
+ const currentPath = window.location.pathname;
248
+ if (currentPath !== '/' && !currentPath.startsWith('/login') && !currentPath.startsWith('/signup')) {
249
+ window.location.reload();
250
+ } else {
251
+ window.location.href = successRedirect;
252
+ }
253
+ return;
254
+ }
255
+
256
+ closeGlobalModal();
257
+
258
+ // Clear any form errors on successful submit
259
+ if (form instanceof HTMLFormElement) {
260
+ clearFormErrors(form);
261
+ }
262
+
263
+ // Optional: refresh something after a successful modal submit.
264
+ // Two patterns supported:
265
+ //
266
+ // 1. data-refresh-target="#some-element" — fires HTMX "refresh" trigger
267
+ // on the element (used by list pages with hx-trigger="... refresh").
268
+ //
269
+ // 2. data-refresh-event="event-name" — dispatches a custom event on
270
+ // document.body (used by detail pages where multiple elements listen
271
+ // via hx-trigger="... event-name from:body").
272
+ //
273
+ // Both are read from the form first, then from the triggering element.
274
+
275
+ const refreshSelector = form?.getAttribute('data-refresh-target')
276
+ || elt.getAttribute('data-refresh-target')
277
+ || elt.getAttribute('data-refresh-click');
278
+ if (refreshSelector) {
279
+ const refreshEl = document.querySelector(refreshSelector);
280
+ if (refreshEl instanceof HTMLElement && typeof htmx?.trigger === 'function') {
281
+ htmx.trigger(refreshEl, 'refresh');
282
+ }
283
+ }
284
+
285
+ const refreshEvent = form?.getAttribute('data-refresh-event') || elt.getAttribute('data-refresh-event');
286
+ if (refreshEvent) {
287
+ document.body.dispatchEvent(new CustomEvent(refreshEvent, { bubbles: true }));
288
+ }
289
+ }
290
+ });
@@ -0,0 +1,115 @@
1
+ // ---------------------------
2
+ // Navigation utilities + active nav highlighting
3
+ // ---------------------------
4
+ // URL helpers, active nav link highlighting, and history event handlers.
5
+ // Self-registers afterSwap/afterSettle/pushedIntoHistory/popstate listeners.
6
+
7
+ export function normalizePath(p) {
8
+ if (!p) return '/';
9
+ if (p === '/') return '/';
10
+ return p.endsWith('/') ? p : `${p}/`;
11
+ }
12
+
13
+ export function setActiveNav(pathOverride) {
14
+ const current = normalizePath(pathOverride ?? window.location.pathname);
15
+
16
+ // Subnav link highlighting (exact match, or prefix match via data-nav-match)
17
+ document.querySelectorAll('a[data-nav]').forEach((a) => {
18
+ const href = normalizePath(a.getAttribute('href'));
19
+ const matchPrefix = a.getAttribute('data-nav-match');
20
+ const isActive = href === current || (matchPrefix && current.startsWith(matchPrefix));
21
+
22
+ a.classList.toggle('active-nav', isActive);
23
+ a.classList.toggle('underline', isActive);
24
+ });
25
+
26
+ // Section tab highlighting (prefix match)
27
+ document.querySelectorAll('a[data-nav-section]').forEach((a) => {
28
+ const prefix = a.getAttribute('data-nav-section');
29
+ const isActive = current.startsWith(prefix);
30
+
31
+ a.classList.toggle('active-nav', isActive);
32
+ a.classList.toggle('underline', isActive);
33
+ });
34
+
35
+ // Subnav toggling — show the matching section, hide others
36
+ document.querySelectorAll('[data-subnav]').forEach((el) => {
37
+ const prefix = el.getAttribute('data-subnav');
38
+ const show = current.startsWith(prefix);
39
+ el.style.display = show ? '' : 'none';
40
+ el.classList.remove('hidden');
41
+ });
42
+ }
43
+
44
+ export function setUrlParam(key, value) {
45
+ try {
46
+ const url = new URL(window.location.href);
47
+ if (value === null || value === undefined || value === '') {
48
+ url.searchParams.delete(key);
49
+ } else {
50
+ url.searchParams.set(key, String(value));
51
+ }
52
+ history.pushState({}, '', url.pathname + url.search + url.hash);
53
+ } catch {
54
+ // ignore
55
+ }
56
+ }
57
+
58
+ export function getUrlParam(key) {
59
+ try {
60
+ return new URL(window.location.href).searchParams.get(key);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export function mergeHxVals(el, vals) {
67
+ let existing = {};
68
+ try {
69
+ existing = JSON.parse(el.getAttribute('hx-vals') || '{}');
70
+ } catch { /* ignore */ }
71
+ el.setAttribute('hx-vals', JSON.stringify({ ...existing, ...vals }));
72
+ }
73
+
74
+ export function getUrlParamInt(key, fallback = 1) {
75
+ try {
76
+ const url = new URL(window.location.href);
77
+ const v = url.searchParams.get(key);
78
+ if (!v) return fallback;
79
+ const n = parseInt(v, 10);
80
+ return Number.isFinite(n) && n > 0 ? n : fallback;
81
+ } catch {
82
+ return fallback;
83
+ }
84
+ }
85
+
86
+ // ---------------------------
87
+ // Self-registered lifecycle handlers
88
+ // ---------------------------
89
+
90
+ document.addEventListener('DOMContentLoaded', () => {
91
+ setActiveNav();
92
+ });
93
+
94
+ document.body.addEventListener('htmx:afterSwap', (e) => {
95
+ // Always update nav (nav elements are outside #app, so they persist across swaps)
96
+ setActiveNav();
97
+ });
98
+
99
+ // Update nav highlighting after HTMX pushes a new URL to history.
100
+ // Use e.detail.path since window.location may not be updated yet.
101
+ document.body.addEventListener('htmx:pushedIntoHistory', (e) => {
102
+ const newPath = e.detail?.path;
103
+ setActiveNav(newPath);
104
+ });
105
+
106
+ // Also try afterSettle as a fallback (with microtask delay to ensure URL is updated)
107
+ document.body.addEventListener('htmx:afterSettle', (e) => {
108
+ queueMicrotask(() => {
109
+ setActiveNav();
110
+ });
111
+ });
112
+
113
+ window.addEventListener('popstate', () => {
114
+ setActiveNav();
115
+ });
@@ -0,0 +1,94 @@
1
+ // ---------------------------
2
+ // Pagination controls
3
+ // ---------------------------
4
+ // Wires up paginated HTMX sources, prev/next buttons, goto-page forms,
5
+ // and popstate re-sync. Self-registering — also exports initPagination()
6
+ // for use in DOMContentLoaded/afterSwap orchestration.
7
+
8
+ import htmx from '../lib/htmx.js';
9
+ import { setUrlParam, getUrlParamInt, mergeHxVals } from './navigation.js';
10
+
11
+ /**
12
+ * Wire paginated HTMX sources to read the current page from the URL.
13
+ * @param {Document|HTMLElement} root - The root to search within
14
+ */
15
+ export function initPagination(root = document) {
16
+ root.querySelectorAll('[data-pagination-param]').forEach((el) => {
17
+ if (!(el instanceof HTMLElement)) return;
18
+ const param = el.getAttribute('data-pagination-param') || 'page';
19
+ const page = getUrlParamInt(param, 1);
20
+ mergeHxVals(el, { [param]: page });
21
+ });
22
+ }
23
+
24
+ // Pagination controls: Prev/Next
25
+ // Buttons are rendered by the pagination component template and carry:
26
+ // - data-page="N"
27
+ // - data-pagination-source="#selector-for-hx-element"
28
+ // - data-pagination-param="page"
29
+ document.addEventListener('click', (e) => {
30
+ const btn = e.target.closest('[data-action="paginate-prev"], [data-action="paginate-next"]');
31
+ if (!(btn instanceof HTMLElement)) return;
32
+
33
+ const pageStr = btn.getAttribute('data-page') || '';
34
+ const page = parseInt(pageStr, 10);
35
+ if (!Number.isFinite(page) || page <= 0) return;
36
+
37
+ const sourceSel = btn.getAttribute('data-pagination-source') || '';
38
+ const param = btn.getAttribute('data-pagination-param') || 'page';
39
+ if (!sourceSel) return;
40
+
41
+ const sourceEl = document.querySelector(sourceSel);
42
+ if (!(sourceEl instanceof HTMLElement)) return;
43
+
44
+ mergeHxVals(sourceEl, { [param]: page });
45
+
46
+ // Update URL so pagination is shareable and back/forward works.
47
+ setUrlParam(param, page);
48
+
49
+ // Trigger the refresh request (and pagination will re-render from the response meta).
50
+ if (typeof htmx?.trigger === 'function') {
51
+ htmx.trigger(sourceEl, 'refresh');
52
+ }
53
+ });
54
+
55
+ // Pagination controls: Go to page
56
+ document.addEventListener('submit', (e) => {
57
+ const form = e.target.closest('[data-action="paginate-goto"]');
58
+ if (!form) return;
59
+ e.preventDefault();
60
+
61
+ const input = form.querySelector('input[type="number"]');
62
+ if (!input) return;
63
+
64
+ const page = parseInt(input.value, 10);
65
+ const lastPage = parseInt(form.getAttribute('data-last-page') || '1', 10);
66
+ if (!Number.isFinite(page) || page < 1 || page > lastPage) return;
67
+
68
+ const sourceSel = form.getAttribute('data-pagination-source') || '';
69
+ const param = form.getAttribute('data-pagination-param') || 'page';
70
+ if (!sourceSel) return;
71
+
72
+ const sourceEl = document.querySelector(sourceSel);
73
+ if (!(sourceEl instanceof HTMLElement)) return;
74
+
75
+ mergeHxVals(sourceEl, { [param]: page });
76
+ setUrlParam(param, page);
77
+
78
+ if (typeof htmx?.trigger === 'function') {
79
+ htmx.trigger(sourceEl, 'refresh');
80
+ }
81
+ });
82
+
83
+ // Re-sync paginated sources on back/forward navigation.
84
+ window.addEventListener('popstate', () => {
85
+ document.querySelectorAll('[data-pagination-param]').forEach((el) => {
86
+ if (!(el instanceof HTMLElement)) return;
87
+ const param = el.getAttribute('data-pagination-param') || 'page';
88
+ const page = getUrlParamInt(param, 1);
89
+ mergeHxVals(el, { [param]: page });
90
+ if (typeof htmx?.trigger === 'function') {
91
+ htmx.trigger(el, 'refresh');
92
+ }
93
+ });
94
+ });
@@ -0,0 +1,77 @@
1
+ // ---------------------------
2
+ // Search input wiring (delegated)
3
+ // ---------------------------
4
+ // Debounced search: updates hx-get URL on target element and triggers refresh.
5
+ // Usage: <input data-search-target="#some-htmx-element" data-search-endpoint="/api/foo" />
6
+ // Fully self-registering — import for side effects only.
7
+
8
+ import htmx from '../lib/htmx.js';
9
+ import { debounce, scrubSearchInput } from '../helpers/index.js';
10
+ import { setUrlParam, mergeHxVals } from './navigation.js';
11
+
12
+ const searchDebouncers = new WeakMap();
13
+
14
+ document.addEventListener('input', (e) => {
15
+ const input = e.target;
16
+ if (!(input instanceof HTMLInputElement)) return;
17
+ if (!input.dataset.searchTarget) return;
18
+
19
+ const targetSelector = input.dataset.searchTarget;
20
+ const baseEndpoint = input.dataset.searchEndpoint || '/api/series';
21
+
22
+ // Get or create debouncer for this input
23
+ let debouncedSearch = searchDebouncers.get(input);
24
+ if (!debouncedSearch) {
25
+ debouncedSearch = debounce((inp, sel, endpoint) => {
26
+ const target = document.querySelector(sel);
27
+ if (!(target instanceof HTMLElement)) return;
28
+
29
+ const rawValue = inp.value.trim();
30
+ const scrubbed = scrubSearchInput(rawValue);
31
+
32
+ // Detect UUID pattern
33
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
34
+ const isUuid = uuidPattern.test(scrubbed);
35
+
36
+ let url = endpoint;
37
+ if (scrubbed) {
38
+ const paramKey = isUuid ? 'id' : 'search';
39
+ const params = new URLSearchParams({ [paramKey]: scrubbed });
40
+ url = `${endpoint}?${params.toString()}`;
41
+ }
42
+
43
+ // Reset pagination to page 1 so search results aren't empty on page N
44
+ const pageParam = target.getAttribute('data-pagination-param') || 'page';
45
+ mergeHxVals(target, { [pageParam]: 1 });
46
+ setUrlParam(pageParam, null);
47
+
48
+ // Show loading state, then trigger refresh after min visible duration
49
+ target.classList.add('search-loading');
50
+ setTimeout(() => {
51
+ target.setAttribute('hx-get', url);
52
+ htmx.process(target);
53
+
54
+ if (typeof htmx?.trigger === 'function') {
55
+ htmx.trigger(target, 'refresh');
56
+ }
57
+ }, 100);
58
+ }, 350);
59
+ searchDebouncers.set(input, debouncedSearch);
60
+ }
61
+
62
+ debouncedSearch(input, targetSelector, baseEndpoint);
63
+ });
64
+
65
+ // Remove search loading state after swap completes
66
+ document.body.addEventListener('htmx:afterSwap', (e) => {
67
+ const target = e.detail?.target;
68
+ if (target instanceof HTMLElement) {
69
+ target.classList.remove('search-loading');
70
+ }
71
+ });
72
+
73
+ // Re-init after HTMX history cache restoration (back/forward navigation)
74
+ document.body.addEventListener('htmx:historyRestore', () => {
75
+ // No-op — search inputs are stateless (delegated input handler re-attaches automatically).
76
+ // Kept for documentation: if search inputs ever need re-initialization, add it here.
77
+ });
@@ -0,0 +1,116 @@
1
+ // ---------------------------
2
+ // Drag-and-drop sorting (delegated)
3
+ // ---------------------------
4
+ // Initializes SortableJS on elements with [data-sortable] after HTMX swaps.
5
+ // Sends PATCH with redistributed sort_order values on drag end.
6
+ // Fully self-registering — import for side effects only.
7
+
8
+ import Sortable from 'sortablejs';
9
+ import htmx from '../lib/htmx.js';
10
+ import { GrasprToast } from '../ui/toast.js';
11
+
12
+ const instances = new WeakMap();
13
+
14
+ function isSearchActive(container) {
15
+ const hxGet = container.getAttribute('hx-get') || '';
16
+ return hxGet.includes('search=');
17
+ }
18
+
19
+ function initSortable(container) {
20
+ // Tear down previous instance
21
+ const prev = instances.get(container);
22
+ if (prev) {
23
+ prev.destroy();
24
+ instances.delete(container);
25
+ }
26
+
27
+ // Disable when search is active
28
+ if (isSearchActive(container)) {
29
+ container.classList.add('sortable-disabled');
30
+ return;
31
+ }
32
+ container.classList.remove('sortable-disabled');
33
+
34
+ const endpoint = container.dataset.sortableEndpoint;
35
+ const key = container.dataset.sortableKey || 'locations';
36
+ if (!endpoint) return;
37
+
38
+ const sortable = new Sortable(container, {
39
+ handle: '.drag-handle',
40
+ animation: 150,
41
+ ghostClass: 'sortable-ghost',
42
+ chosenClass: 'sortable-chosen',
43
+ onEnd(evt) {
44
+ if (evt.oldIndex === evt.newIndex) return;
45
+
46
+ const rows = Array.from(container.querySelectorAll('tr[data-id]'));
47
+ if (rows.length === 0) return;
48
+
49
+ // Collect existing sort_order values and sort them numerically
50
+ const sortOrders = rows
51
+ .map((row) => parseInt(row.dataset.sortOrder, 10))
52
+ .filter((n) => !isNaN(n))
53
+ .sort((a, b) => a - b);
54
+
55
+ if (sortOrders.length !== rows.length) return;
56
+
57
+ // Redistribute: assign sorted values in new DOM order
58
+ const payload = rows.map((row, i) => {
59
+ const newOrder = sortOrders[i];
60
+ // Optimistic UI: update data attribute and visible display
61
+ row.dataset.sortOrder = String(newOrder);
62
+ const display = row.querySelector('[data-sort-order-display]');
63
+ if (display) display.textContent = String(newOrder);
64
+ return { id: row.dataset.id, sort_order: newOrder };
65
+ });
66
+
67
+ // Send to server
68
+ fetch(endpoint, {
69
+ method: 'PATCH',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ [key]: payload }),
72
+ })
73
+ .then((res) => {
74
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
75
+ return res.json();
76
+ })
77
+ .then(() => {
78
+ GrasprToast?.show({ message: 'Sort order updated.', status: 'success' });
79
+ })
80
+ .catch(() => {
81
+ GrasprToast?.show({ message: 'Failed to update sort order.', status: 'error' });
82
+ // Reload server state
83
+ if (typeof htmx?.trigger === 'function') {
84
+ htmx.trigger(container, 'refresh');
85
+ }
86
+ });
87
+ },
88
+ });
89
+
90
+ instances.set(container, sortable);
91
+ }
92
+
93
+ function initAllSortables(root) {
94
+ const containers = root.querySelectorAll
95
+ ? root.querySelectorAll('[data-sortable]')
96
+ : [];
97
+
98
+ // Also check if root itself is a sortable container
99
+ if (root instanceof HTMLElement && root.hasAttribute('data-sortable')) {
100
+ initSortable(root);
101
+ }
102
+
103
+ containers.forEach((el) => initSortable(el));
104
+ }
105
+
106
+ // Init on full page load
107
+ document.addEventListener('DOMContentLoaded', () => {
108
+ initAllSortables(document);
109
+ });
110
+
111
+ // Re-init after HTMX swaps (covers self-loading tbody, boosted nav, search clear)
112
+ document.body.addEventListener('htmx:afterSwap', (e) => {
113
+ const target = e.detail?.target;
114
+ if (!target) return;
115
+ initAllSortables(target);
116
+ });