PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 4.1.19, at includes/js/tableInteractions.js

432 lines 13.7 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 timeAgoIntervalHandle = null;
16
17 $(document).ready(function() {
18 window.abj404InitTableInteractions();
19 initModal();
20 initFilterBar();
21 initRowActions();
22 initTimeAgo();
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 if (selectedCount > 0) {
62 $bulkActions.addClass('active');
63 $selectionInfo.text(selectedCount);
64 } else {
65 $bulkActions.removeClass('active');
66 }
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({
265 url: url,
266 type: 'POST',
267 dataType: 'json',
268 success: function(response) {
269 // Validate shape before reading fields: a malformed
270 // body (gateway HTML, plugin-conflict mangled output)
271 // previously threw on response.success or response
272 // .data.message and left the button stuck in the
273 // disabled+loading state.
274 if (response && typeof response === 'object' && response.success === true) {
275 // Remove the row with animation
276 $btn.closest('tr').fadeOut(300, function() {
277 $(this).remove();
278 updateSelectionCount();
279 });
280
281 var successMsg = (response.data && response.data.message)
282 ? response.data.message : 'Action completed';
283 abj404ShowToast(successMsg, 'success');
284 return;
285 }
286 var errMsg = 'Action failed';
287 if (response && typeof response === 'object' && response.data) {
288 if (typeof response.data === 'string') {
289 errMsg = response.data;
290 } else if (response.data.message) {
291 errMsg = response.data.message;
292 }
293 }
294 abj404ShowToast(errMsg, 'error');
295 $btn.removeClass('loading').prop('disabled', false);
296 },
297 error: function() {
298 abj404ShowToast('An error occurred', 'error');
299 $btn.removeClass('loading').prop('disabled', false);
300 }
301 });
302 });
303 }
304
305 /**
306 * Show toast notification
307 */
308 window.abj404ShowToast = function(message, type) {
309 var $toast = $('#abj404-toast');
310
311 // Create toast if it doesn't exist
312 if (!$toast.length) {
313 $toast = $('<div id="abj404-toast" class="abj404-toast"><span class="abj404-toast-message"></span></div>');
314 $('body').append($toast);
315 }
316
317 var $message = $toast.find('.abj404-toast-message');
318 $message.text(message);
319
320 $toast.removeClass('error success').addClass(type || 'success').addClass('show');
321
322 // Auto-hide after 3 seconds
323 setTimeout(function() {
324 $toast.removeClass('show');
325 }, 3000);
326 };
327
328 /**
329 * Utility: Get URL parameter
330 */
331 function getUrlParam(param) {
332 var urlParams = new URLSearchParams(window.location.search);
333 return urlParams.get(param);
334 }
335
336 /**
337 * Toggle regex explanation info
338 */
339 window.abj404ToggleRegexInfo = function(event) {
340 event.preventDefault();
341 var $info = $('.abj404-regex-info');
342 var $toggle = $('.abj404-regex-toggle');
343
344 if ($info.is(':visible')) {
345 $info.slideUp(200);
346 $toggle.text('(Explain)');
347 } else {
348 $info.slideDown(200);
349 $toggle.text('(Hide Info)');
350 }
351 };
352
353 /**
354 * Initialize dynamic time-ago updates
355 * Updates elements with class 'abj404-time-ago' and data-timestamp attribute
356 */
357 function initTimeAgo() {
358 var $timeElements = $('.abj404-time-ago[data-timestamp]');
359 if (!$timeElements.length) return;
360
361 // Update immediately and then every N seconds
362 updateTimeAgo($timeElements);
363 if (timeAgoIntervalHandle !== null) {
364 return;
365 }
366 timeAgoIntervalHandle = setInterval(function() {
367 updateTimeAgo($('.abj404-time-ago[data-timestamp]'));
368 }, 10000);
369 }
370
371 // Expose re-init hook for AJAX table replacements.
372 window.abj404InitTimeAgo = initTimeAgo;
373
374 /**
375 * Update time-ago text for all matching elements
376 */
377 function updateTimeAgo($elements) {
378 var now = Math.floor(Date.now() / 1000);
379
380 $elements.each(function() {
381 var $el = $(this);
382 var timestamp = parseInt($el.attr('data-timestamp'), 10);
383 if (!timestamp || isNaN(timestamp)) return;
384
385 var diff = now - timestamp;
386 // Handle negative diff (future timestamp due to clock skew)
387 if (diff < 0) diff = 0;
388
389 var text = formatTimeAgo(diff);
390 $el.text(text);
391 });
392 }
393
394 /**
395 * Format seconds difference as human-readable time ago
396 * Uses localized strings from abj404_time_ago if available
397 */
398 function formatTimeAgo(seconds) {
399 // Get localized strings or use English defaults
400 var strings = window.abj404_time_ago || {
401 second: 'second',
402 seconds: 'seconds',
403 minute: 'minute',
404 minutes: 'minutes',
405 hour: 'hour',
406 hours: 'hours',
407 day: 'day',
408 days: 'days',
409 ago: 'ago'
410 };
411
412 var value, unit;
413
414 if (seconds < 60) {
415 value = seconds;
416 unit = (value === 1) ? strings.second : strings.seconds;
417 } else if (seconds < 3600) {
418 value = Math.floor(seconds / 60);
419 unit = (value === 1) ? strings.minute : strings.minutes;
420 } else if (seconds < 86400) {
421 value = Math.floor(seconds / 3600);
422 unit = (value === 1) ? strings.hour : strings.hours;
423 } else {
424 value = Math.floor(seconds / 86400);
425 unit = (value === 1) ? strings.day : strings.days;
426 }
427
428 return value + ' ' + unit + ' ' + strings.ago;
429 }
430
431 })(jQuery);
432