PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / js / tableInteractions.js

tableInteractions.js in 404 Solution trunk, at includes/js/tableInteractions.js

424 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * 404 Solution - Modern Table Interactions
3 * Handles checkbox selection, bulk actions, modal, row actions, and filtering
4 *
5 * @since 3.0.3
6 */
7
8 (function($) {
9 'use strict';
10
11 // State
12 var selectedCount = 0;
13 var allCheckboxes = [];
14 var selectAllCheckbox = null;
15 var sourcePanelsInitialized = false;
16
17 $(document).ready(function() {
18 window.abj404InitTableInteractions();
19 initModal();
20 initFilterBar();
21 initRowActions();
22 initSourcePanels();
23 });
24
25 /**
26 * Initialize table checkbox and bulk action interactions
27 * Exposed globally for reinitialization after AJAX table refresh
28 */
29 window.abj404InitTableInteractions = function() {
30 var $table = $('.abj404-table');
31 if (!$table.length) return;
32
33 allCheckboxes = $table.find('tbody input[type="checkbox"]');
34 selectAllCheckbox = $table.find('thead input[type="checkbox"]');
35
36 // Select all checkbox
37 selectAllCheckbox.on('change', function() {
38 var isChecked = $(this).prop('checked');
39 allCheckboxes.prop('checked', isChecked);
40 updateSelectionCount();
41 });
42
43 // Individual checkboxes
44 allCheckboxes.on('change', function() {
45 updateSelectionCount();
46 updateSelectAllState();
47 });
48
49 // Initial state
50 updateSelectionCount();
51 }
52
53 /**
54 * Update the selection count and toggle bulk actions bar
55 */
56 function updateSelectionCount() {
57 selectedCount = allCheckboxes.filter(':checked').length;
58 var $bulkActions = $('.abj404-bulk-actions');
59 var $selectionInfo = $bulkActions.find('.abj404-selection-info strong');
60
61 // The count text is always updated, including when the selection
62 // drops to 0. Previously the 0 case skipped this update because the
63 // bulk-actions bar was hidden via CSS when empty; now the bar stays
64 // inline and dimmed, so a stale count would remain visible.
65 $selectionInfo.text(selectedCount);
66 $bulkActions.toggleClass('active', selectedCount > 0);
67
68 // Enable/disable the legacy apply buttons too
69 var $applyButtons = $('input[name="abj404action"]').closest('form').find('input[type="submit"]');
70 $applyButtons.prop('disabled', selectedCount === 0);
71 }
72
73 /**
74 * Update the "select all" checkbox state based on individual selections
75 */
76 function updateSelectAllState() {
77 if (!selectAllCheckbox.length) return;
78
79 var totalCount = allCheckboxes.length;
80 var checkedCount = allCheckboxes.filter(':checked').length;
81
82 if (checkedCount === 0) {
83 selectAllCheckbox.prop('checked', false);
84 selectAllCheckbox.prop('indeterminate', false);
85 } else if (checkedCount === totalCount) {
86 selectAllCheckbox.prop('checked', true);
87 selectAllCheckbox.prop('indeterminate', false);
88 } else {
89 selectAllCheckbox.prop('checked', false);
90 selectAllCheckbox.prop('indeterminate', true);
91 }
92 }
93
94 /**
95 * Clear all selections
96 */
97 window.abj404ClearSelection = function() {
98 allCheckboxes.prop('checked', false);
99 if (selectAllCheckbox.length) {
100 selectAllCheckbox.prop('checked', false);
101 selectAllCheckbox.prop('indeterminate', false);
102 }
103 updateSelectionCount();
104 };
105
106 /**
107 * Initialize modal functionality
108 */
109 function initModal() {
110 var $modal = $('.abj404-modal');
111 if (!$modal.length) return;
112
113 // Add ARIA attributes to modals
114 $modal.each(function() {
115 var $m = $(this);
116 $m.attr('role', 'dialog');
117 $m.attr('aria-modal', 'true');
118
119 // Find the modal title for aria-labelledby
120 var $title = $m.find('.abj404-modal-header h2');
121 if ($title.length) {
122 var titleId = $m.attr('id') + '-title';
123 $title.attr('id', titleId);
124 $m.attr('aria-labelledby', titleId);
125 }
126 });
127
128 // Open modal
129 $(document).on('click', '[data-modal-open]', function(e) {
130 e.preventDefault();
131 var $trigger = $(this);
132 var modalId = $trigger.data('modal-open');
133 var $targetModal = $('#' + modalId);
134
135 // Store the trigger element to return focus on close
136 $targetModal.data('trigger', $trigger);
137
138 $targetModal.addClass('active');
139 $('body').css('overflow', 'hidden');
140
141 // Focus the first focusable element in the modal
142 var $focusable = $targetModal.find('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])').filter(':visible');
143 if ($focusable.length) {
144 $focusable.first().focus();
145 }
146
147 // Trap focus within modal
148 trapFocus($targetModal);
149 });
150
151 // Close modal - close button
152 $modal.find('.abj404-modal-close').on('click', function() {
153 closeModal($(this).closest('.abj404-modal'));
154 });
155
156 // Close modal - clicking outside
157 $modal.on('click', function(e) {
158 if (e.target === this) {
159 closeModal($(this));
160 }
161 });
162
163 // Close modal - escape key
164 $(document).on('keydown', function(e) {
165 if (e.key === 'Escape') {
166 var $activeModal = $('.abj404-modal.active');
167 if ($activeModal.length) {
168 closeModal($activeModal);
169 }
170 }
171 });
172 }
173
174 /**
175 * Trap focus within modal for accessibility
176 */
177 function trapFocus($modal) {
178 $modal.on('keydown.trapFocus', function(e) {
179 if (e.key !== 'Tab') return;
180
181 var $focusable = $modal.find('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])').filter(':visible');
182 var $first = $focusable.first();
183 var $last = $focusable.last();
184
185 if (e.shiftKey) {
186 // Shift+Tab: if on first element, go to last
187 if (document.activeElement === $first[0]) {
188 e.preventDefault();
189 $last.focus();
190 }
191 } else {
192 // Tab: if on last element, go to first
193 if (document.activeElement === $last[0]) {
194 e.preventDefault();
195 $first.focus();
196 }
197 }
198 });
199 }
200
201 /**
202 * Close a modal
203 */
204 function closeModal($modal) {
205 $modal.removeClass('active');
206 $('body').css('overflow', '');
207
208 // Remove focus trap
209 $modal.off('keydown.trapFocus');
210
211 // Return focus to trigger element
212 var $trigger = $modal.data('trigger');
213 if ($trigger && $trigger.length) {
214 $trigger.focus();
215 }
216 }
217
218 /**
219 * Open the add redirect modal
220 */
221 window.abj404OpenAddRedirectModal = function() {
222 $('#abj404-add-redirect-modal').addClass('active');
223 $('body').css('overflow', 'hidden');
224 };
225
226 /**
227 * Close the add redirect modal
228 */
229 window.abj404CloseAddRedirectModal = function() {
230 $('#abj404-add-redirect-modal').removeClass('active');
231 $('body').css('overflow', '');
232 };
233
234 /**
235 * Initialize filter bar functionality
236 * Note: Server-side filtering is handled by view_updater.js on Enter key press.
237 * This function only handles non-search interactions.
238 */
239 function initFilterBar() {
240 // Server-side search filtering is handled by view_updater.js
241 // which binds to input[name=searchFilter] and triggers on Enter key.
242 // No client-side filtering here to avoid conflicts.
243 }
244
245 /**
246 * Initialize row action buttons
247 */
248 function initRowActions() {
249 // Handle AJAX trash action (existing functionality enhancement)
250 $(document).on('click', '.abj404-action-btn.ajax-trash', function(e) {
251 e.preventDefault();
252 var $btn = $(this);
253 var url = $btn.data('url');
254
255 if (!url) return;
256
257 // Confirm action
258 if (!confirm($btn.data('confirm') || 'Are you sure?')) {
259 return;
260 }
261
262 $btn.addClass('loading').prop('disabled', true);
263
264 // ajax-direct-approved: legacy row-action URL is rendered per button; response shape is validated before use below.
265 $.ajax({
266 url: url,
267 type: 'POST',
268 dataType: 'json',
269 // A request with no deadline never reaches the error handler that
270 // re-enables the control this call disabled, so the page stays stuck.
271 timeout: 30000,
272 success: function(response) {
273 // Validate shape before reading fields: a malformed
274 // body (gateway HTML, plugin-conflict mangled output)
275 // previously threw on response.success or response
276 // .data.message and left the button stuck in the
277 // disabled+loading state.
278 if (response && typeof response === 'object' && response.success === true) {
279 // Remove the row with animation
280 $btn.closest('tr').fadeOut(300, function() {
281 $(this).remove();
282 updateSelectionCount();
283 });
284
285 var successMsg = (response.data && response.data.message)
286 ? response.data.message : 'Action completed';
287 abj404ShowToast(successMsg, 'success');
288 return;
289 }
290 var errMsg = 'Action failed';
291 if (response && typeof response === 'object' && response.data) {
292 if (typeof response.data === 'string') {
293 errMsg = response.data;
294 } else if (response.data.message) {
295 errMsg = response.data.message;
296 }
297 }
298 abj404ShowToast(errMsg, 'error');
299 $btn.removeClass('loading').prop('disabled', false);
300 },
301 error: function() {
302 abj404ShowToast('An error occurred', 'error');
303 $btn.removeClass('loading').prop('disabled', false);
304 }
305 });
306 });
307 }
308
309 /**
310 * Initialize captured-404 source evidence panels.
311 *
312 * The handlers are delegated because the list table body can be replaced by
313 * AJAX pagination without reloading this script.
314 */
315 function initSourcePanels() {
316 if (sourcePanelsInitialized) return;
317 sourcePanelsInitialized = true;
318
319 $(document).on('click', '[data-abj404-source-toggle]', function(e) {
320 e.preventDefault();
321 toggleSourcePanel(this);
322 });
323
324 $(document).on('keydown', function(e) {
325 if (e.key !== 'Escape') return;
326 var openTrigger = document.querySelector('[data-abj404-source-toggle][aria-expanded="true"]');
327 if (!openTrigger) return;
328 closeSourcePanel(openTrigger, true);
329 });
330 }
331
332 function toggleSourcePanel(trigger) {
333 var expanded = trigger.getAttribute('aria-expanded') === 'true';
334 if (expanded) {
335 closeSourcePanel(trigger, false);
336 return;
337 }
338
339 closeAllSourcePanels();
340 openSourcePanel(trigger);
341 }
342
343 function openSourcePanel(trigger) {
344 var panel = sourcePanelForTrigger(trigger);
345 if (!panel) return;
346
347 trigger.setAttribute('aria-expanded', 'true');
348 panel.removeAttribute('hidden');
349 }
350
351 function closeSourcePanel(trigger, restoreFocus) {
352 var panel = sourcePanelForTrigger(trigger);
353 trigger.setAttribute('aria-expanded', 'false');
354 if (panel) {
355 panel.setAttribute('hidden', 'hidden');
356 }
357 if (restoreFocus && typeof trigger.focus === 'function') {
358 trigger.focus();
359 }
360 }
361
362 function closeAllSourcePanels() {
363 var triggers = document.querySelectorAll('[data-abj404-source-toggle][aria-expanded="true"]');
364 Array.prototype.forEach.call(triggers, function(trigger) {
365 closeSourcePanel(trigger, false);
366 });
367 }
368
369 function sourcePanelForTrigger(trigger) {
370 var panelId = trigger.getAttribute('aria-controls');
371 if (!panelId) return null;
372 return document.getElementById(panelId);
373 }
374
375 /**
376 * Show toast notification
377 */
378 window.abj404ShowToast = function(message, type) {
379 var $toast = $('#abj404-toast');
380
381 // Create toast if it doesn't exist
382 if (!$toast.length) {
383 $toast = $('<div id="abj404-toast" class="abj404-toast"><span class="abj404-toast-message"></span></div>');
384 $('body').append($toast);
385 }
386
387 var $message = $toast.find('.abj404-toast-message');
388 $message.text(message);
389
390 $toast.removeClass('error success').addClass(type || 'success').addClass('show');
391
392 // Auto-hide after 3 seconds
393 setTimeout(function() {
394 $toast.removeClass('show');
395 }, 3000);
396 };
397
398 /**
399 * Utility: Get URL parameter
400 */
401 function getUrlParam(param) {
402 var urlParams = new URLSearchParams(window.location.search);
403 return urlParams.get(param);
404 }
405
406 /**
407 * Toggle regex explanation info
408 */
409 window.abj404ToggleRegexInfo = function(event) {
410 event.preventDefault();
411 var $info = $('.abj404-regex-info');
412 var $toggle = $('.abj404-regex-toggle');
413
414 if ($info.is(':visible')) {
415 $info.slideUp(200);
416 $toggle.text('(Explain)');
417 } else {
418 $info.slideDown(200);
419 $toggle.text('(Hide Info)');
420 }
421 };
422
423 })(jQuery);
424